@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
|
@@ -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;
|
|
@@ -164,15 +166,22 @@ const sanitizePlanLinks = (value, now) => {
|
|
|
164
166
|
const id = asNonEmptyString(entry.id);
|
|
165
167
|
const file = asNonEmptyString(entry.file);
|
|
166
168
|
if (!id || !file || !PLAN_FILE_PATTERN.test(file)) continue;
|
|
167
|
-
|
|
169
|
+
// A personal file and a shared file may carry the same name: they live in different folders.
|
|
170
|
+
const shared = entry.shared === true;
|
|
171
|
+
const fileKey = `${shared ? 'shared' : 'personal'}/${file}`;
|
|
172
|
+
if (seenIds.has(id) || seenFiles.has(fileKey)) continue;
|
|
168
173
|
seenIds.add(id);
|
|
169
|
-
seenFiles.add(
|
|
174
|
+
seenFiles.add(fileKey);
|
|
170
175
|
result.push({
|
|
171
176
|
id,
|
|
172
177
|
file,
|
|
173
178
|
title: sanitizePlanTitle(entry.title) || 'Plan',
|
|
174
179
|
createdAt: Number.isFinite(entry.createdAt) && entry.createdAt >= 0 ? entry.createdAt : now,
|
|
175
180
|
pinned: entry.pinned === true,
|
|
181
|
+
// The user's own plan that was moved into the team's folder: it keeps
|
|
182
|
+
// its id (session attachments still point at it) and this flag says
|
|
183
|
+
// which folder holds the file.
|
|
184
|
+
shared,
|
|
176
185
|
});
|
|
177
186
|
}
|
|
178
187
|
return result.sort((a, b) => b.createdAt - a.createdAt);
|
|
@@ -185,8 +194,15 @@ const createEmptyContext = () => ({
|
|
|
185
194
|
plans: [],
|
|
186
195
|
});
|
|
187
196
|
|
|
197
|
+
const SHARED_PLAN_ID_PREFIX = 'shared:';
|
|
198
|
+
|
|
188
199
|
export const createProjectContextRuntime = (deps) => {
|
|
189
|
-
const { fsPromises, path, projectsDirPath, createId } = deps;
|
|
200
|
+
const { fsPromises, path, projectsDirPath, createId, resolveSharedPlansDir } = deps;
|
|
201
|
+
// The team's shared plans folder for a project (absolute path) or null; the
|
|
202
|
+
// project config runtime owns that answer (`plansDir` in the shared file).
|
|
203
|
+
const sharedPlansDirFor = typeof resolveSharedPlansDir === 'function'
|
|
204
|
+
? resolveSharedPlansDir
|
|
205
|
+
: async () => null;
|
|
190
206
|
|
|
191
207
|
const idFactory = typeof createId === 'function'
|
|
192
208
|
? createId
|
|
@@ -207,10 +223,14 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
207
223
|
return value;
|
|
208
224
|
};
|
|
209
225
|
|
|
210
|
-
|
|
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));
|
|
211
231
|
const contextPathFor = (projectId) => path.join(storageDirFor(projectId), 'context.json');
|
|
212
232
|
const plansDirFor = (projectId) => path.join(storageDirFor(projectId), 'plans');
|
|
213
|
-
const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${
|
|
233
|
+
const legacyConfigPathFor = (projectId) => path.join(projectsDirPath, `${storageStemFor(projectId)}.json`);
|
|
214
234
|
|
|
215
235
|
const readJson = async (filePath) => {
|
|
216
236
|
let raw;
|
|
@@ -356,7 +376,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
356
376
|
* of identical content, so concurrent migrations converge instead of
|
|
357
377
|
* interleaving.
|
|
358
378
|
*/
|
|
359
|
-
const
|
|
379
|
+
const readStoredContext = async (projectId) => {
|
|
360
380
|
const now = Date.now();
|
|
361
381
|
const stored = await readJson(contextPathFor(projectId));
|
|
362
382
|
|
|
@@ -385,6 +405,67 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
385
405
|
};
|
|
386
406
|
};
|
|
387
407
|
|
|
408
|
+
/** The file name behind a shared plan id, or null when the id is not one. */
|
|
409
|
+
const sharedPlanFileOf = (planId) => {
|
|
410
|
+
if (typeof planId !== 'string' || !planId.startsWith(SHARED_PLAN_ID_PREFIX)) return null;
|
|
411
|
+
const file = planId.slice(SHARED_PLAN_ID_PREFIX.length);
|
|
412
|
+
return PLAN_FILE_PATTERN.test(file) ? file : null;
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Plans in the team's shared folder: every `.md` file there, newest first,
|
|
417
|
+
* addressed by `shared:<file>`. Plans written by other tools have no
|
|
418
|
+
* manifest entry, so the title comes from the file each time.
|
|
419
|
+
*/
|
|
420
|
+
const listSharedPlans = async (projectId, claimedFiles = new Set()) => {
|
|
421
|
+
const dir = await sharedPlansDirFor(projectId);
|
|
422
|
+
if (!dir) return { dir: null, plans: [] };
|
|
423
|
+
let entries;
|
|
424
|
+
try {
|
|
425
|
+
entries = await fsPromises.readdir(dir, { withFileTypes: true });
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error && error.code === 'ENOENT') return { dir, plans: [] };
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
const plans = [];
|
|
431
|
+
for (const entry of entries) {
|
|
432
|
+
if (!entry.isFile() || !PLAN_FILE_PATTERN.test(entry.name)) continue;
|
|
433
|
+
// Listed under its own id by the manifest entry that moved it there.
|
|
434
|
+
if (claimedFiles.has(entry.name)) continue;
|
|
435
|
+
const filePath = path.join(dir, entry.name);
|
|
436
|
+
const [raw, stat] = await Promise.all([fsPromises.readFile(filePath, 'utf8'), fsPromises.stat(filePath)]);
|
|
437
|
+
plans.push({
|
|
438
|
+
id: `${SHARED_PLAN_ID_PREFIX}${entry.name}`,
|
|
439
|
+
file: entry.name,
|
|
440
|
+
title: parsePlanMarkdown(raw).title,
|
|
441
|
+
createdAt: Math.round(stat.mtimeMs),
|
|
442
|
+
pinned: false,
|
|
443
|
+
source: 'shared',
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
plans.sort((left, right) => right.createdAt - left.createdAt);
|
|
447
|
+
return { dir, plans };
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
/** What clients see: the stored context plus the team's shared plans, each marked with its source. */
|
|
451
|
+
const readContext = async (projectId) => {
|
|
452
|
+
const stored = await readStoredContext(projectId);
|
|
453
|
+
const claimed = new Set(stored.plans.filter((plan) => plan.shared).map((plan) => plan.file));
|
|
454
|
+
const shared = await listSharedPlans(projectId, claimed);
|
|
455
|
+
const own = stored.plans
|
|
456
|
+
// A moved plan is only reachable while the project still has a shared folder.
|
|
457
|
+
.filter((plan) => !plan.shared || shared.dir)
|
|
458
|
+
.map(({ shared: isShared, ...plan }) => ({ ...plan, source: isShared ? 'shared' : 'personal' }));
|
|
459
|
+
return {
|
|
460
|
+
...stored,
|
|
461
|
+
plans: [...own, ...shared.plans],
|
|
462
|
+
sharedPlansDir: shared.dir,
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
/** The folder a manifest plan's file lives in; null for a moved plan when the project lost its shared folder. */
|
|
467
|
+
const folderOfLink = async (projectId, link) => (link.shared ? sharedPlansDirFor(projectId) : plansDirFor(projectId));
|
|
468
|
+
|
|
388
469
|
const writeContext = async (projectId, context) => {
|
|
389
470
|
await writeJsonAtomic(contextPathFor(projectId), {
|
|
390
471
|
version: PROJECT_CONTEXT_VERSION,
|
|
@@ -397,7 +478,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
397
478
|
const saveTodos = async (projectId, todos) => {
|
|
398
479
|
return withWriteLock(projectId, async () => {
|
|
399
480
|
const now = Date.now();
|
|
400
|
-
const current = await
|
|
481
|
+
const current = await readStoredContext(projectId);
|
|
401
482
|
const next = { ...current, todos: sanitizeTodos(todos, now) };
|
|
402
483
|
await writeContext(projectId, next);
|
|
403
484
|
return next;
|
|
@@ -420,7 +501,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
420
501
|
|
|
421
502
|
return withWriteLock(projectId, async () => {
|
|
422
503
|
const now = Date.now();
|
|
423
|
-
const current = await
|
|
504
|
+
const current = await readStoredContext(projectId);
|
|
424
505
|
if (current.notes.length >= PROJECT_NOTE_MAX_ITEMS) {
|
|
425
506
|
throw new Error(`A project can hold at most ${PROJECT_NOTE_MAX_ITEMS} notes`);
|
|
426
507
|
}
|
|
@@ -462,7 +543,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
462
543
|
|
|
463
544
|
return withWriteLock(projectId, async () => {
|
|
464
545
|
const now = Date.now();
|
|
465
|
-
const current = await
|
|
546
|
+
const current = await readStoredContext(projectId);
|
|
466
547
|
const existing = current.notes.find((note) => note.id === id);
|
|
467
548
|
if (!existing) {
|
|
468
549
|
return null;
|
|
@@ -486,7 +567,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
486
567
|
}
|
|
487
568
|
|
|
488
569
|
return withWriteLock(projectId, async () => {
|
|
489
|
-
const current = await
|
|
570
|
+
const current = await readStoredContext(projectId);
|
|
490
571
|
if (!current.notes.some((note) => note.id === id)) {
|
|
491
572
|
return { deleted: false, context: current };
|
|
492
573
|
}
|
|
@@ -501,22 +582,38 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
501
582
|
if (!id) {
|
|
502
583
|
throw new Error('planId is required');
|
|
503
584
|
}
|
|
504
|
-
const
|
|
585
|
+
const sharedFile = sharedPlanFileOf(id);
|
|
586
|
+
if (sharedFile) {
|
|
587
|
+
const dir = await sharedPlansDirFor(projectId);
|
|
588
|
+
if (!dir) return null;
|
|
589
|
+
let raw;
|
|
590
|
+
try {
|
|
591
|
+
raw = await fsPromises.readFile(path.join(dir, sharedFile), 'utf8');
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error && error.code === 'ENOENT') return null;
|
|
594
|
+
throw error;
|
|
595
|
+
}
|
|
596
|
+
const parsed = parsePlanMarkdown(raw);
|
|
597
|
+
return { id, file: sharedFile, createdAt: 0, title: parsed.title, body: parsed.body, raw, source: 'shared' };
|
|
598
|
+
}
|
|
599
|
+
const context = await readStoredContext(projectId);
|
|
505
600
|
const link = context.plans.find((entry) => entry.id === id);
|
|
506
601
|
if (!link) {
|
|
507
602
|
return null;
|
|
508
603
|
}
|
|
509
604
|
|
|
605
|
+
const folder = await folderOfLink(projectId, link);
|
|
606
|
+
if (!folder) return null;
|
|
510
607
|
let raw;
|
|
511
608
|
try {
|
|
512
|
-
raw = await fsPromises.readFile(path.join(
|
|
609
|
+
raw = await fsPromises.readFile(path.join(folder, link.file), 'utf8');
|
|
513
610
|
} catch (error) {
|
|
514
611
|
if (error && error.code === 'ENOENT') return null;
|
|
515
612
|
throw error;
|
|
516
613
|
}
|
|
517
614
|
|
|
518
615
|
const parsed = parsePlanMarkdown(raw);
|
|
519
|
-
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw };
|
|
616
|
+
return { id: link.id, file: link.file, createdAt: link.createdAt, title: parsed.title, body: parsed.body, raw, source: link.shared ? 'shared' : 'personal' };
|
|
520
617
|
};
|
|
521
618
|
|
|
522
619
|
/**
|
|
@@ -541,14 +638,37 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
541
638
|
}
|
|
542
639
|
const raw = clampLength(value.raw, PROJECT_PLAN_BODY_MAX_LENGTH);
|
|
543
640
|
|
|
641
|
+
const sharedFile = sharedPlanFileOf(id);
|
|
642
|
+
if (sharedFile) {
|
|
643
|
+
// A shared plan is the file itself: written verbatim, no manifest.
|
|
644
|
+
return withWriteLock(projectId, async () => {
|
|
645
|
+
const dir = await sharedPlansDirFor(projectId);
|
|
646
|
+
if (!dir) return null;
|
|
647
|
+
const filePath = path.join(dir, sharedFile);
|
|
648
|
+
try {
|
|
649
|
+
await fsPromises.access(filePath);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (error && error.code === 'ENOENT') return null;
|
|
652
|
+
throw error;
|
|
653
|
+
}
|
|
654
|
+
await fsPromises.writeFile(filePath, raw, 'utf8');
|
|
655
|
+
const parsed = parsePlanMarkdown(raw);
|
|
656
|
+
const context = await readContext(projectId);
|
|
657
|
+
const plan = context.plans.find((entry) => entry.id === id) ?? { id, file: sharedFile, title: parsed.title, createdAt: Date.now(), pinned: false, source: 'shared' };
|
|
658
|
+
return { plan, context, title: parsed.title, body: parsed.body, raw };
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
|
|
544
662
|
return withWriteLock(projectId, async () => {
|
|
545
|
-
const current = await
|
|
663
|
+
const current = await readStoredContext(projectId);
|
|
546
664
|
const link = current.plans.find((entry) => entry.id === id);
|
|
547
665
|
if (!link) {
|
|
548
666
|
return null;
|
|
549
667
|
}
|
|
550
668
|
|
|
551
|
-
const
|
|
669
|
+
const folder = await folderOfLink(projectId, link);
|
|
670
|
+
if (!folder) return null;
|
|
671
|
+
const filePath = path.join(folder, link.file);
|
|
552
672
|
// Refuse to recreate a file that was deleted underneath us: the link is
|
|
553
673
|
// already dead, and writing here would resurrect it with editor content
|
|
554
674
|
// the user believed was discarded.
|
|
@@ -569,7 +689,8 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
569
689
|
};
|
|
570
690
|
await writeContext(projectId, next);
|
|
571
691
|
|
|
572
|
-
|
|
692
|
+
const { shared: isShared, ...publicLink } = nextLink;
|
|
693
|
+
return { plan: { ...publicLink, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId), title: parsed.title, body: parsed.body, raw };
|
|
573
694
|
});
|
|
574
695
|
};
|
|
575
696
|
|
|
@@ -586,7 +707,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
586
707
|
const body = clampLength(typeof value?.body === 'string' ? value.body : '', PROJECT_PLAN_BODY_MAX_LENGTH);
|
|
587
708
|
|
|
588
709
|
return withWriteLock(projectId, async () => {
|
|
589
|
-
const current = await
|
|
710
|
+
const current = await readStoredContext(projectId);
|
|
590
711
|
const createdAt = Date.now();
|
|
591
712
|
const plansDir = plansDirFor(projectId);
|
|
592
713
|
await fsPromises.mkdir(plansDir, { recursive: true });
|
|
@@ -594,7 +715,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
594
715
|
const baseName = `${createdAt}-${slugifyPlanTitle(title)}`;
|
|
595
716
|
let file = `${baseName}.md`;
|
|
596
717
|
let attempt = 1;
|
|
597
|
-
while (current.plans.some((entry) => entry.file === file)) {
|
|
718
|
+
while (current.plans.some((entry) => !entry.shared && entry.file === file)) {
|
|
598
719
|
file = `${baseName}-${attempt}.md`;
|
|
599
720
|
attempt += 1;
|
|
600
721
|
}
|
|
@@ -604,7 +725,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
604
725
|
const link = { id: idFactory(), file, title, createdAt, pinned: false };
|
|
605
726
|
const next = { ...current, plans: [link, ...current.plans] };
|
|
606
727
|
await writeContext(projectId, next);
|
|
607
|
-
return { plan: link, context:
|
|
728
|
+
return { plan: { ...link, source: 'personal' }, context: await readContext(projectId) };
|
|
608
729
|
});
|
|
609
730
|
};
|
|
610
731
|
|
|
@@ -623,7 +744,7 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
623
744
|
}
|
|
624
745
|
|
|
625
746
|
return withWriteLock(projectId, async () => {
|
|
626
|
-
const current = await
|
|
747
|
+
const current = await readStoredContext(projectId);
|
|
627
748
|
const existing = current.plans.find((entry) => entry.id === id);
|
|
628
749
|
if (!existing) {
|
|
629
750
|
return null;
|
|
@@ -631,7 +752,8 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
631
752
|
const plan = { ...existing, pinned: pinned === true };
|
|
632
753
|
const next = { ...current, plans: current.plans.map((entry) => (entry.id === id ? plan : entry)) };
|
|
633
754
|
await writeContext(projectId, next);
|
|
634
|
-
|
|
755
|
+
const { shared: isShared, ...publicPlan } = plan;
|
|
756
|
+
return { plan: { ...publicPlan, source: isShared ? 'shared' : 'personal' }, context: await readContext(projectId) };
|
|
635
757
|
});
|
|
636
758
|
};
|
|
637
759
|
|
|
@@ -641,17 +763,124 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
641
763
|
throw new Error('planId is required');
|
|
642
764
|
}
|
|
643
765
|
|
|
766
|
+
const sharedFile = sharedPlanFileOf(id);
|
|
767
|
+
if (sharedFile) {
|
|
768
|
+
return withWriteLock(projectId, async () => {
|
|
769
|
+
const dir = await sharedPlansDirFor(projectId);
|
|
770
|
+
const filePath = dir ? path.join(dir, sharedFile) : null;
|
|
771
|
+
const exists = filePath ? await fsPromises.access(filePath).then(() => true, () => false) : false;
|
|
772
|
+
if (!exists) return { deleted: false, context: await readContext(projectId) };
|
|
773
|
+
await fsPromises.rm(filePath, { force: true });
|
|
774
|
+
return { deleted: true, context: await readContext(projectId) };
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
644
778
|
return withWriteLock(projectId, async () => {
|
|
645
|
-
const current = await
|
|
779
|
+
const current = await readStoredContext(projectId);
|
|
646
780
|
const link = current.plans.find((entry) => entry.id === id);
|
|
647
781
|
if (!link) {
|
|
648
|
-
return { deleted: false, context:
|
|
782
|
+
return { deleted: false, context: await readContext(projectId) };
|
|
649
783
|
}
|
|
650
784
|
|
|
651
785
|
const next = { ...current, plans: current.plans.filter((entry) => entry.id !== id) };
|
|
652
786
|
await writeContext(projectId, next);
|
|
653
|
-
await
|
|
654
|
-
|
|
787
|
+
const folder = await folderOfLink(projectId, link);
|
|
788
|
+
if (folder) await fsPromises.rm(path.join(folder, link.file), { force: true });
|
|
789
|
+
return { deleted: true, context: await readContext(projectId) };
|
|
790
|
+
});
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
/** A free file name in `dir`, keeping the wanted base name unless it is taken. */
|
|
794
|
+
const freeFileNameIn = async (dir, wanted, taken = new Set()) => {
|
|
795
|
+
const base = wanted.replace(/\.md$/, '');
|
|
796
|
+
let file = wanted;
|
|
797
|
+
let attempt = 1;
|
|
798
|
+
while (taken.has(file) || await fsPromises.access(path.join(dir, file)).then(() => true, () => false)) {
|
|
799
|
+
file = `${base}-${attempt}.md`;
|
|
800
|
+
attempt += 1;
|
|
801
|
+
}
|
|
802
|
+
return file;
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
const moveFile = async (from, to) => {
|
|
806
|
+
try {
|
|
807
|
+
await fsPromises.rename(from, to);
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (!error || error.code !== 'EXDEV') throw error;
|
|
810
|
+
await fsPromises.copyFile(from, to);
|
|
811
|
+
await fsPromises.rm(from, { force: true });
|
|
812
|
+
}
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* Move one of the user's plans into the team's shared folder. The plan
|
|
817
|
+
* keeps its id: the manifest entry stays and gets the `shared` flag, so a
|
|
818
|
+
* session that attached the plan still finds it. The markdown moves first,
|
|
819
|
+
* then the manifest is written; a failure in between leaves the file in the
|
|
820
|
+
* shared folder (listed there as `shared:<file>`) and a dead personal entry
|
|
821
|
+
* that `readPlan` reports as gone. Needs a shared plans folder; refused
|
|
822
|
+
* (validation error) when the project has none.
|
|
823
|
+
*/
|
|
824
|
+
const sharePlan = async (projectId, planId) => {
|
|
825
|
+
const id = asNonEmptyString(planId);
|
|
826
|
+
if (!id) throw new Error('planId is required');
|
|
827
|
+
return withWriteLock(projectId, async () => {
|
|
828
|
+
const dir = await sharedPlansDirFor(projectId);
|
|
829
|
+
if (!dir) throw new Error('shared plans folder is required');
|
|
830
|
+
const current = await readStoredContext(projectId);
|
|
831
|
+
const link = current.plans.find((entry) => entry.id === id);
|
|
832
|
+
if (!link || link.shared) return null;
|
|
833
|
+
const from = path.join(plansDirFor(projectId), link.file);
|
|
834
|
+
const exists = await fsPromises.access(from).then(() => true, () => false);
|
|
835
|
+
if (!exists) return null;
|
|
836
|
+
await fsPromises.mkdir(dir, { recursive: true });
|
|
837
|
+
const file = await freeFileNameIn(dir, link.file);
|
|
838
|
+
await moveFile(from, path.join(dir, file));
|
|
839
|
+
const moved = { ...link, file, shared: true };
|
|
840
|
+
await writeContext(projectId, { ...current, plans: current.plans.map((entry) => (entry.id === id ? moved : entry)) });
|
|
841
|
+
const context = await readContext(projectId);
|
|
842
|
+
return { plan: context.plans.find((entry) => entry.id === id), context };
|
|
843
|
+
});
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Move a shared plan back into the user's own plans; the reverse of
|
|
848
|
+
* `sharePlan`. A plan the user moved keeps its id; a plan that only ever
|
|
849
|
+
* lived in the team's folder (`shared:<file>`) gets a manifest entry now.
|
|
850
|
+
*/
|
|
851
|
+
const unsharePlan = async (projectId, planId) => {
|
|
852
|
+
const id = asNonEmptyString(planId);
|
|
853
|
+
if (!id) throw new Error('planId is required');
|
|
854
|
+
return withWriteLock(projectId, async () => {
|
|
855
|
+
const dir = await sharedPlansDirFor(projectId);
|
|
856
|
+
if (!dir) return null;
|
|
857
|
+
const current = await readStoredContext(projectId);
|
|
858
|
+
const ownLink = current.plans.find((entry) => entry.id === id && entry.shared);
|
|
859
|
+
const sharedFile = ownLink ? ownLink.file : sharedPlanFileOf(id);
|
|
860
|
+
if (!sharedFile) return null;
|
|
861
|
+
const from = path.join(dir, sharedFile);
|
|
862
|
+
let raw;
|
|
863
|
+
try {
|
|
864
|
+
raw = await fsPromises.readFile(from, 'utf8');
|
|
865
|
+
} catch (error) {
|
|
866
|
+
if (error && error.code === 'ENOENT') return null;
|
|
867
|
+
throw error;
|
|
868
|
+
}
|
|
869
|
+
const plansDir = plansDirFor(projectId);
|
|
870
|
+
await fsPromises.mkdir(plansDir, { recursive: true });
|
|
871
|
+
const taken = new Set(current.plans.filter((entry) => !entry.shared).map((entry) => entry.file));
|
|
872
|
+
const file = await freeFileNameIn(plansDir, sharedFile, taken);
|
|
873
|
+
await moveFile(from, path.join(plansDir, file));
|
|
874
|
+
const title = parsePlanMarkdown(raw).title;
|
|
875
|
+
const link = ownLink
|
|
876
|
+
? { ...ownLink, file, title, shared: false }
|
|
877
|
+
: { id: idFactory(), file, title, createdAt: Date.now(), pinned: false, shared: false };
|
|
878
|
+
const plans = ownLink
|
|
879
|
+
? current.plans.map((entry) => (entry.id === id ? link : entry))
|
|
880
|
+
: [link, ...current.plans];
|
|
881
|
+
await writeContext(projectId, { ...current, plans });
|
|
882
|
+
const { shared: _movedFlag, ...publicLink } = link;
|
|
883
|
+
return { plan: { ...publicLink, source: 'personal' }, context: await readContext(projectId) };
|
|
655
884
|
});
|
|
656
885
|
};
|
|
657
886
|
|
|
@@ -666,6 +895,8 @@ export const createProjectContextRuntime = (deps) => {
|
|
|
666
895
|
createPlan,
|
|
667
896
|
setPlanPinned,
|
|
668
897
|
deletePlan,
|
|
898
|
+
sharePlan,
|
|
899
|
+
unsharePlan,
|
|
669
900
|
contextPathFor,
|
|
670
901
|
plansDirFor,
|
|
671
902
|
};
|
|
@@ -3,6 +3,7 @@ import fsPromises from 'node:fs/promises';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
|
+
import { createProjectIdFromPath, projectConfigFileStemOf } from '../projects/project-id.js';
|
|
6
7
|
import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
|
|
7
8
|
|
|
8
9
|
const PROJECT_ID = 'path_dGVzdA';
|
|
@@ -52,6 +53,7 @@ describe('readContext', () => {
|
|
|
52
53
|
notes: [],
|
|
53
54
|
todos: [],
|
|
54
55
|
plans: [],
|
|
56
|
+
sharedPlansDir: null,
|
|
55
57
|
});
|
|
56
58
|
});
|
|
57
59
|
|
|
@@ -134,7 +136,7 @@ describe('legacy migration', () => {
|
|
|
134
136
|
const context = await runtime.readContext(PROJECT_ID);
|
|
135
137
|
expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
|
|
136
138
|
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
|
|
137
|
-
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false }]);
|
|
139
|
+
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false, source: 'personal' }]);
|
|
138
140
|
|
|
139
141
|
const remaining = await readJson(legacyConfigPath());
|
|
140
142
|
expect(remaining).toEqual({
|
|
@@ -152,7 +154,7 @@ describe('legacy migration', () => {
|
|
|
152
154
|
});
|
|
153
155
|
|
|
154
156
|
const context = await runtime.readContext(PROJECT_ID);
|
|
155
|
-
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false }]);
|
|
157
|
+
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false, source: 'personal' }]);
|
|
156
158
|
expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
|
|
157
159
|
});
|
|
158
160
|
|
|
@@ -170,7 +172,7 @@ describe('legacy migration', () => {
|
|
|
170
172
|
test('does not run when the legacy config holds no context keys', async () => {
|
|
171
173
|
await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
|
|
172
174
|
|
|
173
|
-
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [] });
|
|
175
|
+
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
|
|
174
176
|
await expect(fsPromises.access(contextPath())).rejects.toThrow();
|
|
175
177
|
expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
|
|
176
178
|
});
|
|
@@ -199,6 +201,59 @@ describe('legacy migration', () => {
|
|
|
199
201
|
});
|
|
200
202
|
});
|
|
201
203
|
|
|
204
|
+
describe('long project ids', () => {
|
|
205
|
+
// A checkout nested deep enough that its `path_<base64url>` id is longer
|
|
206
|
+
// than a file or folder name may be; the storage lands under the bounded
|
|
207
|
+
// stem instead of failing with ENAMETOOLONG.
|
|
208
|
+
const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
|
|
209
|
+
const stem = projectConfigFileStemOf(longProjectId);
|
|
210
|
+
|
|
211
|
+
test('the id maps to a bounded folder name', () => {
|
|
212
|
+
expect(longProjectId.length).toBeGreaterThan(240);
|
|
213
|
+
expect(stem.startsWith('path_sha256_')).toBe(true);
|
|
214
|
+
expect(stem.length).toBeLessThan(100);
|
|
215
|
+
expect(runtime.contextPathFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'context.json'));
|
|
216
|
+
expect(runtime.plansDirFor(longProjectId)).toBe(path.join(projectsDirPath, stem, 'plans'));
|
|
217
|
+
// A short id keeps naming its folder itself.
|
|
218
|
+
expect(runtime.contextPathFor(PROJECT_ID)).toBe(contextPath());
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a missing context reads as empty instead of failing on the legacy config lookup', async () => {
|
|
222
|
+
expect(await runtime.readContext(longProjectId)).toEqual({ version: 2, notes: [], todos: [], plans: [], sharedPlansDir: null });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('context and plans round-trip through the bounded folder', async () => {
|
|
226
|
+
await runtime.saveTodos(longProjectId, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
|
|
227
|
+
const { note } = await runtime.createNote(longProjectId, { body: 'kept' });
|
|
228
|
+
const { plan } = await runtime.createPlan(longProjectId, { title: 'Deep plan', body: 'step one' });
|
|
229
|
+
|
|
230
|
+
const context = await runtime.readContext(longProjectId);
|
|
231
|
+
expect(context.todos).toEqual([{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
|
|
232
|
+
expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
|
|
233
|
+
expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
234
|
+
expect((await runtime.readPlan(longProjectId, plan.id)).body).toBe('step one');
|
|
235
|
+
|
|
236
|
+
expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).todos).toHaveLength(1);
|
|
237
|
+
await fsPromises.access(path.join(projectsDirPath, stem, 'plans', plan.file));
|
|
238
|
+
expect(await fsPromises.readdir(projectsDirPath)).toEqual([stem]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('legacy keys are migrated out of the bounded config file', async () => {
|
|
242
|
+
const boundedConfigPath = path.join(projectsDirPath, `${stem}.json`);
|
|
243
|
+
await writeJson(boundedConfigPath, {
|
|
244
|
+
'setup-worktree': ['bun install'],
|
|
245
|
+
projectNotes: 'legacy notes',
|
|
246
|
+
projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const context = await runtime.readContext(longProjectId);
|
|
250
|
+
expect(context.notes.map((entry) => entry.body)).toEqual(['legacy notes']);
|
|
251
|
+
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
|
|
252
|
+
expect(await readJson(boundedConfigPath)).toEqual({ 'setup-worktree': ['bun install'] });
|
|
253
|
+
expect((await readJson(path.join(projectsDirPath, stem, 'context.json'))).notes[0].body).toBe('legacy notes');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
202
257
|
describe('todos', () => {
|
|
203
258
|
test('round-trips through disk', async () => {
|
|
204
259
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
|
|
@@ -479,6 +534,108 @@ describe('plans', () => {
|
|
|
479
534
|
});
|
|
480
535
|
});
|
|
481
536
|
|
|
537
|
+
describe('shared plans', () => {
|
|
538
|
+
let sharedDir;
|
|
539
|
+
let sharedRuntime;
|
|
540
|
+
|
|
541
|
+
beforeEach(async () => {
|
|
542
|
+
sharedDir = path.join(projectsDirPath, 'repo', 'docs', 'plans');
|
|
543
|
+
sharedRuntime = createProjectContextRuntime({
|
|
544
|
+
fsPromises,
|
|
545
|
+
path,
|
|
546
|
+
projectsDirPath,
|
|
547
|
+
createId: () => `plan-${++idCounter}`,
|
|
548
|
+
resolveSharedPlansDir: async () => sharedDir,
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
test('lists the shared folder\'s markdown files after the personal plans, marked shared, and reads them by file', async () => {
|
|
553
|
+
await fsPromises.mkdir(sharedDir, { recursive: true });
|
|
554
|
+
await fsPromises.writeFile(path.join(sharedDir, 'roadmap.md'), '# Roadmap\n\n- ship it\n');
|
|
555
|
+
await fsPromises.writeFile(path.join(sharedDir, 'notes.txt'), 'not a plan');
|
|
556
|
+
await fsPromises.writeFile(path.join(sharedDir, 'untitled.md'), 'first line only');
|
|
557
|
+
const mine = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
|
|
558
|
+
|
|
559
|
+
const context = await sharedRuntime.readContext(PROJECT_ID);
|
|
560
|
+
expect(context.sharedPlansDir).toBe(sharedDir);
|
|
561
|
+
expect(context.plans.map((plan) => `${plan.id}:${plan.source}`)).toEqual(expect.arrayContaining([
|
|
562
|
+
`${mine.plan.id}:personal`, 'shared:roadmap.md:shared', 'shared:untitled.md:shared',
|
|
563
|
+
]));
|
|
564
|
+
expect(context.plans[0].id).toBe(mine.plan.id);
|
|
565
|
+
expect(context.plans.find((plan) => plan.id === 'shared:untitled.md').title).toBe('first line only');
|
|
566
|
+
|
|
567
|
+
const read = await sharedRuntime.readPlan(PROJECT_ID, 'shared:roadmap.md');
|
|
568
|
+
expect(read.title).toBe('Roadmap');
|
|
569
|
+
expect(read.raw).toBe('# Roadmap\n\n- ship it\n');
|
|
570
|
+
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:missing.md')).toBeNull();
|
|
571
|
+
expect(await sharedRuntime.readPlan(PROJECT_ID, 'shared:../escape.md')).toBeNull();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test('a missing shared folder lists nothing; no folder configured lists nothing', async () => {
|
|
575
|
+
expect((await sharedRuntime.readContext(PROJECT_ID)).plans).toEqual([]);
|
|
576
|
+
expect((await runtime.readContext(PROJECT_ID)).sharedPlansDir).toBeNull();
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('updates and deletes a shared plan in place, verbatim', async () => {
|
|
580
|
+
await fsPromises.mkdir(sharedDir, { recursive: true });
|
|
581
|
+
await fsPromises.writeFile(path.join(sharedDir, 'a.md'), '# A\n');
|
|
582
|
+
const updated = await sharedRuntime.updatePlan(PROJECT_ID, 'shared:a.md', { raw: '# Renamed\n\nbody\n' });
|
|
583
|
+
expect(updated.plan.title).toBe('Renamed');
|
|
584
|
+
expect(updated.plan.source).toBe('shared');
|
|
585
|
+
expect(await fsPromises.readFile(path.join(sharedDir, 'a.md'), 'utf8')).toBe('# Renamed\n\nbody\n');
|
|
586
|
+
expect(await sharedRuntime.updatePlan(PROJECT_ID, 'shared:gone.md', { raw: 'x' })).toBeNull();
|
|
587
|
+
expect(await sharedRuntime.setPlanPinned(PROJECT_ID, 'shared:a.md', true)).toBeNull();
|
|
588
|
+
|
|
589
|
+
const deleted = await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md');
|
|
590
|
+
expect(deleted.deleted).toBe(true);
|
|
591
|
+
await expect(fsPromises.access(path.join(sharedDir, 'a.md'))).rejects.toThrow();
|
|
592
|
+
expect((await sharedRuntime.deletePlan(PROJECT_ID, 'shared:a.md')).deleted).toBe(false);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('share moves a personal plan into the shared folder and unshare brings it back, avoiding name collisions', async () => {
|
|
596
|
+
const { plan } = await sharedRuntime.createPlan(PROJECT_ID, { title: 'Mine', body: 'body' });
|
|
597
|
+
const shared = await sharedRuntime.sharePlan(PROJECT_ID, plan.id);
|
|
598
|
+
// The id survives the move: a session that attached the plan still finds it.
|
|
599
|
+
expect(shared.plan.id).toBe(plan.id);
|
|
600
|
+
expect(shared.plan.source).toBe('shared');
|
|
601
|
+
expect(shared.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:shared`]);
|
|
602
|
+
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
|
603
|
+
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine\n\nbody');
|
|
604
|
+
expect((await readJson(path.join(projectsDirPath, PROJECT_ID, 'context.json'))).plans[0]).toMatchObject({ id: plan.id, shared: true });
|
|
605
|
+
const readMoved = await sharedRuntime.readPlan(PROJECT_ID, plan.id);
|
|
606
|
+
expect(readMoved.source).toBe('shared');
|
|
607
|
+
expect(readMoved.raw).toBe('# Mine\n\nbody');
|
|
608
|
+
expect((await sharedRuntime.updatePlan(PROJECT_ID, plan.id, { raw: '# Mine v2\n' })).plan).toMatchObject({ id: plan.id, title: 'Mine v2', source: 'shared' });
|
|
609
|
+
expect(await fsPromises.readFile(path.join(sharedDir, plan.file), 'utf8')).toBe('# Mine v2\n');
|
|
610
|
+
|
|
611
|
+
// A personal file with the same name already exists: the returning plan gets a suffix, same id.
|
|
612
|
+
await fsPromises.mkdir(plansDir(), { recursive: true });
|
|
613
|
+
await fsPromises.writeFile(path.join(plansDir(), plan.file), 'squatter');
|
|
614
|
+
const back = await sharedRuntime.unsharePlan(PROJECT_ID, plan.id);
|
|
615
|
+
expect(back.plan.id).toBe(plan.id);
|
|
616
|
+
expect(back.plan.source).toBe('personal');
|
|
617
|
+
expect(back.plan.file).toBe(plan.file.replace(/\.md$/, '-1.md'));
|
|
618
|
+
expect(back.plan.title).toBe('Mine v2');
|
|
619
|
+
expect(back.context.plans.map((entry) => `${entry.id}:${entry.source}`)).toEqual([`${plan.id}:personal`]);
|
|
620
|
+
await expect(fsPromises.access(path.join(sharedDir, plan.file))).rejects.toThrow();
|
|
621
|
+
expect(await sharedRuntime.unsharePlan(PROJECT_ID, plan.id)).toBeNull();
|
|
622
|
+
expect(await sharedRuntime.sharePlan(PROJECT_ID, 'nope')).toBeNull();
|
|
623
|
+
|
|
624
|
+
// A plan that only ever lived in the team's folder gets an id of its own on the way in.
|
|
625
|
+
await fsPromises.writeFile(path.join(sharedDir, 'foreign.md'), '# Foreign\n');
|
|
626
|
+
const adopted = await sharedRuntime.unsharePlan(PROJECT_ID, 'shared:foreign.md');
|
|
627
|
+
expect(adopted.plan.id).not.toMatch(/^shared:/);
|
|
628
|
+
expect(adopted.plan.title).toBe('Foreign');
|
|
629
|
+
expect(adopted.plan.source).toBe('personal');
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test('share is refused without a shared plans folder', async () => {
|
|
633
|
+
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Mine', body: 'x' });
|
|
634
|
+
await expect(runtime.sharePlan(PROJECT_ID, plan.id)).rejects.toThrow('shared plans folder is required');
|
|
635
|
+
expect((await runtime.readContext(PROJECT_ID)).plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
482
639
|
describe('parsePlanMarkdown', () => {
|
|
483
640
|
test('reads the leading heading as the title', () => {
|
|
484
641
|
expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });
|