@vantran-se/openchamber-web 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{CodeMirrorCommentWidgets-DZF8ntaq.js → CodeMirrorCommentWidgets-BlzB2e0C.js} +1 -1
- package/dist/assets/DiffView-D7JQzQVZ.js +4 -0
- package/dist/assets/FilesView-DC2gadXM.js +17 -0
- package/dist/assets/{GitView-DwQwqDvu.js → GitView-Gzl0wbdd.js} +4 -4
- package/dist/assets/{GuestAttachDialog-CoXM0gh_.js → GuestAttachDialog-CQdDIeco.js} +1 -1
- package/dist/assets/GuestSurfacePane-CrOhlIdt.js +1 -0
- package/dist/assets/{JsonTreeView-Cq_27v0y.js → JsonTreeView-C3QZifYe.js} +1 -1
- package/dist/assets/{LinearIssuesView-CsSSRMhn.js → LinearIssuesView-X27TCI3v.js} +1 -1
- package/dist/assets/MarkdownImageGallery-BlTWU9oH.js +1 -0
- package/dist/assets/MarkdownRendererImpl-Cno7QsJO.js +12 -0
- package/dist/assets/{OnboardingScreen-ClatMQAX.js → OnboardingScreen-Wr_UzNtt.js} +1 -1
- package/dist/assets/{PatchDiff-DOwlrIK9.js → PatchDiff-D6pWS7cD.js} +1 -1
- package/dist/assets/{PierreDiffViewer-CrcGqqD_.js → PierreDiffViewer-DDljFbJJ.js} +3 -3
- package/dist/assets/{PlanView-BGLsMgQk.js → PlanView-LcfEbBPi.js} +4 -4
- package/dist/assets/PluginPane-DPWPLc2f.js +3 -0
- package/dist/assets/SettingsView-CFe9O3EM.js +30 -0
- package/dist/assets/{SettingsWindow-B0BAy-f9.js → SettingsWindow-DT1xhvgi.js} +1 -1
- package/dist/assets/{SubmoduleDiffSummary-fcdDzCSW.js → SubmoduleDiffSummary-CBHXaIkB.js} +1 -1
- package/dist/assets/{ToolOutputDialog-B7xaUz2H.js → ToolOutputDialog-B_ZX8ZbZ.js} +6 -6
- package/dist/assets/{ToolPartDiffPreview-CkWCaUmZ.js → ToolPartDiffPreview-D_HQzkXU.js} +2 -2
- package/dist/assets/{VirtualizedFileDiff-DONC4eI2.js → VirtualizedFileDiff-BUK1ujXg.js} +1 -1
- package/dist/assets/{WalkthroughView-CXDImOXW.js → WalkthroughView-H-tYOvhO.js} +2 -2
- package/dist/assets/{appThemeRegistry-Br2TLSlb.js → appThemeRegistry-BoJGAwCP.js} +1 -1
- package/dist/assets/changesTree-CePHbFcj.js +1 -0
- package/dist/assets/commitAndPush-BzZrF8bK.js +1 -0
- package/dist/assets/{computeVirtualFileMetrics-_-20umCs.js → computeVirtualFileMetrics-kYyVjXLu.js} +2 -2
- package/dist/assets/de-ByfQDcPj.js +10 -0
- package/dist/assets/es-DzNbjsQS.js +16 -0
- package/dist/assets/fr-B8AVLrnX.js +14 -0
- package/dist/assets/{index-OKDYjcMF.js → index-0-ON1L7L.js} +1 -1
- package/dist/assets/index-DUke1nme.css +1 -0
- package/dist/assets/index-Di6zUe5h.js +41 -0
- package/dist/assets/{isDiffPlainText-D77M6q1f.js → isDiffPlainText-Bgm_aLpu.js} +1 -1
- package/dist/assets/{isFilePlainText-C54B4Mns.js → isFilePlainText-D5GMgURH.js} +1 -1
- package/dist/assets/ja-1g6S0G19.js +16 -0
- package/dist/assets/ko-CxplvYkJ.js +16 -0
- package/dist/assets/{main-CGEvLMd2.js → main--Y98ZLsD.js} +2 -2
- package/dist/assets/main-GjXmVNpD.js +896 -0
- package/dist/assets/{markdown-shiki.worker-CahLvHIq.js → markdown-shiki.worker-Bg8SoAJJ.js} +8 -5
- package/dist/assets/markdownCore-CVdUcVCi.js +7 -0
- package/dist/assets/{miniChat-BSxkRQLj.js → miniChat-tTx5mpbd.js} +2 -2
- package/dist/assets/{mobile-C5KRTNOB.js → mobile-BmbiDWGb.js} +2 -2
- package/dist/assets/patchFileDiff-CV4iVQ8L.js +13 -0
- package/dist/assets/pl-f3QyqvIB.js +16 -0
- package/dist/assets/pt-BR-BsaoaiCK.js +16 -0
- package/dist/assets/{renderElectronMiniChatApp-CnkAQV6o.js → renderElectronMiniChatApp-BnCdnDhv.js} +2 -2
- package/dist/assets/renderMobileApp-CKn8nbSW.js +25 -0
- package/dist/assets/runtimeEndpointReset-sCi88eed.js +27 -0
- package/dist/assets/scheduledTasksApi-Dahv3ws0.js +1 -0
- package/dist/assets/{shikiHighlight-4O0ZM14D.js → shikiHighlight-O5_xLmWt.js} +1 -1
- package/dist/assets/{textMateThemeFromAppTheme-yNq7b0DZ.js → textMateThemeFromAppTheme-XM4VefqN.js} +1 -1
- package/dist/assets/tr-BNFsa6nz.js +7 -0
- package/dist/assets/uk-CJ9y5ULA.js +16 -0
- package/dist/assets/{useAppFontEffects-Bzyszv2N.js → useAppFontEffects-BN06mgHB.js} +160 -156
- package/dist/assets/{useInlineCommentController-zOf6ORAq.js → useInlineCommentController-Bb9Gjz1Q.js} +2 -2
- package/dist/assets/{useRootScrollLock-DCaZ4BWi.js → useRootScrollLock-Chk1cFUw.js} +2 -2
- package/dist/assets/{vendor-codemirror-language-data-6cEFPNUR.js → vendor-codemirror-language-data-C2qMZiA7.js} +2 -2
- package/dist/assets/vendor-opencode-client-DSZl7jSi.js +12 -0
- package/dist/assets/vendor-zod-BSoePkpP.js +39 -0
- package/dist/assets/{web-components-BEo4-ISu.js → web-components-BQWjCn_U.js} +1 -1
- package/dist/assets/zh-CN-U0QjVlAA.js +16 -0
- package/dist/assets/zh-TW-J9PTJQJi.js +16 -0
- package/dist/index.html +5 -5
- package/dist/mini-chat.html +5 -5
- package/dist/mobile.html +5 -5
- package/package.json +4 -4
- package/server/index.js +82 -0
- package/server/lib/agent-tool/DOCUMENTATION.md +132 -47
- package/server/lib/agent-tool/runtime.js +34 -15
- package/server/lib/agent-tool/runtime.test.js +15 -0
- package/server/lib/browser-control/DOCUMENTATION.md +14 -2
- package/server/lib/event-stream/global-hub.js +23 -1
- package/server/lib/event-stream/global-hub.test.js +35 -0
- package/server/lib/event-stream/global-ws-bridge.js +3 -1
- package/server/lib/event-stream/upstream-reader.js +3 -2
- package/server/lib/git/DOCUMENTATION.md +1 -1
- package/server/lib/git/service.js +22 -0
- package/server/lib/git/service.test.js +8 -0
- package/server/lib/guests/DOCUMENTATION.md +4 -3
- package/server/lib/guests/catalog.js +19 -1
- package/server/lib/guests/catalog.test.js +22 -0
- package/server/lib/guests/html-styles.js +5 -3
- package/server/lib/guests/html-styles.test.js +3 -1
- package/server/lib/guests/routes.js +11 -3
- package/server/lib/guests/service.js +5 -0
- package/server/lib/guests/surface.js +55 -9
- package/server/lib/guests/surface.test.js +56 -6
- package/server/lib/notifications/DOCUMENTATION.md +10 -0
- package/server/lib/notifications/emit-route.js +118 -0
- package/server/lib/notifications/emit-route.test.js +112 -0
- package/server/lib/openchamber-control/actions.js +16 -2
- package/server/lib/openchamber-control/service.js +23 -0
- package/server/lib/openchamber-control/service.test.js +31 -0
- package/server/lib/openchamber-sessions/fork-inheritance.js +103 -0
- package/server/lib/openchamber-sessions/fork-inheritance.test.js +69 -0
- package/server/lib/openchamber-sessions/routes.js +11 -0
- package/server/lib/openchamber-sessions/routes.test.js +18 -0
- package/server/lib/opencode/bootstrap-runtime.js +14 -1
- package/server/lib/opencode/compatibility.js +6 -3
- package/server/lib/opencode/compatibility.test.js +8 -0
- package/server/lib/opencode/config-entity-v2.test.js +27 -0
- package/server/lib/opencode/config-paths.test.js +12 -0
- package/server/lib/opencode/config-v2.d.ts +2 -0
- package/server/lib/opencode/config-v2.js +42 -0
- package/server/lib/opencode/config-v2.test.js +17 -0
- package/server/lib/opencode/core-routes.js +20 -4
- package/server/lib/opencode/core-routes.test.js +41 -0
- package/server/lib/opencode/env-runtime.js +1 -8
- package/server/lib/opencode/feature-routes-runtime.js +2 -1
- package/server/lib/opencode/lifecycle.js +30 -2
- package/server/lib/opencode/lifecycle.test.js +27 -0
- package/server/lib/opencode/managed-config-file.js +18 -6
- package/server/lib/opencode/managed-config-file.test.js +23 -7
- package/server/lib/opencode/mcp.js +7 -2
- package/server/lib/opencode/npm-registry-config.d.ts +1 -0
- package/server/lib/opencode/npm-registry-config.js +123 -0
- package/server/lib/opencode/npm-registry-config.test.js +167 -0
- package/server/lib/opencode/npm-registry.js +5 -6
- package/server/lib/opencode/npm-registry.test.js +30 -0
- package/server/lib/opencode/openchamber-routes.js +29 -11
- package/server/lib/opencode/openchamber-routes.test.js +18 -0
- package/server/lib/opencode/project-directory-runtime.js +8 -0
- package/server/lib/opencode/project-directory-runtime.test.js +13 -0
- package/server/lib/opencode/provider-source-route.test.js +59 -0
- package/server/lib/opencode/providers.js +3 -0
- package/server/lib/opencode/providers.test.js +62 -0
- package/server/lib/opencode/proxy.js +38 -2
- package/server/lib/opencode/routes-upgrade.test.js +1 -1
- package/server/lib/opencode/routes.js +36 -1
- package/server/lib/opencode/server-utils-runtime.js +5 -0
- package/server/lib/opencode/settings-helpers.js +10 -0
- package/server/lib/opencode/settings-helpers.test.js +2 -2
- package/server/lib/opencode/settings-registry.json +17 -0
- package/server/lib/opencode/settings-runtime.js +1 -0
- package/server/lib/opencode/shared.js +49 -4
- package/server/lib/opencode/shared.test.js +78 -1
- package/server/lib/opencode/shutdown-runtime.js +3 -0
- package/server/lib/opencode/skill-routes.js +28 -12
- package/server/lib/opencode/skill-routes.test.js +121 -1
- package/server/lib/opencode/upgrade-capability.js +0 -14
- package/server/lib/opencode/v1-migration-topup.js +21 -15
- package/server/lib/opencode/v1-migration-topup.test.js +14 -6
- package/server/lib/opencode/v2-install.d.ts +1 -0
- package/server/lib/opencode/v2-install.js +8 -7
- package/server/lib/opencode/v2-install.test.js +13 -4
- package/server/lib/opencode/watcher.js +3 -1
- package/server/lib/opencode/websearch-config.js +13 -1
- package/server/lib/quota/providers/kimi.js +1 -1
- package/server/lib/realtime-proxy.js +5 -1
- package/server/lib/realtime-proxy.test.js +29 -0
- package/server/lib/relay/tunnel-host.js +4 -1
- package/server/lib/relay/tunnel-host.test.js +11 -0
- package/server/lib/session-assist/DOCUMENTATION.md +17 -5
- package/server/lib/session-assist/runtime.js +19 -2
- package/server/lib/session-assist/runtime.test.js +8 -0
- package/server/lib/session-goal/DOCUMENTATION.md +11 -0
- package/server/lib/small-model/DOCUMENTATION.md +11 -10
- package/server/lib/small-model/index.js +27 -32
- package/server/lib/small-model/index.test.js +28 -16
- package/server/lib/spaces/DOCUMENTATION.md +404 -32
- package/server/lib/spaces/code-in-bait.js +145 -6
- package/server/lib/spaces/code-in.js +10 -150
- package/server/lib/spaces/code-in.test.js +7 -83
- package/server/lib/spaces/code-out.js +1819 -0
- package/server/lib/spaces/code-out.test.js +3945 -0
- package/server/lib/spaces/code-transfer.js +173 -0
- package/server/lib/spaces/command-stream.test.js +181 -0
- package/server/lib/spaces/dispatcher.js +670 -0
- package/server/lib/spaces/dispatcher.test.js +582 -0
- package/server/lib/spaces/gatekeeper-channel.js +61 -7
- package/server/lib/spaces/gatekeeper-channel.test.js +43 -7
- package/server/lib/spaces/gatekeeper-program.cjs +68 -14
- package/server/lib/spaces/gatekeeper-program.test.js +118 -3
- package/server/lib/spaces/hardening.js +20 -4
- package/server/lib/spaces/hardening.test.js +18 -3
- package/server/lib/spaces/host-git.js +7 -4
- package/server/lib/spaces/host-git.test.js +6 -2
- package/server/lib/spaces/host.js +310 -0
- package/server/lib/spaces/host.test.js +273 -0
- package/server/lib/spaces/journey.js +552 -0
- package/server/lib/spaces/journey.test.js +589 -0
- package/server/lib/spaces/layout.js +42 -8
- package/server/lib/spaces/manager.js +4 -3
- package/server/lib/spaces/manager.test.js +6 -0
- package/server/lib/spaces/places/code-out.docker.live.test.js +412 -0
- package/server/lib/spaces/places/contract-suite.js +36 -0
- package/server/lib/spaces/places/dispatcher.docker.live.test.js +296 -0
- package/server/lib/spaces/places/docker-live-support.js +27 -4
- package/server/lib/spaces/places/docker.js +33 -5
- package/server/lib/spaces/places/docker.test.js +42 -3
- package/server/lib/spaces/places/escape-suite.js +118 -14
- package/server/lib/spaces/places/fake-docker.js +3 -1
- package/server/lib/spaces/places/grants.docker.live.test.js +197 -0
- package/server/lib/spaces/places/memory-place.js +24 -1
- package/server/lib/spaces/places/registry.js +1 -1
- package/server/lib/spaces/places/server.docker.live.test.js +69 -33
- package/server/lib/spaces/places/sessions.docker.live.test.js +222 -0
- package/server/lib/spaces/routes.js +216 -0
- package/server/lib/spaces/routes.test.js +213 -0
- package/server/lib/spaces/run-command.js +261 -8
- package/server/lib/spaces/run-command.test.js +193 -3
- package/server/lib/spaces/space-events.js +153 -0
- package/server/lib/spaces/space-events.test.js +160 -0
- package/server/lib/spaces/space-opencode.js +52 -0
- package/server/lib/spaces/space-opencode.test.js +41 -0
- package/server/lib/spaces/space-records.js +150 -0
- package/server/lib/spaces/space-records.test.js +113 -0
- package/server/lib/spaces/space-sessions.js +210 -0
- package/server/lib/spaces/space-sessions.test.js +161 -0
- package/server/lib/spaces/websocket.js +329 -0
- package/server/lib/spaces/websocket.test.js +345 -0
- package/server/lib/ui-auth/ui-auth.js +8 -2
- package/server/lib/ui-auth/ui-auth.test.js +29 -0
- package/server/opencode-proxy.test.js +113 -0
- package/dist/assets/DiffView-D2p4Ar0H.js +0 -4
- package/dist/assets/FilesView-BeTtbsip.js +0 -17
- package/dist/assets/GuestSurfacePane-DIJvkkM-.js +0 -1
- package/dist/assets/MarkdownImageGallery-CY0dWTaE.js +0 -1
- package/dist/assets/MarkdownRendererImpl-ILdXzLrt.js +0 -12
- package/dist/assets/PluginPane-B39JEUL8.js +0 -3
- package/dist/assets/SettingsView-13B2_G-A.js +0 -29
- package/dist/assets/commitAndPush-CcI4ol7c.js +0 -1
- package/dist/assets/de-BtYnUgII.js +0 -10
- package/dist/assets/es-crP5qPye.js +0 -16
- package/dist/assets/fr-CLSRuALk.js +0 -14
- package/dist/assets/index-CLcM8HKx.css +0 -1
- package/dist/assets/index-CvLgnuNI.js +0 -41
- package/dist/assets/ja-SZ1-ucLV.js +0 -16
- package/dist/assets/ko-HthbT_Ve.js +0 -16
- package/dist/assets/main-BJklC7Cl.js +0 -896
- package/dist/assets/markdownCore-BzQuETm0.js +0 -5
- package/dist/assets/patchFileDiff-DGw2KryM.js +0 -13
- package/dist/assets/pl-C8eYfjL1.js +0 -16
- package/dist/assets/pt-BR-cYOrZdxS.js +0 -16
- package/dist/assets/renderMobileApp-CMxjwisj.js +0 -25
- package/dist/assets/runtimeEndpointReset-C45jrFq4.js +0 -27
- package/dist/assets/scheduledTasksApi-CkK1F9s2.js +0 -1
- package/dist/assets/tr-B4IQ5t-T.js +0 -7
- package/dist/assets/uk-DCoXG6ve.js +0 -16
- package/dist/assets/vendor-opencode-client-_BAHQo4R.js +0 -12
- package/dist/assets/vendor-zod-Gea9uBlY.js +0 -39
- package/dist/assets/zh-CN-BXBoxsFk.js +0 -16
- package/dist/assets/zh-TW-C6oelpnL.js +0 -16
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// The journey of a space as the host drives it: made with its network choice and the project's
|
|
2
|
+
// code, listed with its state, started again with its network said again to a gatekeeper that
|
|
3
|
+
// forgot it, stopped, removed with everything the host kept for it, its journal read, and its
|
|
4
|
+
// work brought out and applied. Everything here is written once on top of the manager, the
|
|
5
|
+
// gatekeeper channel, code in and code out; the routes in `routes.js` only translate HTTP.
|
|
6
|
+
//
|
|
7
|
+
// A creation runs in the background: the request comes back at once with the space's id, and the
|
|
8
|
+
// steps are announced as `openchamber:space-progress` events on the host's hub, so the group in
|
|
9
|
+
// the sidebar can say what is happening now (DESIGN.md, journey step 2). A creation that fails
|
|
10
|
+
// after the containers exist removes them again, and its failure stays listed until the user
|
|
11
|
+
// dismisses it, so an error is never silent.
|
|
12
|
+
//
|
|
13
|
+
// Grants, since 5b: a model key the gatekeeper's window adds to the provider's requests, or an
|
|
14
|
+
// opened domain the window forwards to with no credential. The host keeps the grant without its
|
|
15
|
+
// value (decision 5) and says it again to the gatekeeper after every start; a value it cannot
|
|
16
|
+
// find again leaves the space "needs access" until the user grants once more.
|
|
17
|
+
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
22
|
+
import { SpaceError } from './errors.js';
|
|
23
|
+
import { createSpaceId, hashProjectDirectory } from './labels.js';
|
|
24
|
+
import { spaceProjectPath, spaceWindowUrl } from './layout.js';
|
|
25
|
+
import { grantSchema, networkSchema, secretSourceSchema } from './space-records.js';
|
|
26
|
+
|
|
27
|
+
// The four choices of the create dialog, parsed at the boundary. Each field refuses with a code
|
|
28
|
+
// of its own, so the dialog can point at the field.
|
|
29
|
+
const createRequestSchema = z.object({
|
|
30
|
+
projectDirectory: z.string().min(1),
|
|
31
|
+
name: z.string().trim().min(1),
|
|
32
|
+
start: z.enum(['clean', 'uncommitted']).default('uncommitted'),
|
|
33
|
+
network: networkSchema.default({ mode: 'allowlist', domains: [] }),
|
|
34
|
+
});
|
|
35
|
+
const CREATE_REFUSALS = {
|
|
36
|
+
projectDirectory: ['project_not_registered', 'A space is made for a project this OpenChamber knows. Add the project first, then create the space.'],
|
|
37
|
+
name: ['invalid_space_name', 'A space needs a name.'],
|
|
38
|
+
start: ['invalid_snapshot_mode', 'A space starts from a clean commit or with the uncommitted changes.'],
|
|
39
|
+
network: ['invalid_network', 'The network of a space is allowlist or open, with a list of domain names for the allowlist.'],
|
|
40
|
+
};
|
|
41
|
+
// The grant dialog's request, parsed at the boundary. A model grant names the provider as the
|
|
42
|
+
// host's catalog does, the provider's API as the upstream, and where the key comes from: typed
|
|
43
|
+
// once, or an environment variable of the host's by name. The value of a typed key is used now
|
|
44
|
+
// and remembered nowhere. An opened domain has no key.
|
|
45
|
+
const MAX_SECRET_LENGTH = 8192;
|
|
46
|
+
const grantRequestSchema = z.discriminatedUnion('kind', [
|
|
47
|
+
z.object({
|
|
48
|
+
kind: z.literal('model'),
|
|
49
|
+
provider: grantSchema.options[0].shape.provider,
|
|
50
|
+
upstream: grantSchema.options[0].shape.upstream,
|
|
51
|
+
secret: z.discriminatedUnion('kind', [
|
|
52
|
+
z.object({ kind: z.literal('typed'), value: z.string().min(1).max(MAX_SECRET_LENGTH) }).strict(),
|
|
53
|
+
secretSourceSchema.options[0],
|
|
54
|
+
]),
|
|
55
|
+
}).strict(),
|
|
56
|
+
z.object({ kind: z.literal('domain'), upstream: grantSchema.options[1].shape.upstream }).strict(),
|
|
57
|
+
]);
|
|
58
|
+
// The header the window sets for a provider: what its API reads a key from. Only providers
|
|
59
|
+
// named here are taken: one that reads its key another way or signs its requests, Azure,
|
|
60
|
+
// Bedrock or Vertex, would take the grant and fail every turn with a 401 upstream.
|
|
61
|
+
const HEADER_BY_PROVIDER = new Map([
|
|
62
|
+
['anthropic', 'x-api-key'],
|
|
63
|
+
['google', 'x-goog-api-key'],
|
|
64
|
+
...['openai', 'openrouter', 'groq', 'mistral', 'deepseek', 'xai'].map((provider) => [provider, 'authorization']),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
const applyRequestSchema = z.discriminatedUnion('as', [
|
|
68
|
+
z.object({ as: z.literal('branch'), branch: z.string(), removeAfterwards: z.boolean().default(false) }),
|
|
69
|
+
z.object({ as: z.literal('changes'), removeAfterwards: z.boolean().default(false) }),
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
const failureOf = (error) => ({
|
|
73
|
+
code: error instanceof SpaceError ? error.code : 'space_journey_failed',
|
|
74
|
+
message: error?.message ?? String(error),
|
|
75
|
+
details: error instanceof SpaceError ? error.details ?? null : null,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `listProjectDirectories` answers the host's registered projects; a space is made for one of them
|
|
80
|
+
* and its label carries the project's hash. `announce(spaceId, payload)` enters an event into the
|
|
81
|
+
* host's hub, `onSpacesChanged()` tells the host to read its list again at once. `spaceOpenCode`
|
|
82
|
+
* writes OpenCode's files inside a space, and `readHostSecret(name)` is how a key named by an
|
|
83
|
+
* environment variable of the host's is found again: its value or undefined, never stored.
|
|
84
|
+
*/
|
|
85
|
+
export function createSpaceJourney({
|
|
86
|
+
manager,
|
|
87
|
+
place,
|
|
88
|
+
gatekeeper,
|
|
89
|
+
codeIn,
|
|
90
|
+
codeOut,
|
|
91
|
+
records,
|
|
92
|
+
spaceOpenCode,
|
|
93
|
+
listProjectDirectories,
|
|
94
|
+
readHostSecret = () => undefined,
|
|
95
|
+
announce = () => {},
|
|
96
|
+
onSpacesChanged = () => {},
|
|
97
|
+
logger = console,
|
|
98
|
+
now = () => new Date(),
|
|
99
|
+
}) {
|
|
100
|
+
// Creations under way or failed, by space id, until they succeed or the user dismisses them.
|
|
101
|
+
const pending = new Map();
|
|
102
|
+
// Spaces with an action under way: start, stop, remove, journal or apply. One at a time per space,
|
|
103
|
+
// so a remove cannot pull the refs from under an apply that is writing them.
|
|
104
|
+
const busy = new Set();
|
|
105
|
+
// Set while the switch is being turned off: no creation may slip in between the stop of the
|
|
106
|
+
// spaces and the moment the feature is gone.
|
|
107
|
+
let closing = false;
|
|
108
|
+
|
|
109
|
+
const exclusive = async (spaceId, work) => {
|
|
110
|
+
if (busy.has(spaceId)) throw new SpaceError('space_busy', 'Another action on this space is still running. Wait for it to finish.');
|
|
111
|
+
busy.add(spaceId);
|
|
112
|
+
try {
|
|
113
|
+
return await work();
|
|
114
|
+
} finally {
|
|
115
|
+
busy.delete(spaceId);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/** Refuses an action on a creation under way, and names a failed one for what it is. */
|
|
120
|
+
const requireNotPending = (spaceId) => {
|
|
121
|
+
const waiting = pending.get(spaceId);
|
|
122
|
+
if (!waiting) return;
|
|
123
|
+
if (waiting.state === 'failed') throw new SpaceError('space_creation_failed', 'Making this space failed. Dismiss it, then create a new one.');
|
|
124
|
+
throw new SpaceError('space_preparing', 'This space is still being made.');
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const registeredProjects = async () => {
|
|
128
|
+
const directories = await listProjectDirectories();
|
|
129
|
+
return new Map(directories.map((directory) => [hashProjectDirectory(directory), directory]));
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const requireRegisteredProject = async (directory) => {
|
|
133
|
+
const projects = await registeredProjects();
|
|
134
|
+
if (projects.get(hashProjectDirectory(directory)) !== directory) {
|
|
135
|
+
throw new SpaceError(...CREATE_REFUSALS.projectDirectory);
|
|
136
|
+
}
|
|
137
|
+
return directory;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const progress = (entry, step, failure = null) => {
|
|
141
|
+
entry.step = step;
|
|
142
|
+
entry.state = step === 'failed' ? 'failed' : step === 'ready' ? 'running' : 'preparing';
|
|
143
|
+
entry.failure = failure;
|
|
144
|
+
announce(entry.id, { type: 'openchamber:space-progress', properties: { spaceId: entry.id, step, failure, timestamp: now().getTime() } });
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Removes the space and everything the host kept for it, and says what did not go. */
|
|
148
|
+
const removeEverything = async (spaceId, repository) => {
|
|
149
|
+
const outcome = { removed: false, refsRemoved: null, failures: [] };
|
|
150
|
+
try {
|
|
151
|
+
await manager.removeSpace({ placeId: place.id, spaceId });
|
|
152
|
+
outcome.removed = true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
outcome.failures.push(failureOf(error));
|
|
155
|
+
}
|
|
156
|
+
if (repository) {
|
|
157
|
+
try {
|
|
158
|
+
await codeIn.removeSpaceRefs({ repository, spaceId });
|
|
159
|
+
outcome.refsRemoved = true;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
outcome.refsRemoved = false;
|
|
162
|
+
outcome.failures.push(failureOf(error));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
records.remove(spaceId);
|
|
166
|
+
return outcome;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const sendHistoryInBackground = ({ repository, spaceId, spacePath, base }) => {
|
|
170
|
+
codeIn.sendHistory({ repository, spaceId, spacePath, base })
|
|
171
|
+
.then((result) => { records.update(spaceId, { history: result.status }); })
|
|
172
|
+
.catch((error) => {
|
|
173
|
+
logger.warn?.(`[spaces] the history of space ${spaceId} did not arrive: ${error?.details?.cause ?? error?.code ?? error?.message}`);
|
|
174
|
+
records.update(spaceId, { history: 'failed' });
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const prepare = async (entry, { projectDirectory, name, start, network }) => {
|
|
179
|
+
let created = false;
|
|
180
|
+
try {
|
|
181
|
+
progress(entry, 'checking_place');
|
|
182
|
+
const check = await place.check();
|
|
183
|
+
if (!check.available) throw new SpaceError(check.code, check.message);
|
|
184
|
+
if (network.mode === 'allowlist' && check.hostIsolation !== true) {
|
|
185
|
+
throw new SpaceError('place_cannot_restrict_network', 'This place cannot keep a space away from the machine it runs on, so an allowlist would not hold. Use a newer Docker engine, or choose the open network with that in mind.');
|
|
186
|
+
}
|
|
187
|
+
progress(entry, 'creating');
|
|
188
|
+
await manager.createSpace({ id: entry.id, placeId: place.id, projectDirectory, name });
|
|
189
|
+
created = true;
|
|
190
|
+
records.write(entry.id, { network, repository: projectDirectory });
|
|
191
|
+
progress(entry, 'setting_network');
|
|
192
|
+
await gatekeeper.setNetwork(entry.id, network);
|
|
193
|
+
progress(entry, 'bringing_code');
|
|
194
|
+
const arrived = await codeIn.bringCodeIn({ repository: projectDirectory, spaceId: entry.id, mode: start });
|
|
195
|
+
records.update(entry.id, { spacePath: arrived.spacePath, base: arrived.base });
|
|
196
|
+
entry.identityCopied = arrived.identityCopied;
|
|
197
|
+
pending.delete(entry.id);
|
|
198
|
+
progress(entry, 'ready');
|
|
199
|
+
onSpacesChanged();
|
|
200
|
+
sendHistoryInBackground({ repository: projectDirectory, spaceId: entry.id, spacePath: arrived.spacePath, base: arrived.base });
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const failure = failureOf(error);
|
|
203
|
+
if (created) {
|
|
204
|
+
const cleanup = await removeEverything(entry.id, projectDirectory);
|
|
205
|
+
if (cleanup.failures.length > 0) failure.cleanup = cleanup.failures;
|
|
206
|
+
}
|
|
207
|
+
logger.warn?.(`[spaces] creating space ${entry.id} failed at ${entry.step}: ${failure.code}`);
|
|
208
|
+
progress(entry, 'failed', failure);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Starts making a space and answers at once with the entry the list will carry. The four
|
|
214
|
+
* choices of the create dialog come in: the project, the name, the starting point and the
|
|
215
|
+
* network. The place is checked inside, as decision 19 asks: nothing probes a runtime before
|
|
216
|
+
* the user acts.
|
|
217
|
+
*/
|
|
218
|
+
const createSpace = async (request) => {
|
|
219
|
+
if (closing) throw new SpaceError('isolated_spaces_off', 'Isolated spaces are being turned off.');
|
|
220
|
+
const parsed = createRequestSchema.safeParse(request ?? {});
|
|
221
|
+
if (!parsed.success) {
|
|
222
|
+
const [code, message] = CREATE_REFUSALS[parsed.error.issues[0]?.path?.[0]] ?? CREATE_REFUSALS.network;
|
|
223
|
+
throw new SpaceError(code, message);
|
|
224
|
+
}
|
|
225
|
+
const { name, start, network } = parsed.data;
|
|
226
|
+
const projectDirectory = await requireRegisteredProject(parsed.data.projectDirectory);
|
|
227
|
+
const id = createSpaceId();
|
|
228
|
+
const entry = {
|
|
229
|
+
id,
|
|
230
|
+
name,
|
|
231
|
+
placeId: place.id,
|
|
232
|
+
projectDirectory,
|
|
233
|
+
directory: spaceProjectPath(id, projectDirectory),
|
|
234
|
+
created: now().toISOString(),
|
|
235
|
+
state: 'preparing',
|
|
236
|
+
step: 'checking_place',
|
|
237
|
+
failure: null,
|
|
238
|
+
network,
|
|
239
|
+
};
|
|
240
|
+
pending.set(entry.id, entry);
|
|
241
|
+
void prepare(entry, { projectDirectory, name, start, network });
|
|
242
|
+
return describePending(entry);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
/** A grant as the list and the grant route show it: everything the record holds, which holds no value. */
|
|
246
|
+
const describeGrant = (grant) => ({ ...grant, url: spaceWindowUrl(grant.id) });
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Says one grant of the record to the gatekeeper. Resolves whether it could: a typed key is
|
|
250
|
+
* not remembered and needs the user again, and so does an environment variable that is not set
|
|
251
|
+
* now; a gatekeeper that refuses or does not answer counts the same way and is logged, because
|
|
252
|
+
* the space runs either way and the list must say that its access is missing.
|
|
253
|
+
*/
|
|
254
|
+
const deliverGrant = async (spaceId, grant) => {
|
|
255
|
+
let secret = null;
|
|
256
|
+
if (grant.kind === 'model') {
|
|
257
|
+
if (grant.source.kind === 'typed') return false;
|
|
258
|
+
secret = readHostSecret(grant.source.name);
|
|
259
|
+
if (typeof secret !== 'string' || secret === '') return false;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
await gatekeeper.addGrant(spaceId, { id: grant.id, upstream: grant.upstream, header: grant.kind === 'model' ? grant.header : null, secret });
|
|
263
|
+
return true;
|
|
264
|
+
} catch (error) {
|
|
265
|
+
logger.warn?.(`[spaces] the grant ${grant.id} of space ${spaceId} could not be said again: ${error?.code ?? error?.message ?? error}`);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* OpenCode's configuration inside, written again from the record at a start: it survives a
|
|
272
|
+
* stop in the home volume, but a write that failed at the grant is repaired only here. It
|
|
273
|
+
* cooperates and enforces nothing, so a failure is logged and the start goes on.
|
|
274
|
+
*/
|
|
275
|
+
const rewriteProviderConfig = async (spaceId, grants) => {
|
|
276
|
+
if (!grants.some((grant) => grant.kind === 'model')) return;
|
|
277
|
+
try {
|
|
278
|
+
await spaceOpenCode.writeProviderConfig(spaceId, grants);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
logger.warn?.(`[spaces] the provider configuration of space ${spaceId} was not written again: ${error?.code ?? error?.message ?? error}`);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/** Every grant of the record said again after a start. Resolves the ids that went and the ids that need the user. */
|
|
285
|
+
const restoreGrants = async (spaceId, grants) => {
|
|
286
|
+
const restored = [];
|
|
287
|
+
const needsAccess = [];
|
|
288
|
+
for (const grant of grants) {
|
|
289
|
+
(await deliverGrant(spaceId, grant) ? restored : needsAccess).push(grant.id);
|
|
290
|
+
}
|
|
291
|
+
return { restored, needsAccess };
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Which grants of the record a running gatekeeper holds now, read from the gatekeeper itself:
|
|
296
|
+
* after a machine restart it holds none, and the space "needs access" (DESIGN.md, Gatekeeper).
|
|
297
|
+
* A gatekeeper that cannot be asked leaves the answer unknown, never "granted".
|
|
298
|
+
*/
|
|
299
|
+
const readAccess = async (space, grants) => {
|
|
300
|
+
// A start that is still saying the grants again, or a grant on its way, would read as
|
|
301
|
+
// "needs access" for a moment; while an action holds the space the answer is not given.
|
|
302
|
+
if (space.state !== 'running' || grants.length === 0 || busy.has(space.id)) return { access: null, needsAccess: [] };
|
|
303
|
+
try {
|
|
304
|
+
const held = new Set((await gatekeeper.readPolicy(space.id)).grants);
|
|
305
|
+
const needsAccess = grants.filter((grant) => !held.has(grant.id)).map((grant) => grant.id);
|
|
306
|
+
return { access: needsAccess.length === 0 ? 'granted' : 'needs_access', needsAccess };
|
|
307
|
+
} catch (error) {
|
|
308
|
+
logger.warn?.(`[spaces] the gatekeeper of space ${space.id} did not say what it holds: ${error?.code ?? error?.message ?? error}`);
|
|
309
|
+
return { access: 'unknown', needsAccess: [] };
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const describePending = (entry) => ({
|
|
314
|
+
id: entry.id,
|
|
315
|
+
name: entry.name,
|
|
316
|
+
placeId: entry.placeId,
|
|
317
|
+
projectDirectory: entry.projectDirectory,
|
|
318
|
+
directory: entry.directory,
|
|
319
|
+
created: entry.created,
|
|
320
|
+
state: entry.state,
|
|
321
|
+
step: entry.step,
|
|
322
|
+
failure: entry.failure,
|
|
323
|
+
network: entry.network,
|
|
324
|
+
history: 'pending',
|
|
325
|
+
grants: [],
|
|
326
|
+
access: null,
|
|
327
|
+
needsAccess: [],
|
|
328
|
+
damaged: false,
|
|
329
|
+
missing: [],
|
|
330
|
+
orphans: [],
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Every space of this host: the place's list with what the host remembers about each, the
|
|
335
|
+
* creations under way in their place, and the failed ones after it. A record the host cannot read leaves `network` null,
|
|
336
|
+
* which the UI must show as "unknown" and never as "open". With `access` each running space
|
|
337
|
+
* with grants is asked what its gatekeeper holds, one request per such space, so the list can
|
|
338
|
+
* say "needs access"; without it `access` stays null.
|
|
339
|
+
*/
|
|
340
|
+
const listSpaces = async ({ access = false } = {}) => {
|
|
341
|
+
const [spaces, projects] = await Promise.all([manager.listSpaces({ placeId: place.id }), registeredProjects()]);
|
|
342
|
+
const listed = await Promise.all(spaces.map(async (space) => {
|
|
343
|
+
const projectDirectory = projects.get(space.project) ?? null;
|
|
344
|
+
const { record } = records.read(space.id);
|
|
345
|
+
const grants = record?.grants ?? [];
|
|
346
|
+
return {
|
|
347
|
+
id: space.id,
|
|
348
|
+
name: space.name,
|
|
349
|
+
placeId: space.placeId,
|
|
350
|
+
projectDirectory,
|
|
351
|
+
directory: projectDirectory === null ? null : spaceProjectPath(space.id, projectDirectory),
|
|
352
|
+
created: space.created,
|
|
353
|
+
state: space.state,
|
|
354
|
+
step: null,
|
|
355
|
+
failure: null,
|
|
356
|
+
network: record?.network ?? null,
|
|
357
|
+
history: record?.history ?? 'unknown',
|
|
358
|
+
grants: grants.map(describeGrant),
|
|
359
|
+
...(access ? await readAccess(space, grants) : { access: null, needsAccess: [] }),
|
|
360
|
+
damaged: space.damaged,
|
|
361
|
+
missing: space.missing,
|
|
362
|
+
orphans: space.orphans,
|
|
363
|
+
};
|
|
364
|
+
}));
|
|
365
|
+
// A creation under way wins over the place's view of it: the containers run before the code is there.
|
|
366
|
+
const waiting = new Map(Array.from(pending.values(), (entry) => [entry.id, describePending(entry)]));
|
|
367
|
+
const merged = listed.map((space) => waiting.get(space.id) ?? space);
|
|
368
|
+
const known = new Set(listed.map((space) => space.id));
|
|
369
|
+
return [...merged, ...Array.from(waiting.values()).filter((entry) => !known.has(entry.id))];
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const requireListed = async (spaceId) => {
|
|
373
|
+
const space = (await listSpaces()).find((entry) => entry.id === spaceId);
|
|
374
|
+
if (!space) throw new SpaceError('space_not_found', `There is no space ${spaceId}`);
|
|
375
|
+
return space;
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Starts a stopped space. Its gatekeeper comes up allowing nothing, so the network the user
|
|
380
|
+
* chose is said again from the record; without a readable record the space stays closed, and
|
|
381
|
+
* the answer says so with `networkRestored` false.
|
|
382
|
+
*/
|
|
383
|
+
const startSpace = (spaceId) => exclusive(spaceId, async () => {
|
|
384
|
+
// As for a creation: no start may slip in while the switch is stopping the spaces one by one.
|
|
385
|
+
if (closing) throw new SpaceError('isolated_spaces_off', 'Isolated spaces are being turned off.');
|
|
386
|
+
requireNotPending(spaceId);
|
|
387
|
+
await manager.startSpace({ placeId: place.id, spaceId });
|
|
388
|
+
const { record } = records.read(spaceId);
|
|
389
|
+
let networkRestored = false;
|
|
390
|
+
let grants = { restored: [], needsAccess: [] };
|
|
391
|
+
if (record) {
|
|
392
|
+
await gatekeeper.setNetwork(spaceId, record.network);
|
|
393
|
+
networkRestored = true;
|
|
394
|
+
grants = await restoreGrants(spaceId, record.grants);
|
|
395
|
+
await rewriteProviderConfig(spaceId, record.grants);
|
|
396
|
+
// A history that never arrived, or that failed, is sent again: a stop right after the
|
|
397
|
+
// creation is the usual way it fails, and the space would otherwise stay shallow for good.
|
|
398
|
+
if ((record.history === 'pending' || record.history === 'failed') && record.repository && record.spacePath && record.base) {
|
|
399
|
+
sendHistoryInBackground({ repository: record.repository, spaceId, spacePath: record.spacePath, base: record.base });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
onSpacesChanged();
|
|
403
|
+
return { ...(await requireListed(spaceId)), networkRestored, grantsRestored: grants.restored, needsAccess: grants.needsAccess };
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Gives a running space a grant: the key goes to the gatekeeper, the grant without its value
|
|
408
|
+
* goes to the record, and for a model grant OpenCode inside is told to send that provider's
|
|
409
|
+
* calls through the window. A second grant for the same provider replaces the first, which is
|
|
410
|
+
* how a key is changed; a grant is never taken back (decision 4). The value of a typed key is
|
|
411
|
+
* in this request and in the gatekeeper's memory, and nowhere else afterwards.
|
|
412
|
+
*/
|
|
413
|
+
const grantAccess = (spaceId, request) => exclusive(spaceId, async () => {
|
|
414
|
+
requireNotPending(spaceId);
|
|
415
|
+
const parsed = grantRequestSchema.safeParse(request ?? {});
|
|
416
|
+
if (!parsed.success) throw new SpaceError('invalid_grant_request', 'A grant is a model key for a provider, typed or named by a host environment variable, or an opened domain.');
|
|
417
|
+
const space = await requireListed(spaceId);
|
|
418
|
+
if (space.state !== 'running') throw new SpaceError('space_not_running', 'A grant goes to the gatekeeper of a running space. Start the space, then grant.');
|
|
419
|
+
const current = records.read(spaceId);
|
|
420
|
+
if (current.status !== 'ok') throw new SpaceError('space_record_unreadable', 'The host\'s record of this space cannot be read, so a grant could not be remembered. Remove the space and create it again.');
|
|
421
|
+
|
|
422
|
+
const asked = parsed.data;
|
|
423
|
+
if (asked.kind === 'model' && !HEADER_BY_PROVIDER.has(asked.provider)) {
|
|
424
|
+
throw new SpaceError('provider_not_supported', `A key for ${asked.provider} cannot be given through the gatekeeper yet: it reads its key in a way the gatekeeper does not know.`);
|
|
425
|
+
}
|
|
426
|
+
let grant;
|
|
427
|
+
let secret = null;
|
|
428
|
+
if (asked.kind === 'model') {
|
|
429
|
+
const source = asked.secret.kind === 'typed' ? { kind: 'typed' } : { kind: 'env', name: asked.secret.name };
|
|
430
|
+
secret = asked.secret.kind === 'typed' ? asked.secret.value : readHostSecret(asked.secret.name);
|
|
431
|
+
if (typeof secret !== 'string' || secret === '') {
|
|
432
|
+
throw new SpaceError('secret_source_missing', `The environment variable ${asked.secret.name} is not set for OpenChamber, so there is no key to give.`);
|
|
433
|
+
}
|
|
434
|
+
grant = { kind: 'model', id: asked.provider, provider: asked.provider, upstream: asked.upstream, header: HEADER_BY_PROVIDER.get(asked.provider), source };
|
|
435
|
+
} else {
|
|
436
|
+
grant = { kind: 'domain', id: `open-${crypto.randomBytes(6).toString('hex')}`, upstream: asked.upstream };
|
|
437
|
+
}
|
|
438
|
+
await gatekeeper.addGrant(spaceId, { id: grant.id, upstream: grant.upstream, header: grant.kind === 'model' ? grant.header : null, secret });
|
|
439
|
+
const grants = [...current.record.grants.filter((entry) => entry.id !== grant.id), grant];
|
|
440
|
+
if (records.update(spaceId, { grants }).status !== 'ok') {
|
|
441
|
+
throw new SpaceError('space_record_unreadable', 'The grant reached the gatekeeper and could not be remembered, so it is gone at the next start. Remove the space and create it again.');
|
|
442
|
+
}
|
|
443
|
+
// A failure here answers the grant with it; the key is in the gatekeeper and the record, and
|
|
444
|
+
// the next start writes the configuration again.
|
|
445
|
+
if (grant.kind === 'model') await spaceOpenCode.writeProviderConfig(spaceId, grants);
|
|
446
|
+
return { grant: describeGrant(grant) };
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
const stopSpace = (spaceId) => exclusive(spaceId, async () => {
|
|
450
|
+
requireNotPending(spaceId);
|
|
451
|
+
await manager.stopSpace({ placeId: place.id, spaceId });
|
|
452
|
+
onSpacesChanged();
|
|
453
|
+
return requireListed(spaceId);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Removes a space and everything the host kept for it: the containers, networks and volumes,
|
|
458
|
+
* the service refs in the user's repository and the record. A failed creation is forgotten here.
|
|
459
|
+
* The chat archive of decision 9 is a later stage; today the sessions go with the space.
|
|
460
|
+
*/
|
|
461
|
+
const removeUnlocked = async (spaceId) => {
|
|
462
|
+
const waiting = pending.get(spaceId);
|
|
463
|
+
if (waiting) {
|
|
464
|
+
if (waiting.state !== 'failed') throw new SpaceError('space_preparing', 'This space is still being made. Wait for it, then remove it.');
|
|
465
|
+
pending.delete(spaceId);
|
|
466
|
+
// A failed creation whose clean-up failed still has containers; those go now, or the space
|
|
467
|
+
// comes back in the list as damaged. One that was cleaned up has nothing left to remove.
|
|
468
|
+
const still = (await manager.listSpaces({ placeId: place.id })).some((space) => space.id === spaceId);
|
|
469
|
+
if (!still) return { id: spaceId, removed: true, refsRemoved: null, failures: [] };
|
|
470
|
+
return { id: spaceId, ...(await removeEverything(spaceId, waiting.projectDirectory)) };
|
|
471
|
+
}
|
|
472
|
+
const space = await requireListed(spaceId);
|
|
473
|
+
const { record } = records.read(spaceId);
|
|
474
|
+
const outcome = await removeEverything(spaceId, record?.repository ?? space.projectDirectory);
|
|
475
|
+
onSpacesChanged();
|
|
476
|
+
return { id: spaceId, ...outcome };
|
|
477
|
+
};
|
|
478
|
+
const removeSpace = (spaceId) => exclusive(spaceId, () => removeUnlocked(spaceId));
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Stops every running space, for the switch being turned off. Each space is tried on its own:
|
|
482
|
+
* one that could not be stopped is reported as still running, never counted as stopped.
|
|
483
|
+
*/
|
|
484
|
+
const stopAllSpaces = async () => {
|
|
485
|
+
const preparing = Array.from(pending.values()).filter((entry) => entry.state === 'preparing');
|
|
486
|
+
if (preparing.length > 0) throw new SpaceError('space_preparing', `${preparing.length === 1 ? 'A space is' : `${preparing.length} spaces are`} still being made. Wait for that to finish first.`, { spaces: preparing.map((entry) => entry.id) });
|
|
487
|
+
closing = true;
|
|
488
|
+
const spaces = await manager.listSpaces({ placeId: place.id });
|
|
489
|
+
const stopped = [];
|
|
490
|
+
const stillRunning = [];
|
|
491
|
+
for (const space of spaces) {
|
|
492
|
+
if (space.state !== 'running') continue;
|
|
493
|
+
try {
|
|
494
|
+
await manager.stopSpace({ placeId: place.id, spaceId: space.id });
|
|
495
|
+
stopped.push({ id: space.id, name: space.name });
|
|
496
|
+
} catch (error) {
|
|
497
|
+
stillRunning.push({ id: space.id, name: space.name, ...failureOf(error) });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return { stopped, stillRunning };
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
/** For a turn-off that did not go through after the spaces were stopped: creations are taken again. */
|
|
504
|
+
const reopen = () => { closing = false; };
|
|
505
|
+
|
|
506
|
+
/** What the gatekeeper allowed and refused since it last started; a stopped space has no journal. */
|
|
507
|
+
const readJournal = (spaceId) => exclusive(spaceId, async () => {
|
|
508
|
+
const space = await requireListed(spaceId);
|
|
509
|
+
if (space.state !== 'running') {
|
|
510
|
+
throw new SpaceError('space_not_running', 'The journal lives in the memory of the space\'s gatekeeper and is gone with a stop. Start the space to record new attempts.');
|
|
511
|
+
}
|
|
512
|
+
return gatekeeper.readJournal(spaceId);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
const requireRepository = async (spaceId) => {
|
|
516
|
+
requireNotPending(spaceId);
|
|
517
|
+
const space = await requireListed(spaceId);
|
|
518
|
+
const { record } = records.read(spaceId);
|
|
519
|
+
const repository = record?.repository ?? space.projectDirectory;
|
|
520
|
+
if (!repository) throw new SpaceError('project_not_registered', 'The project this space was made for is no longer registered, so its work has nowhere to go.');
|
|
521
|
+
return { space, repository, spacePath: record?.spacePath ?? space.directory };
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
/** Brings the work out and describes what an apply would do, for the apply dialog. */
|
|
525
|
+
const previewApply = (spaceId) => exclusive(spaceId, async () => {
|
|
526
|
+
const { repository, spacePath } = await requireRepository(spaceId);
|
|
527
|
+
const brought = await codeOut.bringCodeOut({ repository, spaceId, spacePath });
|
|
528
|
+
const state = await codeOut.describeApplyState({ repository, spaceId });
|
|
529
|
+
return { ...brought, ...state };
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Brings the work out once more and applies it as a branch or as uncommitted changes. What is
|
|
534
|
+
* applied is what this call fetched, whatever a preview showed. With `removeAfterwards` the
|
|
535
|
+
* space goes once the apply went through, and only then.
|
|
536
|
+
*/
|
|
537
|
+
const applySpace = (spaceId, request) => exclusive(spaceId, async () => {
|
|
538
|
+
const parsed = applyRequestSchema.safeParse(request ?? {});
|
|
539
|
+
if (!parsed.success) throw new SpaceError('invalid_apply_request', 'The work is applied as a branch, with its name, or as uncommitted changes.');
|
|
540
|
+
const { as, removeAfterwards } = parsed.data;
|
|
541
|
+
const branch = as === 'branch' ? parsed.data.branch : null;
|
|
542
|
+
const { repository, spacePath } = await requireRepository(spaceId);
|
|
543
|
+
const brought = await codeOut.bringCodeOut({ repository, spaceId, spacePath });
|
|
544
|
+
const applied = as === 'branch'
|
|
545
|
+
? { status: 'applied', ...(await codeOut.applyAsBranch({ repository, spaceId, branch })) }
|
|
546
|
+
: await codeOut.applyAsChanges({ repository, spaceId });
|
|
547
|
+
const removal = removeAfterwards && applied.status === 'applied' ? await removeUnlocked(spaceId) : null;
|
|
548
|
+
return { brought, applied, removal };
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return { createSpace, listSpaces, startSpace, stopSpace, removeSpace, stopAllSpaces, reopen, grantAccess, readJournal, previewApply, applySpace };
|
|
552
|
+
}
|