@openchambery/web 1.19.22-beta.1 → 2.0.0-beta.3
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/README.md +13 -7
- package/bin/cli-opencode.test.js +208 -0
- package/bin/cli.js +62 -9
- package/bin/lib/cli-startup.js +105 -2
- package/bin/lib/cli-startup.test.js +179 -0
- package/dist/assets/{AgentAvatar-CRaBJXKs.js → AgentAvatar-B_JQoreg.js} +1 -1
- package/dist/assets/AssistantView-ClQAfGOT.js +3 -0
- package/dist/assets/{DiagramView-D1LGK_s2.js → DiagramView-BNnda8qO.js} +1 -1
- package/dist/assets/{FadeInOnReveal-B0kJo9Ii.js → FadeInOnReveal-VYQTSAkr.js} +1 -1
- package/dist/assets/{Icon-BgdfPhNs.js → Icon-BiEQEcih.js} +1 -1
- package/dist/assets/MarkdownRenderer-CJHSsWin.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-DfoCJ1Mq.js → MarkdownRendererImpl-DwrHFGUp.js} +3 -3
- package/dist/assets/{MarkstreamRendererImpl-D2dsW5Lg.js → MarkstreamRendererImpl-CSnbQMu3.js} +1 -1
- package/dist/assets/MobileDetailNavigation-BXr6AGLT.js +1 -0
- package/dist/assets/{MobileShareBridge-2Yjn6wXg.js → MobileShareBridge-D7OE9OP_.js} +2 -2
- package/dist/assets/MobileSurface-D2jUkX9l.js +2 -0
- package/dist/assets/{MultiRunWindow-tTbMFowS.js → MultiRunWindow-DF3iIFCd.js} +1 -1
- package/dist/assets/{OnboardingScreen-CD2KtlF3.js → OnboardingScreen-BjHmlwlw.js} +1 -1
- package/dist/assets/OverlayScrollbar-DLtWO_Gp.js +2 -0
- package/dist/assets/SettingsView-BRxELjow.js +25 -0
- package/dist/assets/{SettingsWindow-zIa76s4t.js → SettingsWindow-BI5swspb.js} +1 -1
- package/dist/assets/{TerminalView-CDdzF4yi.js → TerminalView-BU3x8-fr.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DNqA4uBl.js → ToolOutputDialog-BEWhBcRd.js} +2 -2
- package/dist/assets/appThemeRegistry-4MR0F7Jz.js +1 -0
- package/dist/assets/{button-CI7Q-Zw_.js → button-DKuz3L8l.js} +1 -1
- package/dist/assets/collapsible-DXbDynN4.js +3 -0
- package/dist/assets/context-menu-CqolsLCm.js +1 -0
- package/dist/assets/{desktopHosts-B1ixEylr.js → desktopHosts-CoDkOnyS.js} +1 -1
- package/dist/assets/{desktopRelayRestore-4JmFUw8r.js → desktopRelayRestore-DDlVKyOE.js} +1 -1
- package/dist/assets/{diagram-DdLO8HZ9.js → diagram-D7T8HQgj.js} +1 -1
- package/dist/assets/{dialog-B6pnIYmp.js → dialog-DQYl3W_o.js} +1 -1
- package/dist/assets/es-CN3E_zXj.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-DNeiqns-.js → fileMentionTouchSelection-BZ6ovBYH.js} +1 -1
- package/dist/assets/{fileQueries-DQc8EJUf.js → fileQueries-JrpcsOCs.js} +1 -1
- package/dist/assets/fr-9zZvQZD2.js +13 -0
- package/dist/assets/gitApi-CM42qUed.js +10 -0
- package/dist/assets/{imageSource-BlAJJUEI.js → imageSource-CIl0FL10.js} +1 -1
- package/dist/assets/{input-D-__fwRi.js → input-CzQy8UhV.js} +1 -1
- package/dist/assets/insertionBoundaries-CtkZ9720.js +1 -0
- package/dist/assets/ja-Dnjk_82N.js +15 -0
- package/dist/assets/ko-BxCEE99C.js +15 -0
- package/dist/assets/main-BILgBI3B.js +169 -0
- package/dist/assets/main-BeveiW6F.js +2 -0
- package/dist/assets/markdown-worker-bS5OgGO3.js +2 -0
- package/dist/assets/{markdownHeightCache-BhxqJOvf.js → markdownHeightCache-Cj1I5NDw.js} +1 -1
- package/dist/assets/{markdownTheme-DOfGg-A7.js → markdownTheme-CKac3wKC.js} +1 -1
- package/dist/assets/mcpQueries-D5mQymgM.js +1 -0
- package/dist/assets/miniChat-BgrzM3u7.js +2 -0
- package/dist/assets/mobile-DtYoQE8C.js +2 -0
- package/dist/assets/multirun-jy8dEI96.js +4 -0
- package/dist/assets/{persistence-Ce3AnpEr.js → persistence-C9LQpiOS.js} +2 -2
- package/dist/assets/pl-CNNsWnPM.js +15 -0
- package/dist/assets/platform-BQghHRfp.js +6 -0
- package/dist/assets/pt-BR-BZB2DJMp.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-BGrrmeME.js +2 -0
- package/dist/assets/renderMobileApp-u3c4GtFC.js +28 -0
- package/dist/assets/{runtimeConfig-CMkwoQ_X.js → runtimeConfig-BIGBRGQI.js} +1 -1
- package/dist/assets/{runtimeEndpointReset-ChOxt-J7.js → runtimeEndpointReset-D2savkuw.js} +12 -12
- package/dist/assets/{sessionChangeSummary-NnxcqCTI.js → sessionChangeSummary-eIbpSfrO.js} +1 -1
- package/dist/assets/{sessionLookup-BrU3rFHJ.js → sessionLookup-myHe5ltu.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-Fnfhf3K9.js → sortable-tabs-strip-BQtmHx9r.js} +1 -1
- package/dist/assets/src-CLN7Z-zx.css +2 -0
- package/dist/assets/store-DLCiZaA6.js +15 -0
- package/dist/assets/{terminalApi-xjqssKke.js → terminalApi-BxcdiduX.js} +1 -1
- package/dist/assets/{textarea-bHYxZtfH.js → textarea-CakKvqFw.js} +1 -1
- package/dist/assets/{tooltip-0T5jv0cu.js → tooltip-Cx5gTbdY.js} +1 -1
- package/dist/assets/uk-rwQrxeYM.js +15 -0
- package/dist/assets/{url-_z7evzdK.js → url-CDHnjlsm.js} +1 -1
- package/dist/assets/{useAppFontEffects-C02YFyAv.js → useAppFontEffects-CNeo0aZs.js} +59 -45
- package/dist/assets/{useAssistantUIStore-CMutVh9B.js → useAssistantUIStore-asNG6K7n.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-DBJ56qIo.js +1 -0
- package/dist/assets/useEffectiveDirectory-B4LvR8Gt.js +1 -0
- package/dist/assets/{useFontPreferences-B1JYyzpK.js → useFontPreferences-CUnQ4TVm.js} +1 -1
- package/dist/assets/useMobileNavigationStore-ZHJPxlHt.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DSLXJybW.js → useRuntimeAPIs-DwLtO0c_.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-BIVmrnYU.js +2 -0
- package/dist/assets/{useThemeSystem-DMzWVJaW.js → useThemeSystem-BlNUWH47.js} +1 -1
- package/dist/assets/useUIStore-CQYKJnrg.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-C9z0ML1z.js → useWorkerHighlightedLines-CJZbJDW3.js} +2 -2
- package/dist/assets/{vendor-base-ui-CnHfUmDN.js → vendor-base-ui-CDjyofeD.js} +1 -1
- package/dist/assets/{vendor-cmdk-CeAGfrLo.js → vendor-cmdk-BFH0UpXX.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BY5LhhUB.js → vendor-dnd-kit-accessibility-BrR3AKvM.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CvWxUPdb.js → vendor-dnd-kit-core-CNfdvk6H.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-hHdP9lLb.js → vendor-dnd-kit-sortable-DQUaTeGj.js} +1 -1
- package/dist/assets/{vendor-framer-motion-DFy_BSVd.js → vendor-framer-motion-4dogsUk3.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-WE32lwNk.js → vendor-legendapp-list-BU7MdPZR.js} +1 -1
- package/dist/assets/{vendor-markstream-react-DX55XLGO.js → vendor-markstream-react-qHC2FykU.js} +3 -3
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +12 -0
- package/dist/assets/{vendor-pierre-diffs-DX7XTPG3.js → vendor-pierre-diffs-CUfqC003.js} +1 -1
- package/dist/assets/{vendor-react-DLPElpoQ.js → vendor-react-C94A0AHl.js} +1 -1
- package/dist/assets/{vendor-react-drawio-ECtEwvbc.js → vendor-react-drawio-FVlWqKVE.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-BnW6GxJt.js → vendor-reactuses-core-C2A3EjUi.js} +1 -1
- package/dist/assets/{vendor-sonner-DVzioF3d.js → vendor-sonner-DNqHmUQs.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-DWy3xSmp.js → vendor-stream-diffs-CwbKsmlv.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D7pys_c9.js → vendor-tanstack-react-query-D0iFK57C.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-tlDgH6PG.js → vendor-tanstack-react-virtual-B2kV6cnT.js} +1 -1
- package/dist/assets/{vendor-yaml-DIcAK3EC.js → vendor-yaml-C9KlJAlj.js} +37 -37
- package/dist/assets/{vendor-zustand-B4dlGEM2.js → vendor-zustand-BqLKf6ZY.js} +1 -1
- package/dist/assets/zh-CN-BvmBDnkD.js +15 -0
- package/dist/assets/zh-TW-9wcHTX0y.js +15 -0
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +2 -2
- package/server/index.js +392 -20
- package/server/lib/assistants/DOCUMENTATION.md +26 -21
- package/server/lib/assistants/assign.js +2 -1
- package/server/lib/assistants/contact-store.js +37 -19
- package/server/lib/assistants/contracts.js +11 -1
- package/server/lib/assistants/projection-history.test.js +454 -0
- package/server/lib/assistants/routes.js +11 -5
- package/server/lib/assistants/service.js +850 -294
- package/server/lib/assistants/service.test.js +588 -274
- package/server/lib/conversations/DOCUMENTATION.md +5 -5
- package/server/lib/conversations/service.js +87 -57
- package/server/lib/conversations/service.test.js +132 -100
- package/server/lib/conversations/validation.js +11 -1
- package/server/lib/event-stream/directory-ws-bridge.js +13 -2
- package/server/lib/event-stream/global-hub.js +51 -5
- package/server/lib/event-stream/runtime.js +57 -20
- package/server/lib/event-stream/runtime.test.js +112 -0
- package/server/lib/llm/DOCUMENTATION.md +95 -84
- package/server/lib/llm/catalog.js +111 -45
- package/server/lib/llm/catalog.test.js +121 -59
- package/server/lib/llm/completions.js +41 -12
- package/server/lib/llm/completions.test.js +45 -4
- package/server/lib/llm/generate.js +516 -453
- package/server/lib/llm/generate.test.js +705 -484
- package/server/lib/llm/routes.js +8 -4
- package/server/lib/message-queue/DOCUMENTATION.md +5 -5
- package/server/lib/message-queue/opencode-adapter.js +191 -104
- package/server/lib/message-queue/opencode-adapter.test.js +328 -58
- package/server/lib/message-queue/runtime.js +15 -2
- package/server/lib/message-queue/runtime.test.js +6 -6
- package/server/lib/message-queue/worker.js +7 -1
- package/server/lib/notifications/runtime.js +5 -0
- package/server/lib/opencode/DOCUMENTATION.md +103 -16
- package/server/lib/opencode/agent-document.d.ts +28 -0
- package/server/lib/opencode/agent-document.js +280 -0
- package/server/lib/opencode/agent-document.test.js +137 -0
- package/server/lib/opencode/agents.js +96 -0
- package/server/lib/opencode/auth-state-runtime.js +5 -0
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/config-entity-routes.js +111 -102
- package/server/lib/opencode/config-entity-routes.test.js +123 -10
- package/server/lib/opencode/config-hot-reload.integration.test.js +105 -0
- package/server/lib/opencode/ensure-cli.js +238 -0
- package/server/lib/opencode/ensure-cli.test.js +119 -0
- package/server/lib/opencode/env-runtime.js +208 -111
- package/server/lib/opencode/env-runtime.test.js +211 -14
- package/server/lib/opencode/feature-routes-runtime.js +80 -5
- package/server/lib/opencode/feature-routes-runtime.test.js +18 -0
- package/server/lib/opencode/http-server-connections.js +98 -0
- package/server/lib/opencode/http-server-connections.test.js +321 -0
- package/server/lib/opencode/lifecycle.js +539 -95
- package/server/lib/opencode/lifecycle.test.js +698 -6
- package/server/lib/opencode/managed-capabilities-runtime.js +15 -2
- package/server/lib/opencode/managed-capabilities-runtime.test.js +1 -0
- package/server/lib/opencode/network-runtime.js +28 -16
- package/server/lib/opencode/network-runtime.test.js +68 -4
- package/server/lib/opencode/openchamber-routes.js +7 -1
- package/server/lib/opencode/opencode2-pin.js +139 -0
- package/server/lib/opencode/opencode2-pin.test.js +88 -0
- package/server/lib/opencode/opencode2-sidecar.e2e.test.js +326 -0
- package/server/lib/opencode/owned-runtime-upgrade.js +431 -0
- package/server/lib/opencode/owned-runtime-upgrade.test.js +160 -0
- package/server/lib/opencode/plugin-compatibility.js +15 -0
- package/server/lib/opencode/plugin-compatibility.test.js +21 -0
- package/server/lib/opencode/plugin-routes.js +3 -18
- package/server/lib/opencode/plugin-routes.test.js +9 -9
- package/server/lib/opencode/plugins.js +4 -3
- package/server/lib/opencode/plugins.test.js +12 -0
- package/server/lib/opencode/provider-catalog.js +58 -12
- package/server/lib/opencode/provider-catalog.test.js +48 -0
- package/server/lib/opencode/proxy.js +368 -28
- package/server/lib/opencode/proxy.test.js +338 -0
- package/server/lib/opencode/routes.js +303 -76
- package/server/lib/opencode/routes.test.js +315 -50
- package/server/lib/opencode/runtime-contract.js +401 -0
- package/server/lib/opencode/runtime-contract.test.js +214 -0
- package/server/lib/opencode/server-opencode-fetch.js +242 -0
- package/server/lib/opencode/server-opencode-fetch.test.js +130 -0
- package/server/lib/opencode/server-utils-runtime.js +35 -0
- package/server/lib/opencode/shared-service.integration.test.js +66 -0
- package/server/lib/opencode/shared-service.js +147 -0
- package/server/lib/opencode/shared-service.test.js +93 -0
- package/server/lib/opencode/shared.js +3 -3
- package/server/lib/opencode/shutdown-runtime.js +15 -6
- package/server/lib/opencode/shutdown-runtime.test.js +49 -1
- package/server/lib/opencode/skill-routes.js +26 -40
- package/server/lib/opencode/skill-routes.test.js +71 -7
- package/server/lib/opencode/skills.test.js +15 -0
- package/server/lib/opencode/startup-pipeline-runtime.js +4 -0
- package/server/lib/opencode/static-routes-runtime.js +3 -1
- package/server/lib/opencode/static-routes-runtime.test.js +33 -0
- package/server/lib/opencode/v1-migration-gate.js +157 -0
- package/server/lib/opencode/v1-migration-gate.test.js +111 -0
- package/server/lib/opencode/v2-client.js +60 -0
- package/server/lib/opencode/v2-plugin-host-shim/index.mjs +155 -0
- package/server/lib/opencode/v2-plugin-host-shim/package.json +4 -0
- package/server/lib/opencode/watcher.js +2 -1
- package/server/lib/opencode/watcher.test.js +1 -1
- package/server/lib/permission-auto-accept/DOCUMENTATION.md +5 -5
- package/server/lib/permission-auto-accept/runtime.js +14 -7
- package/server/lib/permission-auto-accept/runtime.test.js +27 -12
- package/server/lib/relay/reasoning-projection.e2e.test.ts +22 -7
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +79 -39
- package/server/lib/scheduled-tasks/managed-tool-route.js +31 -1
- package/server/lib/scheduled-tasks/managed-tool-route.test.js +23 -0
- package/server/lib/scheduled-tasks/routes.js +27 -0
- package/server/lib/scheduled-tasks/routes.test.js +94 -0
- package/server/lib/scheduled-tasks/runtime.js +381 -310
- package/server/lib/scheduled-tasks/runtime.test.js +600 -309
- package/server/lib/session-goal/DOCUMENTATION.md +173 -22
- package/server/lib/session-goal/capability.js +57 -0
- package/server/lib/session-goal/capability.test.js +33 -0
- package/server/lib/session-goal/routes.js +240 -2
- package/server/lib/session-goal/routes.test.js +211 -0
- package/server/lib/session-goal/runtime.js +1617 -113
- package/server/lib/session-goal/runtime.test.js +1500 -70
- package/server/lib/session-index/DOCUMENTATION.md +18 -15
- package/server/lib/session-index/event-ingest.js +89 -16
- package/server/lib/session-index/event-ingest.test.js +73 -0
- package/server/lib/session-index/host-authority-snapshot.js +92 -0
- package/server/lib/session-index/routes.host-archive.test.js +201 -0
- package/server/lib/session-index/routes.js +63 -10
- package/server/lib/session-index/routes.test.js +8 -8
- package/server/lib/session-index/service.js +7 -1
- package/server/lib/session-index/sync-runtime.js +171 -41
- package/server/lib/session-index/sync-runtime.test.js +164 -9
- package/server/lib/session-metadata/DOCUMENTATION.md +86 -0
- package/server/lib/session-metadata/routes.js +193 -0
- package/server/lib/session-metadata/routes.test.js +156 -0
- package/server/lib/session-metadata/session-archive.d.ts +73 -0
- package/server/lib/session-metadata/session-archive.js +654 -0
- package/server/lib/session-metadata/session-archive.test.js +359 -0
- package/server/lib/session-metadata/session-metadata-store.d.ts +36 -0
- package/server/lib/session-metadata/session-metadata-store.js +375 -0
- package/server/lib/session-metadata/session-metadata-store.test.js +379 -0
- package/server/lib/session-metadata/session-projection.d.ts +41 -0
- package/server/lib/session-metadata/session-projection.js +255 -0
- package/server/lib/session-metadata/session-projection.test.js +64 -0
- package/server/lib/session-metadata/system-session.js +55 -0
- package/server/lib/session-metadata/system-session.test.js +55 -0
- package/server/lib/session-title/DOCUMENTATION.md +40 -7
- package/server/lib/session-title/native-runtime.test.js +167 -0
- package/server/lib/session-title/runtime.js +67 -42
- package/server/lib/session-title/session-access.js +46 -0
- package/server/lib/session-title/session-access.test.js +91 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +12 -11
- package/server/lib/session-turn-pages/reconcile.service.js +1 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +16 -0
- package/server/lib/session-turn-pages/routes.js +123 -75
- package/server/lib/session-turn-pages/routes.test.js +566 -0
- package/server/lib/session-turn-pages/service.js +6 -2
- package/server/lib/session-turn-pages/service.test.js +34 -0
- package/server/lib/session-turn-pages/session-message-projection.d.ts +13 -0
- package/server/lib/session-turn-pages/session-message-projection.js +281 -0
- package/server/lib/small-model/DOCUMENTATION.md +101 -146
- package/server/lib/small-model/catalog.js +84 -54
- package/server/lib/small-model/catalog.test.js +111 -9
- package/server/lib/small-model/custom-api.js +68 -0
- package/server/lib/small-model/custom-api.test.js +64 -0
- package/server/lib/small-model/index.js +125 -241
- package/server/lib/small-model/resolve.js +35 -120
- package/server/lib/small-model/resolve.test.js +20 -110
- package/server/lib/small-model/routes.js +1 -3
- package/server/lib/small-model/summary-settings.test.js +157 -253
- package/server/opencode-proxy.test.js +63 -12
- package/dist/assets/AssistantView-BUveAgc4.js +0 -3
- package/dist/assets/MarkdownRenderer-BbrbDH6-.js +0 -2
- package/dist/assets/MobileDetailNavigation-B0aqKXd7.js +0 -1
- package/dist/assets/MobileSurface-DW7WfPsy.js +0 -2
- package/dist/assets/OverlayScrollbar-DrA0Vsg5.js +0 -2
- package/dist/assets/SettingsView-PkXIavr7.js +0 -25
- package/dist/assets/appThemeRegistry-D5Yxg4YF.js +0 -1
- package/dist/assets/collapsible-D5Phaykh.js +0 -1
- package/dist/assets/context-menu-CubSDW2g.js +0 -1
- package/dist/assets/es-DA3MqOo0.js +0 -15
- package/dist/assets/fr-Dbz0CdIA.js +0 -13
- package/dist/assets/gitApi-DXyC4MiV.js +0 -7
- package/dist/assets/insertionBoundaries-KlOVrHHt.js +0 -1
- package/dist/assets/ja-DumanVQA.js +0 -15
- package/dist/assets/ko-C0IkiUjQ.js +0 -15
- package/dist/assets/main-BPS5ylib.js +0 -2
- package/dist/assets/main-Cpd9VWYs.js +0 -169
- package/dist/assets/markdown-worker-CptoICmq.js +0 -2
- package/dist/assets/miniChat-B8_KLWiY.js +0 -2
- package/dist/assets/mobile-CtktKrXn.js +0 -2
- package/dist/assets/multirun-CYfP9Cfm.js +0 -4
- package/dist/assets/pl-B9No6MQp.js +0 -15
- package/dist/assets/platform-0w_Q9_ji.js +0 -2
- package/dist/assets/projectMeta-S1hJ7oBp.js +0 -3
- package/dist/assets/pt-BR-DyQ0WTDh.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-iK14LWm8.js +0 -2
- package/dist/assets/renderMobileApp-DORLPWnb.js +0 -28
- package/dist/assets/src-rS3zCcsC.css +0 -2
- package/dist/assets/store-5B_LDnLM.js +0 -15
- package/dist/assets/uk-C_1KkqyP.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-Dnr-jX0C.js +0 -1
- package/dist/assets/useEffectiveDirectory-CZLLyarc.js +0 -1
- package/dist/assets/useMobileNavigationStore-CoCiqNgm.js +0 -413
- package/dist/assets/useSessionAutoCleanup-xQyETQkG.js +0 -2
- package/dist/assets/useUIStore-Bxv3f23A.js +0 -1
- package/dist/assets/vendor-opencode-sdk-C07HQypa.js +0 -7
- package/dist/assets/zh-CN-uWILgBdt.js +0 -15
- package/dist/assets/zh-TW-MYfdvhbD.js +0 -15
- package/server/lib/small-model/call.js +0 -427
- package/server/lib/small-model/call.test.js +0 -382
- package/server/lib/small-model/opencode-session.js +0 -339
- package/server/lib/small-model/opencode-session.test.js +0 -177
|
@@ -2,9 +2,8 @@ import crypto from 'node:crypto';
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { makeOpenCodeV2Client } from '../opencode/v2-client.js';
|
|
6
6
|
import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
|
|
7
|
-
import { reduceBackfillState } from './history-state.js';
|
|
8
7
|
import { createContactMemoryReader } from './memory.js';
|
|
9
8
|
import { getWorktrees as defaultListWorktrees } from '../git/service.js';
|
|
10
9
|
import { contactCardIdentity, parseContactCard, parseContactPart } from './cards.js';
|
|
@@ -78,6 +77,7 @@ import {
|
|
|
78
77
|
} from './assign.js';
|
|
79
78
|
import { runContactTurn as defaultRunContactTurn } from './harness.js';
|
|
80
79
|
import { loadConnectedCatalog } from '../llm/catalog.js';
|
|
80
|
+
import { buildAssistantSessionMetadata } from '../session-metadata/system-session.js';
|
|
81
81
|
|
|
82
82
|
const require = createRequire(import.meta.url);
|
|
83
83
|
const SCHEMA_VERSION = 13;
|
|
@@ -117,22 +117,98 @@ export class AssistantError extends Error {
|
|
|
117
117
|
const fail = (code, message) => { throw new AssistantError(code, message); };
|
|
118
118
|
const string = (value, max = 10_000, required = false) => { if (value == null && !required) return null; if (typeof value !== 'string' || value.length > max || (required && !value.trim())) fail('validation_error'); return value.trim(); };
|
|
119
119
|
const nonEmptyString = (value, max = 10_000) => typeof value === 'string' && value.length > 0 && value.length <= max;
|
|
120
|
-
const isMissing = (result) =>
|
|
120
|
+
const isMissing = (result) => {
|
|
121
|
+
if (!result || typeof result !== 'object') return false;
|
|
122
|
+
if (result.error?.status === 404 || result.error?.statusCode === 404 || result.status === 404) return true;
|
|
123
|
+
const code = result.error?.code ?? result.error?.type ?? result.error?._tag ?? result._tag ?? result.code;
|
|
124
|
+
return code === 'not_found' || code === 'SessionNotFoundError' || code === 'MessageNotFoundError';
|
|
125
|
+
};
|
|
121
126
|
const messagesErrorStatus = (result) => { const status = result?.error?.status ?? result?.error?.statusCode ?? result?.status; return Number.isFinite(status) ? status : null; };
|
|
127
|
+
const getHttpStatus = (error) => {
|
|
128
|
+
const candidates = [error?.cause?.status, error?.status, error?.response?.status];
|
|
129
|
+
for (const value of candidates) { if (Number.isFinite(value)) return value; }
|
|
130
|
+
return undefined;
|
|
131
|
+
};
|
|
132
|
+
// Real @opencode/client throws declared JSON with `_tag` (no HTTP status).
|
|
133
|
+
const isMissingError = (error) => {
|
|
134
|
+
if (getHttpStatus(error) === 404 || isMissing(error)) return true;
|
|
135
|
+
if (!error || typeof error !== 'object') return false;
|
|
136
|
+
const tag = error._tag ?? error.name ?? error.code ?? error.type ?? error.error?._tag;
|
|
137
|
+
return tag === 'SessionNotFoundError' || tag === 'MessageNotFoundError' || tag === 'not_found';
|
|
138
|
+
};
|
|
139
|
+
const ASSISTANT_SUCCESS_FINISH = new Set(['stop']);
|
|
140
|
+
const ASSISTANT_ERROR_FINISH = new Set(['error', 'length', 'content-filter']);
|
|
141
|
+
const GOAL_ACTIVE_STATUSES = new Set(['active', 'paused']);
|
|
142
|
+
const GOAL_ERROR_STATUSES = new Set(['blocked', 'budgetLimited']);
|
|
122
143
|
const isTransientMessagesFailure = (result, error) => {
|
|
123
144
|
if (error) {
|
|
124
145
|
if (error instanceof AssistantError) return false;
|
|
125
|
-
if (error?.name === 'TypeError' || error?.name === 'FetchError') return true;
|
|
146
|
+
if (error?.reason === 'Transport' || error?.name === 'TypeError' || error?.name === 'FetchError' || error?.name === 'AbortError') return true;
|
|
126
147
|
const code = error?.cause?.code || error?.code;
|
|
127
148
|
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'ECONNRESET' || code === 'ETIMEDOUT' || code === 'EPIPE') return true;
|
|
128
|
-
|
|
149
|
+
const status = getHttpStatus(error);
|
|
150
|
+
if (status == null) return false;
|
|
151
|
+
return status === 408 || status === 429 || status >= 500;
|
|
129
152
|
}
|
|
130
153
|
const status = messagesErrorStatus(result);
|
|
131
154
|
if (status == null) return true;
|
|
132
155
|
return status === 408 || status === 429 || status >= 500;
|
|
133
156
|
};
|
|
134
157
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
135
|
-
const
|
|
158
|
+
const sessionRecord = (value) => (value && typeof value === 'object' && value.data && value.id == null ? value.data : value);
|
|
159
|
+
const sessionIDOf = (value) => sessionRecord(value)?.id ?? null;
|
|
160
|
+
const sessionDirectoryOf = (value) => {
|
|
161
|
+
const session = sessionRecord(value);
|
|
162
|
+
return session?.location?.directory ?? session?.directory ?? session?.project?.worktree ?? null;
|
|
163
|
+
};
|
|
164
|
+
const toV2PromptInput = (sessionID, parts, messageID) => {
|
|
165
|
+
const text = (parts || []).filter((part) => part?.type === 'text').map((part) => part.text).join('\n');
|
|
166
|
+
const files = (parts || []).filter((part) => part?.type === 'file').map((part) => ({ uri: part.url, ...(part.filename ? { name: part.filename } : {}) }));
|
|
167
|
+
const agents = (parts || []).filter((part) => part?.type === 'agent').map((part) => ({ name: part.name }));
|
|
168
|
+
return { sessionID, id: messageID, text, ...(files.length ? { files } : {}), ...(agents.length ? { agents } : {}), delivery: 'steer' };
|
|
169
|
+
};
|
|
170
|
+
const projectV2Parts = (entry, sessionID) => {
|
|
171
|
+
if (Array.isArray(entry?.parts)) return entry.parts;
|
|
172
|
+
const messageID = entry?.id;
|
|
173
|
+
const parts = [];
|
|
174
|
+
if (typeof entry?.text === 'string' && entry.text) parts.push({ id: `${messageID}:text`, sessionID, messageID, type: 'text', text: entry.text });
|
|
175
|
+
if (Array.isArray(entry?.files)) entry.files.forEach((file, index) => parts.push({ id: `${messageID}:file:${index}`, sessionID, messageID, type: 'file', mime: file?.mime || 'application/octet-stream', url: file?.uri ?? file?.url, ...(file?.name ? { filename: file.name } : {}) }));
|
|
176
|
+
if (Array.isArray(entry?.content)) entry.content.forEach((item, index) => {
|
|
177
|
+
const partID = item?.id || `${messageID}:content:${index}`;
|
|
178
|
+
if (item?.type === 'text') parts.push({ id: partID, sessionID, messageID, type: 'text', text: item.text ?? '' });
|
|
179
|
+
else if (item?.type === 'reasoning') parts.push({ id: partID, sessionID, messageID, type: 'reasoning', text: item.text ?? '' });
|
|
180
|
+
else if (item?.type === 'tool') parts.push({ id: partID, sessionID, messageID, type: 'tool', tool: item.name, callID: item.id, state: item.state ?? {} });
|
|
181
|
+
});
|
|
182
|
+
return parts;
|
|
183
|
+
};
|
|
184
|
+
const projectProjectionEntry = (entry, sessionID) => {
|
|
185
|
+
if (plainObject(entry?.info)) return { info: entry.info, parts: Array.isArray(entry.parts) ? entry.parts : [] };
|
|
186
|
+
if (!plainObject(entry) || !nonEmptyString(entry.id)) return { info: entry, parts: [] };
|
|
187
|
+
const role = entry.type === 'user' || entry.type === 'assistant' ? entry.type : (entry.role ?? entry.type);
|
|
188
|
+
return { info: { id: entry.id, sessionID: nonEmptyString(entry.sessionID) ? entry.sessionID : sessionID, role, time: entry.time ?? { created: 0 }, ...entry }, parts: projectV2Parts(entry, nonEmptyString(entry.sessionID) ? entry.sessionID : sessionID) };
|
|
189
|
+
};
|
|
190
|
+
const projectionEntries = (payload, sessionID) => {
|
|
191
|
+
const items = Array.isArray(payload?.data) ? payload.data : Array.isArray(payload) ? payload : Array.isArray(payload?.data?.items) ? payload.data.items : null;
|
|
192
|
+
if (!items) return null;
|
|
193
|
+
return items.map((entry) => projectProjectionEntry(entry, sessionID));
|
|
194
|
+
};
|
|
195
|
+
const projectionCursor = (result) => { const next = typeof result?.cursor === 'string' ? result.cursor : result?.cursor?.next; return (typeof next === 'string' && next) ? next : (result?.response?.headers?.get?.('x-next-cursor') ?? null); };
|
|
196
|
+
const promptAdmitted = (result) => !result?.error && (result?.response?.status === 204 || result?.status === 204 || result?.id != null || result?.type != null || result?.data !== undefined || result?.response?.ok === true);
|
|
197
|
+
const invokeSession = async (work) => {
|
|
198
|
+
try {
|
|
199
|
+
const value = await work();
|
|
200
|
+
if (isMissing(value)) return { error: { status: 404 }, status: 404 };
|
|
201
|
+
if (value?.error) return value;
|
|
202
|
+
return value;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
if (error instanceof AssistantError) throw error;
|
|
205
|
+
if (isMissingError(error)) return { error: { status: 404 }, status: 404 };
|
|
206
|
+
const status = getHttpStatus(error);
|
|
207
|
+
if (status != null) return { error: { status }, status };
|
|
208
|
+
return { error: { status: 500 }, status: 500 };
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
136
212
|
/** Bound catalog / project list waits so a contact lane cannot hang forever. */
|
|
137
213
|
export const CONTACT_CATALOG_DEADLINE_MS = 8_000;
|
|
138
214
|
/** Bound last worker assistant text injected into assigned-session resume prompts. */
|
|
@@ -160,7 +236,7 @@ const awaitWithDeadline = async (work, ms = CONTACT_CATALOG_DEADLINE_MS, code =
|
|
|
160
236
|
}
|
|
161
237
|
};
|
|
162
238
|
|
|
163
|
-
export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
|
|
239
|
+
export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, getOpenCodeAuthHeaders, getServerId = async () => null, getAllowedRoots = () => [], listProjects = async () => [], readModelPreferences = async () => null, listScheduledTasks = null, sessionIndexService = null, upsertScheduledTask = null, syncScheduledTaskProject = null, globalEventHub = null, onRevisionTip = null, onContactTurnEvent = null, onContactTurnComplete = null, /** Host archive op: ({ sessionID, directory, archivedAt }) => Promise */ archiveSessionHost = null, /** Idempotent Host metadata drop after successful SDK delete */ forgetSessionHost = null, /** Host store write: (sessionId, patch) => Promise */ persistSessionMetadata = null, /** After Host isolation metadata is committed. */ onSystemSessionPersisted = null, clock = () => Date.now(), setIntervalFn = setInterval, clearIntervalFn = clearInterval, setImmediateFn = setImmediate, reconcileIntervalMs = 60_000, clientFactory, fetchImpl, createChatCompletion = null, runContactTurn = defaultRunContactTurn, listWorktrees = defaultListWorktrees } = {}) => {
|
|
164
240
|
if (!dbPath || !dataDir) return null;
|
|
165
241
|
const Database = require('better-sqlite3');
|
|
166
242
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -244,15 +320,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
244
320
|
const existing = db.prepare('SELECT directory FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
|
|
245
321
|
if (existing) {
|
|
246
322
|
if (existing.directory == null && directory != null) db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=?').run(directory, assistantID, sessionID);
|
|
247
|
-
db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
|
|
248
|
-
db.prepare("UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND COALESCE(json_extract(info_json,'$.role'),'')<>'user' AND COALESCE(json_extract(info_json,'$.openchamberAssistantAdmission'),0)<>1").run(assistantID, sessionID);
|
|
249
323
|
return;
|
|
250
324
|
}
|
|
251
325
|
const effectiveDirectory = directory ?? effectiveWorkspace(editable(assistantID));
|
|
252
326
|
const ordinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(assistantID).next);
|
|
253
327
|
db.prepare('INSERT INTO assistant_session_history(assistant_id, session_id, ordinal, directory, created_at) VALUES (?,?,?,?,?)').run(assistantID, sessionID, ordinal, effectiveDirectory, now());
|
|
254
|
-
db.prepare('DELETE FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
|
|
255
|
-
db.prepare("UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND COALESCE(json_extract(info_json,'$.role'),'')<>'user' AND COALESCE(json_extract(info_json,'$.openchamberAssistantAdmission'),0)<>1").run(assistantID, sessionID);
|
|
256
328
|
};
|
|
257
329
|
const activeContactTurnsFor = (assistantID) => activeContactTurnsByAssistant.get(assistantID) ?? new Map();
|
|
258
330
|
const rememberActiveContactTurn = (assistantID, input) => {
|
|
@@ -348,30 +420,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
348
420
|
};
|
|
349
421
|
};
|
|
350
422
|
const binding = (row) => ({ sessionID: row.current_session_id, directory: effectiveWorkspace(row), sessionGeneration: row.session_generation });
|
|
351
|
-
const client = () => clientFactory
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
if (
|
|
423
|
+
const client = () => clientFactory
|
|
424
|
+
? clientFactory()
|
|
425
|
+
: makeOpenCodeV2Client({
|
|
426
|
+
baseUrl: buildOpenCodeUrl('/', '').replace(/\/$/, ''),
|
|
427
|
+
authHeaders: getOpenCodeAuthHeaders(),
|
|
428
|
+
});
|
|
429
|
+
const metadata = (row) => buildAssistantSessionMetadata({
|
|
430
|
+
assistantID: row.assistant_id,
|
|
431
|
+
name: row.name,
|
|
432
|
+
});
|
|
433
|
+
const persistAssistantIsolation = async (sessionID, directory, isolation) => {
|
|
434
|
+
if (typeof persistSessionMetadata !== 'function') return;
|
|
435
|
+
try {
|
|
436
|
+
await persistSessionMetadata(sessionID, isolation);
|
|
437
|
+
onSystemSessionPersisted?.({ sessionID, directory, metadata: isolation });
|
|
438
|
+
} catch {
|
|
439
|
+
// Archive still isolates; Host persist is the sidebar authority when it succeeds.
|
|
440
|
+
}
|
|
363
441
|
};
|
|
364
442
|
const createSession = async (row) => {
|
|
365
443
|
const directory = effectiveWorkspace(row);
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
444
|
+
const isolation = metadata(row);
|
|
445
|
+
const result = await invokeSession(() => client().session.create({
|
|
446
|
+
title: `[Assistant] ${row.name}`,
|
|
447
|
+
location: { directory },
|
|
448
|
+
metadata: isolation,
|
|
449
|
+
...(row.agent ? { agent: row.agent } : {}),
|
|
450
|
+
...(row.provider_id && row.model_id ? { model: { id: row.model_id, providerID: row.provider_id, ...(row.variant ? { variant: row.variant } : {}) } } : {}),
|
|
451
|
+
}));
|
|
452
|
+
const sessionID = sessionIDOf(result);
|
|
453
|
+
if (result?.error || !sessionID) fail('upstream_error');
|
|
454
|
+
// Host metadata isolates the session if archive is delayed or unavailable.
|
|
455
|
+
await persistAssistantIsolation(sessionID, directory, isolation);
|
|
369
456
|
// Archive before binding so ordinary session lists never flash system sessions.
|
|
370
|
-
// Metadata still isolates the session if archive fails after create.
|
|
371
|
-
await archiveCreatedSession(sessionID, directory);
|
|
372
457
|
return { sessionID, directory };
|
|
373
458
|
};
|
|
374
|
-
const sessionExists = async (row) => { if (!row.current_session_id) return false; const result = await client().session.get({ sessionID: row.current_session_id
|
|
459
|
+
const sessionExists = async (row) => { if (!row.current_session_id) return false; const result = await invokeSession(() => client().session.get({ sessionID: row.current_session_id })); if (isMissing(result)) return false; if (result.error) fail('upstream_error'); return Boolean(sessionIDOf(result) || result); };
|
|
375
460
|
const replaceBinding = (row, created) => { if (row.current_session_id && row.current_session_id !== created.sessionID) archiveSession(row.assistant_id, row.current_session_id, effectiveWorkspace(row)); const result = db.prepare('UPDATE assistant_v2 SET current_session_id=?,session_generation=session_generation+1,updated_at=? WHERE assistant_id=? AND session_generation=? AND tombstone_at IS NULL').run(created.sessionID, now(), row.assistant_id, row.session_generation); if (result.changes) bump(); return result.changes ? assistant(row.assistant_id) : null; };
|
|
376
461
|
const statelessLanes = new Map();
|
|
377
462
|
const inStatelessLane = (assistantID, task) => {
|
|
@@ -413,75 +498,21 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
413
498
|
db.prepare("INSERT OR REPLACE INTO assistant_meta(key,value) VALUES ('schema_version',?)").run(String(SCHEMA_VERSION));
|
|
414
499
|
};
|
|
415
500
|
migrate();
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const existing = db.prepare('SELECT ordinal FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(assistantID, sessionID, messageID);
|
|
420
|
-
const nextOrdinal = Number.isSafeInteger(ordinal) ? ordinal : existing?.ordinal ?? Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_message_mirror WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID).next);
|
|
421
|
-
db.prepare('INSERT INTO assistant_message_mirror(assistant_id,session_id,message_id,info_json,ordinal,covered,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id,message_id) DO UPDATE SET info_json=excluded.info_json,ordinal=excluded.ordinal,covered=CASE WHEN assistant_message_mirror.covered=1 OR excluded.covered=1 THEN 1 ELSE 0 END,updated_at=excluded.updated_at').run(assistantID, sessionID, messageID, json(info), nextOrdinal, covered ? 1 : 0, now());
|
|
422
|
-
};
|
|
423
|
-
const mirrorPart = (assistantID, sessionID, part, ordinal) => {
|
|
424
|
-
const messageID = part?.messageID; const partID = part?.id;
|
|
425
|
-
if (!nonEmptyString(assistantID) || !nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID) || !plainObject(part)) return;
|
|
426
|
-
const existing = db.prepare('SELECT ordinal FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id=?').get(assistantID, sessionID, messageID, partID);
|
|
427
|
-
const nextOrdinal = Number.isSafeInteger(ordinal) ? ordinal : existing?.ordinal ?? Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(assistantID, sessionID, messageID).next);
|
|
428
|
-
db.prepare('INSERT INTO assistant_message_part_mirror(assistant_id,session_id,message_id,part_id,part_json,ordinal,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(assistant_id,session_id,message_id,part_id) DO UPDATE SET part_json=excluded.part_json,ordinal=excluded.ordinal,updated_at=excluded.updated_at').run(assistantID, sessionID, messageID, partID, json(part), nextOrdinal, now());
|
|
429
|
-
};
|
|
430
|
-
const mirrorAdmittedUserMessage = (row, sessionID, messageID, parts, config) => {
|
|
431
|
-
const existingMessage = db.prepare('SELECT info_json FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').get(row.assistant_id, sessionID, messageID);
|
|
432
|
-
const existingInfo = existingMessage ? parse(existingMessage.info_json) : null;
|
|
433
|
-
const info = plainObject(existingInfo) && existingInfo.role === 'user'
|
|
434
|
-
? existingInfo
|
|
435
|
-
: {
|
|
436
|
-
id: messageID,
|
|
437
|
-
sessionID,
|
|
438
|
-
role: 'user',
|
|
439
|
-
time: { created: now() },
|
|
440
|
-
...(config?.agent ? { agent: config.agent } : {}),
|
|
441
|
-
...(plainObject(config?.model) ? { model: config.model } : {}),
|
|
442
|
-
...(typeof config?.system === 'string' && config.system ? { system: config.system } : {}),
|
|
443
|
-
summary: { diffs: [] },
|
|
444
|
-
openchamberAssistantAdmission: true,
|
|
445
|
-
};
|
|
446
|
-
db.exec('BEGIN IMMEDIATE');
|
|
447
|
-
try {
|
|
448
|
-
mirrorMessage(row.assistant_id, sessionID, info, undefined, true);
|
|
449
|
-
const hasAuthoritativeParts = Number(db.prepare("SELECT COUNT(*) AS count FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id NOT GLOB 'oc_asst_admission:*'").get(row.assistant_id, sessionID, messageID).count) > 0;
|
|
450
|
-
if (!hasAuthoritativeParts) {
|
|
451
|
-
db.prepare("DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id GLOB 'oc_asst_admission:*'").run(row.assistant_id, sessionID, messageID);
|
|
452
|
-
parts.forEach((part, index) => mirrorPart(row.assistant_id, sessionID, {
|
|
453
|
-
...part,
|
|
454
|
-
id: `oc_asst_admission:${index + 1}`,
|
|
455
|
-
sessionID,
|
|
456
|
-
messageID,
|
|
457
|
-
}, index + 1));
|
|
458
|
-
}
|
|
459
|
-
db.exec('COMMIT');
|
|
460
|
-
} catch (error) {
|
|
461
|
-
db.exec('ROLLBACK');
|
|
462
|
-
throw error;
|
|
463
|
-
}
|
|
464
|
-
bump();
|
|
465
|
-
};
|
|
501
|
+
// V2: OpenCode projection is the sole message-body authority. Legacy mirror /
|
|
502
|
+
// backfill tables may still exist for leftover rows but are never written or
|
|
503
|
+
// read for history serving.
|
|
466
504
|
const mappedAssistants = (sessionID) => db.prepare("SELECT assistant_id FROM assistant_v2 WHERE current_session_id=? AND tombstone_at IS NULL UNION SELECT h.assistant_id FROM assistant_session_history h JOIN assistant_v2 a ON a.assistant_id=h.assistant_id WHERE h.session_id=? AND a.tombstone_at IS NULL").all(sessionID, sessionID).map((row) => row.assistant_id);
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
// Do not blanket-uncover on ordinary message/part upserts — that blanks served history until re-backfill.
|
|
479
|
-
const invalidateMessageCoverage = (assistantID, sessionID, messageID) => {
|
|
480
|
-
db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=? AND message_id=?').run(assistantID, sessionID, messageID);
|
|
481
|
-
db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=NULL,complete=0,updated_at=excluded.updated_at').run(assistantID, sessionID, null, 0, now());
|
|
482
|
-
};
|
|
483
|
-
const eventSessionID = (properties) => {
|
|
484
|
-
const sessionID = properties?.sessionID || properties?.sessionId || properties?.info?.sessionID || properties?.info?.sessionId;
|
|
505
|
+
// Accept bridge/legacy `{ properties }` and native v2 `{ data }` envelopes.
|
|
506
|
+
const eventBody = (payload) => {
|
|
507
|
+
if (!plainObject(payload)) return null;
|
|
508
|
+
const data = plainObject(payload.data) ? payload.data : null;
|
|
509
|
+
const properties = plainObject(payload.properties) ? payload.properties : null;
|
|
510
|
+
if (data && Object.keys(data).length > 0) return data;
|
|
511
|
+
if (properties) return properties;
|
|
512
|
+
return data || properties || null;
|
|
513
|
+
};
|
|
514
|
+
const eventSessionID = (body) => {
|
|
515
|
+
const sessionID = body?.sessionID || body?.sessionId || body?.info?.sessionID || body?.info?.sessionId;
|
|
485
516
|
return nonEmptyString(sessionID) ? sessionID : '';
|
|
486
517
|
};
|
|
487
518
|
const isUserAbort = (error) => error?.name === 'MessageAbortedError';
|
|
@@ -519,21 +550,44 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
519
550
|
if (status === 'cancelled') entry.observed.resume = resume !== false;
|
|
520
551
|
}
|
|
521
552
|
};
|
|
522
|
-
const settleStatusFromEvent = (payload,
|
|
523
|
-
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
553
|
+
const settleStatusFromEvent = (payload, body) => {
|
|
554
|
+
const type = typeof payload?.type === 'string' ? payload.type : '';
|
|
555
|
+
if (type === 'session.error' || type === 'session.execution.failed') {
|
|
556
|
+
return isUserAbort(body?.error) ? 'cancelled' : 'error';
|
|
557
|
+
}
|
|
558
|
+
// Official reason: user | shutdown | superseded | inactivity (ticket 07).
|
|
559
|
+
// Shutdown keeps the upstream execution claim for restart recovery — do not
|
|
560
|
+
// settle the watch or schedule contact resume; wait for authoritative terminal.
|
|
561
|
+
if (type === 'session.execution.interrupted') {
|
|
562
|
+
const reason = typeof body?.reason === 'string' ? body.reason.trim() : '';
|
|
563
|
+
if (reason === 'shutdown') return null;
|
|
564
|
+
if (reason === 'user' || isUserAbort(body?.error)) return 'cancelled';
|
|
565
|
+
return 'error';
|
|
566
|
+
}
|
|
567
|
+
if (type === 'session.execution.succeeded') return 'complete';
|
|
568
|
+
if (type === 'session.execution.started' || type === 'session.retry.scheduled') return 'busy';
|
|
569
|
+
if (type === 'question.asked' || type === 'permission.asked'
|
|
570
|
+
|| type === 'session.pending.question' || type === 'session.pending.permission'
|
|
571
|
+
|| type === 'pending.question' || type === 'pending.permission') return 'question';
|
|
572
|
+
if (type === 'session.idle') return 'complete';
|
|
573
|
+
if (type === 'session.status') {
|
|
574
|
+
const statusType = typeof body?.status?.type === 'string' ? body.status.type : typeof body?.info?.type === 'string' ? body.info.type : '';
|
|
575
|
+
if (statusType === 'busy' || statusType === 'retry' || statusType === 'running') return 'busy';
|
|
529
576
|
if (statusType === 'idle') return 'complete';
|
|
530
577
|
}
|
|
531
578
|
return null;
|
|
532
579
|
};
|
|
580
|
+
// Stash assign delivery message IDs until the session card watch is written.
|
|
581
|
+
const pendingAssignMessageIDs = new Map();
|
|
533
582
|
// Assigned-session complete/error/cancelled resumes the contact LLM (no canned settle bubble).
|
|
534
583
|
// Assigned after inContactTurnLane exists; reportAssignedSession only schedules via this ref.
|
|
535
584
|
const assignedSessionResumeRef = { schedule: null };
|
|
536
|
-
const reportAssignedSession = (sessionID, status,
|
|
585
|
+
const reportAssignedSession = (sessionID, status, resumeOrOpts = true) => {
|
|
586
|
+
const opts = plainObject(resumeOrOpts)
|
|
587
|
+
? resumeOrOpts
|
|
588
|
+
: { resume: resumeOrOpts !== false };
|
|
589
|
+
const resume = opts.resume !== false;
|
|
590
|
+
const requireExecutionOwned = opts.requireExecutionOwned === true;
|
|
537
591
|
const inflight = sessionAbortInflight.get(sessionID);
|
|
538
592
|
if (inflight) {
|
|
539
593
|
// Defer watch terminal + continuation until stop_session abort settles.
|
|
@@ -554,6 +608,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
554
608
|
if (watch.status === status || watch.status === 'cancelled') continue;
|
|
555
609
|
// session.idle / status idle follow session.error. Do not rewrite 失败 as 完成.
|
|
556
610
|
if (status === 'complete' && watch.status === 'error') continue;
|
|
611
|
+
// Weak idle signals must not settle a delivery-watermarked watch — old
|
|
612
|
+
// session.idle can race a reuse assign. Formal execution.* owns that path.
|
|
613
|
+
if (requireExecutionOwned && status === 'complete' && watch.messageID) continue;
|
|
557
614
|
const updatedAt = now();
|
|
558
615
|
updateSessionCardStatus(db, { assistantID: watch.assistantID, sessionID, status });
|
|
559
616
|
upsertContactWatch(db, {
|
|
@@ -563,6 +620,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
563
620
|
status,
|
|
564
621
|
updatedAt,
|
|
565
622
|
resumeAllowed: watch.resumeAllowed,
|
|
623
|
+
...(watch.messageID ? { messageID: watch.messageID } : {}),
|
|
566
624
|
});
|
|
567
625
|
// complete/error/user-interrupt cancelled: hand result back to contact LLM.
|
|
568
626
|
// question: card only (worker waiting). stop_session passes resume=false.
|
|
@@ -586,7 +644,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
586
644
|
}
|
|
587
645
|
// processEvent stays sync; resume runs on the contact turn lane asynchronously.
|
|
588
646
|
if (typeof assignedSessionResumeRef.schedule === 'function') {
|
|
589
|
-
for (const
|
|
647
|
+
for (const item of resumes) assignedSessionResumeRef.schedule(item);
|
|
590
648
|
}
|
|
591
649
|
return changed;
|
|
592
650
|
};
|
|
@@ -596,26 +654,134 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
596
654
|
if (typeof session?.type === 'string') return session.type;
|
|
597
655
|
return '';
|
|
598
656
|
};
|
|
657
|
+
const readMessageInfo = (entry) => {
|
|
658
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
659
|
+
if (plainObject(entry.info)) return entry.info;
|
|
660
|
+
return entry;
|
|
661
|
+
};
|
|
662
|
+
const isAssistantMessage = (info) => info && (info.type === 'assistant' || info.role === 'assistant');
|
|
663
|
+
const messageListItems = (result) => {
|
|
664
|
+
if (!result || result.error) return null;
|
|
665
|
+
if (Array.isArray(result.data)) return result.data;
|
|
666
|
+
if (Array.isArray(result)) return result;
|
|
667
|
+
if (Array.isArray(result.data?.items)) return result.data.items;
|
|
668
|
+
return null;
|
|
669
|
+
};
|
|
670
|
+
const activeMapOf = (value) => {
|
|
671
|
+
if (!value || typeof value !== 'object' || Array.isArray(value) || value.error) return null;
|
|
672
|
+
// Tolerate `{ data: { ses_x: … } }` wrappers without treating a bare map as nested data.
|
|
673
|
+
if (value.data && typeof value.data === 'object' && !Array.isArray(value.data) && value.id == null
|
|
674
|
+
&& !Object.prototype.hasOwnProperty.call(value, 'type')) {
|
|
675
|
+
const nested = value.data;
|
|
676
|
+
if (!Array.isArray(nested) && typeof nested === 'object') return nested;
|
|
677
|
+
}
|
|
678
|
+
return value;
|
|
679
|
+
};
|
|
680
|
+
const goalOfSession = (session) => {
|
|
681
|
+
const goal = session?.metadata?.openchamber?.goal;
|
|
682
|
+
if (!goal || typeof goal !== 'object') return null;
|
|
683
|
+
const status = typeof goal.status === 'string' ? goal.status.trim() : '';
|
|
684
|
+
return status ? { status } : null;
|
|
685
|
+
};
|
|
686
|
+
/** Messages that belong to this delivery watermark (assign prompt messageID). */
|
|
687
|
+
const messagesOwnedByDelivery = (messages, deliveryMessageID) => {
|
|
688
|
+
if (!Array.isArray(messages)) return [];
|
|
689
|
+
if (!nonEmptyString(deliveryMessageID)) return messages;
|
|
690
|
+
const rows = messages.map((entry) => ({ entry, info: readMessageInfo(entry) }));
|
|
691
|
+
const delivery = rows.find((row) => row.info?.id === deliveryMessageID);
|
|
692
|
+
if (delivery) {
|
|
693
|
+
const created = typeof delivery.info?.time?.created === 'number' ? delivery.info.time.created : null;
|
|
694
|
+
return rows
|
|
695
|
+
.filter((row) => {
|
|
696
|
+
if (row.info?.id === deliveryMessageID) return true;
|
|
697
|
+
const rowCreated = typeof row.info?.time?.created === 'number' ? row.info.time.created : null;
|
|
698
|
+
if (created != null && rowCreated != null) {
|
|
699
|
+
if (rowCreated > created) return true;
|
|
700
|
+
if (rowCreated < created) return false;
|
|
701
|
+
}
|
|
702
|
+
// Ascending msg_ ids: strictly after the delivery user row.
|
|
703
|
+
return typeof row.info?.id === 'string' && row.info.id > deliveryMessageID;
|
|
704
|
+
})
|
|
705
|
+
.map((row) => row.entry);
|
|
706
|
+
}
|
|
707
|
+
// Delivery not on this page — only keep ids after the watermark.
|
|
708
|
+
return rows
|
|
709
|
+
.filter((row) => typeof row.info?.id === 'string' && row.info.id > deliveryMessageID)
|
|
710
|
+
.map((row) => row.entry);
|
|
711
|
+
};
|
|
599
712
|
const lastAssistantInfo = (messages) => {
|
|
600
|
-
if (!Array.isArray(messages)) return null;
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
713
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
714
|
+
// Prefer newest by time.created; fall back to array order (desc list → index 0).
|
|
715
|
+
let best = null;
|
|
716
|
+
let bestCreated = Number.NEGATIVE_INFINITY;
|
|
717
|
+
let bestIndex = -1;
|
|
718
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
719
|
+
const info = readMessageInfo(messages[index]);
|
|
720
|
+
if (!isAssistantMessage(info)) continue;
|
|
721
|
+
const created = typeof info?.time?.created === 'number' ? info.time.created : Number.NEGATIVE_INFINITY;
|
|
722
|
+
if (!best || created > bestCreated || (created === bestCreated && index < bestIndex)) {
|
|
723
|
+
best = info;
|
|
724
|
+
bestCreated = created;
|
|
725
|
+
bestIndex = index;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
if (best) return best;
|
|
729
|
+
// Desc page without timestamps: first assistant from the start.
|
|
730
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
731
|
+
const info = readMessageInfo(messages[index]);
|
|
732
|
+
if (isAssistantMessage(info)) return info;
|
|
604
733
|
}
|
|
605
734
|
return null;
|
|
606
735
|
};
|
|
607
|
-
const
|
|
736
|
+
const classifyOwnedAssistantTail = (assistantInfo) => {
|
|
737
|
+
if (!assistantInfo) return null;
|
|
738
|
+
if (assistantInfo.error) {
|
|
739
|
+
// MessageAbortedError → cancelled for contact resume; other errors fail the card.
|
|
740
|
+
return isUserAbort(assistantInfo.error) ? 'cancelled' : 'error';
|
|
741
|
+
}
|
|
742
|
+
const finish = typeof assistantInfo.finish === 'string' ? assistantInfo.finish.trim() : '';
|
|
743
|
+
// Mid-loop tool-calls (even with time.completed) are intermediate steps.
|
|
744
|
+
if (finish === 'tool-calls') return null;
|
|
745
|
+
if (ASSISTANT_ERROR_FINISH.has(finish)) return 'error';
|
|
746
|
+
if (assistantInfo.time?.completed && (!finish || ASSISTANT_SUCCESS_FINISH.has(finish))) return 'complete';
|
|
747
|
+
if (!finish && !assistantInfo.time?.completed) return null;
|
|
748
|
+
return null;
|
|
749
|
+
};
|
|
750
|
+
const inferAssignedSessionSettleStatus = (getResult, messagesResult, { deliveryMessageID = null, activeBusy = null } = {}) => {
|
|
608
751
|
if (isMissing(getResult)) return 'complete';
|
|
609
752
|
if (getResult?.error) return null;
|
|
610
|
-
|
|
753
|
+
// Authoritative active membership wins over stale get snapshots.
|
|
754
|
+
if (activeBusy === true) return null;
|
|
755
|
+
const session = sessionRecord(getResult);
|
|
611
756
|
if (session?.error) return isUserAbort(session.error) ? 'cancelled' : 'error';
|
|
757
|
+
const goal = goalOfSession(session);
|
|
758
|
+
// Goal settle is owned by the session-goal reporter — never invent success.
|
|
759
|
+
if (goal && GOAL_ACTIVE_STATUSES.has(goal.status)) return null;
|
|
760
|
+
if (goal && GOAL_ERROR_STATUSES.has(goal.status)) return 'error';
|
|
761
|
+
if (goal && goal.status === 'complete') return 'complete';
|
|
612
762
|
const statusType = sessionStatusType(session);
|
|
613
|
-
if (statusType === 'busy' || statusType === 'retry') return null;
|
|
763
|
+
if (statusType === 'busy' || statusType === 'retry' || statusType === 'running') return null;
|
|
614
764
|
if (isMissing(messagesResult)) return 'complete';
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
if (
|
|
618
|
-
|
|
765
|
+
if (messagesResult?.error) return null;
|
|
766
|
+
const items = messageListItems(messagesResult);
|
|
767
|
+
if (!items) return null;
|
|
768
|
+
const owned = messagesOwnedByDelivery(items, deliveryMessageID);
|
|
769
|
+
// Reused session: only pre-delivery assistants remain → wait for this run.
|
|
770
|
+
if (nonEmptyString(deliveryMessageID) && owned.length === 0) return null;
|
|
771
|
+
if (nonEmptyString(deliveryMessageID) && !owned.some((entry) => readMessageInfo(entry)?.id === deliveryMessageID)
|
|
772
|
+
&& !owned.some((entry) => isAssistantMessage(readMessageInfo(entry)))) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const assistantInfo = lastAssistantInfo(owned);
|
|
776
|
+
const fromTail = classifyOwnedAssistantTail(assistantInfo);
|
|
777
|
+
if (fromTail) return fromTail;
|
|
778
|
+
// No owned assistant yet after delivery — keep waiting (superseded / not started).
|
|
779
|
+
if (nonEmptyString(deliveryMessageID) && !assistantInfo) return null;
|
|
780
|
+
// Legacy watches without watermark: idle / session completed may settle.
|
|
781
|
+
if (!nonEmptyString(deliveryMessageID)) {
|
|
782
|
+
if (statusType === 'idle' || session?.time?.completed) return 'complete';
|
|
783
|
+
if (assistantInfo?.time?.completed && assistantInfo.finish !== 'tool-calls') return 'complete';
|
|
784
|
+
}
|
|
619
785
|
return null;
|
|
620
786
|
};
|
|
621
787
|
const resolveWatchDirectory = (watch) => {
|
|
@@ -634,26 +800,50 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
634
800
|
} catch {
|
|
635
801
|
return;
|
|
636
802
|
}
|
|
803
|
+
if (watches.length === 0) return;
|
|
804
|
+
// One active query per reconcile — authoritative busy membership map.
|
|
805
|
+
let activeMap = null;
|
|
806
|
+
let activeKnown = false;
|
|
807
|
+
try {
|
|
808
|
+
const api = client();
|
|
809
|
+
if (typeof api.session?.active === 'function') {
|
|
810
|
+
const raw = await api.session.active();
|
|
811
|
+
activeMap = activeMapOf(raw);
|
|
812
|
+
activeKnown = activeMap != null;
|
|
813
|
+
}
|
|
814
|
+
} catch {
|
|
815
|
+
activeKnown = false;
|
|
816
|
+
}
|
|
817
|
+
if (closed) return;
|
|
637
818
|
for (const watch of watches) {
|
|
638
819
|
if (closed) return;
|
|
639
820
|
try {
|
|
821
|
+
const activeBusy = activeKnown
|
|
822
|
+
? Object.prototype.hasOwnProperty.call(activeMap, watch.sessionID)
|
|
823
|
+
: null;
|
|
824
|
+
if (activeBusy === true) continue;
|
|
640
825
|
const directory = resolveWatchDirectory(watch);
|
|
641
826
|
if (!directory) continue;
|
|
642
827
|
let getResult;
|
|
643
828
|
try {
|
|
644
|
-
getResult = await client().session.get({ sessionID: watch.sessionID, directory });
|
|
645
|
-
} catch {
|
|
646
|
-
|
|
829
|
+
getResult = await invokeSession(() => client().session.get({ sessionID: watch.sessionID, directory }));
|
|
830
|
+
} catch (error) {
|
|
831
|
+
if (isMissingError(error)) getResult = { error: { status: 404 }, status: 404 };
|
|
832
|
+
else continue;
|
|
647
833
|
}
|
|
648
834
|
if (closed) return;
|
|
649
835
|
let messagesResult = null;
|
|
650
836
|
try {
|
|
651
|
-
messagesResult = await client().
|
|
652
|
-
} catch {
|
|
653
|
-
messagesResult =
|
|
837
|
+
messagesResult = await invokeSession(() => client().message.list({ sessionID: watch.sessionID, limit: 100, order: 'desc' }));
|
|
838
|
+
} catch (error) {
|
|
839
|
+
if (isMissingError(error)) messagesResult = { error: { status: 404 }, status: 404 };
|
|
840
|
+
else messagesResult = null;
|
|
654
841
|
}
|
|
655
842
|
if (closed) return;
|
|
656
|
-
const status = inferAssignedSessionSettleStatus(getResult, messagesResult
|
|
843
|
+
const status = inferAssignedSessionSettleStatus(getResult, messagesResult, {
|
|
844
|
+
deliveryMessageID: watch.messageID,
|
|
845
|
+
activeBusy,
|
|
846
|
+
});
|
|
657
847
|
if (status) reportAssignedSession(watch.sessionID, status);
|
|
658
848
|
} catch {
|
|
659
849
|
// One failed watch must not block unrelated watches.
|
|
@@ -662,47 +852,74 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
662
852
|
};
|
|
663
853
|
const processEvent = (event) => {
|
|
664
854
|
const payload = event?.payload?.payload ?? event?.payload ?? event;
|
|
665
|
-
|
|
666
|
-
|
|
855
|
+
if (!plainObject(payload) || typeof payload.type !== 'string') return false;
|
|
856
|
+
const body = eventBody(payload);
|
|
857
|
+
// Message lifecycle still requires a body; execution/status may carry sessionID on body only.
|
|
667
858
|
if (payload.type === 'message.updated') {
|
|
668
|
-
|
|
859
|
+
if (!plainObject(body)) return false;
|
|
860
|
+
const info = body.info; const sessionID = info?.sessionID;
|
|
669
861
|
if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
|
|
670
862
|
const cancelled = info.role === 'assistant' && isUserAbort(info.error)
|
|
671
863
|
? reportAssignedSession(sessionID, 'cancelled') : false;
|
|
672
|
-
|
|
864
|
+
// Body authority is OpenCode projection GET — do not mirror message bodies.
|
|
865
|
+
return cancelled || mappedAssistants(sessionID).length > 0;
|
|
673
866
|
}
|
|
674
867
|
if (payload.type === 'message.part.updated') {
|
|
675
|
-
|
|
868
|
+
if (!plainObject(body)) return false;
|
|
869
|
+
const part = body.part; const sessionID = body.sessionID ?? part?.sessionID;
|
|
676
870
|
if (!nonEmptyString(sessionID) || !plainObject(part)) return false;
|
|
677
|
-
|
|
871
|
+
return mappedAssistants(sessionID).length > 0;
|
|
678
872
|
}
|
|
679
873
|
if (payload.type === 'message.removed') {
|
|
680
|
-
|
|
874
|
+
if (!plainObject(body)) return false;
|
|
875
|
+
const sessionID = body.sessionID; const messageID = body.messageID;
|
|
681
876
|
if (!nonEmptyString(sessionID) || !nonEmptyString(messageID)) return false;
|
|
682
|
-
|
|
877
|
+
return mappedAssistants(sessionID).length > 0;
|
|
683
878
|
}
|
|
684
879
|
if (payload.type === 'message.part.removed') {
|
|
685
|
-
|
|
880
|
+
if (!plainObject(body)) return false;
|
|
881
|
+
const sessionID = body.sessionID; const messageID = body.messageID ?? body.part?.messageID; const partID = body.partID ?? body.part?.id;
|
|
686
882
|
if (!nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID)) return false;
|
|
687
|
-
|
|
883
|
+
return mappedAssistants(sessionID).length > 0;
|
|
884
|
+
}
|
|
885
|
+
const sessionID = plainObject(body) ? eventSessionID(body) : '';
|
|
886
|
+
if (payload.type === 'session.execution.started' || payload.type === 'session.retry.scheduled') {
|
|
887
|
+
if (!sessionID) return false;
|
|
888
|
+
return reportAssignedSession(sessionID, 'busy');
|
|
889
|
+
}
|
|
890
|
+
if (payload.type === 'session.execution.succeeded'
|
|
891
|
+
|| payload.type === 'session.execution.failed'
|
|
892
|
+
|| payload.type === 'session.execution.interrupted') {
|
|
893
|
+
if (!sessionID) return false;
|
|
894
|
+
const status = settleStatusFromEvent(payload, body);
|
|
895
|
+
// shutdown interrupt: status null — keep watch in-flight (pending recovery).
|
|
896
|
+
if (!status) {
|
|
897
|
+
return listWatchesBySession(db, sessionID).length > 0
|
|
898
|
+
|| mappedAssistants(sessionID).length > 0;
|
|
899
|
+
}
|
|
900
|
+
const reported = reportAssignedSession(sessionID, status);
|
|
901
|
+
const assistants = mappedAssistants(sessionID);
|
|
902
|
+
return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
688
903
|
}
|
|
689
904
|
if (payload.type === 'session.idle' || payload.type === 'session.error') {
|
|
690
|
-
const sessionID = eventSessionID(properties);
|
|
691
905
|
if (!sessionID) return false;
|
|
692
|
-
|
|
693
|
-
const
|
|
906
|
+
// session.idle is a weak fallback: watermarked watches need execution.* or reconcile.
|
|
907
|
+
const status = settleStatusFromEvent(payload, body);
|
|
908
|
+
const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: payload.type === 'session.idle' });
|
|
909
|
+
const assistants = mappedAssistants(sessionID);
|
|
694
910
|
return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
695
911
|
}
|
|
696
912
|
if (payload.type === 'session.status') {
|
|
697
|
-
|
|
698
|
-
const status = settleStatusFromEvent(payload,
|
|
699
|
-
if (!
|
|
700
|
-
const reported = reportAssignedSession(sessionID, status);
|
|
701
|
-
const assistants = mappedAssistants(sessionID);
|
|
913
|
+
if (!sessionID) return false;
|
|
914
|
+
const status = settleStatusFromEvent(payload, body);
|
|
915
|
+
if (!status) return false;
|
|
916
|
+
const reported = reportAssignedSession(sessionID, status, { requireExecutionOwned: status === 'complete' });
|
|
917
|
+
const assistants = mappedAssistants(sessionID);
|
|
702
918
|
return reported || assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
703
919
|
}
|
|
704
|
-
if (payload.type === 'question.asked' || payload.type === 'permission.asked'
|
|
705
|
-
|
|
920
|
+
if (payload.type === 'question.asked' || payload.type === 'permission.asked'
|
|
921
|
+
|| payload.type === 'session.pending.question' || payload.type === 'session.pending.permission'
|
|
922
|
+
|| payload.type === 'pending.question' || payload.type === 'pending.permission') {
|
|
706
923
|
if (!sessionID) return false;
|
|
707
924
|
return reportAssignedSession(sessionID, 'question');
|
|
708
925
|
}
|
|
@@ -715,131 +932,384 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
715
932
|
}
|
|
716
933
|
return null;
|
|
717
934
|
};
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
const writeBackfillState = (assistantID, sessionID, next) => {
|
|
723
|
-
db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?) ON CONFLICT(assistant_id,session_id) DO UPDATE SET cursor=excluded.cursor,complete=excluded.complete,updated_at=excluded.updated_at').run(assistantID, sessionID, next.cursor, next.complete ? 1 : 0, now());
|
|
724
|
-
};
|
|
725
|
-
// Authoritative 404 means this session ID is gone from OpenCode. Converge its
|
|
726
|
-
// backfill so one deleted archive cannot block demand scans of other sessions.
|
|
727
|
-
// Covered/admitted rows stay; only uncovered event mirrors are dropped. Safe
|
|
728
|
-
// under concurrent ensure: a replaced current binding archives under its old
|
|
729
|
-
// ID, while a still-current missing ID is recreated with a new session ID.
|
|
730
|
-
const completeMissingSessionBackfill = (assistantID, sessionID) => {
|
|
731
|
-
db.exec('BEGIN IMMEDIATE');
|
|
935
|
+
// Host history cursor: binding ordinal + official opaque upstream cursor +
|
|
936
|
+
// page-local skip. Carries positions/IDs only — never message bodies.
|
|
937
|
+
const decodeCursor = (value) => {
|
|
938
|
+
if (value == null || value === '') return null;
|
|
732
939
|
try {
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
if (
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
return
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
940
|
+
const parsed = parse(Buffer.from(String(value), 'base64url').toString('utf8'));
|
|
941
|
+
if (!Number.isSafeInteger(parsed?.sessionOrdinal)) fail('validation_error');
|
|
942
|
+
if (parsed.upstream != null && !nonEmptyString(parsed.upstream)) fail('validation_error');
|
|
943
|
+
const skip = parsed.skip == null ? 0 : parsed.skip;
|
|
944
|
+
if (!Number.isSafeInteger(skip) || skip < 0) fail('validation_error');
|
|
945
|
+
return {
|
|
946
|
+
sessionOrdinal: parsed.sessionOrdinal,
|
|
947
|
+
upstream: parsed.upstream ?? null,
|
|
948
|
+
skip,
|
|
949
|
+
};
|
|
950
|
+
} catch (error) {
|
|
951
|
+
if (error instanceof AssistantError) throw error;
|
|
952
|
+
fail('validation_error');
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
const encodeCursor = (state) => Buffer.from(json({
|
|
956
|
+
sessionOrdinal: state.sessionOrdinal,
|
|
957
|
+
upstream: state.upstream ?? null,
|
|
958
|
+
skip: state.skip ?? 0,
|
|
959
|
+
})).toString('base64url');
|
|
960
|
+
// Official v2: GET /api/session/:id/message — first page order=desc; continuation
|
|
961
|
+
// cursor only (never combine order+cursor). Prefer fetchImpl when injected.
|
|
962
|
+
const fetchProjectionMessages = async (sessionID, directory, cursor, pageLimit = BACKFILL_PAGE_SIZE) => {
|
|
742
963
|
for (let attempt = 0; attempt < BACKFILL_MESSAGES_ATTEMPTS; attempt++) {
|
|
743
964
|
try {
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
965
|
+
if (typeof fetchImpl === 'function') {
|
|
966
|
+
const base = String(buildOpenCodeUrl('/', '') || '').replace(/\/$/, '') || 'http://opencode.local';
|
|
967
|
+
const url = new URL(`${base}/api/session/${encodeURIComponent(sessionID)}/message`);
|
|
968
|
+
url.searchParams.set('limit', String(pageLimit));
|
|
969
|
+
// Official contract: do not combine order with cursor.
|
|
970
|
+
if (cursor) url.searchParams.set('cursor', cursor);
|
|
971
|
+
else url.searchParams.set('order', 'desc');
|
|
972
|
+
if (directory) url.searchParams.set('directory', directory);
|
|
973
|
+
const headers = {
|
|
974
|
+
Accept: 'application/json',
|
|
975
|
+
...(typeof getOpenCodeAuthHeaders === 'function' ? getOpenCodeAuthHeaders() : {}),
|
|
976
|
+
};
|
|
977
|
+
const response = await fetchImpl(url, { method: 'GET', headers });
|
|
978
|
+
const status = response?.status ?? response?.statusCode;
|
|
979
|
+
if (status === 404) return { error: { status: 404 }, status: 404 };
|
|
980
|
+
if (status != null && status >= 400) {
|
|
981
|
+
const errBody = { error: { status }, status };
|
|
982
|
+
if (!isTransientMessagesFailure(errBody, null) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) return errBody;
|
|
983
|
+
} else {
|
|
984
|
+
const payload = typeof response?.json === 'function' ? await response.json() : null;
|
|
985
|
+
return {
|
|
986
|
+
data: Array.isArray(payload?.data) ? payload.data : (Array.isArray(payload) ? payload : payload?.data),
|
|
987
|
+
cursor: payload?.cursor,
|
|
988
|
+
response: {
|
|
989
|
+
status: status ?? 200,
|
|
990
|
+
headers: {
|
|
991
|
+
get: (name) => {
|
|
992
|
+
if (typeof response?.headers?.get === 'function') return response.headers.get(name);
|
|
993
|
+
return null;
|
|
994
|
+
},
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
const result = await invokeSession(() => client().message.list({
|
|
1001
|
+
sessionID,
|
|
1002
|
+
limit: pageLimit,
|
|
1003
|
+
// First page: order=desc. Continuation: opaque cursor only.
|
|
1004
|
+
...(cursor ? { cursor } : { order: 'desc' }),
|
|
1005
|
+
}));
|
|
1006
|
+
if (!result?.error || isMissing(result)) return result;
|
|
1007
|
+
if (!isTransientMessagesFailure(result, null) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) return result;
|
|
1008
|
+
}
|
|
747
1009
|
} catch (error) {
|
|
748
1010
|
if (error instanceof AssistantError) throw error;
|
|
749
|
-
if (
|
|
1011
|
+
if (isMissingError(error)) return { error: { status: 404 }, status: 404 };
|
|
1012
|
+
if (!isTransientMessagesFailure(null, error) || attempt === BACKFILL_MESSAGES_ATTEMPTS - 1) {
|
|
1013
|
+
const status = getHttpStatus(error);
|
|
1014
|
+
return { error: { status: status ?? 500 }, status: status ?? 500 };
|
|
1015
|
+
}
|
|
750
1016
|
}
|
|
751
1017
|
await sleep(BACKFILL_RETRY_MS[Math.min(attempt, BACKFILL_RETRY_MS.length - 1)]);
|
|
752
1018
|
}
|
|
753
|
-
|
|
754
|
-
};
|
|
755
|
-
const
|
|
756
|
-
|
|
757
|
-
if (
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
1019
|
+
return { error: { status: 500 }, status: 500 };
|
|
1020
|
+
};
|
|
1021
|
+
const resolveBindingDirectory = async (binding) => {
|
|
1022
|
+
let directory = binding.directory ?? null;
|
|
1023
|
+
if (directory != null) return directory;
|
|
1024
|
+
const session = await invokeSession(() => client().session.get({ sessionID: binding.session_id })).catch(() => null);
|
|
1025
|
+
if (!session?.error) {
|
|
1026
|
+
const resolved = resolveArchivedDirectory(
|
|
1027
|
+
binding.assistant_id,
|
|
1028
|
+
sessionDirectoryOf(session),
|
|
1029
|
+
session?.data?.directory,
|
|
1030
|
+
session?.data?.project?.worktree,
|
|
1031
|
+
);
|
|
763
1032
|
if (resolved) {
|
|
764
|
-
db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=? AND directory IS NULL')
|
|
1033
|
+
db.prepare('UPDATE assistant_session_history SET directory=? WHERE assistant_id=? AND session_id=? AND directory IS NULL')
|
|
1034
|
+
.run(resolved, binding.assistant_id, binding.session_id);
|
|
765
1035
|
directory = resolved;
|
|
766
1036
|
}
|
|
767
1037
|
}
|
|
768
|
-
|
|
769
|
-
if (isMissing(result)) return completeMissingSessionBackfill(history.assistant_id, history.session_id);
|
|
770
|
-
if (result?.error) fail('upstream_error');
|
|
771
|
-
const entries = Array.isArray(result?.data) ? result.data : Array.isArray(result?.data?.items) ? result.data.items : null;
|
|
772
|
-
if (!entries) fail('upstream_error');
|
|
773
|
-
db.exec('BEGIN IMMEDIATE');
|
|
774
|
-
try {
|
|
775
|
-
entries.forEach((entry) => { const info = entry?.info ?? entry; if (nonEmptyString(info?.sessionID) && info.sessionID !== history.session_id) return; const parts = Array.isArray(entry?.parts) ? entry.parts : []; const messageOrdinal = Number.isSafeInteger(info?.time?.created) ? info.time.created : undefined; mirrorMessage(history.assistant_id, history.session_id, info, messageOrdinal, true); const partIDs = parts.filter((part) => nonEmptyString(part?.id)).map((part) => part.id); if (partIDs.length) db.prepare(`DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id NOT IN (${partIDs.map(() => '?').join(',')})`).run(history.assistant_id, history.session_id, info?.id, ...partIDs); else db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=?').run(history.assistant_id, history.session_id, info?.id); parts.forEach((part, partIndex) => mirrorPart(history.assistant_id, history.session_id, part, partIndex + 1)); });
|
|
776
|
-
const nextCursor = result?.response?.headers?.get('x-next-cursor') ?? null;
|
|
777
|
-
const next = reduceBackfillState({ cursor, complete: false }, { type: 'page', nextCursor });
|
|
778
|
-
// Successful pages only upsert authoritative rows; never delete provisional
|
|
779
|
-
// event mirrors that the snapshot omitted — only 404 / message.removed /
|
|
780
|
-
// assistant deletion may shrink the message set.
|
|
781
|
-
writeBackfillState(history.assistant_id, history.session_id, next);
|
|
782
|
-
db.exec('COMMIT');
|
|
783
|
-
return next.complete;
|
|
784
|
-
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
1038
|
+
return directory;
|
|
785
1039
|
};
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
1040
|
+
/**
|
|
1041
|
+
* Walk one binding from an official upstream position (tip or opaque cursor).
|
|
1042
|
+
* Emits rows in authoritative projection order (order=desc ⇒ newest first).
|
|
1043
|
+
* Host page boundary is `skip` within the current upstream page — no tip rescan.
|
|
1044
|
+
*/
|
|
1045
|
+
const loadProjectionBinding = async (binding, {
|
|
1046
|
+
upstream = null,
|
|
1047
|
+
skip = 0,
|
|
1048
|
+
need = BACKFILL_PAGE_SIZE,
|
|
1049
|
+
maxPages = BACKFILL_MAX_PAGES,
|
|
1050
|
+
pageLimit = BACKFILL_PAGE_SIZE,
|
|
1051
|
+
} = {}) => {
|
|
1052
|
+
const directory = await resolveBindingDirectory(binding);
|
|
1053
|
+
const collected = [];
|
|
1054
|
+
let cursor = upstream;
|
|
1055
|
+
let pageSkip = Math.max(0, skip);
|
|
1056
|
+
let exhausted = false;
|
|
1057
|
+
let failed = false;
|
|
1058
|
+
let failedStatus = null;
|
|
1059
|
+
let pagesUsed = 0;
|
|
1060
|
+
let resumeUpstream = upstream;
|
|
1061
|
+
let resumeSkip = pageSkip;
|
|
1062
|
+
const pageSize = Math.min(BACKFILL_PAGE_SIZE, Math.max(1, pageLimit));
|
|
1063
|
+
|
|
1064
|
+
for (let page = 0; page < maxPages; page++) {
|
|
1065
|
+
resumeUpstream = cursor;
|
|
1066
|
+
resumeSkip = pageSkip;
|
|
1067
|
+
const result = await fetchProjectionMessages(binding.session_id, directory, cursor, pageSize);
|
|
1068
|
+
pagesUsed += 1;
|
|
1069
|
+
if (isMissing(result)) {
|
|
1070
|
+
exhausted = true;
|
|
1071
|
+
return {
|
|
1072
|
+
entries: collected, directory, missing: true, failed: false, failedStatus: null,
|
|
1073
|
+
exhausted: true, pagesUsed, resumeUpstream: null, resumeSkip: 0,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
if (result?.error) {
|
|
1077
|
+
failed = true;
|
|
1078
|
+
failedStatus = messagesErrorStatus(result) ?? result.status ?? 500;
|
|
1079
|
+
break;
|
|
1080
|
+
}
|
|
1081
|
+
const entries = projectionEntries(result, binding.session_id);
|
|
1082
|
+
if (!entries) {
|
|
1083
|
+
failed = true;
|
|
1084
|
+
failedStatus = 500;
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
// Empty page is the authoritative end (non-empty pages may still carry next).
|
|
1088
|
+
if (entries.length === 0) {
|
|
1089
|
+
exhausted = true;
|
|
1090
|
+
resumeUpstream = null;
|
|
1091
|
+
resumeSkip = 0;
|
|
1092
|
+
break;
|
|
1093
|
+
}
|
|
1094
|
+
let index = 0;
|
|
1095
|
+
let stoppedEarly = false;
|
|
1096
|
+
for (; index < entries.length; index++) {
|
|
1097
|
+
if (index < pageSkip) continue;
|
|
1098
|
+
const entry = entries[index];
|
|
1099
|
+
const info = entry?.info ?? entry;
|
|
1100
|
+
if (!plainObject(info) || !nonEmptyString(info.id)) continue;
|
|
1101
|
+
if (nonEmptyString(info.sessionID) && info.sessionID !== binding.session_id) continue;
|
|
1102
|
+
collected.push({
|
|
1103
|
+
session_id: binding.session_id,
|
|
1104
|
+
session_ordinal: binding.ordinal,
|
|
1105
|
+
directory,
|
|
1106
|
+
message_id: info.id,
|
|
1107
|
+
// Source seq within authoritative page order (not time.created).
|
|
1108
|
+
seq: collected.length,
|
|
1109
|
+
info,
|
|
1110
|
+
parts: Array.isArray(entry?.parts) ? entry.parts : [],
|
|
1111
|
+
});
|
|
1112
|
+
if (collected.length >= need) {
|
|
1113
|
+
// Keep resumeSkip on this row when it is the probe past `limit`.
|
|
1114
|
+
stoppedEarly = true;
|
|
1115
|
+
break;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
pageSkip = 0;
|
|
1119
|
+
if (collected.length >= need) {
|
|
1120
|
+
// Page boundary: resume at the probe row (or next upstream page if none left).
|
|
1121
|
+
if (stoppedEarly && index < entries.length) {
|
|
1122
|
+
resumeUpstream = cursor;
|
|
1123
|
+
resumeSkip = index;
|
|
1124
|
+
} else {
|
|
1125
|
+
const next = projectionCursor(result);
|
|
1126
|
+
resumeUpstream = next || null;
|
|
1127
|
+
resumeSkip = 0;
|
|
1128
|
+
// Official: last non-empty page may still expose next; empty follow-up ends.
|
|
1129
|
+
if (!next) exhausted = true;
|
|
1130
|
+
}
|
|
1131
|
+
break;
|
|
1132
|
+
}
|
|
1133
|
+
const next = projectionCursor(result);
|
|
1134
|
+
if (!next) {
|
|
1135
|
+
exhausted = true;
|
|
1136
|
+
resumeUpstream = null;
|
|
1137
|
+
resumeSkip = 0;
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
cursor = next;
|
|
1141
|
+
pageSkip = 0;
|
|
1142
|
+
resumeUpstream = next;
|
|
1143
|
+
resumeSkip = 0;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
return {
|
|
1147
|
+
entries: collected,
|
|
1148
|
+
directory,
|
|
1149
|
+
missing: false,
|
|
1150
|
+
failed,
|
|
1151
|
+
failedStatus,
|
|
1152
|
+
exhausted: exhausted && !failed,
|
|
1153
|
+
pagesUsed,
|
|
1154
|
+
resumeUpstream,
|
|
1155
|
+
resumeSkip,
|
|
802
1156
|
};
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1157
|
+
};
|
|
1158
|
+
const listHistoryBindings = (row) => {
|
|
1159
|
+
const history = db.prepare('SELECT session_id, directory, ordinal FROM assistant_session_history WHERE assistant_id=? ORDER BY ordinal ASC')
|
|
1160
|
+
.all(row.assistant_id);
|
|
1161
|
+
const bindings = history.map((item) => ({
|
|
1162
|
+
assistant_id: row.assistant_id,
|
|
1163
|
+
session_id: item.session_id,
|
|
1164
|
+
directory: item.directory ?? null,
|
|
1165
|
+
ordinal: item.ordinal,
|
|
1166
|
+
}));
|
|
1167
|
+
const currentIsArchived = row.current_session_id != null
|
|
1168
|
+
&& Boolean(db.prepare('SELECT 1 FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(row.assistant_id, row.current_session_id));
|
|
1169
|
+
if (row.current_session_id && !currentIsArchived) {
|
|
1170
|
+
const nextOrdinal = Number(db.prepare('SELECT COALESCE(MAX(ordinal), 0) + 1 AS next FROM assistant_session_history WHERE assistant_id=?').get(row.assistant_id).next);
|
|
1171
|
+
bindings.push({
|
|
1172
|
+
assistant_id: row.assistant_id,
|
|
1173
|
+
session_id: row.current_session_id,
|
|
1174
|
+
directory: effectiveWorkspace(row),
|
|
1175
|
+
ordinal: nextOrdinal,
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
return bindings;
|
|
1179
|
+
};
|
|
1180
|
+
const historicalMessages = async (assistantID, input = {}) => {
|
|
1181
|
+
const row = editable(assistantID);
|
|
1182
|
+
const limit = input.limit == null ? 50 : Number(input.limit);
|
|
1183
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) fail('validation_error');
|
|
1184
|
+
const before = decodeCursor(input.before);
|
|
1185
|
+
const bindings = listHistoryBindings(row);
|
|
1186
|
+
// Newest bindings first; within a binding keep official projection order.
|
|
1187
|
+
const scanOrder = [...bindings].sort((left, right) => right.ordinal - left.ordinal);
|
|
1188
|
+
let startIndex = 0;
|
|
1189
|
+
if (before != null) {
|
|
1190
|
+
const found = scanOrder.findIndex((binding) => binding.ordinal === before.sessionOrdinal);
|
|
1191
|
+
// Binding disappeared (archive GC) — resume at the nearest older binding.
|
|
1192
|
+
startIndex = found >= 0 ? found : scanOrder.findIndex((binding) => binding.ordinal < before.sessionOrdinal);
|
|
1193
|
+
if (startIndex < 0) startIndex = scanOrder.length;
|
|
1194
|
+
}
|
|
1195
|
+
const collected = [];
|
|
1196
|
+
const failedScope = [];
|
|
1197
|
+
let anySuccess = false;
|
|
1198
|
+
let pagesUsed = 0;
|
|
1199
|
+
let nextState = null;
|
|
1200
|
+
|
|
1201
|
+
for (let index = startIndex; index < scanOrder.length; index++) {
|
|
1202
|
+
if (collected.length > limit) break;
|
|
1203
|
+
if (pagesUsed >= BACKFILL_MAX_PAGES) {
|
|
1204
|
+
const binding = scanOrder[index];
|
|
1205
|
+
nextState = {
|
|
1206
|
+
sessionOrdinal: binding.ordinal,
|
|
1207
|
+
upstream: index === startIndex && before ? before.upstream : null,
|
|
1208
|
+
skip: index === startIndex && before ? before.skip : 0,
|
|
1209
|
+
};
|
|
1210
|
+
break;
|
|
1211
|
+
}
|
|
1212
|
+
const binding = scanOrder[index];
|
|
1213
|
+
const resumeHere = index === startIndex && before != null;
|
|
1214
|
+
const loaded = await loadProjectionBinding(binding, {
|
|
1215
|
+
upstream: resumeHere ? before.upstream : null,
|
|
1216
|
+
skip: resumeHere ? before.skip : 0,
|
|
1217
|
+
need: limit + 1 - collected.length,
|
|
1218
|
+
maxPages: BACKFILL_MAX_PAGES - pagesUsed,
|
|
1219
|
+
pageLimit: limit,
|
|
1220
|
+
});
|
|
1221
|
+
pagesUsed += loaded.pagesUsed;
|
|
1222
|
+
if (loaded.failed) {
|
|
1223
|
+
failedScope.push({
|
|
1224
|
+
sessionID: binding.session_id,
|
|
1225
|
+
sessionOrdinal: binding.ordinal,
|
|
1226
|
+
status: loaded.failedStatus ?? 500,
|
|
1227
|
+
});
|
|
1228
|
+
// Do not advance past a failed binding — cursor stays retryable here.
|
|
1229
|
+
// Newer successes already in `collected` are still delivered.
|
|
1230
|
+
nextState = {
|
|
1231
|
+
sessionOrdinal: binding.ordinal,
|
|
1232
|
+
upstream: loaded.resumeUpstream,
|
|
1233
|
+
skip: loaded.resumeSkip,
|
|
1234
|
+
};
|
|
1235
|
+
break;
|
|
1236
|
+
}
|
|
1237
|
+
if (loaded.missing) {
|
|
1238
|
+
anySuccess = true;
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
anySuccess = true;
|
|
1242
|
+
collected.push(...loaded.entries);
|
|
1243
|
+
if (collected.length > limit) {
|
|
1244
|
+
// Overshot: drop the probe row and resume at the page boundary of the last binding load.
|
|
1245
|
+
nextState = {
|
|
1246
|
+
sessionOrdinal: binding.ordinal,
|
|
1247
|
+
upstream: loaded.resumeUpstream,
|
|
1248
|
+
skip: loaded.resumeSkip,
|
|
1249
|
+
};
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
if (!loaded.exhausted) {
|
|
1253
|
+
nextState = {
|
|
1254
|
+
sessionOrdinal: binding.ordinal,
|
|
1255
|
+
upstream: loaded.resumeUpstream,
|
|
1256
|
+
skip: loaded.resumeSkip,
|
|
1257
|
+
};
|
|
818
1258
|
break;
|
|
819
1259
|
}
|
|
820
|
-
|
|
1260
|
+
// Binding exhausted — fall through to the next older binding.
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// Total failure must not masquerade as authoritative empty success.
|
|
1264
|
+
if (collected.length === 0 && failedScope.length > 0 && !anySuccess) fail('upstream_error');
|
|
1265
|
+
|
|
1266
|
+
const partial = failedScope.length > 0;
|
|
1267
|
+
// collected is newest-first (binding order + desc projection). Public page is ascending.
|
|
1268
|
+
const pageDesc = collected.slice(0, limit);
|
|
1269
|
+
let nextCursor = nextState != null ? encodeCursor(nextState) : null;
|
|
1270
|
+
if (nextCursor == null && partial && failedScope[0]) {
|
|
1271
|
+
nextCursor = encodeCursor({
|
|
1272
|
+
sessionOrdinal: failedScope[0].sessionOrdinal,
|
|
1273
|
+
upstream: null,
|
|
1274
|
+
skip: 0,
|
|
1275
|
+
});
|
|
821
1276
|
}
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
1277
|
+
// complete only when nothing remains and no partial failure left to retry.
|
|
1278
|
+
const complete = nextCursor == null && !partial;
|
|
1279
|
+
const ordered = [...pageDesc].reverse().map((entry) => ({
|
|
1280
|
+
sessionID: entry.session_id,
|
|
1281
|
+
directory: entry.directory,
|
|
1282
|
+
info: entry.info,
|
|
1283
|
+
parts: entry.parts,
|
|
1284
|
+
}));
|
|
1285
|
+
return {
|
|
1286
|
+
entries: ordered,
|
|
1287
|
+
nextCursor: complete ? null : nextCursor,
|
|
1288
|
+
complete,
|
|
1289
|
+
partial,
|
|
1290
|
+
...(partial ? { failed: failedScope } : {}),
|
|
1291
|
+
};
|
|
825
1292
|
};
|
|
826
|
-
for (const current of db.prepare('SELECT * FROM assistant_v2 WHERE current_session_id IS NOT NULL AND tombstone_at IS NULL').all()) void backfillSession({ assistant_id: current.assistant_id, session_id: current.current_session_id, directory: effectiveWorkspace(current) }).catch(() => {});
|
|
827
1293
|
const unsubscribeEvents = typeof globalEventHub?.subscribeEvent === 'function' ? globalEventHub.subscribeEvent(processEvent) : null;
|
|
828
1294
|
const createAssistant = (input) => { const allowed = new Set(['enabled', 'name', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const mode = input.mode == null ? 'continuous' : input.mode === 'stateless' || input.mode === 'continuous' ? input.mode : fail('validation_error'); const assistantID = id(); const workspacePath = input.workspacePath == null ? null : workspace(input.workspacePath, assistantID); effectiveWorkspace({ assistant_id: assistantID, workspace_path: workspacePath }); const at = now(); db.exec('BEGIN IMMEDIATE'); try { if (Number(db.prepare('SELECT COUNT(*) AS count FROM assistant_v2 WHERE tombstone_at IS NULL').get().count) >= 100) fail('assistant_limit'); db.prepare('INSERT INTO assistant_v2 (assistant_id,revision,enabled,name,default_prompt,workspace_path,provider_id,model_id,agent,variant,mode,current_session_id,session_generation,created_at,updated_at,tombstone_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)').run(assistantID, 1, input.enabled === false ? 0 : 1, string(input.name, 256, true), input.defaultPrompt ? string(input.defaultPrompt, 200_000) : '', workspacePath, string(input.providerID, 256, true), string(input.modelID, 256, true), input.agent == null ? null : string(input.agent, 256), input.variant == null ? null : string(input.variant, 256), mode, null, 0, at, at, null); bump(); db.exec('COMMIT'); return output(assistant(assistantID)); } catch (error) { db.exec('ROLLBACK'); throw error; } };
|
|
829
1295
|
const updateAssistant = async (assistantID, input) => { const row = editable(assistantID); const allowed = new Set(['expectedRevision', 'enabled', 'name', 'defaultPrompt', 'providerID', 'modelID', 'agent', 'variant', 'mode', 'workspacePath']); if (!plainObject(input) || !Number.isInteger(input.expectedRevision) || Object.keys(input).some((key) => !allowed.has(key))) fail('validation_error'); const next = { enabled: input.enabled === undefined ? row.enabled : input.enabled ? 1 : 0, name: input.name === undefined ? row.name : string(input.name, 256, true), prompt: input.defaultPrompt === undefined ? row.default_prompt : string(input.defaultPrompt, 200_000), provider: input.providerID === undefined ? row.provider_id : string(input.providerID, 256, true), model: input.modelID === undefined ? row.model_id : string(input.modelID, 256, true), agent: input.agent === undefined ? row.agent : input.agent === null ? null : string(input.agent, 256), variant: input.variant === undefined ? row.variant : input.variant === null ? null : string(input.variant, 256), mode: input.mode === undefined ? (row.mode === 'stateless' ? 'stateless' : 'continuous') : input.mode === 'continuous' || input.mode === 'stateless' ? input.mode : fail('validation_error'), workspacePath: input.workspacePath === undefined ? row.workspace_path : input.workspacePath == null ? null : workspace(input.workspacePath, assistantID) }; const nextRow = { ...row, workspace_path: next.workspacePath, name: next.name }; effectiveWorkspace(nextRow); const workspaceChanged = next.workspacePath !== row.workspace_path; if (workspaceChanged && row.current_session_id) archiveSession(assistantID, row.current_session_id); const created = workspaceChanged ? await createSession(nextRow) : null; const result = db.prepare('UPDATE assistant_v2 SET enabled=?,name=?,default_prompt=?,provider_id=?,model_id=?,agent=?,variant=?,mode=?,workspace_path=?,current_session_id=?,session_generation=session_generation+?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND session_generation=? AND tombstone_at IS NULL').run(next.enabled, next.name, next.prompt, next.provider, next.model, next.agent, next.variant, next.mode, next.workspacePath, created?.sessionID ?? row.current_session_id, workspaceChanged ? 1 : 0, now(), assistantID, input.expectedRevision, row.session_generation); if (!result.changes) fail('revision_conflict'); bump(); return output(assistant(assistantID)); };
|
|
830
|
-
const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await client().session.
|
|
1296
|
+
const compact = async (assistantID, input) => { const row = active(assistantID); if (!plainObject(input) || input.sessionID !== row.current_session_id || input.sessionGeneration !== row.session_generation || !row.current_session_id) fail('revision_conflict'); let target = row; let result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); if (isMissing(result)) { await restoreOnce(row, row.current_session_id, row.session_generation); target = active(assistantID); result = await invokeSession(() => client().session.compact({ sessionID: target.current_session_id })); } if (result?.error || (result?.data !== true && result?.id == null && result?.type !== 'compaction')) fail('upstream_error'); return { binding: binding(active(assistantID)), summarized: true }; };
|
|
831
1297
|
const sendWithConfig = async ({ row, sessionID, directory, config, parts, messageID, restore }) => {
|
|
832
|
-
const sendPrompt = (targetSessionID,
|
|
833
|
-
|
|
1298
|
+
const sendPrompt = async (targetSessionID, targetConfig) => invokeSession(async () => {
|
|
1299
|
+
const api = client();
|
|
1300
|
+
if (typeof api.session.switchAgent === 'function' && targetConfig?.agent) await api.session.switchAgent({ sessionID: targetSessionID, agent: targetConfig.agent });
|
|
1301
|
+
if (typeof api.session.switchModel === 'function' && targetConfig?.model) await api.session.switchModel({ sessionID: targetSessionID, model: { id: targetConfig.model.modelID, providerID: targetConfig.model.providerID, ...(targetConfig.variant ? { variant: targetConfig.variant } : {}) } });
|
|
1302
|
+
if (typeof api.session.instructions?.entry?.put === 'function' && typeof targetConfig?.system === 'string' && targetConfig.system) await api.session.instructions.entry.put({ sessionID: targetSessionID, key: 'system', value: targetConfig.system });
|
|
1303
|
+
return api.session.prompt(toV2PromptInput(targetSessionID, parts, messageID));
|
|
1304
|
+
});
|
|
1305
|
+
let result = await sendPrompt(sessionID, config);
|
|
834
1306
|
if (isMissing(result) && restore) {
|
|
835
1307
|
const restored = await restoreOnce(row, sessionID, row.session_generation);
|
|
836
1308
|
const target = active(row.assistant_id);
|
|
837
1309
|
const targetConfig = configuration(target);
|
|
838
|
-
result = await sendPrompt(restored.sessionID,
|
|
839
|
-
if (promptAdmitted(result)) mirrorAdmittedUserMessage(target, restored.sessionID, messageID, parts, targetConfig);
|
|
1310
|
+
result = await sendPrompt(restored.sessionID, targetConfig);
|
|
840
1311
|
return { result, binding: restored };
|
|
841
1312
|
}
|
|
842
|
-
if (promptAdmitted(result)) mirrorAdmittedUserMessage(row, sessionID, messageID, parts, config);
|
|
843
1313
|
return { result, binding: binding(row) };
|
|
844
1314
|
};
|
|
845
1315
|
const extractUserText = (input) => {
|
|
@@ -1012,12 +1482,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1012
1482
|
parts: [card],
|
|
1013
1483
|
});
|
|
1014
1484
|
if (card.cardType === 'session' && card.sessionID) {
|
|
1015
|
-
|
|
1485
|
+
const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
|
|
1486
|
+
|| (nonEmptyString(card.messageID) ? card.messageID : null);
|
|
1487
|
+
if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
|
|
1488
|
+
upsertContactWatch(db, {
|
|
1016
1489
|
assistantID,
|
|
1017
1490
|
sessionID: card.sessionID,
|
|
1018
1491
|
directory: card.directory,
|
|
1019
1492
|
status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
|
|
1020
1493
|
updatedAt: now(),
|
|
1494
|
+
...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
|
|
1021
1495
|
});
|
|
1022
1496
|
}
|
|
1023
1497
|
bump();
|
|
@@ -1115,12 +1589,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1115
1589
|
});
|
|
1116
1590
|
ordinal += 1;
|
|
1117
1591
|
if (card.cardType === 'session' && card.sessionID) {
|
|
1118
|
-
|
|
1592
|
+
const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
|
|
1593
|
+
|| (nonEmptyString(card.messageID) ? card.messageID : null);
|
|
1594
|
+
if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
|
|
1595
|
+
upsertContactWatch(db, {
|
|
1119
1596
|
assistantID,
|
|
1120
1597
|
sessionID: card.sessionID,
|
|
1121
1598
|
directory: card.directory,
|
|
1122
1599
|
status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
|
|
1123
1600
|
updatedAt: now(),
|
|
1601
|
+
...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
|
|
1124
1602
|
});
|
|
1125
1603
|
}
|
|
1126
1604
|
});
|
|
@@ -1259,16 +1737,19 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1259
1737
|
};
|
|
1260
1738
|
const v2 = await viaExact(api?.v2?.session?.message, api?.v2?.session);
|
|
1261
1739
|
if (v2) return v2;
|
|
1740
|
+
// Official 2.x: session.message.get; shim may also expose callable session.message.
|
|
1741
|
+
const nestedGet = await viaExact(api?.session?.message?.get, api?.session?.message);
|
|
1742
|
+
if (nestedGet) return nestedGet;
|
|
1262
1743
|
const legacy = await viaExact(api?.session?.message, api?.session);
|
|
1263
1744
|
if (legacy) return legacy;
|
|
1264
1745
|
try {
|
|
1265
|
-
const listed = await api.
|
|
1746
|
+
const listed = await api.message.list({
|
|
1266
1747
|
sessionID,
|
|
1267
|
-
...(directory ? { directory } : {}),
|
|
1268
1748
|
limit: 100,
|
|
1749
|
+
order: 'desc',
|
|
1269
1750
|
}, options);
|
|
1270
1751
|
if (listed?.error) return { found: false };
|
|
1271
|
-
const rows = Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []);
|
|
1752
|
+
const rows = projectionEntries(listed, sessionID) ?? (Array.isArray(listed?.data) ? listed.data : (Array.isArray(listed) ? listed : []));
|
|
1272
1753
|
const hit = rows.some((row) => identity(row) === messageID);
|
|
1273
1754
|
return { found: hit };
|
|
1274
1755
|
} catch {
|
|
@@ -1295,14 +1776,14 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1295
1776
|
const fromSession = extractAssignSessionModel({ session: sessionPayload });
|
|
1296
1777
|
if (fromSession) return fromSession;
|
|
1297
1778
|
try {
|
|
1298
|
-
if (typeof api.
|
|
1299
|
-
const listed = await api.
|
|
1779
|
+
if (typeof api.message?.list !== 'function') return null;
|
|
1780
|
+
const listed = await api.message.list({
|
|
1300
1781
|
sessionID,
|
|
1301
|
-
...(dir ? { directory: dir } : {}),
|
|
1302
1782
|
limit: 40,
|
|
1783
|
+
order: 'desc',
|
|
1303
1784
|
}, options);
|
|
1304
1785
|
if (listed?.error) return null;
|
|
1305
|
-
messages = listed?.data ?? listed;
|
|
1786
|
+
messages = projectionEntries(listed, sessionID) ?? listed?.data ?? listed;
|
|
1306
1787
|
} catch {
|
|
1307
1788
|
return null;
|
|
1308
1789
|
}
|
|
@@ -1456,12 +1937,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1456
1937
|
}
|
|
1457
1938
|
|
|
1458
1939
|
let messagesResult = null;
|
|
1459
|
-
if (includeMessages && typeof client().
|
|
1940
|
+
if (includeMessages && typeof client().message?.list === 'function') {
|
|
1460
1941
|
try {
|
|
1461
|
-
messagesResult = await client().
|
|
1942
|
+
messagesResult = await client().message.list({
|
|
1462
1943
|
sessionID,
|
|
1463
|
-
directory,
|
|
1464
1944
|
limit: 100,
|
|
1945
|
+
order: 'desc',
|
|
1465
1946
|
}, options);
|
|
1466
1947
|
if (messagesResult?.error && !isMissing(messagesResult)) {
|
|
1467
1948
|
// Message fetch failure must not invent status; keep messages null.
|
|
@@ -1499,14 +1980,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1499
1980
|
const { signal } = params;
|
|
1500
1981
|
signal?.throwIfAborted();
|
|
1501
1982
|
const resolved = await resolveReferencedSession({ sessionID: params.sessionID, signal, includeMessages: false });
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1983
|
+
// First page: order=desc. Continuation: opaque cursor only (upstream 400s on both).
|
|
1984
|
+
const result = await invokeSession(() => client().message.list({
|
|
1985
|
+
sessionID: resolved.sessionID, limit,
|
|
1986
|
+
...(params.before ? { cursor: params.before } : { order: 'desc' }),
|
|
1987
|
+
}));
|
|
1506
1988
|
signal?.throwIfAborted();
|
|
1507
1989
|
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, 'That session no longer exists.');
|
|
1508
|
-
|
|
1509
|
-
|
|
1990
|
+
const projected = result?.error ? null : projectionEntries(result, resolved.sessionID);
|
|
1991
|
+
if (!projected) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Could not read session messages.');
|
|
1992
|
+
// Desc pages arrive newest-first; quote them oldest→newest.
|
|
1993
|
+
const rows = projected.slice().reverse();
|
|
1510
1994
|
if (rows.some((entry) => (entry?.info?.sessionID && entry.info.sessionID !== resolved.sessionID))) throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session message identity mismatch.');
|
|
1511
1995
|
let remaining = 24000;
|
|
1512
1996
|
let partial = rows.length > limit;
|
|
@@ -1532,8 +2016,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1532
2016
|
}
|
|
1533
2017
|
return { messageID: info.id, role: info.role, parts };
|
|
1534
2018
|
});
|
|
1535
|
-
const
|
|
1536
|
-
const nextCursor = typeof cursor === 'string' && cursor ? cursor : null;
|
|
2019
|
+
const nextCursor = projectionCursor(result);
|
|
1537
2020
|
return { sessionID: resolved.sessionID, directory: resolved.directory, title: resolved.title.slice(0, 500), messages, nextCursor, partial: partial || nextCursor !== null };
|
|
1538
2021
|
};
|
|
1539
2022
|
const assignWork = async (row, params = {}) => {
|
|
@@ -1614,7 +2097,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1614
2097
|
}
|
|
1615
2098
|
// Worker model is prompt-only. Never mutate the contact assistant row.
|
|
1616
2099
|
// Forward tool variant + AbortSignal; lookupMessage uses real SDK exact/list paths.
|
|
1617
|
-
|
|
2100
|
+
const assigned = await assignSession({
|
|
1618
2101
|
...params,
|
|
1619
2102
|
...(reuseScope ? {
|
|
1620
2103
|
sessionID: reuseScope.sessionID,
|
|
@@ -1641,16 +2124,43 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1641
2124
|
allowedRoots: getAllowedRoots(),
|
|
1642
2125
|
managedWorkspaceRoot: path.resolve(dataDir, 'assistant-workspaces'),
|
|
1643
2126
|
listWorktrees,
|
|
1644
|
-
createSession: (created) => client().session.create(
|
|
1645
|
-
|
|
2127
|
+
createSession: (created) => client().session.create({
|
|
2128
|
+
title: created?.title,
|
|
2129
|
+
...(created?.directory ? { location: { directory: created.directory } } : {}),
|
|
2130
|
+
...(created?.agent ? { agent: created.agent } : {}),
|
|
2131
|
+
...(created?.model ? { model: created.model } : {}),
|
|
2132
|
+
...(created?.metadata ? { metadata: created.metadata } : {}),
|
|
2133
|
+
}),
|
|
2134
|
+
promptExisting: async (prompted) => {
|
|
2135
|
+
const api = client();
|
|
2136
|
+
if (typeof api.session.switchAgent === 'function' && prompted.agent) {
|
|
2137
|
+
await api.session.switchAgent({ sessionID: prompted.sessionID, agent: prompted.agent });
|
|
2138
|
+
}
|
|
2139
|
+
if (typeof api.session.switchModel === 'function' && prompted.model) {
|
|
2140
|
+
await api.session.switchModel({
|
|
2141
|
+
sessionID: prompted.sessionID,
|
|
2142
|
+
model: {
|
|
2143
|
+
id: prompted.model.modelID,
|
|
2144
|
+
providerID: prompted.model.providerID,
|
|
2145
|
+
...(prompted.variant ? { variant: prompted.variant } : {}),
|
|
2146
|
+
},
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
return api.session.prompt(toV2PromptInput(prompted.sessionID, prompted.parts, prompted.messageID));
|
|
2150
|
+
},
|
|
1646
2151
|
deleteSession: (deleted) => (
|
|
1647
|
-
typeof client().session.
|
|
1648
|
-
? client().session.
|
|
2152
|
+
typeof client().session.remove === 'function'
|
|
2153
|
+
? client().session.remove({ sessionID: deleted?.sessionID, ...(deleted?.directory ? {} : {}) })
|
|
1649
2154
|
: undefined
|
|
1650
2155
|
),
|
|
1651
2156
|
lookupMessage: (lookup) => lookupAssignMessage({ ...lookup, signal }),
|
|
1652
2157
|
messageID: params?.messageID || `msg_assign_${id()}`,
|
|
1653
2158
|
});
|
|
2159
|
+
// Stash until the session card watch is written so reuse cannot settle on an old tail.
|
|
2160
|
+
if (nonEmptyString(assigned?.sessionID) && nonEmptyString(assigned?.messageID)) {
|
|
2161
|
+
pendingAssignMessageIDs.set(assigned.sessionID, assigned.messageID);
|
|
2162
|
+
}
|
|
2163
|
+
return assigned;
|
|
1654
2164
|
};
|
|
1655
2165
|
/** Watch an existing session only — no prompt. Baseline status avoids false settle resume. */
|
|
1656
2166
|
const watchSessionWork = async (_row, params = {}) => {
|
|
@@ -1675,7 +2185,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1675
2185
|
reused: true,
|
|
1676
2186
|
};
|
|
1677
2187
|
};
|
|
1678
|
-
/** Abort an existing coding session via real OpenCode session.
|
|
2188
|
+
/** Abort an existing coding session via real OpenCode session.interrupt. */
|
|
1679
2189
|
const stopSessionWork = async (_row, params = {}) => {
|
|
1680
2190
|
const sessionID = typeof params.sessionID === 'string' ? params.sessionID.trim() : '';
|
|
1681
2191
|
if (!sessionID) {
|
|
@@ -1690,8 +2200,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1690
2200
|
});
|
|
1691
2201
|
const signal = params.signal;
|
|
1692
2202
|
signal?.throwIfAborted();
|
|
1693
|
-
if (typeof client().session.
|
|
1694
|
-
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session
|
|
2203
|
+
if (typeof client().session.interrupt !== 'function') {
|
|
2204
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session interrupt is unavailable.');
|
|
1695
2205
|
}
|
|
1696
2206
|
const targetID = resolved.sessionID;
|
|
1697
2207
|
// Share one abort lifecycle per worker session (register before await abort).
|
|
@@ -1719,10 +2229,9 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1719
2229
|
let result;
|
|
1720
2230
|
try {
|
|
1721
2231
|
signal?.throwIfAborted();
|
|
1722
|
-
result = await client().session.
|
|
2232
|
+
result = await invokeSession(() => client().session.interrupt({
|
|
1723
2233
|
sessionID: targetID,
|
|
1724
|
-
|
|
1725
|
-
}, sdkOptions);
|
|
2234
|
+
}));
|
|
1726
2235
|
} catch (error) {
|
|
1727
2236
|
const observed = entry.observed;
|
|
1728
2237
|
if (sessionAbortInflight.get(targetID) === entry) sessionAbortInflight.delete(targetID);
|
|
@@ -1784,27 +2293,69 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
1784
2293
|
}
|
|
1785
2294
|
const resolved = await resolveReferencedSession({ sessionID, signal: params.signal, includeMessages: false });
|
|
1786
2295
|
params.signal?.throwIfAborted();
|
|
1787
|
-
const
|
|
1788
|
-
const method = operation === 'steer' ? 'promptAsync' : operation === 'archive' ? 'update' : 'delete';
|
|
1789
|
-
if (typeof session[method] !== 'function') throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} is unavailable.`);
|
|
1790
|
-
const request = { sessionID, directory: resolved.directory };
|
|
1791
|
-
if (operation === 'steer') Object.assign(request, {
|
|
1792
|
-
messageID: `msg_steer_${id()}`, delivery: 'steer', parts: [{ type: 'text', text: params.text.trim() }],
|
|
1793
|
-
});
|
|
1794
|
-
if (operation === 'archive') request.time = { archived: now() };
|
|
2296
|
+
const api = client();
|
|
1795
2297
|
let result;
|
|
1796
2298
|
try {
|
|
1797
|
-
|
|
2299
|
+
if (operation === 'steer') {
|
|
2300
|
+
const messageID = `msg_steer_${id()}`;
|
|
2301
|
+
const parts = [{ type: 'text', text: params.text.trim() }];
|
|
2302
|
+
result = await invokeSession(() => api.session.prompt(toV2PromptInput(sessionID, parts, messageID)));
|
|
2303
|
+
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
|
|
2304
|
+
if (result?.error || !promptAdmitted(result)) {
|
|
2305
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
|
|
2306
|
+
}
|
|
2307
|
+
return { sessionID, directory: resolved.directory, operation, messageID, admitted: true };
|
|
2308
|
+
}
|
|
2309
|
+
if (operation === 'archive') {
|
|
2310
|
+
// OC2: Host owns archive stamps (openchamber.archive.archivedAt). Do not
|
|
2311
|
+
// call upstream session.update time.archived — it is unavailable.
|
|
2312
|
+
if (typeof archiveSessionHost !== 'function') {
|
|
2313
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session archive is unavailable.');
|
|
2314
|
+
}
|
|
2315
|
+
result = await archiveSessionHost({
|
|
2316
|
+
sessionID,
|
|
2317
|
+
directory: resolved.directory,
|
|
2318
|
+
archivedAt: now(),
|
|
2319
|
+
});
|
|
2320
|
+
} else if (operation === 'delete') {
|
|
2321
|
+
if (typeof api.session.remove !== 'function') {
|
|
2322
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, 'Session delete is unavailable.');
|
|
2323
|
+
}
|
|
2324
|
+
result = await invokeSession(() => api.session.remove({ sessionID }));
|
|
2325
|
+
} else {
|
|
2326
|
+
throw new AssignError(ASSIGN_CODES.VALIDATION, `Unknown session operation ${operation}.`);
|
|
2327
|
+
}
|
|
1798
2328
|
} catch (error) {
|
|
2329
|
+
if (error instanceof AssignError) throw error;
|
|
1799
2330
|
throw new AssignError(ASSIGN_CODES.UPSTREAM, error?.message || `Session ${operation} failed.`);
|
|
1800
2331
|
}
|
|
2332
|
+
if (operation === 'archive') {
|
|
2333
|
+
if (!result?.session?.id) {
|
|
2334
|
+
throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
|
|
2335
|
+
}
|
|
2336
|
+
reportAssignedSession(sessionID, 'cancelled', false);
|
|
2337
|
+
return { sessionID, directory: resolved.directory, operation, archived: true };
|
|
2338
|
+
}
|
|
1801
2339
|
if (isMissing(result)) throw new AssignError(ASSIGN_CODES.NOT_FOUND, `No coding session ${sessionID} was found.`);
|
|
1802
|
-
if (result?.error || result?.data === false
|
|
1803
|
-
// Do not retry an ambiguous prompt submission: it may already be admitted upstream.
|
|
2340
|
+
if (result?.error || result?.data === false) {
|
|
1804
2341
|
throw new AssignError(ASSIGN_CODES.UPSTREAM, `Session ${operation} was not confirmed. Do not resend automatically.`);
|
|
1805
2342
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
2343
|
+
// Successful delete: direct idempotent Host metadata cleanup; session.deleted
|
|
2344
|
+
// events remain compensatory. Upstream failure never reaches here.
|
|
2345
|
+
if (typeof forgetSessionHost === 'function') {
|
|
2346
|
+
try {
|
|
2347
|
+
const cleanup = forgetSessionHost(sessionID);
|
|
2348
|
+
if (cleanup && typeof cleanup.catch === 'function') {
|
|
2349
|
+
cleanup.catch((error) => {
|
|
2350
|
+
console.warn('[assistants] metadata cleanup after delete failed (retry via event):', error?.message ?? error);
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
console.warn('[assistants] metadata cleanup after delete failed (retry via event):', error?.message ?? error);
|
|
2355
|
+
}
|
|
2356
|
+
}
|
|
2357
|
+
reportAssignedSession(sessionID, 'cancelled', false);
|
|
2358
|
+
return { sessionID, directory: resolved.directory, operation, deleted: true };
|
|
1808
2359
|
};
|
|
1809
2360
|
const matchRegisteredProject = async (directory) => {
|
|
1810
2361
|
const projects = await listProjects();
|
|
@@ -2192,11 +2743,11 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2192
2743
|
try { return effectiveWorkspace(live); } catch { return null; }
|
|
2193
2744
|
})();
|
|
2194
2745
|
if (resolvedDirectory) {
|
|
2195
|
-
const messagesResult = await client().
|
|
2746
|
+
const messagesResult = await invokeSession(() => client().message.list({
|
|
2196
2747
|
sessionID,
|
|
2197
|
-
directory: resolvedDirectory,
|
|
2198
2748
|
limit: 100,
|
|
2199
|
-
|
|
2749
|
+
order: 'desc',
|
|
2750
|
+
}));
|
|
2200
2751
|
if (!messagesResult?.error) {
|
|
2201
2752
|
const rows = Array.isArray(messagesResult?.data)
|
|
2202
2753
|
? messagesResult.data
|
|
@@ -2959,12 +3510,16 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2959
3510
|
parts: [card],
|
|
2960
3511
|
});
|
|
2961
3512
|
if (card.cardType === 'session' && card.sessionID) {
|
|
3513
|
+
const deliveryMessageID = pendingAssignMessageIDs.get(card.sessionID)
|
|
3514
|
+
|| (nonEmptyString(card.messageID) ? card.messageID : null);
|
|
3515
|
+
if (pendingAssignMessageIDs.has(card.sessionID)) pendingAssignMessageIDs.delete(card.sessionID);
|
|
2962
3516
|
upsertContactWatch(db, {
|
|
2963
3517
|
assistantID: row.assistant_id,
|
|
2964
3518
|
sessionID: card.sessionID,
|
|
2965
3519
|
directory: card.directory,
|
|
2966
3520
|
status: card.status === 'error' || card.status === 'question' || card.status === 'complete' ? card.status : 'busy',
|
|
2967
3521
|
updatedAt: now(),
|
|
3522
|
+
...(deliveryMessageID ? { messageID: deliveryMessageID } : {}),
|
|
2968
3523
|
});
|
|
2969
3524
|
}
|
|
2970
3525
|
bump();
|
|
@@ -2977,7 +3532,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
2977
3532
|
};
|
|
2978
3533
|
/**
|
|
2979
3534
|
* Read-only inter-assistant DM. Inserts into the recipient's OpenChamber
|
|
2980
|
-
* contact transcript only. Must never call OpenCode
|
|
3535
|
+
* contact transcript only. Must never call OpenCode session.prompt, mutate
|
|
2981
3536
|
* sessions/files/worktrees, or run tools on the recipient's behalf.
|
|
2982
3537
|
*
|
|
2983
3538
|
* Assign opens a worker session on the sender's contact turn — never here.
|
|
@@ -3068,17 +3623,17 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3068
3623
|
db.prepare('UPDATE assistant_contact_watch SET resume_allowed=0 WHERE assistant_id=?').run(assistantID);
|
|
3069
3624
|
if (interrupted) return { binding: binding(row), aborted: true };
|
|
3070
3625
|
if (!row.current_session_id) fail('revision_conflict');
|
|
3071
|
-
const result = await client().session.
|
|
3626
|
+
const result = await invokeSession(() => client().session.interrupt({ sessionID: row.current_session_id }));
|
|
3072
3627
|
if (isMissing(result)) fail('not_found');
|
|
3073
|
-
if (result
|
|
3628
|
+
if (result?.error) fail('upstream_error');
|
|
3074
3629
|
return { binding: binding(row), aborted: true };
|
|
3075
3630
|
};
|
|
3076
3631
|
const createNew = async (assistantID) => { for (let attempt = 0; attempt < 3; attempt++) { const row = active(assistantID); const created = await createSession(row); const won = replaceBinding(row, created); if (won) return binding(won); } return binding(active(assistantID)); };
|
|
3077
3632
|
const shareOperation = (operationID) => { const row = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); return row && { operationID: row.operation_id, assistantID: row.assistant_id, sessionID: row.session_id, messageID: row.message_id, state: row.state, phase: row.phase, attempt: row.attempt, leaseExpiresAt: row.lease_expires_at, errorCode: row.error_code }; };
|
|
3078
3633
|
const claim = (operationID, retry = false) => { db.exec('BEGIN IMMEDIATE'); try { const operation = db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID); if (!operation) { db.exec('COMMIT'); return null; } const at = now(); const eligible = operation.state === 'failed' ? retry : operation.state === 'running' && operation.lease_expires_at <= at && operation.phase === 'admitted'; if (!eligible || operation.attempt >= SHARE_MAX_ATTEMPTS) { db.exec('COMMIT'); return null; } const result = db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitting',attempt=attempt+1,lease_expires_at=?,error_code=NULL,updated_at=? WHERE operation_id=? AND state=? AND phase=? AND attempt<? AND (state='failed' OR lease_expires_at<=?)").run(at + SHARE_LEASE_MS, at, operationID, operation.state, operation.phase, SHARE_MAX_ATTEMPTS, at); const claimed = result.changes ? db.prepare('SELECT * FROM assistant_share_operation WHERE operation_id=?').get(operationID) : null; db.exec('COMMIT'); return claimed; } catch (error) { db.exec('ROLLBACK'); throw error; } };
|
|
3079
3634
|
const completeOrFail = (operation, errorCode = null) => { const at = now(); if (errorCode) db.prepare("UPDATE assistant_share_operation SET state='failed',phase='admitted',error_code=?,lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(errorCode, at, operation.operation_id); else db.prepare("UPDATE assistant_share_operation SET state='running',phase='submitted',error_code=NULL,lease_expires_at=?,updated_at=? WHERE operation_id=? AND state='running' AND phase='submitting'").run(at + SHARE_LEASE_MS, at, operation.operation_id); };
|
|
3080
|
-
const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const
|
|
3081
|
-
const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await client().
|
|
3635
|
+
const submitClaim = async (operation) => { const payload = parse(operation.response); const row = active(operation.assistant_id); try { const config = configuration(row); const sent = await sendWithConfig({ row, sessionID: operation.session_id, directory: effectiveWorkspace(row), config, parts: payload.parts, messageID: operation.message_id, restore: false }); if (!promptAdmitted(sent.result)) fail('upstream_error'); completeOrFail(operation); } catch (error) { completeOrFail(operation, error instanceof AssistantError ? error.code : 'upstream_error'); } };
|
|
3636
|
+
const reconcileShareOperations = async () => { const candidates = db.prepare("SELECT * FROM assistant_share_operation WHERE state='running'").all(); for (const operation of candidates) { const row = assistant(operation.assistant_id); if (!row || !operation.session_id || !operation.message_id) continue; try { const result = await invokeSession(() => client().message.list({ sessionID: operation.session_id, limit: 100, order: 'desc' })); if (result?.error) continue; const messages = projectionEntries(result, operation.session_id) ?? result.data ?? []; const found = Array.isArray(messages) && messages.some((message) => message?.info?.id === operation.message_id || message?.id === operation.message_id); if (found) db.prepare("UPDATE assistant_share_operation SET state='completed',phase='submitted',lease_expires_at=NULL,updated_at=? WHERE operation_id=? AND state='running'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now() && operation.attempt >= SHARE_MAX_ATTEMPTS) db.prepare("UPDATE assistant_share_operation SET state='failed',lease_expires_at=NULL,error_code='attempt_limit',updated_at=? WHERE operation_id=? AND state='running' AND phase='admitted'").run(now(), operation.operation_id); else if (operation.phase === 'admitted' && operation.lease_expires_at <= now()) { const claimed = claim(operation.operation_id); if (claimed) void submitClaim(claimed); } else if (operation.phase === 'submitted' && operation.lease_expires_at <= now()) db.prepare("UPDATE assistant_share_operation SET state='unresolved',lease_expires_at=NULL,error_code='message_unresolved',updated_at=? WHERE operation_id=? AND state='running' AND phase='submitted'").run(now(), operation.operation_id); } catch { /* Reconciliation remains retryable until its lease expires. */ } } };
|
|
3082
3637
|
const reconcile = async () => {
|
|
3083
3638
|
if (closed) return;
|
|
3084
3639
|
try {
|
|
@@ -3154,7 +3709,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3154
3709
|
)),
|
|
3155
3710
|
};
|
|
3156
3711
|
};
|
|
3157
|
-
return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId() }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
|
|
3712
|
+
return { capability: async () => ({ supported: true, enabled: enabled(), revision: revision(), serverInstanceID: await getServerId(), sharingAvailable: false, archiveMetadataAvailable: typeof archiveSessionHost === 'function', sessionMetadataAvailable: false }), snapshot, createAssistant, updateAssistant, setEnabled: (input) => { if (!plainObject(input) || typeof input.enabled !== 'boolean' || input.expectedRevision !== revision()) fail('revision_conflict'); db.prepare("UPDATE assistant_meta SET value=? WHERE key='enabled'").run(input.enabled ? '1' : '0'); return { enabled: input.enabled, revision: bump() }; }, removeAssistant: (assistantID, expectedRevision) => {
|
|
3158
3713
|
db.exec('BEGIN IMMEDIATE');
|
|
3159
3714
|
try {
|
|
3160
3715
|
const result = db.prepare('UPDATE assistant_v2 SET tombstone_at=?,revision=revision+1,updated_at=? WHERE assistant_id=? AND revision=? AND tombstone_at IS NULL').run(now(), now(), assistantID, expectedRevision);
|
|
@@ -3178,6 +3733,7 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
3178
3733
|
}, ensure, createNew, compact, send, whenContactTurnSettled, abort, captureQueueDeliveryTarget, sendWithCapturedConfig, share, shareOperation, historicalMessages, contactMessages, clearContactMemory, resetContact, markContactRead, appendContactCard, deliverPeerMessage, listAssistantScheduledTasks, putAssistantContactAttachment, getAssistantContactAttachment, migrateContactAttachments, processEvent, reportAssignedSessionSettle: reportAssignedSession, reconcile, close: () => {
|
|
3179
3734
|
cancelAssignedResumes({});
|
|
3180
3735
|
sessionAbortInflight.clear();
|
|
3736
|
+
pendingAssignMessageIDs.clear();
|
|
3181
3737
|
for (const turn of contactControllers.values()) turn.controller.abort();
|
|
3182
3738
|
if (!closed) {
|
|
3183
3739
|
closed = true;
|