@vantran-se/openchamber-web 2.0.3 → 2.0.5

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.
Files changed (265) hide show
  1. package/README.md +20 -28
  2. package/bin/cli.js +3 -1
  3. package/bin/cli.test.js +13 -4
  4. package/bin/lib/DOCUMENTATION.md +4 -1
  5. package/bin/lib/commands-serve.js +9 -0
  6. package/dist/assets/{CodeMirrorCommentWidgets-CWhdh0wf.js → CodeMirrorCommentWidgets-DCAQTLMM.js} +1 -1
  7. package/dist/assets/DiffView-uzNpHBlV.js +4 -0
  8. package/dist/assets/FilesView-Bkq0sjNx.js +17 -0
  9. package/dist/assets/{GitView-DKIjpnu4.js → GitView-BmibZC7n.js} +4 -4
  10. package/dist/assets/{GuestAttachDialog-BIa9jCfL.js → GuestAttachDialog-D7TJG1rt.js} +1 -1
  11. package/dist/assets/GuestSurfacePane-PAcYaFzP.js +1 -0
  12. package/dist/assets/{JsonTreeView-CtI1Mqwo.js → JsonTreeView-BNT2yUVu.js} +1 -1
  13. package/dist/assets/{LinearIssuesView-BD2zLu-r.js → LinearIssuesView-D3Azksju.js} +1 -1
  14. package/dist/assets/MarkdownImageGallery-BGY6tuiK.js +1 -0
  15. package/dist/assets/MarkdownRendererImpl-DJmhQ37f.js +12 -0
  16. package/dist/assets/{OnboardingScreen-B8TxloXZ.js → OnboardingScreen-D_41H2qC.js} +1 -1
  17. package/dist/assets/{PatchDiff--Civs1xY.js → PatchDiff-C0RSqa9W.js} +1 -1
  18. package/dist/assets/{PierreDiffViewer-BlDmw3-A.js → PierreDiffViewer-BBE7tTY2.js} +3 -3
  19. package/dist/assets/{PlanView-DyH2SyCP.js → PlanView-Bqc-xuDe.js} +4 -4
  20. package/dist/assets/PluginPane-DQ0k41Q-.js +3 -0
  21. package/dist/assets/SettingsView-BAiJU5hJ.js +30 -0
  22. package/dist/assets/{SettingsWindow-y9WpZqK6.js → SettingsWindow-DuC4ecLw.js} +1 -1
  23. package/dist/assets/{SubmoduleDiffSummary-DeFmhPFZ.js → SubmoduleDiffSummary-0HgmcMXz.js} +1 -1
  24. package/dist/assets/{ToolOutputDialog-8DmF7_PZ.js → ToolOutputDialog-DdHkQJEE.js} +6 -6
  25. package/dist/assets/{ToolPartDiffPreview-Ca15DKE1.js → ToolPartDiffPreview-DWdN0r5K.js} +2 -2
  26. package/dist/assets/{VirtualizedFileDiff-BfEBad09.js → VirtualizedFileDiff-ClDezf2d.js} +1 -1
  27. package/dist/assets/{WalkthroughView-Cj3K8Xz6.js → WalkthroughView-BJicp0RC.js} +2 -2
  28. package/dist/assets/{appThemeRegistry-DAdawJZn.js → appThemeRegistry-CiuyzfSW.js} +1 -1
  29. package/dist/assets/changesTree-CePHbFcj.js +1 -0
  30. package/dist/assets/commitAndPush-DTKb6IwT.js +1 -0
  31. package/dist/assets/{computeVirtualFileMetrics-RF-KjfUH.js → computeVirtualFileMetrics-kM_zJNMt.js} +2 -2
  32. package/dist/assets/de-BMnfXm-G.js +10 -0
  33. package/dist/assets/es-DKEvCCL6.js +16 -0
  34. package/dist/assets/fr-D9fgE2xE.js +14 -0
  35. package/dist/assets/index-DUke1nme.css +1 -0
  36. package/dist/assets/{index-CL7ZQ94-.js → index-DWB8bR_g.js} +1 -1
  37. package/dist/assets/index-Di6zUe5h.js +41 -0
  38. package/dist/assets/{isDiffPlainText-D2yJ9fO6.js → isDiffPlainText-DOoIsGqs.js} +1 -1
  39. package/dist/assets/{isFilePlainText-BwMEq1Ey.js → isFilePlainText-bt5VHvcd.js} +1 -1
  40. package/dist/assets/ja-BEXad-KW.js +16 -0
  41. package/dist/assets/ko-DGrQ0lEe.js +16 -0
  42. package/dist/assets/{main-quLSA7jc.js → main-BpLQbZC2.js} +2 -2
  43. package/dist/assets/main-CI9DtHJv.js +896 -0
  44. package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
  45. package/dist/assets/markdownCore-DK2KK5RW.js +7 -0
  46. package/dist/assets/{miniChat-D-lJJibr.js → miniChat-CIWaR8wG.js} +2 -2
  47. package/dist/assets/{mobile-VTtphbW6.js → mobile-CnWKEYIP.js} +2 -2
  48. package/dist/assets/patchFileDiff-Cqn0C0az.js +13 -0
  49. package/dist/assets/pl-BlhkmR5b.js +16 -0
  50. package/dist/assets/pt-BR-Bs96Ew9I.js +16 -0
  51. package/dist/assets/{renderElectronMiniChatApp-DF7ch5eM.js → renderElectronMiniChatApp-D9t0-9nV.js} +2 -2
  52. package/dist/assets/renderMobileApp-CW7fVs_s.js +25 -0
  53. package/dist/assets/runtimeEndpointReset-D0PcCb3K.js +27 -0
  54. package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
  55. package/dist/assets/{shikiHighlight-_HeCCHx1.js → shikiHighlight-l-MbaNS4.js} +1 -1
  56. package/dist/assets/{textMateThemeFromAppTheme-C95XdFGj.js → textMateThemeFromAppTheme-C-kGcOpD.js} +1 -1
  57. package/dist/assets/tr-CehUK1M9.js +7 -0
  58. package/dist/assets/uk-Bno5WGNz.js +16 -0
  59. package/dist/assets/{useAppFontEffects-ZM9ij19-.js → useAppFontEffects-DDjuwVqi.js} +160 -156
  60. package/dist/assets/{useInlineCommentController-CkfxMJPy.js → useInlineCommentController-Clg8EO3A.js} +2 -2
  61. package/dist/assets/{useRootScrollLock-BtiAC1fj.js → useRootScrollLock-nDcRrgJ1.js} +2 -2
  62. package/dist/assets/{vendor-codemirror-language-data-U-lXzXNK.js → vendor-codemirror-language-data-oKF0MAwd.js} +2 -2
  63. package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
  64. package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
  65. package/dist/assets/{web-components-C583Pmfm.js → web-components-Bc1WPd4i.js} +1 -1
  66. package/dist/assets/zh-CN-DnAweIWo.js +16 -0
  67. package/dist/assets/zh-TW-BbBBK25p.js +16 -0
  68. package/dist/index.html +5 -5
  69. package/dist/mini-chat.html +5 -5
  70. package/dist/mobile.html +5 -5
  71. package/package.json +3 -3
  72. package/server/index.d.ts +2 -0
  73. package/server/index.js +196 -52
  74. package/server/lib/agent-tool/DOCUMENTATION.md +5 -0
  75. package/server/lib/agent-tool/runtime.js +89 -51
  76. package/server/lib/agent-tool/runtime.test.js +17 -0
  77. package/server/lib/browser-control/DOCUMENTATION.md +14 -2
  78. package/server/lib/event-stream/DOCUMENTATION.md +2 -2
  79. package/server/lib/event-stream/global-hub.js +45 -7
  80. package/server/lib/event-stream/global-hub.test.js +128 -2
  81. package/server/lib/event-stream/global-ws-bridge.js +3 -1
  82. package/server/lib/event-stream/rebind.test.js +1 -0
  83. package/server/lib/event-stream/runtime.js +1 -2
  84. package/server/lib/event-stream/upstream-reader.js +3 -2
  85. package/server/lib/git/DOCUMENTATION.md +1 -1
  86. package/server/lib/git/service.js +22 -0
  87. package/server/lib/git/service.test.js +8 -0
  88. package/server/lib/guests/DOCUMENTATION.md +4 -3
  89. package/server/lib/guests/catalog.js +19 -1
  90. package/server/lib/guests/catalog.test.js +22 -0
  91. package/server/lib/guests/html-styles.js +5 -3
  92. package/server/lib/guests/html-styles.test.js +3 -1
  93. package/server/lib/guests/routes.js +11 -3
  94. package/server/lib/guests/service.js +5 -0
  95. package/server/lib/guests/surface.js +55 -9
  96. package/server/lib/guests/surface.test.js +56 -6
  97. package/server/lib/notifications/DOCUMENTATION.md +10 -0
  98. package/server/lib/notifications/emit-route.js +118 -0
  99. package/server/lib/notifications/emit-route.test.js +112 -0
  100. package/server/lib/openchamber-control/actions.js +16 -2
  101. package/server/lib/openchamber-control/service.js +23 -0
  102. package/server/lib/openchamber-control/service.test.js +31 -0
  103. package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
  104. package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
  105. package/server/lib/openchamber-sessions/routes.js +11 -0
  106. package/server/lib/openchamber-sessions/routes.test.js +18 -0
  107. package/server/lib/opencode/DOCUMENTATION.md +23 -23
  108. package/server/lib/opencode/bootstrap-runtime.js +14 -1
  109. package/server/lib/opencode/compatibility.js +6 -3
  110. package/server/lib/opencode/compatibility.test.js +8 -0
  111. package/server/lib/opencode/config-entity-v2.test.js +27 -0
  112. package/server/lib/opencode/config-paths.test.js +12 -0
  113. package/server/lib/opencode/config-v2.d.ts +2 -0
  114. package/server/lib/opencode/config-v2.js +42 -0
  115. package/server/lib/opencode/config-v2.test.js +17 -0
  116. package/server/lib/opencode/core-routes.js +20 -4
  117. package/server/lib/opencode/core-routes.test.js +41 -0
  118. package/server/lib/opencode/env-runtime.js +1 -8
  119. package/server/lib/opencode/feature-routes-runtime.js +2 -1
  120. package/server/lib/opencode/lifecycle.js +156 -2
  121. package/server/lib/opencode/lifecycle.test.js +255 -1
  122. package/server/lib/opencode/managed-config-file.js +18 -6
  123. package/server/lib/opencode/managed-config-file.test.js +23 -7
  124. package/server/lib/opencode/mcp.js +7 -2
  125. package/server/lib/opencode/network-runtime.js +16 -7
  126. package/server/lib/opencode/network-runtime.test.js +29 -1
  127. package/server/lib/opencode/npm-registry-config.d.ts +1 -0
  128. package/server/lib/opencode/npm-registry-config.js +123 -0
  129. package/server/lib/opencode/npm-registry-config.test.js +167 -0
  130. package/server/lib/opencode/npm-registry.js +5 -6
  131. package/server/lib/opencode/npm-registry.test.js +30 -0
  132. package/server/lib/opencode/openchamber-routes.js +29 -11
  133. package/server/lib/opencode/openchamber-routes.test.js +18 -0
  134. package/server/lib/opencode/project-directory-runtime.js +8 -0
  135. package/server/lib/opencode/project-directory-runtime.test.js +13 -0
  136. package/server/lib/opencode/provider-source-route.test.js +59 -0
  137. package/server/lib/opencode/providers.js +3 -0
  138. package/server/lib/opencode/providers.test.js +62 -0
  139. package/server/lib/opencode/proxy.js +53 -16
  140. package/server/lib/opencode/routes-upgrade.test.js +1 -1
  141. package/server/lib/opencode/routes.js +36 -1
  142. package/server/lib/opencode/server-startup-runtime.js +25 -0
  143. package/server/lib/opencode/server-startup-runtime.test.js +67 -1
  144. package/server/lib/opencode/server-utils-runtime.js +5 -0
  145. package/server/lib/opencode/settings-helpers.js +10 -0
  146. package/server/lib/opencode/settings-helpers.test.js +2 -2
  147. package/server/lib/opencode/settings-registry.json +17 -0
  148. package/server/lib/opencode/settings-runtime.js +1 -0
  149. package/server/lib/opencode/shared-service-runtime.js +155 -0
  150. package/server/lib/opencode/shared-service-runtime.test.js +180 -0
  151. package/server/lib/opencode/shared.js +49 -4
  152. package/server/lib/opencode/shared.test.js +78 -1
  153. package/server/lib/opencode/shutdown-runtime.js +14 -5
  154. package/server/lib/opencode/shutdown-runtime.test.js +77 -2
  155. package/server/lib/opencode/skill-routes.js +28 -12
  156. package/server/lib/opencode/skill-routes.test.js +121 -1
  157. package/server/lib/opencode/startup-pipeline-runtime.js +5 -4
  158. package/server/lib/opencode/startup-pipeline-runtime.test.js +18 -7
  159. package/server/lib/opencode/upgrade-capability.js +0 -14
  160. package/server/lib/opencode/v1-migration-topup.js +21 -15
  161. package/server/lib/opencode/v1-migration-topup.test.js +14 -6
  162. package/server/lib/opencode/v2-install.d.ts +1 -0
  163. package/server/lib/opencode/v2-install.js +8 -7
  164. package/server/lib/opencode/v2-install.test.js +13 -4
  165. package/server/lib/opencode/watcher.js +3 -1
  166. package/server/lib/opencode/watcher.test.js +54 -0
  167. package/server/lib/opencode/websearch-config.js +13 -1
  168. package/server/lib/quota/providers/kimi.js +1 -1
  169. package/server/lib/realtime-proxy.js +5 -1
  170. package/server/lib/realtime-proxy.test.js +29 -0
  171. package/server/lib/relay/tunnel-host.js +4 -1
  172. package/server/lib/relay/tunnel-host.test.js +11 -0
  173. package/server/lib/session-assist/DOCUMENTATION.md +17 -5
  174. package/server/lib/session-assist/runtime.js +19 -2
  175. package/server/lib/session-assist/runtime.test.js +8 -0
  176. package/server/lib/session-goal/DOCUMENTATION.md +11 -0
  177. package/server/lib/small-model/DOCUMENTATION.md +11 -10
  178. package/server/lib/small-model/index.js +27 -32
  179. package/server/lib/small-model/index.test.js +28 -16
  180. package/server/lib/spaces/DOCUMENTATION.md +404 -32
  181. package/server/lib/spaces/code-in-bait.js +145 -6
  182. package/server/lib/spaces/code-in.js +10 -150
  183. package/server/lib/spaces/code-in.test.js +7 -83
  184. package/server/lib/spaces/code-out.js +1819 -0
  185. package/server/lib/spaces/code-out.test.js +3945 -0
  186. package/server/lib/spaces/code-transfer.js +173 -0
  187. package/server/lib/spaces/command-stream.test.js +181 -0
  188. package/server/lib/spaces/dispatcher.js +670 -0
  189. package/server/lib/spaces/dispatcher.test.js +582 -0
  190. package/server/lib/spaces/gatekeeper-channel.js +61 -7
  191. package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
  192. package/server/lib/spaces/gatekeeper-program.cjs +68 -14
  193. package/server/lib/spaces/gatekeeper-program.test.js +118 -3
  194. package/server/lib/spaces/hardening.js +20 -4
  195. package/server/lib/spaces/hardening.test.js +18 -3
  196. package/server/lib/spaces/host-git.js +7 -4
  197. package/server/lib/spaces/host-git.test.js +6 -2
  198. package/server/lib/spaces/host.js +310 -0
  199. package/server/lib/spaces/host.test.js +273 -0
  200. package/server/lib/spaces/journey.js +552 -0
  201. package/server/lib/spaces/journey.test.js +589 -0
  202. package/server/lib/spaces/layout.js +42 -8
  203. package/server/lib/spaces/manager.js +4 -3
  204. package/server/lib/spaces/manager.test.js +6 -0
  205. package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
  206. package/server/lib/spaces/places/contract-suite.js +36 -0
  207. package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
  208. package/server/lib/spaces/places/docker-live-support.js +27 -4
  209. package/server/lib/spaces/places/docker.js +33 -5
  210. package/server/lib/spaces/places/docker.test.js +42 -3
  211. package/server/lib/spaces/places/escape-suite.js +118 -14
  212. package/server/lib/spaces/places/fake-docker.js +3 -1
  213. package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
  214. package/server/lib/spaces/places/memory-place.js +24 -1
  215. package/server/lib/spaces/places/registry.js +1 -1
  216. package/server/lib/spaces/places/server.docker.live.test.js +69 -33
  217. package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
  218. package/server/lib/spaces/routes.js +216 -0
  219. package/server/lib/spaces/routes.test.js +213 -0
  220. package/server/lib/spaces/run-command.js +261 -8
  221. package/server/lib/spaces/run-command.test.js +193 -3
  222. package/server/lib/spaces/space-events.js +153 -0
  223. package/server/lib/spaces/space-events.test.js +160 -0
  224. package/server/lib/spaces/space-opencode.js +52 -0
  225. package/server/lib/spaces/space-opencode.test.js +41 -0
  226. package/server/lib/spaces/space-records.js +150 -0
  227. package/server/lib/spaces/space-records.test.js +113 -0
  228. package/server/lib/spaces/space-sessions.js +210 -0
  229. package/server/lib/spaces/space-sessions.test.js +161 -0
  230. package/server/lib/spaces/websocket.js +329 -0
  231. package/server/lib/spaces/websocket.test.js +345 -0
  232. package/server/lib/ui-auth/ui-auth.js +8 -2
  233. package/server/lib/ui-auth/ui-auth.test.js +29 -0
  234. package/server/opencode-proxy.test.js +271 -0
  235. package/server/proxy-headers.js +8 -2
  236. package/server/proxy-headers.test.js +20 -0
  237. package/dist/assets/DiffView-CX3wAX2S.js +0 -4
  238. package/dist/assets/FilesView-OL3CON3r.js +0 -17
  239. package/dist/assets/GuestSurfacePane-B_cqX5I5.js +0 -1
  240. package/dist/assets/MarkdownImageGallery-DFiTFZgj.js +0 -1
  241. package/dist/assets/MarkdownRendererImpl-DZm5sf1W.js +0 -12
  242. package/dist/assets/PluginPane-BibJit86.js +0 -3
  243. package/dist/assets/SettingsView-h-VpVO47.js +0 -29
  244. package/dist/assets/commitAndPush-BIPfOMXY.js +0 -1
  245. package/dist/assets/de-BqCNGROu.js +0 -10
  246. package/dist/assets/es-BTPpTRgN.js +0 -16
  247. package/dist/assets/fr-hxbzMdAg.js +0 -14
  248. package/dist/assets/index-CLcM8HKx.css +0 -1
  249. package/dist/assets/index-CvLgnuNI.js +0 -41
  250. package/dist/assets/ja-_zLwwYiy.js +0 -16
  251. package/dist/assets/ko-CFn3Dzhc.js +0 -16
  252. package/dist/assets/main-BLJaug9I.js +0 -896
  253. package/dist/assets/markdownCore-DS9FdwrI.js +0 -5
  254. package/dist/assets/patchFileDiff-D9iBcTEi.js +0 -13
  255. package/dist/assets/pl-DFHE7xac.js +0 -16
  256. package/dist/assets/pt-BR-CUjZF179.js +0 -16
  257. package/dist/assets/renderMobileApp-CNQhdXsi.js +0 -25
  258. package/dist/assets/runtimeEndpointReset-DeaERc0M.js +0 -27
  259. package/dist/assets/scheduledTasksApi-BD6Vx9pu.js +0 -1
  260. package/dist/assets/tr-BB6sA_zH.js +0 -7
  261. package/dist/assets/uk-CB4o8g-o.js +0 -16
  262. package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
  263. package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
  264. package/dist/assets/zh-CN-BRyDuWhc.js +0 -16
  265. package/dist/assets/zh-TW-BDGbWA1Q.js +0 -16
