@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Projects
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Server-owned storage for a project's per-user config file,
|
|
6
|
+
`~/.config/openchamber/projects/<projectId>.json`. The file holds two
|
|
7
|
+
families of keys with different writers, and this module is the only place
|
|
8
|
+
that writes it:
|
|
9
|
+
|
|
10
|
+
| Keys | Owner | Reached through |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| `version`, `scheduledTasks` | `project-config.js` (scheduled-task runtime) | `/api/projects/:projectId/scheduled-tasks/*` |
|
|
13
|
+
| `setup-worktree`, `setup-worktree-wait`, `projectActions`, `projectActionsPrimaryId`, `draftStarters`, `projectPath` | `project-setup.js` via `readProjectSetup` / `updateProjectSetup` on the same runtime | `GET/PUT /api/projects/:projectId/config` (`routes.js`) |
|
|
14
|
+
|
|
15
|
+
Notes, todos, and plans moved out of this file to `packages/web/server/lib/project-context`.
|
|
16
|
+
|
|
17
|
+
A second, optional source is the team's shared file, `<repo>/.aiworker/project.json`
|
|
18
|
+
(`version: 1`; `setupWorktree`, `setupWorktreeWait`, `projectActions`, `draftStarters`,
|
|
19
|
+
`plansDir`). The server reads it from the checkout the project id names
|
|
20
|
+
(`projectPathFromId`). `GET /api/projects/:projectId/config`
|
|
21
|
+
returns one merged view: what runs at the top level, plus `shared` and `personal`
|
|
22
|
+
blocks so a page can edit the personal file without copying a teammate's entry into it.
|
|
23
|
+
|
|
24
|
+
| Field | Merge rule |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `setupWorktree` | shared first, then personal; personal `setupWorktreeMode: "replace"` uses the personal list only |
|
|
27
|
+
| `setupWorktreeWait` | personal when the personal file sets it, else shared, else `false` |
|
|
28
|
+
| `projectActions` | union by `id`; a personal action replaces the shared one with the same id; ids in personal `hiddenSharedActionIds` are dropped; every entry carries `source` |
|
|
29
|
+
| `projectActionsPrimaryId` | personal only |
|
|
30
|
+
| `draftStarters` | union by `type:name`, shared first, every entry carries `source` |
|
|
31
|
+
| `plansDir` | shared only |
|
|
32
|
+
|
|
33
|
+
A shared file that exists but cannot be parsed (or names a `plansDir` outside the
|
|
34
|
+
repo) is `shared.status: "invalid"` with a `reason`; the personal setup is still
|
|
35
|
+
served. It is never treated as "no shared setup".
|
|
36
|
+
|
|
37
|
+
### Writing the shared file
|
|
38
|
+
|
|
39
|
+
`PUT /api/projects/:projectId/config/shared` (`updateSharedProjectSetup`) is
|
|
40
|
+
the only writer. The patch replaces the keys it names over the current file
|
|
41
|
+
(a broken file counts as empty, so a write repairs it); the result is written
|
|
42
|
+
pretty-printed with `version` first and only the keys that carry something
|
|
43
|
+
(`serializeSharedProjectConfig`), because the file is committed and reviewed.
|
|
44
|
+
A result with nothing in it removes the file and the `.aiworker` folder
|
|
45
|
+
when that leaves it empty, so unsharing the last item leaves no trace. The
|
|
46
|
+
write refuses a checkout that does not exist and a `plansDir` outside the
|
|
47
|
+
repo. The writer has seen what it shared, so its personal trust record is set
|
|
48
|
+
to the new hash; teammates still get the prompt. The shared UI composes
|
|
49
|
+
"share" and "make personal" as a shared write followed by a personal write.
|
|
50
|
+
|
|
51
|
+
### Trust
|
|
52
|
+
|
|
53
|
+
Shared setup commands and shared actions run on the machine of whoever pulls
|
|
54
|
+
the repo, so they run only after the user has seen them. The view carries
|
|
55
|
+
`trust: { hash, trusted }`: `hash` is `sharedTrustHashOf(shared)`, a SHA-256
|
|
56
|
+
over the executable parts (`setupWorktree` and each action's `id`, `command`,
|
|
57
|
+
`runIn`, actions sorted by id; names and icons do not count), or `null` when
|
|
58
|
+
nothing executes. `trusted` is true when nothing executes or the personal
|
|
59
|
+
file's `sharedTrust.hash` equals the current hash, so a pull that changes a
|
|
60
|
+
command brings the prompt back. The client records an answer with a PUT of
|
|
61
|
+
`sharedTrustHash` (`null` forgets it). The prompt itself lives in the shared
|
|
62
|
+
UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
|
|
63
|
+
|
|
64
|
+
## Modules
|
|
65
|
+
|
|
66
|
+
- `project-id.js` — `createProjectIdFromPath` / `projectPathFromId`: the path-derived id (`path_<base64url>`) that names the file, and the checkout path back from it. The shared UI derives the same id (`packages/ui/src/lib/projectId.ts`); both sides must agree. `projectConfigFileStemOf`: the stem that names the file and the sibling folder for an id, see the file name invariant below.
|
|
67
|
+
- `project-config.js` — `createProjectConfigRuntime`: raw read, atomic write, the cross-process file lock (Electron and a CLI `serve` can share one projects dir), scheduled-task normalization, and the project-setup read/update.
|
|
68
|
+
- `project-setup.js` — sanitizers, the shared-file parser (`parseSharedProjectConfig`, `normalizePlansDir`), the merge (`mergeProjectSetup`), and the personal view for the setup keys. Mirrored in the VS Code extension host (`packages/vscode/src/project-setup.ts`), which owns the same file when the webview has no AiWorker server; keep the two in sync.
|
|
69
|
+
- `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
|
|
70
|
+
|
|
71
|
+
## Invariants
|
|
72
|
+
|
|
73
|
+
- **Every write is a locked read-modify-write of the whole document.** Keys the writer does not own, and keys from newer builds, come back out unchanged. A setup update and a scheduled-task update never clobber each other.
|
|
74
|
+
- **A wrongly shaped key is a 400, not a silent drop.** `projectSetupPatchToStored` throws; the file is untouched. Values inside a well-shaped key are sanitized (trimmed, capped, deduplicated) rather than rejected.
|
|
75
|
+
- **The file name is bounded, and so is the folder beside it.** The file is `<projectId>.json` and the per-project folder is `<projectId>/` while the id is at most 200 characters. A `path_<base64url>` id grows with the checkout path, so a deeply nested project (a path of roughly 150 characters or more) would otherwise get a name beyond the 255-byte limit and every write, lock, and temp file would fail with ENAMETOOLONG. Such an id is stored as `path_sha256_<hex digest of the id>.json` instead, with the folder `path_sha256_<digest>/` beside it (`projectConfigFileStemOf`; every composer of either path goes through it: `project-config.js`, `project-context/runtime.js`, `agent-memory/runtime.js`, and the id migration and orphan recovery in `opencode/settings-runtime.js`). The digest keeps the `path_` prefix so orphan recovery skips it. A file an older build managed to write under the long name is still read when the bounded file is missing and is moved to the bounded name by the next write; a malformed one is a read failure, not an empty project. A folder an older build created under the raw id (possible only for ids of 201 to 255 characters; longer names never got one) is moved into the bounded folder once at startup, by the project id migration in `opencode/settings-runtime.js`, with `context.json` merged by entry identity when both exist. The VS Code extension host mirrors both the naming rule and the legacy read-then-move for the file (`bridge-project-setup-runtime.ts`), because a VS Code-only user has no server to do it; it never touches the folder.
|
|
76
|
+
- **The client never composes the path.** `packages/ui/src/lib/openchamberConfig.ts` speaks only HTTP; the same code serves web, desktop, VS Code, and the phone, including a phone on a remote instance.
|
|
77
|
+
- **`AIWORKER_DATA_DIR` moves this directory too.** Every AiWorker folder hangs off the one root; a custom root gets `projects/`, `themes/`, and `speech-models/` copied in from `~/.config/openchamber` once at startup (copied, not moved: a second instance beside the default one must not strip it) (`lib/data-dir-migration.js`). A scratch server started with its own data dir therefore never touches the real project configs.
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { DateTime, IANAZone } from 'luxon';
|
|
2
2
|
import parser from 'cron-parser';
|
|
3
3
|
|
|
4
|
+
import { projectConfigFileStemOf, projectPathFromId } from './project-id.js';
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_PLANS_DIR,
|
|
7
|
+
EMPTY_SHARED_PROJECT_CONFIG,
|
|
8
|
+
SHARED_CONFIG_RELATIVE_PATH,
|
|
9
|
+
applySharedProjectSetupPatch,
|
|
10
|
+
isSharedProjectConfigEmpty,
|
|
11
|
+
mergeProjectSetup,
|
|
12
|
+
parseSharedProjectConfig,
|
|
13
|
+
projectSetupPatchToStored,
|
|
14
|
+
projectSetupViewOf,
|
|
15
|
+
serializeSharedProjectConfig,
|
|
16
|
+
sharedTrustHashOf,
|
|
17
|
+
} from './project-setup.js';
|
|
18
|
+
|
|
4
19
|
const PROJECT_CONFIG_VERSION = 1;
|
|
5
20
|
export const MAX_TASK_NAME_LENGTH = 80;
|
|
6
21
|
const MAX_TASK_PROMPT_LENGTH = 20_000;
|
|
@@ -384,6 +399,15 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
384
399
|
|
|
385
400
|
const resolveProjectConfigPath = (projectID) => {
|
|
386
401
|
const safeProjectID = sanitizeProjectID(projectID);
|
|
402
|
+
return path.join(projectsDirPath, `${projectConfigFileStemOf(safeProjectID)}.json`);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Where a build before the bounded file name stored an id too long for one
|
|
406
|
+
// (on a filesystem that still accepted it), or null when the id's own name
|
|
407
|
+
// is the current one. Read as a fallback, moved on the next write.
|
|
408
|
+
const resolveLegacyProjectConfigPath = (projectID) => {
|
|
409
|
+
const safeProjectID = sanitizeProjectID(projectID);
|
|
410
|
+
if (projectConfigFileStemOf(safeProjectID) === safeProjectID) return null;
|
|
387
411
|
return path.join(projectsDirPath, `${safeProjectID}.json`);
|
|
388
412
|
};
|
|
389
413
|
|
|
@@ -485,18 +509,31 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
485
509
|
throw new Error(`timeout acquiring project config lock for ${projectID}`);
|
|
486
510
|
};
|
|
487
511
|
|
|
488
|
-
|
|
489
|
-
|
|
512
|
+
// The parsed document, or null when there is no file (one of
|
|
513
|
+
// `missingCodes`). Malformed JSON still throws; it is a broken file, not
|
|
514
|
+
// an empty one.
|
|
515
|
+
const readJsonDocumentIfPresent = async (filePath, missingCodes) => {
|
|
516
|
+
let raw;
|
|
490
517
|
try {
|
|
491
|
-
|
|
492
|
-
const parsed = JSON.parse(raw);
|
|
493
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
518
|
+
raw = await fsPromises.readFile(filePath, 'utf8');
|
|
494
519
|
} catch (error) {
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
}
|
|
520
|
+
const code = error && typeof error === 'object' ? error.code : null;
|
|
521
|
+
if (missingCodes.includes(code)) return null;
|
|
498
522
|
throw error;
|
|
499
523
|
}
|
|
524
|
+
const parsed = JSON.parse(raw);
|
|
525
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
526
|
+
};
|
|
527
|
+
|
|
528
|
+
// The legacy name of a long id may exceed what the filesystem can hold;
|
|
529
|
+
// ENAMETOOLONG there means "no such file". On the bounded path it would
|
|
530
|
+
// mean the projects dir itself is unusable, so it stays an error.
|
|
531
|
+
const readRawProjectConfigFromDisk = async (projectID) => {
|
|
532
|
+
const current = await readJsonDocumentIfPresent(resolveProjectConfigPath(projectID), ['ENOENT']);
|
|
533
|
+
if (current) return current;
|
|
534
|
+
const legacyPath = resolveLegacyProjectConfigPath(projectID);
|
|
535
|
+
const legacy = legacyPath ? await readJsonDocumentIfPresent(legacyPath, ['ENOENT', 'ENAMETOOLONG']) : null;
|
|
536
|
+
return legacy ?? {};
|
|
500
537
|
};
|
|
501
538
|
|
|
502
539
|
// Normalized tasks for reading, plus the raw on-disk record of each one for
|
|
@@ -548,26 +585,37 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
548
585
|
})
|
|
549
586
|
);
|
|
550
587
|
|
|
551
|
-
|
|
588
|
+
// Atomic whole-document write; callers hand in the merged document so the
|
|
589
|
+
// keys they do not own survive untouched.
|
|
590
|
+
const writeRawProjectConfigToDisk = async (projectID, document) => {
|
|
552
591
|
const filePath = resolveProjectConfigPath(projectID);
|
|
553
592
|
const parentDirectory = path.dirname(filePath);
|
|
554
593
|
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
555
594
|
|
|
556
|
-
const existing = await readRawProjectConfigFromDisk(projectID);
|
|
557
|
-
const merged = {
|
|
558
|
-
...existing,
|
|
559
|
-
version: PROJECT_CONFIG_VERSION,
|
|
560
|
-
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
|
561
|
-
};
|
|
562
|
-
|
|
563
595
|
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
|
564
596
|
try {
|
|
565
|
-
await fsPromises.writeFile(temporaryPath, JSON.stringify(
|
|
597
|
+
await fsPromises.writeFile(temporaryPath, JSON.stringify(document, null, 2), 'utf8');
|
|
566
598
|
await fsPromises.rename(temporaryPath, filePath);
|
|
567
599
|
} catch (error) {
|
|
568
600
|
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
569
601
|
throw error;
|
|
570
602
|
}
|
|
603
|
+
// Every write goes through a read of the whole document, so the legacy
|
|
604
|
+
// file's content is now in the current one; drop it so a later read (or an
|
|
605
|
+
// older build) cannot see two copies drifting apart.
|
|
606
|
+
const legacyPath = resolveLegacyProjectConfigPath(projectID);
|
|
607
|
+
if (legacyPath) {
|
|
608
|
+
await fsPromises.rm(legacyPath, { force: true }).catch(() => {});
|
|
609
|
+
}
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
const writeProjectConfigToDisk = async (projectID, config) => {
|
|
613
|
+
const existing = await readRawProjectConfigFromDisk(projectID);
|
|
614
|
+
await writeRawProjectConfigToDisk(projectID, {
|
|
615
|
+
...existing,
|
|
616
|
+
version: PROJECT_CONFIG_VERSION,
|
|
617
|
+
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
|
618
|
+
});
|
|
571
619
|
};
|
|
572
620
|
|
|
573
621
|
const withProjectWriteLock = async (projectID, mutate) => {
|
|
@@ -905,7 +953,121 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
905
953
|
});
|
|
906
954
|
};
|
|
907
955
|
|
|
956
|
+
// The client-owned part of the file (worktree setup, project actions, draft
|
|
957
|
+
// starters); see `project-setup.js`. Reads are lock-free like task lists;
|
|
958
|
+
// an update merges the sanitized patch over the raw document under the same
|
|
959
|
+
// cross-process lock the task writers use, so neither side clobbers the other.
|
|
960
|
+
// The shared file lives in the project's checkout. The checkout path comes
|
|
961
|
+
// from the id itself (`path_<base64url>`), with the personal file's
|
|
962
|
+
// `projectPath` as the fallback for ids of another form. A missing file is
|
|
963
|
+
// the normal case; an unreadable or unparsable one is reported as invalid,
|
|
964
|
+
// never as "no shared setup".
|
|
965
|
+
const projectPathOf = (projectID, personalRaw) => (
|
|
966
|
+
projectPathFromId(projectID) || (typeof personalRaw.projectPath === 'string' ? personalRaw.projectPath.trim() : '')
|
|
967
|
+
);
|
|
968
|
+
const sharedConfigPathOf = (projectPath) => path.join(projectPath, ...SHARED_CONFIG_RELATIVE_PATH.split('/'));
|
|
969
|
+
|
|
970
|
+
const readSharedProjectConfig = async (projectID, personalRaw) => {
|
|
971
|
+
const projectPath = projectPathOf(projectID, personalRaw);
|
|
972
|
+
if (!projectPath) return { status: 'missing' };
|
|
973
|
+
let raw;
|
|
974
|
+
try {
|
|
975
|
+
raw = await fsPromises.readFile(sharedConfigPathOf(projectPath), 'utf8');
|
|
976
|
+
} catch (error) {
|
|
977
|
+
if (error && typeof error === 'object' && error.code === 'ENOENT') return { status: 'missing' };
|
|
978
|
+
return { status: 'invalid', reason: error instanceof Error ? error.message : String(error) };
|
|
979
|
+
}
|
|
980
|
+
return parseSharedProjectConfig(raw);
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
const mergedProjectSetupOf = async (projectID, personalRaw) => (
|
|
984
|
+
mergeProjectSetup(projectSetupViewOf(personalRaw), await readSharedProjectConfig(projectID, personalRaw))
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
const readProjectSetup = async (projectID) => mergedProjectSetupOf(projectID, await readRawProjectConfigFromDisk(projectID));
|
|
988
|
+
|
|
989
|
+
const updateProjectSetup = async (projectID, patch) => {
|
|
990
|
+
const stored = projectSetupPatchToStored(patch);
|
|
991
|
+
return withProjectWriteLock(projectID, async () => {
|
|
992
|
+
const existing = await readRawProjectConfigFromDisk(projectID);
|
|
993
|
+
const merged = { ...existing, ...stored };
|
|
994
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
995
|
+
if (value === undefined) delete merged[key];
|
|
996
|
+
}
|
|
997
|
+
await writeRawProjectConfigToDisk(projectID, merged);
|
|
998
|
+
return mergedProjectSetupOf(projectID, merged);
|
|
999
|
+
});
|
|
1000
|
+
};
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* Change the team's shared file in the checkout: the patch replaces the
|
|
1004
|
+
* keys it names over the current file (a broken file counts as empty, so
|
|
1005
|
+
* a write repairs it). A result with nothing in it removes the file (and
|
|
1006
|
+
* the `.aiworker` folder when that leaves it empty), so unsharing the
|
|
1007
|
+
* last item leaves no trace. The writer has seen the commands it just
|
|
1008
|
+
* shared, so the personal trust record is set to the new hash on this
|
|
1009
|
+
* instance; teammates still get the prompt.
|
|
1010
|
+
*/
|
|
1011
|
+
const updateSharedProjectSetup = async (projectID, patch) => (
|
|
1012
|
+
withProjectWriteLock(projectID, async () => {
|
|
1013
|
+
const personalRaw = await readRawProjectConfigFromDisk(projectID);
|
|
1014
|
+
const projectPath = projectPathOf(projectID, personalRaw);
|
|
1015
|
+
if (!projectPath) throw new Error('project checkout not found');
|
|
1016
|
+
try {
|
|
1017
|
+
if (!(await fsPromises.stat(projectPath)).isDirectory()) throw new Error('project checkout not found');
|
|
1018
|
+
} catch {
|
|
1019
|
+
throw new Error('project checkout not found');
|
|
1020
|
+
}
|
|
1021
|
+
const currentRead = await readSharedProjectConfig(projectID, personalRaw);
|
|
1022
|
+
const current = currentRead.status === 'ok' ? currentRead.config : EMPTY_SHARED_PROJECT_CONFIG;
|
|
1023
|
+
const next = applySharedProjectSetupPatch(current, patch);
|
|
1024
|
+
|
|
1025
|
+
const filePath = sharedConfigPathOf(projectPath);
|
|
1026
|
+
if (isSharedProjectConfigEmpty(next)) {
|
|
1027
|
+
await fsPromises.rm(filePath, { force: true });
|
|
1028
|
+
await fsPromises.rmdir(path.dirname(filePath)).catch(() => {});
|
|
1029
|
+
} else {
|
|
1030
|
+
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
1031
|
+
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
1032
|
+
try {
|
|
1033
|
+
await fsPromises.writeFile(temporaryPath, serializeSharedProjectConfig(next), 'utf8');
|
|
1034
|
+
await fsPromises.rename(temporaryPath, filePath);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
1037
|
+
throw error;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
const hash = sharedTrustHashOf(next);
|
|
1042
|
+
const personalNext = { ...personalRaw };
|
|
1043
|
+
if (hash) personalNext.sharedTrust = { hash, trustedAt: Date.now() };
|
|
1044
|
+
else delete personalNext.sharedTrust;
|
|
1045
|
+
await writeRawProjectConfigToDisk(projectID, personalNext);
|
|
1046
|
+
return mergedProjectSetupOf(projectID, personalNext);
|
|
1047
|
+
})
|
|
1048
|
+
);
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* The absolute repository plans folder of a project: `plansDir` from the
|
|
1052
|
+
* shared file when set, else the default `.aiworker/plans`. Setting
|
|
1053
|
+
* `plansDir` replaces the default outright (nothing is read from it any
|
|
1054
|
+
* more); moving files between the two is the user's job. Null only when the
|
|
1055
|
+
* checkout cannot be located.
|
|
1056
|
+
*/
|
|
1057
|
+
const resolveSharedPlansDir = async (projectID) => {
|
|
1058
|
+
const personalRaw = await readRawProjectConfigFromDisk(projectID);
|
|
1059
|
+
const projectPath = projectPathOf(projectID, personalRaw);
|
|
1060
|
+
if (!projectPath) return null;
|
|
1061
|
+
const shared = await readSharedProjectConfig(projectID, personalRaw);
|
|
1062
|
+
const relative = shared.status === 'ok' && shared.config.plansDir ? shared.config.plansDir : DEFAULT_PLANS_DIR;
|
|
1063
|
+
return path.join(projectPath, ...relative.split('/'));
|
|
1064
|
+
};
|
|
1065
|
+
|
|
908
1066
|
return {
|
|
1067
|
+
readProjectSetup,
|
|
1068
|
+
updateProjectSetup,
|
|
1069
|
+
updateSharedProjectSetup,
|
|
1070
|
+
resolveSharedPlansDir,
|
|
909
1071
|
listScheduledTasks,
|
|
910
1072
|
upsertScheduledTask,
|
|
911
1073
|
deleteScheduledTask,
|
|
@@ -3,6 +3,7 @@ import os from 'os';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
|
|
5
5
|
import { createProjectConfigRuntime } from './project-config.js';
|
|
6
|
+
import { createProjectIdFromPath } from './project-id.js';
|
|
6
7
|
|
|
7
8
|
const createRuntime = async () => {
|
|
8
9
|
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-scheduled-project-config-'));
|
|
@@ -175,6 +176,102 @@ describe('project-config runtime', () => {
|
|
|
175
176
|
});
|
|
176
177
|
});
|
|
177
178
|
|
|
179
|
+
describe('project-config file naming', () => {
|
|
180
|
+
const nightly = {
|
|
181
|
+
name: 'nightly',
|
|
182
|
+
enabled: true,
|
|
183
|
+
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
|
|
184
|
+
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
it('names the file by the id while the id fits a file name', async () => {
|
|
188
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
189
|
+
try {
|
|
190
|
+
const projectID = createProjectIdFromPath('/Users/someone/projects/demo');
|
|
191
|
+
expect(runtime.resolveProjectConfigPath(projectID)).toBe(path.join(tempRoot, `${projectID}.json`));
|
|
192
|
+
} finally {
|
|
193
|
+
await cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('stores a project whose path is too long for a file name under a bounded name', async () => {
|
|
198
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
199
|
+
try {
|
|
200
|
+
// ~190 characters, like a scratchpad checkout nested under a worktree;
|
|
201
|
+
// the base64url id is longer than the 255-byte file name limit allows.
|
|
202
|
+
const projectPath = `/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`;
|
|
203
|
+
expect(projectPath.length).toBeGreaterThan(180);
|
|
204
|
+
const projectID = createProjectIdFromPath(projectPath);
|
|
205
|
+
expect(projectID.length).toBeGreaterThan(240);
|
|
206
|
+
|
|
207
|
+
const filePath = runtime.resolveProjectConfigPath(projectID);
|
|
208
|
+
const fileName = path.basename(filePath);
|
|
209
|
+
expect(path.dirname(filePath)).toBe(tempRoot);
|
|
210
|
+
expect(fileName.startsWith('path_sha256_')).toBe(true);
|
|
211
|
+
expect(fileName.length).toBeLessThan(100);
|
|
212
|
+
// The same id always maps to the same file.
|
|
213
|
+
expect(runtime.resolveProjectConfigPath(projectID)).toBe(filePath);
|
|
214
|
+
|
|
215
|
+
// The whole write path (lock file, temp file, rename) works on a real filesystem.
|
|
216
|
+
const created = await runtime.upsertScheduledTask(projectID, nightly);
|
|
217
|
+
expect(created.created).toBe(true);
|
|
218
|
+
const setup = await runtime.updateProjectSetup(projectID, { setupWorktree: ['bun install'] });
|
|
219
|
+
expect(setup.setupWorktree).toEqual(['bun install']);
|
|
220
|
+
|
|
221
|
+
const raw = JSON.parse(await readFile(filePath, 'utf8'));
|
|
222
|
+
expect(raw.scheduledTasks).toHaveLength(1);
|
|
223
|
+
expect(raw['setup-worktree']).toEqual(['bun install']);
|
|
224
|
+
expect(await runtime.listScheduledTasks(projectID)).toHaveLength(1);
|
|
225
|
+
expect((await runtime.readProjectSetup(projectID)).setupWorktree).toEqual(['bun install']);
|
|
226
|
+
} finally {
|
|
227
|
+
await cleanup();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('reads a long id from its pre-bound file name and moves it on the next write', async () => {
|
|
232
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
233
|
+
try {
|
|
234
|
+
// Long enough for the bounded name, short enough that an older build
|
|
235
|
+
// could still have written `<id>.json` on this filesystem.
|
|
236
|
+
const projectID = `path_${'a'.repeat(200)}`;
|
|
237
|
+
const legacyPath = path.join(tempRoot, `${projectID}.json`);
|
|
238
|
+
const currentPath = runtime.resolveProjectConfigPath(projectID);
|
|
239
|
+
expect(currentPath).not.toBe(legacyPath);
|
|
240
|
+
await writeFile(legacyPath, JSON.stringify({
|
|
241
|
+
version: 1,
|
|
242
|
+
scheduledTasks: [{ id: 'task-legacy', ...nightly, state: { createdAt: 1, updatedAt: 2, lastStatus: 'idle' } }],
|
|
243
|
+
projectNotes: 'keep me',
|
|
244
|
+
}), 'utf8');
|
|
245
|
+
|
|
246
|
+
const tasks = await runtime.listScheduledTasks(projectID);
|
|
247
|
+
expect(tasks.map((task) => task.id)).toEqual(['task-legacy']);
|
|
248
|
+
|
|
249
|
+
await runtime.upsertScheduledTask(projectID, { ...nightly, name: 'weekly digest' });
|
|
250
|
+
|
|
251
|
+
const raw = JSON.parse(await readFile(currentPath, 'utf8'));
|
|
252
|
+
expect(raw.scheduledTasks.map((task) => task.id)).toEqual(['task-legacy', 'task-fixed-id']);
|
|
253
|
+
expect(raw.projectNotes).toBe('keep me');
|
|
254
|
+
await expect(readFile(legacyPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
255
|
+
expect((await runtime.listScheduledTasks(projectID)).map((task) => task.name)).toEqual(['nightly', 'weekly digest']);
|
|
256
|
+
} finally {
|
|
257
|
+
await cleanup();
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('treats a malformed pre-bound file as a failure, not as an empty project', async () => {
|
|
262
|
+
const { runtime, tempRoot, cleanup } = await createRuntime();
|
|
263
|
+
try {
|
|
264
|
+
const projectID = `path_${'b'.repeat(200)}`;
|
|
265
|
+
await writeFile(path.join(tempRoot, `${projectID}.json`), '{ not json', 'utf8');
|
|
266
|
+
await expect(runtime.listScheduledTasks(projectID)).rejects.toThrow();
|
|
267
|
+
await expect(runtime.upsertScheduledTask(projectID, nightly)).rejects.toThrow();
|
|
268
|
+
await expect(readFile(runtime.resolveProjectConfigPath(projectID), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
|
|
269
|
+
} finally {
|
|
270
|
+
await cleanup();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
178
275
|
describe('project-config loop reconciliation', () => {
|
|
179
276
|
const loop = (name, overrides = {}) => ({
|
|
180
277
|
scope: 'project',
|
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// The longest stem `<stem>.json` may have and still leave room, inside the
|
|
4
|
+
// 255-byte file name limit, for the atomic-write `.tmp-<pid>-<ms>-<random>`
|
|
5
|
+
// suffix and the `.json.lock` sibling. Ids are ASCII, so characters are bytes.
|
|
6
|
+
const MAX_PROJECT_CONFIG_FILE_STEM_LENGTH = 200;
|
|
7
|
+
const HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX = 'path_sha256_';
|
|
8
|
+
|
|
1
9
|
const normalizeProjectPathForId = (value) => {
|
|
2
10
|
if (typeof value !== 'string') return '';
|
|
3
11
|
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
|
|
@@ -11,3 +19,38 @@ export const createProjectIdFromPath = (projectPath) => {
|
|
|
11
19
|
|
|
12
20
|
return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`;
|
|
13
21
|
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The path a `path_<base64url>` id was made from, or `''` when the id is not
|
|
25
|
+
* of that form. The projects dir names files by this id, so the server can
|
|
26
|
+
* find the project's checkout (and the shared config inside it) from the id
|
|
27
|
+
* alone.
|
|
28
|
+
*/
|
|
29
|
+
export const projectPathFromId = (projectId) => {
|
|
30
|
+
if (typeof projectId !== 'string' || !projectId.startsWith('path_')) return '';
|
|
31
|
+
const encoded = projectId.slice('path_'.length);
|
|
32
|
+
if (!encoded || !/^[A-Za-z0-9_-]+$/.test(encoded)) return '';
|
|
33
|
+
try {
|
|
34
|
+
return Buffer.from(encoded, 'base64url').toString('utf8');
|
|
35
|
+
} catch {
|
|
36
|
+
return '';
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The stem of everything a project owns in the projects dir: the per-user
|
|
42
|
+
* config file `<projectsDir>/<stem>.json` and the sibling folder
|
|
43
|
+
* `<projectsDir>/<stem>/` (context, plans, memory). It is the id itself while
|
|
44
|
+
* that fits a file name. A `path_<base64url>` id grows with the checkout path,
|
|
45
|
+
* so a deeply nested project would otherwise get a name the filesystem
|
|
46
|
+
* rejects (ENAMETOOLONG); such an id maps to a fixed-length digest instead.
|
|
47
|
+
* The digest keeps the `path_` prefix so the orphan recovery in
|
|
48
|
+
* `opencode/settings-runtime.js` never mistakes the file for a leftover of the
|
|
49
|
+
* random-id era. The VS Code extension host applies the same rule
|
|
50
|
+
* (`packages/vscode/src/bridge-project-setup-runtime.ts`); keep the two in sync.
|
|
51
|
+
*/
|
|
52
|
+
export const projectConfigFileStemOf = (projectId) => {
|
|
53
|
+
if (projectId.length <= MAX_PROJECT_CONFIG_FILE_STEM_LENGTH) return projectId;
|
|
54
|
+
const digest = crypto.createHash('sha256').update(projectId, 'utf8').digest('hex');
|
|
55
|
+
return `${HASHED_PROJECT_CONFIG_FILE_STEM_PREFIX}${digest}`;
|
|
56
|
+
};
|