@openchambery/web 1.18.2-beta.3 → 1.18.2-beta.30
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/dist/assets/{AgentAvatar-B2kZjgaf.js → AgentAvatar-B4fj9PIe.js} +1 -1
- package/dist/assets/AssistantView-Bn-UFf4Z.js +1 -0
- package/dist/assets/{ChatContainer-Dg1fcQRH.js → ChatContainer-BXit8m8H.js} +39 -39
- package/dist/assets/{DiagramView-Cl5iwEHD.js → DiagramView-BdhMAzT0.js} +1 -1
- package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-CKKU6fKI.js} +1 -1
- package/dist/assets/{Icon-AXqa-fYB.js → Icon-DiIuWOuC.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-N_HaSj0n.js → MarkdownRendererImpl-BtmidffL.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-JFidUnW2.js → MobileDetailNavigation-UU07EhGy.js} +1 -1
- package/dist/assets/MobileShareBridge-CIwA1gVb.js +4 -0
- package/dist/assets/MobileSurface-gtthIGYq.js +2 -0
- package/dist/assets/{MultiRunWindow-CFVe1qvV.js → MultiRunWindow-DCOP8Gcw.js} +1 -1
- package/dist/assets/{OnboardingScreen-DiPnIwza.js → OnboardingScreen-CW21rCqC.js} +1 -1
- package/dist/assets/OverlayScrollbar-isP804Zw.js +2 -0
- package/dist/assets/SettingsView-sUF6jjPL.js +26 -0
- package/dist/assets/{SettingsWindow-yYmo-0Ic.js → SettingsWindow--LlmgmdV.js} +1 -1
- package/dist/assets/{TerminalView-BNxPKANo.js → TerminalView-cFZkmAgN.js} +1 -1
- package/dist/assets/{ToolOutputDialog-COiOtTRX.js → ToolOutputDialog-BElR7uDu.js} +1 -1
- package/dist/assets/appThemeRegistry-BG1qltDX.js +2 -0
- package/dist/assets/assistant-guide-hero-wide-DiFIThB0.jpg +0 -0
- package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
- package/dist/assets/capgoAdapter-LvGrONDp.js +1 -0
- package/dist/assets/{clipboard-CbqhDr1g.js → clipboard-ByPKHEt8.js} +1 -1
- package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
- package/dist/assets/{context-menu-CKua77Jz.js → context-menu-DlxAp9du.js} +1 -1
- package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-ZK_vkdzL.js} +1 -1
- package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-CewM6-g5.js} +1 -1
- package/dist/assets/{diagram-CEon0BQs.js → diagram-DAE4CZy2.js} +1 -1
- package/dist/assets/{dialog--cf4dygO.js → dialog-CQLvE6F6.js} +1 -1
- package/dist/assets/{es-DCoGZljP.js → es-ZHyqPb3M.js} +2 -2
- package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
- package/dist/assets/{fr-dkEoZpPz.js → fr-nXAAl4qu.js} +3 -3
- package/dist/assets/{gitApi-B7IetQyZ.js → gitApi-B11XffZP.js} +6 -6
- package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
- package/dist/assets/insertionBoundaries-CDNLmXA6.js +1 -0
- package/dist/assets/{ja-bQwSsvRr.js → ja-CLcxQZ5K.js} +2 -2
- package/dist/assets/{ko-Kq5C3hsv.js → ko-KgjBt0nP.js} +2 -2
- package/dist/assets/main-By-Um3x1.js +172 -0
- package/dist/assets/main-Byhyzh0I.js +2 -0
- package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-BDyY0vt6.js} +1 -1
- package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-D-aLQZaM.js} +1 -1
- package/dist/assets/miniChat-Cw4DM3Kk.js +2 -0
- package/dist/assets/mobile-BkWoFtBb.js +2 -0
- package/dist/assets/multirun-DvOlESuG.js +4 -0
- package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
- package/dist/assets/{pl-CYpMAgVn.js → pl-ZJkD7N-8.js} +1 -1
- package/dist/assets/platform-G8Cxe5wN.js +2 -0
- package/dist/assets/projectMeta-DMM_G-a5.js +3 -0
- package/dist/assets/{pt-BR-MSPTLfTm.js → pt-BR-B6nleSMM.js} +2 -2
- package/dist/assets/radio-DQ3ENkQN.js +1 -0
- package/dist/assets/renderElectronMiniChatApp-DQWu_vZ6.js +2 -0
- package/dist/assets/renderMobileApp-YBH3SUWo.js +23 -0
- package/dist/assets/runtimeConfig-w7pmXQY1.js +2 -0
- package/dist/assets/runtimeEndpointReset-BcEksuFy.js +145 -0
- package/dist/assets/{sortable-tabs-strip-BJcpkbMH.js → sortable-tabs-strip-C4abQGAg.js} +1 -1
- package/dist/assets/src-De3WpqHP.css +2 -0
- package/dist/assets/{store-OURVnLSN.js → store-BDxgw-Hx.js} +3 -3
- package/dist/assets/terminalApi-os4uWEou.js +1 -0
- package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
- package/dist/assets/types-BuiqMmh_.js +1 -0
- package/dist/assets/{uk-cSkRAYFY.js → uk-KTDMzdyl.js} +2 -2
- package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
- package/dist/assets/useAppFontEffects-DfjqolPi.js +7 -0
- package/dist/assets/useDesktopWindowControlsLayout-Dk_J2R2l.js +1 -0
- package/dist/assets/useEffectiveDirectory-DvwBlXlV.js +1 -0
- package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-B2QwnhAp.js} +1 -1
- package/dist/assets/useMobileNavigationStore-CtwYvj4I.js +476 -0
- package/dist/assets/useSessionAutoCleanup-jK8ZenbX.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-BdJWWBeW.js → useWorkerHighlightedLines-DCkxJxo9.js} +1 -1
- package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
- package/dist/assets/{zh-CN-BKqeT_uz.js → zh-CN-BilLDIh_.js} +2 -2
- package/dist/assets/{zh-TW-BaNjJ8Y4.js → zh-TW-DBDsyRLQ.js} +2 -2
- package/dist/index.html +12 -10
- package/dist/mini-chat.html +10 -8
- package/dist/mobile.html +10 -8
- package/package.json +1 -1
- package/server/index.js +7 -0
- package/server/lib/assistants/DOCUMENTATION.md +2 -2
- package/server/lib/assistants/history-state.js +23 -0
- package/server/lib/assistants/history-state.test.js +57 -0
- package/server/lib/assistants/service.js +44 -15
- package/server/lib/assistants/service.test.js +120 -5
- package/server/lib/config-sync/DOCUMENTATION.md +2 -2
- package/server/lib/config-sync/contract.js +3 -18
- package/server/lib/config-sync/index.js +0 -2
- package/server/lib/config-sync/tar.js +2 -3
- package/server/lib/config-sync/target-id.js +5 -1
- package/server/lib/event-stream/DOCUMENTATION.md +2 -2
- package/server/lib/event-stream/diff-summary.js +39 -11
- package/server/lib/event-stream/diff-summary.test.js +73 -6
- package/server/lib/event-stream/protocol.test.js +70 -7
- package/server/lib/notifications/DOCUMENTATION.md +2 -2
- package/server/lib/notifications/runtime.js +68 -27
- package/server/lib/notifications/runtime.test.js +176 -0
- package/server/lib/opencode/DOCUMENTATION.md +1 -1
- package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
- package/server/lib/session-index/DOCUMENTATION.md +12 -6
- package/server/lib/session-index/routes.js +34 -0
- package/server/lib/session-index/routes.test.js +76 -0
- package/server/lib/session-index/service.js +53 -4
- package/server/lib/session-index/service.test.js +72 -2
- package/server/lib/session-title/DOCUMENTATION.md +19 -14
- package/server/lib/session-title/runtime.js +8 -8
- package/server/lib/session-title/runtime.test.js +231 -0
- package/server/lib/session-turn-pages/DOCUMENTATION.md +26 -6
- package/server/lib/session-turn-pages/changes.service.js +156 -0
- package/server/lib/session-turn-pages/changes.service.test.js +188 -0
- package/server/lib/session-turn-pages/reconcile.routes.test.js +52 -0
- package/server/lib/session-turn-pages/reconcile.service.js +3 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +44 -0
- package/server/lib/session-turn-pages/routes.js +273 -12
- package/server/lib/session-turn-pages/routes.test.js +387 -0
- package/server/lib/session-turn-pages/service.js +31 -7
- package/server/lib/session-turn-pages/service.test.js +40 -0
- package/server/lib/small-model/DOCUMENTATION.md +93 -38
- package/server/lib/small-model/catalog.js +10 -2
- package/server/lib/small-model/catalog.test.js +3 -1
- package/server/lib/small-model/index.js +52 -28
- package/server/lib/small-model/opencode-session.js +339 -0
- package/server/lib/small-model/opencode-session.test.js +177 -0
- package/server/lib/small-model/resolve.js +116 -5
- package/server/lib/small-model/resolve.test.js +76 -10
- package/server/lib/small-model/summary-settings.test.js +60 -3
- package/dist/assets/AssistantView-C-4WY4_Y.js +0 -1
- package/dist/assets/MobileShareBridge-Dtye6XbD.js +0 -4
- package/dist/assets/MobileSurface-uh9xGE4W.js +0 -2
- package/dist/assets/OverlayScrollbar-DV6Bf5MB.js +0 -1
- package/dist/assets/SettingsView-BJ0E268r.js +0 -25
- package/dist/assets/appThemeRegistry-DT9JSHvx.js +0 -2
- package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
- package/dist/assets/insertionBoundaries-CXhYvvQ1.js +0 -1
- package/dist/assets/main-CLlU-0_3.js +0 -2
- package/dist/assets/main-DStWT6gv.js +0 -172
- package/dist/assets/miniChat-B4wfuAbi.js +0 -2
- package/dist/assets/mobile-C6CUAJNV.js +0 -2
- package/dist/assets/multirun-BvOVsUJY.js +0 -4
- package/dist/assets/projectMeta-COUPYr2M.js +0 -3
- package/dist/assets/radio-ByL02rGC.js +0 -1
- package/dist/assets/renderElectronMiniChatApp-CmmF6m6P.js +0 -2
- package/dist/assets/renderMobileApp-BlkP7KQl.js +0 -23
- package/dist/assets/runtimeConfig-BtWi74Pm.js +0 -1
- package/dist/assets/runtimeEndpointReset-CJfmeHr6.js +0 -145
- package/dist/assets/src-CQN5yJYP.css +0 -2
- package/dist/assets/terminalApi-tB3qLcfa.js +0 -1
- package/dist/assets/useAppFontEffects-BLm1WFVC.js +0 -7
- package/dist/assets/useDesktopWindowControlsLayout-Bdc8u8ax.js +0 -1
- package/dist/assets/useEffectiveDirectory-By_ohSx-.js +0 -1
- package/dist/assets/useMobileNavigationStore-BQOQWNUY.js +0 -475
- package/dist/assets/useSessionAutoCleanup-BmPmMhJN.js +0 -1
package/dist/index.html
CHANGED
|
@@ -546,13 +546,13 @@
|
|
|
546
546
|
margin-top: 10px;
|
|
547
547
|
min-height: 16px;
|
|
548
548
|
text-align: center;
|
|
549
|
-
font-size:
|
|
550
|
-
line-height:
|
|
549
|
+
font-size: calc(12 * var(--dpt));
|
|
550
|
+
line-height: calc(16 * var(--dpt));
|
|
551
551
|
color: var(--splash-stroke);
|
|
552
552
|
opacity: 0.72;
|
|
553
553
|
}
|
|
554
554
|
</style>
|
|
555
|
-
<script type="module" crossorigin src="/assets/main-
|
|
555
|
+
<script type="module" crossorigin src="/assets/main-Byhyzh0I.js"></script>
|
|
556
556
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
557
557
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
558
558
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -560,15 +560,17 @@
|
|
|
560
560
|
<link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
|
|
561
561
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
|
|
562
562
|
<link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
|
|
563
|
-
<link rel="modulepreload" crossorigin href="/assets/persistence-
|
|
564
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-
|
|
565
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-
|
|
563
|
+
<link rel="modulepreload" crossorigin href="/assets/persistence-D1lIuyqO.js">
|
|
564
|
+
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-ZK_vkdzL.js">
|
|
565
|
+
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-CewM6-g5.js">
|
|
566
566
|
<link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
|
|
567
567
|
<link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
|
|
568
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
569
|
-
<link rel="modulepreload" crossorigin href="/assets/terminalApi-
|
|
570
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
571
|
-
<link rel="
|
|
568
|
+
<link rel="modulepreload" crossorigin href="/assets/platform-G8Cxe5wN.js">
|
|
569
|
+
<link rel="modulepreload" crossorigin href="/assets/terminalApi-os4uWEou.js">
|
|
570
|
+
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
571
|
+
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-LvGrONDp.js">
|
|
572
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-w7pmXQY1.js">
|
|
573
|
+
<link rel="stylesheet" crossorigin href="/assets/src-De3WpqHP.css">
|
|
572
574
|
</head>
|
|
573
575
|
<body class="h-full bg-background text-foreground">
|
|
574
576
|
<!-- Keep the startup layer outside #root. React replaces every child of
|
package/dist/mini-chat.html
CHANGED
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-Cw4DM3Kk.js"></script>
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
79
79
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
80
80
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -82,15 +82,17 @@
|
|
|
82
82
|
<link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
|
|
83
83
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
|
|
84
84
|
<link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
|
|
85
|
-
<link rel="modulepreload" crossorigin href="/assets/persistence-
|
|
86
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-
|
|
87
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-
|
|
85
|
+
<link rel="modulepreload" crossorigin href="/assets/persistence-D1lIuyqO.js">
|
|
86
|
+
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-ZK_vkdzL.js">
|
|
87
|
+
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-CewM6-g5.js">
|
|
88
88
|
<link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
|
|
89
89
|
<link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
|
|
90
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
91
|
-
<link rel="modulepreload" crossorigin href="/assets/terminalApi-
|
|
92
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
93
|
-
<link rel="
|
|
90
|
+
<link rel="modulepreload" crossorigin href="/assets/platform-G8Cxe5wN.js">
|
|
91
|
+
<link rel="modulepreload" crossorigin href="/assets/terminalApi-os4uWEou.js">
|
|
92
|
+
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
93
|
+
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-LvGrONDp.js">
|
|
94
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-w7pmXQY1.js">
|
|
95
|
+
<link rel="stylesheet" crossorigin href="/assets/src-De3WpqHP.css">
|
|
94
96
|
</head>
|
|
95
97
|
<body class="h-full bg-background text-foreground">
|
|
96
98
|
<div id="root" class="h-full">
|
package/dist/mobile.html
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-BkWoFtBb.js"></script>
|
|
67
67
|
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-C0FnF6B9.js">
|
|
68
68
|
<link rel="modulepreload" crossorigin href="/assets/vendor-base-ui-CnHfUmDN.js">
|
|
69
69
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-B4dlGEM2.js">
|
|
@@ -71,15 +71,17 @@
|
|
|
71
71
|
<link rel="modulepreload" crossorigin href="/assets/vendor-pathe-vBmvQE8f.js">
|
|
72
72
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-DLPElpoQ.js">
|
|
73
73
|
<link rel="modulepreload" crossorigin href="/assets/vendor-tanstack-query-core-BWz0ose6.js">
|
|
74
|
-
<link rel="modulepreload" crossorigin href="/assets/persistence-
|
|
75
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-
|
|
76
|
-
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-
|
|
74
|
+
<link rel="modulepreload" crossorigin href="/assets/persistence-D1lIuyqO.js">
|
|
75
|
+
<link rel="modulepreload" crossorigin href="/assets/desktopHosts-ZK_vkdzL.js">
|
|
76
|
+
<link rel="modulepreload" crossorigin href="/assets/desktopRelayRestore-CewM6-g5.js">
|
|
77
77
|
<link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-C07HQypa.js">
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/vendor-aparajita-capacitor-secure-storage-CjQdwfjS.js">
|
|
79
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
80
|
-
<link rel="modulepreload" crossorigin href="/assets/terminalApi-
|
|
81
|
-
<link rel="modulepreload" crossorigin href="/assets/
|
|
82
|
-
<link rel="
|
|
79
|
+
<link rel="modulepreload" crossorigin href="/assets/platform-G8Cxe5wN.js">
|
|
80
|
+
<link rel="modulepreload" crossorigin href="/assets/terminalApi-os4uWEou.js">
|
|
81
|
+
<link rel="modulepreload" crossorigin href="/assets/types-BuiqMmh_.js">
|
|
82
|
+
<link rel="modulepreload" crossorigin href="/assets/capgoAdapter-LvGrONDp.js">
|
|
83
|
+
<link rel="modulepreload" crossorigin href="/assets/runtimeConfig-w7pmXQY1.js">
|
|
84
|
+
<link rel="stylesheet" crossorigin href="/assets/src-De3WpqHP.css">
|
|
83
85
|
</head>
|
|
84
86
|
<body class="h-full bg-background text-foreground">
|
|
85
87
|
<div id="root" class="h-full"></div>
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -1714,6 +1714,13 @@ async function main(options = {}) {
|
|
|
1714
1714
|
} catch {
|
|
1715
1715
|
// best-effort shutdown of the dictation worker
|
|
1716
1716
|
}
|
|
1717
|
+
try {
|
|
1718
|
+
if (smallModelServiceInstance?.stop) {
|
|
1719
|
+
await smallModelServiceInstance.stop();
|
|
1720
|
+
}
|
|
1721
|
+
} catch {
|
|
1722
|
+
// best-effort cleanup of the small-model temp OpenCode directory
|
|
1723
|
+
}
|
|
1717
1724
|
return gracefulShutdown({
|
|
1718
1725
|
exitProcess: shutdownOptions.exitProcess ?? false,
|
|
1719
1726
|
forceCloseConnections: shutdownOptions.forceCloseConnections === true,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## History coverage
|
|
4
4
|
|
|
5
|
-
Snapshots expose the latest 50 `historySessionIDs` in chronological order and `historySessionCount` for the full count. The messages pagination endpoint owns complete history, including the current binding. A successful direct, shared, or queued OpenCode admission writes its user message and provisional parts to the Assistant SQLite mirror before the HTTP operation settles; this covered admission row survives binding replacement, restart, and an unavailable OpenCode history endpoint. Matching live parts replace provisional parts by message ID. Other event mirrors retain raw JSON as uncovered rows. Authoritative OpenCode backfill
|
|
5
|
+
Snapshots expose the latest 50 `historySessionIDs` in chronological order and `historySessionCount` for the full count. The messages pagination endpoint owns complete history, including the current binding. A successful direct, shared, or queued OpenCode admission writes its user message and provisional parts to the Assistant SQLite mirror before the HTTP operation settles; this covered admission row survives binding replacement, restart, and an unavailable OpenCode history endpoint. Matching live parts replace provisional parts by message ID. Other event mirrors retain raw JSON as uncovered (`covered=0`) provisional fallback rows: they remain visible in archived history until an explicit shrink signal. Authoritative OpenCode backfill upserts covered rows and reconciles authoritative parts, rejects entries whose embedded session ID belongs to another session, and never deletes provisional mirrors merely because a successful page omitted them. Pagination `complete` only records that the OpenCode cursor was exhausted; it does not authorize deleting uncovered event mirrors. The only shrink whitelist is explicit `message.removed`, an authoritative `session.messages` 404 (`session-missing`), or assistant deletion. Late archived `message.updated` / `message.part.updated` and session lifecycle events (`session.idle`, `session.error`, `session.status` busy/retry/idle) call `invalidateBackfill` to reopen that session's cursor/complete without clearing covered flags. `history-state.js` owns the pure backfill reducer (`invalidate` → preserve; `page` → complete iff next cursor empty, always preserve; `session-missing` → discard provisional). Backfill persists the OpenCode `x-next-cursor` header verbatim and derives completion from its presence. A demand request commits up to three upstream pages and returns committed archived mirror rows (covered and provisional) with a stable scan-boundary cursor when more work remains. Startup begins one non-blocking current-binding backfill page. Archived rows preserve their stored directory; a legacy null directory triggers an authoritative session lookup of `data.directory` and `data.project.worktree`, with containment validation before persistence. Unresolved legacy directories remain `null` in persisted and historical response data. Ordinary mirror upserts preserve covered rows and never blanket-invalidate a session; explicit authoritative upserts elevate coverage; session-wide `invalidateCoverage` preserves every already-covered user row, including admitted rows whose local marker was replaced by live OpenCode info, while resetting other coverage on archive/re-archive; message-scoped coverage invalidation runs only after structural part deletes that require re-backfill. Removing an assistant tombstones the row and deletes its session history, message/part mirrors, and backfill state in one transaction. Schema v11 adds the mirror coverage marker.
|
|
6
6
|
|
|
7
7
|
`assistants` owns Assistant configuration, session bindings, and a durable assistant-owned OpenCode message/part mirror in `assistants.sqlite`. Routes register before the generic OpenCode proxy.
|
|
8
8
|
|
|
@@ -12,7 +12,7 @@ Snapshots expose the latest 50 `historySessionIDs` in chronological order and `h
|
|
|
12
12
|
|
|
13
13
|
Composer messages carry the expected binding and client message ID. The service confirms the binding, sends through OpenCode `promptAsync`, persists the admitted user row in Assistant SQLite, and returns the binding, client message ID, and admission. OpenCode `204` is an admitted empty response. A 404 restores one generation-scoped binding before retrying. Ambiguous network errors preserve the client message ID without another send. Every committed Assistant revision publishes `openchamber:assistants-changed` only after its SQLite transaction completes; clients use the tip to reload the authoritative snapshot after worker-driven stateless binding changes. `POST /:id/session/abort` requires the same `{ sessionID, sessionGeneration }`, returns the binding and `aborted: true`, reports a changed binding as `revision_conflict`, and preserves upstream missing-session `not_found` semantics.
|
|
14
14
|
|
|
15
|
-
`GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `session.messages` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when
|
|
15
|
+
`GET /api/openchamber/assistants/:assistantID/messages?before=&limit=` returns archived and current bindings as `{ entries: [{ sessionID, directory, info, parts }], nextCursor, complete }`. It reads assistant-owned mirror rows in chronological session and message order, pages toward older rows with an opaque stable cursor, and preserves the raw OpenCode `info` and `part` JSON. `directory` is the effective workspace for that source session and is nullable for legacy archived rows with an unknown workspace. `complete === (nextCursor === null)` marks arrival at the oldest available history. The service reads the latest persisted `limit + 1` rows first, then demand-backfills one page at a time from the newest incomplete archived session covered by the cursor; a request performs at most three upstream pages and reuses persisted pages without upstream access. Backfill page commits are independent. Each `session.messages` page attempt retries only transient 5xx/network-class SDK failures up to three times with short backoff; persistent failures still throw `upstream_error`. An authoritative OpenCode 404 for an archived or old binding marks that session's backfill complete via the `session-missing` reducer path, deletes only its uncovered event mirrors, preserves covered/admitted rows, and continues scanning other incomplete sessions so one deleted session cannot permanently block Assistant history. Successful backfill pages upsert authoritative rows without clearing provisional mirrors missing from that snapshot. Concurrent ensure that replaces a missing current binding archives the old ID and creates a new one; 404 completion never deletes covered history under either identity. A required-page failure surfaces as an upstream error only when the current request's `pageRows()` is empty; when the current range already has covered or provisional rows, the service returns that partial result and keeps the incomplete cursor retryable. Provisional event mirrors stay readable as `covered=0` fallbacks until a shrink-whitelist signal or a later authoritative upsert elevates them. Current-session live sync overrides the matching SQLite message identity in the UI.
|
|
16
16
|
|
|
17
17
|
Queue admission accepts `{ kind: 'assistant', assistantID }`, captures a structured binding plus provider, model, agent, nullable variant, default prompt/system, compiled text parts, and durable attachment descriptors. Direct messages, shares, and queued Assistant deliveries accept up to 129 strict OpenCode text/file parts, including up to 64 file parts. Queue dispatch uses `sendWithCapturedConfig`, so queued work never reads mutable Assistant configuration. Continuous delivery retains binding fencing and a changed binding produces `stale_target`. Stateless delivery instead belongs to the stable `assistant:<assistantID>` queue lane: it ignores disposable binding replacement, serializes prompt admissions per Assistant, and creates a fresh execution Session for every row.
|
|
18
18
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure backfill pagination reducer for Assistant history mirrors.
|
|
3
|
+
* Disposition tells the service whether provisional (covered=0) rows may be dropped.
|
|
4
|
+
*/
|
|
5
|
+
export const reduceBackfillState = (state, event) => {
|
|
6
|
+
const type = typeof event === 'string' ? event : event?.type;
|
|
7
|
+
if (type === 'invalidate') {
|
|
8
|
+
return { cursor: null, complete: false, disposition: 'preserve' };
|
|
9
|
+
}
|
|
10
|
+
if (type === 'page') {
|
|
11
|
+
const raw = event?.nextCursor;
|
|
12
|
+
const nextCursor = typeof raw === 'string' && raw.length > 0 ? raw : null;
|
|
13
|
+
return { cursor: nextCursor, complete: nextCursor == null, disposition: 'preserve' };
|
|
14
|
+
}
|
|
15
|
+
if (type === 'session-missing') {
|
|
16
|
+
return { cursor: null, complete: true, disposition: 'discard-provisional' };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
cursor: state?.cursor ?? null,
|
|
20
|
+
complete: Boolean(state?.complete),
|
|
21
|
+
disposition: 'preserve',
|
|
22
|
+
};
|
|
23
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { reduceBackfillState } from './history-state.js';
|
|
3
|
+
|
|
4
|
+
describe('reduceBackfillState', () => {
|
|
5
|
+
it.each([
|
|
6
|
+
{
|
|
7
|
+
name: 'invalidate resets cursor/complete and preserves provisionals',
|
|
8
|
+
state: { cursor: 'opaque-1', complete: true },
|
|
9
|
+
event: 'invalidate',
|
|
10
|
+
expected: { cursor: null, complete: false, disposition: 'preserve' },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'invalidate from incomplete state',
|
|
14
|
+
state: { cursor: 'opaque-2', complete: false },
|
|
15
|
+
event: { type: 'invalidate' },
|
|
16
|
+
expected: { cursor: null, complete: false, disposition: 'preserve' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
name: 'page with next cursor stays incomplete and preserves',
|
|
20
|
+
state: { cursor: null, complete: false },
|
|
21
|
+
event: { type: 'page', nextCursor: 'opaque-3' },
|
|
22
|
+
expected: { cursor: 'opaque-3', complete: false, disposition: 'preserve' },
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: 'page with empty cursor completes and preserves',
|
|
26
|
+
state: { cursor: 'opaque-3', complete: false },
|
|
27
|
+
event: { type: 'page', nextCursor: null },
|
|
28
|
+
expected: { cursor: null, complete: true, disposition: 'preserve' },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'page with empty string cursor completes and preserves',
|
|
32
|
+
state: { cursor: 'opaque-4', complete: false },
|
|
33
|
+
event: { type: 'page', nextCursor: '' },
|
|
34
|
+
expected: { cursor: null, complete: true, disposition: 'preserve' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'session-missing completes and discards provisionals',
|
|
38
|
+
state: { cursor: 'opaque-5', complete: false },
|
|
39
|
+
event: 'session-missing',
|
|
40
|
+
expected: { cursor: null, complete: true, disposition: 'discard-provisional' },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'session-missing from complete state',
|
|
44
|
+
state: { cursor: null, complete: true },
|
|
45
|
+
event: { type: 'session-missing' },
|
|
46
|
+
expected: { cursor: null, complete: true, disposition: 'discard-provisional' },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'unknown event preserves state',
|
|
50
|
+
state: { cursor: 'opaque-6', complete: true },
|
|
51
|
+
event: { type: 'unknown' },
|
|
52
|
+
expected: { cursor: 'opaque-6', complete: true, disposition: 'preserve' },
|
|
53
|
+
},
|
|
54
|
+
])('$name', ({ state, event, expected }) => {
|
|
55
|
+
expect(reduceBackfillState(state, event)).toEqual(expected);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
|
6
6
|
import { validAssistantDeliveryParts } from '../assistant-delivery-parts.js';
|
|
7
|
+
import { reduceBackfillState } from './history-state.js';
|
|
7
8
|
|
|
8
9
|
const require = createRequire(import.meta.url);
|
|
9
10
|
const SCHEMA_VERSION = 11;
|
|
@@ -222,6 +223,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
222
223
|
db.prepare('UPDATE assistant_message_mirror SET covered=0 WHERE assistant_id=? AND session_id=?').run(assistantID, sessionID);
|
|
223
224
|
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());
|
|
224
225
|
};
|
|
226
|
+
// Reopen demand backfill without clearing covered/provisional mirrors.
|
|
227
|
+
const invalidateBackfill = (assistantID, sessionID) => {
|
|
228
|
+
const current = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
|
|
229
|
+
const next = reduceBackfillState({ cursor: current?.cursor ?? null, complete: Boolean(current?.complete) }, 'invalidate');
|
|
230
|
+
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());
|
|
231
|
+
};
|
|
225
232
|
// Structural part deletes may leave a covered message incomplete; re-demand that session only.
|
|
226
233
|
// Do not blanket-uncover on ordinary message/part upserts — that blanks served history until re-backfill.
|
|
227
234
|
const invalidateMessageCoverage = (assistantID, sessionID, messageID) => {
|
|
@@ -235,12 +242,12 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
235
242
|
if (payload.type === 'message.updated') {
|
|
236
243
|
const info = properties.info; const sessionID = info?.sessionID;
|
|
237
244
|
if (!nonEmptyString(sessionID) || !plainObject(info)) return false;
|
|
238
|
-
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) mirrorMessage(assistantID, sessionID, info, undefined,
|
|
245
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { const current = assistant(assistantID)?.current_session_id === sessionID; mirrorMessage(assistantID, sessionID, info, undefined, current); if (!current) invalidateBackfill(assistantID, sessionID); } return assistants.length > 0;
|
|
239
246
|
}
|
|
240
247
|
if (payload.type === 'message.part.updated') {
|
|
241
248
|
const part = properties.part; const sessionID = properties.sessionID ?? part?.sessionID;
|
|
242
249
|
if (!nonEmptyString(sessionID) || !plainObject(part)) return false;
|
|
243
|
-
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { 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(assistantID, sessionID, part.messageID); mirrorPart(assistantID, sessionID, part); } return assistants.length > 0;
|
|
250
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { 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(assistantID, sessionID, part.messageID); mirrorPart(assistantID, sessionID, part); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.length > 0;
|
|
244
251
|
}
|
|
245
252
|
if (payload.type === 'message.removed') {
|
|
246
253
|
const sessionID = properties.sessionID; const messageID = properties.messageID;
|
|
@@ -252,6 +259,18 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
252
259
|
if (!nonEmptyString(sessionID) || !nonEmptyString(messageID) || !nonEmptyString(partID)) return false;
|
|
253
260
|
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id=? AND part_id=?').run(assistantID, sessionID, messageID, partID); if (assistant(assistantID)?.current_session_id !== sessionID) invalidateMessageCoverage(assistantID, sessionID, messageID); } return assistants.length > 0;
|
|
254
261
|
}
|
|
262
|
+
if (payload.type === 'session.idle' || payload.type === 'session.error') {
|
|
263
|
+
const sessionID = properties.sessionID;
|
|
264
|
+
if (!nonEmptyString(sessionID)) return false;
|
|
265
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
266
|
+
}
|
|
267
|
+
if (payload.type === 'session.status') {
|
|
268
|
+
const sessionID = properties.sessionID;
|
|
269
|
+
const statusType = typeof properties.status?.type === 'string' ? properties.status.type : typeof properties.info?.type === 'string' ? properties.info.type : '';
|
|
270
|
+
if (!nonEmptyString(sessionID) || !nonEmptyString(statusType)) return false;
|
|
271
|
+
if (statusType !== 'busy' && statusType !== 'retry' && statusType !== 'idle') return false;
|
|
272
|
+
const assistants = mappedAssistants(sessionID); for (const assistantID of assistants) { if (assistant(assistantID)?.current_session_id !== sessionID) invalidateBackfill(assistantID, sessionID); } return assistants.some((assistantID) => assistant(assistantID)?.current_session_id !== sessionID);
|
|
273
|
+
}
|
|
255
274
|
return false;
|
|
256
275
|
};
|
|
257
276
|
const resolveArchivedDirectory = (assistantID, ...candidates) => {
|
|
@@ -265,8 +284,8 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
265
284
|
db.prepare('DELETE FROM assistant_message_part_mirror WHERE assistant_id=? AND session_id=? AND message_id IN (SELECT message_id FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=0)').run(assistantID, sessionID, assistantID, sessionID);
|
|
266
285
|
db.prepare('DELETE FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=0').run(assistantID, sessionID);
|
|
267
286
|
};
|
|
268
|
-
const
|
|
269
|
-
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,
|
|
287
|
+
const writeBackfillState = (assistantID, sessionID, next) => {
|
|
288
|
+
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());
|
|
270
289
|
};
|
|
271
290
|
// Authoritative 404 means this session ID is gone from OpenCode. Converge its
|
|
272
291
|
// backfill so one deleted archive cannot block demand scans of other sessions.
|
|
@@ -276,8 +295,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
276
295
|
const completeMissingSessionBackfill = (assistantID, sessionID) => {
|
|
277
296
|
db.exec('BEGIN IMMEDIATE');
|
|
278
297
|
try {
|
|
279
|
-
|
|
280
|
-
|
|
298
|
+
const current = db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE assistant_id=? AND session_id=?').get(assistantID, sessionID);
|
|
299
|
+
const next = reduceBackfillState({ cursor: current?.cursor ?? null, complete: Boolean(current?.complete) }, 'session-missing');
|
|
300
|
+
if (next.disposition === 'discard-provisional') clearUncoveredSessionMirror(assistantID, sessionID);
|
|
301
|
+
writeBackfillState(assistantID, sessionID, next);
|
|
281
302
|
db.exec('COMMIT');
|
|
282
303
|
return true;
|
|
283
304
|
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
@@ -318,11 +339,13 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
318
339
|
try {
|
|
319
340
|
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)); });
|
|
320
341
|
const nextCursor = result?.response?.headers?.get('x-next-cursor') ?? null;
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
342
|
+
const next = reduceBackfillState({ cursor, complete: false }, { type: 'page', nextCursor });
|
|
343
|
+
// Successful pages only upsert authoritative rows; never delete provisional
|
|
344
|
+
// event mirrors that the snapshot omitted — only 404 / message.removed /
|
|
345
|
+
// assistant deletion may shrink the message set.
|
|
346
|
+
writeBackfillState(history.assistant_id, history.session_id, next);
|
|
324
347
|
db.exec('COMMIT');
|
|
325
|
-
return complete;
|
|
348
|
+
return next.complete;
|
|
326
349
|
} catch (error) { db.exec('ROLLBACK'); throw error; }
|
|
327
350
|
};
|
|
328
351
|
const decodeCursor = (value) => { if (value == null || value === '') return null; try { const parsed = parse(Buffer.from(String(value), 'base64url').toString('utf8')); return Number.isSafeInteger(parsed?.sessionOrdinal) && Number.isSafeInteger(parsed?.messageOrdinal) && nonEmptyString(parsed?.messageID) && (parsed.scanSessionOrdinal == null || Number.isSafeInteger(parsed.scanSessionOrdinal)) ? parsed : fail('validation_error'); } catch (error) { if (error instanceof AssistantError) throw error; fail('validation_error'); } };
|
|
@@ -333,7 +356,10 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
333
356
|
const currentIsArchived = row.current_session_id != null && Boolean(db.prepare('SELECT 1 FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(row.assistant_id, row.current_session_id));
|
|
334
357
|
const beforeIncludes = (sessionOrdinal, messageOrdinal, messageID) => before == null || sessionOrdinal < before.sessionOrdinal || (sessionOrdinal === before.sessionOrdinal && (messageOrdinal < before.messageOrdinal || (messageOrdinal === before.messageOrdinal && messageID < before.messageID)));
|
|
335
358
|
const pageRows = () => {
|
|
336
|
-
|
|
359
|
+
// Archived mirrors include covered=1 authoritative rows and covered=0
|
|
360
|
+
// provisional event mirrors so a partial REST snapshot cannot hide a reply
|
|
361
|
+
// already observed on the live channel.
|
|
362
|
+
const historical = db.prepare(`SELECT h.ordinal AS session_ordinal,h.directory,m.ordinal AS message_ordinal,m.message_id,m.info_json,m.session_id FROM assistant_session_history h JOIN assistant_message_mirror m ON m.assistant_id=h.assistant_id AND m.session_id=h.session_id WHERE h.assistant_id=? AND (? IS NULL OR h.ordinal<? OR (h.ordinal=? AND (m.ordinal<? OR (m.ordinal=? AND m.message_id<?)))) ORDER BY h.ordinal DESC,m.ordinal DESC,m.message_id DESC LIMIT ?`).all(row.assistant_id, before?.sessionOrdinal ?? null, before?.sessionOrdinal ?? 0, before?.sessionOrdinal ?? 0, before?.messageOrdinal ?? 0, before?.messageOrdinal ?? 0, before?.messageID ?? '', limit + 1);
|
|
337
363
|
const current = row.current_session_id && !currentIsArchived
|
|
338
364
|
? db.prepare('SELECT ? AS session_ordinal,? AS directory,ordinal AS message_ordinal,message_id,info_json,session_id FROM assistant_message_mirror WHERE assistant_id=? AND session_id=? AND covered=1 ORDER BY ordinal DESC,message_id DESC LIMIT ?').all(currentSessionOrdinal, effectiveWorkspace(row), row.assistant_id, row.current_session_id, limit + 1).filter((message) => beforeIncludes(message.session_ordinal, message.message_ordinal, message.message_id))
|
|
339
365
|
: [];
|
|
@@ -341,15 +367,18 @@ export const createAssistantsService = ({ dbPath, dataDir, buildOpenCodeUrl, get
|
|
|
341
367
|
};
|
|
342
368
|
const nextIncomplete = (boundary = before?.scanSessionOrdinal ?? before?.sessionOrdinal ?? null) => db.prepare(`SELECT h.* FROM assistant_session_history h LEFT JOIN assistant_message_backfill b ON b.assistant_id=h.assistant_id AND b.session_id=h.session_id WHERE h.assistant_id=? AND (b.complete IS NULL OR b.complete=0) AND (? IS NULL OR h.ordinal<=?) ORDER BY h.ordinal DESC LIMIT 1`).get(row.assistant_id, boundary, boundary ?? 0);
|
|
343
369
|
let rows = pageRows();
|
|
344
|
-
|
|
370
|
+
// Demand-backfill incomplete archived sessions even when provisional
|
|
371
|
+
// mirrors already fill the page, so authoritative upserts can elevate
|
|
372
|
+
// covered=0 fallbacks. Cap remains BACKFILL_MAX_PAGES per request.
|
|
373
|
+
for (let page = 0; page < BACKFILL_MAX_PAGES; page++) {
|
|
345
374
|
const target = nextIncomplete();
|
|
346
375
|
if (!target) break;
|
|
347
376
|
try {
|
|
348
377
|
await backfillSession(target);
|
|
349
378
|
} catch (error) {
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
379
|
+
// Partial-result scope is this request's pageRows() only: any already
|
|
380
|
+
// visible covered or provisional row keeps the page usable and the
|
|
381
|
+
// incomplete cursor retryable. An empty current page still throws.
|
|
353
382
|
if (rows.length === 0) throw error;
|
|
354
383
|
break;
|
|
355
384
|
}
|
|
@@ -363,8 +363,15 @@ describe('assistants service', () => {
|
|
|
363
363
|
service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_saved', sessionID: first.sessionID, role: 'assistant', time: { created: 1 } } } }); service.close();
|
|
364
364
|
const Database = require('better-sqlite3'); const db = new Database(path.join(directory, 'assistants.sqlite')); db.prepare('DELETE FROM assistant_message_backfill').run(); db.prepare('INSERT INTO assistant_message_backfill(assistant_id,session_id,cursor,complete,updated_at) VALUES (?,?,?,?,?)').run(assistant.id, first.sessionID, null, 0, 1); db.close();
|
|
365
365
|
const restarted = setup(directory, { messages: async () => ({ error: { status: 503 } }) });
|
|
366
|
-
await
|
|
367
|
-
|
|
366
|
+
const page = await restarted.historicalMessages(assistant.id, { limit: 10 });
|
|
367
|
+
expect(page.entries.map((entry) => entry.info.id)).toEqual(['msg_saved']);
|
|
368
|
+
expect(page.complete).toBe(false);
|
|
369
|
+
expect(page.nextCursor).toEqual(expect.any(String));
|
|
370
|
+
restarted.close();
|
|
371
|
+
const persisted = new Database(path.join(directory, 'assistants.sqlite'));
|
|
372
|
+
expect(persisted.prepare('SELECT message_id,covered FROM assistant_message_mirror').all()).toEqual([{ message_id: 'msg_saved', covered: 0 }]);
|
|
373
|
+
expect(persisted.prepare('SELECT complete FROM assistant_message_backfill WHERE session_id=?').get(first.sessionID)).toEqual({ complete: 0 });
|
|
374
|
+
persisted.close();
|
|
368
375
|
});
|
|
369
376
|
|
|
370
377
|
it('retries a transient session.messages failure once then succeeds', async () => {
|
|
@@ -501,14 +508,14 @@ describe('assistants service', () => {
|
|
|
501
508
|
expect((await restarted.historicalMessages(assistant.id)).entries[0]).toMatchObject({ sessionID: first.sessionID, directory: null }); const historicalRequest = messages.find((input) => input.sessionID === first.sessionID); expect(historicalRequest).toMatchObject({ sessionID: first.sessionID }); expect(historicalRequest.directory).toBeUndefined(); const persisted = new Database(path.join(directory, 'assistants.sqlite')); expect(persisted.prepare('SELECT directory FROM assistant_session_history WHERE assistant_id=? AND session_id=?').get(assistant.id, first.sessionID).directory).toBeNull(); persisted.close(); restarted.close();
|
|
502
509
|
});
|
|
503
510
|
|
|
504
|
-
it('keeps covered history visible across ordinary message events without
|
|
511
|
+
it('keeps covered history visible across ordinary message events without clearing coverage', async () => {
|
|
505
512
|
const directory = root(); let creates = 0; let calls = 0;
|
|
506
513
|
const service = setup(directory, {
|
|
507
514
|
create: async () => ({ data: { id: `ses_${++creates}` } }),
|
|
508
515
|
messages: async ({ sessionID }) => {
|
|
509
516
|
calls += 1;
|
|
510
517
|
return {
|
|
511
|
-
data: [{ info: { id: 'msg_1', sessionID, role: 'assistant', time: { created: 1 } }, parts: [{ id: 'part_1', sessionID, messageID: 'msg_1', type: 'text', text: 'hello' }] }],
|
|
518
|
+
data: [{ info: { id: 'msg_1', sessionID, role: 'assistant', time: { created: 1 }, status: 'completed' }, parts: [{ id: 'part_1', sessionID, messageID: 'msg_1', type: 'text', text: 'hello updated' }] }],
|
|
512
519
|
response: { headers: { get: () => null } },
|
|
513
520
|
};
|
|
514
521
|
},
|
|
@@ -520,13 +527,121 @@ describe('assistants service', () => {
|
|
|
520
527
|
expect(calls).toBe(1);
|
|
521
528
|
service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_1', sessionID: first.sessionID, role: 'assistant', time: { created: 1 }, status: 'completed' } } });
|
|
522
529
|
service.processEvent({ type: 'message.part.updated', properties: { sessionID: first.sessionID, part: { id: 'part_1', sessionID: first.sessionID, messageID: 'msg_1', type: 'text', text: 'hello updated' } } });
|
|
530
|
+
const Database = require('better-sqlite3');
|
|
531
|
+
const db = new Database(path.join(directory, 'assistants.sqlite'));
|
|
532
|
+
expect(db.prepare('SELECT covered FROM assistant_message_mirror WHERE session_id=? AND message_id=?').get(first.sessionID, 'msg_1')).toEqual({ covered: 1 });
|
|
533
|
+
expect(db.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE session_id=?').get(first.sessionID)).toEqual({ cursor: null, complete: 0 });
|
|
534
|
+
db.close();
|
|
523
535
|
expect((await service.historicalMessages(assistant.id)).entries).toEqual([{
|
|
524
536
|
sessionID: first.sessionID,
|
|
525
537
|
directory: expect.any(String),
|
|
526
538
|
info: { id: 'msg_1', sessionID: first.sessionID, role: 'assistant', time: { created: 1 }, status: 'completed' },
|
|
527
539
|
parts: [{ id: 'part_1', sessionID: first.sessionID, messageID: 'msg_1', type: 'text', text: 'hello updated' }],
|
|
528
540
|
}]);
|
|
529
|
-
expect(calls).toBe(
|
|
541
|
+
expect(calls).toBe(2);
|
|
542
|
+
const after = new Database(path.join(directory, 'assistants.sqlite'));
|
|
543
|
+
expect(after.prepare('SELECT covered FROM assistant_message_mirror WHERE session_id=? AND message_id=?').get(first.sessionID, 'msg_1')).toEqual({ covered: 1 });
|
|
544
|
+
after.close();
|
|
545
|
+
service.close();
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('keeps a provisional archived assistant reply when the first backfill page only returns the user row', async () => {
|
|
549
|
+
const directory = root();
|
|
550
|
+
let creates = 0;
|
|
551
|
+
let archivedPages = 0;
|
|
552
|
+
let allowFullArchive = false;
|
|
553
|
+
const service = setup(directory, {
|
|
554
|
+
create: async () => ({ data: { id: `ses_${++creates}` } }),
|
|
555
|
+
promptAsync: async () => ({ response: { status: 204 } }),
|
|
556
|
+
messages: async ({ sessionID }) => {
|
|
557
|
+
if (sessionID !== 'ses_2') return { data: [], response: { headers: { get: () => null } } };
|
|
558
|
+
archivedPages += 1;
|
|
559
|
+
if (!allowFullArchive) {
|
|
560
|
+
return {
|
|
561
|
+
data: [{ info: { id: 'msg_user_1', sessionID, role: 'user', time: { created: 10 } }, parts: [{ id: 'part_user_1', sessionID, messageID: 'msg_user_1', type: 'text', text: 'one' }] }],
|
|
562
|
+
response: { headers: { get: () => null } },
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
data: [
|
|
567
|
+
{ info: { id: 'msg_reply_1', sessionID, role: 'assistant', time: { created: 20 } }, parts: [{ id: 'part_reply_1', sessionID, messageID: 'msg_reply_1', type: 'text', text: 'reply-one' }] },
|
|
568
|
+
{ info: { id: 'msg_user_1', sessionID, role: 'user', time: { created: 10 } }, parts: [{ id: 'part_user_1', sessionID, messageID: 'msg_user_1', type: 'text', text: 'one' }] },
|
|
569
|
+
],
|
|
570
|
+
response: { headers: { get: () => null } },
|
|
571
|
+
};
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
|
|
575
|
+
const initial = await service.ensure(assistant.id);
|
|
576
|
+
expect(initial.sessionID).toBe('ses_1');
|
|
577
|
+
// Stateless send replaces the binding: execution lives on a fresh session.
|
|
578
|
+
const first = await service.send(assistant.id, { ...initial, messageID: 'msg_user_1', parts: [{ type: 'text', text: 'one' }] });
|
|
579
|
+
expect(first.binding.sessionID).toBe('ses_2');
|
|
580
|
+
service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_user_1', sessionID: first.binding.sessionID, role: 'user', time: { created: 10 } } } });
|
|
581
|
+
service.processEvent({ type: 'message.updated', properties: { info: { id: 'msg_reply_1', sessionID: first.binding.sessionID, role: 'assistant', time: { created: 20 } } } });
|
|
582
|
+
service.processEvent({ type: 'message.part.updated', properties: { sessionID: first.binding.sessionID, part: { id: 'part_reply_1', sessionID: first.binding.sessionID, messageID: 'msg_reply_1', type: 'text', text: 'reply-one' } } });
|
|
583
|
+
await service.send(assistant.id, { ...first.binding, messageID: 'msg_user_2', parts: [{ type: 'text', text: 'two' }] });
|
|
584
|
+
const page = await service.historicalMessages(assistant.id, { limit: 10 });
|
|
585
|
+
expect(page.entries.map((entry) => [entry.sessionID, entry.info.id, entry.info.role])).toEqual(expect.arrayContaining([
|
|
586
|
+
[first.binding.sessionID, 'msg_user_1', 'user'],
|
|
587
|
+
[first.binding.sessionID, 'msg_reply_1', 'assistant'],
|
|
588
|
+
]));
|
|
589
|
+
expect(page.complete).toBe(true);
|
|
590
|
+
expect(archivedPages).toBe(1);
|
|
591
|
+
const Database = require('better-sqlite3');
|
|
592
|
+
const persisted = new Database(path.join(directory, 'assistants.sqlite'));
|
|
593
|
+
expect(persisted.prepare('SELECT message_id,covered FROM assistant_message_mirror WHERE session_id=? ORDER BY message_id').all(first.binding.sessionID)).toEqual(expect.arrayContaining([
|
|
594
|
+
{ message_id: 'msg_reply_1', covered: 0 },
|
|
595
|
+
{ message_id: 'msg_user_1', covered: 1 },
|
|
596
|
+
]));
|
|
597
|
+
expect(persisted.prepare('SELECT message_id FROM assistant_message_mirror WHERE session_id=? AND message_id=?').get(first.binding.sessionID, 'msg_reply_1')).toEqual({ message_id: 'msg_reply_1' });
|
|
598
|
+
persisted.close();
|
|
599
|
+
allowFullArchive = true;
|
|
600
|
+
service.processEvent({ type: 'session.idle', properties: { sessionID: first.binding.sessionID } });
|
|
601
|
+
const upgraded = await service.historicalMessages(assistant.id, { limit: 10 });
|
|
602
|
+
expect(upgraded.entries.map((entry) => [entry.sessionID, entry.info.id, entry.info.role])).toEqual(expect.arrayContaining([
|
|
603
|
+
[first.binding.sessionID, 'msg_user_1', 'user'],
|
|
604
|
+
[first.binding.sessionID, 'msg_reply_1', 'assistant'],
|
|
605
|
+
]));
|
|
606
|
+
expect(archivedPages).toBe(2);
|
|
607
|
+
const after = new Database(path.join(directory, 'assistants.sqlite'));
|
|
608
|
+
expect(after.prepare('SELECT covered FROM assistant_message_mirror WHERE session_id=? AND message_id=?').get(first.binding.sessionID, 'msg_reply_1')).toEqual({ covered: 1 });
|
|
609
|
+
after.close();
|
|
610
|
+
service.close();
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it.each([
|
|
614
|
+
['message.updated', (sessionID) => ({ type: 'message.updated', properties: { info: { id: 'msg_late', sessionID, role: 'assistant', time: { created: 99 } } } })],
|
|
615
|
+
['message.part.updated', (sessionID) => ({ type: 'message.part.updated', properties: { sessionID, part: { id: 'part_late', sessionID, messageID: 'msg_1', type: 'text', text: 'late' } } })],
|
|
616
|
+
['session.idle', (sessionID) => ({ type: 'session.idle', properties: { sessionID } })],
|
|
617
|
+
['session.error', (sessionID) => ({ type: 'session.error', properties: { sessionID } })],
|
|
618
|
+
['session.status busy', (sessionID) => ({ type: 'session.status', properties: { sessionID, status: { type: 'busy' } } })],
|
|
619
|
+
['session.status retry via info', (sessionID) => ({ type: 'session.status', properties: { sessionID, info: { type: 'retry' } } })],
|
|
620
|
+
['session.status idle', (sessionID) => ({ type: 'session.status', properties: { sessionID, status: { type: 'idle' } } })],
|
|
621
|
+
])('reopens archived backfill on %s while preserving covered rows', async (_label, buildEvent) => {
|
|
622
|
+
const directory = root();
|
|
623
|
+
let creates = 0;
|
|
624
|
+
const service = setup(directory, {
|
|
625
|
+
create: async () => ({ data: { id: `ses_${++creates}` } }),
|
|
626
|
+
messages: async ({ sessionID }) => ({
|
|
627
|
+
data: [{ info: { id: 'msg_1', sessionID, role: 'assistant', time: { created: 1 } }, parts: [{ id: 'part_1', sessionID, messageID: 'msg_1', type: 'text', text: 'hello' }] }],
|
|
628
|
+
response: { headers: { get: () => null } },
|
|
629
|
+
}),
|
|
630
|
+
});
|
|
631
|
+
const assistant = service.createAssistant({ ...assistantInput, mode: 'stateless' });
|
|
632
|
+
const first = await service.ensure(assistant.id);
|
|
633
|
+
await service.createNew(assistant.id);
|
|
634
|
+
await service.historicalMessages(assistant.id, { limit: 10 });
|
|
635
|
+
const Database = require('better-sqlite3');
|
|
636
|
+
const before = new Database(path.join(directory, 'assistants.sqlite'));
|
|
637
|
+
expect(before.prepare('SELECT covered FROM assistant_message_mirror WHERE session_id=? AND message_id=?').get(first.sessionID, 'msg_1')).toEqual({ covered: 1 });
|
|
638
|
+
expect(before.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE session_id=?').get(first.sessionID)).toEqual({ cursor: null, complete: 1 });
|
|
639
|
+
before.close();
|
|
640
|
+
expect(service.processEvent(buildEvent(first.sessionID))).toBe(true);
|
|
641
|
+
const after = new Database(path.join(directory, 'assistants.sqlite'));
|
|
642
|
+
expect(after.prepare('SELECT covered FROM assistant_message_mirror WHERE session_id=? AND message_id=?').get(first.sessionID, 'msg_1')).toEqual({ covered: 1 });
|
|
643
|
+
expect(after.prepare('SELECT cursor,complete FROM assistant_message_backfill WHERE session_id=?').get(first.sessionID)).toEqual({ cursor: null, complete: 0 });
|
|
644
|
+
after.close();
|
|
530
645
|
service.close();
|
|
531
646
|
});
|
|
532
647
|
|
|
@@ -15,7 +15,7 @@ Own the direction-agnostic OpenCode config sync contract: allowlist planning on
|
|
|
15
15
|
| `selections.js` | Default/normalize/filter whitelist selections |
|
|
16
16
|
| `scripts.js` | Probe / inventory / generational prepare / finalize / remote tar scripts |
|
|
17
17
|
| `local-backup.js` | Local (pull destination) generational backup + tar extract |
|
|
18
|
-
| `tar.js` | `collectLocalTarBuffer` (buffered
|
|
18
|
+
| `tar.js` | `collectLocalTarBuffer` (buffered) |
|
|
19
19
|
| `engine.js` | `applyConfigSyncPlan` orchestration for **push** (prepare → putTar* → finalize) |
|
|
20
20
|
| `credential-auth.js` | Credential grant enforcement helpers |
|
|
21
21
|
| `target-id.js` | `ssh:<instanceId>` namespace helper |
|
|
@@ -24,7 +24,7 @@ Own the direction-agnostic OpenCode config sync contract: allowlist planning on
|
|
|
24
24
|
|
|
25
25
|
- **SyncTarget**: `{ id, kind, capabilities: { posixShell, tarExtract, authFileWrite } }`
|
|
26
26
|
- **TargetExecutor**: `{ probe(plan), prepare(plan, { syncRunId }), putTar({ kind, payload }), finalize(plan, { syncRunId }) }`
|
|
27
|
-
- `payload`: `Buffer | Uint8Array
|
|
27
|
+
- `payload`: `Buffer | Uint8Array` — all executors currently buffer; reintroduce streaming only when a transport needs it.
|
|
28
28
|
- **Plan** (source-computed): `{ direction: 'push' | 'pull', syncRunId?, sourceTargetId?, targetId?, files, directories, agentsRoot, authFile, deletes, totalBytes, selections? }`
|
|
29
29
|
- **Selections**: `{ fileGroups: boolean[], singleFiles: boolean[], directories: boolean[], agentsRoot: boolean, authFile: boolean }`
|
|
30
30
|
- Preview and apply must share one selections snapshot so the confirmed scope cannot drift.
|