@@ -5,6 +5,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
5
5
 
6
6
  import { createSpaceId, hashProjectDirectory, toolsKeyFromVolumeName } from '../labels.js';
7
7
  import { SPACE_SERVER_PORT, SPACE_TOKEN_PATH, TOOLS_MOUNT_PATH } from '../layout.js';
8
+ import { createGatekeeperChannel } from '../gatekeeper-channel.js';
8
9
  import { createSpaceServerChannel } from '../space-server.js';
9
10
  import { createRegistryToolsSource, readHostToolVersions } from '../tools.js';
10
11
  import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
@@ -51,11 +52,12 @@ setTimeout(() => finish({ error: 'timeout' }), 30000);
51
52
  })().catch((error) => finish({ error: String(error) }));
52
53
  `;
53
54
 
54
- const HELLO_TOOL = `import { tool } from "@opencode/plugin"
55
- export default tool({
56
- description: "Says hello",
57
- args: { name: tool.schema.string() },
58
- async execute(args) { return "hello " + args.name },
55
+ // A project plugin in the form OpenCode 2 loads from `.opencode/plugins/`. It imports the
56
+ // plugin package, which is the import that needs the link above the projects.
57
+ const HELLO_PLUGIN = `import { Plugin } from "@opencode/plugin"
58
+ export default Plugin.define({
59
+ id: "hello",
60
+ setup: async () => {},
59
61
  })
60
62
  `;
61
63
 
@@ -69,6 +71,7 @@ describe.skipIf(!LIVE_DOCKER_ENABLED)('server inside a space: docker (live)', ()
69
71
  };
70
72
  const repo = `/spaces/${spec.id}/repo`;
71
73
  const link = `/spaces/${spec.id}/link-to-repo`;
74
+ const plugin = `${repo}/.opencode/plugins/hello.ts`;
72
75
  let place;
73
76
  let placeWith;
74
77
  let host;
@@ -88,6 +91,14 @@ describe.skipIf(!LIVE_DOCKER_ENABLED)('server inside a space: docker (live)', ()
88
91
  return login.headers['set-cookie'][0].split(';')[0];
89
92
  };
90
93
  const health = async () => JSON.parse((await server.request(spec.id, { path: '/health' })).body);
94
+ // OpenCode 2 answers with the record under `data`.
95
+ const api = async (path, { method = 'GET', body } = {}) => {
96
+ const answer = await server.request(spec.id, { method, path, headers: body === undefined ? { Cookie: cookie } : { ...JSON_HEADERS, Cookie: cookie }, body });
97
+ expect(answer.status, `${method} ${path}: ${answer.body}`).toBe(200);
98
+ return JSON.parse(answer.body).data;
99
+ };
100
+ const createSession = (directory) => api('/api/session', { method: 'POST', body: JSON.stringify({ location: { directory } }) });
101
+ const listSessions = async (directory) => (await api(`/api/session?directory=${encodeURIComponent(directory)}`)).map((entry) => entry.id);
91
102
 
92
103
  beforeAll(async () => {
93
104
  ({ place, placeWith, host, owner, dispose } = createLiveDockerPlace());
@@ -115,45 +126,70 @@ describe.skipIf(!LIVE_DOCKER_ENABLED)('server inside a space: docker (live)', ()
115
126
  expect(await shell('tr "\\0" "\\n" < /proc/1/environ')).not.toContain('OPENCHAMBER_UI_PASSWORD');
116
127
  });
117
128
 
118
- // The open question of stage 0: does OpenCode report the space path unchanged?
129
+ // The open question of stage 0: does OpenCode report the space path unchanged? OpenCode 2
130
+ // takes the directory from `location` in the body. It ignores `?directory=` on this route, and
131
+ // such a session lands in HOME, so the dispatcher must never rely on the query here.
119
132
  it('reports the directory of a session byte for byte as the space path', async () => {
120
133
  await shell(`mkdir -p ${repo} && cd ${repo} && git init -q . && git -c user.email=space@example.invalid -c user.name=Space commit -q --allow-empty -m init`);
121
134
 
122
- const created = await server.request(spec.id, { method: 'POST', path: `/api/session?directory=${encodeURIComponent(repo)}`, headers: { ...JSON_HEADERS, Cookie: cookie }, body: '{}' });
123
- expect(created.status).toBe(200);
124
- const session = JSON.parse(created.body);
125
- expect(session.directory).toBe(repo);
126
-
127
- const readBack = await server.request(spec.id, { path: `/api/session/${session.id}?directory=${encodeURIComponent(repo)}`, headers: { Cookie: cookie } });
128
- expect(JSON.parse(readBack.body).directory).toBe(repo);
135
+ const session = await createSession(repo);
136
+ expect(session.location.directory).toBe(repo);
137
+ expect((await api(`/api/session/${session.id}`)).location.directory).toBe(repo);
138
+ expect(await listSessions(repo)).toEqual([session.id]);
129
139
 
130
- const listed = await server.request(spec.id, { path: `/api/session?directory=${encodeURIComponent(repo)}`, headers: { Cookie: cookie } });
131
- expect(JSON.parse(listed.body).map((entry) => entry.directory)).toEqual([repo]);
140
+ const byQuery = await api(`/api/session?directory=${encodeURIComponent(repo)}`, { method: 'POST', body: '{}' });
141
+ expect(byQuery.location.directory).toBe('/home/space');
142
+ expect(await listSessions(repo)).toEqual([session.id]);
132
143
  });
133
144
 
134
- // Recorded, not wished for: OpenCode 1.18.31 resolves a symlink and reports the real path.
135
- // The dispatcher stage must therefore hand OpenCode real paths only.
136
- it('reports the real path for a directory that was reached through a symlink', async () => {
145
+ // Recorded, not wished for. OpenCode 1.18.31 resolved a symlink and reported the real path.
146
+ // OpenCode 2 keeps the link path, says how it relates to the project in `subpath`, and leaves
147
+ // the session out of the list for the real path. OpenCode no longer normalises anything, so
148
+ // handing it real paths only is the whole defence, and the dispatcher stage must do it.
149
+ it('reports the link path for a directory that was reached through a symlink', async () => {
137
150
  await shell(`ln -sfn ${repo} ${link}`);
151
+ const before = await listSessions(repo);
138
152
 
139
- const created = await server.request(spec.id, { method: 'POST', path: `/api/session?directory=${encodeURIComponent(link)}`, headers: { ...JSON_HEADERS, Cookie: cookie }, body: '{}' });
140
- expect(created.status).toBe(200);
141
- expect(JSON.parse(created.body).directory).toBe(repo);
153
+ const session = await createSession(link);
154
+ expect(session.location.directory).toBe(link);
155
+ expect(session.subpath).toBe('../link-to-repo');
156
+ expect(await listSessions(repo)).toEqual(before);
142
157
  });
143
158
 
144
- it('lists a project tool that imports @opencode/plugin, without waiting for a download that cannot happen', async () => {
145
- await shell(`mkdir -p ${repo}/.opencode/tool && cat > ${repo}/.opencode/tool/hello.ts`, HELLO_TOOL);
146
- // Only the plugin is linked above the projects, not the whole tools node_modules.
147
- expect(await shell(`ls /spaces/${spec.id}/node_modules /spaces/${spec.id}/node_modules/@opencode`)).toBe(`/spaces/${spec.id}/node_modules:\n@opencode\n\n/spaces/${spec.id}/node_modules/@opencode:\nplugin\n`);
148
- // The instance of the earlier tests loaded before the tool existed.
149
- await server.request(spec.id, { method: 'POST', path: `/api/instance/dispose?directory=${encodeURIComponent(repo)}`, headers: { Cookie: cookie } });
159
+ // With no network, a project plugin that imports @opencode/plugin must load from the link above
160
+ // the projects. OpenCode 2 does not download the package for a local plugin: without the link
161
+ // the plugin fails at once with "Cannot find package". OpenCode 1 waited 131 seconds for a
162
+ // background install instead. The state check is what turns red when the link goes.
163
+ it('loads a project plugin that imports @opencode/plugin, without a download', async () => {
164
+ await shell(`mkdir -p "$(dirname ${plugin})" && cat > ${plugin}`, HELLO_PLUGIN);
165
+ const location = `location%5Bdirectory%5D=${encodeURIComponent(repo)}`;
150
166
 
151
167
  const started = Date.now();
152
- const answer = await server.request(spec.id, { path: `/api/experimental/tool/ids?directory=${encodeURIComponent(repo)}`, headers: { Cookie: cookie } });
153
- expect(answer.status).toBe(200);
154
- expect(JSON.parse(answer.body)).toContain('hello');
155
- // Without npm_config_fetch_retries=0 this took 131 seconds.
168
+ // The location of the earlier tests loaded before the plugin existed.
169
+ const reload = await server.request(spec.id, { method: 'POST', path: '/api/location/reload', headers: { Cookie: cookie } });
170
+ expect(reload.status).toBe(204);
171
+ // OpenCode loads plugins in the background, so the list can show this one before it has
172
+ // loaded, or not yet at all. Only `active` or `failed` is an answer.
173
+ let entry;
174
+ for (;;) {
175
+ entry = (await api(`/api/plugin?${location}`)).find((candidate) => candidate.source?.path === plugin);
176
+ if (['active', 'failed'].includes(entry?.state?.status) || Date.now() - started >= 15_000) break;
177
+ await new Promise((resolve) => { setTimeout(resolve, 250); });
178
+ }
179
+ expect(entry, 'OpenCode never listed the project plugin').toBeDefined();
180
+ // OpenCode answers a failure with a generic text and a ref. Its log has the cause under that ref.
181
+ const cause = entry.state?.ref
182
+ ? (await place.exec(spec.id, ['sh', '-c', 'grep -h -F -- "$1" "$HOME"/.local/share/opencode/log/*.log | tail -1', 'sh', entry.state.ref])).stdout.trim()
183
+ : '';
184
+ expect(entry.state, cause).toEqual({ status: 'active' });
156
185
  expect(Date.now() - started).toBeLessThan(15_000);
186
+
187
+ // Nothing tried to fetch a package for it.
188
+ const journal = await createGatekeeperChannel({ exec: place.exec }).readJournal(spec.id);
189
+ expect(journal.records.filter((record) => /npm/i.test(record.host))).toEqual([]);
190
+
191
+ // Only the plugin is linked above the projects, not the whole tools node_modules.
192
+ expect(await shell(`ls /spaces/${spec.id}/node_modules /spaces/${spec.id}/node_modules/@opencode`)).toBe(`/spaces/${spec.id}/node_modules:\n@opencode\n\n/spaces/${spec.id}/node_modules/@opencode:\nplugin\n`);
157
193
  }, 60_000);
158
194
 
159
195
  it('runs commands in a terminal as uid 1000, through the terminal WebSocket', async () => {
@@ -173,7 +209,7 @@ describe.skipIf(!LIVE_DOCKER_ENABLED)('server inside a space: docker (live)', ()
173
209
 
174
210
  expect(await health()).toMatchObject({ isOpenCodeReady: true });
175
211
  expect(await server.readToken(spec.id)).toBe(tokenBefore);
176
- expect(await shell(`cat ${repo}/.opencode/tool/hello.ts`)).toBe(HELLO_TOOL);
212
+ expect(await shell(`cat ${plugin}`)).toBe(HELLO_PLUGIN);
177
213
  expect(await place.verify(spec.id)).toEqual([]);
178
214
  }, 5 * 60_000);
179
215
 
@@ -197,7 +233,7 @@ describe.skipIf(!LIVE_DOCKER_ENABLED)('server inside a space: docker (live)', ()
197
233
  expect(await updatedPlace.verify(spec.id)).toEqual([]);
198
234
  expect(await health()).toMatchObject({ isOpenCodeReady: true });
199
235
  expect(await server.readToken(spec.id)).toBe(tokenBefore);
200
- expect(await shell(`cat ${repo}/.opencode/tool/hello.ts`)).toBe(HELLO_TOOL);
236
+ expect(await shell(`cat ${plugin}`)).toBe(HELLO_PLUGIN);
201
237
  expect(await updatedPlace.list()).toMatchObject([{ id: spec.id, state: 'running', damaged: false }]);
202
238
  // No container mounts the old tools volume any more, so it is gone.
203
239
  expect((await host.volumes()).filter((name) => name.startsWith('openchamber-tools-'))).toEqual([after]);
@@ -0,0 +1,222 @@
1
+ // Sessions and events of a real space on a real Docker daemon. Runs only with
2
+ // OPENCHAMBER_TEST_DOCKER=1. The host of `host.js` is mounted the way `server/index.js` mounts
3
+ // it: the dispatcher and the socket forwarder in front of an Express app, the real OpenCode
4
+ // proxy with a stand-in for the host's own OpenCode, and the real event hub.
5
+
6
+ import fs from 'node:fs';
7
+ import http from 'node:http';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ import express from 'express';
12
+ import { WebSocket } from 'ws';
13
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
14
+
15
+ import { createGlobalMessageStreamHub } from '../../event-stream/global-hub.js';
16
+ import { registerOpenCodeProxy } from '../../opencode/proxy.js';
17
+ import { runCommand } from '../run-command.js';
18
+ import { createSpacesHost } from '../host.js';
19
+ import { ROLE_SPACE, createSpaceId, hashProjectDirectory, spaceResourceName } from '../labels.js';
20
+ import { LIVE_DOCKER_ENABLED, createLiveDockerPlace } from './docker-live-support.js';
21
+
22
+ const CREATE_TIMEOUT_MS = 25 * 60_000;
23
+ const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
24
+ const until = async (check, timeoutMs = 10_000) => {
25
+ const deadline = Date.now() + timeoutMs;
26
+ while (!check() && Date.now() < deadline) await sleep(50);
27
+ return check();
28
+ };
29
+ const listen = (app) => new Promise((resolve) => {
30
+ const server = http.createServer(app);
31
+ server.listen(0, '127.0.0.1', () => resolve(server));
32
+ });
33
+ const frame = (payload) => Buffer.concat([Buffer.from([1]), Buffer.from(JSON.stringify(payload), 'utf8')]);
34
+
35
+ describe.skipIf(!LIVE_DOCKER_ENABLED)('sessions and events of a real space: docker (live)', () => {
36
+ const spec = {
37
+ id: createSpaceId(),
38
+ name: 'Sessions live',
39
+ project: hashProjectDirectory('/sessions/live/project'),
40
+ created: new Date().toISOString(),
41
+ memoryBytes: 2 * 1024 * 1024 * 1024,
42
+ };
43
+ const repo = `/spaces/${spec.id}/repo`;
44
+ const hostSession = { id: 'ses_host_1', location: { directory: '/home/me/project' }, title: 'Host session' };
45
+ let place;
46
+ let dispose = async () => {};
47
+ let dataDir;
48
+ let host;
49
+ let hub;
50
+ let upstream;
51
+ let server;
52
+ let hostRouteRuns = 0;
53
+ const received = [];
54
+ const url = (suffix) => `http://127.0.0.1:${server.address().port}${suffix}`;
55
+ const wsUrl = (suffix) => `ws://127.0.0.1:${server.address().port}${suffix}`;
56
+ const shell = async (script) => {
57
+ const result = await place.exec(spec.id, ['sh', '-c', script]);
58
+ expect(result.code, `${script}: ${result.stderr}`).toBe(0);
59
+ return result.stdout;
60
+ };
61
+
62
+ beforeAll(async () => {
63
+ ({ place, dispose } = createLiveDockerPlace());
64
+ await place.create(spec);
65
+ await shell(`mkdir -p ${repo} && cd ${repo} && git init -q . && git -c user.email=space@example.invalid -c user.name=Space commit -q --allow-empty -m init`);
66
+ dataDir = temporary('openchamber-sessions-live-');
67
+ host = createSpacesHost({ dataDir, place, logger: { warn: () => {} } });
68
+
69
+ // The host's own OpenCode: one session, an event stream that stays open.
70
+ const upstreamApp = express();
71
+ upstreamApp.get('/api/session', (_req, res) => res.json({ data: [hostSession], cursor: {} }));
72
+ upstreamApp.get('/api/event', (_req, res) => {
73
+ res.setHeader('content-type', 'text/event-stream');
74
+ res.flushHeaders();
75
+ res.write(`data: ${JSON.stringify({ id: 'host-e1', type: 'server.connected', data: {} })}\n\n`);
76
+ });
77
+ upstream = await listen(upstreamApp);
78
+ const upstreamPort = upstream.address().port;
79
+ hub = createGlobalMessageStreamHub({ buildOpenCodeUrl: (p) => `http://127.0.0.1:${upstreamPort}${p}`, getOpenCodeAuthHeaders: () => ({}), deltaCoalesceWindowMs: 0 });
80
+ hub.subscribeEvent((event) => received.push(event), { spaces: true });
81
+
82
+ const app = express();
83
+ app.use(host.middleware);
84
+ app.all('/api/git/status', (req, res) => { hostRouteRuns += 1; res.json({ ranOnHost: true, directory: req.query.directory ?? null }); });
85
+ registerOpenCodeProxy(app, {
86
+ fs: {}, os: {}, path, OPEN_CODE_READY_GRACE_MS: 0,
87
+ getRuntime: () => ({ openCodePort: upstreamPort, isOpenCodeReady: true, openCodeNotReadySince: 0, isRestartingOpenCode: false }),
88
+ getOpenCodeAuthHeaders: () => ({}),
89
+ buildOpenCodeUrl: (p) => `http://127.0.0.1:${upstreamPort}${p}`,
90
+ ensureOpenCodeApiPrefix: () => {},
91
+ mergeSpaceSessionList: (payload) => host.mergeSessionList(payload),
92
+ spaceEventHub: hub,
93
+ });
94
+ server = await listen(app);
95
+ host.prepareUpgrades({ uiAuthController: { enabled: false }, isRequestOriginAllowed: async () => true });
96
+ server.on('upgrade', host.upgradeHandler);
97
+ await host.startEvents(hub);
98
+ }, CREATE_TIMEOUT_MS);
99
+
100
+ afterAll(async () => {
101
+ host?.close();
102
+ hub?.stop();
103
+ for (const running of [server, upstream]) {
104
+ await new Promise((resolve) => { if (running) { running.closeAllConnections?.(); running.close(() => resolve()); } else resolve(); });
105
+ }
106
+ await dispose();
107
+ if (dataDir) fs.rmSync(dataDir, { recursive: true, force: true });
108
+ });
109
+
110
+ it('runs a command inside as uid 1000 over the terminal socket under the prefix', async () => {
111
+ const created = await fetch(url(`/api/spaces/${spec.id}/terminal/create`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cwd: repo, cols: 80, rows: 24 }) });
112
+ expect(created.status, await created.clone().text()).toBe(200);
113
+ const { sessionId } = await created.json();
114
+ const socket = new WebSocket(wsUrl(`/api/spaces/${spec.id}/terminal/ws`), { headers: { origin: 'http://app.test' } });
115
+ let output = '';
116
+ let backend = null;
117
+ const done = new Promise((resolve, reject) => {
118
+ socket.on('error', reject);
119
+ socket.on('open', () => socket.send(frame({ t: 'attach', v: 3, s: sessionId })));
120
+ socket.on('message', (raw) => {
121
+ const message = JSON.parse(raw.subarray(1).toString('utf8'));
122
+ if (message.t === 'snapshot') {
123
+ backend = message.ptyBackend;
124
+ socket.send(frame({ t: 'write', v: 3, s: sessionId, d: 'echo space-$((40+2)); id -u\r' }));
125
+ }
126
+ if (message.t === 'output') output += message.d;
127
+ if (/space-42[\s\S]*\n1000\r?\n/.test(output)) resolve();
128
+ });
129
+ setTimeout(() => reject(new Error(`no answer from the terminal in time; output so far: ${output}`)), 30_000).unref();
130
+ });
131
+ await done;
132
+ expect(backend).toBe('node-pty');
133
+ expect(output).toMatch(/space-42\r?\n1000\r?\n/);
134
+ socket.close();
135
+ expect((await fetch(url(`/api/spaces/${spec.id}/terminal/${sessionId}`), { method: 'DELETE' })).status).toBe(200);
136
+ }, 90_000);
137
+
138
+ it('lists a session of the space in the merged list with its directory, and carries its events to the host\'s streams', async () => {
139
+ const controller = new AbortController();
140
+ const sse = await fetch(url('/api/global/event'), { headers: { accept: 'text/event-stream' }, signal: controller.signal });
141
+ expect(sse.status).toBe(200);
142
+ const reader = sse.body.getReader();
143
+ const decoder = new TextDecoder();
144
+ let text = '';
145
+ const readInBackground = (async () => {
146
+ while (true) {
147
+ const { value, done } = await reader.read();
148
+ if (done) break;
149
+ text += decoder.decode(value, { stream: true });
150
+ }
151
+ })().catch(() => {});
152
+ expect(await until(() => text.includes('host-e1'))).toBe(true);
153
+ // The space's stream is connected before its events can be trusted to arrive.
154
+ expect(await until(() => received.some((event) => event.payload.type === 'openchamber:space-stream' && event.payload.properties.status === 'connected'), 30_000)).toBe(true);
155
+
156
+ const created = await fetch(url(`/api/spaces/${spec.id}/session`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ location: { directory: repo }, title: 'Inside the space' }) });
157
+ expect(created.status, await created.clone().text()).toBe(200);
158
+ const session = (await created.json()).data;
159
+ expect(session.location.directory).toBe(repo);
160
+
161
+ const merged = await (await fetch(url('/api/session'))).json();
162
+ const ids = merged.data.map((entry) => entry.id);
163
+ expect(ids).toContain(hostSession.id);
164
+ expect(ids).toContain(session.id);
165
+ expect(merged.data.find((entry) => entry.id === session.id).location.directory).toBe(repo);
166
+ // Since 4c each space entry carries its name and the project it was made for; this space's
167
+ // project is not one the host registered, so both of those are null here.
168
+ expect(merged.spaces).toEqual([{ id: spec.id, name: spec.name, projectDirectory: null, directory: null, state: 'complete', sessions: expect.any(Number) }]);
169
+
170
+ expect(await until(() => received.some((event) => event.spaceId === spec.id && event.payload.type === 'session.created' && event.payload.data?.sessionID === session.id), 30_000)).toBe(true);
171
+ expect(await until(() => text.includes(session.id), 30_000)).toBe(true);
172
+ controller.abort();
173
+ await readInBackground;
174
+ }, 120_000);
175
+
176
+ it('keeps a stopped space\'s last list as stale, and the host\'s list whole', async () => {
177
+ await place.stop(spec.id);
178
+ // The last accepted list is served for two seconds without asking; after that the space is asked and does not answer.
179
+ await sleep(2_100);
180
+ const merged = await (await fetch(url('/api/session'))).json();
181
+ expect(merged.data.map((entry) => entry.id)).toContain(hostSession.id);
182
+ expect(merged.spaces).toEqual([{ id: spec.id, name: spec.name, projectDirectory: null, directory: null, state: 'stale', sessions: expect.any(Number) }]);
183
+ expect(merged.spaces[0].sessions).toBeGreaterThan(0);
184
+ const socket = await new Promise((resolve) => {
185
+ const attempt = new WebSocket(wsUrl(`/api/spaces/${spec.id}/terminal/ws`), { headers: { origin: 'http://app.test' } });
186
+ attempt.on('unexpected-response', (_request, response) => resolve({ status: response.statusCode }));
187
+ attempt.on('error', () => {});
188
+ });
189
+ expect(socket.status).toBe(503);
190
+
191
+ await place.start(spec.id);
192
+ expect(await until(async () => (await (await fetch(url('/api/session'))).json()).spaces[0].state === 'complete', 60_000)).toBe(true);
193
+ }, 5 * 60_000);
194
+
195
+ // The check of the 4b row in STAGES.md: the space killed in the middle of its work.
196
+ it('leaves the host\'s sessions and routes untouched when the space is killed under an open socket', async () => {
197
+ const created = await fetch(url(`/api/spaces/${spec.id}/terminal/create`), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cwd: repo, cols: 80, rows: 24 }) });
198
+ expect(created.status).toBe(200);
199
+ const { sessionId } = await created.json();
200
+ const socket = new WebSocket(wsUrl(`/api/spaces/${spec.id}/terminal/ws`), { headers: { origin: 'http://app.test' } });
201
+ await new Promise((resolve, reject) => { socket.on('open', resolve); socket.on('error', reject); });
202
+ socket.send(frame({ t: 'attach', v: 3, s: sessionId }));
203
+ socket.send(frame({ t: 'write', v: 3, s: sessionId, d: 'sleep 600\r' }));
204
+ const closed = new Promise((resolve) => socket.on('close', (code, reason) => resolve({ code, reason: reason.toString('utf8') })));
205
+
206
+ const kill = await runCommand('docker', ['kill', spaceResourceName(spec.id, ROLE_SPACE)]);
207
+ expect(kill.code, kill.stderr).toBe(0);
208
+ expect(await closed).toMatchObject({ code: expect.any(Number) });
209
+ await sleep(2_100);
210
+
211
+ const merged = await (await fetch(url('/api/session'))).json();
212
+ expect(merged.data.map((entry) => entry.id)).toContain(hostSession.id);
213
+ expect(merged.spaces[0]).toMatchObject({ id: spec.id, state: 'stale' });
214
+ const ordinary = await fetch(url('/api/git/status?directory=/tmp'));
215
+ expect(ordinary.status).toBe(200);
216
+ expect(hostRouteRuns).toBe(1);
217
+ // The host's own stream is still the host's: no space event of the dead space is invented.
218
+ expect(received.filter((event) => event.spaceId === spec.id && event.payload.type === 'openchamber:space-stream').at(-1).payload.properties.status).toBe('disconnected');
219
+ }, 120_000);
220
+ });
221
+
222
+ const temporary = (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix));
@@ -0,0 +1,216 @@
1
+ // The host's own routes of the isolated-spaces journey, under `/api/openchamber/spaces`: a
2
+ // namespace of the host's, never forwarded, beside `/api/openchamber/tunnel`. A request to the
3
+ // server inside a space is `/api/spaces/<id>/...` and belongs to the dispatcher; nothing here
4
+ // touches that shape.
5
+ //
6
+ // Every route but the switch exists only while the switch is on: `getJourney()` answers null
7
+ // while it is off, and then the journey routes say so with 404 and `isolated_spaces_off`, the same
8
+ // answer a route that does not exist would give. The switch itself is the one thing reachable
9
+ // while the feature is off, because turning it on is what makes the feature exist (decision 19).
10
+ // `/api/openchamber/spaces` is on the JSON-body allowlist of `core-routes.js`.
11
+
12
+ import { z } from 'zod';
13
+
14
+ import { SpaceError } from './errors.js';
15
+ import { isSpaceId } from './labels.js';
16
+
17
+ export const SPACES_ROUTE = '/api/openchamber/spaces';
18
+
19
+ // Which HTTP status a refusal of the journey gets. Everything not named here is 502 when it comes
20
+ // from the place or the space, and 500 when it is not a `SpaceError` at all.
21
+ const STATUS_BY_CODE = new Map([
22
+ ['space_not_found', 404],
23
+ ['place_not_found', 404],
24
+ ['project_not_registered', 400],
25
+ ['invalid_space_name', 400],
26
+ ['invalid_project_directory', 400],
27
+ ['invalid_snapshot_mode', 400],
28
+ ['invalid_network', 400],
29
+ ['invalid_apply_request', 400],
30
+ ['invalid_branch_name', 400],
31
+ ['invalid_space_id', 400],
32
+ ['invalid_request_body', 400],
33
+ ['invalid_grant_request', 400],
34
+ ['provider_not_supported', 400],
35
+ ['secret_source_missing', 409],
36
+ ['space_record_unreadable', 409],
37
+ ['space_preparing', 409],
38
+ ['space_creation_failed', 409],
39
+ ['space_busy', 409],
40
+ ['isolated_spaces_off', 404],
41
+ ['space_not_running', 409],
42
+ ['branch_exists', 409],
43
+ ['changes_do_not_apply', 409],
44
+ ['changes_route_closed', 409],
45
+ ['changes_partly_applied', 409],
46
+ ['changes_undecided', 409],
47
+ ['changes_too_large', 409],
48
+ ['changes_blocked_by_link', 409],
49
+ ['name_not_allowed_here', 409],
50
+ ['case_only_rename', 409],
51
+ ['patch_not_possible', 409],
52
+ ['nothing_to_apply', 409],
53
+ ['place_cannot_restrict_network', 409],
54
+ ['space_remove_incomplete', 502],
55
+ ]);
56
+
57
+ /** One JSON answer per failure, with a stable code. Details travel as data; a stack never does. */
58
+ export const answerFailure = (res, error) => {
59
+ if (error instanceof SpaceError) {
60
+ res.status(STATUS_BY_CODE.get(error.code) ?? 502).json({ code: error.code, message: error.message, details: error.details ?? null });
61
+ return;
62
+ }
63
+ res.status(500).json({ code: 'space_journey_failed', message: error?.message ?? String(error), details: null });
64
+ };
65
+
66
+ const isObjectRecord = (value) => value instanceof Object && !Array.isArray(value);
67
+ const switchBodySchema = z.object({ enabled: z.boolean() });
68
+
69
+ /**
70
+ * The switch as `index.js` runs it. `getHost()` and `setHost(host)` read and replace the running
71
+ * host, `buildHost()` makes one as the start would, `startHost(host)` connects it, and
72
+ * `persist(enabled)` writes the setting. One change runs at a time: a second request waits for
73
+ * the first and then sees what it left, so a double click builds one host and never two.
74
+ */
75
+ export function createSwitchController({ getHost, setHost, buildHost, startHost, persist }) {
76
+ let turning = Promise.resolve();
77
+ const inTurn = (work) => {
78
+ const next = turning.then(work, work);
79
+ turning = next.catch(() => {});
80
+ return next;
81
+ };
82
+
83
+ const readSwitch = async () => {
84
+ const host = getHost();
85
+ if (!host) return { enabled: false, spaces: [] };
86
+ try {
87
+ const spaces = await host.journey.listSpaces();
88
+ return { enabled: true, spaces: spaces.map(({ id, name, state }) => ({ id, name, state })) };
89
+ } catch (error) {
90
+ // The place cannot be asked, Docker being down among the reasons. The switch is still on and
91
+ // must still be reachable, so the answer says that the list is unknown rather than failing.
92
+ return { enabled: true, spaces: null, failure: { code: error instanceof SpaceError ? error.code : 'space_journey_failed', message: error?.message ?? String(error) } };
93
+ }
94
+ };
95
+
96
+ // Off: every space is stopped and its files kept, then the feature goes; a space that could not
97
+ // be stopped is reported as still running (decision 18). On: the setting is written and the
98
+ // feature comes up, as it would at the next start.
99
+ const setSwitch = (enabled) => inTurn(async () => {
100
+ const host = getHost();
101
+ if (enabled === Boolean(host)) return { enabled, stopped: [], stillRunning: [] };
102
+ if (!enabled) {
103
+ const outcome = await host.journey.stopAllSpaces();
104
+ try {
105
+ await persist(false);
106
+ } catch (error) {
107
+ host.journey.reopen();
108
+ throw error;
109
+ }
110
+ setHost(null);
111
+ host.close();
112
+ return { enabled: false, ...outcome };
113
+ }
114
+ await persist(true);
115
+ const made = buildHost();
116
+ startHost(made);
117
+ setHost(made);
118
+ return { enabled: true, stopped: [], stillRunning: [] };
119
+ });
120
+
121
+ return { readSwitch, setSwitch };
122
+ }
123
+
124
+ /**
125
+ * `getJourney()` is the journey of the running host or null, `getPlaces()` the places to check,
126
+ * `readSwitch()` and `setSwitch(enabled)` the switch: `setSwitch(false)` stops the spaces and takes
127
+ * the feature down, `setSwitch(true)` brings it up, and each resolves what the client shows.
128
+ */
129
+ export function registerSpaceRoutes(app, { getJourney, getPlaces = () => [], readSwitch, setSwitch }) {
130
+ const withJourney = (handler) => async (req, res) => {
131
+ const journey = getJourney();
132
+ if (!journey) {
133
+ res.status(404).json({ code: 'isolated_spaces_off', message: 'Isolated spaces are turned off.', details: null });
134
+ return;
135
+ }
136
+ try {
137
+ await handler(journey, req, res);
138
+ } catch (error) {
139
+ answerFailure(res, error);
140
+ }
141
+ };
142
+
143
+ const spaceIdOf = (req) => {
144
+ const id = String(req.params.id ?? '');
145
+ if (!isSpaceId(id)) throw new SpaceError('space_not_found', `There is no space ${id}`);
146
+ return id;
147
+ };
148
+
149
+ const requireBody = (req) => {
150
+ if (!isObjectRecord(req.body)) throw new SpaceError('invalid_request_body', 'The body is a JSON object.');
151
+ return req.body;
152
+ };
153
+
154
+ // The switch: what it is and, while on, what turning it off would stop, for the banner of decision 18.
155
+ app.get(`${SPACES_ROUTE}/switch`, async (_req, res) => {
156
+ try {
157
+ res.json(await readSwitch());
158
+ } catch (error) {
159
+ answerFailure(res, error);
160
+ }
161
+ });
162
+ app.put(`${SPACES_ROUTE}/switch`, async (req, res) => {
163
+ try {
164
+ const body = switchBodySchema.safeParse(req.body);
165
+ if (!body.success) throw new SpaceError('invalid_request_body', 'The switch takes `enabled`, true or false.');
166
+ res.json(await setSwitch(body.data.enabled));
167
+ } catch (error) {
168
+ answerFailure(res, error);
169
+ }
170
+ });
171
+
172
+ // The places funnel: each place asked what it can do, now, because the user is looking.
173
+ app.get(`${SPACES_ROUTE}/places`, withJourney(async (_journey, _req, res) => {
174
+ const places = await Promise.all(getPlaces().map(async (place) => ({ id: place.id, ...(await place.check()) })));
175
+ res.json({ places });
176
+ }));
177
+
178
+ app.get(SPACES_ROUTE, withJourney(async (journey, _req, res) => {
179
+ res.json({ spaces: await journey.listSpaces({ access: true }) });
180
+ }));
181
+
182
+ // Answers as soon as the space has an id; the steps follow as `openchamber:space-progress` events.
183
+ app.post(SPACES_ROUTE, withJourney(async (journey, req, res) => {
184
+ res.status(202).json(await journey.createSpace(requireBody(req)));
185
+ }));
186
+
187
+ app.post(`${SPACES_ROUTE}/:id/start`, withJourney(async (journey, req, res) => {
188
+ res.json(await journey.startSpace(spaceIdOf(req)));
189
+ }));
190
+
191
+ app.post(`${SPACES_ROUTE}/:id/stop`, withJourney(async (journey, req, res) => {
192
+ res.json(await journey.stopSpace(spaceIdOf(req)));
193
+ }));
194
+
195
+ app.delete(`${SPACES_ROUTE}/:id`, withJourney(async (journey, req, res) => {
196
+ res.json(await journey.removeSpace(spaceIdOf(req)));
197
+ }));
198
+
199
+ // A grant for a running space; the key in the body goes to the gatekeeper and nowhere else.
200
+ app.post(`${SPACES_ROUTE}/:id/grants`, withJourney(async (journey, req, res) => {
201
+ res.json(await journey.grantAccess(spaceIdOf(req), requireBody(req)));
202
+ }));
203
+
204
+ app.get(`${SPACES_ROUTE}/:id/journal`, withJourney(async (journey, req, res) => {
205
+ res.json(await journey.readJournal(spaceIdOf(req)));
206
+ }));
207
+
208
+ // Brings the work out and says what an apply would do; nothing of the user's is written.
209
+ app.get(`${SPACES_ROUTE}/:id/apply`, withJourney(async (journey, req, res) => {
210
+ res.json(await journey.previewApply(spaceIdOf(req)));
211
+ }));
212
+
213
+ app.post(`${SPACES_ROUTE}/:id/apply`, withJourney(async (journey, req, res) => {
214
+ res.json(await journey.applySpace(spaceIdOf(req), requireBody(req)));
215
+ }));
216
+ }