@openchambery/web 1.18.2-beta.2 → 1.18.2-beta.21
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-DvoyOGkx.js → AssistantView-C00aiEHK.js} +1 -1
- package/dist/assets/{ChatContainer-D6yfuigV.js → ChatContainer-BDFeDKu-.js} +39 -39
- package/dist/assets/{DiagramView-MGGo9iME.js → DiagramView-CUfSFI3e.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-jVOkZkyX.js → MarkdownRendererImpl-Cen6E9Lj.js} +2 -2
- package/dist/assets/{MobileDetailNavigation-Q2IzHqGA.js → MobileDetailNavigation-DILi0tN7.js} +1 -1
- package/dist/assets/{MobileShareBridge-apXM34Lb.js → MobileShareBridge-PZMRiieI.js} +1 -1
- package/dist/assets/MobileSurface-BLYcR0tO.js +2 -0
- package/dist/assets/{MultiRunWindow-B-l_O-bd.js → MultiRunWindow-D_2mWZyv.js} +1 -1
- package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-CYeRZznl.js} +1 -1
- package/dist/assets/{OverlayScrollbar-D6NySAxi.js → OverlayScrollbar-BgMLtMIq.js} +1 -1
- package/dist/assets/SettingsView-CbDBfQp1.js +25 -0
- package/dist/assets/{SettingsWindow-DfVgTQ4I.js → SettingsWindow-Br7XLcSd.js} +1 -1
- package/dist/assets/{TerminalView-DNyjfkvT.js → TerminalView-CM5jRKlj.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DLG1iThm.js → ToolOutputDialog-D3U6aTZ4.js} +1 -1
- package/dist/assets/appThemeRegistry-CDxBJNNB.js +2 -0
- package/dist/assets/assistant-guide-hero-BeUHQcxE.jpg +0 -0
- package/dist/assets/{button-CDvMVaiO.js → button-DaAoVF4X.js} +1 -1
- package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-BnElHSnO.js} +1 -1
- package/dist/assets/{collapsible-C2aamDvD.js → collapsible-BmuQu0dq.js} +1 -1
- package/dist/assets/{context-menu-BaxmodD8.js → context-menu-DBOMervd.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-DjYfj1Sn.js → dialog-COrLBm9O.js} +1 -1
- package/dist/assets/{es-5Lhudh0n.js → es-DIVYdoxi.js} +1 -1
- package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-CmWYnRVz.js} +1 -1
- package/dist/assets/{fr-Q_UhFmKT.js → fr-CNTlVypq.js} +2 -2
- package/dist/assets/{gitApi-BYxg_qGJ.js → gitApi-BQT3QXD2.js} +1 -1
- package/dist/assets/gitApiHttp-BxGAFLTf.js +2 -0
- package/dist/assets/{input-k9DGGyZ6.js → input-XiL2-2gG.js} +1 -1
- package/dist/assets/insertionBoundaries-9a0RuIDc.js +1 -0
- package/dist/assets/{ja-CzMPz7bT.js → ja-C_eW5ZM8.js} +1 -1
- package/dist/assets/{ko-B-EslUsR.js → ko-CE3cOv7v.js} +1 -1
- package/dist/assets/{main-LDphyc-v.js → main-BGfX81Y9.js} +20 -20
- package/dist/assets/{main-CO6z5yRB.js → main-CEqSDnE2.js} +2 -2
- 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-BZQwTZpx.js → miniChat-CKucSAhg.js} +2 -2
- package/dist/assets/mobile-C4bTWKry.js +2 -0
- package/dist/assets/{multirun-ZlwlkIy5.js → multirun-DeNtSkgb.js} +1 -1
- package/dist/assets/{persistence-DPNMIbF5.js → persistence-D1lIuyqO.js} +1 -1
- package/dist/assets/{pl-Dnea6Ppc.js → pl-BZKZOE99.js} +1 -1
- package/dist/assets/projectMeta-BunN9pH5.js +3 -0
- package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-DHDgp8S3.js} +1 -1
- package/dist/assets/{radio-CJuhbQ5a.js → radio-Bziafaje.js} +1 -1
- package/dist/assets/renderElectronMiniChatApp-D8-FDgWE.js +2 -0
- package/dist/assets/renderMobileApp-CdU0aJ-e.js +23 -0
- package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-BXCt7ZsQ.js} +1 -1
- package/dist/assets/runtimeEndpointReset-CK1GhIsm.js +145 -0
- package/dist/assets/{sortable-tabs-strip-D8JHUI0z.js → sortable-tabs-strip-pWlBorwQ.js} +1 -1
- package/dist/assets/src-8kQFmtjw.css +2 -0
- package/dist/assets/{store-wa1ersRf.js → store-CUWqzRt7.js} +2 -2
- package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-DpiAJlil.js} +1 -1
- package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-BLugzWq-.js} +1 -1
- package/dist/assets/{uk-xGc9Pzge.js → uk-CfQK6cz1.js} +1 -1
- package/dist/assets/{url-7MqoLs9X.js → url-DMs_PgIC.js} +1 -1
- package/dist/assets/useAppFontEffects-IEUQDvE8.js +7 -0
- package/dist/assets/useDesktopWindowControlsLayout-MlqJX89k.js +1 -0
- package/dist/assets/{useEffectiveDirectory-aUC3zzl_.js → useEffectiveDirectory-GbhkdO6m.js} +1 -1
- package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-92Qg-98H.js} +1 -1
- package/dist/assets/useMobileNavigationStore-DQHF_ETu.js +476 -0
- package/dist/assets/useSessionAutoCleanup-BuU_S_Sh.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CiYQjBe6.js → useWorkerHighlightedLines-Ca6OgNHF.js} +1 -1
- package/dist/assets/{utils-Co19EJEL.js → utils-BxJEIWv5.js} +1 -1
- package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-DvY2fKWp.js} +1 -1
- package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-eWe0lm3-.js} +1 -1
- package/dist/index.html +8 -8
- package/dist/mini-chat.html +8 -8
- package/dist/mobile.html +8 -8
- package/package.json +1 -1
- package/server/index.d.ts +6 -0
- package/server/index.js +13 -0
- package/server/lib/config-sync/DOCUMENTATION.md +70 -0
- package/server/lib/config-sync/config-sync.test.js +197 -0
- package/server/lib/config-sync/constants.js +39 -0
- package/server/lib/config-sync/contract.js +130 -0
- package/server/lib/config-sync/credential-auth.js +39 -0
- package/server/lib/config-sync/engine.js +108 -0
- package/server/lib/config-sync/index.js +67 -0
- package/server/lib/config-sync/local-backup.js +171 -0
- package/server/lib/config-sync/plan.js +318 -0
- package/server/lib/config-sync/receiver.js +330 -0
- package/server/lib/config-sync/routes.js +141 -0
- package/server/lib/config-sync/routes.test.js +154 -0
- package/server/lib/config-sync/scripts.js +264 -0
- package/server/lib/config-sync/selections.js +112 -0
- package/server/lib/config-sync/tar.js +35 -0
- package/server/lib/config-sync/target-id.js +24 -0
- 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 +4 -1
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- package/server/lib/opencode/core-routes.js +1 -0
- package/server/lib/opencode/feature-routes-runtime.test.js +10 -1
- package/server/lib/opencode/openchamber-routes.js +7 -0
- package/server/lib/opencode/settings-runtime.js +82 -51
- package/server/lib/opencode/settings-runtime.test.js +42 -0
- package/server/lib/relay/DOCUMENTATION.md +1 -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/MobileSurface-htGkf7Ua.js +0 -2
- package/dist/assets/SettingsView-DxMpVLlt.js +0 -25
- package/dist/assets/appThemeRegistry-CgfOR_0L.js +0 -2
- package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
- package/dist/assets/insertionBoundaries-DzXMTleb.js +0 -1
- package/dist/assets/mobile-BktO3_4R.js +0 -2
- package/dist/assets/projectMeta-v-LP2mzz.js +0 -3
- package/dist/assets/renderElectronMiniChatApp-Db8uO-6q.js +0 -2
- package/dist/assets/renderMobileApp-BCV81Ryl.js +0 -23
- package/dist/assets/runtimeEndpointReset-Dg3DMy9v.js +0 -145
- package/dist/assets/src-oHrYIqHF.css +0 -2
- package/dist/assets/useAppFontEffects-CM0VKsdo.js +0 -7
- package/dist/assets/useDesktopWindowControlsLayout-Cq-r4Hcb.js +0 -1
- package/dist/assets/useMobileNavigationStore-DnzT4jO2.js +0 -475
- package/dist/assets/useSessionAutoCleanup-DxZeceKO.js +0 -1
|
@@ -39,9 +39,41 @@ export const createSettingsRuntime = (deps) => {
|
|
|
39
39
|
normalizeStringArray,
|
|
40
40
|
formatSettingsResponse,
|
|
41
41
|
resolveDirectoryCandidate,
|
|
42
|
+
/**
|
|
43
|
+
* Optional late-bound exclusive runner (Electron injects after startWebUiServer).
|
|
44
|
+
* May be a function returning the runner, or the runner itself.
|
|
45
|
+
*/
|
|
46
|
+
getRunExclusivePersist,
|
|
47
|
+
runExclusivePersist: initialRunExclusivePersist,
|
|
42
48
|
} = deps;
|
|
43
49
|
|
|
44
50
|
let persistSettingsLock = Promise.resolve();
|
|
51
|
+
let runExclusivePersist = typeof initialRunExclusivePersist === 'function'
|
|
52
|
+
? initialRunExclusivePersist
|
|
53
|
+
: null;
|
|
54
|
+
|
|
55
|
+
const resolveRunExclusivePersist = () => {
|
|
56
|
+
if (typeof getRunExclusivePersist === 'function') {
|
|
57
|
+
const resolved = getRunExclusivePersist();
|
|
58
|
+
if (typeof resolved === 'function') return resolved;
|
|
59
|
+
}
|
|
60
|
+
return typeof runExclusivePersist === 'function' ? runExclusivePersist : null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const runPersistedWrite = (work) => {
|
|
64
|
+
const external = resolveRunExclusivePersist();
|
|
65
|
+
if (external) {
|
|
66
|
+
return external(work);
|
|
67
|
+
}
|
|
68
|
+
const next = persistSettingsLock.then(async () => work());
|
|
69
|
+
// Keep the chain alive even if one writer throws.
|
|
70
|
+
persistSettingsLock = next.catch(() => {});
|
|
71
|
+
return next;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const setRunExclusivePersist = (fn) => {
|
|
75
|
+
runExclusivePersist = typeof fn === 'function' ? fn : null;
|
|
76
|
+
};
|
|
45
77
|
|
|
46
78
|
// Orphan recovery is a one-shot best-effort scan: when orphans can't be
|
|
47
79
|
// matched on first pass they stay on disk and every subsequent settings
|
|
@@ -495,7 +527,7 @@ export const createSettingsRuntime = (deps) => {
|
|
|
495
527
|
await fsPromises.rm(tmp, { force: true });
|
|
496
528
|
};
|
|
497
529
|
|
|
498
|
-
const
|
|
530
|
+
const writeSettingsToDiskUnlocked = async (settings) => {
|
|
499
531
|
try {
|
|
500
532
|
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
|
|
501
533
|
// Atomic write: Electron main and ssh-manager read this file via plain
|
|
@@ -511,6 +543,8 @@ export const createSettingsRuntime = (deps) => {
|
|
|
511
543
|
}
|
|
512
544
|
};
|
|
513
545
|
|
|
546
|
+
const writeSettingsToDisk = async (settings) => runPersistedWrite(() => writeSettingsToDiskUnlocked(settings));
|
|
547
|
+
|
|
514
548
|
const validateProjectEntries = async (projects) => {
|
|
515
549
|
if (!Array.isArray(projects)) {
|
|
516
550
|
return [];
|
|
@@ -778,67 +812,63 @@ export const createSettingsRuntime = (deps) => {
|
|
|
778
812
|
const migration7 = migrateSettingsRemoveApprovedDirectories(migration6.settings);
|
|
779
813
|
const migration8 = migrateSettingsCompactChatDefaults(migration7.settings);
|
|
780
814
|
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed || migration8.changed) {
|
|
781
|
-
await
|
|
815
|
+
await runPersistedWrite(() => writeSettingsToDiskUnlocked(migration8.settings));
|
|
782
816
|
}
|
|
783
817
|
return migration8.settings;
|
|
784
818
|
};
|
|
785
819
|
|
|
786
|
-
const persistSettings = async (changes) => {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
let next = mergePersistedSettings(current, sanitized);
|
|
794
|
-
|
|
795
|
-
const normalizedState = normalizeSettingsPaths(next);
|
|
796
|
-
if (normalizedState.changed) {
|
|
797
|
-
next = normalizedState.settings;
|
|
798
|
-
}
|
|
820
|
+
const persistSettings = async (changes) => runPersistedWrite(async () => {
|
|
821
|
+
// Log field names only — changes can carry credentials (UI password,
|
|
822
|
+
// client tokens) that must never reach the log file.
|
|
823
|
+
console.log('[persistSettings] Updating fields:', Object.keys(changes || {}).join(', ') || '(none)');
|
|
824
|
+
const current = await readSettingsFromDisk();
|
|
825
|
+
const sanitized = sanitizeSettingsUpdate(changes);
|
|
826
|
+
let next = mergePersistedSettings(current, sanitized);
|
|
799
827
|
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
828
|
+
const normalizedState = normalizeSettingsPaths(next);
|
|
829
|
+
if (normalizedState.changed) {
|
|
830
|
+
next = normalizedState.settings;
|
|
831
|
+
}
|
|
804
832
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
833
|
+
const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
|
|
834
|
+
if (deterministicProjectIdMigration.changed) {
|
|
835
|
+
next = deterministicProjectIdMigration.settings;
|
|
836
|
+
}
|
|
809
837
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
}
|
|
838
|
+
const approvedDirectoriesMigration = migrateSettingsRemoveApprovedDirectories(next);
|
|
839
|
+
if (approvedDirectoriesMigration.changed) {
|
|
840
|
+
next = approvedDirectoriesMigration.settings;
|
|
841
|
+
}
|
|
815
842
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
next = { ...next, projects: validated };
|
|
822
|
-
}
|
|
843
|
+
// Ensure marker + new defaults exist before the first settings write lands.
|
|
844
|
+
const compactChatDefaultsMigration = migrateSettingsCompactChatDefaults(next);
|
|
845
|
+
if (compactChatDefaultsMigration.changed) {
|
|
846
|
+
next = compactChatDefaultsMigration.settings;
|
|
847
|
+
}
|
|
823
848
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
} else if (next.activeProjectId) {
|
|
832
|
-
console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
|
|
833
|
-
next = { ...next, activeProjectId: undefined };
|
|
834
|
-
}
|
|
849
|
+
// Validating project paths hits the filesystem for every entry, so only
|
|
850
|
+
// do it when the incoming update actually touches the projects list —
|
|
851
|
+
// not on every theme/window-state/etc. save.
|
|
852
|
+
if (Object.prototype.hasOwnProperty.call(sanitized, 'projects') && Array.isArray(next.projects)) {
|
|
853
|
+
const validated = await validateProjectEntries(next.projects);
|
|
854
|
+
next = { ...next, projects: validated };
|
|
855
|
+
}
|
|
835
856
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
857
|
+
if (Array.isArray(next.projects) && next.projects.length > 0) {
|
|
858
|
+
const activeId = typeof next.activeProjectId === 'string' ? next.activeProjectId : '';
|
|
859
|
+
const active = next.projects.find((project) => project.id === activeId) || null;
|
|
860
|
+
if (!active) {
|
|
861
|
+
console.log(`[persistSettings] Active project ID ${activeId} not found, switching to ${next.projects[0].id}`);
|
|
862
|
+
next = { ...next, activeProjectId: next.projects[0].id };
|
|
863
|
+
}
|
|
864
|
+
} else if (next.activeProjectId) {
|
|
865
|
+
console.log(`[persistSettings] No projects found, clearing activeProjectId ${next.activeProjectId}`);
|
|
866
|
+
next = { ...next, activeProjectId: undefined };
|
|
867
|
+
}
|
|
839
868
|
|
|
840
|
-
|
|
841
|
-
|
|
869
|
+
await writeSettingsToDiskUnlocked(next);
|
|
870
|
+
return formatSettingsResponse(next);
|
|
871
|
+
});
|
|
842
872
|
|
|
843
873
|
return {
|
|
844
874
|
readSettingsFromDisk,
|
|
@@ -846,5 +876,6 @@ export const createSettingsRuntime = (deps) => {
|
|
|
846
876
|
readSettingsFromDiskMigrated,
|
|
847
877
|
writeSettingsToDisk,
|
|
848
878
|
persistSettings,
|
|
879
|
+
setRunExclusivePersist,
|
|
849
880
|
};
|
|
850
881
|
};
|
|
@@ -190,6 +190,48 @@ describe('settings runtime', () => {
|
|
|
190
190
|
}
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
it('joins an external exclusive persist runner when provided', async () => {
|
|
194
|
+
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
|
195
|
+
try {
|
|
196
|
+
const order = [];
|
|
197
|
+
let releaseOuter;
|
|
198
|
+
const outerGate = new Promise((resolve) => {
|
|
199
|
+
releaseOuter = resolve;
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
runtime.setRunExclusivePersist(async (work) => {
|
|
203
|
+
order.push('lock-enter');
|
|
204
|
+
const result = await work();
|
|
205
|
+
order.push('lock-exit');
|
|
206
|
+
return result;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const pending = (async () => {
|
|
210
|
+
order.push('outer-start');
|
|
211
|
+
await outerGate;
|
|
212
|
+
order.push('outer-before-write');
|
|
213
|
+
await runtime.writeSettingsToDisk({ theme: 'external' });
|
|
214
|
+
order.push('outer-after-write');
|
|
215
|
+
})();
|
|
216
|
+
|
|
217
|
+
// Without awaiting the outer gate, ensure the lock wrapper is the one used.
|
|
218
|
+
releaseOuter();
|
|
219
|
+
await pending;
|
|
220
|
+
expect(order).toEqual([
|
|
221
|
+
'outer-start',
|
|
222
|
+
'outer-before-write',
|
|
223
|
+
'lock-enter',
|
|
224
|
+
'lock-exit',
|
|
225
|
+
'outer-after-write',
|
|
226
|
+
]);
|
|
227
|
+
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(
|
|
228
|
+
JSON.stringify({ theme: 'external' }, null, 2),
|
|
229
|
+
);
|
|
230
|
+
} finally {
|
|
231
|
+
await cleanup();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
193
235
|
it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
|
|
194
236
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
195
237
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
@@ -50,7 +50,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
|
|
|
50
50
|
Private Relay is transparent for allowlisted HTTP paths, so most “works on LAN / Desktop, empty on mobile Relay” bugs are Host routing or client transport-identity mistakes rather than Relay framing bugs:
|
|
51
51
|
|
|
52
52
|
- **Same path, real Host route required.** Clients call the ordinary `/api/...` path through the tunnel. If that route is missing on the Host process currently holding the relay claim (wrong git worktree, stale packaged Desktop build, or a backend that never registered the OpenChamber route before the OpenCode proxy/SPA fallback), the tunnel still returns HTTP 200 with SPA HTML or proxied OpenCode content. Symptom: chat and status work, Provider/model catalog does not.
|
|
53
|
-
- **HTTP allowlist is prefix-based for `/api/`.** New REST/SSE APIs under `/api/` do not need a tunnel-host allowlist edit. New **WebSocket** paths still need both `ALLOWED_WS_PATHS` and `isUrlAuthWebSocketPath` (see the `relay-transport` skill).
|
|
53
|
+
- **HTTP allowlist is prefix-based for `/api/`.** New REST/SSE APIs under `/api/` do not need a tunnel-host allowlist edit. New **WebSocket** paths still need both `ALLOWED_WS_PATHS` and `isUrlAuthWebSocketPath` (see the `relay-transport` skill). Config sync (`/api/openchamber/config-sync/*`, ticket 05/06) therefore works over Relay without a tunnel-host allowlist change; identity and credential grants remain endpoint concerns (`relay:<serverId>`, pairing-settings / inbound host grant).
|
|
54
54
|
- **Transport identity ≠ runtime key.** After LAN⇄relay swaps, UI catalog loaders commit only when `useConfigStore.catalogTransportIdentity` matches `getRuntimeTransportIdentity()`. `runtimeEndpointReset.ts` must write that transport fingerprint on both full endpoint reset and in-place transport reconnect. Writing `runtimeKey` instead leaves Providers empty because the stable device/instance id is shared across LAN and relay.
|
|
55
55
|
- **Safe catalog projection stays Host-owned.** Provider credentials never cross the browser; Relay only carries the already-allowlisted `GET /api/config/catalog/providers` JSON. Keep Host projection, client parser bounds, and `partial` rules in sync (empty `release_date` is absent, not partial).
|
|
56
56
|
|
|
@@ -13,14 +13,20 @@ newest 20 root-session summaries per runtime and directory. It never stores
|
|
|
13
13
|
messages, attachments, permissions, provider data, or model metadata.
|
|
14
14
|
Sessions titled `smartfetch-secondary` are temporary SmartFetch model calls;
|
|
15
15
|
the index excludes them from every snapshot and clears prior summaries when a
|
|
16
|
-
matching session update arrives. System-owned sessions are excluded by
|
|
17
|
-
authoritative metadata only: a non-empty
|
|
18
|
-
`metadata.openchamber.assistant.assistantID
|
|
19
|
-
`metadata.openchamber.scheduledTask.taskID
|
|
20
|
-
|
|
21
|
-
|
|
16
|
+
matching session update arrives. System-owned sessions are excluded by
|
|
17
|
+
authoritative metadata only: a non-empty
|
|
18
|
+
`metadata.openchamber.assistant.assistantID`,
|
|
19
|
+
`metadata.openchamber.scheduledTask.taskID`, or
|
|
20
|
+
`metadata.openchamber.smallModel.purpose`. Title prefixes are human labels and
|
|
21
|
+
never participate in this filter. Metadata is the ownership/isolation signal;
|
|
22
|
+
`time.archived` is archive state; `time.pinned` is OpenChamber-owned pin state
|
|
23
|
+
(persisted as `pinned_at`, rescued across directory rebuilds, never overwritten
|
|
24
|
+
by live OpenCode upserts); titles are for recognition. The index does
|
|
22
25
|
not cache system sessions, so cold-start and live upserts stay aligned with
|
|
23
26
|
ordinary sidebar lists.
|
|
27
|
+
Pin and unpin use `POST` / `DELETE`
|
|
28
|
+
`/api/openchamber/session-index/session/:id/pin`, update `pinned_at`, then
|
|
29
|
+
`publishChange()` so revision tips broadcast `openchamber:session-index-changed`.
|
|
24
30
|
|
|
25
31
|
The server-side global OpenCode event subscriber writes session summary events
|
|
26
32
|
directly into this index. User `message.updated` events and `session.idle`
|
|
@@ -62,4 +62,38 @@ export const registerSessionIndexRoutes = (app, { sessionIndexService, sessionIn
|
|
|
62
62
|
}
|
|
63
63
|
res.json({ available: true, session: hit });
|
|
64
64
|
});
|
|
65
|
+
|
|
66
|
+
app.post('/api/openchamber/session-index/session/:id/pin', (req, res) => {
|
|
67
|
+
if (!sessionIndexService) return unsupported(res);
|
|
68
|
+
if (typeof sessionIndexService.setPinned !== 'function') {
|
|
69
|
+
return res.status(501).json({ error: 'Session pin is unavailable' });
|
|
70
|
+
}
|
|
71
|
+
const sessionId = req.params.id;
|
|
72
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
|
73
|
+
return res.status(400).json({ error: 'sessionId is required' });
|
|
74
|
+
}
|
|
75
|
+
const changed = sessionIndexService.setPinned(sessionId.trim());
|
|
76
|
+
if (!changed) {
|
|
77
|
+
return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
|
|
78
|
+
}
|
|
79
|
+
sessionIndexSyncRuntime?.publishChange?.();
|
|
80
|
+
res.status(204).end();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
app.delete('/api/openchamber/session-index/session/:id/pin', (req, res) => {
|
|
84
|
+
if (!sessionIndexService) return unsupported(res);
|
|
85
|
+
if (typeof sessionIndexService.clearPinned !== 'function') {
|
|
86
|
+
return res.status(501).json({ error: 'Session unpin is unavailable' });
|
|
87
|
+
}
|
|
88
|
+
const sessionId = req.params.id;
|
|
89
|
+
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
|
90
|
+
return res.status(400).json({ error: 'sessionId is required' });
|
|
91
|
+
}
|
|
92
|
+
const changed = sessionIndexService.clearPinned(sessionId.trim());
|
|
93
|
+
if (!changed) {
|
|
94
|
+
return res.status(404).json({ error: 'session_not_found', sessionId: sessionId.trim() });
|
|
95
|
+
}
|
|
96
|
+
sessionIndexSyncRuntime?.publishChange?.();
|
|
97
|
+
res.status(204).end();
|
|
98
|
+
});
|
|
65
99
|
};
|
|
@@ -137,4 +137,80 @@ describe('session index routes', () => {
|
|
|
137
137
|
expect(res.statusCode).toBe(404);
|
|
138
138
|
expect(res.body).toMatchObject({ error: 'session_not_found' });
|
|
139
139
|
});
|
|
140
|
+
|
|
141
|
+
it('pins a session and publishes a session-index change', () => {
|
|
142
|
+
const { app, route } = registry();
|
|
143
|
+
const setPinned = vi.fn(() => true);
|
|
144
|
+
const publishChange = vi.fn();
|
|
145
|
+
registerSessionIndexRoutes(app, {
|
|
146
|
+
sessionIndexService: {
|
|
147
|
+
snapshot: () => ({ directories: [] }),
|
|
148
|
+
setPinned,
|
|
149
|
+
},
|
|
150
|
+
sessionIndexSyncRuntime: { publishChange },
|
|
151
|
+
});
|
|
152
|
+
const res = response();
|
|
153
|
+
|
|
154
|
+
route('POST', '/api/openchamber/session-index/session/:id/pin')(
|
|
155
|
+
{ params: { id: 'ses_abc' } },
|
|
156
|
+
res,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(setPinned).toHaveBeenCalledWith('ses_abc');
|
|
160
|
+
expect(publishChange).toHaveBeenCalledTimes(1);
|
|
161
|
+
expect(res.statusCode).toBe(204);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('unpins a session and publishes a session-index change', () => {
|
|
165
|
+
const { app, route } = registry();
|
|
166
|
+
const clearPinned = vi.fn(() => true);
|
|
167
|
+
const publishChange = vi.fn();
|
|
168
|
+
registerSessionIndexRoutes(app, {
|
|
169
|
+
sessionIndexService: {
|
|
170
|
+
snapshot: () => ({ directories: [] }),
|
|
171
|
+
clearPinned,
|
|
172
|
+
},
|
|
173
|
+
sessionIndexSyncRuntime: { publishChange },
|
|
174
|
+
});
|
|
175
|
+
const res = response();
|
|
176
|
+
|
|
177
|
+
route('DELETE', '/api/openchamber/session-index/session/:id/pin')(
|
|
178
|
+
{ params: { id: 'ses_abc' } },
|
|
179
|
+
res,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
expect(clearPinned).toHaveBeenCalledWith('ses_abc');
|
|
183
|
+
expect(publishChange).toHaveBeenCalledTimes(1);
|
|
184
|
+
expect(res.statusCode).toBe(204);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('returns 404 for pin and unpin when the session is missing', () => {
|
|
188
|
+
const { app, route } = registry();
|
|
189
|
+
const publishChange = vi.fn();
|
|
190
|
+
registerSessionIndexRoutes(app, {
|
|
191
|
+
sessionIndexService: {
|
|
192
|
+
snapshot: () => ({ directories: [] }),
|
|
193
|
+
setPinned: () => false,
|
|
194
|
+
clearPinned: () => false,
|
|
195
|
+
},
|
|
196
|
+
sessionIndexSyncRuntime: { publishChange },
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const pinRes = response();
|
|
200
|
+
route('POST', '/api/openchamber/session-index/session/:id/pin')(
|
|
201
|
+
{ params: { id: 'ses_missing' } },
|
|
202
|
+
pinRes,
|
|
203
|
+
);
|
|
204
|
+
expect(pinRes.statusCode).toBe(404);
|
|
205
|
+
expect(pinRes.body).toMatchObject({ error: 'session_not_found' });
|
|
206
|
+
|
|
207
|
+
const unpinRes = response();
|
|
208
|
+
route('DELETE', '/api/openchamber/session-index/session/:id/pin')(
|
|
209
|
+
{ params: { id: 'ses_missing' } },
|
|
210
|
+
unpinRes,
|
|
211
|
+
);
|
|
212
|
+
expect(unpinRes.statusCode).toBe(404);
|
|
213
|
+
expect(unpinRes.body).toMatchObject({ error: 'session_not_found' });
|
|
214
|
+
expect(publishChange).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
140
216
|
});
|
|
@@ -15,6 +15,7 @@ const isSystemSession = (session) => {
|
|
|
15
15
|
if (!openchamber || typeof openchamber !== 'object') return false;
|
|
16
16
|
if (nonEmptySystemID(openchamber.assistant?.assistantID)) return true;
|
|
17
17
|
if (nonEmptySystemID(openchamber.scheduledTask?.taskID)) return true;
|
|
18
|
+
if (nonEmptySystemID(openchamber.smallModel?.purpose)) return true;
|
|
18
19
|
return false;
|
|
19
20
|
};
|
|
20
21
|
|
|
@@ -116,6 +117,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
116
117
|
updated_at INTEGER NOT NULL,
|
|
117
118
|
activity_updated_at INTEGER NOT NULL,
|
|
118
119
|
archived_at INTEGER NOT NULL DEFAULT 0,
|
|
120
|
+
pinned_at INTEGER,
|
|
119
121
|
status TEXT,
|
|
120
122
|
status_changed_at INTEGER NOT NULL DEFAULT 0,
|
|
121
123
|
parent_id TEXT,
|
|
@@ -139,6 +141,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
139
141
|
CREATE INDEX IF NOT EXISTS session_child_parent
|
|
140
142
|
ON session_child(runtime_key, directory, parent_id);
|
|
141
143
|
`);
|
|
144
|
+
const sessionSummaryColumns = new Set(
|
|
145
|
+
db.prepare("SELECT name FROM pragma_table_info('session_summary')").all().map((column) => column.name),
|
|
146
|
+
);
|
|
147
|
+
if (!sessionSummaryColumns.has('pinned_at')) {
|
|
148
|
+
db.exec('ALTER TABLE session_summary ADD COLUMN pinned_at INTEGER');
|
|
149
|
+
}
|
|
142
150
|
db.prepare('INSERT OR REPLACE INTO session_index_meta(key, value) VALUES (?, ?)')
|
|
143
151
|
.run('schema_version', String(SCHEMA_VERSION));
|
|
144
152
|
|
|
@@ -165,7 +173,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
165
173
|
const deleteDirectoryRows = db.prepare('DELETE FROM session_summary WHERE runtime_key = ? AND directory = ?');
|
|
166
174
|
const existingSummaryState = db.prepare(`
|
|
167
175
|
SELECT session_id, has_children AS hasChildren, activity_updated_at AS activityUpdatedAt,
|
|
168
|
-
status, status_changed_at AS statusChangedAt
|
|
176
|
+
status, status_changed_at AS statusChangedAt, pinned_at AS pinnedAt
|
|
169
177
|
FROM session_summary WHERE runtime_key = ? AND directory = ?
|
|
170
178
|
`);
|
|
171
179
|
const readSummary = db.prepare(`
|
|
@@ -183,8 +191,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
183
191
|
LIMIT 1 OFFSET ?
|
|
184
192
|
`);
|
|
185
193
|
const insertSummary = db.prepare(`
|
|
186
|
-
INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
|
|
187
|
-
VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
|
|
194
|
+
INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, pinned_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
|
|
195
|
+
VALUES (@runtimeKey, @directory, @id, @title, @createdAt, @updatedAt, @activityUpdatedAt, @archivedAt, @pinnedAt, @status, @statusChangedAt, @parentID, @hasChildren, @assistantID, @assistantName)
|
|
188
196
|
`);
|
|
189
197
|
const upsertSummary = db.prepare(`
|
|
190
198
|
INSERT INTO session_summary(runtime_key, directory, session_id, title, created_at, updated_at, activity_updated_at, archived_at, status, status_changed_at, parent_id, has_children, assistant_id, assistant_name)
|
|
@@ -259,6 +267,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
259
267
|
insertSummary.run({
|
|
260
268
|
...summary,
|
|
261
269
|
activityUpdatedAt: Math.max(summary.activityUpdatedAt, toTimestamp(previous?.activityUpdatedAt)),
|
|
270
|
+
pinnedAt: previous?.pinnedAt != null ? toTimestamp(previous.pinnedAt) : null,
|
|
262
271
|
status: typeof previous?.status === 'string' ? previous.status : null,
|
|
263
272
|
statusChangedAt: toTimestamp(previous?.statusChangedAt),
|
|
264
273
|
hasChildren: toBooleanInt(summary.hasChildren ?? Boolean(previous?.hasChildren)),
|
|
@@ -364,6 +373,7 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
364
373
|
sessions: db.prepare(`
|
|
365
374
|
SELECT session_id AS id, title, created_at AS createdAt, updated_at AS updatedAt,
|
|
366
375
|
activity_updated_at AS activityUpdatedAt, archived_at AS archivedAt,
|
|
376
|
+
pinned_at AS pinnedAt,
|
|
367
377
|
status, status_changed_at AS statusChangedAt, parent_id AS parentID,
|
|
368
378
|
has_children AS hasChildren, assistant_id AS assistantID, assistant_name AS assistantName
|
|
369
379
|
FROM session_summary WHERE runtime_key = ? AND directory = ?
|
|
@@ -373,7 +383,12 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
373
383
|
id: session.id,
|
|
374
384
|
title: session.title,
|
|
375
385
|
directory: directory.directory,
|
|
376
|
-
time: {
|
|
386
|
+
time: {
|
|
387
|
+
created: session.createdAt,
|
|
388
|
+
updated: session.updatedAt,
|
|
389
|
+
...(session.archivedAt ? { archived: session.archivedAt } : {}),
|
|
390
|
+
...(session.pinnedAt ? { pinned: session.pinnedAt } : {}),
|
|
391
|
+
},
|
|
377
392
|
metadata: {
|
|
378
393
|
openchamber: {
|
|
379
394
|
titleRefresh: { activityUpdatedAt: session.activityUpdatedAt },
|
|
@@ -465,6 +480,38 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
465
480
|
return result.changes > 0;
|
|
466
481
|
};
|
|
467
482
|
|
|
483
|
+
const summaryExists = db.prepare(`
|
|
484
|
+
SELECT 1 AS present FROM session_summary
|
|
485
|
+
WHERE runtime_key = ? AND session_id = ?
|
|
486
|
+
LIMIT 1
|
|
487
|
+
`);
|
|
488
|
+
|
|
489
|
+
const setPinned = (sessionID, pinnedAt = Date.now()) => {
|
|
490
|
+
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
491
|
+
const timestamp = toTimestamp(pinnedAt);
|
|
492
|
+
if (!timestamp) return false;
|
|
493
|
+
const key = runtimeKey();
|
|
494
|
+
if (!summaryExists.get(key, sessionID)) return false;
|
|
495
|
+
db.prepare(`
|
|
496
|
+
UPDATE session_summary
|
|
497
|
+
SET pinned_at = ?
|
|
498
|
+
WHERE runtime_key = ? AND session_id = ?
|
|
499
|
+
`).run(timestamp, key, sessionID);
|
|
500
|
+
return true;
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const clearPinned = (sessionID) => {
|
|
504
|
+
if (typeof sessionID !== 'string' || !sessionID) return false;
|
|
505
|
+
const key = runtimeKey();
|
|
506
|
+
if (!summaryExists.get(key, sessionID)) return false;
|
|
507
|
+
db.prepare(`
|
|
508
|
+
UPDATE session_summary
|
|
509
|
+
SET pinned_at = NULL
|
|
510
|
+
WHERE runtime_key = ? AND session_id = ?
|
|
511
|
+
`).run(key, sessionID);
|
|
512
|
+
return true;
|
|
513
|
+
};
|
|
514
|
+
|
|
468
515
|
const replaceChildSessions = db.transaction((directory, parentSessionID, children) => {
|
|
469
516
|
const normalizedDirectory = normalizeDirectory(directory);
|
|
470
517
|
if (!normalizedDirectory || typeof parentSessionID !== 'string' || !parentSessionID) return false;
|
|
@@ -487,6 +534,8 @@ export const createSessionIndexService = ({ dbPath, getRuntimeConfig = () => nul
|
|
|
487
534
|
upsertAndReportChange,
|
|
488
535
|
touchActivity,
|
|
489
536
|
updateStatus,
|
|
537
|
+
setPinned,
|
|
538
|
+
clearPinned,
|
|
490
539
|
remove,
|
|
491
540
|
findBySessionId,
|
|
492
541
|
replaceChildSessions,
|
|
@@ -68,7 +68,7 @@ describe('Electron session index', () => {
|
|
|
68
68
|
service.close();
|
|
69
69
|
});
|
|
70
70
|
|
|
71
|
-
it('excludes Assistant and
|
|
71
|
+
it('excludes Assistant, Scheduled, and smallModel system sessions from ordinary sidebar summaries', () => {
|
|
72
72
|
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
73
73
|
const service = createService(runtimeRef);
|
|
74
74
|
const assistantSession = {
|
|
@@ -79,11 +79,15 @@ describe('Electron session index', () => {
|
|
|
79
79
|
...session('ses_scheduled', 99),
|
|
80
80
|
metadata: { openchamber: { scheduledTask: { taskID: 'task_1' } } },
|
|
81
81
|
};
|
|
82
|
+
const smallModelSession = {
|
|
83
|
+
...session('ses_small_model', 97),
|
|
84
|
+
metadata: { openchamber: { smallModel: { purpose: 'session-title' } } },
|
|
85
|
+
};
|
|
82
86
|
const ordinary = session('ses_ordinary', 98);
|
|
83
87
|
|
|
84
88
|
service.replaceDirectory({
|
|
85
89
|
directory: '/repo',
|
|
86
|
-
sessions: [assistantSession, scheduledSession, ordinary],
|
|
90
|
+
sessions: [assistantSession, scheduledSession, smallModelSession, ordinary],
|
|
87
91
|
cursor: null,
|
|
88
92
|
hasMore: false,
|
|
89
93
|
});
|
|
@@ -91,6 +95,7 @@ describe('Electron session index', () => {
|
|
|
91
95
|
|
|
92
96
|
service.upsert(assistantSession);
|
|
93
97
|
service.upsert(scheduledSession);
|
|
98
|
+
service.upsert(smallModelSession);
|
|
94
99
|
expect(service.snapshot().directories[0].sessions.map((item) => item.id)).toEqual(['ses_ordinary']);
|
|
95
100
|
service.close();
|
|
96
101
|
});
|
|
@@ -318,4 +323,69 @@ describe('Electron session index', () => {
|
|
|
318
323
|
expect(rebuilt.snapshot().directories).toEqual([]);
|
|
319
324
|
rebuilt.close();
|
|
320
325
|
});
|
|
326
|
+
|
|
327
|
+
it('exposes time.pinned in snapshots and clears it on unpin', () => {
|
|
328
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
329
|
+
const service = createService(runtimeRef);
|
|
330
|
+
service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
|
|
331
|
+
|
|
332
|
+
expect(service.setPinned('ses_a', 55)).toBe(true);
|
|
333
|
+
expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
|
|
334
|
+
created: 9,
|
|
335
|
+
updated: 10,
|
|
336
|
+
pinned: 55,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(service.clearPinned('ses_a')).toBe(true);
|
|
340
|
+
expect(service.snapshot().directories[0].sessions[0].time).toEqual({
|
|
341
|
+
created: 9,
|
|
342
|
+
updated: 10,
|
|
343
|
+
});
|
|
344
|
+
service.close();
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('rescues pinned_at across replaceDirectory rebuilds', () => {
|
|
348
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
349
|
+
const service = createService(runtimeRef);
|
|
350
|
+
service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
|
|
351
|
+
expect(service.setPinned('ses_a', 77)).toBe(true);
|
|
352
|
+
|
|
353
|
+
service.replaceDirectory({
|
|
354
|
+
directory: '/repo',
|
|
355
|
+
sessions: [session('ses_a', 20)],
|
|
356
|
+
cursor: null,
|
|
357
|
+
hasMore: false,
|
|
358
|
+
now: 2000,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
expect(service.snapshot().directories[0].sessions[0].time).toMatchObject({
|
|
362
|
+
updated: 20,
|
|
363
|
+
pinned: 77,
|
|
364
|
+
});
|
|
365
|
+
service.close();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('does not let live upsert overwrite pinned_at', () => {
|
|
369
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
370
|
+
const service = createService(runtimeRef);
|
|
371
|
+
service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
|
|
372
|
+
expect(service.setPinned('ses_a', 88)).toBe(true);
|
|
373
|
+
|
|
374
|
+
service.upsert(session('ses_a', 30));
|
|
375
|
+
|
|
376
|
+
expect(service.snapshot().directories[0].sessions[0].time.pinned).toBe(88);
|
|
377
|
+
service.close();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('removes pinned rows when an archived upsert deletes the session', () => {
|
|
381
|
+
const runtimeRef = { value: 'http://runtime-a.test' };
|
|
382
|
+
const service = createService(runtimeRef);
|
|
383
|
+
service.replaceDirectory({ directory: '/repo', sessions: [session('ses_a', 10)], cursor: null, hasMore: false });
|
|
384
|
+
expect(service.setPinned('ses_a', 99)).toBe(true);
|
|
385
|
+
|
|
386
|
+
expect(service.upsert({ ...session('ses_a', 11), time: { created: 9, updated: 11, archived: 12 } })).toBe(true);
|
|
387
|
+
expect(service.snapshot().directories[0].sessions).toEqual([]);
|
|
388
|
+
expect(service.clearPinned('ses_a')).toBe(false);
|
|
389
|
+
service.close();
|
|
390
|
+
});
|
|
321
391
|
});
|