@aiworker/web 1.23.0 → 1.23.2
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/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-COGJG0Iv.js} +1 -1
- package/dist/assets/DiffView-BviYY3zL.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-C26DvfVJ.js} +1 -1
- package/dist/assets/FilesView-D0ZU_nCt.js +13 -0
- package/dist/assets/{GitView-BXsS3jG6.js → GitView-CYgiDRH-.js} +4 -4
- package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-GsV9UScz.js} +1 -1
- package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-shqUS8nl.js} +1 -1
- package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-CQSTFp6m.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DJWP3A8X.js} +2 -2
- package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-CTuu4lgo.js} +1 -1
- package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-Dp53v2pl.js} +1 -1
- package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-B5l0djPS.js} +1 -1
- package/dist/assets/PlanView-CLUWkGiG.js +5 -0
- package/dist/assets/SettingsView-CojPQVHx.js +28 -0
- package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-DbgEnZ6T.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-Du-RZj-a.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-HBLSokoe.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-Bzp3hClU.js} +4 -4
- package/dist/assets/WalkthroughView-CpgA4s9w.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-CI1PC_z2.js} +2 -2
- package/dist/assets/de-BX8b4hDD.js +10 -0
- package/dist/assets/es-e98cS-yM.js +16 -0
- package/dist/assets/fr-BOiqxJAs.js +14 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-jXSK5RrB.js → index-Cmn74sw-.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-DVralkU-.js} +1 -1
- package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-CgTREsRX.js} +1 -1
- package/dist/assets/ja-aoO55hGu.js +16 -0
- package/dist/assets/{ko-idhXGF8j.js → ko-BGzFLogk.js} +3 -3
- package/dist/assets/{main-CzdPuL0s.js → main-BgfXQ-4g.js} +2 -2
- package/dist/assets/main-DGYUWmIt.js +930 -0
- package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-BesaNs_G.js} +3 -3
- package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-DHKXp-_I.js} +2 -2
- package/dist/assets/{mobile-mvxTo055.js → mobile-lXQ08VnX.js} +2 -2
- package/dist/assets/patchFileDiff-i1lTtNbn.js +13 -0
- package/dist/assets/{pl-CGZWUqZJ.js → pl-B5x19laq.js} +3 -3
- package/dist/assets/pt-BR-C3oU51Il.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-CuO4ue6B.js +2 -0
- package/dist/assets/renderMobileApp-B3zFlHy4.js +25 -0
- package/dist/assets/runtimeEndpointReset-BLzrtrmj.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-ctlcPvvx.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-C0QQa7et.js +7 -0
- package/dist/assets/uk-CqLSe9wT.js +16 -0
- package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-B6OfEypo.js} +153 -155
- package/dist/assets/useGitComparison-BXZXEAW6.js +1 -0
- package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-CgbrpEWu.js} +2 -2
- package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-Me41OjBT.js} +1 -1
- package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-SJuFp0a0.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
- package/dist/assets/{web-components-z7hfnS8n.js → web-components-D8GS8Ork.js} +1 -1
- package/dist/assets/zh-CN-iJEie4d6.js +16 -0
- package/dist/assets/zh-TW-CyslEwlK.js +16 -0
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +1 -1
- package/server/index.js +3 -13
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +60 -0
- package/server/lib/git/DOCUMENTATION.md +10 -1
- package/server/lib/git/routes.js +21 -8
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +159 -4
- package/server/lib/git/service.test.js +74 -0
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/message-queue/DOCUMENTATION.md +7 -0
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +18 -4
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/settings-helpers.js +3 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -1
- package/server/lib/opencode/settings-registry.json +3 -0
- package/server/lib/opencode/settings-runtime.js +36 -9
- package/server/lib/opencode/settings-runtime.test.js +158 -2
- package/server/lib/project-context/DOCUMENTATION.md +13 -3
- package/server/lib/project-context/runtime.js +8 -2
- package/server/lib/project-context/runtime.test.js +54 -0
- package/server/lib/projects/DOCUMENTATION.md +2 -1
- package/server/lib/projects/project-config.js +38 -9
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +26 -0
- package/server/lib/relay/DOCUMENTATION.md +20 -0
- package/server/lib/relay/host-client.js +7 -0
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +111 -257
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +13 -2
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-DXjVsEOw.js +0 -4
- package/dist/assets/FilesView-CJdG0-wk.js +0 -17
- package/dist/assets/PlanView-CJTmOF6I.js +0 -5
- package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
- package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
- package/dist/assets/de-DCrsQ5zS.js +0 -10
- package/dist/assets/es-Dwv4YfH6.js +0 -16
- package/dist/assets/fr-BCWg4Cnx.js +0 -14
- package/dist/assets/index-B68h5S5B.css +0 -1
- package/dist/assets/index-DVwMarHI.js +0 -39
- package/dist/assets/ja-Bruw3RYC.js +0 -16
- package/dist/assets/main-BkyaQhJX.js +0 -930
- package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
- package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
- package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
- package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
- package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
- package/dist/assets/surface-oMvhsog3.js +0 -1
- package/dist/assets/tr-BK_KiO2V.js +0 -7
- package/dist/assets/uk-CG74xV8N.js +0 -16
- package/dist/assets/useGitComparison-DH220WmO.js +0 -1
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/zh-CN-T4AXofZI.js +0 -16
- package/dist/assets/zh-TW-Dte40sxp.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
|
@@ -14,6 +14,7 @@ const originalResourcesPath = process.resourcesPath;
|
|
|
14
14
|
const originalWslBinary = process.env.WSL_BINARY;
|
|
15
15
|
const originalAiWorkerWslBinary = process.env.AIWORKER_WSL_BINARY;
|
|
16
16
|
const originalPlatform = process.platform;
|
|
17
|
+
const originalRuntime = process.env.AIWORKER_RUNTIME;
|
|
17
18
|
const tempDirs = [];
|
|
18
19
|
const itIf = (condition) => condition ? it : it.skip;
|
|
19
20
|
|
|
@@ -30,6 +31,8 @@ const setPlatform = (platform) => {
|
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
afterEach(() => {
|
|
34
|
+
if (originalRuntime === undefined) delete process.env.AIWORKER_RUNTIME;
|
|
35
|
+
else process.env.AIWORKER_RUNTIME = originalRuntime;
|
|
33
36
|
Object.defineProperty(process, 'platform', {
|
|
34
37
|
value: originalPlatform,
|
|
35
38
|
});
|
|
@@ -117,7 +120,76 @@ const createRuntime = (settings, options = {}) => {
|
|
|
117
120
|
return { runtime, state };
|
|
118
121
|
};
|
|
119
122
|
|
|
123
|
+
const createBundledCli = () => {
|
|
124
|
+
const resourcesPath = createTempDir('openchamber-resources-');
|
|
125
|
+
const directory = path.join(resourcesPath, 'opencode-cli');
|
|
126
|
+
fs.mkdirSync(directory);
|
|
127
|
+
const binary = path.join(directory, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
128
|
+
fs.writeFileSync(binary, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
129
|
+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: resourcesPath });
|
|
130
|
+
return binary;
|
|
131
|
+
};
|
|
132
|
+
|
|
120
133
|
describe('OpenCode env runtime', () => {
|
|
134
|
+
it.each(['linux', 'darwin', 'win32'])('keeps automatic desktop bundled resolution local across relaunch on %s', (platform) => {
|
|
135
|
+
setPlatform(platform);
|
|
136
|
+
process.env.AIWORKER_RUNTIME = 'desktop';
|
|
137
|
+
delete process.env.OPENCODE_BINARY;
|
|
138
|
+
delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
|
|
139
|
+
|
|
140
|
+
const oldBinary = createBundledCli();
|
|
141
|
+
const oldRuntime = createRuntime({});
|
|
142
|
+
expect(oldRuntime.runtime.ensureOpencodeCliEnv()).toBe(oldBinary);
|
|
143
|
+
expect(oldRuntime.runtime.ensureOpencodeCliEnv()).toBe(oldBinary);
|
|
144
|
+
expect(oldRuntime.state.resolvedOpencodeBinarySource).toBe('bundled');
|
|
145
|
+
expect(process.env.OPENCODE_BINARY).toBeUndefined();
|
|
146
|
+
expect(process.env.PATH.split(path.delimiter)[0]).toBe(path.dirname(oldBinary));
|
|
147
|
+
|
|
148
|
+
// The old binary remains executable and its directory is still in PATH.
|
|
149
|
+
const newBinary = createBundledCli();
|
|
150
|
+
const newRuntime = createRuntime({});
|
|
151
|
+
expect(newRuntime.runtime.ensureOpencodeCliEnv()).toBe(newBinary);
|
|
152
|
+
expect(newRuntime.state.resolvedOpencodeBinarySource).toBe('bundled');
|
|
153
|
+
expect(newRuntime.runtime.isBundledOpenCodeCliPath(newBinary)).toBe(true);
|
|
154
|
+
expect(process.env.OPENCODE_BINARY).toBeUndefined();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it.each(['web', 'ssh-remote'])('preserves automatic binary export outside desktop in %s', (runtimeName) => {
|
|
158
|
+
process.env.AIWORKER_RUNTIME = runtimeName;
|
|
159
|
+
delete process.env.OPENCODE_BINARY;
|
|
160
|
+
delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
|
|
161
|
+
const binary = createBundledCli();
|
|
162
|
+
const { runtime } = createRuntime({});
|
|
163
|
+
expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
|
|
164
|
+
expect(process.env.OPENCODE_BINARY).toBe(binary);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it.each(['env', 'settings'])('preserves explicit desktop %s selection even when it points at a bundled CLI', async (source) => {
|
|
168
|
+
process.env.AIWORKER_RUNTIME = 'desktop';
|
|
169
|
+
delete process.env.OPENCODE_BINARY;
|
|
170
|
+
delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
|
|
171
|
+
const binary = createBundledCli();
|
|
172
|
+
if (source === 'env') process.env.OPENCODE_BINARY = binary;
|
|
173
|
+
const { runtime, state } = createRuntime(source === 'settings' ? { opencodeBinary: binary } : {});
|
|
174
|
+
await runtime.applyOpencodeBinaryFromSettings({ strict: true });
|
|
175
|
+
expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
|
|
176
|
+
expect(state.resolvedOpencodeBinarySource).toBe(source);
|
|
177
|
+
expect(process.env.OPENCODE_BINARY).toBe(binary);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('preserves automatic PATH binary export for desktop without a bundled CLI', () => {
|
|
181
|
+
process.env.AIWORKER_RUNTIME = 'desktop';
|
|
182
|
+
delete process.env.OPENCODE_BINARY;
|
|
183
|
+
delete process.env.AIWORKER_BUNDLED_OPENCODE_CLI_DIR;
|
|
184
|
+
const binary = createBundledCli();
|
|
185
|
+
Object.defineProperty(process, 'resourcesPath', { configurable: true, value: undefined });
|
|
186
|
+
process.env.PATH = path.dirname(binary);
|
|
187
|
+
const { runtime, state } = createRuntime({});
|
|
188
|
+
expect(runtime.ensureOpencodeCliEnv()).toBe(binary);
|
|
189
|
+
expect(state.resolvedOpencodeBinarySource).toBe('path');
|
|
190
|
+
expect(process.env.OPENCODE_BINARY).toBe(binary);
|
|
191
|
+
});
|
|
192
|
+
|
|
121
193
|
it('searches an explicit PATH without mutating the process environment', () => {
|
|
122
194
|
const defaultDir = createTempDir('openchamber-default-path-');
|
|
123
195
|
const explicitDir = createTempDir('openchamber-explicit-path-');
|
|
@@ -307,7 +307,21 @@ export const createFeatureRoutesRuntime = (dependencies) => {
|
|
|
307
307
|
registerSessionGoalRoutes(app);
|
|
308
308
|
registerGitHubRoutes(app);
|
|
309
309
|
registerLinearRoutes(app);
|
|
310
|
-
registerGitRoutes(app
|
|
310
|
+
registerGitRoutes(app, {
|
|
311
|
+
emitWorktreeChanged: ({ directories, at }) => {
|
|
312
|
+
const clients = getAiWorkerEventClients();
|
|
313
|
+
for (const client of clients) {
|
|
314
|
+
try {
|
|
315
|
+
writeSseEvent(client, {
|
|
316
|
+
type: 'aiworker:worktree-changed',
|
|
317
|
+
properties: { directories, at },
|
|
318
|
+
});
|
|
319
|
+
} catch {
|
|
320
|
+
clients.delete(client);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
});
|
|
311
325
|
registerDevServerRoutes(app, { scanner: devServerScanner, getOwnPorts });
|
|
312
326
|
registerMagicPromptRoutes(app, {
|
|
313
327
|
fsPromises,
|
|
@@ -377,8 +377,8 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
377
377
|
return parts.length > 0 ? parts.join('\n\n') : 'No stdout/stderr captured';
|
|
378
378
|
};
|
|
379
379
|
|
|
380
|
-
const createManagedOpenCodeServerProcess = async ({ hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
|
|
381
|
-
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
|
380
|
+
const createManagedOpenCodeServerProcess = async ({ resolvedBinary, hostname, port, timeout, cwd, env: processEnv, shellEnvKeysCount = 0 }) => {
|
|
381
|
+
let binary = (resolvedBinary || process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
|
382
382
|
const sourceBinary = binary;
|
|
383
383
|
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
|
384
384
|
let launchWrapperType = null;
|
|
@@ -694,7 +694,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
694
694
|
);
|
|
695
695
|
|
|
696
696
|
await applyOpencodeBinaryFromSettings({ strict: true });
|
|
697
|
-
ensureOpencodeCliEnv();
|
|
697
|
+
const resolvedBinary = ensureOpencodeCliEnv();
|
|
698
698
|
recordStartupPerformance('opencode.binary.ready', {
|
|
699
699
|
attempt,
|
|
700
700
|
durationMs: performance.now() - phaseStartedAt,
|
|
@@ -721,6 +721,7 @@ export const createOpenCodeLifecycleRuntime = (deps) => {
|
|
|
721
721
|
|
|
722
722
|
try {
|
|
723
723
|
const serverInstance = await createManagedOpenCodeServerProcess({
|
|
724
|
+
resolvedBinary,
|
|
724
725
|
hostname: env.ENV_CONFIGURED_OPENCODE_HOSTNAME,
|
|
725
726
|
port: spawnPort,
|
|
726
727
|
timeout: 30000,
|
|
@@ -126,6 +126,35 @@ const createRuntime = (overrides = {}, stateOverrides = {}, envOverrides = {}) =
|
|
|
126
126
|
};
|
|
127
127
|
|
|
128
128
|
describe('OpenCode lifecycle', () => {
|
|
129
|
+
it('uses the resolved binary directly on startup and managed restart without an env override', async () => {
|
|
130
|
+
delete process.env.OPENCODE_BINARY;
|
|
131
|
+
const binaries = ['/bundle one/opencode-cli/opencode', '/bundle two/opencode-cli/opencode'];
|
|
132
|
+
const ensureOpencodeCliEnv = vi.fn()
|
|
133
|
+
.mockReturnValueOnce(binaries[0])
|
|
134
|
+
.mockReturnValueOnce(binaries[1]);
|
|
135
|
+
spawnMock.mockImplementation(() => {
|
|
136
|
+
const child = createMockChild();
|
|
137
|
+
queueMicrotask(() => child.stdout.emit('data', 'opencode server listening on http://127.0.0.1:45678\n'));
|
|
138
|
+
return child;
|
|
139
|
+
});
|
|
140
|
+
globalThis.fetch = vi.fn(async () => ({ ok: false }));
|
|
141
|
+
const runtime = createRuntime({ ensureOpencodeCliEnv });
|
|
142
|
+
const firstServer = await runtime.startOpenCode();
|
|
143
|
+
runtime.testState.openCodeProcess = firstServer;
|
|
144
|
+
try {
|
|
145
|
+
await runtime.restartOpenCode();
|
|
146
|
+
expect(firstServer.signalCode).toBe('SIGTERM');
|
|
147
|
+
expect(spawnMock.mock.calls.map(([binary]) => binary)).toEqual(binaries);
|
|
148
|
+
expect(runtime.testState.lastOpenCodeLaunchDiagnostics.sourceBinary).toBe(binaries[1]);
|
|
149
|
+
expect(process.env.OPENCODE_BINARY).toBeUndefined();
|
|
150
|
+
for (const [, , options] of spawnMock.mock.calls) {
|
|
151
|
+
expect(options.env).not.toHaveProperty('OPENCODE_BINARY');
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
await runtime.testState.openCodeProcess.close();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
129
158
|
it('records an authoritative ready terminal event for external startup', async () => {
|
|
130
159
|
globalThis.fetch = vi.fn(async () => ({
|
|
131
160
|
ok: true,
|
|
@@ -8,7 +8,7 @@ const isJsonObject = (value) => value !== null && value !== undefined && Object.
|
|
|
8
8
|
*
|
|
9
9
|
* Existing entries are preserved; an earlier entry for the same URL is dropped
|
|
10
10
|
* so a restart never registers the same plugin twice. Every managed plugin
|
|
11
|
-
* (agent tools, system prompt optimizer
|
|
11
|
+
* (agent tools, system prompt optimizer) goes through this one
|
|
12
12
|
* merge so they compose in any order.
|
|
13
13
|
*
|
|
14
14
|
* @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { createRealpathCache } from '../path-realpath-cache.js';
|
|
12
12
|
import { DEFAULT_UPSTREAM_STALL_TIMEOUT_MS } from '../event-stream/upstream-reader.js';
|
|
13
13
|
import { recordStartupPerformance } from './startup-performance.js';
|
|
14
|
+
import { getWorktreeBootstrapStatus } from '../git/service.js';
|
|
14
15
|
|
|
15
16
|
const DEFAULT_SSE_HEARTBEAT_INTERVAL_MS = 20_000;
|
|
16
17
|
|
|
@@ -286,6 +287,8 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
286
287
|
SSE_HEARTBEAT_INTERVAL_MS = DEFAULT_SSE_HEARTBEAT_INTERVAL_MS,
|
|
287
288
|
SSE_UPSTREAM_STALL_TIMEOUT_MS = DEFAULT_UPSTREAM_STALL_TIMEOUT_MS,
|
|
288
289
|
getSseUpstreamStallTimeoutMs = () => SSE_UPSTREAM_STALL_TIMEOUT_MS,
|
|
290
|
+
readWorktreeBootstrapStatus = getWorktreeBootstrapStatus,
|
|
291
|
+
WORKTREE_READY_TIMEOUT_MS = 5 * 60 * 1000,
|
|
289
292
|
} = deps;
|
|
290
293
|
|
|
291
294
|
if (app.get('opencodeProxyConfigured')) {
|
|
@@ -775,6 +778,36 @@ export const registerOpenCodeProxy = (app, deps) => {
|
|
|
775
778
|
}
|
|
776
779
|
});
|
|
777
780
|
|
|
781
|
+
// Any directory-scoped read can initialize OpenCode's cached project/config,
|
|
782
|
+
// before session.create runs. Hold all upstream requests until Git population
|
|
783
|
+
// finishes, independently of the user's optional setup-script wait.
|
|
784
|
+
app.use('/api', async (req, res, next) => {
|
|
785
|
+
normalizeForwardedDirectoryHeaders(req.headers);
|
|
786
|
+
const url = new URL(req.url, 'http://localhost');
|
|
787
|
+
const directory = url.searchParams.get('directory') || req.get('x-opencode-directory');
|
|
788
|
+
if (!directory) return next();
|
|
789
|
+
|
|
790
|
+
const deadline = Date.now() + WORKTREE_READY_TIMEOUT_MS;
|
|
791
|
+
try {
|
|
792
|
+
while (!res.destroyed && !res.writableEnded && !req.aborted) {
|
|
793
|
+
const status = await readWorktreeBootstrapStatus(directory);
|
|
794
|
+
if (res.destroyed || res.writableEnded || req.aborted) return;
|
|
795
|
+
if (status.status === 'failed') {
|
|
796
|
+
return res.status(503).json({ error: status.error || 'Worktree bootstrap failed' });
|
|
797
|
+
}
|
|
798
|
+
if (status.status === 'ready' || status.phase === 'git-ready' || status.phase === 'setup-ready') {
|
|
799
|
+
return next();
|
|
800
|
+
}
|
|
801
|
+
if (Date.now() >= deadline) {
|
|
802
|
+
return res.status(503).json({ error: 'Timed out waiting for worktree checkout' });
|
|
803
|
+
}
|
|
804
|
+
await sleep(75);
|
|
805
|
+
}
|
|
806
|
+
} catch (error) {
|
|
807
|
+
next(error);
|
|
808
|
+
}
|
|
809
|
+
});
|
|
810
|
+
|
|
778
811
|
// Windows: session merge for cross-directory session listing
|
|
779
812
|
if (process.platform === 'win32') {
|
|
780
813
|
app.get('/api/session', async (req, res, next) => {
|
|
@@ -473,6 +473,9 @@ export const createSettingsHelpers = (dependencies) => {
|
|
|
473
473
|
if (candidate.sessionRetentionAction === 'archive' || candidate.sessionRetentionAction === 'delete') {
|
|
474
474
|
result.sessionRetentionAction = candidate.sessionRetentionAction;
|
|
475
475
|
}
|
|
476
|
+
if (typeof candidate.sessionRetentionOnlyArchived === 'boolean') {
|
|
477
|
+
result.sessionRetentionOnlyArchived = candidate.sessionRetentionOnlyArchived;
|
|
478
|
+
}
|
|
476
479
|
if (candidate.tunnelBootstrapTtlMs === null) {
|
|
477
480
|
result.tunnelBootstrapTtlMs = null;
|
|
478
481
|
} else if (typeof candidate.tunnelBootstrapTtlMs === 'number' && Number.isFinite(candidate.tunnelBootstrapTtlMs)) {
|
|
@@ -690,6 +690,14 @@ describe('settings helpers', () => {
|
|
|
690
690
|
});
|
|
691
691
|
|
|
692
692
|
describe('session retention settings persistence', () => {
|
|
693
|
+
it('round-trips archived-only retention and rejects non-boolean values', () => {
|
|
694
|
+
const helpers = createTestHelpersWithRealSanitizers();
|
|
695
|
+
for (const sessionRetentionOnlyArchived of [true, false]) {
|
|
696
|
+
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionOnlyArchived })).toEqual({ sessionRetentionOnlyArchived });
|
|
697
|
+
}
|
|
698
|
+
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionOnlyArchived: 'true' })).toEqual({});
|
|
699
|
+
expect(helpers.sanitizeSettingsUpdate({ sessionRetentionOnlyArchived: null })).toEqual({});
|
|
700
|
+
});
|
|
693
701
|
it('round-trips sessionRetentionAction archive and delete through the sanitizer', () => {
|
|
694
702
|
const helpers = createTestHelpersWithRealSanitizers();
|
|
695
703
|
|
|
@@ -714,6 +722,7 @@ describe('settings helpers', () => {
|
|
|
714
722
|
autoDeleteEnabled: true,
|
|
715
723
|
autoDeleteAfterDays: 60,
|
|
716
724
|
sessionRetentionAction: 'delete',
|
|
725
|
+
sessionRetentionOnlyArchived: true,
|
|
717
726
|
};
|
|
718
727
|
|
|
719
728
|
const sanitized = helpers.sanitizeSettingsUpdate(payload);
|
|
@@ -721,6 +730,7 @@ describe('settings helpers', () => {
|
|
|
721
730
|
expect(sanitized.autoDeleteEnabled).toBe(true);
|
|
722
731
|
expect(sanitized.autoDeleteAfterDays).toBe(60);
|
|
723
732
|
expect(sanitized.sessionRetentionAction).toBe('delete');
|
|
733
|
+
expect(sanitized.sessionRetentionOnlyArchived).toBe(true);
|
|
724
734
|
});
|
|
725
735
|
});
|
|
726
736
|
});
|
|
@@ -746,7 +756,7 @@ describe('settings registry gate', () => {
|
|
|
746
756
|
desktopUiPassword: 'secret', githubClientId: 'client', githubScopes: 'repo', skillCatalogs: [{ id: 'c', label: 'C', source: 'https://x' }],
|
|
747
757
|
defaultGitIdentityId: 'global', permissionAutoAccept: { sessions: { s: true }, revision: 1 },
|
|
748
758
|
agentControlToolEnabled: true, agentWebToolEnabled: true, agentMemoryToolEnabled: true, openCodeUpdateToastDismissedVersion: '1.0.0',
|
|
749
|
-
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
|
|
759
|
+
autoDeleteEnabled: true, autoDeleteAfterDays: 30, sessionRetentionOnlyArchived: false, sessionRetentionAction: 'archive', terminalShell: 'zsh', terminalLoginShells: ['zsh'],
|
|
750
760
|
openInAppId: 'vscode', dictationEnabled: true, sttProvider: 'local', sttServerUrl: 'http://localhost:8001/v1', sttModel: 'm', sttLocalModel: 'm', sttLanguage: 'en',
|
|
751
761
|
tunnelProvider: 'cloudflare', tunnelMode: 'quick', tunnelBootstrapTtlMs: 600000, tunnelSessionTtlMs: 86400000, managedLocalTunnelConfigPath: '/tmp/x',
|
|
752
762
|
managedRemoteTunnelHostname: 'x.example', managedRemoteTunnelToken: 'token', managedRemoteTunnelPresets: [{ id: 'a', name: 'A', hostname: 'a.example' }],
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createProjectIdFromPath } from '../projects/project-id.js';
|
|
1
|
+
import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
|
|
2
2
|
import {
|
|
3
3
|
buildPreferencesFields,
|
|
4
4
|
flattenPreferences,
|
|
@@ -288,10 +288,10 @@ export const createSettingsRuntime = (deps) => {
|
|
|
288
288
|
return;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
-
const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${oldId}.json`);
|
|
292
|
-
const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${newId}.json`);
|
|
293
|
-
const oldStorageDir = path.join(PROJECTS_ROOT_DIR, oldId);
|
|
294
|
-
const newStorageDir = path.join(PROJECTS_ROOT_DIR, newId);
|
|
291
|
+
const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${projectConfigFileStemOf(oldId)}.json`);
|
|
292
|
+
const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${projectConfigFileStemOf(newId)}.json`);
|
|
293
|
+
const oldStorageDir = path.join(PROJECTS_ROOT_DIR, projectConfigFileStemOf(oldId));
|
|
294
|
+
const newStorageDir = path.join(PROJECTS_ROOT_DIR, projectConfigFileStemOf(newId));
|
|
295
295
|
|
|
296
296
|
const [oldConfig, newConfig] = await Promise.all([
|
|
297
297
|
readJsonFile(oldConfigPath),
|
|
@@ -308,6 +308,28 @@ export const createSettingsRuntime = (deps) => {
|
|
|
308
308
|
await fsPromises.rm(oldConfigPath, { force: true });
|
|
309
309
|
};
|
|
310
310
|
|
|
311
|
+
/**
|
|
312
|
+
* A build before the bounded folder name created `<projectId>/` for an id
|
|
313
|
+
* the filesystem still accepted (201 to 255 characters; longer ids never
|
|
314
|
+
* got a folder). Only the bounded folder is read now, so that folder's
|
|
315
|
+
* notes, plans, and memory are moved over once. A raw name the filesystem
|
|
316
|
+
* cannot hold (ENAMETOOLONG) means no such folder ever existed.
|
|
317
|
+
*/
|
|
318
|
+
const migrateRawIdStorageFolder = async (projectId) => {
|
|
319
|
+
const stem = projectConfigFileStemOf(projectId);
|
|
320
|
+
if (stem === projectId) return;
|
|
321
|
+
const rawStorageDir = path.join(PROJECTS_ROOT_DIR, projectId);
|
|
322
|
+
try {
|
|
323
|
+
await fsPromises.stat(rawStorageDir);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENAMETOOLONG') return;
|
|
326
|
+
throw error;
|
|
327
|
+
}
|
|
328
|
+
const boundedStorageDir = path.join(PROJECTS_ROOT_DIR, stem);
|
|
329
|
+
await mergeProjectContextFiles(rawStorageDir, boundedStorageDir);
|
|
330
|
+
await moveDirectoryContents(rawStorageDir, boundedStorageDir);
|
|
331
|
+
};
|
|
332
|
+
|
|
311
333
|
const migrateSettingsToDeterministicProjectIds = async (current) => {
|
|
312
334
|
const settings = current && typeof current === 'object' ? current : {};
|
|
313
335
|
const projects = sanitizeProjects(settings.projects) || [];
|
|
@@ -328,6 +350,7 @@ export const createSettingsRuntime = (deps) => {
|
|
|
328
350
|
await migrateProjectScopedStorage({ oldId: project.id, newId: nextId, projectPath: project.path });
|
|
329
351
|
await migrateProjectIconFiles({ oldId: project.id, newId: nextId });
|
|
330
352
|
}
|
|
353
|
+
await migrateRawIdStorageFolder(nextId);
|
|
331
354
|
nextProjects.push({ ...project, id: nextId });
|
|
332
355
|
}
|
|
333
356
|
|
|
@@ -468,19 +491,23 @@ export const createSettingsRuntime = (deps) => {
|
|
|
468
491
|
for (const [projectId, orphansForProject] of matches.entries()) {
|
|
469
492
|
const project = canonicalProjects.find((p) => p.id === projectId);
|
|
470
493
|
if (!project) continue;
|
|
471
|
-
const
|
|
494
|
+
const targetStem = projectConfigFileStemOf(project.id);
|
|
495
|
+
const targetPath = path.join(PROJECTS_ROOT_DIR, `${targetStem}.json`);
|
|
496
|
+
const targetStorageDir = path.join(PROJECTS_ROOT_DIR, targetStem);
|
|
472
497
|
|
|
473
498
|
for (const orphan of orphansForProject) {
|
|
474
499
|
const targetExisting = (await readJsonFile(targetPath)) || {};
|
|
500
|
+
// An orphan is named by the file found on disk, so its folder is the raw name.
|
|
501
|
+
const orphanStorageDir = path.join(PROJECTS_ROOT_DIR, orphan.orphanId);
|
|
475
502
|
const merged = mergeProjectConfigData({
|
|
476
503
|
oldConfig: orphan.content,
|
|
477
504
|
newConfig: targetExisting,
|
|
478
|
-
oldStorageDir:
|
|
479
|
-
newStorageDir:
|
|
505
|
+
oldStorageDir: orphanStorageDir,
|
|
506
|
+
newStorageDir: targetStorageDir,
|
|
480
507
|
projectPath: project.path,
|
|
481
508
|
});
|
|
482
509
|
await writeJsonFile(targetPath, merged);
|
|
483
|
-
await moveDirectoryContents(
|
|
510
|
+
await moveDirectoryContents(orphanStorageDir, targetStorageDir);
|
|
484
511
|
await fsPromises.rm(orphan.filePath, { force: true });
|
|
485
512
|
orphansConsumed.add(orphan.orphanId);
|
|
486
513
|
console.log(`[projects] Recovered orphan ${orphan.orphanId} -> ${project.id} (${project.path})`);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
import fsPromises from 'fs/promises';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import path from 'path';
|
|
6
|
-
import { createProjectIdFromPath } from '../projects/project-id.js';
|
|
6
|
+
import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
|
|
7
7
|
import { createSettingsRuntime } from './settings-runtime.js';
|
|
8
8
|
|
|
9
9
|
const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
|
|
@@ -39,6 +39,20 @@ const createRuntime = async ({ mergePersistedSettings = (_current, changes) => c
|
|
|
39
39
|
};
|
|
40
40
|
|
|
41
41
|
describe('settings runtime', () => {
|
|
42
|
+
it('round-trips both archived-only retention states through instance settings', async () => {
|
|
43
|
+
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
|
44
|
+
try {
|
|
45
|
+
for (const sessionRetentionOnlyArchived of [true, false]) {
|
|
46
|
+
const settings = { sessionRetentionOnlyArchived, sessionRetentionAction: 'delete', autoDeleteAfterDays: 30 };
|
|
47
|
+
await runtime.persistSettings(settings);
|
|
48
|
+
expect(await runtime.readSettingsFromDisk()).toEqual(settings);
|
|
49
|
+
expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(settings);
|
|
50
|
+
}
|
|
51
|
+
} finally {
|
|
52
|
+
await cleanup();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
42
56
|
it('uses AiWorker themes when a new install has no theme preferences', async () => {
|
|
43
57
|
const { runtime, cleanup } = await createRuntime();
|
|
44
58
|
try {
|
|
@@ -145,6 +159,52 @@ describe('settings runtime', () => {
|
|
|
145
159
|
}
|
|
146
160
|
});
|
|
147
161
|
|
|
162
|
+
it('migrates a legacy id into the bounded file of a project whose path is too long for a file name', async () => {
|
|
163
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
164
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
165
|
+
try {
|
|
166
|
+
const projectPath = path.join(tempRoot, 'a'.repeat(160));
|
|
167
|
+
const oldProjectId = 'legacy-project-id';
|
|
168
|
+
const newProjectId = createProjectIdFromPath(projectPath);
|
|
169
|
+
expect(newProjectId.length).toBeGreaterThan(200);
|
|
170
|
+
const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
|
|
171
|
+
const boundedPath = path.join(projectsRoot, `${projectConfigFileStemOf(newProjectId)}.json`);
|
|
172
|
+
expect(path.basename(boundedPath).startsWith('path_sha256_')).toBe(true);
|
|
173
|
+
|
|
174
|
+
await fsPromises.mkdir(projectPath, { recursive: true });
|
|
175
|
+
await fsPromises.mkdir(projectsRoot, { recursive: true });
|
|
176
|
+
await fsPromises.writeFile(
|
|
177
|
+
settingsFilePath,
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
projects: [{ id: oldProjectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }],
|
|
180
|
+
activeProjectId: oldProjectId,
|
|
181
|
+
}, null, 2),
|
|
182
|
+
'utf8',
|
|
183
|
+
);
|
|
184
|
+
await fsPromises.writeFile(
|
|
185
|
+
path.join(projectsRoot, `${oldProjectId}.json`),
|
|
186
|
+
JSON.stringify({ 'setup-worktree': ['bun install'] }, null, 2),
|
|
187
|
+
'utf8',
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const settings = await runtime.readSettingsFromDiskMigrated();
|
|
191
|
+
|
|
192
|
+
expect(settings.projects[0].id).toBe(newProjectId);
|
|
193
|
+
const migratedConfig = JSON.parse(await fsPromises.readFile(boundedPath, 'utf8'));
|
|
194
|
+
expect(migratedConfig['setup-worktree']).toEqual(['bun install']);
|
|
195
|
+
await expect(fsPromises.readFile(path.join(projectsRoot, `${oldProjectId}.json`), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
196
|
+
|
|
197
|
+
// A second pass sees the bounded file beside the settings; it is the
|
|
198
|
+
// project's own file, not a stray from the random-id era.
|
|
199
|
+
await runtime.readSettingsFromDiskMigrated();
|
|
200
|
+
expect(JSON.parse(await fsPromises.readFile(boundedPath, 'utf8'))['setup-worktree']).toEqual(['bun install']);
|
|
201
|
+
expect(warn.mock.calls.some((call) => String(call[0]).includes('orphan'))).toBe(false);
|
|
202
|
+
} finally {
|
|
203
|
+
warn.mockRestore();
|
|
204
|
+
await cleanup();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
148
208
|
it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
|
|
149
209
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
150
210
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
@@ -184,6 +244,102 @@ describe('settings runtime', () => {
|
|
|
184
244
|
}
|
|
185
245
|
});
|
|
186
246
|
|
|
247
|
+
it('moves project storage into the bounded folder when the canonical id is too long for a folder name', async () => {
|
|
248
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
249
|
+
try {
|
|
250
|
+
const projectPath = path.join(tempRoot, `${'segment-'.repeat(18)}`, 'demo-repo');
|
|
251
|
+
const oldProjectId = 'legacy-project-id';
|
|
252
|
+
const newProjectId = createProjectIdFromPath(projectPath);
|
|
253
|
+
const stem = projectConfigFileStemOf(newProjectId);
|
|
254
|
+
expect(newProjectId.length).toBeGreaterThan(240);
|
|
255
|
+
expect(stem).not.toBe(newProjectId);
|
|
256
|
+
const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
|
|
257
|
+
const oldStorageDir = path.join(projectsRoot, oldProjectId);
|
|
258
|
+
const newStorageDir = path.join(projectsRoot, stem);
|
|
259
|
+
|
|
260
|
+
await fsPromises.mkdir(projectPath, { recursive: true });
|
|
261
|
+
await fsPromises.mkdir(path.join(oldStorageDir, 'plans'), { recursive: true });
|
|
262
|
+
await fsPromises.writeFile(path.join(oldStorageDir, 'context.json'), JSON.stringify({ version: 2, notes: [], todos: [{ id: 't1', text: 'keep', completed: false, createdAt: 1 }], plans: [] }), 'utf8');
|
|
263
|
+
await fsPromises.writeFile(path.join(oldStorageDir, 'plans', 'inside.md'), '# Inside\n', 'utf8');
|
|
264
|
+
await fsPromises.writeFile(path.join(oldStorageDir, 'memory.json'), JSON.stringify({ version: 1, entries: [] }), 'utf8');
|
|
265
|
+
await fsPromises.writeFile(
|
|
266
|
+
settingsFilePath,
|
|
267
|
+
JSON.stringify({
|
|
268
|
+
projects: [{ id: oldProjectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }],
|
|
269
|
+
activeProjectId: oldProjectId,
|
|
270
|
+
}, null, 2),
|
|
271
|
+
'utf8',
|
|
272
|
+
);
|
|
273
|
+
await fsPromises.writeFile(
|
|
274
|
+
path.join(projectsRoot, `${oldProjectId}.json`),
|
|
275
|
+
JSON.stringify({ projectPlanFiles: [{ id: 'inside', path: path.join(oldStorageDir, 'plans', 'inside.md') }] }, null, 2),
|
|
276
|
+
'utf8',
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
const settings = await runtime.readSettingsFromDiskMigrated();
|
|
280
|
+
|
|
281
|
+
expect(settings.projects.map((project) => project.id)).toEqual([newProjectId]);
|
|
282
|
+
const migratedConfig = JSON.parse(await fsPromises.readFile(path.join(projectsRoot, `${stem}.json`), 'utf8'));
|
|
283
|
+
expect(migratedConfig.projectPlanFiles).toEqual([{ id: 'inside', path: path.join(newStorageDir, 'plans', 'inside.md') }]);
|
|
284
|
+
expect(JSON.parse(await fsPromises.readFile(path.join(newStorageDir, 'context.json'), 'utf8')).todos).toHaveLength(1);
|
|
285
|
+
await fsPromises.access(path.join(newStorageDir, 'plans', 'inside.md'));
|
|
286
|
+
await fsPromises.access(path.join(newStorageDir, 'memory.json'));
|
|
287
|
+
expect((await fsPromises.readdir(projectsRoot)).sort()).toEqual([stem, `${stem}.json`]);
|
|
288
|
+
} finally {
|
|
289
|
+
await cleanup();
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('moves a folder an older build created under a raw id that still fit a folder name into the bounded folder', async () => {
|
|
294
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
295
|
+
try {
|
|
296
|
+
// Long enough for the bounded name, short enough that an older build
|
|
297
|
+
// could still have created `<id>/` on this filesystem. The temp root
|
|
298
|
+
// differs per OS (a few characters on Linux runners, dozens on macOS),
|
|
299
|
+
// so pad to a fixed path length instead of a fixed segment.
|
|
300
|
+
const projectPathLength = 170;
|
|
301
|
+
const segment = 'x'.repeat(Math.max(1, projectPathLength - path.join(tempRoot, 'demo-repo').length - 1));
|
|
302
|
+
const projectPath = path.join(tempRoot, segment, 'demo-repo');
|
|
303
|
+
const projectId = createProjectIdFromPath(projectPath);
|
|
304
|
+
const stem = projectConfigFileStemOf(projectId);
|
|
305
|
+
expect(projectId.length).toBeGreaterThan(200);
|
|
306
|
+
expect(projectId.length).toBeLessThanOrEqual(255);
|
|
307
|
+
expect(stem).not.toBe(projectId);
|
|
308
|
+
const projectsRoot = path.join(path.dirname(settingsFilePath), 'projects');
|
|
309
|
+
const rawStorageDir = path.join(projectsRoot, projectId);
|
|
310
|
+
const boundedStorageDir = path.join(projectsRoot, stem);
|
|
311
|
+
|
|
312
|
+
await fsPromises.mkdir(projectPath, { recursive: true });
|
|
313
|
+
await fsPromises.mkdir(path.join(rawStorageDir, 'plans'), { recursive: true });
|
|
314
|
+
await fsPromises.writeFile(path.join(rawStorageDir, 'context.json'), JSON.stringify({ version: 2, notes: [{ id: 'n1', body: 'old note', createdAt: 1, updatedAt: 1 }], todos: [], plans: [] }), 'utf8');
|
|
315
|
+
await fsPromises.writeFile(path.join(rawStorageDir, 'plans', 'old.md'), '# Old\n', 'utf8');
|
|
316
|
+
await fsPromises.writeFile(path.join(rawStorageDir, 'memory.json'), JSON.stringify({ version: 1, entries: [{ id: 'm1', title: 'Old memory', body: 'x', type: 'fact', createdAt: 1, updatedAt: 1 }] }), 'utf8');
|
|
317
|
+
// The bounded folder already holds newer context: both sides survive.
|
|
318
|
+
await fsPromises.mkdir(boundedStorageDir, { recursive: true });
|
|
319
|
+
await fsPromises.writeFile(path.join(boundedStorageDir, 'context.json'), JSON.stringify({ version: 2, notes: [{ id: 'n2', body: 'new note', createdAt: 2, updatedAt: 2 }], todos: [], plans: [] }), 'utf8');
|
|
320
|
+
await fsPromises.writeFile(
|
|
321
|
+
settingsFilePath,
|
|
322
|
+
JSON.stringify({ projects: [{ id: projectId, path: projectPath, addedAt: 1, lastOpenedAt: 1 }], activeProjectId: projectId }, null, 2),
|
|
323
|
+
'utf8',
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const settings = await runtime.readSettingsFromDiskMigrated();
|
|
327
|
+
|
|
328
|
+
expect(settings.projects.map((project) => project.id)).toEqual([projectId]);
|
|
329
|
+
const context = JSON.parse(await fsPromises.readFile(path.join(boundedStorageDir, 'context.json'), 'utf8'));
|
|
330
|
+
expect(context.notes.map((note) => note.id).sort()).toEqual(['n1', 'n2']);
|
|
331
|
+
await fsPromises.access(path.join(boundedStorageDir, 'plans', 'old.md'));
|
|
332
|
+
expect(JSON.parse(await fsPromises.readFile(path.join(boundedStorageDir, 'memory.json'), 'utf8')).entries).toHaveLength(1);
|
|
333
|
+
await expect(fsPromises.access(rawStorageDir)).rejects.toMatchObject({ code: 'ENOENT' });
|
|
334
|
+
|
|
335
|
+
// A second startup finds nothing left to move.
|
|
336
|
+
await runtime.readSettingsFromDiskMigrated();
|
|
337
|
+
expect(JSON.parse(await fsPromises.readFile(path.join(boundedStorageDir, 'context.json'), 'utf8')).notes).toHaveLength(2);
|
|
338
|
+
} finally {
|
|
339
|
+
await cleanup();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
187
343
|
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
|
|
188
344
|
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
189
345
|
try {
|
|
@@ -12,8 +12,18 @@ The managed Chats root (`~/.config/openchamber/chats`) is also one context owner
|
|
|
12
12
|
| `<projectsDir>/<projectId>.json` | `packages/web/server/lib/projects` (`project-setup.js` for the client-owned keys behind `/api/projects/:projectId/config`; `project-config.js` for `version` / `scheduledTasks`), one write lock for both | worktree setup, draft starters, project actions, scheduled tasks |
|
|
13
13
|
| `<projectsDir>/<projectId>/context.json` | **this module, exclusively** | notes, todos, plan manifest |
|
|
14
14
|
| `<projectsDir>/<projectId>/plans/*.md` | **this module, exclusively** | plan bodies |
|
|
15
|
+
| `<projectsDir>/<projectId>/memory.json` | `packages/web/server/lib/agent-memory` | what the agent chose to remember about the project |
|
|
15
16
|
| `<repo>/<plansDir>/*.md` | this module (read, edit, delete, move) when the team config names a `plansDir`; the folder is the team's, any tool may write there | shared plan bodies |
|
|
16
17
|
|
|
18
|
+
`<projectId>` in these paths is the bounded stem `projectConfigFileStemOf`
|
|
19
|
+
(`packages/web/server/lib/projects/project-id.js`) gives the id: the id itself
|
|
20
|
+
up to 200 characters, `path_sha256_<digest>` beyond that, so a deeply nested
|
|
21
|
+
checkout gets a folder the filesystem accepts. The folder, the config file,
|
|
22
|
+
and the memory file all share that one stem; nothing here composes a path from
|
|
23
|
+
the raw id. The legacy-migration read below looks at the bounded config file
|
|
24
|
+
for the same reason: a read of `<raw id>.json` would fail with ENAMETOOLONG
|
|
25
|
+
and turn an empty project into an error.
|
|
26
|
+
|
|
17
27
|
The split is the point. Both files were previously one, written by the client
|
|
18
28
|
with a whole-file read-modify-write. Adding a server writer to that file would
|
|
19
29
|
have made unrelated features (project actions, draft starters) clobber notes
|
|
@@ -147,9 +157,9 @@ and I/O failures are `500`.
|
|
|
147
157
|
## Legacy migration
|
|
148
158
|
|
|
149
159
|
`projectNotes`, `projectTodos`, and `projectPlanFiles` originally lived in
|
|
150
|
-
`<projectId>.json
|
|
151
|
-
are moved out and deleted from the
|
|
152
|
-
preserved untouched.
|
|
160
|
+
`<projectId>.json` (the bounded name, see Ownership). On the first read with
|
|
161
|
+
no `context.json`, those three keys are moved out and deleted from the
|
|
162
|
+
client-owned file; every other key is preserved untouched.
|
|
153
163
|
|
|
154
164
|
Plan links carried absolute paths. Migration converts each to a base name. A
|
|
155
165
|
file already in the plans directory is used in place; one referenced from
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* directory never invalidates a reference.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { projectConfigFileStemOf } from '../projects/project-id.js';
|
|
16
|
+
|
|
15
17
|
const PROJECT_CONTEXT_VERSION = 2;
|
|
16
18
|
const PROJECT_NOTE_BODY_MAX_LENGTH = 3000;
|
|
17
19
|
const PROJECT_NOTE_MAX_ITEMS = 200;
|
|
@@ -221,10 +223,14 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
221
223
|
return value;
|
|
222
224
|
};
|
|
223
225
|
|
|
224
|
-
|
|
226
|
+
// The folder and the client-owned config file share one bounded stem
|
|
227
|
+
// (`projectConfigFileStemOf`): an id too long for a file name would
|
|
228
|
+
// otherwise fail every read with ENAMETOOLONG instead of reading as empty.
|
|
229
|
+
const storageStemFor = (projectId) => projectConfigFileStemOf(sanitizeProjectId(projectId));
|
|
230
|
+
const storageDirFor = (projectId) => path.join(projectsDirPath, storageStemFor(projectId));
|
|
225
231
|
const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json');
|
|
226
232
|
const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans');
|
|
227
|
-
const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${
|
|
233
|
+
const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${storageStemFor(projectId)}.json`);
|
|
228
234
|
|
|
229
235
|
const readJson = async (filePath) => {
|
|
230
236
|
let raw;
|