@openchambery/web 2.0.0-beta.4 → 2.0.0-beta.6
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-Q1SmFRx2.js → AgentAvatar-BBmQXYDq.js} +1 -1
- package/dist/assets/AssistantView-BpvbpZBC.js +3 -0
- package/dist/assets/{DiagramView-BW92schO.js → DiagramView-UwKDr8HS.js} +1 -1
- package/dist/assets/{FadeInOnReveal-BZmwl-zE.js → FadeInOnReveal-w_BQ7K7K.js} +1 -1
- package/dist/assets/Icon-B9kZgYZn.js +1 -0
- package/dist/assets/{MarkdownRenderer-Bv05iDZZ.js → MarkdownRenderer-C8tDsO5F.js} +2 -2
- package/dist/assets/{MarkdownRendererImpl-C81xqYib.js → MarkdownRendererImpl-DRZ1iTbg.js} +1 -1
- package/dist/assets/MarkstreamRendererImpl-fU6uhDTr.js +3 -0
- package/dist/assets/{MobileDetailNavigation-BMgIu7zc.js → MobileDetailNavigation-MXUDe0nG.js} +1 -1
- package/dist/assets/{MobileShareBridge-BOXGNHmC.js → MobileShareBridge-C7x4jOGb.js} +2 -2
- package/dist/assets/MobileSurface-BQSmXYJ_.js +2 -0
- package/dist/assets/{MultiRunWindow-B5At2Z95.js → MultiRunWindow-ClfxxgP5.js} +1 -1
- package/dist/assets/{OnboardingScreen-bsPLgz4r.js → OnboardingScreen-BxItVnnO.js} +1 -1
- package/dist/assets/{OverlayScrollbar-4wp4migh.js → OverlayScrollbar-B4e0QD05.js} +1 -1
- package/dist/assets/SettingsView-DtWgey4y.js +25 -0
- package/dist/assets/{SettingsWindow-CMtX2bMl.js → SettingsWindow-Du8y1EI8.js} +1 -1
- package/dist/assets/TerminalView-DL9qkQDj.js +8 -0
- package/dist/assets/{ToolOutputDialog-uH1ZZDSS.js → ToolOutputDialog-BXawDmmf.js} +1 -1
- package/dist/assets/{appThemeRegistry-DK7q2juD.js → appThemeRegistry-DsSahEsq.js} +1 -1
- package/dist/assets/{button-CcNJUxUd.js → button-CwXyux0a.js} +1 -1
- package/dist/assets/collapsible-BZ6_cYJd.js +1 -0
- package/dist/assets/context-menu-DHrRifPE.js +1 -0
- package/dist/assets/{desktopHosts-C6HrP0jq.js → desktopHosts-JGFqhlaM.js} +1 -1
- package/dist/assets/{desktopRelayRestore-DdVBp45U.js → desktopRelayRestore-CHEwrI93.js} +1 -1
- package/dist/assets/{diagram-CxTs9hCQ.js → diagram-BwnPNKWt.js} +1 -1
- package/dist/assets/{dialog-KMVL9QKF.js → dialog-Dp0lQv5K.js} +1 -1
- package/dist/assets/es-DicJ8lKD.js +15 -0
- package/dist/assets/fileMentionTouchSelection-PZQrywzZ.js +3 -0
- package/dist/assets/{fileQueries-CjdBRy8y.js → fileQueries-DN6gbLhM.js} +1 -1
- package/dist/assets/fr-lhgVcBW_.js +13 -0
- package/dist/assets/{gitApi-yuWg9BN4.js → gitApi-C1oGENfw.js} +4 -4
- package/dist/assets/{imageSource-9vQBq3zp.js → imageSource-BMztRu0c.js} +1 -1
- package/dist/assets/{input-CrIvk3HZ.js → input-Biw8aNzR.js} +1 -1
- package/dist/assets/insertionBoundaries-BmQh-tke.js +1 -0
- package/dist/assets/ja-B9EZwOcw.js +15 -0
- package/dist/assets/ko-Dxk-uJRb.js +15 -0
- package/dist/assets/main-BnyE5H4i.js +169 -0
- package/dist/assets/main-D2BzpnUt.css +1 -0
- package/dist/assets/main-DOgXHO44.js +2 -0
- package/dist/assets/{markdownHeightCache-BmXVY_gc.js → markdownHeightCache-BOOrGCuG.js} +1 -1
- package/dist/assets/markdownTheme-DM5h1i18.js +1 -0
- package/dist/assets/{mcpQueries-C7jknHV8.js → mcpQueries-D9Dyzcve.js} +1 -1
- package/dist/assets/miniChat-C7FecHIX.js +2 -0
- package/dist/assets/mobile-_zrfbam4.js +2 -0
- package/dist/assets/multirun-BLFbv4I9.js +4 -0
- package/dist/assets/persistence-D0y7226-.js +3 -0
- package/dist/assets/pl-CFhwWLJj.js +15 -0
- package/dist/assets/platform-D_c-T5nO.js +6 -0
- package/dist/assets/projectMeta-BCdCRuYS.js +3 -0
- package/dist/assets/pt-BR-Ba6GfZ6J.js +15 -0
- package/dist/assets/renderElectronMiniChatApp-QQq7dj-0.js +2 -0
- package/dist/assets/renderMobileApp-BVIkeTKi.js +28 -0
- package/dist/assets/{runtimeConfig-BEANkvOd.js → runtimeConfig-S9D4fVI5.js} +1 -1
- package/dist/assets/runtimeEndpointReset-B6eE14f9.js +149 -0
- package/dist/assets/sessionChangeSummary-DLo3Iv_g.js +2 -0
- package/dist/assets/{sessionLookup-34XFmP9C.js → sessionLookup-BXXTw54z.js} +1 -1
- package/dist/assets/{sortable-tabs-strip-DyEV0Heg.js → sortable-tabs-strip-CTY_wG46.js} +1 -1
- package/dist/assets/src-lS1keDVV.css +2 -0
- package/dist/assets/store-DmowK-GO.js +15 -0
- package/dist/assets/{terminalApi-CXaBEoa_.js → terminalApi-Dhqqp4xi.js} +1 -1
- package/dist/assets/{textarea-BBOlRI9O.js → textarea-QxsmcBGj.js} +1 -1
- package/dist/assets/{tooltip-CPI4fr1a.js → tooltip-CpMLl5_k.js} +1 -1
- package/dist/assets/uk-ChchBzy6.js +15 -0
- package/dist/assets/{url-DRDQXjyX.js → url-CGbJbiJq.js} +1 -1
- package/dist/assets/{useAppFontEffects-CiDe42I5.js → useAppFontEffects-BDs0O2qK.js} +56 -51
- package/dist/assets/{useAssistantUIStore-asNG6K7n.js → useAssistantUIStore-Bz4tRflF.js} +1 -1
- package/dist/assets/useDesktopWindowControlsLayout-ojoBEglc.js +1 -0
- package/dist/assets/{useEffectiveDirectory-C_CouL5v.js → useEffectiveDirectory-B_nHyDMJ.js} +1 -1
- package/dist/assets/{useFontPreferences-B0oUZAiB.js → useFontPreferences-Xw1vSkmC.js} +1 -1
- package/dist/assets/useMobileNavigationStore-D_96D661.js +414 -0
- package/dist/assets/{useRuntimeAPIs-DwLtO0c_.js → useRuntimeAPIs-DSLXJybW.js} +1 -1
- package/dist/assets/useSessionAutoCleanup-Dw34f-EW.js +2 -0
- package/dist/assets/{useThemeSystem-BlNUWH47.js → useThemeSystem-DMzWVJaW.js} +1 -1
- package/dist/assets/useUIStore-syVmbuDA.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CPNYz6Wf.js → useWorkerHighlightedLines-CNlfP7nA.js} +1 -1
- package/dist/assets/{vendor-base-ui-CDjyofeD.js → vendor-base-ui-CnHfUmDN.js} +1 -1
- package/dist/assets/{vendor-cmdk-BFH0UpXX.js → vendor-cmdk-CeAGfrLo.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-accessibility-BrR3AKvM.js → vendor-dnd-kit-accessibility-BY5LhhUB.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-core-CNfdvk6H.js → vendor-dnd-kit-core-CvWxUPdb.js} +1 -1
- package/dist/assets/{vendor-dnd-kit-sortable-DQUaTeGj.js → vendor-dnd-kit-sortable-hHdP9lLb.js} +1 -1
- package/dist/assets/{vendor-framer-motion-4dogsUk3.js → vendor-framer-motion-DFy_BSVd.js} +1 -1
- package/dist/assets/{vendor-legendapp-list-BU7MdPZR.js → vendor-legendapp-list-WE32lwNk.js} +1 -1
- package/dist/assets/{vendor-markstream-react-qHC2FykU.js → vendor-markstream-react-DX55XLGO.js} +3 -3
- package/dist/assets/vendor-opencode-client-C12gfjag.js +12 -0
- package/dist/assets/{vendor-pierre-diffs-CUfqC003.js → vendor-pierre-diffs-DX7XTPG3.js} +1 -1
- package/dist/assets/{vendor-react-C94A0AHl.js → vendor-react-DLPElpoQ.js} +1 -1
- package/dist/assets/{vendor-react-drawio-FVlWqKVE.js → vendor-react-drawio-ECtEwvbc.js} +1 -1
- package/dist/assets/{vendor-reactuses-core-C2A3EjUi.js → vendor-reactuses-core-Dq5ocTF8.js} +1 -1
- package/dist/assets/{vendor-sonner-DNqHmUQs.js → vendor-sonner-DVzioF3d.js} +1 -1
- package/dist/assets/{vendor-stream-diffs-CwbKsmlv.js → vendor-stream-diffs-DWy3xSmp.js} +2 -2
- package/dist/assets/{vendor-tanstack-react-query-D0iFK57C.js → vendor-tanstack-react-query-D7pys_c9.js} +1 -1
- package/dist/assets/{vendor-tanstack-react-virtual-B2kV6cnT.js → vendor-tanstack-react-virtual-tlDgH6PG.js} +1 -1
- package/dist/assets/{vendor-zustand-BqLKf6ZY.js → vendor-zustand-B4dlGEM2.js} +1 -1
- package/dist/assets/zh-CN-Z_bkUQk5.js +15 -0
- package/dist/assets/zh-TW-CZeTm7kd.js +15 -0
- package/dist/index.html +13 -13
- package/dist/mini-chat.html +13 -13
- package/dist/mobile.html +13 -13
- package/package.json +3 -2
- package/server/index.js +38 -7
- package/server/lib/browser-provider/DOCUMENTATION.md +141 -0
- package/server/lib/browser-provider/contract.js +312 -0
- package/server/lib/browser-provider/host.js +421 -0
- package/server/lib/browser-provider/host.test.js +304 -0
- package/server/lib/browser-provider/routes.js +122 -0
- package/server/lib/browser-provider/routes.test.js +120 -0
- package/server/lib/browser-provider/selection-store.js +43 -0
- package/server/lib/browser-provider/selection-store.test.js +35 -0
- package/server/lib/notifications/DOCUMENTATION.md +3 -1
- package/server/lib/notifications/runtime.js +110 -26
- package/server/lib/notifications/runtime.test.js +92 -0
- package/server/lib/notifications/template-runtime.js +130 -38
- package/server/lib/notifications/template-runtime.test.js +96 -0
- package/server/lib/opencode/DOCUMENTATION.md +4 -0
- package/server/lib/opencode/ensure-cli.d.ts +8 -0
- package/server/lib/opencode/feature-routes-runtime.js +10 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +7 -0
- package/server/lib/opencode/proxy.js +3 -1
- package/server/lib/opencode/proxy.test.js +3 -3
- package/server/lib/opencode/routes.js +30 -0
- package/server/lib/opencode/upgrade-screen.d.ts +30 -0
- package/server/lib/opencode/upgrade-screen.js +299 -0
- package/server/lib/opencode/upgrade-screen.test.js +196 -0
- package/server/lib/session-metadata/DOCUMENTATION.md +27 -17
- package/server/lib/session-metadata/opencode-session-record.d.ts +57 -0
- package/server/lib/session-metadata/opencode-session-record.js +316 -0
- package/server/lib/session-metadata/opencode-session-record.test.js +242 -0
- package/server/lib/session-metadata/routes.js +5 -1
- package/server/lib/session-metadata/session-archive.js +1 -1
- package/server/lib/session-metadata/session-metadata-store.d.ts +2 -0
- package/server/lib/session-metadata/session-metadata-store.js +63 -16
- package/server/lib/session-metadata/session-projection.js +26 -2
- package/server/lib/session-title/runtime.js +2 -2
- package/dist/assets/AssistantView-CwXkiCAd.js +0 -3
- package/dist/assets/Icon-AQPvxMiU.js +0 -1
- package/dist/assets/MarkstreamRendererImpl-CXUCQuKJ.js +0 -3
- package/dist/assets/MobileSurface-xuc_z0_G.js +0 -2
- package/dist/assets/SettingsView-CJ6tcH5_.js +0 -25
- package/dist/assets/TerminalView-Dvr-ysTS.js +0 -8
- package/dist/assets/collapsible-ZmZ6rLWs.js +0 -3
- package/dist/assets/context-menu-B2LXJgXL.js +0 -1
- package/dist/assets/es-CN3E_zXj.js +0 -15
- package/dist/assets/fileMentionTouchSelection-7RIwlpkB.js +0 -4
- package/dist/assets/fr-9zZvQZD2.js +0 -13
- package/dist/assets/insertionBoundaries-BiUCSkx-.js +0 -1
- package/dist/assets/ja-Dnjk_82N.js +0 -15
- package/dist/assets/ko-BxCEE99C.js +0 -15
- package/dist/assets/main-C9mIBo77.js +0 -2
- package/dist/assets/main-DiL6w6KF.js +0 -169
- package/dist/assets/main-qSeeJRW1.css +0 -1
- package/dist/assets/markdownTheme-CKac3wKC.js +0 -1
- package/dist/assets/miniChat-CwtUaElU.js +0 -2
- package/dist/assets/mobile-DB-6--ws.js +0 -2
- package/dist/assets/multirun-TAsxFI8e.js +0 -4
- package/dist/assets/persistence-56kq8Z4V.js +0 -3
- package/dist/assets/pl-CNNsWnPM.js +0 -15
- package/dist/assets/platform-C13fMVEs.js +0 -6
- package/dist/assets/pt-BR-BZB2DJMp.js +0 -15
- package/dist/assets/renderElectronMiniChatApp-CGj0MNRU.js +0 -2
- package/dist/assets/renderMobileApp-CGMMqbsO.js +0 -28
- package/dist/assets/runtimeEndpointReset-A_ZBUJFS.js +0 -147
- package/dist/assets/sessionChangeSummary-qOLrP9fw.js +0 -2
- package/dist/assets/src-ubCWXZhR.css +0 -2
- package/dist/assets/store-DLCiZaA6.js +0 -15
- package/dist/assets/uk-rwQrxeYM.js +0 -15
- package/dist/assets/useDesktopWindowControlsLayout-BlizAlOt.js +0 -1
- package/dist/assets/useMobileNavigationStore-DfPClaW-.js +0 -414
- package/dist/assets/useSessionAutoCleanup-DtrfbxAp.js +0 -2
- package/dist/assets/useUIStore-CQYKJnrg.js +0 -1
- package/dist/assets/vendor-opencode-client-BOe2qpQb.js +0 -12
- package/dist/assets/zh-CN-BvmBDnkD.js +0 -15
- package/dist/assets/zh-TW-9wcHTX0y.js +0 -15
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createSessionArchiveService } from './session-archive.js';
|
|
4
|
+
import { createSessionMetadataStore } from './session-metadata-store.js';
|
|
5
|
+
import {
|
|
6
|
+
createHttpSessionRecordClient,
|
|
7
|
+
mergeOwnedMetadata,
|
|
8
|
+
migrateLoadedSideStore,
|
|
9
|
+
readSessionRecordViaClient,
|
|
10
|
+
retainOpenCodeSessions,
|
|
11
|
+
writeSessionRecordViaClient,
|
|
12
|
+
} from './opencode-session-record.js';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const makeDataDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-record-'));
|
|
18
|
+
|
|
19
|
+
describe('OpenCode session record ownership', () => {
|
|
20
|
+
it('keeps title and archive on the OpenCode record across archive and restore', async () => {
|
|
21
|
+
const dataDir = makeDataDir();
|
|
22
|
+
const records = new Map([
|
|
23
|
+
['ses_1', {
|
|
24
|
+
id: 'ses_1',
|
|
25
|
+
title: 'Keep me',
|
|
26
|
+
metadata: { openchamber: { goal: { status: 'active' }, assistant: { assistantID: 'ast_1' } } },
|
|
27
|
+
time: { created: 1, updated: 2 },
|
|
28
|
+
}],
|
|
29
|
+
]);
|
|
30
|
+
const writes = [];
|
|
31
|
+
const store = createSessionMetadataStore({
|
|
32
|
+
dataDir,
|
|
33
|
+
recordReader: async (sessionID) => records.get(sessionID) ?? null,
|
|
34
|
+
recordWriter: async (sessionID, metadata) => {
|
|
35
|
+
const current = records.get(sessionID);
|
|
36
|
+
if (!current) {
|
|
37
|
+
const error = new Error('missing');
|
|
38
|
+
error.status = 404;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
writes.push({ sessionID, metadata, title: current.title });
|
|
42
|
+
records.set(sessionID, { ...current, metadata });
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
const archive = createSessionArchiveService({
|
|
46
|
+
sessionMetadataStore: store,
|
|
47
|
+
fetchUpstreamSession: async ({ sessionID }) => records.get(sessionID) ?? null,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const archived = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 50, directory: '/repo' });
|
|
51
|
+
expect(archived.session.title).toBe('Keep me');
|
|
52
|
+
expect(archived.session.time.archived).toBe(50);
|
|
53
|
+
expect(records.get('ses_1').title).toBe('Keep me');
|
|
54
|
+
expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(50);
|
|
55
|
+
expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
|
|
56
|
+
expect(records.get('ses_1').metadata.openchamber.assistant.assistantID).toBe('ast_1');
|
|
57
|
+
expect(writes.at(-1).metadata.openchamber.assistant.assistantID).toBe('ast_1');
|
|
58
|
+
|
|
59
|
+
const restored = await archive.setArchive({ sessionID: 'ses_1', archivedAt: 0 });
|
|
60
|
+
expect(restored.session.title).toBe('Keep me');
|
|
61
|
+
expect(restored.session.time.archived).toBeUndefined();
|
|
62
|
+
expect(records.get('ses_1').title).toBe('Keep me');
|
|
63
|
+
expect(records.get('ses_1').metadata.openchamber.archive.archivedAt).toBe(0);
|
|
64
|
+
expect(records.get('ses_1').metadata.openchamber.goal.status).toBe('active');
|
|
65
|
+
|
|
66
|
+
const restarted = createSessionMetadataStore({
|
|
67
|
+
dataDir: makeDataDir(),
|
|
68
|
+
recordReader: async (sessionID) => records.get(sessionID) ?? null,
|
|
69
|
+
recordWriter: async () => undefined,
|
|
70
|
+
});
|
|
71
|
+
await expect(restarted.get('ses_1')).resolves.toMatchObject({
|
|
72
|
+
openchamber: {
|
|
73
|
+
archive: { archivedAt: 0 },
|
|
74
|
+
goal: { status: 'active' },
|
|
75
|
+
assistant: { assistantID: 'ast_1' },
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
const visible = retainOpenCodeSessions([records.get('ses_1')], {});
|
|
79
|
+
expect(visible).toHaveLength(1);
|
|
80
|
+
expect(visible[0].title).toBe('Keep me');
|
|
81
|
+
expect(visible[0].time.archived).toBeUndefined();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('does not drop an OpenCode session when the side store has no row', () => {
|
|
85
|
+
const visible = retainOpenCodeSessions([
|
|
86
|
+
{
|
|
87
|
+
id: 'ses_1',
|
|
88
|
+
title: 'Still here',
|
|
89
|
+
metadata: { openchamber: { archive: { archivedAt: 9 } } },
|
|
90
|
+
time: { created: 1, updated: 2 },
|
|
91
|
+
},
|
|
92
|
+
{ id: 'ses_2', title: 'Also here', time: { created: 1, updated: 2 } },
|
|
93
|
+
], {});
|
|
94
|
+
|
|
95
|
+
expect(visible.map((session) => session.id)).toEqual(['ses_1', 'ses_2']);
|
|
96
|
+
expect(visible[0].title).toBe('Still here');
|
|
97
|
+
expect(visible[0].time.archived).toBe(9);
|
|
98
|
+
expect(visible[1].title).toBe('Also here');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not treat an unread side store as an empty catalog', () => {
|
|
102
|
+
expect(() => retainOpenCodeSessions([{ id: 'ses_1', title: 'Stay' }], null)).toThrow(/unavailable/);
|
|
103
|
+
expect(() => retainOpenCodeSessions(null, {})).toThrow(/unavailable/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('does not treat a failed side-store load as an authoritative empty migration', async () => {
|
|
107
|
+
const writes = [];
|
|
108
|
+
const records = {
|
|
109
|
+
ses_1: { id: 'ses_1', title: 'Stay', metadata: { openchamber: { goal: { id: 'g1' } } } },
|
|
110
|
+
};
|
|
111
|
+
const result = await migrateLoadedSideStore({
|
|
112
|
+
loadResult: { ok: false, entries: {} },
|
|
113
|
+
readSession: async (id) => records[id] ?? null,
|
|
114
|
+
writeMetadata: async (id, metadata) => {
|
|
115
|
+
writes.push(id);
|
|
116
|
+
records[id] = { ...records[id], metadata };
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(result.status).toBe('unavailable');
|
|
121
|
+
expect(writes).toEqual([]);
|
|
122
|
+
expect(records.ses_1.title).toBe('Stay');
|
|
123
|
+
expect(records.ses_1.metadata.openchamber.goal.id).toBe('g1');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('keeps other sessions when one migration write fails', async () => {
|
|
127
|
+
const records = {
|
|
128
|
+
ses_ok: { id: 'ses_ok', title: 'Ok', metadata: {} },
|
|
129
|
+
ses_bad: { id: 'ses_bad', title: 'Bad', metadata: { openchamber: { llm: { purpose: 'keep' } } } },
|
|
130
|
+
ses_only_opencode: { id: 'ses_only_opencode', title: 'Stay', metadata: { fromOpenCode: true } },
|
|
131
|
+
};
|
|
132
|
+
const side = {
|
|
133
|
+
ses_ok: { openchamber: { goal: { id: 'g1' } } },
|
|
134
|
+
ses_bad: { openchamber: { assistant: { assistantID: 'ast_1' } } },
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const result = await migrateLoadedSideStore({
|
|
138
|
+
loadResult: { ok: true, entries: side },
|
|
139
|
+
readSession: async (id) => records[id] ?? null,
|
|
140
|
+
writeMetadata: async (id, metadata) => {
|
|
141
|
+
if (id === 'ses_bad') throw new Error('write failed');
|
|
142
|
+
records[id] = { ...records[id], metadata };
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
expect(result.status).toBe('partial');
|
|
147
|
+
expect(result.migrated).toEqual(['ses_ok']);
|
|
148
|
+
expect(result.failed).toEqual([{ id: 'ses_bad', error: 'write failed' }]);
|
|
149
|
+
expect(records.ses_ok.metadata.openchamber.goal.id).toBe('g1');
|
|
150
|
+
expect(records.ses_ok.title).toBe('Ok');
|
|
151
|
+
expect(records.ses_bad.title).toBe('Bad');
|
|
152
|
+
expect(records.ses_bad.metadata.openchamber.llm.purpose).toBe('keep');
|
|
153
|
+
expect(records.ses_bad.metadata.openchamber.assistant).toBeUndefined();
|
|
154
|
+
expect(records.ses_only_opencode).toEqual({
|
|
155
|
+
id: 'ses_only_opencode',
|
|
156
|
+
title: 'Stay',
|
|
157
|
+
metadata: { fromOpenCode: true },
|
|
158
|
+
});
|
|
159
|
+
expect(side.ses_bad.openchamber.assistant.assistantID).toBe('ast_1');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('fills side-store gaps without letting an empty side row wipe OpenCode fields', () => {
|
|
163
|
+
expect(mergeOwnedMetadata(
|
|
164
|
+
{ openchamber: { goal: { status: 'paused' } }, fromOpenCode: true },
|
|
165
|
+
{ openchamber: { goal: { id: 'g1' }, archive: { archivedAt: 4 } } },
|
|
166
|
+
)).toEqual({
|
|
167
|
+
fromOpenCode: true,
|
|
168
|
+
openchamber: { goal: { id: 'g1', status: 'paused' }, archive: { archivedAt: 4 } },
|
|
169
|
+
});
|
|
170
|
+
expect(mergeOwnedMetadata({ keep: true }, {})).toEqual({ keep: true });
|
|
171
|
+
expect(mergeOwnedMetadata({ keep: true }, null)).toEqual({ keep: true });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws when the installed client has no session.update instead of writing nothing', async () => {
|
|
175
|
+
await expect(writeSessionRecordViaClient({ session: {} }, {
|
|
176
|
+
sessionID: 'ses_1',
|
|
177
|
+
metadata: { openchamber: { archive: { archivedAt: 1 } } },
|
|
178
|
+
})).rejects.toThrow(/session\.update is unavailable/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('writes metadata through session.update without sending title', async () => {
|
|
182
|
+
const update = vi.fn(async () => undefined);
|
|
183
|
+
await writeSessionRecordViaClient({ session: { update } }, {
|
|
184
|
+
sessionID: 'ses_1',
|
|
185
|
+
metadata: { openchamber: { archive: { archivedAt: 3 } } },
|
|
186
|
+
});
|
|
187
|
+
expect(update).toHaveBeenCalledWith({
|
|
188
|
+
sessionID: 'ses_1',
|
|
189
|
+
metadata: { openchamber: { archive: { archivedAt: 3 } } },
|
|
190
|
+
}, undefined);
|
|
191
|
+
expect(update.mock.calls[0][0].title).toBeUndefined();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('reads a missing session as not-found and a transport failure as failure', async () => {
|
|
195
|
+
const missing = Object.assign(new Error('gone'), { status: 404 });
|
|
196
|
+
await expect(readSessionRecordViaClient({
|
|
197
|
+
session: { get: async () => { throw missing; } },
|
|
198
|
+
}, { sessionID: 'ses_1' })).resolves.toBeNull();
|
|
199
|
+
|
|
200
|
+
const down = Object.assign(new Error('down'), { status: 503 });
|
|
201
|
+
await expect(readSessionRecordViaClient({
|
|
202
|
+
session: { get: async () => { throw down; } },
|
|
203
|
+
}, { sessionID: 'ses_1' })).rejects.toThrow(/down/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('patches the session record over HTTP and does not turn a failed read into an empty session', async () => {
|
|
207
|
+
const calls = [];
|
|
208
|
+
const fetchFn = vi.fn(async (url, init) => {
|
|
209
|
+
calls.push({ url, method: init.method, body: init.body ? JSON.parse(init.body) : undefined });
|
|
210
|
+
if (init.method === 'PATCH' && String(url).includes('ses_ok')) {
|
|
211
|
+
return { ok: true, status: 204, json: async () => null };
|
|
212
|
+
}
|
|
213
|
+
if (String(url).includes('ses_missing')) {
|
|
214
|
+
return { ok: false, status: 404, json: async () => ({}) };
|
|
215
|
+
}
|
|
216
|
+
if (String(url).includes('ses_down')) {
|
|
217
|
+
return { ok: false, status: 503, json: async () => ({}) };
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
ok: true,
|
|
221
|
+
status: 200,
|
|
222
|
+
json: async () => ({ id: 'ses_ok', title: 'Keep', metadata: { fromOpenCode: true } }),
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
const client = createHttpSessionRecordClient({
|
|
226
|
+
buildSessionUrl: (sessionID) => `http://opencode.local/api/session/${sessionID}`,
|
|
227
|
+
fetchFn,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
await expect(client.read('ses_ok')).resolves.toMatchObject({ id: 'ses_ok', title: 'Keep' });
|
|
231
|
+
await expect(client.read('ses_missing')).resolves.toBeNull();
|
|
232
|
+
await expect(client.read('ses_down')).rejects.toThrow(/503/);
|
|
233
|
+
await client.write({
|
|
234
|
+
sessionID: 'ses_ok',
|
|
235
|
+
metadata: { openchamber: { archive: { archivedAt: 8 } } },
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const patch = calls.find((call) => call.method === 'PATCH');
|
|
239
|
+
expect(patch.body).toEqual({ metadata: { openchamber: { archive: { archivedAt: 8 } } } });
|
|
240
|
+
expect(patch.body.title).toBeUndefined();
|
|
241
|
+
});
|
|
242
|
+
});
|
|
@@ -83,13 +83,17 @@ export const registerSessionMetadataRoutes = (app, deps = {}) => {
|
|
|
83
83
|
},
|
|
84
84
|
},
|
|
85
85
|
};
|
|
86
|
+
}, {
|
|
87
|
+
directory: asNonEmptyString(body.directory) || undefined,
|
|
86
88
|
});
|
|
87
89
|
if (!result?.committed) {
|
|
88
90
|
throw new Error(result?.reason || 'session metadata mutate was rejected');
|
|
89
91
|
}
|
|
90
92
|
metadata = result.metadata;
|
|
91
93
|
} else {
|
|
92
|
-
metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch
|
|
94
|
+
metadata = await sessionMetadataStore.setSessionMetadata(sessionId, patch, {
|
|
95
|
+
directory: asNonEmptyString(body.directory) || undefined,
|
|
96
|
+
});
|
|
93
97
|
}
|
|
94
98
|
const directory = asNonEmptyString(body.directory) || '';
|
|
95
99
|
if (typeof onMetadataWritten === 'function') {
|
|
@@ -451,7 +451,7 @@ export const createSessionArchiveService = (deps = {}) => {
|
|
|
451
451
|
const metadata = await sessionMetadataStore.setSessionMetadata(
|
|
452
452
|
sessionID,
|
|
453
453
|
{ openchamber: { archive: { archivedAt } } },
|
|
454
|
-
{ allowArchive: true },
|
|
454
|
+
{ allowArchive: true, directory: upstreamDirectory || directoryHint },
|
|
455
455
|
);
|
|
456
456
|
|
|
457
457
|
// New Host mutation wins: bump generation and cancel older repair timers
|
|
@@ -33,4 +33,6 @@ export function createSessionMetadataStore(options: {
|
|
|
33
33
|
fsPromises?: typeof import('node:fs').promises;
|
|
34
34
|
path?: typeof import('node:path');
|
|
35
35
|
now?: () => number;
|
|
36
|
+
recordReader?: (sessionID: string, directory?: string | null) => Promise<object | null>;
|
|
37
|
+
recordWriter?: (sessionID: string, metadata: object, directory?: string | null) => Promise<void>;
|
|
36
38
|
}): SessionMetadataStore;
|
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OpenChamber
|
|
2
|
+
* OpenChamber session metadata cache and migration source.
|
|
3
3
|
*
|
|
4
|
-
* OpenCode
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* The OpenCode session record is the durable authority for title, archive, and
|
|
5
|
+
* other OpenChamber metadata (`opencode-session-record.js`). This JSON file
|
|
6
|
+
* remains the cache and the pre-migration source. A failed or corrupt read is
|
|
7
|
+
* never empty success: the store stays unwritable and does not overwrite
|
|
8
|
+
* unknown disk state or the OpenCode record.
|
|
9
9
|
*
|
|
10
10
|
* Writes are a JSON Merge Patch (RFC 7386): nested objects merge key by key and
|
|
11
|
-
* a `null` deletes. That
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* a `null` deletes. That keeps two features writing into the same `openchamber`
|
|
12
|
+
* namespace from erasing each other — goal mode saving progress must not drop
|
|
13
|
+
* an assist recap. When a record reader and writer are wired, the merged object
|
|
14
|
+
* is written onto the OpenCode session before the local cache commits. A failed
|
|
15
|
+
* record read is not treated as empty metadata.
|
|
14
16
|
*
|
|
15
17
|
* Mutations are fully serialized. Committed `entries` update only after a
|
|
16
18
|
* successful persist so concurrent readers never observe uncommitted drafts.
|
|
17
|
-
* A failed or corrupt file read is never empty success: the store stays
|
|
18
|
-
* unwritable and reports the error instead of overwriting unknown disk state.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import fsDefault from 'node:fs';
|
|
22
22
|
import pathDefault from 'node:path';
|
|
23
23
|
|
|
24
|
+
import { mergeOwnedMetadata } from './opencode-session-record.js';
|
|
25
|
+
|
|
24
26
|
const METADATA_FILE_NAME = 'sessions-metadata.json';
|
|
25
27
|
const HOST_ARCHIVE_KEY = 'archive';
|
|
26
28
|
|
|
@@ -103,13 +105,22 @@ const applyReservedArchiveProtection = (previous, patch, merged, { allowArchive
|
|
|
103
105
|
* @param {typeof fsDefault.promises} [options.fsPromises]
|
|
104
106
|
* @param {typeof pathDefault} [options.path]
|
|
105
107
|
* @param {() => number} [options.now]
|
|
108
|
+
* @param {(sessionID: string, directory?: string | null) => Promise<object | null>} [options.recordReader]
|
|
109
|
+
* OpenCode session read. Null means the session is gone. Any other failure throws.
|
|
110
|
+
* @param {(sessionID: string, metadata: object, directory?: string | null) => Promise<void>} [options.recordWriter]
|
|
111
|
+
* Writes the full merged metadata onto the OpenCode session record. Must not send title.
|
|
106
112
|
*/
|
|
107
113
|
export const createSessionMetadataStore = ({
|
|
108
114
|
dataDir,
|
|
109
115
|
fsPromises = fsDefault.promises,
|
|
110
116
|
path = pathDefault,
|
|
111
117
|
now = Date.now,
|
|
118
|
+
recordReader = null,
|
|
119
|
+
recordWriter = null,
|
|
112
120
|
}) => {
|
|
121
|
+
if (typeof recordWriter === 'function' && typeof recordReader !== 'function') {
|
|
122
|
+
throw new Error('session record writer requires a record reader');
|
|
123
|
+
}
|
|
113
124
|
const filePath = path.join(dataDir, METADATA_FILE_NAME);
|
|
114
125
|
|
|
115
126
|
/** sessionID → metadata. Committed only — never holds an in-flight draft. */
|
|
@@ -218,11 +229,27 @@ export const createSessionMetadataStore = ({
|
|
|
218
229
|
return next;
|
|
219
230
|
};
|
|
220
231
|
|
|
221
|
-
const
|
|
232
|
+
const readRecordMetadata = async (id, directory) => {
|
|
233
|
+
if (typeof recordReader !== 'function') return undefined;
|
|
234
|
+
try {
|
|
235
|
+
const record = await recordReader(id, directory);
|
|
236
|
+
if (record == null) return null;
|
|
237
|
+
return isPlainObject(record.metadata) ? record.metadata : {};
|
|
238
|
+
} catch (error) {
|
|
239
|
+
if (entries.has(id)) return undefined;
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const get = async (sessionID, options = {}) => {
|
|
222
245
|
const id = asNonEmptyString(sessionID);
|
|
223
246
|
if (!id) return {};
|
|
224
247
|
await ensureReadable();
|
|
225
|
-
|
|
248
|
+
const side = entries.get(id);
|
|
249
|
+
const fromRecord = await readRecordMetadata(id, options.directory);
|
|
250
|
+
if (fromRecord === undefined) return side ?? {};
|
|
251
|
+
if (fromRecord === null) return side ?? {};
|
|
252
|
+
return mergeOwnedMetadata(fromRecord, side);
|
|
226
253
|
};
|
|
227
254
|
|
|
228
255
|
const has = async (sessionID) => {
|
|
@@ -243,16 +270,36 @@ export const createSessionMetadataStore = ({
|
|
|
243
270
|
*/
|
|
244
271
|
const commitMergedPatch = async (id, previous, patch, options = {}) => {
|
|
245
272
|
const allowArchive = options.allowArchive === true;
|
|
273
|
+
let base = previous;
|
|
274
|
+
if (typeof recordReader === 'function') {
|
|
275
|
+
let record;
|
|
276
|
+
try {
|
|
277
|
+
record = await recordReader(id, options.directory);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
// A failed read is not an empty record. Do not replace metadata we could not see.
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
if (record == null) {
|
|
283
|
+
const error = new Error(`session ${id} was not found`);
|
|
284
|
+
error.status = 404;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
base = mergeOwnedMetadata(record.metadata, previous);
|
|
288
|
+
}
|
|
246
289
|
const effectivePatch = allowArchive ? patch : stripArchiveFromMetadataPatch(patch);
|
|
247
290
|
|
|
248
|
-
let merged = mergeMetadataPatch(
|
|
249
|
-
merged = applyReservedArchiveProtection(
|
|
291
|
+
let merged = mergeMetadataPatch(base, effectivePatch);
|
|
292
|
+
merged = applyReservedArchiveProtection(base, patch, merged, { allowArchive });
|
|
293
|
+
|
|
294
|
+
if (typeof recordWriter === 'function') {
|
|
295
|
+
await recordWriter(id, merged, options.directory);
|
|
296
|
+
}
|
|
250
297
|
|
|
251
298
|
const nextMap = new Map(entries);
|
|
252
299
|
if (Object.keys(merged).length === 0) nextMap.delete(id);
|
|
253
300
|
else nextMap.set(id, merged);
|
|
254
301
|
|
|
255
|
-
//
|
|
302
|
+
// OpenCode write already landed. Publish the local cache only after its persist succeeds.
|
|
256
303
|
await persistMap(nextMap);
|
|
257
304
|
|
|
258
305
|
if (Object.keys(merged).length === 0) entries.delete(id);
|
|
@@ -168,6 +168,30 @@ export const projectSessionWithHostMetadata = (session, hostMetadata) => {
|
|
|
168
168
|
return next;
|
|
169
169
|
};
|
|
170
170
|
|
|
171
|
+
/**
|
|
172
|
+
* OpenCode record keys win. Side-store keys the record does not have yet are
|
|
173
|
+
* filled in. A missing or empty side row does not wipe the record.
|
|
174
|
+
*
|
|
175
|
+
* @param {unknown} openCodeMetadata
|
|
176
|
+
* @param {unknown} sideMetadata
|
|
177
|
+
*/
|
|
178
|
+
export const mergeOwnedMetadata = (openCodeMetadata, sideMetadata) => {
|
|
179
|
+
const openCode = isPlainObject(openCodeMetadata) ? openCodeMetadata : {};
|
|
180
|
+
if (!isPlainObject(sideMetadata) || Object.keys(sideMetadata).length === 0) {
|
|
181
|
+
return { ...openCode };
|
|
182
|
+
}
|
|
183
|
+
return mergeMetadataPatch(sideMetadata, openCode);
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const projectRecordMetadata = (session, sideMetadata) => {
|
|
187
|
+
const metadata = mergeOwnedMetadata(session.metadata, sideMetadata);
|
|
188
|
+
const hadMetadata = isPlainObject(session.metadata);
|
|
189
|
+
const withRecord = Object.keys(metadata).length === 0 && !hadMetadata
|
|
190
|
+
? session
|
|
191
|
+
: { ...session, metadata };
|
|
192
|
+
return projectSessionWithHostMetadata(withRecord, null);
|
|
193
|
+
};
|
|
194
|
+
|
|
171
195
|
/**
|
|
172
196
|
* @param {object | null | undefined} session
|
|
173
197
|
* @param {Record<string, object> | null | undefined} storedBySessionId
|
|
@@ -178,7 +202,7 @@ export const projectSessionWithStoredMap = (session, storedBySessionId) => {
|
|
|
178
202
|
return projectSessionWithHostMetadata(session, null);
|
|
179
203
|
}
|
|
180
204
|
const host = storedBySessionId[session.id];
|
|
181
|
-
return
|
|
205
|
+
return projectRecordMetadata(session, isPlainObject(host) ? host : null);
|
|
182
206
|
};
|
|
183
207
|
|
|
184
208
|
const projectLifecycleInner = (payload, readHostMetadata) => {
|
|
@@ -196,7 +220,7 @@ const projectLifecycleInner = (payload, readHostMetadata) => {
|
|
|
196
220
|
host = null;
|
|
197
221
|
}
|
|
198
222
|
}
|
|
199
|
-
const projected =
|
|
223
|
+
const projected = projectRecordMetadata(info, isPlainObject(host) ? host : null);
|
|
200
224
|
if (projected === info) return payload;
|
|
201
225
|
|
|
202
226
|
const next = { ...payload };
|
|
@@ -122,8 +122,8 @@ const buildTitleSystemPrompt = () => [
|
|
|
122
122
|
'Name the main subject of the work.',
|
|
123
123
|
'Keep that subject across follow-ups, polish, commit, push, tidy, and review.',
|
|
124
124
|
'Switch only when the user clearly starts a different topic.',
|
|
125
|
-
'One line,
|
|
126
|
-
'
|
|
125
|
+
'One line, no explanation.',
|
|
126
|
+
'Hard limit: never exceed 6 CJK characters (or 6 words for English). No modifiers. No verbs.',
|
|
127
127
|
'Use the language of the user messages.',
|
|
128
128
|
'Keep technical terms, numbers, filenames, and HTTP codes exact.',
|
|
129
129
|
'Never include tool names, summarizing, or generating.',
|