shadow-claw 1.27.0 → 1.28.0
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/.well-known/ai-catalog.json +11 -0
- package/.well-known/mcp/index.html +19 -0
- package/.well-known/mcp/server-card.json +52 -0
- package/.well-known/mcp/servers.json +15 -0
- package/.well-known/mcp.json +52 -0
- package/.well-known/oauth-protected-resource +10 -0
- package/.well-known/oauth-protected-resource.json +10 -0
- package/bin/assert-version-bump.mjs +61 -3
- package/bin/assert-version-bump.test.mjs +77 -1
- package/bin/build/build.mjs +116 -49
- package/bin/build/build.test.mjs +103 -0
- package/bin/cli.mjs +299 -237
- package/bin/cli.test.mjs +27 -0
- package/bin/commands/mcp.mjs +132 -2
- package/bin/commands/mcp.test.mjs +166 -0
- package/bin/commands/skills-index.mjs +139 -20
- package/bin/commands/skills-index.test.mjs +82 -0
- package/bin/utils/control-client.mjs +17 -0
- package/bin/utils/control-client.test.mjs +46 -0
- package/bin/version.mjs +208 -0
- package/bin/version.test.mjs +138 -0
- package/dist/electron/main.cjs +195 -185
- package/dist/lib/components/index.js +21 -21
- package/dist/lib/components/settings/shadow-claw-channel-config.js +1 -1
- package/dist/lib/components/settings/shadow-claw-control-plane.js +1 -1
- package/dist/lib/components/settings/shadow-claw-git.js +1 -1
- package/dist/lib/components/settings/shadow-claw-llm.js +1 -1
- package/dist/lib/components/settings/shadow-claw-networking.js +1 -1
- package/dist/lib/components/settings/shadow-claw-notifications.js +1 -1
- package/dist/lib/components/settings/shadow-claw-peerjs.js +1 -1
- package/dist/lib/components/settings/shadow-claw-storage.js +1 -1
- package/dist/lib/components/settings/shadow-claw-task-server.js +1 -1
- package/dist/lib/components/settings/shadow-claw-webvm.js +1 -1
- package/dist/lib/components/shadow-claw/shadow-claw.d.ts +1 -0
- package/dist/lib/components/shadow-claw/utils/setupAppLifecycle.d.ts +23 -0
- package/dist/lib/components/shadow-claw-channel-config.js +1 -1
- package/dist/lib/components/shadow-claw-channels.js +1 -1
- package/dist/lib/components/shadow-claw-chat.js +1 -1
- package/dist/lib/components/shadow-claw-control-plane.js +1 -1
- package/dist/lib/components/shadow-claw-conversations.js +1 -1
- package/dist/lib/components/shadow-claw-file-viewer.js +1 -1
- package/dist/lib/components/shadow-claw-files.js +1 -1
- package/dist/lib/components/shadow-claw-git.js +1 -1
- package/dist/lib/components/shadow-claw-llm.js +1 -1
- package/dist/lib/components/shadow-claw-networking.js +1 -1
- package/dist/lib/components/shadow-claw-notifications.js +1 -1
- package/dist/lib/components/shadow-claw-pages.js +1 -1
- package/dist/lib/components/shadow-claw-peerjs.js +1 -1
- package/dist/lib/components/shadow-claw-settings.js +1 -1
- package/dist/lib/components/shadow-claw-storage.js +1 -1
- package/dist/lib/components/shadow-claw-task-server.js +1 -1
- package/dist/lib/components/shadow-claw-tasks.js +1 -1
- package/dist/lib/components/shadow-claw-terminal.js +1 -1
- package/dist/lib/components/shadow-claw-tools.js +1 -1
- package/dist/lib/components/shadow-claw-webvm.js +1 -1
- package/dist/lib/components/shadow-claw.js +1 -1
- package/dist/lib/{configurePeerJs-oODzltaC.js → configurePeerJs-D8Ph0-A9.js} +2 -2
- package/dist/lib/{configurePeerJs-oODzltaC.js.map → configurePeerJs-D8Ph0-A9.js.map} +1 -1
- package/dist/lib/core/control-plane-client.d.ts +2 -0
- package/dist/lib/core/orchestrator/orchestrator.d.ts +10 -0
- package/dist/lib/core/orchestrator/utils/operations/channel.d.ts +2 -2
- package/dist/lib/core/utils/initControlPlane.d.ts +6 -0
- package/dist/lib/index.js +21 -21
- package/dist/lib/{initControlPlane-C2zURVIS.js → initControlPlane-BIrWmpzc.js} +76 -9
- package/dist/lib/initControlPlane-BIrWmpzc.js.map +1 -0
- package/dist/lib/{markdown-C6EHl9zs.js → markdown-DEUoM1c6.js} +2 -2
- package/dist/lib/{markdown-C6EHl9zs.js.map → markdown-DEUoM1c6.js.map} +1 -1
- package/dist/lib/{orchestrator-KRjx0eba.js → orchestrator-BtVj9bBB.js} +11 -10
- package/dist/lib/{orchestrator-KRjx0eba.js.map → orchestrator-BtVj9bBB.js.map} +1 -1
- package/dist/lib/{orchestrator-CACLa_Ro.js → orchestrator-CFCW-l1O.js} +52 -4
- package/dist/lib/orchestrator-CFCW-l1O.js.map +1 -0
- package/dist/lib/{peerjs-DFWOBcXf.js → peerjs-BoBkx9DJ.js} +23 -3
- package/dist/lib/peerjs-BoBkx9DJ.js.map +1 -0
- package/dist/lib/{push-client-_tiWQsCT.js → push-client-D6hbzDNV.js} +10 -4
- package/dist/lib/push-client-D6hbzDNV.js.map +1 -0
- package/dist/lib/{shadow-claw-6UHdiy4a.js → shadow-claw-Cuf2O5bZ.js} +71 -4
- package/dist/lib/shadow-claw-Cuf2O5bZ.js.map +1 -0
- package/dist/lib/{shadow-claw-channel-config-DmfwJc6G.js → shadow-claw-channel-config-BDPKt6Bd.js} +3 -3
- package/dist/lib/{shadow-claw-channel-config-DmfwJc6G.js.map → shadow-claw-channel-config-BDPKt6Bd.js.map} +1 -1
- package/dist/lib/{shadow-claw-channels-1N6Jl-By.js → shadow-claw-channels-BsP5JABM.js} +2 -2
- package/dist/lib/{shadow-claw-channels-1N6Jl-By.js.map → shadow-claw-channels-BsP5JABM.js.map} +1 -1
- package/dist/lib/{shadow-claw-chat-BMoqQJRK.js → shadow-claw-chat-Cam2lX4k.js} +4 -4
- package/dist/lib/{shadow-claw-chat-BMoqQJRK.js.map → shadow-claw-chat-Cam2lX4k.js.map} +1 -1
- package/dist/lib/{shadow-claw-control-plane-UG0o7pCz.js → shadow-claw-control-plane-DB2qcCFs.js} +20 -10
- package/dist/lib/shadow-claw-control-plane-DB2qcCFs.js.map +1 -0
- package/dist/lib/{shadow-claw-conversations-BwXep3iu.js → shadow-claw-conversations-C9Vf4XGx.js} +2 -2
- package/dist/lib/{shadow-claw-conversations-BwXep3iu.js.map → shadow-claw-conversations-C9Vf4XGx.js.map} +1 -1
- package/dist/lib/{shadow-claw-file-viewer-DUxzVxDT.js → shadow-claw-file-viewer-B4HYiI0v.js} +3 -3
- package/dist/lib/{shadow-claw-file-viewer-DUxzVxDT.js.map → shadow-claw-file-viewer-B4HYiI0v.js.map} +1 -1
- package/dist/lib/{shadow-claw-files-DIREEnT2.js → shadow-claw-files-DVROUESO.js} +2 -2
- package/dist/lib/{shadow-claw-files-DIREEnT2.js.map → shadow-claw-files-DVROUESO.js.map} +1 -1
- package/dist/lib/{shadow-claw-git-Bg1SWgsF.js → shadow-claw-git-BqrO7cHJ.js} +3 -3
- package/dist/lib/{shadow-claw-git-Bg1SWgsF.js.map → shadow-claw-git-BqrO7cHJ.js.map} +1 -1
- package/dist/lib/{shadow-claw-llm-CazBrVpM.js → shadow-claw-llm-BexUQdYo.js} +2 -2
- package/dist/lib/{shadow-claw-llm-CazBrVpM.js.map → shadow-claw-llm-BexUQdYo.js.map} +1 -1
- package/dist/lib/{shadow-claw-networking-Df85tCbO.js → shadow-claw-networking-CfXRGpUD.js} +2 -2
- package/dist/lib/{shadow-claw-networking-Df85tCbO.js.map → shadow-claw-networking-CfXRGpUD.js.map} +1 -1
- package/dist/lib/{shadow-claw-notifications-v62FryTc.js → shadow-claw-notifications-C9KPMgx_.js} +2 -2
- package/dist/lib/{shadow-claw-notifications-v62FryTc.js.map → shadow-claw-notifications-C9KPMgx_.js.map} +1 -1
- package/dist/lib/{shadow-claw-pages-BgPyIEZO.js → shadow-claw-pages-CGzGXmrI.js} +3 -3
- package/dist/lib/{shadow-claw-pages-BgPyIEZO.js.map → shadow-claw-pages-CGzGXmrI.js.map} +1 -1
- package/dist/lib/{shadow-claw-peerjs-xkBXILed.js → shadow-claw-peerjs-B0ZB7yhB.js} +3 -3
- package/dist/lib/{shadow-claw-peerjs-xkBXILed.js.map → shadow-claw-peerjs-B0ZB7yhB.js.map} +1 -1
- package/dist/lib/{shadow-claw-settings-CN89xRVU.js → shadow-claw-settings-DBfMZdDS.js} +2 -2
- package/dist/lib/{shadow-claw-settings-CN89xRVU.js.map → shadow-claw-settings-DBfMZdDS.js.map} +1 -1
- package/dist/lib/{shadow-claw-storage-VnhcObCe.js → shadow-claw-storage-CM-w6mgG.js} +2 -2
- package/dist/lib/{shadow-claw-storage-VnhcObCe.js.map → shadow-claw-storage-CM-w6mgG.js.map} +1 -1
- package/dist/lib/{shadow-claw-task-server-jgEC2hHB.js → shadow-claw-task-server-DV4b1g2-.js} +4 -2
- package/dist/lib/{shadow-claw-task-server-jgEC2hHB.js.map → shadow-claw-task-server-DV4b1g2-.js.map} +1 -1
- package/dist/lib/{shadow-claw-tasks-i_evIufy.js → shadow-claw-tasks-K-foOHSE.js} +3 -3
- package/dist/lib/{shadow-claw-tasks-i_evIufy.js.map → shadow-claw-tasks-K-foOHSE.js.map} +1 -1
- package/dist/lib/{shadow-claw-terminal-CwScROlu.js → shadow-claw-terminal-CpAknKyA.js} +2 -2
- package/dist/lib/{shadow-claw-terminal-CwScROlu.js.map → shadow-claw-terminal-CpAknKyA.js.map} +1 -1
- package/dist/lib/{shadow-claw-tools-DD3fyXP4.js → shadow-claw-tools-D98XZ0Ep.js} +3 -3
- package/dist/lib/{shadow-claw-tools-DD3fyXP4.js.map → shadow-claw-tools-D98XZ0Ep.js.map} +1 -1
- package/dist/lib/{shadow-claw-webvm-DbJq4I3-.js → shadow-claw-webvm-DLOb8dMI.js} +2 -2
- package/dist/lib/{shadow-claw-webvm-DbJq4I3-.js.map → shadow-claw-webvm-DLOb8dMI.js.map} +1 -1
- package/dist/lib/subsystems/channels/imessage.d.ts +1 -0
- package/dist/lib/subsystems/channels/peerjs.d.ts +1 -0
- package/dist/lib/subsystems/channels/telegram.d.ts +1 -0
- package/dist/lib/subsystems/channels/types.d.ts +2 -0
- package/dist/lib/subsystems/notifications/push-client.d.ts +1 -1
- package/dist/lib/{syncWebMcpRegistration-DNLnQ-jz.js → syncWebMcpRegistration-NspKefOI.js} +2 -2
- package/dist/lib/{syncWebMcpRegistration-DNLnQ-jz.js.map → syncWebMcpRegistration-NspKefOI.js.map} +1 -1
- package/dist/public/.well-known/ai-catalog.json +11 -0
- package/dist/public/.well-known/mcp/index.html +19 -0
- package/dist/public/.well-known/mcp/server-card.json +52 -0
- package/dist/public/.well-known/mcp/servers.json +15 -0
- package/dist/public/.well-known/mcp.json +52 -0
- package/dist/public/.well-known/oauth-protected-resource +10 -0
- package/dist/public/.well-known/oauth-protected-resource.json +10 -0
- package/dist/public/404.html +28 -1
- package/dist/public/AGENTS.md +1 -1
- package/dist/public/agent.worker.js +1 -1
- package/dist/public/{configurePeerJs-BqgeBjP7.js → configurePeerJs-BhBokQlC.js} +1 -1
- package/dist/public/docs/example/article/index.html +3 -3
- package/dist/public/docs/publishing/index.html +3 -3
- package/dist/public/docs/skill-creator/index.html +3 -3
- package/dist/public/docs/subsystems/cli.md +11 -8
- package/dist/public/docs/subsystems/mcp-server.md +1 -0
- package/dist/public/docs/subsystems/notifications.md +10 -0
- package/dist/public/{e2e-bridge-Ceqr5kGc.js → e2e-bridge-DcBoeFvd.js} +1 -1
- package/dist/public/{file-viewer-C-6Jc_tb.js → file-viewer-CKtVC9Ix.js} +1 -1
- package/dist/public/index.css +1 -1
- package/dist/public/index.html +3 -3
- package/dist/public/index.js +1 -1
- package/dist/public/initControlPlane-Bi8ouFEb.js +1 -0
- package/dist/public/llms.txt +36 -1
- package/dist/public/main/index.html +3 -3
- package/dist/public/main/memory/index.html +3 -3
- package/dist/public/{markdown-C3M4OW_A.js → markdown-C-hCVJtc.js} +1 -1
- package/dist/public/orchestrator-8qNGfw8r.js +15 -0
- package/dist/public/{orchestrator-DHjdLtqa.js → orchestrator-BT3Ks5ip.js} +2 -2
- package/dist/public/peerjs-DfhwEiiq.js +1 -0
- package/dist/public/push-client-BVLrE9pK.js +1 -0
- package/dist/public/service-worker/fetch-proxy.js +1 -1
- package/dist/public/service-worker.js +1 -1
- package/dist/public/{shadow-claw-EAt2ml5Y.js → shadow-claw-DCjxNDCX.js} +3 -3
- package/dist/public/{shadow-claw-channels-XsfuWFF8.js → shadow-claw-channels-CITDrqMl.js} +1 -1
- package/dist/public/{shadow-claw-chat-CbSaOZ4q.js → shadow-claw-chat-BQrSgSKF.js} +1 -1
- package/dist/public/{shadow-claw-control-plane-DHvN3Ukg.js → shadow-claw-control-plane-Cp001OY0.js} +2 -2
- package/dist/public/{shadow-claw-conversations-BWs5VVfs.js → shadow-claw-conversations-EhX4RvIj.js} +1 -1
- package/dist/public/{shadow-claw-file-viewer-BWsoW5QS.js → shadow-claw-file-viewer-DiQXIjZD.js} +1 -1
- package/dist/public/{shadow-claw-files-CG7MqWiV.js → shadow-claw-files-lkTnSJsn.js} +1 -1
- package/dist/public/{shadow-claw-git-DBRPZNav.js → shadow-claw-git-BlcA_z8x.js} +2 -2
- package/dist/public/{shadow-claw-llm-DMvLgKXo.js → shadow-claw-llm-CW16X9Rg.js} +1 -1
- package/dist/public/{shadow-claw-networking-Dd8Rw31D.js → shadow-claw-networking-ySsMBY_i.js} +1 -1
- package/dist/public/{shadow-claw-notifications-Dbevj-lq.js → shadow-claw-notifications-CyV2zCQ8.js} +1 -1
- package/dist/public/{shadow-claw-pages-3_lQAv0t.js → shadow-claw-pages-Cx7cF9ah.js} +1 -1
- package/dist/public/{shadow-claw-settings-t8TIlgrM.js → shadow-claw-settings-zm4PHHGi.js} +2 -2
- package/dist/public/{shadow-claw-storage-CfEXoSIE.js → shadow-claw-storage-dy9JAEGd.js} +1 -1
- package/dist/public/{shadow-claw-task-server-DxQSsgRZ.js → shadow-claw-task-server-Bleww48s.js} +2 -2
- package/dist/public/{shadow-claw-tasks-DNpicQwz.js → shadow-claw-tasks-CTI5oaLN.js} +1 -1
- package/dist/public/{shadow-claw-terminal-DpY6zU_r.js → shadow-claw-terminal-mZ3i4HaQ.js} +1 -1
- package/dist/public/{shadow-claw-tools-WSwWohnk.js → shadow-claw-tools-B_nuk8Zl.js} +1 -1
- package/dist/public/{shadow-claw-webvm-CP6oFjg5.js → shadow-claw-webvm-Vl_jH5YL.js} +1 -1
- package/dist/public/{syncWebMcpRegistration-DOn9gLnD.js → syncWebMcpRegistration-n2vAMJ4w.js} +1 -1
- package/dist/server.js +197 -187
- package/package.json +2 -1
- package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.test.ts +6 -0
- package/src/components/settings/shadow-claw-control-plane/shadow-claw-control-plane.ts +19 -9
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.test.ts +14 -0
- package/src/components/settings/shadow-claw-task-server/shadow-claw-task-server.ts +12 -0
- package/src/components/shadow-claw/shadow-claw.ts +6 -0
- package/src/components/shadow-claw/utils/bindEventListeners.test.ts +57 -0
- package/src/components/shadow-claw/utils/bindEventListeners.ts +14 -0
- package/src/components/shadow-claw/utils/setupAppLifecycle.test.ts +201 -0
- package/src/components/shadow-claw/utils/setupAppLifecycle.ts +101 -0
- package/src/core/control-plane-client.test.ts +82 -1
- package/src/core/control-plane-client.ts +35 -1
- package/src/core/orchestrator/orchestrator.test.ts +49 -0
- package/src/core/orchestrator/orchestrator.ts +43 -0
- package/src/core/orchestrator/utils/operations/channel.test.ts +17 -0
- package/src/core/orchestrator/utils/operations/channel.ts +11 -5
- package/src/core/orchestrator/utils/operations/task.test.ts +26 -0
- package/src/core/orchestrator/utils/operations/task.ts +8 -1
- package/src/core/utils/initControlPlane.test.ts +138 -0
- package/src/core/utils/initControlPlane.ts +82 -0
- package/src/core/utils/initializeApp.test.ts +27 -1
- package/src/core/utils/initializeApp.ts +6 -13
- package/src/server/mcp/mcp-server.test.ts +6 -0
- package/src/server/mcp/mcp-server.ts +2 -1
- package/src/server/mcp/tools/built-in-tools.ts +88 -1
- package/src/server/mcp/tools/client-tool-relay.ts +6 -0
- package/src/server/mcp/tools/mcp-tools.test.ts +128 -0
- package/src/server/openapi/openapi-coverage.test.ts +2 -1
- package/src/server/openapi/openapi-spec.ts +2 -1
- package/src/server/openapi/paths/mcp.ts +104 -0
- package/src/server/openapi/paths/push.ts +3 -0
- package/src/server/routes/mcp.test.ts +92 -0
- package/src/server/routes/mcp.ts +138 -2
- package/src/server/utils/packageVersion.test.ts +10 -0
- package/src/server/utils/packageVersion.ts +32 -0
- package/src/service-worker/fetch-proxy-rules.test.ts +73 -0
- package/src/service-worker/fetch-proxy-rules.ts +44 -0
- package/src/service-worker/fetch-proxy.ts +1 -1
- package/src/subsystems/channels/imessage.test.ts +36 -0
- package/src/subsystems/channels/imessage.ts +11 -0
- package/src/subsystems/channels/peerjs.test.ts +56 -0
- package/src/subsystems/channels/peerjs.ts +29 -0
- package/src/subsystems/channels/telegram.test.ts +38 -0
- package/src/subsystems/channels/telegram.ts +13 -0
- package/src/subsystems/channels/types.ts +2 -0
- package/src/subsystems/notifications/push-client.test.ts +70 -0
- package/src/subsystems/notifications/push-client.ts +28 -3
- package/src/subsystems/notifications/push-routes.test.ts +82 -0
- package/src/subsystems/notifications/push-routes.ts +70 -16
- package/src/subsystems/notifications/push-store.test.ts +59 -0
- package/src/subsystems/notifications/push-store.ts +113 -19
- package/src/testing/jest-setup.ts +15 -0
- package/dist/lib/initControlPlane-C2zURVIS.js.map +0 -1
- package/dist/lib/orchestrator-CACLa_Ro.js.map +0 -1
- package/dist/lib/peerjs-DFWOBcXf.js.map +0 -1
- package/dist/lib/push-client-_tiWQsCT.js.map +0 -1
- package/dist/lib/shadow-claw-6UHdiy4a.js.map +0 -1
- package/dist/lib/shadow-claw-control-plane-UG0o7pCz.js.map +0 -1
- package/dist/public/initControlPlane-BN9cWyO0.js +0 -1
- package/dist/public/orchestrator-CMbCTjBf.js +0 -15
- package/dist/public/peerjs-DUCKH-m9.js +0 -1
- package/dist/public/push-client-D0lkwrK0.js +0 -1
|
@@ -54,8 +54,13 @@ class MockClientWebSocket {
|
|
|
54
54
|
|
|
55
55
|
// Mock EventSource implementation for SSE client testing
|
|
56
56
|
class MockEventSource {
|
|
57
|
+
static readonly CONNECTING = 0;
|
|
58
|
+
static readonly OPEN = 1;
|
|
59
|
+
static readonly CLOSED = 2;
|
|
60
|
+
|
|
57
61
|
static instances: MockEventSource[] = [];
|
|
58
62
|
url: string;
|
|
63
|
+
readyState: number = 0; // CONNECTING
|
|
59
64
|
onopen: (() => void) | null = null;
|
|
60
65
|
onmessage: ((event: { data: string }) => void) | null = null;
|
|
61
66
|
onerror: ((err: any) => void) | null = null;
|
|
@@ -64,9 +69,11 @@ class MockEventSource {
|
|
|
64
69
|
|
|
65
70
|
constructor(url: string) {
|
|
66
71
|
this.url = url;
|
|
72
|
+
this.readyState = MockEventSource.CONNECTING;
|
|
67
73
|
MockEventSource.instances.push(this);
|
|
68
74
|
setTimeout(() => {
|
|
69
75
|
if (!this.closed) {
|
|
76
|
+
this.readyState = MockEventSource.OPEN;
|
|
70
77
|
this.onopen?.();
|
|
71
78
|
}
|
|
72
79
|
}, 10);
|
|
@@ -80,6 +87,7 @@ class MockEventSource {
|
|
|
80
87
|
|
|
81
88
|
close() {
|
|
82
89
|
this.closed = true;
|
|
90
|
+
this.readyState = MockEventSource.CLOSED;
|
|
83
91
|
}
|
|
84
92
|
|
|
85
93
|
// Helper for test to simulate server event
|
|
@@ -225,6 +233,42 @@ describe("control-plane-client", () => {
|
|
|
225
233
|
|
|
226
234
|
client.disconnect();
|
|
227
235
|
});
|
|
236
|
+
|
|
237
|
+
it("ensureConnected reconnects and resets backoff for SSE when forced or disconnected", async () => {
|
|
238
|
+
const client = new ControlPlaneClient({
|
|
239
|
+
clientId: "sse-client-reconnect",
|
|
240
|
+
reconnectDelayMs: 500,
|
|
241
|
+
maxReconnectDelayMs: 30000,
|
|
242
|
+
EventSourceClass: MockEventSource as any,
|
|
243
|
+
fetchFn: mockFetch as any,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
client.connect();
|
|
247
|
+
expect(MockEventSource.instances).toHaveLength(1);
|
|
248
|
+
jest.advanceTimersByTime(15);
|
|
249
|
+
expect(client.getState()).toBe("connected");
|
|
250
|
+
|
|
251
|
+
// Calling ensureConnected(false) when healthy is a no-op
|
|
252
|
+
client.ensureConnected(false);
|
|
253
|
+
expect(MockEventSource.instances).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
// Simulate disconnection and exponential backoff
|
|
256
|
+
const es1 = MockEventSource.instances[0];
|
|
257
|
+
es1.close();
|
|
258
|
+
es1.onerror?.(new Error("Network drop"));
|
|
259
|
+
expect(client.getState()).toBe("disconnected");
|
|
260
|
+
|
|
261
|
+
// Advance by small time so reconnect delay increases
|
|
262
|
+
jest.advanceTimersByTime(200);
|
|
263
|
+
|
|
264
|
+
// Mobile sleep wake-up: ensureConnected(true) immediately reconnects without waiting for backoff timer
|
|
265
|
+
client.ensureConnected(true);
|
|
266
|
+
expect(MockEventSource.instances).toHaveLength(2);
|
|
267
|
+
jest.advanceTimersByTime(15);
|
|
268
|
+
expect(client.getState()).toBe("connected");
|
|
269
|
+
|
|
270
|
+
client.disconnect();
|
|
271
|
+
});
|
|
228
272
|
});
|
|
229
273
|
|
|
230
274
|
describe("WebSocket Transport (Opt-in)", () => {
|
|
@@ -256,7 +300,7 @@ describe("control-plane-client", () => {
|
|
|
256
300
|
clientId: "client-test-1",
|
|
257
301
|
deviceLabel: "Test Device",
|
|
258
302
|
capabilities: ["webmcp", "opfs"],
|
|
259
|
-
version: "1.
|
|
303
|
+
version: "1.27.1",
|
|
260
304
|
transport: "websocket",
|
|
261
305
|
});
|
|
262
306
|
|
|
@@ -411,5 +455,42 @@ describe("control-plane-client", () => {
|
|
|
411
455
|
|
|
412
456
|
client.disconnect();
|
|
413
457
|
});
|
|
458
|
+
|
|
459
|
+
it("ensureConnected reconnects and resets backoff for WebSocket when forced or disconnected", async () => {
|
|
460
|
+
const client = new ControlPlaneClient({
|
|
461
|
+
transport: "websocket",
|
|
462
|
+
url: "ws://127.0.0.1:8888/ws/control",
|
|
463
|
+
clientId: "client-ws-ensure",
|
|
464
|
+
reconnectDelayMs: 500,
|
|
465
|
+
WebSocketClass: MockClientWebSocket as any,
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
client.connect();
|
|
469
|
+
expect(MockClientWebSocket.instances).toHaveLength(1);
|
|
470
|
+
jest.advanceTimersByTime(15);
|
|
471
|
+
expect(client.getState()).toBe("connected");
|
|
472
|
+
|
|
473
|
+
// Non-forced ensureConnected is a no-op when connected
|
|
474
|
+
client.ensureConnected(false);
|
|
475
|
+
expect(MockClientWebSocket.instances).toHaveLength(1);
|
|
476
|
+
|
|
477
|
+
// Force reconnect (e.g. mobile sleep / app switch wake-up)
|
|
478
|
+
client.ensureConnected(true);
|
|
479
|
+
expect(MockClientWebSocket.instances).toHaveLength(2);
|
|
480
|
+
jest.advanceTimersByTime(15);
|
|
481
|
+
expect(client.getState()).toBe("connected");
|
|
482
|
+
|
|
483
|
+
// Also verify when disconnected and timer is active
|
|
484
|
+
const ws2 = MockClientWebSocket.instances[1];
|
|
485
|
+
ws2.close();
|
|
486
|
+
expect(client.getState()).toBe("disconnected");
|
|
487
|
+
|
|
488
|
+
client.ensureConnected(false);
|
|
489
|
+
expect(MockClientWebSocket.instances).toHaveLength(3);
|
|
490
|
+
jest.advanceTimersByTime(15);
|
|
491
|
+
expect(client.getState()).toBe("connected");
|
|
492
|
+
|
|
493
|
+
client.disconnect();
|
|
494
|
+
});
|
|
414
495
|
});
|
|
415
496
|
});
|
|
@@ -71,7 +71,7 @@ export class ControlPlaneClient {
|
|
|
71
71
|
this._peerId = options.peerId;
|
|
72
72
|
this._deviceLabel = options.deviceLabel || "ShadowClaw Browser Client";
|
|
73
73
|
this._capabilities = options.capabilities || ["webmcp", "opfs", "push"];
|
|
74
|
-
this._version = options.version || "1.
|
|
74
|
+
this._version = options.version || "1.27.1";
|
|
75
75
|
this._token = options.token;
|
|
76
76
|
this._heartbeatIntervalMs = options.heartbeatIntervalMs || 10000;
|
|
77
77
|
this._reconnectDelayMs = options.reconnectDelayMs || 1000;
|
|
@@ -113,6 +113,10 @@ export class ControlPlaneClient {
|
|
|
113
113
|
return this._transport;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
public get version(): string {
|
|
117
|
+
return this._version;
|
|
118
|
+
}
|
|
119
|
+
|
|
116
120
|
public registerHandler(action: string, handler: CommandHandler): void {
|
|
117
121
|
this._handlers.set(action, handler);
|
|
118
122
|
}
|
|
@@ -125,6 +129,36 @@ export class ControlPlaneClient {
|
|
|
125
129
|
return this._state;
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
public ensureConnected(force: boolean = false): void {
|
|
133
|
+
this._isDestroyed = false;
|
|
134
|
+
if (this._reconnectTimer) {
|
|
135
|
+
clearTimeout(this._reconnectTimer);
|
|
136
|
+
this._reconnectTimer = null;
|
|
137
|
+
}
|
|
138
|
+
this._currentReconnectDelay = this._reconnectDelayMs;
|
|
139
|
+
|
|
140
|
+
if (this._transport === "websocket") {
|
|
141
|
+
const isWsOpen =
|
|
142
|
+
this._ws &&
|
|
143
|
+
(this._ws.readyState === 1 ||
|
|
144
|
+
this._ws.readyState === (this._WebSocketClass as any)?.OPEN);
|
|
145
|
+
if (force || !isWsOpen || this._state !== "connected") {
|
|
146
|
+
this._cleanupConnection();
|
|
147
|
+
this.connect();
|
|
148
|
+
}
|
|
149
|
+
} else {
|
|
150
|
+
// SSE transport
|
|
151
|
+
const isEsOpen =
|
|
152
|
+
this._es &&
|
|
153
|
+
(this._es.readyState === 1 ||
|
|
154
|
+
this._es.readyState === (this._EventSourceClass as any)?.OPEN);
|
|
155
|
+
if (force || !isEsOpen || this._state !== "connected") {
|
|
156
|
+
this._cleanupConnection();
|
|
157
|
+
this.connect();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
128
162
|
public connect(): void {
|
|
129
163
|
if (
|
|
130
164
|
this._isDestroyed ||
|
|
@@ -1894,4 +1894,53 @@ describe("Orchestrator", () => {
|
|
|
1894
1894
|
}
|
|
1895
1895
|
});
|
|
1896
1896
|
});
|
|
1897
|
+
|
|
1898
|
+
describe("ensureAllConnections", () => {
|
|
1899
|
+
it("coordinates Control Plane, channels, task server, and scheduler", async () => {
|
|
1900
|
+
const o = new Orchestrator();
|
|
1901
|
+
const fakeDb = {} as any;
|
|
1902
|
+
o.db = fakeDb;
|
|
1903
|
+
o.taskServerEnabled = true;
|
|
1904
|
+
|
|
1905
|
+
const mockScheduler = {
|
|
1906
|
+
start: jest.fn(),
|
|
1907
|
+
tick: jest.fn().mockResolvedValue(undefined as never),
|
|
1908
|
+
};
|
|
1909
|
+
o.scheduler = mockScheduler as any;
|
|
1910
|
+
|
|
1911
|
+
const ensureControlPlaneSpy = jest
|
|
1912
|
+
.spyOn(o, "ensureControlPlaneConnected")
|
|
1913
|
+
.mockResolvedValue(null as any);
|
|
1914
|
+
|
|
1915
|
+
const peerjsEnsureSpy = jest.fn();
|
|
1916
|
+
(o.peerjs as any).ensureConnected = peerjsEnsureSpy;
|
|
1917
|
+
o.peerjs.running = true;
|
|
1918
|
+
o.channelEnabledByType.peerjs = true;
|
|
1919
|
+
o.peerjsMyPeerId = "my-peer";
|
|
1920
|
+
|
|
1921
|
+
const replaySpy = jest
|
|
1922
|
+
.spyOn(orchestratorStore, "replayTaskSyncOutbox")
|
|
1923
|
+
.mockResolvedValue();
|
|
1924
|
+
const loadTasksSpy = jest
|
|
1925
|
+
.spyOn(orchestratorStore, "loadTasks")
|
|
1926
|
+
.mockResolvedValue();
|
|
1927
|
+
|
|
1928
|
+
await o.ensureAllConnections({ force: true });
|
|
1929
|
+
|
|
1930
|
+
expect(ensureControlPlaneSpy).toHaveBeenCalledWith({
|
|
1931
|
+
orchestrator: o,
|
|
1932
|
+
db: fakeDb,
|
|
1933
|
+
force: true,
|
|
1934
|
+
});
|
|
1935
|
+
expect(peerjsEnsureSpy).toHaveBeenCalledWith(true);
|
|
1936
|
+
expect(replaySpy).toHaveBeenCalledWith(fakeDb);
|
|
1937
|
+
expect(loadTasksSpy).toHaveBeenCalledWith(fakeDb);
|
|
1938
|
+
expect(mockScheduler.start).toHaveBeenCalled();
|
|
1939
|
+
expect(mockScheduler.tick).toHaveBeenCalled();
|
|
1940
|
+
|
|
1941
|
+
ensureControlPlaneSpy.mockRestore();
|
|
1942
|
+
replaySpy.mockRestore();
|
|
1943
|
+
loadTasksSpy.mockRestore();
|
|
1944
|
+
});
|
|
1945
|
+
});
|
|
1897
1946
|
});
|
|
@@ -74,6 +74,8 @@ import {
|
|
|
74
74
|
|
|
75
75
|
import { syncWebMcpRegistration } from "./utils/syncWebMcpRegistration.js";
|
|
76
76
|
import { DEFAULT_DIRECT_TOOL_COMMAND_POLICY } from "./utils/types.js";
|
|
77
|
+
import { ensureControlPlaneConnected } from "../utils/initControlPlane.js";
|
|
78
|
+
import { applyAllChannelRunningStates } from "./utils/operations/channel.js";
|
|
77
79
|
|
|
78
80
|
import type { ShadowClawDatabase } from "../../db/db.js";
|
|
79
81
|
import type { A2UIAction } from "../../ui/a2ui/types.js";
|
|
@@ -104,6 +106,9 @@ export class Orchestrator {
|
|
|
104
106
|
...DEFAULT_DIRECT_TOOL_COMMAND_POLICY,
|
|
105
107
|
};
|
|
106
108
|
|
|
109
|
+
ensureControlPlaneConnected: typeof ensureControlPlaneConnected =
|
|
110
|
+
ensureControlPlaneConnected;
|
|
111
|
+
|
|
107
112
|
events: EventBus = new EventBus();
|
|
108
113
|
gitProxyUrl: string = "/git-proxy";
|
|
109
114
|
|
|
@@ -287,6 +292,44 @@ export class Orchestrator {
|
|
|
287
292
|
unregisterWebMcpTools();
|
|
288
293
|
}
|
|
289
294
|
|
|
295
|
+
/**
|
|
296
|
+
* Ensure all enabled external connections (Control Plane, Channels, Task Server)
|
|
297
|
+
* are actively connected and healthy (e.g. on initial app load, wake-up from sleep,
|
|
298
|
+
* switching back from another app, or regaining network connectivity).
|
|
299
|
+
*/
|
|
300
|
+
async ensureAllConnections(options: { force?: boolean } = {}): Promise<void> {
|
|
301
|
+
const force = options.force ?? false;
|
|
302
|
+
|
|
303
|
+
// 1. Control Plane
|
|
304
|
+
try {
|
|
305
|
+
await this.ensureControlPlaneConnected({
|
|
306
|
+
orchestrator: this,
|
|
307
|
+
db: this.db || undefined,
|
|
308
|
+
force,
|
|
309
|
+
});
|
|
310
|
+
} catch (err) {
|
|
311
|
+
console.warn("Error ensuring Control Plane connection:", err);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 2. Messaging Channels
|
|
315
|
+
try {
|
|
316
|
+
applyAllChannelRunningStates(this, force);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
console.warn("Error ensuring channel connections:", err);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 3. Server Task Scheduling & Local Scheduler
|
|
322
|
+
if (this.taskServerEnabled && this.db) {
|
|
323
|
+
void orchestratorStore.replayTaskSyncOutbox(this.db);
|
|
324
|
+
void orchestratorStore.loadTasks(this.db);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (this.scheduler) {
|
|
328
|
+
this.scheduler.start();
|
|
329
|
+
void this.scheduler.tick();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
290
333
|
stopCurrentRequest(groupId = DEFAULT_GROUP_ID): void {
|
|
291
334
|
if (this.state !== "thinking" && this.state !== "responding") {
|
|
292
335
|
return;
|
|
@@ -192,6 +192,23 @@ describe("channel operations", () => {
|
|
|
192
192
|
expect(state.peerjs.stop).toHaveBeenCalled();
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
+
it("applyChannelRunningState invokes ensureConnected if channel is already running", () => {
|
|
196
|
+
const ensureConnectedMock = jest.fn();
|
|
197
|
+
const state = makeState({
|
|
198
|
+
channelEnabledByType: { telegram: true } as any,
|
|
199
|
+
telegramBotToken: "token",
|
|
200
|
+
telegram: {
|
|
201
|
+
running: true,
|
|
202
|
+
start: jest.fn(),
|
|
203
|
+
stop: jest.fn(),
|
|
204
|
+
ensureConnected: ensureConnectedMock,
|
|
205
|
+
} as any,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
applyAllChannelRunningStates(state, true);
|
|
209
|
+
expect(ensureConnectedMock).toHaveBeenCalledWith(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
195
212
|
it("clearPeerJsTypingState invokes store update", () => {
|
|
196
213
|
clearPeerJsTypingState("peer:group1");
|
|
197
214
|
});
|
|
@@ -191,6 +191,7 @@ export function applyChannelRunningState(
|
|
|
191
191
|
| "peerjsMyPeerId"
|
|
192
192
|
>,
|
|
193
193
|
channelType: ChannelType,
|
|
194
|
+
force: boolean = false,
|
|
194
195
|
): void {
|
|
195
196
|
const channel = getChannelByType(state, channelType);
|
|
196
197
|
if (!channel) {
|
|
@@ -198,7 +199,11 @@ export function applyChannelRunningState(
|
|
|
198
199
|
}
|
|
199
200
|
|
|
200
201
|
if (shouldRunChannel(state, channelType)) {
|
|
201
|
-
channel.
|
|
202
|
+
if (!channel.running) {
|
|
203
|
+
channel.start();
|
|
204
|
+
} else if (typeof (channel as any).ensureConnected === "function") {
|
|
205
|
+
(channel as any).ensureConnected(force);
|
|
206
|
+
}
|
|
202
207
|
|
|
203
208
|
return;
|
|
204
209
|
}
|
|
@@ -218,11 +223,12 @@ export function applyAllChannelRunningStates(
|
|
|
218
223
|
| "imessageServerUrl"
|
|
219
224
|
| "peerjsMyPeerId"
|
|
220
225
|
>,
|
|
226
|
+
force: boolean = false,
|
|
221
227
|
): void {
|
|
222
|
-
applyChannelRunningState(state, "browser");
|
|
223
|
-
applyChannelRunningState(state, "telegram");
|
|
224
|
-
applyChannelRunningState(state, "imessage");
|
|
225
|
-
applyChannelRunningState(state, "peerjs");
|
|
228
|
+
applyChannelRunningState(state, "browser", force);
|
|
229
|
+
applyChannelRunningState(state, "telegram", force);
|
|
230
|
+
applyChannelRunningState(state, "imessage", force);
|
|
231
|
+
applyChannelRunningState(state, "peerjs", force);
|
|
226
232
|
}
|
|
227
233
|
|
|
228
234
|
export function clearPeerJsTypingState(groupId: string): void {
|
|
@@ -57,4 +57,30 @@ describe("task operations", () => {
|
|
|
57
57
|
expect.objectContaining({ method: "DELETE" }),
|
|
58
58
|
);
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
it("syncTaskToServer sets targetAddressSpace private for local hostnames", async () => {
|
|
62
|
+
await syncTaskToServer(
|
|
63
|
+
{
|
|
64
|
+
taskServerUrl: "https://hostname:8888/schedule",
|
|
65
|
+
taskServerEnabled: true,
|
|
66
|
+
} as any,
|
|
67
|
+
{
|
|
68
|
+
id: "t2",
|
|
69
|
+
groupId: "br:main",
|
|
70
|
+
prompt: "hello",
|
|
71
|
+
schedule: "*/5 * * * *",
|
|
72
|
+
enabled: true,
|
|
73
|
+
lastRun: null,
|
|
74
|
+
createdAt: 1,
|
|
75
|
+
} as any,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect((globalThis as any).fetch).toHaveBeenCalledWith(
|
|
79
|
+
"https://hostname:8888/schedule/tasks",
|
|
80
|
+
expect.objectContaining({
|
|
81
|
+
method: "POST",
|
|
82
|
+
targetAddressSpace: "private",
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
60
86
|
});
|
|
@@ -23,7 +23,14 @@ function getTaskFetchOptions(
|
|
|
23
23
|
host === "[::1]"
|
|
24
24
|
) {
|
|
25
25
|
opts.targetAddressSpace = "loopback";
|
|
26
|
-
} else if (
|
|
26
|
+
} else if (
|
|
27
|
+
/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/.test(host) ||
|
|
28
|
+
!host.includes(".") ||
|
|
29
|
+
host.endsWith(".local") ||
|
|
30
|
+
host.endsWith(".lan") ||
|
|
31
|
+
host.endsWith(".home") ||
|
|
32
|
+
host.endsWith(".internal")
|
|
33
|
+
) {
|
|
27
34
|
opts.targetAddressSpace = "private";
|
|
28
35
|
}
|
|
29
36
|
} catch (_) {}
|
|
@@ -3,10 +3,12 @@ import {
|
|
|
3
3
|
shouldConnectControlPlane,
|
|
4
4
|
detectCapabilities,
|
|
5
5
|
createDefaultControlPlaneClient,
|
|
6
|
+
ensureControlPlaneConnected,
|
|
6
7
|
executeClientControlCommand,
|
|
7
8
|
getControlPlaneServerUrl,
|
|
8
9
|
getActiveControlPlaneClient,
|
|
9
10
|
stopControlPlaneClient,
|
|
11
|
+
detectClientVersion,
|
|
10
12
|
} from "./initControlPlane.js";
|
|
11
13
|
|
|
12
14
|
describe("initControlPlane", () => {
|
|
@@ -90,6 +92,65 @@ describe("initControlPlane", () => {
|
|
|
90
92
|
});
|
|
91
93
|
});
|
|
92
94
|
|
|
95
|
+
describe("detectClientVersion", () => {
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
document
|
|
98
|
+
.querySelectorAll('meta[name="version"], meta[name="revision"]')
|
|
99
|
+
.forEach((el) => el.remove());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns default fallback 1.27.1 when no DOM metadata is present", () => {
|
|
103
|
+
expect(detectClientVersion()).toBe("1.27.1");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("returns '<semver> (<short-rev>)' when both version and revision are present", () => {
|
|
107
|
+
const metaVersion = document.createElement("meta");
|
|
108
|
+
metaVersion.setAttribute("name", "version");
|
|
109
|
+
metaVersion.setAttribute("content", "1.27.1");
|
|
110
|
+
document.head.appendChild(metaVersion);
|
|
111
|
+
|
|
112
|
+
const metaRevision = document.createElement("meta");
|
|
113
|
+
metaRevision.setAttribute("name", "revision");
|
|
114
|
+
metaRevision.setAttribute(
|
|
115
|
+
"content",
|
|
116
|
+
"ad65dc305c71a18cb2152e4f47da1491148fb07f",
|
|
117
|
+
);
|
|
118
|
+
document.head.appendChild(metaRevision);
|
|
119
|
+
|
|
120
|
+
expect(detectClientVersion()).toBe("1.27.1 (ad65dc3)");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns '<semver>' when only version is present", () => {
|
|
124
|
+
const metaVersion = document.createElement("meta");
|
|
125
|
+
metaVersion.setAttribute("name", "version");
|
|
126
|
+
metaVersion.setAttribute("content", "1.27.1");
|
|
127
|
+
document.head.appendChild(metaVersion);
|
|
128
|
+
|
|
129
|
+
expect(detectClientVersion()).toBe("1.27.1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("returns '<short-rev>' when only revision is present", () => {
|
|
133
|
+
const metaRevision = document.createElement("meta");
|
|
134
|
+
metaRevision.setAttribute("name", "revision");
|
|
135
|
+
metaRevision.setAttribute(
|
|
136
|
+
"content",
|
|
137
|
+
"ad65dc305c71a18cb2152e4f47da1491148fb07f",
|
|
138
|
+
);
|
|
139
|
+
document.head.appendChild(metaRevision);
|
|
140
|
+
|
|
141
|
+
expect(detectClientVersion()).toBe("ad65dc3");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("handles 7-char short revision directly", () => {
|
|
145
|
+
const metaRevision = document.createElement("meta");
|
|
146
|
+
metaRevision.setAttribute("name", "revision");
|
|
147
|
+
metaRevision.setAttribute("content", "ad65dc3");
|
|
148
|
+
document.head.appendChild(metaRevision);
|
|
149
|
+
|
|
150
|
+
expect(detectClientVersion()).toBe("ad65dc3");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
93
154
|
describe("isControlPlaneEnabled", () => {
|
|
94
155
|
it("returns false by default (opt-in)", async () => {
|
|
95
156
|
const { isControlPlaneEnabled } = await import("./initControlPlane.js");
|
|
@@ -800,6 +861,24 @@ describe("initControlPlane", () => {
|
|
|
800
861
|
localStorage.removeItem("control_plane_transport");
|
|
801
862
|
});
|
|
802
863
|
|
|
864
|
+
it("passes detected or custom version to ControlPlaneClient", () => {
|
|
865
|
+
const metaVersion = document.createElement("meta");
|
|
866
|
+
metaVersion.setAttribute("name", "version");
|
|
867
|
+
metaVersion.setAttribute("content", "1.27.1");
|
|
868
|
+
document.head.appendChild(metaVersion);
|
|
869
|
+
|
|
870
|
+
const client1 = createDefaultControlPlaneClient({ autoConnect: false });
|
|
871
|
+
expect((client1 as any).version).toBe("1.27.1");
|
|
872
|
+
|
|
873
|
+
const client2 = createDefaultControlPlaneClient({
|
|
874
|
+
autoConnect: false,
|
|
875
|
+
version: "custom-v2",
|
|
876
|
+
});
|
|
877
|
+
expect((client2 as any).version).toBe("custom-v2");
|
|
878
|
+
|
|
879
|
+
metaVersion.remove();
|
|
880
|
+
});
|
|
881
|
+
|
|
803
882
|
it("manages active client lifecycle and autoConnect", () => {
|
|
804
883
|
stopControlPlaneClient();
|
|
805
884
|
expect(getActiveControlPlaneClient()).toBeNull();
|
|
@@ -811,4 +890,63 @@ describe("initControlPlane", () => {
|
|
|
811
890
|
expect(getActiveControlPlaneClient()).toBeNull();
|
|
812
891
|
});
|
|
813
892
|
});
|
|
893
|
+
|
|
894
|
+
describe("ensureControlPlaneConnected", () => {
|
|
895
|
+
afterEach(() => {
|
|
896
|
+
stopControlPlaneClient();
|
|
897
|
+
localStorage.removeItem("control_plane_enabled");
|
|
898
|
+
localStorage.removeItem("control_plane_url");
|
|
899
|
+
localStorage.removeItem("control_plane_transport");
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("returns null when control plane is disabled", async () => {
|
|
903
|
+
localStorage.setItem("control_plane_enabled", "false");
|
|
904
|
+
const client = await ensureControlPlaneConnected();
|
|
905
|
+
expect(client).toBeNull();
|
|
906
|
+
expect(getActiveControlPlaneClient()).toBeNull();
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
it("syncs config from db to localStorage and connects when enabled", async () => {
|
|
910
|
+
const mockDb = {
|
|
911
|
+
transaction: () => ({
|
|
912
|
+
objectStore: () => ({
|
|
913
|
+
get: () => ({
|
|
914
|
+
onsuccess: null,
|
|
915
|
+
onerror: null,
|
|
916
|
+
}),
|
|
917
|
+
}),
|
|
918
|
+
}),
|
|
919
|
+
} as any;
|
|
920
|
+
|
|
921
|
+
// Mock getConfig
|
|
922
|
+
jest.unstable_mockModule("../../db/getConfig.js", () => ({
|
|
923
|
+
getConfig: jest.fn(async (_db: any, key: string) => {
|
|
924
|
+
if (key === "control_plane_enabled") return "true";
|
|
925
|
+
if (key === "control_plane_url") return "http://192.168.1.50:8888";
|
|
926
|
+
if (key === "control_plane_transport") return "websocket";
|
|
927
|
+
return undefined;
|
|
928
|
+
}),
|
|
929
|
+
}));
|
|
930
|
+
|
|
931
|
+
localStorage.removeItem("control_plane_enabled");
|
|
932
|
+
localStorage.removeItem("control_plane_url");
|
|
933
|
+
|
|
934
|
+
const client = await ensureControlPlaneConnected({ db: mockDb });
|
|
935
|
+
expect(client).not.toBeNull();
|
|
936
|
+
expect(getActiveControlPlaneClient()).toBe(client);
|
|
937
|
+
expect(localStorage.getItem("control_plane_enabled")).toBe("true");
|
|
938
|
+
expect(localStorage.getItem("control_plane_url")).toBe(
|
|
939
|
+
"http://192.168.1.50:8888",
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
// Subsequent call reuses active client and calls ensureConnected
|
|
943
|
+
const ensureConnectedSpy = jest.spyOn(client!, "ensureConnected");
|
|
944
|
+
const client2 = await ensureControlPlaneConnected({
|
|
945
|
+
db: mockDb,
|
|
946
|
+
force: true,
|
|
947
|
+
});
|
|
948
|
+
expect(client2).toBe(client);
|
|
949
|
+
expect(ensureConnectedSpy).toHaveBeenCalledWith(true);
|
|
950
|
+
});
|
|
951
|
+
});
|
|
814
952
|
});
|
|
@@ -202,11 +202,43 @@ export function detectDeviceLabel(): string {
|
|
|
202
202
|
return `${device} ${browser}`;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
export function detectClientVersion(): string {
|
|
206
|
+
let version = "";
|
|
207
|
+
let revision = "";
|
|
208
|
+
|
|
209
|
+
if (typeof document !== "undefined") {
|
|
210
|
+
version =
|
|
211
|
+
document
|
|
212
|
+
.querySelector('meta[name="version"]')
|
|
213
|
+
?.getAttribute("content")
|
|
214
|
+
?.trim() || "";
|
|
215
|
+
revision =
|
|
216
|
+
document
|
|
217
|
+
.querySelector('meta[name="revision"]')
|
|
218
|
+
?.getAttribute("content")
|
|
219
|
+
?.trim() || "";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const shortRevision = revision.length > 7 ? revision.slice(0, 7) : revision;
|
|
223
|
+
|
|
224
|
+
if (version && shortRevision) {
|
|
225
|
+
return `${version} (${shortRevision})`;
|
|
226
|
+
}
|
|
227
|
+
if (version) {
|
|
228
|
+
return version;
|
|
229
|
+
}
|
|
230
|
+
if (shortRevision) {
|
|
231
|
+
return shortRevision;
|
|
232
|
+
}
|
|
233
|
+
return "1.27.1";
|
|
234
|
+
}
|
|
235
|
+
|
|
205
236
|
export interface InitControlPlaneOptions {
|
|
206
237
|
orchestrator?: any;
|
|
207
238
|
clientId?: string;
|
|
208
239
|
transport?: ControlPlaneTransport;
|
|
209
240
|
autoConnect?: boolean;
|
|
241
|
+
version?: string;
|
|
210
242
|
}
|
|
211
243
|
|
|
212
244
|
let activeClient: ControlPlaneClient | null = null;
|
|
@@ -263,6 +295,7 @@ export async function executeClientControlCommand(
|
|
|
263
295
|
clientId,
|
|
264
296
|
deviceLabel,
|
|
265
297
|
capabilities,
|
|
298
|
+
version: options.version || detectClientVersion(),
|
|
266
299
|
activeGroupId:
|
|
267
300
|
options.orchestrator?.activeGroupId ||
|
|
268
301
|
orchestratorStore.activeGroupId ||
|
|
@@ -646,6 +679,7 @@ export function createDefaultControlPlaneClient(
|
|
|
646
679
|
getOrCreateControlPlaneClientId();
|
|
647
680
|
const capabilities = detectCapabilities();
|
|
648
681
|
const deviceLabel = detectDeviceLabel();
|
|
682
|
+
const version = options.version || detectClientVersion();
|
|
649
683
|
|
|
650
684
|
let configuredTransport: ControlPlaneTransport | null = null;
|
|
651
685
|
if (typeof localStorage !== "undefined") {
|
|
@@ -678,6 +712,7 @@ export function createDefaultControlPlaneClient(
|
|
|
678
712
|
clientId,
|
|
679
713
|
deviceLabel,
|
|
680
714
|
capabilities,
|
|
715
|
+
version,
|
|
681
716
|
peerId,
|
|
682
717
|
transport,
|
|
683
718
|
httpUrl,
|
|
@@ -718,3 +753,50 @@ export function stopControlPlaneClient(): void {
|
|
|
718
753
|
activeClient = null;
|
|
719
754
|
}
|
|
720
755
|
}
|
|
756
|
+
|
|
757
|
+
export async function ensureControlPlaneConnected(
|
|
758
|
+
options: InitControlPlaneOptions & { db?: any; force?: boolean } = {},
|
|
759
|
+
): Promise<ControlPlaneClient | null> {
|
|
760
|
+
let db = options.db;
|
|
761
|
+
if (!db) {
|
|
762
|
+
try {
|
|
763
|
+
db = await getDb();
|
|
764
|
+
} catch (_) {}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const enabled = await isControlPlaneEnabled(db);
|
|
768
|
+
if (!enabled) {
|
|
769
|
+
stopControlPlaneClient();
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (!shouldConnectControlPlane()) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Synchronize configuration from db to localStorage so synchronous lookups remain consistent
|
|
778
|
+
if (typeof localStorage !== "undefined" && db) {
|
|
779
|
+
try {
|
|
780
|
+
const { getConfig } = await import("../../db/getConfig.js");
|
|
781
|
+
const [url, transport] = await Promise.all([
|
|
782
|
+
getConfig(db, CONFIG_KEYS.CONTROL_PLANE_URL),
|
|
783
|
+
getConfig(db, CONFIG_KEYS.CONTROL_PLANE_TRANSPORT),
|
|
784
|
+
]);
|
|
785
|
+
if (url) {
|
|
786
|
+
localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_URL, url);
|
|
787
|
+
}
|
|
788
|
+
if (transport) {
|
|
789
|
+
localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_TRANSPORT, transport);
|
|
790
|
+
}
|
|
791
|
+
localStorage.setItem(CONFIG_KEYS.CONTROL_PLANE_ENABLED, "true");
|
|
792
|
+
} catch (_) {}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const client = getActiveControlPlaneClient();
|
|
796
|
+
if (client) {
|
|
797
|
+
client.ensureConnected(options.force);
|
|
798
|
+
return client;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return createDefaultControlPlaneClient(options);
|
|
802
|
+
}
|