@aiworker/web 1.23.0 → 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/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-teB3HmBT.js} +1 -1
- package/dist/assets/DiffView-DC9RX3MF.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-DwPkzXsS.js} +1 -1
- package/dist/assets/FilesView-BFZkuu4O.js +13 -0
- package/dist/assets/{GitView-BXsS3jG6.js → GitView-B_x52MHY.js} +4 -4
- package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-cmAzUTiw.js} +1 -1
- package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-Dehh5afm.js} +1 -1
- package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-DgNOqqhE.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DusmMZ_p.js} +2 -2
- package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-B8fm1H4m.js} +1 -1
- package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-BVr7gJU0.js} +1 -1
- package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-DzLt-nel.js} +1 -1
- package/dist/assets/PlanView-BmQUYT_z.js +5 -0
- package/dist/assets/SettingsView-ChWmAcZT.js +28 -0
- package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-Dy3M6ZB9.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-cTl_eLIH.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-C_3iYNJQ.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-CAWthkoy.js} +4 -4
- package/dist/assets/WalkthroughView-C7AHOpCA.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-BkiDSlTW.js} +2 -2
- 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/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-jXSK5RrB.js → index-n6O3hklM.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-GMdPz-71.js} +1 -1
- package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-BVuH1zH9.js} +1 -1
- package/dist/assets/ja-BzR9xOY_.js +16 -0
- package/dist/assets/{ko-idhXGF8j.js → ko-BEjHAm14.js} +3 -3
- package/dist/assets/main-BydQ1tr_.js +930 -0
- package/dist/assets/{main-CzdPuL0s.js → main-D3ZDQglc.js} +2 -2
- package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-Cb2MdZnV.js} +3 -3
- package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-d01k801U.js} +2 -2
- package/dist/assets/{mobile-mvxTo055.js → mobile-D7VV-Ufz.js} +2 -2
- package/dist/assets/patchFileDiff-CdztVMz0.js +13 -0
- package/dist/assets/{pl-CGZWUqZJ.js → pl-rQmpxNIF.js} +3 -3
- 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-CRl0SUC9.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-CPKeOpUo.js → useAppFontEffects-Cwyhjfl5.js} +153 -155
- package/dist/assets/useGitComparison-BCZVefdE.js +1 -0
- package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-DDkpBEU8.js} +2 -2
- package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-CwjoWuC4.js} +1 -1
- package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-Cb1YnICj.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
- package/dist/assets/{web-components-z7hfnS8n.js → web-components-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 +4 -4
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +1 -1
- package/server/index.js +3 -13
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +60 -0
- package/server/lib/git/DOCUMENTATION.md +10 -1
- package/server/lib/git/routes.js +21 -8
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +159 -4
- package/server/lib/git/service.test.js +74 -0
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/message-queue/DOCUMENTATION.md +7 -0
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +18 -4
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/settings-helpers.js +3 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -1
- package/server/lib/opencode/settings-registry.json +3 -0
- package/server/lib/opencode/settings-runtime.js +36 -9
- package/server/lib/opencode/settings-runtime.test.js +158 -2
- package/server/lib/project-context/DOCUMENTATION.md +13 -3
- package/server/lib/project-context/runtime.js +8 -2
- package/server/lib/project-context/runtime.test.js +54 -0
- package/server/lib/projects/DOCUMENTATION.md +2 -1
- package/server/lib/projects/project-config.js +38 -9
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +26 -0
- package/server/lib/relay/DOCUMENTATION.md +20 -0
- package/server/lib/relay/host-client.js +7 -0
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +111 -257
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +13 -2
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-DXjVsEOw.js +0 -4
- package/dist/assets/FilesView-CJdG0-wk.js +0 -17
- package/dist/assets/PlanView-CJTmOF6I.js +0 -5
- package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
- package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
- package/dist/assets/de-DCrsQ5zS.js +0 -10
- package/dist/assets/es-Dwv4YfH6.js +0 -16
- package/dist/assets/fr-BCWg4Cnx.js +0 -14
- package/dist/assets/index-B68h5S5B.css +0 -1
- package/dist/assets/index-DVwMarHI.js +0 -39
- package/dist/assets/ja-Bruw3RYC.js +0 -16
- package/dist/assets/main-BkyaQhJX.js +0 -930
- package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
- package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
- package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
- package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
- package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
- package/dist/assets/surface-oMvhsog3.js +0 -1
- package/dist/assets/tr-BK_KiO2V.js +0 -7
- package/dist/assets/uk-CG74xV8N.js +0 -16
- package/dist/assets/useGitComparison-DH220WmO.js +0 -1
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/zh-CN-T4AXofZI.js +0 -16
- package/dist/assets/zh-TW-Dte40sxp.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
|
@@ -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';
|
|
@@ -200,6 +201,59 @@ describe('legacy migration', () => {
|
|
|
200
201
|
});
|
|
201
202
|
});
|
|
202
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
|
+
|
|
203
257
|
describe('todos', () => {
|
|
204
258
|
test('round-trips through disk', async () => {
|
|
205
259
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
|
|
@@ -63,7 +63,7 @@ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
|
|
|
63
63
|
|
|
64
64
|
## Modules
|
|
65
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.
|
|
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
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
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
69
|
- `routes.js` — the setup routes. `/api/projects` is on the JSON-body allowlist in `opencode/core-routes.js`.
|
|
@@ -72,5 +72,6 @@ UI (`packages/ui/src/lib/sharedTrustConfirmation.ts`).
|
|
|
72
72
|
|
|
73
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
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.
|
|
75
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.
|
|
76
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,7 +1,7 @@
|
|
|
1
1
|
import { DateTime, IANAZone } from 'luxon';
|
|
2
2
|
import parser from 'cron-parser';
|
|
3
3
|
|
|
4
|
-
import { projectPathFromId } from './project-id.js';
|
|
4
|
+
import { projectConfigFileStemOf, projectPathFromId } from './project-id.js';
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_PLANS_DIR,
|
|
7
7
|
EMPTY_SHARED_PROJECT_CONFIG,
|
|
@@ -399,6 +399,15 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
399
399
|
|
|
400
400
|
const resolveProjectConfigPath = (projectID) => {
|
|
401
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;
|
|
402
411
|
return path.join(projectsDirPath, `${safeProjectID}.json`);
|
|
403
412
|
};
|
|
404
413
|
|
|
@@ -500,18 +509,31 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
500
509
|
throw new Error(`timeout acquiring project config lock for ${projectID}`);
|
|
501
510
|
};
|
|
502
511
|
|
|
503
|
-
|
|
504
|
-
|
|
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;
|
|
505
517
|
try {
|
|
506
|
-
|
|
507
|
-
const parsed = JSON.parse(raw);
|
|
508
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
518
|
+
raw = await fsPromises.readFile(filePath, 'utf8');
|
|
509
519
|
} catch (error) {
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
520
|
+
const code = error && typeof error === 'object' ? error.code : null;
|
|
521
|
+
if (missingCodes.includes(code)) return null;
|
|
513
522
|
throw error;
|
|
514
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 ?? {};
|
|
515
537
|
};
|
|
516
538
|
|
|
517
539
|
// Normalized tasks for reading, plus the raw on-disk record of each one for
|
|
@@ -578,6 +600,13 @@ export const createProjectConfigRuntime = (deps) => {
|
|
|
578
600
|
await fsPromises.rm(temporaryPath, { force: true }).catch(() => {});
|
|
579
601
|
throw error;
|
|
580
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
|
+
}
|
|
581
610
|
};
|
|
582
611
|
|
|
583
612
|
const writeProjectConfigToDisk = async (projectID, config) => {
|
|
@@ -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;
|
|
@@ -28,3 +36,21 @@ export const projectPathFromId = (projectId) => {
|
|
|
28
36
|
return '';
|
|
29
37
|
}
|
|
30
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
|
+
};
|
|
@@ -97,6 +97,26 @@ It does not replace a physical iOS/LTE check.
|
|
|
97
97
|
|
|
98
98
|
## Authentication model
|
|
99
99
|
|
|
100
|
+
### Self-reported app diagnostics
|
|
101
|
+
|
|
102
|
+
Host-control, host-data and client upgrades include optional `appId`,
|
|
103
|
+
`appVersion` and `platform` query parameters. AiWorker sends `aiworker`,
|
|
104
|
+
the local package version and the local runtime. Client platforms use the
|
|
105
|
+
existing presence values: `desktop` for Electron, `vscode`, `ios`, `android`
|
|
106
|
+
and `web` for browsers, including hosted mobile. Hosts use
|
|
107
|
+
`AIWORKER_RUNTIME`, defaulting to `web`, so SSH hosts report `ssh-remote`.
|
|
108
|
+
These labels describe the connecting process, not its remote target.
|
|
109
|
+
|
|
110
|
+
These fields are diagnostics only, outside the signed auth payload. Older
|
|
111
|
+
relays ignore them and older apps remain connectable. The hosted relay groups
|
|
112
|
+
missing or invalid fields as `unknown`. Its analytics count host-control and
|
|
113
|
+
client connections separately, including reconnects, and show labels as
|
|
114
|
+
self-reported, never as verified app identity. Host-data sockets are excluded.
|
|
115
|
+
App diagnostics are written on connection, without per-app traffic counters.
|
|
116
|
+
No credentials, device names or application contents are added to diagnostics.
|
|
117
|
+
|
|
118
|
+
### Access checks
|
|
119
|
+
|
|
100
120
|
- The tunnel is **transport only**. The AiWorker server still authenticates every tunneled request exactly as it authenticates a direct remote client. The relay path grants reachability, not authorization.
|
|
101
121
|
- Clients carry their normal credential. HTTP and SSE requests authenticate with the client's bearer token (a header). **WebSocket upgrades cannot send headers**, so they authenticate with a short-lived URL-scoped token minted beforehand and passed as a query parameter. This asymmetry is important when adding new WebSocket features (see the skill).
|
|
102
122
|
- The host authenticates itself to the relay with a signed handshake using its long-lived signing key.
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// dispatcher. Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 1).
|
|
5
5
|
|
|
6
6
|
import { WebSocket } from 'ws';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
7
8
|
|
|
8
9
|
import { RELAY_PROTOCOL_VERSION, RelayCloseCode, createHostHandshake } from './e2ee.js';
|
|
9
10
|
import { createOutboundFrameBatcher, decodeFrameBatch, decodeTunnelFrame, decodeDeliveryAck, encodeFrameBatch, TunnelFrameType } from './tunnel-codec.js';
|
|
@@ -50,6 +51,8 @@ const resolveBatchWindowMs = (option) => {
|
|
|
50
51
|
* }} options
|
|
51
52
|
*/
|
|
52
53
|
export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, onStatus, logger = console, batchWindowMs, batch, flowControl }) => {
|
|
54
|
+
const { version } = createRequire(import.meta.url)('../../../package.json');
|
|
55
|
+
const platform = process.env.AIWORKER_RUNTIME || 'web';
|
|
53
56
|
const resolveLocalPort = typeof getLocalPort === 'function' ? getLocalPort : () => localPort;
|
|
54
57
|
const localBatch = batch !== false;
|
|
55
58
|
const resolvedBatchWindowMs = resolveBatchWindowMs(batchWindowMs);
|
|
@@ -82,6 +85,10 @@ export const startRelayHost = ({ relayUrl, identity, localPort, getLocalPort, on
|
|
|
82
85
|
url.searchParams.set('v', String(RELAY_PROTOCOL_VERSION));
|
|
83
86
|
url.searchParams.set('role', role);
|
|
84
87
|
url.searchParams.set('serverId', identity.serverId);
|
|
88
|
+
// Self-reported diagnostics, not part of relay authentication.
|
|
89
|
+
url.searchParams.set('appId', 'aiworker');
|
|
90
|
+
url.searchParams.set('appVersion', version);
|
|
91
|
+
url.searchParams.set('platform', platform);
|
|
85
92
|
if (connectionId) url.searchParams.set('connectionId', connectionId);
|
|
86
93
|
const auth = identity.signRelayAuth(role, connectionId ?? null);
|
|
87
94
|
url.searchParams.set('ts', String(auth.ts));
|
|
@@ -39,11 +39,17 @@ const startFakeRelay = () => {
|
|
|
39
39
|
clients: new Map(), // connectionId -> ws
|
|
40
40
|
buffered: new Map(), // connectionId -> [[data, isBinary]] awaiting host-data
|
|
41
41
|
relayFrames: [], // observed forwarded frames (for plaintext assertions)
|
|
42
|
+
apps: new Map(),
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
wss.on('connection', (ws, req) => {
|
|
45
46
|
const url = new URL(req.url, 'http://localhost');
|
|
46
47
|
const role = url.searchParams.get('role');
|
|
48
|
+
state.apps.set(role, {
|
|
49
|
+
appId: url.searchParams.get('appId'),
|
|
50
|
+
appVersion: url.searchParams.get('appVersion'),
|
|
51
|
+
platform: url.searchParams.get('platform'),
|
|
52
|
+
});
|
|
47
53
|
const connectionId = url.searchParams.get('connectionId');
|
|
48
54
|
|
|
49
55
|
if (role === 'host-control') {
|
|
@@ -101,6 +107,7 @@ const startFakeRelay = () => {
|
|
|
101
107
|
// A socket a failed test left open would hold server.close() until
|
|
102
108
|
// the hook timeout; drop them so a failure is reported once.
|
|
103
109
|
for (const client of wss.clients) client.terminate();
|
|
110
|
+
server.closeAllConnections();
|
|
104
111
|
wss.close();
|
|
105
112
|
server.close(() => r());
|
|
106
113
|
}),
|
|
@@ -286,6 +293,13 @@ describe('relay host-client integration', () => {
|
|
|
286
293
|
});
|
|
287
294
|
|
|
288
295
|
expect(result.status).toBe(200);
|
|
296
|
+
for (const role of ['host-control', 'host-data']) {
|
|
297
|
+
expect(relay.state.apps.get(role)).toEqual({
|
|
298
|
+
appId: 'aiworker',
|
|
299
|
+
appVersion: expect.stringMatching(/^\d+\.\d+\.\d+/),
|
|
300
|
+
platform: process.env.AIWORKER_RUNTIME || 'web',
|
|
301
|
+
});
|
|
302
|
+
}
|
|
289
303
|
expect(result.body.ok).toBe(true);
|
|
290
304
|
expect(result.body.relayConn).toBe('conn-test-1');
|
|
291
305
|
expect(result.body.origin).toBe(`http://127.0.0.1:${origin.port}`);
|
|
@@ -25,6 +25,12 @@ in shared project config under the project write lock:
|
|
|
25
25
|
from the winner's persisted `nextRunAt`.
|
|
26
26
|
- Project config writes also take a cross-process `.json.lock` file so the
|
|
27
27
|
read-modify-write is serialized across processes, not only within one process.
|
|
28
|
+
- `syncAllProjects` (startup and every full resync) syncs each registered
|
|
29
|
+
project on its own: a project whose config cannot be read or written (a
|
|
30
|
+
broken file, a lock timeout) is logged with its id and skipped, and every
|
|
31
|
+
other project's tasks are still scheduled. Only `listProjects` failing
|
|
32
|
+
aborts the sync as a whole. `syncProject` for one project still throws, so
|
|
33
|
+
a route for that project reports the failure.
|
|
28
34
|
- The sharing processes may run different AiWorker versions. Normalization
|
|
29
35
|
keeps only the fields a build knows, so every writer persists tasks it did
|
|
30
36
|
not change verbatim from disk and swaps only `state` onto a task whose state
|
|
@@ -63,7 +69,9 @@ in shared project config under the project write lock:
|
|
|
63
69
|
- Claiming always writes `nextRunAt` (including `undefined`) so a past once-slot
|
|
64
70
|
is cleared when there is no following occurrence.
|
|
65
71
|
|
|
66
|
-
Manual `runNow` does not claim a schedule occurrence.
|
|
72
|
+
Manual `runNow` does not claim a schedule occurrence. It also runs paused
|
|
73
|
+
(`enabled: false`) tasks — that is the point of the button — while scheduled
|
|
74
|
+
dispatches still skip disabled tasks, and completion never re-arms a paused task.
|
|
67
75
|
|
|
68
76
|
## Files
|
|
69
77
|
|
|
@@ -485,4 +485,32 @@ describe('issue 2710: daily scheduled task double execution at the configured ti
|
|
|
485
485
|
|
|
486
486
|
runtime.stop();
|
|
487
487
|
});
|
|
488
|
+
|
|
489
|
+
it('manual runNow runs a paused task but does not schedule future runs', async () => {
|
|
490
|
+
vi.setSystemTime(UTC(2026, 0, 1, 14, 0, 0));
|
|
491
|
+
const task = { ...makeTask({ kind: 'daily', times: ['15:00'] }), enabled: false };
|
|
492
|
+
const projectConfigRuntime = createSharedProjectConfigRuntime(task);
|
|
493
|
+
|
|
494
|
+
const runtime = createScheduledTasksRuntime(createRuntimeDeps(projectConfigRuntime));
|
|
495
|
+
await runtime.start();
|
|
496
|
+
|
|
497
|
+
// The scheduler must not fire a disabled task on its own.
|
|
498
|
+
await vi.advanceTimersByTimeAsync(HOUR + 3_000);
|
|
499
|
+
expect(sdk.sessionCreates.length).toBe(0);
|
|
500
|
+
|
|
501
|
+
const manual = await runtime.runNow('p1', 'task-1');
|
|
502
|
+
expect(manual.ok).toBe(true);
|
|
503
|
+
expect(manual.sessionID).toBeTruthy();
|
|
504
|
+
expect(sdk.sessionCreates.length).toBe(1);
|
|
505
|
+
expect(runtime.getStatus().runningScheduledTasksCount).toBe(0);
|
|
506
|
+
|
|
507
|
+
// Completion records state but leaves the task paused with no next run.
|
|
508
|
+
const tasks = await projectConfigRuntime.listScheduledTasks('p1');
|
|
509
|
+
expect(tasks[0].enabled).toBe(false);
|
|
510
|
+
expect(tasks[0].state.lastStatus).toBe('success');
|
|
511
|
+
expect(tasks[0].state.lastSessionId).toBe('sess-1');
|
|
512
|
+
expect(tasks[0].state.nextRunAt).toBeUndefined();
|
|
513
|
+
|
|
514
|
+
runtime.stop();
|
|
515
|
+
});
|
|
488
516
|
});
|
|
@@ -426,7 +426,16 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
426
426
|
}
|
|
427
427
|
|
|
428
428
|
for (const projectID of activeProjectIDs) {
|
|
429
|
-
|
|
429
|
+
try {
|
|
430
|
+
await syncProject(projectID);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
// One project's config being unusable (a broken file, a lock timeout)
|
|
433
|
+
// must not keep every other project's tasks from being scheduled.
|
|
434
|
+
logger.warn?.('[ScheduledTasks] failed to sync project', {
|
|
435
|
+
projectID,
|
|
436
|
+
error: error?.message ?? String(error),
|
|
437
|
+
});
|
|
438
|
+
}
|
|
430
439
|
}
|
|
431
440
|
};
|
|
432
441
|
|
|
@@ -676,7 +685,8 @@ export const createScheduledTasksRuntime = (deps) => {
|
|
|
676
685
|
const runTask = async (projectID, taskID, reason, scheduledFor) => {
|
|
677
686
|
const taskMap = tasksByProject.get(projectID);
|
|
678
687
|
const task = taskMap?.get(taskID);
|
|
679
|
-
|
|
688
|
+
// Manual runNow runs paused tasks too; only scheduled dispatches skip them.
|
|
689
|
+
if (!task || (reason !== 'manual' && !task.enabled)) {
|
|
680
690
|
return { ok: false, skipped: true };
|
|
681
691
|
}
|
|
682
692
|
|
|
@@ -206,3 +206,56 @@ Run daily.
|
|
|
206
206
|
}
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
describe('scheduled-tasks runtime syncAllProjects', () => {
|
|
211
|
+
it('keeps scheduling the other projects when one project cannot be synced', async () => {
|
|
212
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-sync-all-'));
|
|
213
|
+
try {
|
|
214
|
+
const brokenPath = path.join(tempRoot, 'broken');
|
|
215
|
+
const healthyPath = path.join(tempRoot, 'healthy');
|
|
216
|
+
await mkdir(path.join(healthyPath, '.agents', 'loops'), { recursive: true });
|
|
217
|
+
await mkdir(brokenPath, { recursive: true });
|
|
218
|
+
await writeFile(path.join(healthyPath, '.agents', 'loops', 'daily.md'), `---
|
|
219
|
+
name: daily
|
|
220
|
+
schedule: "0 9 * * *"
|
|
221
|
+
enabled: true
|
|
222
|
+
model: openai/gpt-5
|
|
223
|
+
---
|
|
224
|
+
Run daily.
|
|
225
|
+
`, 'utf8');
|
|
226
|
+
|
|
227
|
+
const projectConfigRuntime = createProjectConfigRuntime({
|
|
228
|
+
fsPromises: await import('fs/promises'),
|
|
229
|
+
path,
|
|
230
|
+
projectsDirPath: path.join(tempRoot, 'config'),
|
|
231
|
+
createTaskID: () => 'task-fixed-id',
|
|
232
|
+
});
|
|
233
|
+
await mkdir(path.join(tempRoot, 'config'), { recursive: true });
|
|
234
|
+
await writeFile(projectConfigRuntime.resolveProjectConfigPath('broken'), '{ not json', 'utf8');
|
|
235
|
+
|
|
236
|
+
const warnings = [];
|
|
237
|
+
const runtime = createScheduledTasksRuntime({
|
|
238
|
+
buildOpenCodeUrl: () => 'http://localhost',
|
|
239
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
240
|
+
waitForOpenCodeReady: async () => {},
|
|
241
|
+
projectConfigRuntime,
|
|
242
|
+
listProjects: async () => [
|
|
243
|
+
{ id: 'broken', path: brokenPath },
|
|
244
|
+
{ id: 'healthy', path: healthyPath },
|
|
245
|
+
],
|
|
246
|
+
logger: { warn: (...args) => warnings.push(args) },
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
await expect(runtime.start()).resolves.toBeUndefined();
|
|
250
|
+
|
|
251
|
+
expect(runtime.getStatus().enabledScheduledTasksCount).toBe(1);
|
|
252
|
+
const healthyTasks = await projectConfigRuntime.listScheduledTasks('healthy');
|
|
253
|
+
expect(healthyTasks.map((task) => task.id)).toEqual(['loop:project:daily']);
|
|
254
|
+
expect(warnings).toHaveLength(1);
|
|
255
|
+
expect(warnings[0][1]).toMatchObject({ projectID: 'broken' });
|
|
256
|
+
runtime.stop();
|
|
257
|
+
} finally {
|
|
258
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|