@openchambery/web 2.1.7-beta.9 → 2.1.8-beta.10
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-DxS7U-Cp.js → AgentAvatar-mchFM3n-.js} +1 -1
- package/dist/assets/AssistantView-pLVk20s1.js +3 -0
- package/dist/assets/{DiagramView-Bw2sCV3Y.js → DiagramView-DmaKTHol.js} +1 -1
- package/dist/assets/{FadeInOnReveal-kNFcAb-K.js → FadeInOnReveal-BXDM9joC.js} +1 -1
- package/dist/assets/{Icon-DxBvAKXH.js → Icon-BGp_ize2.js} +1 -1
- package/dist/assets/{ImageLoadingPlaceholder-DQvRSYkt.js → ImageLoadingPlaceholder-DmUxjyxq.js} +1 -1
- package/dist/assets/{LoadingImage-CaXJqavb.js → LoadingImage-DGjlKW_O.js} +1 -1
- package/dist/assets/MarkdownRenderer-VYYHEQHc.js +2 -0
- package/dist/assets/{MarkdownRendererImpl-EACzZK2L.js → MarkdownRendererImpl-BQPOlcOs.js} +2 -2
- package/dist/assets/{MarkstreamRendererImpl-yohfk508.js → MarkstreamRendererImpl-B9DtJ2K2.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-ByVEkbBy.js → MobileDetailNavigation-9MnryETM.js} +1 -1
- package/dist/assets/{MobileShareBridge-DddCiczO.js → MobileShareBridge-e3WTrRks.js} +2 -2
- package/dist/assets/MobileTabPageHeader-Fh7QCZc7.js +2 -0
- package/dist/assets/{MultiRunWindow-CQWEGqds.js → MultiRunWindow-sfoabcvx.js} +1 -1
- package/dist/assets/{OnboardingScreen-BGZp5N1h.js → OnboardingScreen-DmJgU0Jc.js} +1 -1
- package/dist/assets/{OverlayScrollbar-OftHd9vR.js → OverlayScrollbar-BkJCkZp3.js} +1 -1
- package/dist/assets/{SettingsView-CDkq_RGv.js → SettingsView-DbNglFY1.js} +10 -9
- package/dist/assets/{SettingsWindow-CQhiLvNH.js → SettingsWindow-BGzCAKgh.js} +1 -1
- package/dist/assets/{TerminalView-CEiDqWoI.js → TerminalView-oT_BTx_W.js} +1 -1
- package/dist/assets/{ToolOutputDialog-jXsCdUsx.js → ToolOutputDialog-D85_cJ2B.js} +1 -1
- package/dist/assets/{appThemeRegistry-C90Vniux.js → appThemeRegistry-CGjHffdt.js} +1 -1
- package/dist/assets/{button-DfHNBl0U.js → button-BKjGCkH3.js} +1 -1
- package/dist/assets/{collapsible-BJ3u2c1m.js → collapsible-03FGLsF4.js} +1 -1
- package/dist/assets/{desktopHosts-BKeEWmL5.js → desktopHosts-BZ7iYE-l.js} +1 -1
- package/dist/assets/{desktopRelayRestore-FWm2fXr6.js → desktopRelayRestore-S9wEiY2U.js} +1 -1
- package/dist/assets/{diagram-KOjGEiKO.js → diagram-D4QHcaSQ.js} +1 -1
- package/dist/assets/{dialog-DBNBD_KB.js → dialog-nm_cQEUZ.js} +1 -1
- package/dist/assets/dropdown-menu.styles-WkR8IcF2.js +1 -0
- package/dist/assets/es-C9PRwAQm.js +15 -0
- package/dist/assets/{fileMentionTouchSelection-zOe2SiY_.js → fileMentionTouchSelection-DujPmFPX.js} +1 -1
- package/dist/assets/{fileQueries-Dhgypzyl.js → fileQueries-DZR-mK8h.js} +1 -1
- package/dist/assets/fr-2tfGTcme.js +13 -0
- package/dist/assets/{gitApi-pv3A0rDM.js → gitApi-CrqGdlbS.js} +1 -1
- package/dist/assets/{image-loading-DLh8WsH4.js → image-loading-CsxDGgek.js} +1 -1
- package/dist/assets/{input-CQ5f8Zqb.js → input-Buww8enN.js} +1 -1
- package/dist/assets/insertionBoundaries-o5RDEXaZ.js +1 -0
- package/dist/assets/ja-D7NHmhJr.js +15 -0
- package/dist/assets/ko-DeGmXmv6.js +15 -0
- package/dist/assets/main-6vgvFImW.js +169 -0
- package/dist/assets/main-Zix8q1Qb.js +2 -0
- package/dist/assets/{markdownHeightCache-BJ7s_IuQ.js → markdownHeightCache-B6wZ-UwD.js} +1 -1
- package/dist/assets/{mcpQueries-BlrZ9jet.js → mcpQueries-BVPwR92u.js} +1 -1
- package/dist/assets/miniChat-BYZJ4yQd.js +2 -0
- package/dist/assets/mobile-CvQVwDyf.js +2 -0
- package/dist/assets/{multirun-CZOOUr7O.js → multirun-BoFWk8S5.js} +1 -1
- package/dist/assets/{persistence-DUPZl7Oy.js → persistence-B4Ni3Gmv.js} +1 -1
- package/dist/assets/{pl-BE8MC0XQ.js → pl-CyWLY1AT.js} +3 -3
- package/dist/assets/{platform-z2erC6Bs.js → platform-BQPsr8Zl.js} +2 -2
- package/dist/assets/{projectMeta-DiTRU5Dp.js → projectMeta-B073H1YC.js} +1 -1
- package/dist/assets/pt-BR-D9VVxvts.js +15 -0
- package/dist/assets/{renderElectronMiniChatApp-DfB14cCR.js → renderElectronMiniChatApp-Bu1k2_XW.js} +2 -2
- package/dist/assets/renderMobileApp-wEjrkNAR.js +28 -0
- package/dist/assets/{runtimeConfig-DHFwtNBi.js → runtimeConfig-DKN9Jyz-.js} +1 -1
- package/dist/assets/runtimeEndpointReset-Cqf421q-.js +149 -0
- package/dist/assets/scheduledTaskNavigation-C-HGgCQA.js +1 -0
- package/dist/assets/sessionChangeSummary-COHW-iNe.js +1 -0
- package/dist/assets/{sessionLookup-BSK3URnU.js → sessionLookup-ByvikQSd.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-D655zJ3T.js → sortable-tabs-strip-cHZM0f0Z.js} +1 -1
- package/dist/assets/speechSettingsQueries-BJ7hAvj0.js +4 -0
- package/dist/assets/src-OjoeiMbd.css +2 -0
- package/dist/assets/store-Ds-7Em7C.js +15 -0
- package/dist/assets/{terminalApi-BnjU_lmF.js → terminalApi-kRMttlcP.js} +1 -1
- package/dist/assets/{textarea-HlFXg84f.js → textarea-BZHD_GND.js} +1 -1
- package/dist/assets/{tooltip-BQgoGUWx.js → tooltip-DCeWvAqN.js} +1 -1
- package/dist/assets/uk-Df44svOl.js +15 -0
- package/dist/assets/{url-DAyfGWPH.js → url-Bd61gNgl.js} +1 -1
- package/dist/assets/{useAppFontEffects-BQbKLVsD.js → useAppFontEffects-BqrJlSC2.js} +51 -53
- package/dist/assets/{useAssistantUIStore-Dt0l5AKe.js → useAssistantUIStore-Dlvb7P0q.js} +1 -1
- package/dist/assets/{useDesktopWindowControlsLayout-BIn2hI3V.js → useDesktopWindowControlsLayout-DgB-LNHC.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-BycgG8Ov.js → useEffectiveDirectory-axifbbuL.js} +1 -1
- package/dist/assets/{useFontPreferences-B4Cgu3bS.js → useFontPreferences-lAw0Cbza.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DnmY3KH3.js +417 -0
- package/dist/assets/useUIStore-CDMI_Yzu.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-DmI50_3w.js → useWorkerHighlightedLines-D2YpPANs.js} +1 -1
- package/dist/assets/vendor-codemirror-autocomplete-7c2L_4dM.js +12 -0
- package/dist/assets/vendor-codemirror-commands-B8KrBQ9B.js +1 -0
- package/dist/assets/vendor-codemirror-lang-angular-DXOgWEVj.js +23 -0
- package/dist/assets/{vendor-codemirror-lang-cpp-C3UKgJaQ.js → vendor-codemirror-lang-cpp-Dh8C3xqP.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-go-Cv5NYdGm.js → vendor-codemirror-lang-go-BXAJ1_dh.js} +5 -5
- package/dist/assets/{vendor-codemirror-lang-java-D6hBTIgD.js → vendor-codemirror-lang-java-DAyCHRhk.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-jinja-ehGXhAov.js → vendor-codemirror-lang-jinja-0TICrMfO.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-json-tHviF6YN.js → vendor-codemirror-lang-json-mLulNbwB.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-less-B-yDfSDo.js → vendor-codemirror-lang-less-CNvptX3P.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-liquid-BdoLH4KN.js → vendor-codemirror-lang-liquid-8g5seytq.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-markdown-CV1IO6Qs.js +6 -0
- package/dist/assets/{vendor-codemirror-lang-php-Ch8GW4Xz.js → vendor-codemirror-lang-php-xP7rf28_.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-python-YM2WVLHF.js +9 -0
- package/dist/assets/{vendor-codemirror-lang-rust-BEUlElU6.js → vendor-codemirror-lang-rust-CtafR0gt.js} +1 -1
- package/dist/assets/vendor-codemirror-lang-sass-B4iav_Ps.js +1 -0
- package/dist/assets/{vendor-codemirror-lang-sql-BnZql454.js → vendor-codemirror-lang-sql-C6q09UUZ.js} +2 -2
- package/dist/assets/{vendor-codemirror-lang-vue-BCwQBvLR.js → vendor-codemirror-lang-vue-DIB78UsR.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-wast-mrtA2eML.js → vendor-codemirror-lang-wast-C_JVdOCF.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-xml-ClAh-G2D.js → vendor-codemirror-lang-xml-DIuzY_d4.js} +1 -1
- package/dist/assets/{vendor-codemirror-lang-yaml-CjL8XEf2.js → vendor-codemirror-lang-yaml-BO0hIfQM.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-DGDYune4.js → vendor-codemirror-language-data-Ctd4WxgW.js} +2 -2
- package/dist/assets/vendor-codemirror-search-ZqtrPLjU.js +2 -0
- package/dist/assets/{vendor-replit-codemirror-vim-CEibx3EP.js → vendor-replit-codemirror-vim-DDj0dSVY.js} +6 -6
- package/dist/assets/zh-CN-wowfzo60.js +15 -0
- package/dist/assets/zh-TW-DwojcgA_.js +15 -0
- package/dist/index.html +9 -9
- package/dist/mini-chat.html +9 -9
- package/dist/mobile.html +9 -9
- package/package.json +1 -1
- package/server/lib/assistants/DOCUMENTATION.md +40 -1
- package/server/lib/assistants/harness.js +21 -8
- package/server/lib/assistants/harness.test.js +22 -1
- package/server/lib/assistants/routes.js +2 -1
- package/server/lib/assistants/service.js +76 -13
- package/server/lib/assistants/service.test.js +53 -0
- package/server/lib/config-sync/DOCUMENTATION.md +38 -11
- package/server/lib/config-sync/config-sync.test.js +11 -11
- package/server/lib/config-sync/constants.js +0 -3
- package/server/lib/config-sync/contract.js +8 -8
- package/server/lib/config-sync/credentials.js +101 -0
- package/server/lib/config-sync/credentials.test.js +98 -0
- package/server/lib/config-sync/engine.js +17 -11
- package/server/lib/config-sync/index.js +0 -3
- package/server/lib/config-sync/local-backup.js +0 -13
- package/server/lib/config-sync/magpie-auth.js +111 -0
- package/server/lib/config-sync/magpie-auth.test.js +79 -0
- package/server/lib/config-sync/magpie.js +276 -0
- package/server/lib/config-sync/magpie.test.js +265 -0
- package/server/lib/config-sync/plan.js +32 -37
- package/server/lib/config-sync/receiver.js +28 -25
- package/server/lib/config-sync/routes.js +23 -2
- package/server/lib/config-sync/routes.test.js +64 -5
- package/server/lib/config-sync/scripts.js +2 -30
- package/server/lib/config-sync/selections.js +14 -10
- package/server/lib/notifications/DOCUMENTATION.md +5 -0
- package/server/lib/opencode/DOCUMENTATION.md +4 -1
- package/server/lib/opencode/auth.d.ts +1 -0
- package/server/lib/opencode/auth.js +1 -1
- package/server/lib/opencode/config-entities-layering.test.js +2 -1
- package/server/lib/opencode/feature-routes-runtime.js +2 -0
- package/server/lib/opencode/lifecycle.js +4 -0
- package/server/lib/opencode/lifecycle.test.js +7 -2
- package/server/lib/opencode/plugin-routes.test.js +1 -1
- package/server/lib/opencode/plugins.js +31 -21
- package/server/lib/opencode/plugins.test.js +55 -10
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +25 -0
- package/server/lib/scheduled-tasks/run-history-store.js +18 -5
- package/server/lib/scheduled-tasks/run-history-store.test.js +51 -0
- package/server/lib/scheduled-tasks/runtime.js +70 -12
- package/server/lib/scheduled-tasks/runtime.test.js +131 -0
- package/server/lib/session-folders/DOCUMENTATION.md +20 -0
- package/server/lib/session-folders/order-routes.js +69 -0
- package/server/lib/session-folders/routes.js +3 -0
- package/server/lib/session-folders/routes.test.js +49 -0
- package/server/lib/session-index/DOCUMENTATION.md +3 -0
- package/server/lib/session-index/sync-runtime.js +9 -4
- package/server/lib/session-index/sync-runtime.test.js +27 -0
- package/dist/assets/AssistantView-Bnaa8U5L.js +0 -3
- package/dist/assets/MarkdownRenderer-DpXaDH86.js +0 -2
- package/dist/assets/MobileTabPageHeader-CAtpMLFM.js +0 -2
- package/dist/assets/context-menu-DBNnM4a7.js +0 -1
- package/dist/assets/dropdown-menu.styles-Cbl6jSsR.js +0 -1
- package/dist/assets/es-2YvsazK2.js +0 -15
- package/dist/assets/fr-Cy0M48Gw.js +0 -13
- package/dist/assets/insertionBoundaries-CCxWV_ir.js +0 -1
- package/dist/assets/ja-1zLWWJ2R.js +0 -15
- package/dist/assets/ko-BLk_5Dhc.js +0 -15
- package/dist/assets/main-BbqWl6PU.js +0 -2
- package/dist/assets/main-KrnbhzQQ.js +0 -169
- package/dist/assets/miniChat-7ywVBnvf.js +0 -2
- package/dist/assets/mobile-_nwad-rW.js +0 -2
- package/dist/assets/pt-BR-ubhNymyH.js +0 -15
- package/dist/assets/renderMobileApp-fVXPQqNd.js +0 -28
- package/dist/assets/runtimeEndpointReset-DOHn9DSm.js +0 -149
- package/dist/assets/sessionChangeSummary-Y9NdeIoe.js +0 -2
- package/dist/assets/speechSettingsQueries-ojxskgsA.js +0 -4
- package/dist/assets/src-GOs_VnY-.css +0 -2
- package/dist/assets/store-n-H5RvRn.js +0 -15
- package/dist/assets/uk-BNyyigDo.js +0 -15
- package/dist/assets/useMobileNavigationStore-DZUur_4b.js +0 -417
- package/dist/assets/useUIStore-pl39iO82.js +0 -1
- package/dist/assets/vendor-codemirror-autocomplete-DHomsvW7.js +0 -12
- package/dist/assets/vendor-codemirror-commands-C9DCaJ0C.js +0 -1
- package/dist/assets/vendor-codemirror-lang-angular-BDXnzWgq.js +0 -23
- package/dist/assets/vendor-codemirror-lang-markdown-Bt-vLz9k.js +0 -6
- package/dist/assets/vendor-codemirror-lang-python-B4naLXMo.js +0 -9
- package/dist/assets/vendor-codemirror-lang-sass-ibtp2iUB.js +0 -1
- package/dist/assets/vendor-codemirror-search-D2SOgzph.js +0 -2
- package/dist/assets/zh-CN-CNWzMPgL.js +0 -15
- package/dist/assets/zh-TW-DrZ9S1yn.js +0 -15
|
@@ -52,6 +52,9 @@ describe('opencode plugins data layer', () => {
|
|
|
52
52
|
expect(plugins.parsePluginRaw('foo@1.0.0')).toEqual({ spec: 'foo@1.0.0' });
|
|
53
53
|
expect(plugins.parsePluginRaw(['foo', { a: 1 }])).toEqual({ spec: 'foo', options: { a: 1 } });
|
|
54
54
|
expect(plugins.parsePluginRaw(['foo', {}])).toEqual({ spec: 'foo', options: {} });
|
|
55
|
+
expect(plugins.parsePluginRaw({ package: 'foo', options: { a: 1 } })).toEqual({ spec: 'foo', options: { a: 1 } });
|
|
56
|
+
expect(plugins.parsePluginRaw({ package: 'foo' })).toEqual({ spec: 'foo' });
|
|
57
|
+
expect(() => plugins.parsePluginRaw({ package: 'foo', options: [] })).toThrow('Plugin spec');
|
|
55
58
|
expect(() => plugins.parsePluginRaw(123)).toThrow('Plugin spec');
|
|
56
59
|
});
|
|
57
60
|
|
|
@@ -59,7 +62,7 @@ describe('opencode plugins data layer', () => {
|
|
|
59
62
|
expect(plugins.serializePluginEntry({ spec: 'foo' })).toBe('foo');
|
|
60
63
|
expect(plugins.serializePluginEntry({ spec: 'foo', options: undefined })).toBe('foo');
|
|
61
64
|
expect(plugins.serializePluginEntry({ spec: 'foo', options: {} })).toBe('foo');
|
|
62
|
-
expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual(
|
|
65
|
+
expect(plugins.serializePluginEntry({ spec: 'foo', options: { a: 1 } })).toEqual({ package: 'foo', options: { a: 1 } });
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
test('rejects invalid specs and file names', () => {
|
|
@@ -71,11 +74,11 @@ describe('opencode plugins data layer', () => {
|
|
|
71
74
|
expect(() => plugins.writePluginDirFile({ fileName: 'foo.txt', content: '', scope: 'project' }, projectDir)).toThrow('Plugin file name');
|
|
72
75
|
});
|
|
73
76
|
|
|
74
|
-
test('creates string and
|
|
77
|
+
test('creates V2 string and object entries with duplicate rejection', () => {
|
|
75
78
|
plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir);
|
|
76
79
|
plugins.createPluginEntry({ spec: 'tuple-plugin', options: { apiKey: 'x' }, scope: 'user' }, projectDir);
|
|
77
80
|
|
|
78
|
-
expect(readJson(userConfigPath).
|
|
81
|
+
expect(readJson(userConfigPath).plugins).toEqual(['plain-plugin', { package: 'tuple-plugin', options: { apiKey: 'x' } }]);
|
|
79
82
|
expect(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir)).toThrow('already exists');
|
|
80
83
|
expect(thrownBy(() => plugins.createPluginEntry({ spec: 'plain-plugin', scope: 'user' }, projectDir))).toHaveProperty('code', 'ENTRY_EXISTS');
|
|
81
84
|
});
|
|
@@ -84,8 +87,8 @@ describe('opencode plugins data layer', () => {
|
|
|
84
87
|
plugins.createPluginEntry({ spec: 'user-plugin', scope: 'user' }, projectDir);
|
|
85
88
|
plugins.createPluginEntry({ spec: 'project-plugin', scope: 'project' }, projectDir);
|
|
86
89
|
|
|
87
|
-
expect(readJson(userConfigPath).
|
|
88
|
-
expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).
|
|
90
|
+
expect(readJson(userConfigPath).plugins).toEqual(['user-plugin']);
|
|
91
|
+
expect(readJson(path.join(projectDir, '.opencode', 'opencode.json')).plugins).toEqual(['project-plugin']);
|
|
89
92
|
});
|
|
90
93
|
|
|
91
94
|
test('re-resolves custom config env between calls', () => {
|
|
@@ -100,20 +103,62 @@ describe('opencode plugins data layer', () => {
|
|
|
100
103
|
plugins.createPluginEntry({ spec: 'second-plugin', scope: 'user' }, projectDir);
|
|
101
104
|
plugins.writePluginDirFile({ fileName: 'second.js', content: 'two', scope: 'user' }, projectDir);
|
|
102
105
|
|
|
103
|
-
expect(readJson(firstConfigPath).
|
|
104
|
-
expect(readJson(secondConfigPath).
|
|
106
|
+
expect(readJson(firstConfigPath).plugins).toEqual(['first-plugin']);
|
|
107
|
+
expect(readJson(secondConfigPath).plugins).toEqual(['second-plugin']);
|
|
105
108
|
expect(fs.existsSync(path.join(path.dirname(firstConfigPath), 'plugins', 'first.js'))).toBe(true);
|
|
106
109
|
expect(fs.existsSync(path.join(path.dirname(secondConfigPath), 'plugins', 'second.js'))).toBe(true);
|
|
107
110
|
});
|
|
108
111
|
|
|
109
|
-
test('
|
|
112
|
+
test('migrates legacy entries in place and transitions between string and object', () => {
|
|
110
113
|
writeJson(userConfigPath, { plugin: ['first', ['second', { a: 1 }], 'third'] });
|
|
111
114
|
|
|
112
115
|
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:second'), { spec: 'second-new', options: {} }, projectDir);
|
|
113
|
-
expect(readJson(userConfigPath)
|
|
116
|
+
expect(readJson(userConfigPath)).toEqual({ plugins: ['first', 'second-new', 'third'] });
|
|
114
117
|
|
|
115
118
|
plugins.updatePluginEntry(plugins.encodePluginId('config', 'user:first'), { spec: 'first-new', options: { b: 2 } }, projectDir);
|
|
116
|
-
expect(readJson(userConfigPath).
|
|
119
|
+
expect(readJson(userConfigPath).plugins).toEqual([{ package: 'first-new', options: { b: 2 } }, 'second-new', 'third']);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('converts untouched legacy options on save and preserves unrelated JSONC', () => {
|
|
123
|
+
fs.writeFileSync(userConfigPath, '// retained comment\n' + JSON.stringify({ model: 'example/model', plugin: [['old', { nested: { enabled: false } }]] }));
|
|
124
|
+
plugins.createPluginEntry({ spec: 'new', scope: 'user' }, projectDir);
|
|
125
|
+
const text = fs.readFileSync(userConfigPath, 'utf8');
|
|
126
|
+
expect(text).toContain('// retained comment');
|
|
127
|
+
expect(plugins.listPluginEntries(projectDir).map(({ spec, options }) => ({ spec, options }))).toEqual([
|
|
128
|
+
{ spec: 'old', options: { nested: { enabled: false } } }, { spec: 'new', options: undefined },
|
|
129
|
+
]);
|
|
130
|
+
expect(text).toContain('"plugins"');
|
|
131
|
+
expect(text).not.toContain('"plugin"');
|
|
132
|
+
expect(text).toContain('example/model');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('prefers native plugins over legacy entries including an empty native array', () => {
|
|
136
|
+
writeJson(userConfigPath, { plugin: ['shadowed'], plugins: [{ package: 'native', options: { enabled: false } }] });
|
|
137
|
+
expect(plugins.listPluginEntries(projectDir).map((entry) => entry.spec)).toEqual(['native']);
|
|
138
|
+
expect(() => plugins.createPluginEntry({ spec: 'native' }, projectDir)).toThrow('already exists');
|
|
139
|
+
plugins.deletePluginEntry(plugins.encodePluginId('config', 'user:native'), projectDir);
|
|
140
|
+
expect(readJson(userConfigPath)).toEqual({});
|
|
141
|
+
writeJson(userConfigPath, { plugin: ['shadowed'], plugins: [] });
|
|
142
|
+
expect(plugins.listPluginEntries(projectDir)).toEqual([]);
|
|
143
|
+
plugins.createPluginEntry({ spec: 'new' }, projectDir);
|
|
144
|
+
expect(readJson(userConfigPath)).toEqual({ plugins: ['new'] });
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('malformed legacy data and failed atomic writes leave original config intact', () => {
|
|
148
|
+
writeJson(userConfigPath, { plugin: [['bad', null]], model: 'example/model' });
|
|
149
|
+
const malformed = fs.readFileSync(userConfigPath, 'utf8');
|
|
150
|
+
expect(() => plugins.createPluginEntry({ spec: 'new' }, projectDir)).toThrow('Plugin spec');
|
|
151
|
+
expect(fs.readFileSync(userConfigPath, 'utf8')).toBe(malformed);
|
|
152
|
+
|
|
153
|
+
writeJson(userConfigPath, { plugin: [['old', { enabled: false }]] });
|
|
154
|
+
const original = fs.readFileSync(userConfigPath, 'utf8');
|
|
155
|
+
const rename = vi.spyOn(fs, 'renameSync').mockImplementation(() => { throw new Error('write failed'); });
|
|
156
|
+
try {
|
|
157
|
+
expect(() => plugins.createPluginEntry({ spec: 'new' }, projectDir)).toThrow('write failed');
|
|
158
|
+
expect(fs.readFileSync(userConfigPath, 'utf8')).toBe(original);
|
|
159
|
+
} finally {
|
|
160
|
+
rename.mockRestore();
|
|
161
|
+
}
|
|
117
162
|
});
|
|
118
163
|
|
|
119
164
|
test('deletes entries and prunes empty plugin key', () => {
|
|
@@ -10,6 +10,31 @@ Server-owned scheduled task runtime and routes for OpenChamber-only automation.
|
|
|
10
10
|
- This module is OpenChamber feature logic; it is intentionally separate from OpenCode proxy/runtime internals.
|
|
11
11
|
- Prompt and goal execution preserve hash-prefixed text literally; no snippet discovery or expansion occurs before dispatch.
|
|
12
12
|
|
|
13
|
+
## Assistant result handoff
|
|
14
|
+
|
|
15
|
+
`setTaskResultHandler(handler)` installs the Host's assistant result consumer and
|
|
16
|
+
returns its cleanup function. The feature-route composition wires it to the
|
|
17
|
+
assistant service. A handler returns `true` once associated contacts have reviewed
|
|
18
|
+
the result (including explicit silence); `false` retains ordinary scheduled-task
|
|
19
|
+
notification delivery. The scheduler releases its execution slot independently
|
|
20
|
+
of assistant inference. Handler failures keep the result pending.
|
|
21
|
+
|
|
22
|
+
Run history stores the original execution prompt privately alongside a
|
|
23
|
+
`result_pending` marker. The same terminal history update makes the result
|
|
24
|
+
eligible for delivery, including an in-flight run reconciled after restart.
|
|
25
|
+
`listPendingResults` uses bounded keyset pages of 20; the existing five-second
|
|
26
|
+
recovery pass retries handoffs with at most four in flight per runtime. A failing
|
|
27
|
+
page does not prevent the cursor from reaching later results. `acknowledgeResult`
|
|
28
|
+
clears only the delivery marker, never the execution result. Execution failure,
|
|
29
|
+
history-persistence failure, and assistant delivery failure remain distinct.
|
|
30
|
+
|
|
31
|
+
The additive migration leaves already-terminal old history acknowledged, avoiding
|
|
32
|
+
an upgrade notification backlog. Old running records enroll when they really
|
|
33
|
+
settle. Public history DTOs expose neither the prompt nor internal delivery state.
|
|
34
|
+
Assistant receipts deduplicate completed reviews, including silent ones; this is
|
|
35
|
+
a retryable handoff, not an exactly-once guarantee for model requests or device
|
|
36
|
+
pushes during crashes/concurrent Hosts. Result review is read-only.
|
|
37
|
+
|
|
13
38
|
## Built-in new-session skill
|
|
14
39
|
|
|
15
40
|
`session-skill.js` intercepts new `POST /api/session` admissions on a Host using
|
|
@@ -113,6 +113,12 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
113
113
|
if (!hasSlotAt) {
|
|
114
114
|
db.exec('ALTER TABLE scheduled_task_run ADD COLUMN slot_at INTEGER');
|
|
115
115
|
}
|
|
116
|
+
db.transaction(() => {
|
|
117
|
+
const columns = new Set(db.prepare("SELECT name FROM pragma_table_info('scheduled_task_run')").all().map((row) => row.name));
|
|
118
|
+
if (!columns.has('result_pending')) db.exec('ALTER TABLE scheduled_task_run ADD COLUMN result_pending INTEGER NOT NULL DEFAULT 0');
|
|
119
|
+
if (!columns.has('execution_prompt')) db.exec('ALTER TABLE scheduled_task_run ADD COLUMN execution_prompt TEXT');
|
|
120
|
+
db.exec("CREATE INDEX IF NOT EXISTS scheduled_task_run_pending_result ON scheduled_task_run(started_at DESC, run_id DESC) WHERE result_pending=1 AND status IN ('success','error')");
|
|
121
|
+
})();
|
|
116
122
|
db.exec(`
|
|
117
123
|
CREATE UNIQUE INDEX IF NOT EXISTS scheduled_task_run_scheduled_slot
|
|
118
124
|
ON scheduled_task_run (project_id, task_id, slot_at)
|
|
@@ -161,9 +167,9 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
161
167
|
db.prepare(`
|
|
162
168
|
INSERT INTO scheduled_task_run (
|
|
163
169
|
run_id, project_id, task_id, task_name, trigger, status,
|
|
164
|
-
session_id, directory, error, started_at, finished_at, duration_ms, slot_at
|
|
165
|
-
) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?)
|
|
166
|
-
`).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt);
|
|
170
|
+
session_id, directory, error, started_at, finished_at, duration_ms, slot_at, result_pending, execution_prompt
|
|
171
|
+
) VALUES (?, ?, ?, ?, ?, 'running', NULL, ?, NULL, ?, NULL, NULL, ?, 1, ?)
|
|
172
|
+
`).run(id, projectId, taskId, taskName, trigger, directory, startedAt, slotAt, typeof record.prompt === 'string' ? record.prompt : null);
|
|
167
173
|
} catch (error) {
|
|
168
174
|
if (slotAt !== null && SQLITE_UNIQUE_CODES.has(error?.code)) {
|
|
169
175
|
const claimed = new Error('scheduled slot already claimed');
|
|
@@ -220,6 +226,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
220
226
|
const updated = db.prepare(`
|
|
221
227
|
UPDATE scheduled_task_run
|
|
222
228
|
SET status = ?,
|
|
229
|
+
result_pending = CASE WHEN status='running' THEN 1 ELSE result_pending END,
|
|
223
230
|
session_id = ?,
|
|
224
231
|
error = ?,
|
|
225
232
|
finished_at = ?,
|
|
@@ -231,7 +238,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
231
238
|
return rowToDto(db.prepare('SELECT * FROM scheduled_task_run WHERE run_id = ?').get(id));
|
|
232
239
|
};
|
|
233
240
|
|
|
234
|
-
const listRuns = ({ before, limit, projectID, taskID, projectId, taskId } = {}, runningOnly = false) => {
|
|
241
|
+
const listRuns = ({ before, limit, projectID, taskID, projectId, taskId } = {}, runningOnly = false, pendingOnly = false) => {
|
|
235
242
|
assertOpen();
|
|
236
243
|
const cursor = decodeCursor(before);
|
|
237
244
|
const resolvedLimit = (() => {
|
|
@@ -249,6 +256,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
249
256
|
if (runningOnly) {
|
|
250
257
|
filters.push("status = 'running'");
|
|
251
258
|
}
|
|
259
|
+
if (pendingOnly) filters.push("result_pending=1 AND status IN ('success','error')");
|
|
252
260
|
const params = [];
|
|
253
261
|
const resolvedProjectID = asNonEmptyString(projectID ?? projectId);
|
|
254
262
|
const resolvedTaskID = asNonEmptyString(taskID ?? taskId);
|
|
@@ -278,7 +286,7 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
278
286
|
const hasMore = rows.length > resolvedLimit;
|
|
279
287
|
const last = pageRows[pageRows.length - 1];
|
|
280
288
|
return {
|
|
281
|
-
runs: pageRows.map(rowToDto),
|
|
289
|
+
runs: pageRows.map((row) => pendingOnly ? { ...rowToDto(row), prompt: row.execution_prompt } : rowToDto(row)),
|
|
282
290
|
nextCursor: hasMore && last
|
|
283
291
|
? encodeCursor({ startedAt: last.started_at, runID: last.run_id })
|
|
284
292
|
: null,
|
|
@@ -300,6 +308,11 @@ export const createScheduledTaskRunHistoryStore = ({ dbPath, clock = () => Date.
|
|
|
300
308
|
finishRun,
|
|
301
309
|
listRuns,
|
|
302
310
|
listRunningRuns: (options) => listRuns(options, true),
|
|
311
|
+
listPendingResults: (options) => listRuns(options, false, true),
|
|
312
|
+
acknowledgeResult: (runID) => {
|
|
313
|
+
assertOpen();
|
|
314
|
+
db.prepare("UPDATE scheduled_task_run SET result_pending=0 WHERE run_id=? AND status IN ('success','error')").run(runID);
|
|
315
|
+
},
|
|
303
316
|
close,
|
|
304
317
|
};
|
|
305
318
|
};
|
|
@@ -33,6 +33,57 @@ const startSample = (store, overrides = {}) => store.startRun({
|
|
|
33
33
|
});
|
|
34
34
|
|
|
35
35
|
describe('scheduled task run history store', () => {
|
|
36
|
+
it('keeps terminal result handoffs durable, with the execution prompt private and running runs excluded', () => {
|
|
37
|
+
const dbPath = createTempDbPath();
|
|
38
|
+
let store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
39
|
+
try {
|
|
40
|
+
store.startRun({ id: 'report', projectId: 'p', taskId: 't', prompt: 'Deliver research findings' });
|
|
41
|
+
expect(store.listPendingResults().runs).toEqual([]);
|
|
42
|
+
store.finishRun('report', { status: 'success' });
|
|
43
|
+
expect(store.listRuns().runs[0]).not.toHaveProperty('prompt');
|
|
44
|
+
store.close();
|
|
45
|
+
store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
46
|
+
expect(store.listPendingResults().runs).toEqual([expect.objectContaining({ id: 'report', prompt: 'Deliver research findings' })]);
|
|
47
|
+
store.acknowledgeResult('report');
|
|
48
|
+
store.close();
|
|
49
|
+
store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
50
|
+
expect(store.listPendingResults().runs).toEqual([]);
|
|
51
|
+
} finally { store.close(); }
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('migrates old history without replaying completed runs and enrolls recovered in-flight work', () => {
|
|
55
|
+
const dbPath = createTempDbPath();
|
|
56
|
+
const first = createScheduledTaskRunHistoryStore({ dbPath });
|
|
57
|
+
startSample(first, { id: 'old-complete' });
|
|
58
|
+
first.finishRun('old-complete', { status: 'success' });
|
|
59
|
+
startSample(first, { id: 'old-running' });
|
|
60
|
+
first.close();
|
|
61
|
+
const Database = require('better-sqlite3');
|
|
62
|
+
const db = new Database(dbPath);
|
|
63
|
+
db.exec('DROP INDEX scheduled_task_run_pending_result; ALTER TABLE scheduled_task_run DROP COLUMN result_pending; ALTER TABLE scheduled_task_run DROP COLUMN execution_prompt');
|
|
64
|
+
db.close();
|
|
65
|
+
const store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
66
|
+
try {
|
|
67
|
+
expect(store.listPendingResults().runs).toEqual([]);
|
|
68
|
+
store.finishRun('old-running', { status: 'error', error: 'actual failure' });
|
|
69
|
+
expect(store.listPendingResults().runs).toEqual([expect.objectContaining({ id: 'old-running', status: 'error', prompt: null })]);
|
|
70
|
+
} finally { store.close(); }
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not publish a result when the terminal history write rolls back', () => {
|
|
74
|
+
const dbPath = createTempDbPath();
|
|
75
|
+
const store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
76
|
+
startSample(store, { id: 'failed-write' });
|
|
77
|
+
const Database = require('better-sqlite3');
|
|
78
|
+
const db = new Database(dbPath);
|
|
79
|
+
db.exec("CREATE TRIGGER reject_finish BEFORE UPDATE OF status ON scheduled_task_run BEGIN SELECT RAISE(ABORT, 'disk write failed'); END");
|
|
80
|
+
try {
|
|
81
|
+
expect(() => store.finishRun('failed-write', { status: 'success' })).toThrow('disk write failed');
|
|
82
|
+
expect(store.listRuns().runs[0].status).toBe('running');
|
|
83
|
+
expect(store.listPendingResults().runs).toEqual([]);
|
|
84
|
+
} finally { db.close(); store.close(); }
|
|
85
|
+
});
|
|
86
|
+
|
|
36
87
|
it('lists runs newest-first and supports stable keyset pagination', () => {
|
|
37
88
|
let now = 1_000;
|
|
38
89
|
const store = createScheduledTaskRunHistoryStore({
|
|
@@ -1327,7 +1327,53 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1327
1327
|
runID,
|
|
1328
1328
|
error: safeErrorMessage(error),
|
|
1329
1329
|
});
|
|
1330
|
-
return { error };
|
|
1330
|
+
return { persistenceError: error };
|
|
1331
|
+
}
|
|
1332
|
+
};
|
|
1333
|
+
|
|
1334
|
+
let taskResultHandler = null;
|
|
1335
|
+
let resultRecoveryCursor;
|
|
1336
|
+
let resultRecoveryFlight = false;
|
|
1337
|
+
const deliveringResults = new Set();
|
|
1338
|
+
const deliverTaskResult = async (run) => {
|
|
1339
|
+
if (deliveringResults.has(run.id) || deliveringResults.size >= 4) return;
|
|
1340
|
+
deliveringResults.add(run.id);
|
|
1341
|
+
const handler = taskResultHandler;
|
|
1342
|
+
try {
|
|
1343
|
+
const handled = handler ? await handler(run) : false;
|
|
1344
|
+
if (handler !== taskResultHandler) return;
|
|
1345
|
+
if (!handled) {
|
|
1346
|
+
await notifyTaskRun?.({
|
|
1347
|
+
projectID: run.projectId, taskID: run.taskId, taskName: run.taskName,
|
|
1348
|
+
status: run.status, reason: run.trigger,
|
|
1349
|
+
...(run.sessionId ? { sessionId: run.sessionId } : {}),
|
|
1350
|
+
...(run.error ? { errorMessage: run.error } : {}),
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
runHistoryStore?.acknowledgeResult?.(run.id);
|
|
1354
|
+
} catch (error) {
|
|
1355
|
+
logger.warn?.('[ScheduledTasks] result delivery failed', { runID: run.id, error: safeErrorMessage(error) });
|
|
1356
|
+
} finally {
|
|
1357
|
+
deliveringResults.delete(run.id);
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
const recoverTaskResults = async () => {
|
|
1361
|
+
if (resultRecoveryFlight || !taskResultHandler || typeof runHistoryStore?.listPendingResults !== 'function') return;
|
|
1362
|
+
resultRecoveryFlight = true;
|
|
1363
|
+
try {
|
|
1364
|
+
const page = runHistoryStore.listPendingResults({ before: resultRecoveryCursor, limit: 20 });
|
|
1365
|
+
resultRecoveryCursor = page.nextCursor || undefined;
|
|
1366
|
+
let index = 0;
|
|
1367
|
+
await Promise.all(Array.from({ length: Math.min(4, page.runs.length) }, async () => {
|
|
1368
|
+
while (index < page.runs.length && taskResultHandler) {
|
|
1369
|
+
const run = page.runs[index++];
|
|
1370
|
+
await deliverTaskResult(run);
|
|
1371
|
+
}
|
|
1372
|
+
}));
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
logger.warn?.('[ScheduledTasks] result recovery failed', { error: safeErrorMessage(error) });
|
|
1375
|
+
} finally {
|
|
1376
|
+
resultRecoveryFlight = false;
|
|
1331
1377
|
}
|
|
1332
1378
|
};
|
|
1333
1379
|
|
|
@@ -1375,6 +1421,7 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1375
1421
|
trigger: reason === 'manual' ? 'manual' : 'scheduled',
|
|
1376
1422
|
directory: projectPath,
|
|
1377
1423
|
startedAt: runStartedAt,
|
|
1424
|
+
prompt: task.execution?.prompt,
|
|
1378
1425
|
...(resolvedSlotAt !== undefined ? { slotAt: resolvedSlotAt } : {}),
|
|
1379
1426
|
});
|
|
1380
1427
|
} catch (error) {
|
|
@@ -1587,9 +1634,9 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1587
1634
|
...(sessionID ? { sessionId: sessionID } : {}),
|
|
1588
1635
|
...(status === 'error' ? { error: errorMessage || 'Unknown error' } : {}),
|
|
1589
1636
|
});
|
|
1590
|
-
if (historyFinalize?.
|
|
1637
|
+
if (historyFinalize?.persistenceError) {
|
|
1591
1638
|
status = 'error';
|
|
1592
|
-
errorMessage = errorMessage || safeErrorMessage(historyFinalize.
|
|
1639
|
+
errorMessage = errorMessage || safeErrorMessage(historyFinalize.persistenceError);
|
|
1593
1640
|
}
|
|
1594
1641
|
|
|
1595
1642
|
try {
|
|
@@ -1606,15 +1653,21 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1606
1653
|
// Notification fanout for scheduled (non-manual) runs; the notification
|
|
1607
1654
|
// runtime owns every delivery gate (settings toggle, channels).
|
|
1608
1655
|
try {
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1656
|
+
if (taskResultHandler && historyFinalize && !historyFinalize.persistenceError) {
|
|
1657
|
+
// Reviewing a result is independent of the scheduler's execution slot.
|
|
1658
|
+
void deliverTaskResult({ ...historyFinalize, prompt: task.execution?.prompt });
|
|
1659
|
+
} else {
|
|
1660
|
+
await notifyTaskRun?.({
|
|
1661
|
+
projectID,
|
|
1662
|
+
taskID,
|
|
1663
|
+
taskName,
|
|
1664
|
+
status,
|
|
1665
|
+
reason,
|
|
1666
|
+
...(sessionID ? { sessionId: sessionID } : {}),
|
|
1667
|
+
...(status === 'error' ? { errorMessage } : {}),
|
|
1668
|
+
});
|
|
1669
|
+
if (!historyFinalize?.persistenceError && runID) runHistoryStore?.acknowledgeResult?.(runID);
|
|
1670
|
+
}
|
|
1618
1671
|
} catch (error) {
|
|
1619
1672
|
logger.warn?.('[ScheduledTasks] task-run notification failed', {
|
|
1620
1673
|
projectID,
|
|
@@ -1813,6 +1866,7 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1813
1866
|
if (controller.signal.aborted) return;
|
|
1814
1867
|
await recoverTaskStates(controller);
|
|
1815
1868
|
if (controller.signal.aborted) return;
|
|
1869
|
+
void recoverTaskResults();
|
|
1816
1870
|
historyRecoveryTimer = setTimeout(() => {
|
|
1817
1871
|
void recoverRunHistory(controller, nextCursor || undefined);
|
|
1818
1872
|
}, 5_000);
|
|
@@ -1994,5 +2048,9 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
1994
2048
|
runNow,
|
|
1995
2049
|
getStatus,
|
|
1996
2050
|
observeSessionEvent,
|
|
2051
|
+
setTaskResultHandler: (handler) => {
|
|
2052
|
+
taskResultHandler = handler;
|
|
2053
|
+
return () => { if (taskResultHandler === handler) taskResultHandler = null; };
|
|
2054
|
+
},
|
|
1997
2055
|
};
|
|
1998
2056
|
};
|
|
@@ -5,6 +5,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
5
5
|
import { readFileSync } from 'node:fs';
|
|
6
6
|
import { dirname, join } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { createAssistantsService } from '../assistants/service.js';
|
|
9
|
+
import { runContactTurn as reviewTaskResult } from '../assistants/harness.js';
|
|
8
10
|
|
|
9
11
|
vi.mock('../opencode/v2-client.js', () => ({
|
|
10
12
|
makeOpenCodeV2Client: vi.fn(),
|
|
@@ -318,6 +320,135 @@ describe('scheduled-tasks history recovery', () => {
|
|
|
318
320
|
});
|
|
319
321
|
});
|
|
320
322
|
|
|
323
|
+
describe('scheduled task result handoff', () => {
|
|
324
|
+
it.each(['report', 'silent', 'failure'])('connects a real scheduled run to the owner contact with %s delivery', async (mode) => {
|
|
325
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduled-contact-'));
|
|
326
|
+
const store = createScheduledTaskRunHistoryStore({ dbPath: path.join(directory, 'runs.sqlite') });
|
|
327
|
+
let task = { ...scheduledTask, execution: { ...scheduledTask.execution, prompt: mode === 'report' ? 'Research and send a summary' : 'Do background work; no success report needed' } };
|
|
328
|
+
let releaseWorker;
|
|
329
|
+
const workerResult = new Promise((resolve) => { releaseWorker = resolve; });
|
|
330
|
+
const messageList = vi.fn(() => workerResult);
|
|
331
|
+
const client = {
|
|
332
|
+
session: {
|
|
333
|
+
create: vi.fn(async () => ({ id: 'worker' })),
|
|
334
|
+
prompt: vi.fn(async () => ({ id: 'inbox', type: 'user' })),
|
|
335
|
+
active: vi.fn(async () => ({})),
|
|
336
|
+
},
|
|
337
|
+
message: { list: messageList },
|
|
338
|
+
provider: { list: async () => ({ data: [] }) },
|
|
339
|
+
model: { list: async () => ({ data: [] }) },
|
|
340
|
+
};
|
|
341
|
+
makeOpenCodeV2Client.mockReturnValue(client);
|
|
342
|
+
const listProjects = async () => [{ id: 'project-1', path: directory }];
|
|
343
|
+
const projectConfigRuntime = {
|
|
344
|
+
listScheduledTasks: async () => [task],
|
|
345
|
+
upsertScheduledTask: async (_project, input) => ({ task: { ...task, ...input, id: 'task-1' } }),
|
|
346
|
+
updateScheduledTaskState: async (_project, _id, state) => {
|
|
347
|
+
task = { ...task, state: { ...task.state, ...state } };
|
|
348
|
+
return { task };
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
const notifyTaskRun = vi.fn();
|
|
352
|
+
const onContactTurnComplete = vi.fn();
|
|
353
|
+
const reviews = vi.fn(async (input) => {
|
|
354
|
+
expect(input.userText).toContain(task.execution.prompt);
|
|
355
|
+
if (mode === 'failure') expect(input.userText).toContain('upstream failed');
|
|
356
|
+
else expect(input.userText).toContain('Actual research findings');
|
|
357
|
+
return reviewTaskResult({ ...input, createChatCompletion: async () => ({ text: mode === 'silent'
|
|
358
|
+
? '```openchamber-final\n{"status":"silent","text":""}\n```'
|
|
359
|
+
: mode === 'failure' ? '任务执行失败,需要检查服务。' : '资料汇总:有两项值得关注的更新。' }) });
|
|
360
|
+
});
|
|
361
|
+
const runtime = createRuntime(null, {
|
|
362
|
+
projectConfigRuntime, listProjects, runHistoryStore: store, notifyTaskRun,
|
|
363
|
+
waitForOpenCodeReady: async () => {},
|
|
364
|
+
});
|
|
365
|
+
const service = createAssistantsService({
|
|
366
|
+
dbPath: path.join(directory, 'assistants.sqlite'), dataDir: directory,
|
|
367
|
+
getAllowedRoots: () => [directory], listProjects, clientFactory: () => client,
|
|
368
|
+
buildOpenCodeUrl: () => 'http://127.0.0.1:4096', getOpenCodeAuthHeaders: () => ({}),
|
|
369
|
+
upsertScheduledTask: projectConfigRuntime.upsertScheduledTask,
|
|
370
|
+
syncScheduledTaskProject: (id) => runtime.syncProject(id),
|
|
371
|
+
onContactTurnComplete,
|
|
372
|
+
runContactTurn: async (input) => {
|
|
373
|
+
if (input.readOnly) return reviews(input);
|
|
374
|
+
await input.tools.find((tool) => tool.name === 'schedule_task').execute('schedule', {
|
|
375
|
+
name: 'Task', prompt: task.execution.prompt, time: '23:59', timezone: 'UTC',
|
|
376
|
+
});
|
|
377
|
+
return { bubbles: ['已安排'] };
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
const detach = runtime.setTaskResultHandler((run) => service.reportScheduledTaskResult(run));
|
|
381
|
+
try {
|
|
382
|
+
service.setEnabled({ enabled: true, expectedRevision: service.snapshot().revision });
|
|
383
|
+
const owner = service.createAssistant({ name: 'Research', providerID: 'openai', modelID: 'gpt-4.1' });
|
|
384
|
+
const sent = await service.send(owner.id, { messageID: 'schedule', parts: [{ type: 'text', text: 'Create the recurring task' }] });
|
|
385
|
+
await service.whenContactTurnSettled(sent.messageID);
|
|
386
|
+
onContactTurnComplete.mockClear();
|
|
387
|
+
const run = runtime.runNow('project-1', 'task-1');
|
|
388
|
+
await vi.waitFor(() => expect(messageList).toHaveBeenCalled());
|
|
389
|
+
expect(reviews).not.toHaveBeenCalled();
|
|
390
|
+
releaseWorker({ data: [{ id: 'result', type: 'assistant', text: 'Actual research findings', time: { created: 1, completed: 2 }, finish: 'stop', ...(mode === 'failure' ? { error: { name: 'ProviderError', message: 'upstream failed' } } : {}) }] });
|
|
391
|
+
const result = await run;
|
|
392
|
+
expect(result.status).toBe(mode === 'failure' ? 'error' : 'success');
|
|
393
|
+
await vi.waitFor(() => expect(store.listPendingResults().runs).toEqual([]));
|
|
394
|
+
expect(reviews).toHaveBeenCalledOnce();
|
|
395
|
+
expect(notifyTaskRun).not.toHaveBeenCalled();
|
|
396
|
+
expect(onContactTurnComplete).toHaveBeenCalledTimes(mode === 'silent' ? 0 : 1);
|
|
397
|
+
expect(service.snapshot().assistants[0].working).toBe(false);
|
|
398
|
+
} finally {
|
|
399
|
+
detach(); runtime.stop(); service.close(); store.close();
|
|
400
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('recovers durable results after restart and isolates a failed handoff from other runs', async () => {
|
|
405
|
+
vi.useFakeTimers();
|
|
406
|
+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduled-result-recovery-'));
|
|
407
|
+
const dbPath = path.join(directory, 'runs.sqlite');
|
|
408
|
+
let store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
409
|
+
const retryIDs = Array.from({ length: 4 }, (_, index) => `z-retry-${index}`);
|
|
410
|
+
const healthyIDs = Array.from({ length: 21 }, (_, index) => `healthy-${index}`);
|
|
411
|
+
for (const id of [...retryIDs, ...healthyIDs, 'unowned']) {
|
|
412
|
+
store.startRun({ id, projectId: 'project-1', taskId: 'task-1', prompt: 'Original instruction' });
|
|
413
|
+
store.finishRun(id, { status: 'success' });
|
|
414
|
+
}
|
|
415
|
+
store.close();
|
|
416
|
+
store = createScheduledTaskRunHistoryStore({ dbPath });
|
|
417
|
+
let broken = true;
|
|
418
|
+
let active = 0;
|
|
419
|
+
let peak = 0;
|
|
420
|
+
const handler = vi.fn(async (run) => {
|
|
421
|
+
expect(run.prompt).toBe('Original instruction');
|
|
422
|
+
active += 1;
|
|
423
|
+
peak = Math.max(peak, active);
|
|
424
|
+
try {
|
|
425
|
+
await Promise.resolve();
|
|
426
|
+
if (retryIDs.includes(run.id) && broken) throw new Error('temporary handoff failure');
|
|
427
|
+
return run.id !== 'unowned';
|
|
428
|
+
} finally { active -= 1; }
|
|
429
|
+
});
|
|
430
|
+
const notifyTaskRun = vi.fn();
|
|
431
|
+
const runtime = createRuntime(vi.fn(async () => ({ task: scheduledTask })), { runHistoryStore: store, notifyTaskRun });
|
|
432
|
+
const detach = runtime.setTaskResultHandler(handler);
|
|
433
|
+
try {
|
|
434
|
+
await runtime.start();
|
|
435
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
436
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
437
|
+
expect(store.listPendingResults().runs.map((run) => run.id).sort()).toEqual(retryIDs);
|
|
438
|
+
expect(notifyTaskRun).toHaveBeenCalledOnce();
|
|
439
|
+
broken = false;
|
|
440
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
441
|
+
expect(store.listPendingResults().runs).toEqual([]);
|
|
442
|
+
for (const id of healthyIDs) expect(handler.mock.calls.filter(([run]) => run.id === id)).toHaveLength(1);
|
|
443
|
+
expect(peak).toBeLessThanOrEqual(4);
|
|
444
|
+
} finally {
|
|
445
|
+
detach(); runtime.stop(); store.close();
|
|
446
|
+
fs.rmSync(directory, { recursive: true, force: true });
|
|
447
|
+
vi.useRealTimers();
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
321
452
|
describe('scheduled-tasks runtime helpers', () => {
|
|
322
453
|
it('computes next daily run in timezone', () => {
|
|
323
454
|
const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Session folders and order
|
|
2
|
+
|
|
3
|
+
Folder membership/collapse retains `/api/session-folders` and
|
|
4
|
+
`sessions-directories.json`. Session ordering uses `/api/session-folders/order`
|
|
5
|
+
and `session-order.json` independently so an older folder client cannot erase it.
|
|
6
|
+
|
|
7
|
+
GET returns `{ revision, sessionOrderByScope, sessionOrderActivityByScope }`.
|
|
8
|
+
POST replaces one scope using `{ scopeKey, order, activity, onlyIfAbsent? }`.
|
|
9
|
+
Directory paths identify ordinary lists; `__pinned__` identifies global pins.
|
|
10
|
+
Writes serialize per Host, merge other scopes, atomically rename the new file,
|
|
11
|
+
then broadcast `openchamber:session-order-changed` through the product SSE stream.
|
|
12
|
+
Last committed drag wins within a scope; independent scopes never replace each
|
|
13
|
+
other. A failed write leaves the previous file and emits no success event.
|
|
14
|
+
Missing files mean an empty initial catalog; malformed/unreadable files fail.
|
|
15
|
+
|
|
16
|
+
`onlyIfAbsent` imports pre-existing local ranks without overwriting a scope
|
|
17
|
+
already saved by another device. It does not bump revision or emit a tip when
|
|
18
|
+
the scope already exists. Local caches are startup continuity, not authority.
|
|
19
|
+
Clients fetch first and refetch on tips/reconnect/foreground through the shared
|
|
20
|
+
runtime HTTP/SSE transport (direct and Relay use identical routes).
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const emptySnapshot = () => ({ revision: 0, sessionOrderByScope: {}, sessionOrderActivityByScope: {} });
|
|
2
|
+
|
|
3
|
+
export function registerSessionOrderRoutes(app, { fsPromises: fs, path, openchamberDataDir, broadcastOpenChamberEvent }) {
|
|
4
|
+
const file = path.join(openchamberDataDir, 'session-order.json');
|
|
5
|
+
let writes = Promise.resolve();
|
|
6
|
+
const read = async () => {
|
|
7
|
+
try {
|
|
8
|
+
const snapshot = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
9
|
+
if (!Number.isSafeInteger(snapshot?.revision) || snapshot.revision < 0
|
|
10
|
+
|| !snapshot.sessionOrderByScope || typeof snapshot.sessionOrderByScope !== 'object' || Array.isArray(snapshot.sessionOrderByScope)
|
|
11
|
+
|| !snapshot.sessionOrderActivityByScope || typeof snapshot.sessionOrderActivityByScope !== 'object' || Array.isArray(snapshot.sessionOrderActivityByScope)) {
|
|
12
|
+
throw new Error('Invalid session order file');
|
|
13
|
+
}
|
|
14
|
+
return snapshot;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.code === 'ENOENT') return emptySnapshot();
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
app.get('/api/session-folders/order', async (_req, res) => {
|
|
21
|
+
try {
|
|
22
|
+
await writes;
|
|
23
|
+
res.json(await read());
|
|
24
|
+
} catch (error) {
|
|
25
|
+
res.status(500).json({ error: error.message });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
app.post('/api/session-folders/order', async (req, res) => {
|
|
29
|
+
const { scopeKey, order, activity, onlyIfAbsent } = req.body ?? {};
|
|
30
|
+
if (typeof scopeKey !== 'string' || !scopeKey.trim() || scopeKey.length > 4096
|
|
31
|
+
|| !Array.isArray(order) || order.length > 10000
|
|
32
|
+
|| !order.every((id) => typeof id === 'string' && id.length > 0 && id.length < 512)
|
|
33
|
+
|| !activity || typeof activity !== 'object' || Array.isArray(activity)
|
|
34
|
+
|| Object.keys(activity).length > 10000
|
|
35
|
+
|| !Object.values(activity).every((value) => typeof value === 'number' && Number.isFinite(value))
|
|
36
|
+
|| (onlyIfAbsent !== undefined && typeof onlyIfAbsent !== 'boolean')) {
|
|
37
|
+
return res.status(400).json({ error: 'Invalid session order' });
|
|
38
|
+
}
|
|
39
|
+
const write = writes.then(async () => {
|
|
40
|
+
const current = await read();
|
|
41
|
+
if (onlyIfAbsent && Object.hasOwn(current.sessionOrderByScope, scopeKey)) return current;
|
|
42
|
+
const next = {
|
|
43
|
+
revision: current.revision + 1,
|
|
44
|
+
sessionOrderByScope: { ...current.sessionOrderByScope, [scopeKey]: [...new Set(order)] },
|
|
45
|
+
sessionOrderActivityByScope: { ...current.sessionOrderActivityByScope, [scopeKey]: activity },
|
|
46
|
+
};
|
|
47
|
+
await fs.mkdir(openchamberDataDir, { recursive: true });
|
|
48
|
+
const tmp = `${file}.tmp-${process.pid}-${next.revision}`;
|
|
49
|
+
try {
|
|
50
|
+
await fs.writeFile(tmp, JSON.stringify(next), 'utf8');
|
|
51
|
+
await fs.rename(tmp, file);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
await fs.unlink(tmp).catch(() => {});
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
broadcastOpenChamberEvent?.({
|
|
57
|
+
type: 'openchamber:session-order-changed',
|
|
58
|
+
properties: { occurredAt: Date.now() },
|
|
59
|
+
});
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
writes = write.then(() => {}, () => {});
|
|
63
|
+
try {
|
|
64
|
+
res.json(await write);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
res.status(500).json({ error: error.message });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|