@aiworker/web 1.23.0 → 1.23.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CodeMirrorCommentWidgets-B1Gizi3a.js → CodeMirrorCommentWidgets-COGJG0Iv.js} +1 -1
- package/dist/assets/DiffView-BviYY3zL.js +4 -0
- package/dist/assets/{DirtyBranchSwitchDialog-aig6zJjg.js → DirtyBranchSwitchDialog-C26DvfVJ.js} +1 -1
- package/dist/assets/FilesView-D0ZU_nCt.js +13 -0
- package/dist/assets/{GitView-BXsS3jG6.js → GitView-CYgiDRH-.js} +4 -4
- package/dist/assets/{JsonTreeView-D_hpXBnF.js → JsonTreeView-GsV9UScz.js} +1 -1
- package/dist/assets/{LinearIssuesView-BoUiLQG1.js → LinearIssuesView-shqUS8nl.js} +1 -1
- package/dist/assets/{MarkdownImageGallery-B_B_a9KS.js → MarkdownImageGallery-CQSTFp6m.js} +1 -1
- package/dist/assets/{MarkdownRendererImpl-xgyyLiBM.js → MarkdownRendererImpl-DJWP3A8X.js} +2 -2
- package/dist/assets/{OnboardingScreen-DIp84OWt.js → OnboardingScreen-CTuu4lgo.js} +1 -1
- package/dist/assets/{PatchDiff-8p6nLJaU.js → PatchDiff-Dp53v2pl.js} +1 -1
- package/dist/assets/{PierreDiffViewer-DjC8nTk9.js → PierreDiffViewer-B5l0djPS.js} +1 -1
- package/dist/assets/PlanView-CLUWkGiG.js +5 -0
- package/dist/assets/SettingsView-CojPQVHx.js +28 -0
- package/dist/assets/{SettingsWindow-Cpb4hM6q.js → SettingsWindow-DbgEnZ6T.js} +1 -1
- package/dist/assets/{ToolOutputDialog-DfaKZXNU.js → ToolOutputDialog-Du-RZj-a.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-DtZLzrSK.js → ToolPartDiffPreview-HBLSokoe.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-C8i9HpTB.js → VirtualizedFileDiff-Bzp3hClU.js} +4 -4
- package/dist/assets/WalkthroughView-CpgA4s9w.js +4 -0
- package/dist/assets/{computeVirtualFileMetrics-BBI7ZwrC.js → computeVirtualFileMetrics-CI1PC_z2.js} +2 -2
- package/dist/assets/de-BX8b4hDD.js +10 -0
- package/dist/assets/es-e98cS-yM.js +16 -0
- package/dist/assets/fr-BOiqxJAs.js +14 -0
- package/dist/assets/index-BD8ILbxF.js +40 -0
- package/dist/assets/{index-jXSK5RrB.js → index-Cmn74sw-.js} +1 -1
- package/dist/assets/index-zhgg-SDz.css +1 -0
- package/dist/assets/{isDiffPlainText-B_x_rVon.js → isDiffPlainText-DVralkU-.js} +1 -1
- package/dist/assets/{isFilePlainText-DW_xMer1.js → isFilePlainText-CgTREsRX.js} +1 -1
- package/dist/assets/ja-aoO55hGu.js +16 -0
- package/dist/assets/{ko-idhXGF8j.js → ko-BGzFLogk.js} +3 -3
- package/dist/assets/{main-CzdPuL0s.js → main-BgfXQ-4g.js} +2 -2
- package/dist/assets/main-DGYUWmIt.js +930 -0
- package/dist/assets/{markdownCore-Cn4Tyejo.js → markdownCore-BesaNs_G.js} +3 -3
- package/dist/assets/{miniChat-CCkqKOLp.js → miniChat-DHKXp-_I.js} +2 -2
- package/dist/assets/{mobile-mvxTo055.js → mobile-lXQ08VnX.js} +2 -2
- package/dist/assets/patchFileDiff-i1lTtNbn.js +13 -0
- package/dist/assets/{pl-CGZWUqZJ.js → pl-B5x19laq.js} +3 -3
- package/dist/assets/pt-BR-C3oU51Il.js +16 -0
- package/dist/assets/renderElectronMiniChatApp-CuO4ue6B.js +2 -0
- package/dist/assets/renderMobileApp-B3zFlHy4.js +25 -0
- package/dist/assets/runtimeEndpointReset-BLzrtrmj.js +41 -0
- package/dist/assets/scheduledTasksApi-BEuUF_cj.js +1 -0
- package/dist/assets/{shikiHighlight-CRl0SUC9.js → shikiHighlight-ctlcPvvx.js} +1 -1
- package/dist/assets/surface-COKb77Ua.js +1 -0
- package/dist/assets/tr-C0QQa7et.js +7 -0
- package/dist/assets/uk-CqLSe9wT.js +16 -0
- package/dist/assets/{useAppFontEffects-CPKeOpUo.js → useAppFontEffects-B6OfEypo.js} +153 -155
- package/dist/assets/useGitComparison-BXZXEAW6.js +1 -0
- package/dist/assets/{useInlineCommentController-BiWz4AVb.js → useInlineCommentController-CgbrpEWu.js} +2 -2
- package/dist/assets/{useRootScrollLock-BS4-kr7P.js → useRootScrollLock-Me41OjBT.js} +1 -1
- package/dist/assets/{vendor-base-ui-DdV8vhHn.js → vendor-base-ui-BY_5qla_.js} +1 -1
- package/dist/assets/{vendor-codemirror-language-data-Bg4AEeCr.js → vendor-codemirror-language-data-SJuFp0a0.js} +2 -2
- package/dist/assets/vendor-legendapp-list-BuBPBC3R.js +1 -0
- package/dist/assets/{vendor-zod-BPAANqvH.js → vendor-zod-B6551dwG.js} +2 -2
- package/dist/assets/{web-components-z7hfnS8n.js → web-components-D8GS8Ork.js} +1 -1
- package/dist/assets/zh-CN-iJEie4d6.js +16 -0
- package/dist/assets/zh-TW-CyslEwlK.js +16 -0
- package/dist/index.html +4 -4
- package/dist/mini-chat.html +4 -4
- package/dist/mobile.html +4 -4
- package/package.json +1 -1
- package/server/index.js +3 -13
- package/server/lib/agent-memory/runtime.js +7 -2
- package/server/lib/agent-memory/runtime.test.js +22 -0
- package/server/lib/event-stream/DOCUMENTATION.md +3 -0
- package/server/lib/event-stream/global-hub.js +29 -4
- package/server/lib/event-stream/global-hub.test.js +22 -0
- package/server/lib/event-stream/global-ws-bridge.js +15 -18
- package/server/lib/event-stream/protocol.js +21 -7
- package/server/lib/event-stream/runtime.js +9 -6
- package/server/lib/event-stream/runtime.test.js +60 -0
- package/server/lib/git/DOCUMENTATION.md +10 -1
- package/server/lib/git/routes.js +21 -8
- package/server/lib/git/routes.test.js +78 -0
- package/server/lib/git/service.js +159 -4
- package/server/lib/git/service.test.js +74 -0
- package/server/lib/github/DOCUMENTATION.md +5 -0
- package/server/lib/github/routes.js +2 -2
- package/server/lib/message-queue/DOCUMENTATION.md +7 -0
- package/server/lib/message-queue/runtime.js +13 -0
- package/server/lib/message-queue/runtime.test.js +31 -1
- package/server/lib/notifications/DOCUMENTATION.md +1 -1
- package/server/lib/notifications/emitter-runtime.js +4 -3
- package/server/lib/opencode/DOCUMENTATION.md +18 -4
- package/server/lib/opencode/env-runtime.js +5 -1
- package/server/lib/opencode/env-runtime.test.js +72 -0
- package/server/lib/opencode/feature-routes-runtime.js +15 -1
- package/server/lib/opencode/lifecycle.js +4 -3
- package/server/lib/opencode/lifecycle.test.js +29 -0
- package/server/lib/opencode/managed-plugin-config.js +1 -1
- package/server/lib/opencode/proxy.js +33 -0
- package/server/lib/opencode/settings-helpers.js +3 -0
- package/server/lib/opencode/settings-helpers.test.js +11 -1
- package/server/lib/opencode/settings-registry.json +3 -0
- package/server/lib/opencode/settings-runtime.js +36 -9
- package/server/lib/opencode/settings-runtime.test.js +158 -2
- package/server/lib/project-context/DOCUMENTATION.md +13 -3
- package/server/lib/project-context/runtime.js +8 -2
- package/server/lib/project-context/runtime.test.js +54 -0
- package/server/lib/projects/DOCUMENTATION.md +2 -1
- package/server/lib/projects/project-config.js +38 -9
- package/server/lib/projects/project-config.test.js +97 -0
- package/server/lib/projects/project-id.js +26 -0
- package/server/lib/relay/DOCUMENTATION.md +20 -0
- package/server/lib/relay/host-client.js +7 -0
- package/server/lib/relay/host-client.test.js +14 -0
- package/server/lib/scheduled-tasks/DOCUMENTATION.md +9 -1
- package/server/lib/scheduled-tasks/issue-2710-double-execution.test.js +28 -0
- package/server/lib/scheduled-tasks/runtime.js +12 -2
- package/server/lib/scheduled-tasks/runtime.test.js +53 -0
- package/server/lib/session-assist/DOCUMENTATION.md +131 -66
- package/server/lib/session-assist/context.js +127 -0
- package/server/lib/session-assist/context.test.js +132 -0
- package/server/lib/session-assist/prompt.js +54 -0
- package/server/lib/session-assist/runtime.js +111 -257
- package/server/lib/session-assist/runtime.test.js +203 -0
- package/server/lib/walkthrough/DOCUMENTATION.md +15 -5
- package/server/lib/walkthrough/pr-source.test.js +21 -0
- package/server/lib/walkthrough/pull-request.js +7 -4
- package/server/lib/walkthrough/pull-request.test.js +16 -0
- package/server/lib/walkthrough/routes.js +19 -0
- package/server/lib/walkthrough/routes.test.js +26 -0
- package/server/lib/walkthrough/sources.js +13 -2
- package/server/opencode-worktree-readiness.test.js +130 -0
- package/dist/assets/DiffView-DXjVsEOw.js +0 -4
- package/dist/assets/FilesView-CJdG0-wk.js +0 -17
- package/dist/assets/PlanView-CJTmOF6I.js +0 -5
- package/dist/assets/SettingsView-DD-JqIJx.js +0 -28
- package/dist/assets/WalkthroughView-DeKMvZlL.js +0 -4
- package/dist/assets/de-DCrsQ5zS.js +0 -10
- package/dist/assets/es-Dwv4YfH6.js +0 -16
- package/dist/assets/fr-BCWg4Cnx.js +0 -14
- package/dist/assets/index-B68h5S5B.css +0 -1
- package/dist/assets/index-DVwMarHI.js +0 -39
- package/dist/assets/ja-Bruw3RYC.js +0 -16
- package/dist/assets/main-BkyaQhJX.js +0 -930
- package/dist/assets/patchFileDiff-Csk4iNUi.js +0 -13
- package/dist/assets/pt-BR-B8V7u7tA.js +0 -16
- package/dist/assets/renderElectronMiniChatApp-BxoyujA5.js +0 -2
- package/dist/assets/renderMobileApp-B48CsP4r.js +0 -25
- package/dist/assets/runtimeEndpointReset-Bg5gx18H.js +0 -17
- package/dist/assets/scheduledTasksApi-BW0Etzwz.js +0 -1
- package/dist/assets/surface-oMvhsog3.js +0 -1
- package/dist/assets/tr-BK_KiO2V.js +0 -7
- package/dist/assets/uk-CG74xV8N.js +0 -16
- package/dist/assets/useGitComparison-DH220WmO.js +0 -1
- package/dist/assets/vendor-legendapp-list-gg7YLRW7.js +0 -1
- package/dist/assets/zh-CN-T4AXofZI.js +0 -16
- package/dist/assets/zh-TW-Dte40sxp.js +0 -16
- package/server/lib/mcp-reconnect/DOCUMENTATION.md +0 -55
- package/server/lib/mcp-reconnect/runtime.js +0 -122
- package/server/lib/mcp-reconnect/runtime.test.js +0 -140
package/dist/index.html
CHANGED
|
@@ -509,16 +509,16 @@
|
|
|
509
509
|
pointer-events: none;
|
|
510
510
|
}
|
|
511
511
|
</style>
|
|
512
|
-
<script type="module" crossorigin src="/assets/main-
|
|
512
|
+
<script type="module" crossorigin src="/assets/main-BgfXQ-4g.js"></script>
|
|
513
513
|
<link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
|
|
514
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
|
|
514
515
|
<link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
|
|
515
516
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
|
|
516
517
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
|
|
517
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-BPAANqvH.js">
|
|
518
518
|
<link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
|
|
519
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
519
|
+
<link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
|
|
520
520
|
<link rel="modulepreload" crossorigin href="/assets/contextPanelEmbeddedChat-BdA6bdky.js">
|
|
521
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
521
|
+
<link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
|
|
522
522
|
</head>
|
|
523
523
|
<body class="h-full bg-background text-foreground">
|
|
524
524
|
<div id="root" class="h-full">
|
package/dist/mini-chat.html
CHANGED
|
@@ -74,15 +74,15 @@
|
|
|
74
74
|
}
|
|
75
75
|
</style>
|
|
76
76
|
|
|
77
|
-
<script type="module" crossorigin src="/assets/miniChat-
|
|
77
|
+
<script type="module" crossorigin src="/assets/miniChat-DHKXp-_I.js"></script>
|
|
78
78
|
<link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
|
|
79
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
|
|
79
80
|
<link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
|
|
80
81
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
|
|
81
82
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
|
|
82
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-BPAANqvH.js">
|
|
83
83
|
<link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
|
|
84
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
85
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
84
|
+
<link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
|
|
85
|
+
<link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
|
|
86
86
|
</head>
|
|
87
87
|
<body class="h-full bg-background text-foreground">
|
|
88
88
|
<div id="root" class="h-full">
|
package/dist/mobile.html
CHANGED
|
@@ -63,15 +63,15 @@
|
|
|
63
63
|
})();
|
|
64
64
|
</script>
|
|
65
65
|
|
|
66
|
-
<script type="module" crossorigin src="/assets/mobile-
|
|
66
|
+
<script type="module" crossorigin src="/assets/mobile-lXQ08VnX.js"></script>
|
|
67
67
|
<link rel="modulepreload" crossorigin href="/assets/vendor-vite-runtime-DD1OZmLC.js">
|
|
68
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-B6551dwG.js">
|
|
68
69
|
<link rel="modulepreload" crossorigin href="/assets/vendor-scheduler-7OC5HNn7.js">
|
|
69
70
|
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BUCujlKS.js">
|
|
70
71
|
<link rel="modulepreload" crossorigin href="/assets/vendor-zustand-BAGL70zf.js">
|
|
71
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-zod-BPAANqvH.js">
|
|
72
72
|
<link rel="modulepreload" crossorigin href="/assets/vendor-opencode-sdk-BJ73u-bH.js">
|
|
73
|
-
<link rel="modulepreload" crossorigin href="/assets/index-
|
|
74
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
73
|
+
<link rel="modulepreload" crossorigin href="/assets/index-BD8ILbxF.js">
|
|
74
|
+
<link rel="stylesheet" crossorigin href="/assets/index-zhgg-SDz.css">
|
|
75
75
|
</head>
|
|
76
76
|
<body class="h-full bg-background text-foreground">
|
|
77
77
|
<div id="root" class="h-full"></div>
|
package/package.json
CHANGED
package/server/index.js
CHANGED
|
@@ -112,7 +112,6 @@ import { createDevServerScanner } from './lib/dev-servers/routes.js';
|
|
|
112
112
|
import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
|
|
113
113
|
import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
|
|
114
114
|
import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
|
|
115
|
-
import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js';
|
|
116
115
|
import { createAiWorkerSessionService } from './lib/openchamber-sessions/routes.js';
|
|
117
116
|
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
|
|
118
117
|
import { createAiWorkerControlService } from './lib/openchamber-control/service.js';
|
|
@@ -302,7 +301,6 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD
|
|
|
302
301
|
let notificationTemplateRuntime = null;
|
|
303
302
|
let agentToolRuntime = null;
|
|
304
303
|
let systemPromptRuntime = null;
|
|
305
|
-
let mcpReconnectRuntime = null;
|
|
306
304
|
|
|
307
305
|
const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
|
|
308
306
|
const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
|
|
@@ -1256,14 +1254,11 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
|
|
|
1256
1254
|
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
|
|
1257
1255
|
: {};
|
|
1258
1256
|
|
|
1259
|
-
// Each managed plugin appends itself to the config the previous one produced.
|
|
1260
|
-
let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
|
1261
1257
|
if (settings?.optimizeSystemPrompt === true) {
|
|
1262
|
-
|
|
1258
|
+
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
|
|
1259
|
+
Object.assign(managedEnv, await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
|
|
1263
1260
|
}
|
|
1264
|
-
|
|
1265
|
-
const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent);
|
|
1266
|
-
return { ...managedEnv, ...mcpReconnectEnv };
|
|
1261
|
+
return managedEnv;
|
|
1267
1262
|
},
|
|
1268
1263
|
});
|
|
1269
1264
|
|
|
@@ -1537,11 +1532,6 @@ async function main(options = {}) {
|
|
|
1537
1532
|
path,
|
|
1538
1533
|
dataDir: AIWORKER_DATA_DIR,
|
|
1539
1534
|
});
|
|
1540
|
-
mcpReconnectRuntime = createMcpReconnectRuntime({
|
|
1541
|
-
fsPromises,
|
|
1542
|
-
path,
|
|
1543
|
-
dataDir: AIWORKER_DATA_DIR,
|
|
1544
|
-
});
|
|
1545
1535
|
|
|
1546
1536
|
// Pairing transports advertised to the create-device dialog. LAN reachability is
|
|
1547
1537
|
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
|
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
* What the agent has learned and chose to keep, in two scopes:
|
|
5
5
|
*
|
|
6
6
|
* - **project** — `<projectsDir>/<projectId>/memory.json`. How this codebase
|
|
7
|
-
* works, what was decided, where things live.
|
|
7
|
+
* works, what was decided, where things live. `<projectId>` is the bounded
|
|
8
|
+
* stem `projectConfigFileStemOf` gives the id (the id itself up to 200
|
|
9
|
+
* characters, `path_sha256_<digest>` beyond), the same folder that holds
|
|
10
|
+
* `project-context`'s `context.json` and sits beside the config file.
|
|
8
11
|
* - **global** — `<userConfigRoot>/memory.json`. Who the user is and how they
|
|
9
12
|
* want to be worked with. It belongs to no project, so it cannot live under
|
|
10
13
|
* one.
|
|
@@ -51,6 +54,7 @@ const PROJECT_MEMORY_MAX_ITEMS = 200;
|
|
|
51
54
|
*/
|
|
52
55
|
const MEMORY_TYPES = new Set(['fact', 'preference', 'reference']);
|
|
53
56
|
|
|
57
|
+
import { projectConfigFileStemOf } from '../projects/project-id.js';
|
|
54
58
|
import { findThreatPattern } from './threat-patterns.js';
|
|
55
59
|
|
|
56
60
|
const PROJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]+$/;
|
|
@@ -211,7 +215,8 @@ export const createAgentMemoryRuntime = (deps) => {
|
|
|
211
215
|
return {
|
|
212
216
|
scope: 'project',
|
|
213
217
|
key: `project:${projectId}`,
|
|
214
|
-
|
|
218
|
+
// Same bounded folder name as `project-context` and the config file.
|
|
219
|
+
filePath: path.join(projectsDirPath, projectConfigFileStemOf(projectId), 'memory.json'),
|
|
215
220
|
};
|
|
216
221
|
}
|
|
217
222
|
throw new Error('scope is required');
|
|
@@ -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 { createAgentMemoryRuntime } from './runtime.js';
|
|
7
8
|
|
|
8
9
|
const PROJECT_ID = 'path_dGVzdA';
|
|
@@ -48,6 +49,27 @@ describe('scope resolution', () => {
|
|
|
48
49
|
await fsPromises.access(projectPath());
|
|
49
50
|
});
|
|
50
51
|
|
|
52
|
+
test('a project id too long for a folder name is stored under the bounded stem', async () => {
|
|
53
|
+
// A checkout nested deep enough that its `path_<base64url>` id is longer
|
|
54
|
+
// than a folder name may be; the store lands under the bounded stem the
|
|
55
|
+
// config file and project context use, instead of failing with ENAMETOOLONG.
|
|
56
|
+
const longProjectId = createProjectIdFromPath(`/private/tmp/claude-501/${'segment-'.repeat(18)}/scratchpad/evidence/demo-repo`);
|
|
57
|
+
const stem = projectConfigFileStemOf(longProjectId);
|
|
58
|
+
expect(longProjectId.length).toBeGreaterThan(240);
|
|
59
|
+
expect(stem.startsWith('path_sha256_')).toBe(true);
|
|
60
|
+
const target = { scope: 'project', projectId: longProjectId };
|
|
61
|
+
|
|
62
|
+
expect((await runtime.read(target)).entries).toEqual([]);
|
|
63
|
+
await runtime.create(target, { title: 'Uses bun', body: 'Tests run with bun test.' });
|
|
64
|
+
|
|
65
|
+
expect((await runtime.read(target)).entries.map((e) => e.title)).toEqual(['Uses bun']);
|
|
66
|
+
expect((await runtime.readAll(longProjectId)).project.map((e) => e.title)).toEqual(['Uses bun']);
|
|
67
|
+
await fsPromises.access(path.join(rootDir, 'config', 'projects', stem, 'memory.json'));
|
|
68
|
+
expect(await fsPromises.readdir(path.join(rootDir, 'config', 'projects'))).toEqual([stem]);
|
|
69
|
+
// A short id keeps naming its folder itself.
|
|
70
|
+
expect(runtime.resolveTarget(PROJECT).filePath).toBe(projectPath());
|
|
71
|
+
});
|
|
72
|
+
|
|
51
73
|
test('rejects an unknown scope', async () => {
|
|
52
74
|
await expect(runtime.read({ scope: 'nope' })).rejects.toThrow('scope is required');
|
|
53
75
|
});
|
|
@@ -24,6 +24,7 @@ This module contains the AiWorker message-stream WebSocket protocol and runtime
|
|
|
24
24
|
- `parseSseEventEnvelope(block)`: parses an SSE block into `{ eventId, directory, payload }`.
|
|
25
25
|
- `sendMessageStreamWsFrame(socket, payload)`: serializes and sends a JSON WS frame.
|
|
26
26
|
- `sendMessageStreamWsEvent(socket, payload, options)`: sends an event frame with optional `eventId` and `directory`.
|
|
27
|
+
- `serializeMessageStreamWsEvent(payload, options)` and `sendSerializedMessageStreamWsFrame(socket, frame)` separate encoding from per-socket delivery. Delivery retains ready-state and backpressure checks.
|
|
27
28
|
|
|
28
29
|
### Runtime helpers
|
|
29
30
|
- `createGlobalMessageStreamHub(...)`: creates a shared `/global/event` upstream SSE hub with event/status subscribers and bounded event-id replay.
|
|
@@ -40,12 +41,14 @@ This module contains the AiWorker message-stream WebSocket protocol and runtime
|
|
|
40
41
|
- AiWorker still fetches OpenCode upstream event streams over SSE.
|
|
41
42
|
- The web server creates one shared global message-stream hub. OpenCode watcher side effects and global WS clients subscribe to that hub, so there is one upstream `/global/event` SSE reader for both server-side processing and browser fan-out.
|
|
42
43
|
- The global hub keeps a bounded replay buffer keyed by SSE `eventId` so reconnecting browser clients can receive buffered events after their requested `Last-Event-ID`.
|
|
44
|
+
- Replay retains at most 2,048 events and 8 MiB of UTF-8 wire frames. It stores encoded frames rather than retaining parsed payloads as well. Encoding is shared with live WS fanout. An oversized event still reaches live clients in full, but clears the retained suffix so replay cannot cross its gap. A missing replay cursor returns `null`, distinct from a complete empty tail; the bridge sends `ready` with `replayReset: true` and no partial replay. The client retires its cursor and requests authoritative repair, including during the early-boot reconnect grace period. A stopped hub retains its bounded suffix for clients reconnecting after the last socket closed.
|
|
43
45
|
- Directory WS clients still attach one upstream `/event?directory=...` SSE reader per connection because directory streams are scoped.
|
|
44
46
|
- If an upstream SSE stream stalls after the browser WS is already ready, the reader aborts that upstream fetch and reconnects upstream with `Last-Event-ID`, keeping the browser WS alive when recovery is fast.
|
|
45
47
|
- When the shared global upstream reconnects after it was previously ready, the global WS bridge sends a fresh `ready` frame to already-ready browser clients. The browser treats this as a reconnect edge and can run scoped state repair without requiring the browser WS to close.
|
|
46
48
|
- Health checks are reserved for initial upstream connect failures and explicit upstream-unavailable responses, not for ordinary stall recovery on an already-established stream.
|
|
47
49
|
- Global synthetic events such as `aiworker:session-status`, `aiworker:session-activity`, `aiworker:notification`, and `aiworker:heartbeat` are preserved on the WS path, but heartbeat frames are emitted only while an upstream SSE stream is actively attached.
|
|
48
50
|
- Global UI broadcasts are fan-out capable across both SSE and WS clients.
|
|
51
|
+
- Global UI broadcasts serialize once per wire format, irrespective of client count. The SSE writer accepts an optional pre-serialized payload; isolated callers keep the two-argument contract. Failed or backpressured clients cannot block delivery to healthy clients.
|
|
49
52
|
- The reusable upstream reader centralizes SSE fetch/parsing/reconnect behavior for the WS runtime and OpenCode watcher. Additional event consumers should move to it only with parity tests for their lifecycle and error semantics.
|
|
50
53
|
- Browser transport concerns live in the WS bridge modules; server-side global stream ownership lives in `global-hub.js`.
|
|
51
54
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { createUpstreamSseReader } from './upstream-reader.js';
|
|
2
|
+
import { serializeMessageStreamWsEvent } from './protocol.js';
|
|
2
3
|
|
|
3
4
|
// Raised from 512 → 2048 to improve recovery after brief disconnects during
|
|
4
5
|
// long-running agent sessions where many events accumulate quickly.
|
|
5
6
|
const MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT = 2048;
|
|
7
|
+
const MESSAGE_STREAM_GLOBAL_REPLAY_BYTES = 8 * 1024 * 1024;
|
|
6
8
|
|
|
7
9
|
export function createGlobalMessageStreamHub({
|
|
8
10
|
buildOpenCodeUrl,
|
|
@@ -11,10 +13,16 @@ export function createGlobalMessageStreamHub({
|
|
|
11
13
|
upstreamStallTimeoutMs,
|
|
12
14
|
upstreamReconnectDelayMs,
|
|
13
15
|
replayLimit = MESSAGE_STREAM_GLOBAL_REPLAY_LIMIT,
|
|
16
|
+
replayByteLimit = MESSAGE_STREAM_GLOBAL_REPLAY_BYTES,
|
|
14
17
|
}) {
|
|
18
|
+
if (!Number.isSafeInteger(replayLimit) || replayLimit < 0 || !Number.isSafeInteger(replayByteLimit) || replayByteLimit < 0) {
|
|
19
|
+
throw new RangeError('Replay limits must be nonnegative safe integers');
|
|
20
|
+
}
|
|
15
21
|
const eventSubscribers = new Set();
|
|
16
22
|
const statusSubscribers = new Set();
|
|
17
23
|
const replay = [];
|
|
24
|
+
let replayBytes = 0;
|
|
25
|
+
let latestEventId;
|
|
18
26
|
|
|
19
27
|
let controller = null;
|
|
20
28
|
let reader = null;
|
|
@@ -45,11 +53,16 @@ export function createGlobalMessageStreamHub({
|
|
|
45
53
|
const directory =
|
|
46
54
|
typeof envelope?.directory === 'string' && envelope.directory.length > 0 ? envelope.directory : 'global';
|
|
47
55
|
const eventId = typeof envelope?.eventId === 'string' && envelope.eventId.length > 0 ? envelope.eventId : undefined;
|
|
56
|
+
let serializedFrame;
|
|
48
57
|
return {
|
|
49
58
|
envelope,
|
|
50
59
|
payload,
|
|
51
60
|
directory,
|
|
52
61
|
eventId,
|
|
62
|
+
serialize() {
|
|
63
|
+
serializedFrame ??= serializeMessageStreamWsEvent(payload, { directory, eventId });
|
|
64
|
+
return serializedFrame;
|
|
65
|
+
},
|
|
53
66
|
};
|
|
54
67
|
};
|
|
55
68
|
|
|
@@ -87,9 +100,20 @@ export function createGlobalMessageStreamHub({
|
|
|
87
100
|
onEvent(event) {
|
|
88
101
|
const normalized = normalizeEvent(event);
|
|
89
102
|
if (normalized.eventId) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
latestEventId = normalized.eventId;
|
|
104
|
+
const serializedFrame = normalized.serialize();
|
|
105
|
+
const bytes = Buffer.byteLength(serializedFrame);
|
|
106
|
+
if (bytes > replayByteLimit) {
|
|
107
|
+
// An oversized live event creates a hole: retain only a contiguous
|
|
108
|
+
// suffix after it, never replay an older prefix across the gap.
|
|
109
|
+
replay.length = 0;
|
|
110
|
+
replayBytes = 0;
|
|
111
|
+
} else {
|
|
112
|
+
replay.push({ eventId: normalized.eventId, serializedFrame, bytes });
|
|
113
|
+
replayBytes += bytes;
|
|
114
|
+
while (replay.length > replayLimit || replayBytes > replayByteLimit) {
|
|
115
|
+
replayBytes -= replay.shift().bytes;
|
|
116
|
+
}
|
|
93
117
|
}
|
|
94
118
|
}
|
|
95
119
|
|
|
@@ -152,7 +176,8 @@ export function createGlobalMessageStreamHub({
|
|
|
152
176
|
}
|
|
153
177
|
|
|
154
178
|
const index = replay.findIndex((entry) => entry.eventId === eventId);
|
|
155
|
-
|
|
179
|
+
if (eventId === latestEventId) return [];
|
|
180
|
+
return index === -1 ? null : replay.slice(index + 1);
|
|
156
181
|
},
|
|
157
182
|
};
|
|
158
183
|
}
|
|
@@ -2,6 +2,28 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
import { createGlobalMessageStreamHub } from './global-hub.js';
|
|
4
4
|
|
|
5
|
+
it('bounds a contiguous replay suffix by UTF-8 bytes and event count', async () => {
|
|
6
|
+
const blocks = Array.from({ length: 8 }, (_, i) => `id: e${i}\ndata: ${JSON.stringify({ type: 'message', properties: { text: '界'.repeat(40) } })}\n\n`);
|
|
7
|
+
const received = [];
|
|
8
|
+
const hub = createGlobalMessageStreamHub({
|
|
9
|
+
buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
|
|
10
|
+
getOpenCodeAuthHeaders: () => ({}), replayLimit: 3, replayByteLimit: 550,
|
|
11
|
+
upstreamReconnectDelayMs: 60_000,
|
|
12
|
+
fetchImpl: async () => createSseResponse({ blocks }),
|
|
13
|
+
});
|
|
14
|
+
hub.subscribeEvent(event => received.push(event.eventId));
|
|
15
|
+
try {
|
|
16
|
+
hub.start();
|
|
17
|
+
await waitForAssertion(() => expect(received).toHaveLength(8));
|
|
18
|
+
expect(hub.replayAfter('e0')).toBeNull();
|
|
19
|
+
expect(hub.replayAfter('e5')).toBeNull();
|
|
20
|
+
const tail = hub.replayAfter('e6');
|
|
21
|
+
expect(tail.map(entry => entry.eventId)).toEqual(['e7']);
|
|
22
|
+
expect(Buffer.byteLength(tail[0].serializedFrame) * 2).toBeLessThanOrEqual(550);
|
|
23
|
+
expect(Buffer.byteLength(tail[0].serializedFrame) * 3).toBeGreaterThan(550);
|
|
24
|
+
} finally { hub.stop(); }
|
|
25
|
+
});
|
|
26
|
+
|
|
5
27
|
function createSseResponse({ blocks = [] } = {}) {
|
|
6
28
|
const encoder = new TextEncoder();
|
|
7
29
|
let index = 0;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sendMessageStreamWsEvent, sendMessageStreamWsFrame } from './protocol.js';
|
|
1
|
+
import { sendMessageStreamWsEvent, sendMessageStreamWsFrame, sendSerializedMessageStreamWsFrame, serializeMessageStreamWsEvent } from './protocol.js';
|
|
2
2
|
|
|
3
3
|
function shouldTriggerUpstreamHealthCheck(upstream) {
|
|
4
4
|
if (!upstream) {
|
|
@@ -31,12 +31,9 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
31
31
|
wsClients.delete(socket);
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
const replayEvents = (socket,
|
|
35
|
-
for (const entry of
|
|
36
|
-
const sent =
|
|
37
|
-
directory: entry.directory,
|
|
38
|
-
eventId: entry.eventId,
|
|
39
|
-
});
|
|
34
|
+
const replayEvents = (socket, entries) => {
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
const sent = sendSerializedMessageStreamWsFrame(socket, entry.serializedFrame);
|
|
40
37
|
if (!sent) {
|
|
41
38
|
removeClient(socket);
|
|
42
39
|
return;
|
|
@@ -49,10 +46,10 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
49
46
|
return;
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
const replay = globalHub.replayAfter(requestedLastEventId);
|
|
50
|
+
const ready = { type: 'ready', scope: 'global' };
|
|
51
|
+
if (replay === null) ready.replayReset = true;
|
|
52
|
+
const sent = sendMessageStreamWsFrame(socket, ready);
|
|
56
53
|
if (!sent) {
|
|
57
54
|
removeClient(socket);
|
|
58
55
|
return;
|
|
@@ -60,7 +57,7 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
60
57
|
|
|
61
58
|
readyClients.add(socket);
|
|
62
59
|
wsClients.add(socket);
|
|
63
|
-
replayEvents(socket,
|
|
60
|
+
if (replay !== null) replayEvents(socket, replay);
|
|
64
61
|
};
|
|
65
62
|
|
|
66
63
|
const stopHubIfUnused = () => {
|
|
@@ -88,26 +85,26 @@ export function createGlobalMessageStreamWsBridge({
|
|
|
88
85
|
}
|
|
89
86
|
};
|
|
90
87
|
|
|
91
|
-
const unsubscribeEvent = globalHub.subscribeEvent((
|
|
88
|
+
const unsubscribeEvent = globalHub.subscribeEvent((event) => {
|
|
89
|
+
const { payload } = event;
|
|
92
90
|
for (const socket of Array.from(clients)) {
|
|
93
91
|
if (!readyClients.has(socket)) {
|
|
94
92
|
continue;
|
|
95
93
|
}
|
|
96
|
-
const sent =
|
|
97
|
-
directory,
|
|
98
|
-
eventId,
|
|
99
|
-
});
|
|
94
|
+
const sent = sendSerializedMessageStreamWsFrame(socket, event.serialize());
|
|
100
95
|
if (!sent) {
|
|
101
96
|
removeClient(socket);
|
|
102
97
|
}
|
|
103
98
|
}
|
|
104
99
|
|
|
105
100
|
processForwardedEventPayload(payload, (syntheticPayload) => {
|
|
101
|
+
if (readyClients.size === 0) return;
|
|
102
|
+
const serializedFrame = serializeMessageStreamWsEvent(syntheticPayload, { directory: 'global' });
|
|
106
103
|
for (const socket of Array.from(clients)) {
|
|
107
104
|
if (!readyClients.has(socket)) {
|
|
108
105
|
continue;
|
|
109
106
|
}
|
|
110
|
-
const sent =
|
|
107
|
+
const sent = sendSerializedMessageStreamWsFrame(socket, serializedFrame);
|
|
111
108
|
if (!sent) {
|
|
112
109
|
removeClient(socket);
|
|
113
110
|
}
|
|
@@ -71,6 +71,14 @@ export function parseSseEventEnvelope(block) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
export function sendMessageStreamWsFrame(socket, payload) {
|
|
74
|
+
try {
|
|
75
|
+
return sendSerializedMessageStreamWsFrame(socket, JSON.stringify(payload));
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function sendSerializedMessageStreamWsFrame(socket, serializedFrame) {
|
|
74
82
|
if (!socket || socket.readyState !== 1) {
|
|
75
83
|
return false;
|
|
76
84
|
}
|
|
@@ -86,7 +94,7 @@ export function sendMessageStreamWsFrame(socket, payload) {
|
|
|
86
94
|
}
|
|
87
95
|
|
|
88
96
|
try {
|
|
89
|
-
socket.send(
|
|
97
|
+
socket.send(serializedFrame);
|
|
90
98
|
const bufferedAfter = typeof socket.bufferedAmount === 'number' ? socket.bufferedAmount : 0;
|
|
91
99
|
if (bufferedAfter > MESSAGE_STREAM_WS_MAX_BUFFERED_BYTES) {
|
|
92
100
|
try {
|
|
@@ -124,10 +132,16 @@ export function sendMessageStreamWsFrame(socket, payload) {
|
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
export function sendMessageStreamWsEvent(socket, payload, options = {}) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
try {
|
|
136
|
+
return sendSerializedMessageStreamWsFrame(socket, serializeMessageStreamWsEvent(payload, options));
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function serializeMessageStreamWsEvent(payload, options = {}) {
|
|
143
|
+
const frame = { type: 'event', payload };
|
|
144
|
+
if (typeof options.eventId === 'string' && options.eventId.length > 0) frame.eventId = options.eventId;
|
|
145
|
+
if (typeof options.directory === 'string' && options.directory.length > 0) frame.directory = options.directory;
|
|
146
|
+
return JSON.stringify(frame);
|
|
133
147
|
}
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
MESSAGE_STREAM_DIRECTORY_WS_PATH,
|
|
6
6
|
MESSAGE_STREAM_GLOBAL_WS_PATH,
|
|
7
7
|
MESSAGE_STREAM_WS_HEARTBEAT_INTERVAL_MS,
|
|
8
|
-
|
|
8
|
+
serializeMessageStreamWsEvent,
|
|
9
|
+
sendSerializedMessageStreamWsFrame,
|
|
9
10
|
} from './protocol.js';
|
|
10
11
|
import { createGlobalMessageStreamHub } from './global-hub.js';
|
|
11
12
|
import { createGlobalMessageStreamWsBridge } from './global-ws-bridge.js';
|
|
@@ -28,20 +29,22 @@ export function createGlobalUiEventBroadcaster({
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
if (hasSseClients) {
|
|
32
|
+
const serializedPayload = JSON.stringify(payload);
|
|
31
33
|
for (const res of sseClients) {
|
|
32
34
|
try {
|
|
33
|
-
writeSseEvent(res, payload);
|
|
35
|
+
writeSseEvent(res, payload, serializedPayload);
|
|
34
36
|
} catch {
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
if (hasWsClients) {
|
|
42
|
+
const serializedFrame = serializeMessageStreamWsEvent(payload, {
|
|
43
|
+
directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
|
|
44
|
+
eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
|
|
45
|
+
});
|
|
40
46
|
for (const socket of Array.from(wsClients)) {
|
|
41
|
-
const sent =
|
|
42
|
-
directory: typeof options.directory === 'string' && options.directory.length > 0 ? options.directory : 'global',
|
|
43
|
-
eventId: typeof options.eventId === 'string' && options.eventId.length > 0 ? options.eventId : undefined,
|
|
44
|
-
});
|
|
47
|
+
const sent = sendSerializedMessageStreamWsFrame(socket, serializedFrame);
|
|
45
48
|
if (!sent) {
|
|
46
49
|
wsClients.delete(socket);
|
|
47
50
|
}
|
|
@@ -2,6 +2,66 @@ import { EventEmitter } from 'node:events';
|
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
|
|
4
4
|
import { createGlobalUiEventBroadcaster, createMessageStreamWsRuntime } from './runtime.js';
|
|
5
|
+
import { createGlobalMessageStreamHub } from './global-hub.js';
|
|
6
|
+
import { createNotificationEmitterRuntime } from '../notifications/emitter-runtime.js';
|
|
7
|
+
|
|
8
|
+
it('serializes a UI broadcast once per wire format and isolates failed clients', () => {
|
|
9
|
+
let serializations = 0;
|
|
10
|
+
const payload = { toJSON() { serializations++; return { type: 'aiworker:test', properties: { value: 'same' } }; } };
|
|
11
|
+
const writes = [];
|
|
12
|
+
const sseClients = new Set(Array.from({ length: 4 }, () => ({ write: text => writes.push(text) })));
|
|
13
|
+
const sockets = Array.from({ length: 4 }, () => ({ readyState: 1, bufferedAmount: 0, send: text => writes.push(text) }));
|
|
14
|
+
const failed = { readyState: 1, bufferedAmount: 0, send() { throw new Error('closed'); } };
|
|
15
|
+
const wsClients = new Set([...sockets, failed]);
|
|
16
|
+
const { writeSseEvent } = createNotificationEmitterRuntime({});
|
|
17
|
+
const broadcast = createGlobalUiEventBroadcaster({ sseClients, wsClients, writeSseEvent });
|
|
18
|
+
broadcast(payload);
|
|
19
|
+
expect(serializations).toBe(2);
|
|
20
|
+
expect(writes).toHaveLength(8);
|
|
21
|
+
expect(wsClients.has(failed)).toBe(false);
|
|
22
|
+
expect(wsClients.size).toBe(4);
|
|
23
|
+
expect(writes[0]).toBe(writes[3]);
|
|
24
|
+
expect(writes[4]).toBe(writes[7]);
|
|
25
|
+
sseClients.clear(); wsClients.clear();
|
|
26
|
+
broadcast(payload);
|
|
27
|
+
expect(serializations).toBe(2);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('delivers oversized events live, but reports a replay gap instead of replaying across it', async () => {
|
|
31
|
+
const hub = createGlobalMessageStreamHub({
|
|
32
|
+
buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
|
|
33
|
+
getOpenCodeAuthHeaders: () => ({}),
|
|
34
|
+
replayByteLimit: 512,
|
|
35
|
+
fetchImpl: async (_url, options) => createSseResponse({
|
|
36
|
+
signal: options.signal, holdOpen: true,
|
|
37
|
+
blocks: [
|
|
38
|
+
'id: first\ndata: {"type":"first","properties":{}}\n\n',
|
|
39
|
+
`id: large\ndata: ${JSON.stringify({ type: 'large', properties: { text: '界'.repeat(1024) } })}\n\n`,
|
|
40
|
+
'id: last\ndata: {"type":"last","properties":{}}\n\n',
|
|
41
|
+
],
|
|
42
|
+
}),
|
|
43
|
+
});
|
|
44
|
+
const runtime = createMessageStreamWsRuntime({
|
|
45
|
+
server: new EventEmitter(), globalEventHub: hub, wsClients: new Set(),
|
|
46
|
+
uiAuthController: null, isRequestOriginAllowed: async () => true,
|
|
47
|
+
rejectWebSocketUpgrade() {}, buildOpenCodeUrl: path => `http://127.0.0.1:4096${path}`,
|
|
48
|
+
getOpenCodeAuthHeaders: () => ({}), processForwardedEventPayload() {},
|
|
49
|
+
});
|
|
50
|
+
const first = new FakeSocket();
|
|
51
|
+
const reconnect = new FakeSocket();
|
|
52
|
+
try {
|
|
53
|
+
runtime.wsServer.emit('connection', first, { url: '/api/global/event/ws' });
|
|
54
|
+
await expect.poll(() => first.sent.filter(frame => frame.type === 'event').length).toBe(3);
|
|
55
|
+
expect(first.sent.find(frame => frame.eventId === 'large').payload.properties.text).toHaveLength(1024);
|
|
56
|
+
expect(hub.replayAfter('first')).toBeNull();
|
|
57
|
+
expect(hub.replayAfter('last')).toEqual([]);
|
|
58
|
+
runtime.wsServer.emit('connection', reconnect, { url: '/api/global/event/ws?lastEventId=first' });
|
|
59
|
+
await expect.poll(() => reconnect.sent.length).toBeGreaterThan(0);
|
|
60
|
+
expect(reconnect.sent).toEqual([{ type: 'ready', scope: 'global', replayReset: true }]);
|
|
61
|
+
} finally {
|
|
62
|
+
first.close(); reconnect.close(); hub.stop(); await runtime.close();
|
|
63
|
+
}
|
|
64
|
+
});
|
|
5
65
|
|
|
6
66
|
class FakeSocket extends EventEmitter {
|
|
7
67
|
constructor() {
|
|
@@ -50,12 +50,21 @@ The following functions are exported and used by the web server:
|
|
|
50
50
|
- `getRemotes(directory)`: Get list of configured remotes.
|
|
51
51
|
|
|
52
52
|
### Worktree Operations
|
|
53
|
-
- `getWorktrees(directory)`: List all git worktrees for a repository.
|
|
53
|
+
- `getWorktrees(directory)`: List all git worktrees for a repository. A directory outside any repository (or one that does not exist) is an authoritative empty list; any other git failure throws so callers keep their last known topology instead of clearing it. `GET /api/git/worktrees` answers such a failure with 500.
|
|
54
|
+
- `observeWorktreeTopology(directory)`: Compare the repository's registered linked-worktree set with the last one seen for it and notify `subscribeWorktreeTopologyChanges` listeners when it changed. The set is fingerprinted from the `worktrees` directory under the common Git directory (mtime plus entry names), so the check is a stat and a readdir; the common directory is resolved with `git rev-parse --git-common-dir` once per requested directory and cached. The first observation only records a baseline. Never throws.
|
|
55
|
+
- `subscribeWorktreeTopologyChanges(listener)`: Listener receives `{ directories, at }`, where `directories` are every directory of that repository the server has observed, so clients can map them onto registered projects. Returns an unsubscribe function.
|
|
54
56
|
- `validateWorktreeCreate(directory, input)`: Validate worktree creation parameters (mode, branchName, startRef, upstream config).
|
|
55
57
|
- `createWorktree(directory, input)`: Create a new worktree (supports 'new' and 'existing' modes, upstream setup). When the current tracked branch has no unpublished commits, the UI supplies its remote-tracking ref and this operation fetches that branch once before creating the worktree. A failed fetch falls back to the local branch and reports `sourceFetchFailed`; other remote start refs still require an existing local ref when their fetch fails. After populating the worktree, the repository's `post-checkout` hook runs once with git's standard arguments (null ref as previous HEAD, the checked-out HEAD, and flag `1`) from the worktree directory, mirroring `git worktree add` without `--no-checkout`; a missing or non-executable hook is skipped and a failing hook is logged as a warning, never failing worktree creation or the session bootstrap.
|
|
56
58
|
- `removeWorktree(directory, input)`: Remove a worktree (optionally delete local branch).
|
|
57
59
|
- `isLinkedWorktree(directory)`: Check if directory is a linked worktree (not primary).
|
|
58
60
|
|
|
61
|
+
### Worktree topology change tracking
|
|
62
|
+
There is no filesystem watcher and no polling. The server notices worktree changes in two ways, and both scale with what users are doing rather than with the number of registered projects:
|
|
63
|
+
- Its own `createWorktree` and `removeWorktree` publish a change right after `git worktree add` / `git worktree remove` succeed (creation notifies before background population and setup scripts run).
|
|
64
|
+
- `GET /api/git/status` for a repository and `GET /api/git/worktrees` with a non-empty listing call `observeWorktreeTopology` beside the response. Clients request status while they work in a repository, and a completed agent tool call already triggers a status refresh, so a worktree added by an agent or from a terminal is noticed on the next such request; nothing runs while the app is idle.
|
|
65
|
+
|
|
66
|
+
`feature-routes-runtime.js` forwards each change to connected control-event clients as `aiworker:worktree-changed` with `{ directories, at }`. A repository nobody sends status or listing requests for is not observed until the next ordinary listing. `git worktree move` rewrites files inside an entry without touching the `worktrees` directory and is not detected. Tracking state is bounded: 500 directory-to-repository entries, 200 repositories, 100 directories per repository, least recently used dropped first.
|
|
67
|
+
|
|
59
68
|
### Worktree creation from a GitHub pull request
|
|
60
69
|
The UI provisions `pr-<owner>` via `ensureRemoteName`/`ensureRemoteUrl`
|
|
61
70
|
(HTTPS clone URL preferred over SSH) and checks out
|
package/server/lib/git/routes.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
-
export function registerGitRoutes(app) {
|
|
1
|
+
export function registerGitRoutes(app, { emitWorktreeChanged } = {}) {
|
|
2
2
|
let gitLibraries = null;
|
|
3
3
|
const getGitLibraries = async () => {
|
|
4
4
|
if (!gitLibraries) {
|
|
5
5
|
gitLibraries = await import('./index.js');
|
|
6
|
+
if (emitWorktreeChanged) {
|
|
7
|
+
gitLibraries.subscribeWorktreeTopologyChanges(emitWorktreeChanged);
|
|
8
|
+
}
|
|
6
9
|
}
|
|
7
10
|
return gitLibraries;
|
|
8
11
|
};
|
|
@@ -220,7 +223,7 @@ export function registerGitRoutes(app) {
|
|
|
220
223
|
});
|
|
221
224
|
|
|
222
225
|
app.get('/api/git/status', async (req, res) => {
|
|
223
|
-
const { getStatus, isGitRepository } = await getGitLibraries();
|
|
226
|
+
const { getStatus, isGitRepository, observeWorktreeTopology } = await getGitLibraries();
|
|
224
227
|
|
|
225
228
|
try {
|
|
226
229
|
const directory = resolveDirectoryQuery(req.query.directory);
|
|
@@ -233,6 +236,11 @@ export function registerGitRoutes(app) {
|
|
|
233
236
|
return res.json(nonRepoStatusPayload());
|
|
234
237
|
}
|
|
235
238
|
|
|
239
|
+
// Clients ask for status while they work in a repository, so this is
|
|
240
|
+
// where an externally added or removed worktree gets noticed. It runs
|
|
241
|
+
// beside the status call and never delays or fails the response.
|
|
242
|
+
void observeWorktreeTopology(directory);
|
|
243
|
+
|
|
236
244
|
const mode = req.query.mode === 'light' ? 'light' : undefined;
|
|
237
245
|
const status = await getStatus(directory, { mode });
|
|
238
246
|
res.json(status);
|
|
@@ -1078,7 +1086,7 @@ export function registerGitRoutes(app) {
|
|
|
1078
1086
|
});
|
|
1079
1087
|
|
|
1080
1088
|
app.get('/api/git/worktrees', async (req, res) => {
|
|
1081
|
-
const { getWorktrees } = await getGitLibraries();
|
|
1089
|
+
const { getWorktrees, observeWorktreeTopology } = await getGitLibraries();
|
|
1082
1090
|
try {
|
|
1083
1091
|
const directory = req.query.directory;
|
|
1084
1092
|
if (!directory) {
|
|
@@ -1086,13 +1094,18 @@ export function registerGitRoutes(app) {
|
|
|
1086
1094
|
}
|
|
1087
1095
|
|
|
1088
1096
|
const worktrees = await getWorktrees(directory);
|
|
1097
|
+
// A repository always lists at least its primary worktree; an empty
|
|
1098
|
+
// list means "not a repository" and has no topology to track.
|
|
1099
|
+
if (worktrees.length > 0) {
|
|
1100
|
+
void observeWorktreeTopology(directory);
|
|
1101
|
+
}
|
|
1089
1102
|
res.json(worktrees);
|
|
1090
1103
|
} catch (error) {
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
res.json(
|
|
1104
|
+
// A directory outside any repository still answers `[]` from getWorktrees.
|
|
1105
|
+
// Anything else is a real failure the client must not mistake for "no
|
|
1106
|
+
// worktrees", or it would drop the ones it already knows.
|
|
1107
|
+
console.error('Failed to get worktrees:', error);
|
|
1108
|
+
res.status(500).json({ error: error.message || 'Failed to get worktrees' });
|
|
1096
1109
|
}
|
|
1097
1110
|
});
|
|
1098
1111
|
|