@openchambery/web 1.18.1 → 1.18.2-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-B2kZjgaf.js → AgentAvatar-BzRb9sP6.js} +1 -1
- package/dist/assets/{AssistantView-f5eK9QED.js → AssistantView-Bcx9ER-E.js} +1 -1
- package/dist/assets/{ChatContainer-BvJoxcjJ.js → ChatContainer-DaBnpayh.js} +38 -38
- package/dist/assets/{DiagramView-Vu2JnmC-.js → DiagramView-DaZSw3PM.js} +1 -1
- package/dist/assets/{FadeInOnReveal-aeWlaqVB.js → FadeInOnReveal-oeyB_nIi.js} +1 -1
- package/dist/assets/{Icon-AXqa-fYB.js → Icon-DHaR0peD.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-CSxVZ_eA.js → MarkdownRendererImpl-CoMjP1t6.js} +1 -1
- package/dist/assets/{MobileDetailNavigation-3NIRdxpt.js → MobileDetailNavigation-Bsi54FsV.js} +1 -1
- package/dist/assets/{MobileShareBridge-B7jqfJ-S.js → MobileShareBridge-BQd5tCJN.js} +1 -1
- package/dist/assets/MobileSurface-C0bqzAcb.js +2 -0
- package/dist/assets/{MultiRunWindow-DkYTqcPq.js → MultiRunWindow-CJLa67M0.js} +1 -1
- package/dist/assets/{OnboardingScreen-CCfdEtW6.js → OnboardingScreen-BZHN7C2r.js} +1 -1
- package/dist/assets/{OverlayScrollbar-DeL1aO59.js → OverlayScrollbar-BLbgO_QF.js} +1 -1
- package/dist/assets/SettingsView-pHa9Y38q.js +25 -0
- package/dist/assets/{SettingsWindow--nLXOo-L.js → SettingsWindow-q6zT03-N.js} +1 -1
- package/dist/assets/{TerminalView-B80SmYHn.js → TerminalView-BFb17E29.js} +1 -1
- package/dist/assets/{ToolOutputDialog-CtbnU35x.js → ToolOutputDialog-sVkggrhW.js} +1 -1
- package/dist/assets/appThemeRegistry-Bh6gmBoH.js +2 -0
- package/dist/assets/{button-CDvMVaiO.js → button-DCUMIXDC.js} +1 -1
- package/dist/assets/{clipboard-Jdqe05db.js → clipboard-DF5tFLDb.js} +1 -1
- package/dist/assets/{collapsible-C2aamDvD.js → collapsible-DNg5bLKV.js} +1 -1
- package/dist/assets/{context-menu-D16ocNrk.js → context-menu-VD4qvZ6C.js} +1 -1
- package/dist/assets/{desktopHosts-CBoBpJ1Z.js → desktopHosts-DSgGxDpI.js} +1 -1
- package/dist/assets/{desktopRelayRestore-l2R9muux.js → desktopRelayRestore-DVIx7jsz.js} +1 -1
- package/dist/assets/{diagram-CEon0BQs.js → diagram-cjvFqVa8.js} +1 -1
- package/dist/assets/{dialog-BzZ3cKxG.js → dialog-DaSIYgrj.js} +1 -1
- package/dist/assets/{es-C25PRSjo.js → es-DCoGZljP.js} +1 -1
- package/dist/assets/{fileQueries-CE_DaZWQ.js → fileQueries-DXvhIL_U.js} +1 -1
- package/dist/assets/{fr-Cv8ZWQdW.js → fr-dkEoZpPz.js} +2 -2
- package/dist/assets/{gitApi-B1CIqazO.js → gitApi-cPhxx3NZ.js} +1 -1
- package/dist/assets/gitApiHttp-SLrnr8a5.js +2 -0
- package/dist/assets/{input-k9DGGyZ6.js → input-QS3IWgY5.js} +1 -1
- package/dist/assets/{insertionBoundaries-B9cwRNac.js → insertionBoundaries-XUyczmFs.js} +1 -1
- package/dist/assets/{ja-Ue9m9eSe.js → ja-bQwSsvRr.js} +1 -1
- package/dist/assets/{ko-DWBUCCV6.js → ko-Kq5C3hsv.js} +1 -1
- package/dist/assets/{main-BSVnX01h.js → main-D4CTy7xa.js} +2 -2
- package/dist/assets/{main-DzZumJ48.js → main-Mkx9wBnO.js} +23 -23
- package/dist/assets/{markdown-worker-DkeWiER6.js → markdown-worker-D-OR2e_G.js} +1 -1
- package/dist/assets/{markdownTheme-DNT93i5L.js → markdownTheme-y_Rf6mLa.js} +1 -1
- package/dist/assets/{miniChat-CrEtOV4u.js → miniChat-Bq6uelzq.js} +2 -2
- package/dist/assets/mobile-C1L9OCdh.js +2 -0
- package/dist/assets/{multirun-G-ZwKVUq.js → multirun-9OIhLOWP.js} +1 -1
- package/dist/assets/{persistence-DPNMIbF5.js → persistence-oSFWyKgV.js} +1 -1
- package/dist/assets/{pl-DaBaPCyE.js → pl-CYpMAgVn.js} +2 -2
- package/dist/assets/projectMeta-BXPWMXjv.js +3 -0
- package/dist/assets/{pt-BR-C2cJZ43o.js → pt-BR-MSPTLfTm.js} +1 -1
- package/dist/assets/{radio-BtilB34p.js → radio-CqpSp0pL.js} +1 -1
- package/dist/assets/{renderElectronMiniChatApp-D1HWiWMn.js → renderElectronMiniChatApp-B8cSB7U4.js} +2 -2
- package/dist/assets/renderMobileApp-DmBjzy65.js +23 -0
- package/dist/assets/{runtimeConfig-BtWi74Pm.js → runtimeConfig-n4QAk1lU.js} +1 -1
- package/dist/assets/runtimeEndpointReset-Bw-RBy5a.js +145 -0
- package/dist/assets/{sortable-tabs-strip-BpvOof2G.js → sortable-tabs-strip-B4wI7VAP.js} +1 -1
- package/dist/assets/src-BhvNwAr-.css +2 -0
- package/dist/assets/{store-SqV3zfE8.js → store-OURVnLSN.js} +2 -2
- package/dist/assets/{terminalApi-tB3qLcfa.js → terminalApi-_NnfkxgP.js} +1 -1
- package/dist/assets/{tooltip-DMNR5rN0.js → tooltip-xEnco9xY.js} +1 -1
- package/dist/assets/{uk-DJz_P8OZ.js → uk-cSkRAYFY.js} +1 -1
- package/dist/assets/{url-7MqoLs9X.js → url-DIZgk80M.js} +1 -1
- package/dist/assets/{useAppFontEffects-BBxqFIta.js → useAppFontEffects-BHoHqFhx.js} +3 -3
- package/dist/assets/{useDesktopWindowControlsLayout-D8fjPyqI.js → useDesktopWindowControlsLayout-DwmEdp3O.js} +1 -1
- package/dist/assets/{useEffectiveDirectory-C5OxWqUH.js → useEffectiveDirectory-Bjo9xE9q.js} +1 -1
- package/dist/assets/{useFontPreferences-CIurCeNv.js → useFontPreferences-B-QHyR3C.js} +1 -1
- package/dist/assets/useMobileNavigationStore-SCw9NohC.js +476 -0
- package/dist/assets/useSessionAutoCleanup-KwCKRF_d.js +1 -0
- package/dist/assets/{useWorkerHighlightedLines-CJozblYF.js → useWorkerHighlightedLines-Cm3UQxhi.js} +1 -1
- package/dist/assets/{utils-Co19EJEL.js → utils-Dxsi3J3l.js} +1 -1
- package/dist/assets/{zh-CN-DLAKDOM9.js → zh-CN-BKqeT_uz.js} +1 -1
- package/dist/assets/{zh-TW-s3Jjx1_n.js → zh-TW-BaNjJ8Y4.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 +6 -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 +35 -16
- package/server/lib/event-stream/diff-summary.test.js +46 -6
- package/server/lib/event-stream/protocol.test.js +7 -7
- package/server/lib/git/DOCUMENTATION.md +2 -1
- package/server/lib/git/service.js +24 -5
- package/server/lib/git/service.test.js +51 -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-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 +48 -0
- package/server/lib/session-turn-pages/reconcile.service.js +3 -1
- package/server/lib/session-turn-pages/reconcile.service.test.js +40 -0
- package/server/lib/session-turn-pages/routes.js +273 -12
- package/server/lib/session-turn-pages/routes.test.js +359 -0
- package/server/lib/session-turn-pages/service.js +29 -7
- package/server/lib/session-turn-pages/service.test.js +30 -0
- package/dist/assets/MobileSurface-BL3hired.js +0 -2
- package/dist/assets/SettingsView-CyrIDyDl.js +0 -25
- package/dist/assets/appThemeRegistry-D2ZUcQmr.js +0 -2
- package/dist/assets/gitApiHttp-D8j1qRH4.js +0 -2
- package/dist/assets/mobile-DMjO-f5u.js +0 -2
- package/dist/assets/projectMeta-owiDYjF9.js +0 -3
- package/dist/assets/renderMobileApp-Cqxwb3Ub.js +0 -23
- package/dist/assets/runtimeEndpointReset-C0oDuy2R.js +0 -145
- package/dist/assets/src-oHrYIqHF.css +0 -2
- package/dist/assets/useMobileNavigationStore-U3b_hG4m.js +0 -475
- package/dist/assets/useSessionAutoCleanup-CDYDm76L.js +0 -1
|
@@ -111,7 +111,8 @@ The following functions are internal helpers used by exported functions:
|
|
|
111
111
|
- `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
|
|
112
112
|
- `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
|
|
113
113
|
- `isClean`: Boolean indicating if working tree is clean.
|
|
114
|
-
- `
|
|
114
|
+
- `oversized`: Boolean. `true` when the change set exceeds the server-owned `GIT_STATUS_DIFF_STATS_MAX_FILES` threshold; `diffStats` are omitted in that case (same omission shape as light mode). Clients must derive deferred / load-on-demand rendering from this flag rather than from their own threshold, so the server can tune the limit without client changes. Light-mode polls do not set this flag.
|
|
115
|
+
- `diffStats`: Object mapping file paths to `{ insertions, deletions }`. Omitted by light mode and by oversized change sets.
|
|
115
116
|
- `mergeInProgress`: Object with `{ head, message }` if merge in progress.
|
|
116
117
|
- `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
|
|
117
118
|
|
|
@@ -1964,6 +1964,15 @@ export async function setLocalIdentity(directory, profile) {
|
|
|
1964
1964
|
}
|
|
1965
1965
|
}
|
|
1966
1966
|
|
|
1967
|
+
// Change sets larger than this are reported as `oversized` in the getStatus
|
|
1968
|
+
// response and omit diffStats (the same omission shape as light mode). Two
|
|
1969
|
+
// full-tree `git diff --numstat` passes and per-new-file line counting over an
|
|
1970
|
+
// oversized working tree block the server event loop for seconds; regular
|
|
1971
|
+
// repositories never reach this threshold. Clients must derive deferred /
|
|
1972
|
+
// load-on-demand behavior from the `oversized` flag, never from their own copy
|
|
1973
|
+
// of this threshold.
|
|
1974
|
+
const GIT_STATUS_DIFF_STATS_MAX_FILES = 2000;
|
|
1975
|
+
|
|
1967
1976
|
export async function getStatus(directory, options = {}) {
|
|
1968
1977
|
const lightMode = options.mode === 'light';
|
|
1969
1978
|
|
|
@@ -1973,8 +1982,17 @@ export async function getStatus(directory, options = {}) {
|
|
|
1973
1982
|
// Use -uall to show all untracked files individually, not just directories
|
|
1974
1983
|
const status = await git.status(['-uall']);
|
|
1975
1984
|
|
|
1985
|
+
// Oversized change sets: skip numstat + new-file stats the same way light
|
|
1986
|
+
// mode does. Two full-tree `git diff --numstat` passes over a huge working
|
|
1987
|
+
// tree take seconds and block the server event loop; regular repositories
|
|
1988
|
+
// stay below the threshold and keep full diffStats. The `oversized` flag in
|
|
1989
|
+
// the response tells clients to defer list rendering; light mode is a
|
|
1990
|
+
// client-requested lightweight poll and does not set the flag.
|
|
1991
|
+
const oversized = (status.files?.length ?? 0) > GIT_STATUS_DIFF_STATS_MAX_FILES;
|
|
1992
|
+
const skipDiffStats = lightMode || oversized;
|
|
1993
|
+
|
|
1976
1994
|
// Light mode: skip numstat + new-file line counting for faster response
|
|
1977
|
-
const [stagedStatsRaw, workingStatsRaw] =
|
|
1995
|
+
const [stagedStatsRaw, workingStatsRaw] = skipDiffStats
|
|
1978
1996
|
? ['', '']
|
|
1979
1997
|
: await Promise.all([
|
|
1980
1998
|
git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
|
|
@@ -2019,7 +2037,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2019
2037
|
const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024;
|
|
2020
2038
|
const newFileStats = [];
|
|
2021
2039
|
|
|
2022
|
-
if (!
|
|
2040
|
+
if (!skipDiffStats) {
|
|
2023
2041
|
for (const file of status.files) {
|
|
2024
2042
|
if (newFileStats.length >= MAX_NEW_FILE_STATS) {
|
|
2025
2043
|
break;
|
|
@@ -2126,7 +2144,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2126
2144
|
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
|
|
2127
2145
|
// We still want to show the number of unpublished commits to the user.
|
|
2128
2146
|
// Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
|
|
2129
|
-
if (!
|
|
2147
|
+
if (!skipDiffStats && !tracking && status.current) {
|
|
2130
2148
|
const baseRef = await selectBaseRefForUnpublished();
|
|
2131
2149
|
if (baseRef) {
|
|
2132
2150
|
const countRaw = await git
|
|
@@ -2142,7 +2160,7 @@ export async function getStatus(directory, options = {}) {
|
|
|
2142
2160
|
}
|
|
2143
2161
|
|
|
2144
2162
|
if (
|
|
2145
|
-
!
|
|
2163
|
+
!skipDiffStats
|
|
2146
2164
|
&& status.current
|
|
2147
2165
|
&& (!tracking || !tracking.startsWith('upstream/'))
|
|
2148
2166
|
&& await hasRemote(git, directoryPath, 'upstream')
|
|
@@ -2217,7 +2235,8 @@ export async function getStatus(directory, options = {}) {
|
|
|
2217
2235
|
working_dir: f.working_dir,
|
|
2218
2236
|
})),
|
|
2219
2237
|
isClean: status.isClean(),
|
|
2220
|
-
|
|
2238
|
+
oversized,
|
|
2239
|
+
diffStats: skipDiffStats ? undefined : diffStats,
|
|
2221
2240
|
mergeInProgress,
|
|
2222
2241
|
rebaseInProgress,
|
|
2223
2242
|
};
|
|
@@ -282,6 +282,57 @@ describe('getStatus', () => {
|
|
|
282
282
|
|
|
283
283
|
await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
|
|
284
284
|
});
|
|
285
|
+
|
|
286
|
+
it('returns diffStats for regular-sized change sets', async () => {
|
|
287
|
+
if (!canRunGit()) return;
|
|
288
|
+
|
|
289
|
+
const repo = createTempDir();
|
|
290
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
291
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
292
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
293
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
294
|
+
runGit(repo, ['add', 'README.md']);
|
|
295
|
+
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
296
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\nMore lines\n');
|
|
297
|
+
|
|
298
|
+
const status = await getStatus(repo);
|
|
299
|
+
expect(status.oversized).toBe(false);
|
|
300
|
+
expect(status.diffStats).toBeDefined();
|
|
301
|
+
expect(status.diffStats['README.md']).toEqual({ insertions: 1, deletions: 0 });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('marks oversized change sets and omits diffStats without changing the rest of the contract', async () => {
|
|
305
|
+
if (!canRunGit()) return;
|
|
306
|
+
|
|
307
|
+
const repo = createTempDir();
|
|
308
|
+
runGit(repo, ['init', '-b', 'main']);
|
|
309
|
+
runGit(repo, ['config', 'user.email', 'test@example.com']);
|
|
310
|
+
runGit(repo, ['config', 'user.name', 'Test User']);
|
|
311
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
|
|
312
|
+
runGit(repo, ['add', 'README.md']);
|
|
313
|
+
runGit(repo, ['commit', '-m', 'Initial commit']);
|
|
314
|
+
|
|
315
|
+
// Create one modified tracked file plus enough untracked files to cross
|
|
316
|
+
// the oversized threshold (untracked files are part of status.files).
|
|
317
|
+
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\nChanged\n');
|
|
318
|
+
const oversizeCount = 2001;
|
|
319
|
+
fs.mkdirSync(path.join(repo, 'generated'), { recursive: true });
|
|
320
|
+
for (let i = 0; i < oversizeCount; i += 1) {
|
|
321
|
+
fs.writeFileSync(path.join(repo, 'generated', `file-${i}.txt`), `content ${i}\n`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const status = await getStatus(repo);
|
|
325
|
+
expect(status.files.length).toBeGreaterThan(2000);
|
|
326
|
+
// The server flags the oversized state so clients defer list rendering.
|
|
327
|
+
expect(status.oversized).toBe(true);
|
|
328
|
+
// Same omission shape as light mode: the key is absent, not an empty object.
|
|
329
|
+
expect(status.diffStats).toBeUndefined();
|
|
330
|
+
// Everything else in the contract stays intact.
|
|
331
|
+
expect(status.current).toBe('main');
|
|
332
|
+
expect(status.isClean).toBe(false);
|
|
333
|
+
expect(status.files.some((file) => file.path === 'README.md')).toBe(true);
|
|
334
|
+
expect(status.files.some((file) => file.path.startsWith('generated/'))).toBe(true);
|
|
335
|
+
});
|
|
285
336
|
});
|
|
286
337
|
|
|
287
338
|
// ---------------------------------------------------------------------------
|
|
@@ -28,7 +28,7 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
28
28
|
- `packages/web/server/lib/opencode/static-routes-runtime.js`: static asset/SPA fallback route registration and manifest route wiring.
|
|
29
29
|
- `packages/web/server/lib/opencode/feature-routes-runtime.js`: feature route composition runtime for dynamic import-backed config/skill/provider route registration.
|
|
30
30
|
- `packages/web/server/lib/conversations/`: combined create-session-and-prompt orchestration (OpenChamber-owned, registered before generic proxy).
|
|
31
|
-
- `packages/web/server/lib/session-turn-pages/`: turn-window session message pagination (`GET /api/openchamber/sessions/:sessionID/messages`)
|
|
31
|
+
- `packages/web/server/lib/session-turn-pages/`: turn-window session message pagination (`GET /api/openchamber/sessions/:sessionID/messages`), anchor reconcile (`GET .../messages/reconcile`), Changes L2/L3 (`GET .../changes`), and exact message L1 projection (`GET /api/session/:sessionID/message/:messageID`); loops official OpenCode `session.messages` for turn pages and head→anchor gap recovery with continuation/budgets/`resetRequired`; all registered before generic proxy. L1 first-packet / SSE / exact-message paths project `summary.diffs` to `diffCount`/`hasDiffs` only. See `session-turn-pages/DOCUMENTATION.md`.
|
|
32
32
|
- `packages/web/server/lib/opencode/opencode-resolution-runtime.js`: OpenCode binary resolution snapshot runtime for settings routes and diagnostics.
|
|
33
33
|
- `packages/web/server/lib/opencode/tunnel-wiring-runtime.js`: active loopback-port wiring shared by pairing/LAN URLs and the private relay host.
|
|
34
34
|
- `packages/web/server/lib/opencode/startup-pipeline-runtime.js`: server startup tail orchestration runtime for terminal/proxy/static/start-listen flow.
|
|
@@ -184,13 +184,16 @@ This module provides OpenCode server integration utilities for the web server ru
|
|
|
184
184
|
|
|
185
185
|
## Public exports (settings-runtime.js)
|
|
186
186
|
- `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns.
|
|
187
|
+
- Optional dependency `runExclusivePersist` / `getRunExclusivePersist`, plus returned `setRunExclusivePersist(fn)`, let an in-process host (Electron) replace the default `persistSettingsLock` with a shared exclusive runner so desktop main/ssh writers and web settings persistence serialize on one chain.
|
|
187
188
|
- Returned API:
|
|
188
189
|
- `readSettingsFromDisk()`
|
|
189
190
|
- `readSettingsFromDiskMigrated()`
|
|
190
191
|
- `writeSettingsToDisk(settings)`
|
|
191
192
|
- `persistSettings(changes)`
|
|
193
|
+
- `setRunExclusivePersist(fn)`
|
|
192
194
|
- Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
|
|
193
195
|
- One-shot compact-chat defaults migration: when disk marker `compactChatDefaultsMigrationVersion` is missing, rewrite legacy/absent `chatRenderMode`/`activityRenderMode`/`showTurnChangedFiles` to `sorted`/`collapsed`/`true` and persist marker `1` (marker stays on disk; response allowlist still hides it). Marker already `1` preserves user values; `persistSettings` runs the same migration before the first write.
|
|
196
|
+
- `startWebUiServer({ settingsPersistLock })` late-binds that shared runner after module load.
|
|
194
197
|
|
|
195
198
|
## Public exports (settings-helpers.js)
|
|
196
199
|
- `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
|
|
@@ -30,6 +30,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
30
30
|
getServerId,
|
|
31
31
|
getServerLabel,
|
|
32
32
|
readSettingsFromDiskMigrated,
|
|
33
|
+
persistSettings,
|
|
33
34
|
normalizeTunnelSessionTtlMs,
|
|
34
35
|
authorizeManagedOpenCodeBridgeRequest,
|
|
35
36
|
sayTTSCapability,
|
|
@@ -147,6 +148,7 @@ export const createBootstrapRuntime = (dependencies) => {
|
|
|
147
148
|
__dirname,
|
|
148
149
|
openchamberDataDir,
|
|
149
150
|
readSettingsFromDiskMigrated,
|
|
151
|
+
persistSettings,
|
|
150
152
|
fetchFreeZenModels,
|
|
151
153
|
getCachedZenModels,
|
|
152
154
|
sessionIndexService,
|
|
@@ -1127,6 +1127,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
|
|
|
1127
1127
|
} else if (
|
|
1128
1128
|
(req.method === 'PUT' && req.path.match(/^\/api\/openchamber\/message-queue\/attachments\/uploads\/[^/]+\/?$/))
|
|
1129
1129
|
|| (req.method === 'PUT' && req.path.match(/^\/api\/fs\/prompt-attachments\/[^/]+\/?$/))
|
|
1130
|
+
|| (req.method === 'PUT' && req.path.match(/^\/api\/openchamber\/config-sync\/put\/[^/]+\/?$/))
|
|
1130
1131
|
) {
|
|
1131
1132
|
// Binary attachment uploads stream as the request body. Parsing JSON here
|
|
1132
1133
|
// would buffer the entire payload and block the relay tunnel.
|
|
@@ -27,10 +27,19 @@ describe('feature routes runtime composition', () => {
|
|
|
27
27
|
const source = await fs.readFile(new URL('./feature-routes-runtime.js', import.meta.url), 'utf8');
|
|
28
28
|
expect(source).toContain("import { registerSessionTurnPageRoutes } from '../session-turn-pages/routes.js';");
|
|
29
29
|
expect(source).toMatch(/registerSessionTurnPageRoutes\(app, \{[\s\S]*buildOpenCodeUrl,[\s\S]*getOpenCodeAuthHeaders,/);
|
|
30
|
-
// OpenChamber-owned turn-page/reconcile registration
|
|
30
|
+
// OpenChamber-owned turn-page/reconcile/changes/exact-message registration stays before end of feature composition.
|
|
31
31
|
expect(source).toContain('// OpenChamber-owned turn-window messages API — must register before generic proxy.');
|
|
32
32
|
const turnIdx = source.indexOf('registerSessionTurnPageRoutes(app,');
|
|
33
33
|
expect(turnIdx).toBeGreaterThan(-1);
|
|
34
|
+
// Exact message + Changes live inside registerSessionTurnPageRoutes (same Host composition Electron reuses).
|
|
35
|
+
const routesSource = await fs.readFile(new URL('../session-turn-pages/routes.js', import.meta.url), 'utf8');
|
|
36
|
+
expect(routesSource).toContain("app.get('/api/session/:sessionID/message/:messageID'");
|
|
37
|
+
expect(routesSource).toContain("app.get('/api/openchamber/sessions/:sessionID/changes'");
|
|
38
|
+
const exactIdx = routesSource.indexOf("app.get('/api/session/:sessionID/message/:messageID'");
|
|
39
|
+
const changesIdx = routesSource.indexOf("app.get('/api/openchamber/sessions/:sessionID/changes'");
|
|
40
|
+
const messagesIdx = routesSource.indexOf("app.get('/api/openchamber/sessions/:sessionID/messages'");
|
|
41
|
+
expect(exactIdx).toBeGreaterThan(messagesIdx);
|
|
42
|
+
expect(changesIdx).toBeGreaterThan(exactIdx);
|
|
34
43
|
});
|
|
35
44
|
|
|
36
45
|
it('removes broken SSE clients while continuing worktree topology broadcasts', () => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { registerSessionIndexRoutes } from '../session-index/routes.js';
|
|
2
2
|
import { registerTranscriptCacheRoutes } from '../transcript-cache/routes.js';
|
|
3
3
|
import { registerDesktopHostRoutes } from '../desktop-hosts/routes.js';
|
|
4
|
+
import { registerConfigSyncRoutes } from '../config-sync/routes.js';
|
|
4
5
|
|
|
5
6
|
export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
6
7
|
const {
|
|
@@ -11,6 +12,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
11
12
|
__dirname,
|
|
12
13
|
openchamberDataDir,
|
|
13
14
|
readSettingsFromDiskMigrated,
|
|
15
|
+
persistSettings,
|
|
14
16
|
fetchFreeZenModels,
|
|
15
17
|
getCachedZenModels,
|
|
16
18
|
sessionIndexService,
|
|
@@ -31,6 +33,11 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
|
|
|
31
33
|
getPairingSession,
|
|
32
34
|
express,
|
|
33
35
|
});
|
|
36
|
+
registerConfigSyncRoutes(app, {
|
|
37
|
+
readSettingsFromDiskMigrated,
|
|
38
|
+
persistSettings,
|
|
39
|
+
express,
|
|
40
|
+
});
|
|
34
41
|
|
|
35
42
|
app.get('/api/openchamber/update-check', async (req, res) => {
|
|
36
43
|
try {
|
|
@@ -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
|
|
|
@@ -11,6 +11,8 @@ Exposed as:
|
|
|
11
11
|
|
|
12
12
|
- `GET /api/openchamber/sessions/:sessionID/messages`
|
|
13
13
|
- `GET /api/openchamber/sessions/:sessionID/messages/reconcile`
|
|
14
|
+
- `GET /api/openchamber/sessions/:sessionID/changes` (L2 file list / L3 single-file patch)
|
|
15
|
+
- `GET /api/session/:sessionID/message/:messageID` (exact message with L1 summary projection; registered before the generic OpenCode proxy)
|
|
14
16
|
|
|
15
17
|
## Why this exists
|
|
16
18
|
|
|
@@ -69,9 +71,22 @@ Success `200`:
|
|
|
69
71
|
- `complete`: `upstreamComplete && selected.length === accumulated.length`. True when upstream history is exhausted **and** `selectTurnRecords` did not trim any older scanned rows (nothing left for the client). When the scan window held more than N turns and older rows were trimmed, `complete` stays `false` with a `cursor` so the client can fetch the trimmed history.
|
|
70
72
|
- `partsProjection`: `"slim-v1"` on every turn-page response (first packet and prepend). Names the projection applied to `records`; see below. Reconcile responses omit this field and keep full parts.
|
|
71
73
|
|
|
72
|
-
### Turn-page
|
|
74
|
+
### Turn-page payload projection
|
|
75
|
+
|
|
76
|
+
Every turn-page response summarizes `tool`, `reasoning`, and `file` parts plus message `summary.diffs` so it does not have to carry the bodies of a long tool-heavy turn, inline attachment data URLs, or full file patches. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
|
|
77
|
+
|
|
78
|
+
### Changes Host contract (L1 / L2 / L3)
|
|
79
|
+
|
|
80
|
+
| Layer | Surface | Payload |
|
|
81
|
+
|---|---|---|
|
|
82
|
+
| **L1** | Turn pages, reconcile response pages, message/session SSE fan-out, exact `GET /api/session/:sessionID/message/:messageID` | Any `info.summary.diffs` **array** is projected to `summary.diffCount: number` + `summary.hasDiffs: boolean` and **`summary.diffs` is removed**. Other lightweight summary fields are preserved. `diffCount` is the original array length; `hasDiffs === diffCount > 0`. L1 never retains a file array. |
|
|
83
|
+
| **L2** | `GET /api/openchamber/sessions/:sessionID/changes?messageID=` (no `file`) | Host reads official `session.message` locally, returns only `{ files: [{ file, status?, additions, deletions }] }`. Never returns patch/before/after/from/to or the full message envelope. |
|
|
84
|
+
| **L3** | Same route with `file=` | Host calls official `session.diff({ sessionID, messageID, directory })`, returns `{ diff: SnapshotFileDiff }` for the exact `file` string match (includes that file's patch). No match → HTTP 404 `{ error: 'change file not found', code: 'change_not_found' }`. |
|
|
85
|
+
|
|
86
|
+
Exact message GET keeps the official `{ info, parts }` response shape and only applies L1 summary projection so materialize/recovery never relays a raw multi-megabyte `summary.diffs` array through the generic proxy. Abort/45s timeout semantics match the turn-page routes. Global `/api` auth still applies.
|
|
87
|
+
|
|
88
|
+
Changes query guards: `sessionID` / `messageID` length `1..512`; optional `file` length `1..4096` and rejects control characters / NUL. Failures: invalid params → 400, abort → 499, upstream/malformed → 502. Logs carry only safe flags and ids — never paths, patch bodies, or tokens.
|
|
73
89
|
|
|
74
|
-
Every turn-page response summarizes `tool`, `reasoning`, and `file` parts so it does not have to carry the bodies of a long tool-heavy turn or inline attachment data URLs. Projection runs **after** `selectTurnRecords`, so `turnCount`, `complete`, and cursor encoding are all derived from unprojected records and cannot shift.
|
|
75
90
|
|
|
76
91
|
| Kept | Dropped |
|
|
77
92
|
|---|---|
|
|
@@ -85,6 +100,7 @@ Rules:
|
|
|
85
100
|
- Slim tool input keeps only short locator and task-identity fields (path, pattern, query, command, `subagent_type`, `description`, skill `name` / `id`). Slim metadata keeps `sessionId`, skill `name`, plus edit `additions`/`deletions`. Projection still drops result bodies, task prompts, patches, and large write payloads.
|
|
86
101
|
- Assistant `text` stays intact. User rows are no longer wholesale pass-through: their `file` parts are projected; other user parts stay as-is.
|
|
87
102
|
- `file` projection keeps already-present size/width/height (top-level or `metadata`). `byteSize` is derived from a data URL when present. PNG/GIF/JPEG/WebP headers may fill missing `width`/`height`. Unknown dimensions omit those fields.
|
|
103
|
+
- Message `info.summary.diffs` is L1-projected to `diffCount` / `hasDiffs` (file array removed). Reconcile preserves full parts and applies this same L1 summary projection to response-page byte budgeting and serialization; raw upstream scan budgets retain raw bytes. Clients that need file rows or patches use the Changes L2/L3 API.
|
|
88
104
|
- A slim `file` part is a reference, not renderable content. Clients must hydrate the full message (`session.message` by `messageID`) before painting or editing the attachment. Do not treat slim file metadata as a complete part.
|
|
89
105
|
- Records that gain nothing from projection keep their original object reference, so identity-based change detection downstream still works.
|
|
90
106
|
- Projection applies to every turn-page response, including first packet, prepend, and explicit cursors (`before` present). Reconcile routes return full parts, unchanged.
|
|
@@ -242,10 +258,11 @@ When the per-page budget fills before the gap is complete, the host returns `con
|
|
|
242
258
|
|
|
243
259
|
## Files
|
|
244
260
|
|
|
245
|
-
- `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
|
|
261
|
+
- `service.js` — `isUserAuthoredTurnBoundary`, `selectTurnRecords`, `encodeHostCursor`, `decodeHostCursor`, `projectMessageDiffSummaries` (L1), `projectSlimParts`, `createSessionTurnPageService({ fetchPage, maxScanPages?, maxScanMessages? })`
|
|
246
262
|
- `reconcile.service.js` — `encodeReconcileContinuation`, `decodeReconcileContinuation`, `createSessionReconcileService({ fetchPage, runtimeKey?, page/total budgets?, continuationSecret?, clock?, continuationTtlMs? })`
|
|
247
|
-
- `
|
|
248
|
-
- `
|
|
263
|
+
- `changes.service.js` — `createSessionChangesService({ fetchMessage, fetchDiff })` for L2/L3; injectable for unit tests
|
|
264
|
+
- `routes.js` — Express registration for turn-page, reconcile, Changes, and exact message GET; optional injected `sessionTurnPageService` / `sessionReconcileService` / `sessionChangesService` / `fetchExactMessage` for tests; default wires SDK `session.messages` / `session.message` / `session.diff` via `buildOpenCodeUrl` + `getOpenCodeAuthHeaders`
|
|
265
|
+
- `service.test.js` / `routes.test.js` / `reconcile.service.test.js` / `reconcile.routes.test.js` / `changes.service.test.js` — unit contracts
|
|
249
266
|
|
|
250
267
|
## Registration
|
|
251
268
|
|
|
@@ -257,14 +274,17 @@ registerSessionTurnPageRoutes(app, {
|
|
|
257
274
|
});
|
|
258
275
|
```
|
|
259
276
|
|
|
260
|
-
|
|
277
|
+
Inside `registerSessionTurnPageRoutes`, registration order is: reconcile → turn-page messages → exact `GET /api/session/:sessionID/message/:messageID` → Changes. All of these register before the generic OpenCode proxy in `feature-routes-runtime.js`. Electron reuses this same Host composition in-process.
|
|
261
278
|
|
|
262
279
|
## Error HTTP map
|
|
263
280
|
|
|
264
281
|
| Condition | Status |
|
|
265
282
|
|---|---|
|
|
266
283
|
| Invalid turns / scanLimit / sessionID / cursor | 400 |
|
|
284
|
+
| Invalid messageID / file (Changes / exact message) | 400 |
|
|
267
285
|
| Invalid anchor / continuation / reconcile params | 400 |
|
|
286
|
+
| Change file not found (`change_not_found`) | 404 |
|
|
287
|
+
| Exact message not found | 404 |
|
|
268
288
|
| max_scan_pages / max_scan_messages | 413 |
|
|
269
289
|
| Client abort | 499 |
|
|
270
290
|
| Server exception (reconcile) | 500 (stack logged; safe client body) |
|