@aiworker/web 1.22.2 → 1.23.1
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/bin/cli.js +7 -0
- package/dist/assets/{CodeMirrorCommentWidgets-CSICpWQD.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
- package/dist/assets/DiffView-DC9RX3MF.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-C22HdWj4.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
- package/dist/assets/FilesView-BFZkuu4O.js +13 -0
- package/dist/assets/{GitView-VQQkYuUw.js → GitView-B_x52MHY.js} +4 -4
- package/dist/assets/{JsonTreeView-6lrhwD3q.js → JsonTreeView-cmAzUTiw.js} +1 -1
- package/dist/assets/{LinearIssuesView-Ba6Q9569.js → LinearIssuesView-Dehh5afm.js} +1 -1
- package/dist/assets/MarkdownImageGallery-DgNOqqhE.js +1 -0
- package/dist/assets/MarkdownRendererImpl-DusmMZ_p.js +12 -0
- package/dist/assets/OnboardingScreen-B8fm1H4m.js +1 -0
- package/dist/assets/{PatchDiff-BzcZPlOq.js → PatchDiff-BVr7gJU0.js} +1 -1
- package/dist/assets/PierreDiffViewer-DzLt-nel.js +161 -0
- package/dist/assets/PlanView-BmQUYT_z.js +5 -0
- package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
- package/dist/assets/{SettingsWindow-DDQYaKWQ.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
- package/dist/assets/SymbolsNerdFontMono-Regular-aK5vsLov.woff2 +0 -0
- package/dist/assets/{ToolOutputDialog-iYF8Y2bz.js → ToolOutputDialog-cTl_eLIH.js} +5 -5
- package/dist/assets/{ToolPartDiffPreview-CdjbOVMM.js → ToolPartDiffPreview-C_3iYNJQ.js} +1 -1
- package/dist/assets/{VirtualizedFileDiff-fFFm2QgH.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
- package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-CzgLum_K.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
- package/dist/assets/contextPanelEmbeddedChat-BdA6bdky.js +1 -0
- package/dist/assets/de-DJsQr-p7.js +10 -0
- package/dist/assets/es-BuYMlA9g.js +16 -0
- package/dist/assets/fr-ePDay33C.js +14 -0
- package/dist/assets/ghostty-vt-DdA0Zryv.wasm +0 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-Bl3LSbjn.js → index-n6O3hklM.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-Dx-dB_8l.js → isDiffPlainText-GMdPz-71.js} +1 -1
- package/dist/assets/{isFilePlainText-CWFPwmnU.js → isFilePlainText-BVuH1zH9.js} +1 -1
- package/dist/assets/ja-BzR9xOY_.js +16 -0
- package/dist/assets/ko-BEjHAm14.js +16 -0
- package/dist/assets/main-BydQ1tr_.js +930 -0
- package/dist/assets/{main-DnBAoktx.js → main-D3ZDQglc.js} +2 -2
- package/dist/assets/markdownCore-Cb2MdZnV.js +5 -0
- package/dist/assets/{miniChat-BKyvLseN.js → miniChat-d01k801U.js} +2 -2
- package/dist/assets/{mobile-DKjeFgMN.js → mobile-D7VV-Ufz.js} +2 -2
- package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
- package/dist/assets/pl-rQmpxNIF.js +16 -0
- package/dist/assets/pt-BR-CorbsSW-.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-BGG9UIlh.js +2 -0
- package/dist/assets/renderMobileApp-BRP3J1iv.js +25 -0
- package/dist/assets/runtimeEndpointReset-BhT_-c52.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-TEQCmzW8.js → shikiHighlight-BzXUjlby.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-CoAMXPtI.js +7 -0
- package/dist/assets/uk-YlAX0rRU.js +16 -0
- package/dist/assets/{useAppFontEffects-D7sNpuL2.js → useAppFontEffects-Cwyhjfl5.js} +141 -140
- package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
- package/dist/assets/useInlineCommentController-DDkpBEU8.js +2 -0
- package/dist/assets/useRootScrollLock-CwjoWuC4.js +1 -0
- package/dist/assets/{vendor-base-ui-BeMD8X_w.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Dp3KMRQG.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/vendor-zod-B6551dwG.js +39 -0
- package/dist/assets/{web-components-_mNEMjFY.js → web-components-LV3vjTxy.js} +1 -1
- package/dist/assets/zh-CN-DDYg0Jl7.js +16 -0
- package/dist/assets/zh-TW-CQKaCayr.js +16 -0
- package/dist/index.html +5 -43
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +2 -3
- package/server/index.d.ts +15 -0
- package/server/index.js +48 -22
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/data-dir-migration.js +40 -0
- package/server/lib/data-dir-migration.test.js +59 -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 +72 -12
- package/server/lib/fs/DOCUMENTATION.md +3 -0
- package/server/lib/fs/routes.js +31 -0
- package/server/lib/fs/routes.test.js +110 -1
- package/server/lib/git/DOCUMENTATION.md +39 -6
- package/server/lib/git/routes.js +45 -12
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +343 -202
- package/server/lib/git/service.test.js +496 -25
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/inherited-env.js +16 -9
- package/server/lib/inherited-env.test.js +8 -9
- package/server/lib/message-queue/DOCUMENTATION.md +11 -1
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/network-defaults.js +23 -0
- package/server/lib/network-defaults.test.js +52 -0
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +35 -5
- package/server/lib/opencode/bootstrap-runtime.js +2 -0
- 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 +17 -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/openchamber-routes.js +64 -4
- package/server/lib/opencode/openchamber-routes.test.js +128 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/routes.js +5 -3
- package/server/lib/opencode/settings-files.js +214 -0
- package/server/lib/opencode/settings-helpers.js +88 -41
- package/server/lib/opencode/settings-helpers.test.js +176 -1
- package/server/lib/opencode/settings-registry.json +750 -0
- package/server/lib/opencode/settings-runtime.js +175 -27
- package/server/lib/opencode/settings-runtime.test.js +321 -7
- package/server/lib/project-context/DOCUMENTATION.md +38 -4
- package/server/lib/project-context/routes.http.test.js +18 -0
- package/server/lib/project-context/routes.js +15 -0
- package/server/lib/project-context/runtime.js +256 -25
- package/server/lib/project-context/runtime.test.js +160 -3
- package/server/lib/projects/DOCUMENTATION.md +77 -0
- package/server/lib/projects/project-config.js +179 -17
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +43 -0
- package/server/lib/projects/project-setup.js +409 -0
- package/server/lib/projects/project-setup.test.js +557 -0
- package/server/lib/projects/routes.js +59 -0
- package/server/lib/quota/DOCUMENTATION.md +31 -1
- package/server/lib/quota/providers/cline-pass.js +125 -0
- package/server/lib/quota/providers/cline-pass.test.js +103 -0
- package/server/lib/quota/providers/index.js +7 -0
- package/server/lib/quota/providers/neuralwatt.js +3 -6
- package/server/lib/quota/providers/neuralwatt.test.js +7 -7
- package/server/lib/quota/providers/ollama-cloud.js +31 -0
- package/server/lib/quota/providers/ollama-cloud.test.js +33 -1
- package/server/lib/quota/providers/opencode-go.js +1 -1
- package/server/lib/quota/providers/opencode-go.test.js +26 -1
- package/server/lib/quota/providers/openrouter.js +119 -24
- package/server/lib/quota/providers/openrouter.test.js +330 -0
- package/server/lib/relay/DOCUMENTATION.md +75 -0
- package/server/lib/relay/cross-compat.test.js +26 -0
- package/server/lib/relay/downstream-scheduler.js +154 -0
- package/server/lib/relay/downstream-scheduler.test.js +162 -0
- package/server/lib/relay/e2ee.js +10 -8
- package/server/lib/relay/flow-control.test.js +177 -0
- package/server/lib/relay/host-client.js +44 -8
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/relay/tunnel-codec.js +9 -0
- package/server/lib/relay/tunnel-host.js +66 -17
- 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 +117 -267
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/session-goal/runtime.js +4 -9
- package/server/lib/small-model/DOCUMENTATION.md +3 -2
- package/server/lib/small-model/call.js +9 -4
- package/server/lib/small-model/call.test.js +29 -0
- package/server/lib/small-model/index.js +5 -10
- package/server/lib/small-model/runtime-providers.js +15 -7
- package/server/lib/small-model/runtime-providers.test.js +7 -1
- package/server/lib/terminal/DOCUMENTATION.md +4 -4
- package/server/lib/terminal/runtime.js +11 -6
- package/server/lib/terminal/runtime.test.js +20 -13
- package/server/lib/walkthrough/DOCUMENTATION.md +49 -20
- package/server/lib/walkthrough/model-settings.js +8 -12
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/prompt.js +3 -1
- 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 +36 -4
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-hDBKesA_.js +0 -4
- package/dist/assets/FilesView-CdWzeOp9.js +0 -17
- package/dist/assets/MarkdownImageGallery-Czv_mhvS.js +0 -1
- package/dist/assets/MarkdownRendererImpl-BrMcswiy.js +0 -12
- package/dist/assets/OnboardingScreen-ChxQoAkx.js +0 -1
- package/dist/assets/PierreDiffViewer-BchBtZlp.js +0 -157
- package/dist/assets/PlanView-DyDt_nZ_.js +0 -5
- package/dist/assets/SettingsView-BKf5xBsP.js +0 -28
- package/dist/assets/WalkthroughView-DOeCsMju.js +0 -4
- package/dist/assets/contextPanelEmbeddedChat-C0ZLAepL.js +0 -1
- package/dist/assets/de-B5JaA3SA.js +0 -10
- package/dist/assets/es-EPIH88ii.js +0 -16
- package/dist/assets/fr-CAMfxUdZ.js +0 -14
- package/dist/assets/index-DF5kMP-i.js +0 -39
- package/dist/assets/index-Dl3xbAB_.css +0 -1
- package/dist/assets/ja-CifOE1VN.js +0 -16
- package/dist/assets/ko-BBDyAyYQ.js +0 -16
- package/dist/assets/main-MhSaOZoB.js +0 -930
- package/dist/assets/markdownCore-BrBwOOMn.js +0 -3
- package/dist/assets/patchFileDiff-o8ms1me8.js +0 -10
- package/dist/assets/pl-DgEHYfFM.js +0 -16
- package/dist/assets/pt-BR-DuWHojUw.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-CDgBwwCS.js +0 -2
- package/dist/assets/renderMobileApp-DJIKIK1r.js +0 -25
- package/dist/assets/runtimeEndpointReset-CYJ9O_xX.js +0 -17
- package/dist/assets/scheduledTasksApi-D8yamh1b.js +0 -1
- package/dist/assets/tr-D5m2yIWL.js +0 -7
- package/dist/assets/uk-D_q60oog.js +0 -16
- package/dist/assets/useInlineCommentController-Brz_cLcQ.js +0 -2
- package/dist/assets/useRootScrollLock-lXEnWlaU.js +0 -1
- package/dist/assets/vendor-ghostty-web-CrxinGGH.js +0 -13
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/vendor-zod-CK21ihK-.js +0 -39
- package/dist/assets/zh-CN-D8QvsVeg.js +0 -16
- package/dist/assets/zh-TW-B9t7rQQO.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
|
@@ -1,12 +1,12 @@
|
|
|
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
|
-
const createRuntime = async () => {
|
|
9
|
+
const createRuntime = async ({ mergePersistedSettings = (_current, changes) => changes } = {}) => {
|
|
10
10
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
11
11
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
12
12
|
const runtime = createSettingsRuntime({
|
|
@@ -16,7 +16,7 @@ const createRuntime = async () => {
|
|
|
16
16
|
SETTINGS_FILE_PATH: settingsFilePath,
|
|
17
17
|
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
|
|
18
18
|
sanitizeSettingsUpdate: (settings) => settings,
|
|
19
|
-
mergePersistedSettings
|
|
19
|
+
mergePersistedSettings,
|
|
20
20
|
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
|
|
21
21
|
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
|
|
22
22
|
formatSettingsResponse: (settings) => settings,
|
|
@@ -39,6 +39,20 @@ const createRuntime = async () => {
|
|
|
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 {
|
|
@@ -68,8 +82,8 @@ describe('settings runtime', () => {
|
|
|
68
82
|
}
|
|
69
83
|
});
|
|
70
84
|
|
|
71
|
-
it('round-trips shared sidebar preferences through
|
|
72
|
-
const { runtime, settingsFilePath, cleanup } = await createRuntime();
|
|
85
|
+
it('round-trips shared sidebar preferences through preferences.json', async () => {
|
|
86
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
73
87
|
const preferences = {
|
|
74
88
|
sidebarProjectDisplayMode: 'single',
|
|
75
89
|
sidebarSessionGroupingMode: 'flat',
|
|
@@ -80,7 +94,10 @@ describe('settings runtime', () => {
|
|
|
80
94
|
await runtime.persistSettings(preferences);
|
|
81
95
|
|
|
82
96
|
await expect(runtime.readSettingsFromDisk()).resolves.toEqual(preferences);
|
|
83
|
-
|
|
97
|
+
// Profile keys live in preferences.json; settings.json keeps a legacy copy for older builds.
|
|
98
|
+
expect(JSON.parse(await fsPromises.readFile(settingsFilePath, 'utf8'))).toEqual(preferences);
|
|
99
|
+
const stored = JSON.parse(await fsPromises.readFile(path.join(tempRoot, 'preferences.json'), 'utf8'));
|
|
100
|
+
expect(Object.fromEntries(Object.entries(stored.fields).map(([key, entry]) => [key, entry.value]))).toEqual(preferences);
|
|
84
101
|
} finally {
|
|
85
102
|
await cleanup();
|
|
86
103
|
}
|
|
@@ -142,6 +159,52 @@ describe('settings runtime', () => {
|
|
|
142
159
|
}
|
|
143
160
|
});
|
|
144
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
|
+
|
|
145
208
|
it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
|
|
146
209
|
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
|
|
147
210
|
const settingsFilePath = path.join(tempRoot, 'settings.json');
|
|
@@ -181,6 +244,102 @@ describe('settings runtime', () => {
|
|
|
181
244
|
}
|
|
182
245
|
});
|
|
183
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
|
+
|
|
184
343
|
it('cleans up orphaned settings.json.tmp files during startup migration', async () => {
|
|
185
344
|
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
186
345
|
try {
|
|
@@ -248,3 +407,158 @@ describe('settings runtime', () => {
|
|
|
248
407
|
}
|
|
249
408
|
});
|
|
250
409
|
});
|
|
410
|
+
|
|
411
|
+
describe('settings runtime: preferences.json split', () => {
|
|
412
|
+
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
|
|
413
|
+
|
|
414
|
+
it('seeds preferences.json from the profile keys of an existing settings.json and leaves that file intact', async () => {
|
|
415
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
416
|
+
try {
|
|
417
|
+
const legacy = { projects: [], fontSize: 110, themeId: 'openchamber-dark', desktopLanAccessEnabled: true };
|
|
418
|
+
await fsPromises.writeFile(settingsFilePath, JSON.stringify(legacy));
|
|
419
|
+
|
|
420
|
+
const merged = await runtime.readSettingsFromDisk();
|
|
421
|
+
expect(merged).toMatchObject(legacy);
|
|
422
|
+
|
|
423
|
+
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
|
|
424
|
+
expect(preferences.version).toBe(1);
|
|
425
|
+
expect(Object.keys(preferences.fields).sort()).toEqual(['fontSize', 'themeId']);
|
|
426
|
+
expect(preferences.fields.fontSize.value).toBe(110);
|
|
427
|
+
expect(typeof preferences.fields.fontSize.updatedAt).toBe('number');
|
|
428
|
+
expect(await readJson(settingsFilePath)).toEqual(legacy);
|
|
429
|
+
} finally {
|
|
430
|
+
await cleanup();
|
|
431
|
+
}
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it('routes profile keys to preferences.json, keeps a legacy copy of them in settings.json, and drops device keys', async () => {
|
|
435
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
436
|
+
try {
|
|
437
|
+
await runtime.persistSettings({ fontSize: 120, desktopLanAccessEnabled: true, mobileKeyboardMode: 'native' });
|
|
438
|
+
|
|
439
|
+
const settings = await readJson(settingsFilePath);
|
|
440
|
+
expect(settings.desktopLanAccessEnabled).toBe(true);
|
|
441
|
+
// Older builds read only settings.json: the profile's base values stay there as a copy.
|
|
442
|
+
expect(settings.fontSize).toBe(120);
|
|
443
|
+
expect(settings).not.toHaveProperty('mobileKeyboardMode');
|
|
444
|
+
|
|
445
|
+
const preferences = await readJson(path.join(tempRoot, 'preferences.json'));
|
|
446
|
+
expect(preferences.fields.fontSize.value).toBe(120);
|
|
447
|
+
expect(preferences.fields).not.toHaveProperty('mobileKeyboardMode');
|
|
448
|
+
expect(preferences.fields).not.toHaveProperty('desktopLanAccessEnabled');
|
|
449
|
+
|
|
450
|
+
expect(await runtime.readSettingsFromDisk()).toMatchObject({ fontSize: 120, desktopLanAccessEnabled: true });
|
|
451
|
+
} finally {
|
|
452
|
+
await cleanup();
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('keeps the timestamp of an unchanged profile key and restamps a changed one', async () => {
|
|
457
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
458
|
+
try {
|
|
459
|
+
const preferencesPath = path.join(tempRoot, 'preferences.json');
|
|
460
|
+
await runtime.persistSettings({ fontSize: 100, padding: 100 });
|
|
461
|
+
const first = await readJson(preferencesPath);
|
|
462
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
463
|
+
await runtime.persistSettings({ fontSize: 100, padding: 120 });
|
|
464
|
+
const second = await readJson(preferencesPath);
|
|
465
|
+
|
|
466
|
+
expect(second.fields.fontSize.updatedAt).toBe(first.fields.fontSize.updatedAt);
|
|
467
|
+
expect(second.fields.padding.updatedAt).toBeGreaterThan(first.fields.padding.updatedAt);
|
|
468
|
+
expect(second.fields.padding.value).toBe(120);
|
|
469
|
+
} finally {
|
|
470
|
+
await cleanup();
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('treats an unreadable preferences.json as failure: no seed, no overwrite, profile writes refused, instance still served', async () => {
|
|
475
|
+
const { runtime, settingsFilePath, tempRoot, cleanup } = await createRuntime();
|
|
476
|
+
try {
|
|
477
|
+
const preferencesPath = path.join(tempRoot, 'preferences.json');
|
|
478
|
+
await fsPromises.writeFile(settingsFilePath, JSON.stringify({ desktopLanAccessEnabled: true }));
|
|
479
|
+
await fsPromises.writeFile(preferencesPath, '{ not json');
|
|
480
|
+
|
|
481
|
+
expect(await runtime.readSettingsFromDisk()).toEqual({ desktopLanAccessEnabled: true });
|
|
482
|
+
|
|
483
|
+
await runtime.persistSettings({ fontSize: 130, desktopKeepAwakeEnabled: true });
|
|
484
|
+
|
|
485
|
+
expect(await fsPromises.readFile(preferencesPath, 'utf8')).toBe('{ not json');
|
|
486
|
+
const settings = await readJson(settingsFilePath);
|
|
487
|
+
expect(settings.desktopKeepAwakeEnabled).toBe(true);
|
|
488
|
+
// The refused profile write must not land in the legacy copy either.
|
|
489
|
+
expect(settings).not.toHaveProperty('fontSize');
|
|
490
|
+
} finally {
|
|
491
|
+
await cleanup();
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
describe('settings runtime: per-surface profile keys', () => {
|
|
497
|
+
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
|
|
498
|
+
// These sequences persist several times; the default stub replaces the
|
|
499
|
+
// document with the changes, the real merge keeps the current document.
|
|
500
|
+
const createMergingRuntime = () => createRuntime({ mergePersistedSettings: (current, changes) => ({ ...current, ...changes }) });
|
|
501
|
+
|
|
502
|
+
it('stores a per-surface key under the writing surface and leaves the base alone', async () => {
|
|
503
|
+
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
|
504
|
+
try {
|
|
505
|
+
await runtime.persistSettings({ fontSize: 100 }); // base (no surface): migrations and legacy callers
|
|
506
|
+
await runtime.persistSettings({ fontSize: 130, showReasoningTraces: false }, { surface: 'mobile' });
|
|
507
|
+
|
|
508
|
+
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
|
509
|
+
expect(stored.fields.fontSize.value).toBe(100);
|
|
510
|
+
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
|
|
511
|
+
// Not per-surface: written to the base regardless of the surface.
|
|
512
|
+
expect(stored.fields.showReasoningTraces.value).toBe(false);
|
|
513
|
+
expect(stored.fields.showReasoningTraces.surfaces).toBeUndefined();
|
|
514
|
+
|
|
515
|
+
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).fontSize).toBe(130);
|
|
516
|
+
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).fontSize).toBe(100);
|
|
517
|
+
expect((await runtime.readSettingsFromDisk()).fontSize).toBe(100);
|
|
518
|
+
expect((await runtime.readSettingsFromDiskMigrated({ surface: 'mobile' })).fontSize).toBe(130);
|
|
519
|
+
} finally {
|
|
520
|
+
await cleanup();
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
it('a per-surface key set only from one surface has no base and stays absent elsewhere', async () => {
|
|
525
|
+
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
|
526
|
+
try {
|
|
527
|
+
await runtime.persistSettings({ stickyUserHeader: false }, { surface: 'mobile' });
|
|
528
|
+
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
|
529
|
+
expect(stored.fields.stickyUserHeader).not.toHaveProperty('value');
|
|
530
|
+
expect(stored.fields.stickyUserHeader.surfaces.mobile.value).toBe(false);
|
|
531
|
+
expect((await runtime.readSettingsFromDisk({ surface: 'desktop' })).stickyUserHeader).toBeUndefined();
|
|
532
|
+
expect((await runtime.readSettingsFromDisk({ surface: 'mobile' })).stickyUserHeader).toBe(false);
|
|
533
|
+
} finally {
|
|
534
|
+
await cleanup();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('a surface write of an unrelated key does not copy the resolved per-surface view into the file', async () => {
|
|
539
|
+
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
|
540
|
+
try {
|
|
541
|
+
await runtime.persistSettings({ fontSize: 100 });
|
|
542
|
+
await runtime.persistSettings({ fontSize: 130 }, { surface: 'mobile' });
|
|
543
|
+
await runtime.persistSettings({ showReasoningTraces: true }, { surface: 'mobile' });
|
|
544
|
+
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
|
545
|
+
expect(stored.fields.fontSize.value).toBe(100);
|
|
546
|
+
expect(stored.fields.fontSize.surfaces.mobile.value).toBe(130);
|
|
547
|
+
expect(stored.fields.fontSize.surfaces.desktop).toBeUndefined();
|
|
548
|
+
} finally {
|
|
549
|
+
await cleanup();
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
it('ignores an unknown surface header value and writes the base', async () => {
|
|
554
|
+
const { runtime, tempRoot, cleanup } = await createMergingRuntime();
|
|
555
|
+
try {
|
|
556
|
+
await runtime.persistSettings({ fontSize: 90 }, { surface: 'toaster' });
|
|
557
|
+
const stored = await readJson(path.join(tempRoot, 'preferences.json'));
|
|
558
|
+
expect(stored.fields.fontSize.value).toBe(90);
|
|
559
|
+
expect(stored.fields.fontSize.surfaces).toBeUndefined();
|
|
560
|
+
} finally {
|
|
561
|
+
await cleanup();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
@@ -9,9 +9,20 @@ The managed Chats root (`~/.config/openchamber/chats`) is also one context owner
|
|
|
9
9
|
|
|
10
10
|
| Path | Owner | Contents |
|
|
11
11
|
|---|---|---|
|
|
12
|
-
| `<projectsDir>/<projectId>.json` |
|
|
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 |
|
|
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 |
|
|
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.
|
|
15
26
|
|
|
16
27
|
The split is the point. Both files were previously one, written by the client
|
|
17
28
|
with a whole-file read-modify-write. Adding a server writer to that file would
|
|
@@ -58,6 +69,27 @@ denormalized into the manifest so listing plans costs one read rather than one
|
|
|
58
69
|
read per plan; `readPlan` returns the title parsed from the file, which wins if
|
|
59
70
|
the two ever disagree.
|
|
60
71
|
|
|
72
|
+
## Shared plans
|
|
73
|
+
|
|
74
|
+
Every `.md` file in the repository plans folder is a plan too: `.aiworker/plans`
|
|
75
|
+
by default, or the `plansDir` the team config (`<repo>/.aiworker/project.json`,
|
|
76
|
+
see `packages/web/server/lib/projects`) names instead of it (the custom folder
|
|
77
|
+
replaces the default outright; moving files between the two is the user's job). `readContext` appends them after the personal ones,
|
|
78
|
+
each marked `source: "shared"` (personal ones get `source: "personal"`), and
|
|
79
|
+
reports the folder as `sharedPlansDir`. A shared plan is addressed as
|
|
80
|
+
`shared:<file>` when no manifest entry claims it; its title is parsed from the
|
|
81
|
+
file on every list, and `readPlan` / `updatePlan` / `deletePlan` work on the
|
|
82
|
+
file directly (an update writes the raw document verbatim, so a plan another
|
|
83
|
+
tool wrote keeps its shape). `setPlanPinned` is `404` for such a plan.
|
|
84
|
+
|
|
85
|
+
A plan the user moves there keeps its id: `sharePlan` moves the markdown into
|
|
86
|
+
the folder and keeps the manifest entry with `shared: true` (the flag says
|
|
87
|
+
which folder holds the file), so a session that attached the plan still finds
|
|
88
|
+
it, and the file is listed under that id instead of `shared:<file>`.
|
|
89
|
+
`unsharePlan` moves it back and clears the flag; a plan that only ever lived
|
|
90
|
+
in the team's folder gets a manifest entry (and an id) on the way in. A name
|
|
91
|
+
collision gets a numeric suffix. Sharing is refused only when the checkout cannot be located.
|
|
92
|
+
|
|
61
93
|
## Routes
|
|
62
94
|
|
|
63
95
|
| Method | Route | Notes |
|
|
@@ -72,6 +104,8 @@ the two ever disagree.
|
|
|
72
104
|
| POST | `/api/project-context/:projectId/plans` | `201`; takes `{title, body}`, never a path |
|
|
73
105
|
| PUT | `/api/project-context/:projectId/plans/:planId` | takes the whole `{raw}` document; `404` when the link or its markdown is gone |
|
|
74
106
|
| DELETE | `/api/project-context/:projectId/plans/:planId` | `404` when unknown |
|
|
107
|
+
| POST | `/api/project-context/:projectId/plans/:planId/share` | moves the plan into the shared folder; `400` without one, `404` when unknown |
|
|
108
|
+
| POST | `/api/project-context/:projectId/plans/:planId/unshare` | moves a `shared:` plan back; `404` when unknown |
|
|
75
109
|
|
|
76
110
|
**Body parsing is attached per route.** This server has no global JSON parser:
|
|
77
111
|
`core-routes` parses only an allowlist of `/api` path prefixes so the generic
|
|
@@ -123,9 +157,9 @@ and I/O failures are `500`.
|
|
|
123
157
|
## Legacy migration
|
|
124
158
|
|
|
125
159
|
`projectNotes`, `projectTodos`, and `projectPlanFiles` originally lived in
|
|
126
|
-
`<projectId>.json
|
|
127
|
-
are moved out and deleted from the
|
|
128
|
-
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.
|
|
129
163
|
|
|
130
164
|
Plan links carried absolute paths. Migration converts each to a base name. A
|
|
131
165
|
file already in the plans directory is used in place; one referenced from
|
|
@@ -57,6 +57,8 @@ const createApp = (overrides = {}) => {
|
|
|
57
57
|
context: emptyContext,
|
|
58
58
|
}),
|
|
59
59
|
deletePlan: async () => ({ deleted: true, context: emptyContext }),
|
|
60
|
+
sharePlan: async (_projectId, planId) => (planId === 'p1' ? { plan: { id: 'shared:a.md', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'shared' }, context: emptyContext } : null),
|
|
61
|
+
unsharePlan: async (_projectId, planId) => (planId === 'shared:a.md' ? { plan: { id: 'p2', file: 'a.md', title: 'A', createdAt: 1, pinned: false, source: 'personal' }, context: emptyContext } : null),
|
|
60
62
|
...overrides,
|
|
61
63
|
};
|
|
62
64
|
|
|
@@ -212,6 +214,22 @@ describe('project context routes over HTTP', () => {
|
|
|
212
214
|
.expect(400);
|
|
213
215
|
});
|
|
214
216
|
|
|
217
|
+
it('shares and unshares a plan, and answers 404 for an unknown one', async () => {
|
|
218
|
+
const { app } = createApp();
|
|
219
|
+
const shared = await request(app).post('/api/project-context/proj/plans/p1/share');
|
|
220
|
+
expect(shared.status).toBe(200);
|
|
221
|
+
expect(shared.body.plan.id).toBe('shared:a.md');
|
|
222
|
+
const back = await request(app).post('/api/project-context/proj/plans/shared%3Aa.md/unshare');
|
|
223
|
+
expect(back.status).toBe(200);
|
|
224
|
+
expect(back.body.plan.source).toBe('personal');
|
|
225
|
+
expect((await request(app).post('/api/project-context/proj/plans/nope/share')).status).toBe(404);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('answers 400 when sharing without a shared plans folder', async () => {
|
|
229
|
+
const { app } = createApp({ sharePlan: async () => { throw new Error('shared plans folder is required'); } });
|
|
230
|
+
expect((await request(app).post('/api/project-context/proj/plans/p1/share')).status).toBe(400);
|
|
231
|
+
});
|
|
232
|
+
|
|
215
233
|
it('returns 404 for an unknown plan', async () => {
|
|
216
234
|
const { app } = createApp();
|
|
217
235
|
await request(app).get(`${BASE}/plans/nope`).expect(404);
|
|
@@ -220,6 +220,21 @@ export const registerProjectContextRoutes = (app, dependencies) => {
|
|
|
220
220
|
}
|
|
221
221
|
});
|
|
222
222
|
|
|
223
|
+
// Moving a plan between the user's folder and the team's shared folder.
|
|
224
|
+
for (const [suffix, method] of [['share', 'sharePlan'], ['unshare', 'unsharePlan']]) {
|
|
225
|
+
app.post(`/api/project-context/:projectId/plans/:planId/${suffix}`, async (req, res) => {
|
|
226
|
+
try {
|
|
227
|
+
const result = await projectContextRuntime[method](req.params.projectId, req.params.planId);
|
|
228
|
+
if (!result) {
|
|
229
|
+
return res.status(404).json({ error: 'Plan not found' });
|
|
230
|
+
}
|
|
231
|
+
return res.json(result);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
return respondWithError(res, error, `Failed to ${suffix} plan`);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
223
238
|
app.delete('/api/project-context/:projectId/plans/:planId', async (req, res) => {
|
|
224
239
|
try {
|
|
225
240
|
const { deleted, context } = await projectContextRuntime.deletePlan(
|