@volter/world-runtime 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/known-external-services.json +1108 -0
- package/dist/src/ancestry.d.ts +2 -0
- package/dist/src/ancestry.js +42 -0
- package/dist/src/app-url.d.ts +47 -0
- package/dist/src/app-url.js +239 -0
- package/dist/src/attach.d.ts +48 -0
- package/dist/src/attach.js +87 -0
- package/dist/src/branch.d.ts +20 -0
- package/dist/src/branch.js +65 -0
- package/dist/src/browser-proxy-cli.d.ts +2 -0
- package/dist/src/browser-proxy-cli.js +41 -0
- package/dist/src/ca-trust.d.ts +5 -0
- package/dist/src/ca-trust.js +64 -0
- package/dist/src/catalog.d.ts +31 -0
- package/dist/src/catalog.js +148 -0
- package/dist/src/changeset.d.ts +142 -0
- package/dist/src/changeset.js +570 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +1262 -0
- package/dist/src/command-lifetime.d.ts +15 -0
- package/dist/src/command-lifetime.js +98 -0
- package/dist/src/configs.d.ts +18 -0
- package/dist/src/configs.js +119 -0
- package/dist/src/console-apart.d.ts +38 -0
- package/dist/src/console-apart.js +107 -0
- package/dist/src/consumers.d.ts +46 -0
- package/dist/src/consumers.js +200 -0
- package/dist/src/covers.d.ts +183 -0
- package/dist/src/covers.js +800 -0
- package/dist/src/fixture-env.d.ts +42 -0
- package/dist/src/fixture-env.js +221 -0
- package/dist/src/host-cli.d.ts +2 -0
- package/dist/src/host-cli.js +92 -0
- package/dist/src/host-fault-fixture.d.ts +32 -0
- package/dist/src/host-fault-fixture.js +100 -0
- package/dist/src/host-worker.d.ts +1 -0
- package/dist/src/host-worker.js +23 -0
- package/dist/src/host.d.ts +38 -0
- package/dist/src/host.js +135 -0
- package/dist/src/index.d.ts +48 -0
- package/dist/src/index.js +35 -0
- package/dist/src/infra-cli.d.ts +2 -0
- package/dist/src/infra-cli.js +136 -0
- package/dist/src/init.d.ts +227 -0
- package/dist/src/init.js +1117 -0
- package/dist/src/inject-map.d.ts +34 -0
- package/dist/src/inject-map.js +56 -0
- package/dist/src/lifecycle-record.d.ts +47 -0
- package/dist/src/lifecycle-record.js +196 -0
- package/dist/src/origin.d.ts +31 -0
- package/dist/src/origin.js +139 -0
- package/dist/src/pack-facts.d.ts +75 -0
- package/dist/src/pack-facts.js +98 -0
- package/dist/src/pglite-backing.d.ts +21 -0
- package/dist/src/pglite-backing.js +158 -0
- package/dist/src/pglite-host.mjs +147 -0
- package/dist/src/placeholder.d.ts +20 -0
- package/dist/src/placeholder.js +100 -0
- package/dist/src/prerequisites.d.ts +21 -0
- package/dist/src/prerequisites.js +49 -0
- package/dist/src/process-groups.d.ts +4 -0
- package/dist/src/process-groups.js +49 -0
- package/dist/src/project-inspect.d.ts +109 -0
- package/dist/src/project-inspect.js +827 -0
- package/dist/src/proxy-daemon.d.ts +2 -0
- package/dist/src/proxy-daemon.js +18 -0
- package/dist/src/redirect-proxy.d.ts +105 -0
- package/dist/src/redirect-proxy.js +665 -0
- package/dist/src/reflect.d.ts +74 -0
- package/dist/src/reflect.js +392 -0
- package/dist/src/resources.d.ts +26 -0
- package/dist/src/resources.js +22 -0
- package/dist/src/root.d.ts +114 -0
- package/dist/src/root.js +312 -0
- package/dist/src/run-task-worker.d.ts +1 -0
- package/dist/src/run-task-worker.js +38 -0
- package/dist/src/run-task.d.ts +18 -0
- package/dist/src/run-task.js +48 -0
- package/dist/src/runtime-test-support.d.ts +59 -0
- package/dist/src/runtime-test-support.js +205 -0
- package/dist/src/runtime.d.ts +256 -0
- package/dist/src/runtime.js +3502 -0
- package/dist/src/schema.d.ts +449 -0
- package/dist/src/schema.js +605 -0
- package/dist/src/serve.d.ts +30 -0
- package/dist/src/serve.js +82 -0
- package/dist/src/served-world.d.ts +194 -0
- package/dist/src/served-world.js +986 -0
- package/dist/src/service-exit.d.ts +46 -0
- package/dist/src/service-exit.js +195 -0
- package/dist/src/service-recorder.d.ts +1 -0
- package/dist/src/service-recorder.js +121 -0
- package/dist/src/sibling.d.ts +1 -0
- package/dist/src/sibling.js +9 -0
- package/dist/src/signals.d.ts +1 -0
- package/dist/src/signals.js +11 -0
- package/dist/src/storage-capacity.d.ts +8 -0
- package/dist/src/storage-capacity.js +61 -0
- package/dist/src/tail.d.ts +30 -0
- package/dist/src/tail.js +160 -0
- package/dist/src/tcp-port.d.ts +2 -0
- package/dist/src/tcp-port.js +36 -0
- package/dist/src/up-task-worker.d.ts +1 -0
- package/dist/src/up-task-worker.js +61 -0
- package/dist/src/up-task.d.ts +17 -0
- package/dist/src/up-task.js +49 -0
- package/dist/src/websocket-relay.d.ts +3 -0
- package/dist/src/websocket-relay.js +40 -0
- package/known-external-services.json +1108 -0
- package/package.json +83 -0
- package/src/ancestry.ts +36 -0
- package/src/app-url.ts +253 -0
- package/src/attach.ts +117 -0
- package/src/branch.ts +63 -0
- package/src/browser-proxy-cli.ts +44 -0
- package/src/ca-trust.ts +57 -0
- package/src/catalog.ts +156 -0
- package/src/changeset.ts +627 -0
- package/src/cli.ts +1111 -0
- package/src/command-lifetime.ts +79 -0
- package/src/configs.ts +110 -0
- package/src/console-apart.ts +90 -0
- package/src/consumers.ts +185 -0
- package/src/covers.ts +934 -0
- package/src/fixture-env.ts +230 -0
- package/src/host-cli.ts +90 -0
- package/src/host-worker.ts +23 -0
- package/src/host.ts +169 -0
- package/src/index.ts +171 -0
- package/src/infra-cli.ts +133 -0
- package/src/init.ts +1316 -0
- package/src/inject-map.ts +72 -0
- package/src/lifecycle-record.ts +168 -0
- package/src/origin.ts +134 -0
- package/src/pack-facts.ts +128 -0
- package/src/pglite-backing.ts +141 -0
- package/src/pglite-host.mjs +147 -0
- package/src/placeholder.ts +89 -0
- package/src/prerequisites.ts +66 -0
- package/src/process-groups.ts +33 -0
- package/src/project-inspect.ts +770 -0
- package/src/proxy-daemon.ts +21 -0
- package/src/redirect-proxy.ts +684 -0
- package/src/reflect.ts +440 -0
- package/src/resources.ts +22 -0
- package/src/root.ts +290 -0
- package/src/run-task-worker.ts +27 -0
- package/src/run-task.ts +44 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +3357 -0
- package/src/schema.ts +922 -0
- package/src/serve.ts +102 -0
- package/src/served-world.ts +812 -0
- package/src/service-exit.ts +175 -0
- package/src/service-recorder.ts +89 -0
- package/src/sibling.ts +10 -0
- package/src/signals.ts +10 -0
- package/src/storage-capacity.ts +60 -0
- package/src/tail.ts +205 -0
- package/src/tcp-port.ts +35 -0
- package/src/up-task-worker.ts +40 -0
- package/src/up-task.ts +45 -0
- package/src/websocket-relay.ts +32 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
import { normalizeWorldNetwork, type WorldNetwork } from '@volter/world-core/network-policy';
|
|
2
|
+
|
|
3
|
+
export type WorldServiceType = 'twin' | 'process' | 'external';
|
|
4
|
+
export type WorldMode = 'local' | 'share' | 'sealed';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Process model for the world's twins — a DIAL, not a binary (see src/host.ts):
|
|
8
|
+
* - 'process' (default): one OS process per service — the existing spawn path. Strongest
|
|
9
|
+
* service-to-service process separation (not a network or Machine boundary),
|
|
10
|
+
* isolation; the oracle, and the only choice for share/sealed/hosted worlds
|
|
11
|
+
* (enforced by `upWorld` — see runtime.ts, not just documented here; TWIN-67).
|
|
12
|
+
* - 'colocated': every service declaring `colocate` runs in ONE `volter-world-host` child
|
|
13
|
+
* sharing a single event loop + heap (host 'shared' mode). Lightest.
|
|
14
|
+
* - 'worker' : same single host child, but one Worker thread per twin — own event loop +
|
|
15
|
+
* heap, independently isolated (a crashed twin stays dead — never respawned),
|
|
16
|
+
* still one OS process to the orchestrator.
|
|
17
|
+
* Services without `colocate` (and all 'external' services) always use the spawn path.
|
|
18
|
+
*/
|
|
19
|
+
export type WorldIsolation = 'process' | 'colocated' | 'worker';
|
|
20
|
+
export const WORLD_SCHEMA_VERSION = 2 as const;
|
|
21
|
+
export const WORLD_USAGES = ['application', 'dependencies', 'build', 'deployment'] as const;
|
|
22
|
+
export type WorldUsage = typeof WORLD_USAGES[number];
|
|
23
|
+
export type WorldSelectionSelector = { usage?: WorldUsage; vendor?: string };
|
|
24
|
+
export type WorldSelection = { include: WorldSelectionSelector[]; exclude: WorldSelectionSelector[] };
|
|
25
|
+
|
|
26
|
+
/** The format-2 document is normalized to WorldConfig before runtime code sees it. */
|
|
27
|
+
export type WorldManifestV2 = {
|
|
28
|
+
schemaVersion: 2;
|
|
29
|
+
metadata: { id: string; description?: string };
|
|
30
|
+
discovery?: { selection?: WorldSelection };
|
|
31
|
+
services: unknown[];
|
|
32
|
+
runtime?: { isolation?: WorldIsolation; environment?: { values?: Record<string, string>; strip?: string[] }; network?: WorldNetwork };
|
|
33
|
+
serving?: { mode?: 'app' | 'bare'; name?: string; share?: WorldShareConfig };
|
|
34
|
+
remotes?: Record<string, string>;
|
|
35
|
+
scenario?: { actors?: Record<string, unknown>; fixtures?: Record<string, unknown> };
|
|
36
|
+
provenance?: { catalog?: { sha: string; protocol?: string } };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The env value `init` writes for a name whose fake must be minted per instance (a service-account JSON with a real throwaway key). */
|
|
40
|
+
export const MINT_ENV = '$mint';
|
|
41
|
+
export type WorldShareProvider = 'cloudflare-quick' | 'command';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* EXTERNAL (self-managed) service type.
|
|
45
|
+
*
|
|
46
|
+
* Most services in a world are owned by us: we spawn `command`, assign a PORT, and inject a
|
|
47
|
+
* `*_URL` (types 'twin' and 'process'). An EXTERNAL service is the opposite: its lifecycle is
|
|
48
|
+
* DELEGATED to an external tool that runs the REAL software (e.g. Supabase's `supabase start`,
|
|
49
|
+
* LocalStack, MinIO, Mailpit). We do NOT assign it a port — the external tool manages its own
|
|
50
|
+
* ports — and its connection URL + keys are DISCOVERED from the tool's own output, then injected
|
|
51
|
+
* into the world env exactly like a twin's `injectEnv`.
|
|
52
|
+
*
|
|
53
|
+
* The runtime is deliberately VENDOR-AGNOSTIC: it never branches on a tool's identity. Everything
|
|
54
|
+
* the runtime needs (how to start, how to probe readiness, how to read connection info, how to
|
|
55
|
+
* stop) is declared in the config below. Supabase is just an EXAMPLE config (see
|
|
56
|
+
* `configs/supabase-world.json`, which mixes a Supabase external service with stripe/github twins);
|
|
57
|
+
* there is zero Supabase-specific code in `runtime.ts`.
|
|
58
|
+
*
|
|
59
|
+
* Lifecycle on world `up`:
|
|
60
|
+
* 1. run `up` (start the external stack),
|
|
61
|
+
* 2. wait for `readyWhen` (Docker-backed externals take seconds to boot),
|
|
62
|
+
* 3. run `status` (or reuse `up` output) to obtain connection info,
|
|
63
|
+
* 4. apply `discover` mappings → inject env vars into the world env (and world.env/instance.json).
|
|
64
|
+
* On world teardown (`down`): run `down` for each external service.
|
|
65
|
+
*
|
|
66
|
+
* Robustness contract: if the external tool is missing on PATH, or `up`/`status` fail, or
|
|
67
|
+
* readiness times out, world `up` fails LOUDLY with the captured output — never a fake success.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/** Source of the text a `discover` mapping reads from: the `status` output (default) or `up` output. */
|
|
71
|
+
export type WorldExternalDiscoverSource = 'status' | 'up';
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One declarative env-var extraction. Exactly one of `jsonPath` or `pattern` must be set.
|
|
75
|
+
* - `jsonPath`: dotted path into the JSON the source command printed (e.g. `API_URL`, `db.url`,
|
|
76
|
+
* `services.0.endpoint`). Numeric segments index into arrays.
|
|
77
|
+
* - `pattern`: a regex applied to the raw source text; capture group `value` (or group 1) is used.
|
|
78
|
+
* The resolved value is injected as the env var named by `as`.
|
|
79
|
+
*/
|
|
80
|
+
export type WorldExternalDiscover = {
|
|
81
|
+
as: string;
|
|
82
|
+
source?: WorldExternalDiscoverSource;
|
|
83
|
+
jsonPath?: string;
|
|
84
|
+
pattern?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Optional readiness probe, polled after `up` until it passes or `timeoutMs` elapses. Exactly one of:
|
|
89
|
+
* - `command`/`args`: a command that must exit 0,
|
|
90
|
+
* - `httpUrl`: an HTTP(S) URL that must return a 2xx/3xx response,
|
|
91
|
+
* - `stdoutMatch`: a regex that must appear in the `up` command's captured stdout/stderr log.
|
|
92
|
+
*/
|
|
93
|
+
export type WorldExternalReadyWhen = {
|
|
94
|
+
command?: string;
|
|
95
|
+
args?: string[];
|
|
96
|
+
httpUrl?: string;
|
|
97
|
+
stdoutMatch?: string;
|
|
98
|
+
timeoutMs?: number;
|
|
99
|
+
intervalMs?: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type WorldExternalServiceConfig = {
|
|
103
|
+
up: string[];
|
|
104
|
+
status?: string[];
|
|
105
|
+
down: string[];
|
|
106
|
+
discover?: WorldExternalDiscover[];
|
|
107
|
+
readyWhen?: WorldExternalReadyWhen;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export type WorldShareServiceConfig = {
|
|
111
|
+
id: string;
|
|
112
|
+
verifyPath?: string | false;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export type WorldShareConfig = {
|
|
116
|
+
provider?: WorldShareProvider;
|
|
117
|
+
/** Custom tunnel command (provider 'command'). `{url}` in args is replaced with the
|
|
118
|
+
* service's local URL. When set, provider defaults to 'command'. */
|
|
119
|
+
command?: string;
|
|
120
|
+
args?: string[];
|
|
121
|
+
/** Lifecycle, independent of provider type. Cloudflare Quick Tunnels are always ephemeral;
|
|
122
|
+
* custom providers state the same fact explicitly when teardown retires the URL. */
|
|
123
|
+
ephemeral?: boolean;
|
|
124
|
+
services: WorldShareServiceConfig[];
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Deprecated metadata from runtime v2. Not reserved or enforced by local runtime v3. */
|
|
128
|
+
export type WorldResourceRequirements = {
|
|
129
|
+
memoryMiB: number;
|
|
130
|
+
writableStorageMiB: number;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export type WorldServiceConfig = {
|
|
134
|
+
/**
|
|
135
|
+
* A free-text NOTE about this service, ignored by the runtime — JSON has no comments, and a
|
|
136
|
+
* generated config needs somewhere to say WHY it is shaped the way it is. `volter-world init`
|
|
137
|
+
* writes the wiring rationale here (which `*_TWIN_URL` the injector actually reads, or why a
|
|
138
|
+
* vendor has no endpoint env at all), so an operator reading the emitted file is not left to
|
|
139
|
+
* reverse-engineer the decision. Hand-written configs may use it freely.
|
|
140
|
+
*/
|
|
141
|
+
'//'?: string;
|
|
142
|
+
id: string;
|
|
143
|
+
type?: WorldServiceType;
|
|
144
|
+
/**
|
|
145
|
+
* The twin PACKAGE this service boots from (`@volter/twin-<vendor>`), resolved from the world
|
|
146
|
+
* root at boot by node's own `node_modules` walk (docs/concepts/worlds.md#the-config-and-the-running-world).
|
|
147
|
+
* With `package` set, `command` and the entry are derived (`bun <pkg>/src/cli.ts serve`), `args`
|
|
148
|
+
* are appended after `serve`, and a `colocate` without a `module` boots `<pkg>/src/index.ts`.
|
|
149
|
+
* A config written inside this checkout names checkout-relative paths instead.
|
|
150
|
+
*/
|
|
151
|
+
package?: string;
|
|
152
|
+
/** Required for 'twin'/'process' services without `package`. Omitted for 'external' (the tool's `up`/`down` hold the commands). */
|
|
153
|
+
command?: string;
|
|
154
|
+
args?: string[];
|
|
155
|
+
/** Optional working directory for this process. Relative paths resolve from the world root. */
|
|
156
|
+
cwd?: string;
|
|
157
|
+
port?: number | 'auto';
|
|
158
|
+
/**
|
|
159
|
+
* Required whenever `port` is a literal number (schema-enforced, TWIN-66): stable configs are
|
|
160
|
+
* meant to be instance-agnostic (see docs/concepts/worlds.md's config/instance split), so a numeric port is the
|
|
161
|
+
* declared EXCEPTION, not the norm — it must record WHY the port can't be `'auto'`-allocated
|
|
162
|
+
* (e.g. a consumer needs a pre-known issuer/callback URL baked in elsewhere). Ignored/forbidden
|
|
163
|
+
* when `port` is `'auto'` or unset.
|
|
164
|
+
*/
|
|
165
|
+
portReason?: string;
|
|
166
|
+
env?: Record<string, string>;
|
|
167
|
+
/**
|
|
168
|
+
* Marks this service as world infrastructure rather than an app/vendor process.
|
|
169
|
+
*
|
|
170
|
+
* Control-plane services still receive the world's declared/injected service env, but they do not
|
|
171
|
+
* inherit app-side egress machinery (`NODE_OPTIONS` injector preload or ambient HTTP(S) proxy env).
|
|
172
|
+
* Use this for helper proxies, seeders, local asset servers, or orchestration daemons that must talk
|
|
173
|
+
* to loopback services directly while presenting a disguised world to the app/user.
|
|
174
|
+
*/
|
|
175
|
+
controlPlane?: boolean;
|
|
176
|
+
injectEnv?: string;
|
|
177
|
+
/**
|
|
178
|
+
* Additional env vars exported into the generated world env after this service starts.
|
|
179
|
+
* Templates are resolved against the owned loopback service:
|
|
180
|
+
* - `${url}` / `${httpUrl}` → http://127.0.0.1:<port>
|
|
181
|
+
* - `${host}` → 127.0.0.1
|
|
182
|
+
* - `${port}` → the allocated port
|
|
183
|
+
* This is the process-service equivalent of external.discover for services whose client-facing
|
|
184
|
+
* endpoint is not the default http URL, e.g. a real local LiveKit server needing ws://.
|
|
185
|
+
*/
|
|
186
|
+
injectEnvTemplates?: Record<string, string>;
|
|
187
|
+
/** THE ROOT (contract "The model"): the vendor's real account behind this twin on a shared world —
|
|
188
|
+
* its API (or the one resource the account is), the deploy policy, how it is refreshed. Key-free;
|
|
189
|
+
* the credential is sealed under `.volter/credentials/`. Written by `volter twin <vendor> root`. */
|
|
190
|
+
root?: { url: string; deploy: 'auto' | 'gated' | 'hold'; refresh?: { every?: string; webhook?: boolean }; scope?: string };
|
|
191
|
+
rootArg?: string | false;
|
|
192
|
+
portArg?: string | false;
|
|
193
|
+
/**
|
|
194
|
+
* Optional per-CLI redirect: env vars exported into the world env so the vendor's REAL CLI
|
|
195
|
+
* (`aws`, `gh`, `sentry-cli`, …) talks to THIS service instead of the real vendor. The literal
|
|
196
|
+
* `${url}` is substituted with the service's resolved URL — e.g. `{ "AWS_ENDPOINT_URL": "${url}" }`.
|
|
197
|
+
* Honored by `volter-world env/run/activate` (it flows into the world env like `injectEnv`). The
|
|
198
|
+
* pure-env path for CLIs that accept an http endpoint; CLIs needing https use the proxy (Phase 2).
|
|
199
|
+
* See docs/guides/route-a-cli-through-the-world.md.
|
|
200
|
+
*/
|
|
201
|
+
cliRedirect?: Record<string, string>;
|
|
202
|
+
/**
|
|
203
|
+
* Readiness probe for a 'process'/'twin' service. The default (or `'tcp'`) waits only until the
|
|
204
|
+
* port accepts TCP connections — but a process can bind its port BEFORE it is actually serving
|
|
205
|
+
* (Vite, an HTTP app warming up a DB pool). Pass a probe object to make `up` wait until the
|
|
206
|
+
* service truly serves, so `up` returning means "ready" (no post-up re-polling needed):
|
|
207
|
+
* - `{ httpUrl }` — poll until 2xx/3xx. `${url}`/`${host}`/`${port}` resolve to the allocated port.
|
|
208
|
+
* - `{ command, args }` — poll until the command exits 0 (runs with the service env, incl. `PORT`).
|
|
209
|
+
* - `{ stdoutMatch }` — poll until a regex matches the service's stdout/stderr log.
|
|
210
|
+
* 'external' services use `external.readyWhen` instead.
|
|
211
|
+
*/
|
|
212
|
+
ready?: 'tcp' | WorldExternalReadyWhen;
|
|
213
|
+
/**
|
|
214
|
+
* Extra Node preloads for a 'process'/'twin' service, appended as `--require <module>` to the
|
|
215
|
+
* world's NODE_OPTIONS (which already loads `@volter/world-core/inject`). Use this to add an app-local
|
|
216
|
+
* preload (e.g. an in-process sandbox) WITHOUT losing the injector. Setting `service.env.NODE_OPTIONS`
|
|
217
|
+
* directly REPLACES the world default (dropping the injector) — only do that if you truly mean to.
|
|
218
|
+
*/
|
|
219
|
+
preload?: string[];
|
|
220
|
+
/** Required when `type === 'external'`: the self-managed lifecycle (up/status/down/discover/readyWhen). */
|
|
221
|
+
external?: WorldExternalServiceConfig;
|
|
222
|
+
/**
|
|
223
|
+
* Names this twin's in-process server factory so it can run CO-LOCATED inside the single
|
|
224
|
+
* `volter-world-host` child when the world's isolation is 'colocated'/'worker'. `module` is
|
|
225
|
+
* anything `import()` resolves — a package name (`@volter/twin-stripe`) or a file path
|
|
226
|
+
* (relative paths resolve from the world root); `export` names a
|
|
227
|
+
* `({port,root,readOnly}) => {port,stop}` factory (every pack ships one, e.g.
|
|
228
|
+
* `createStripeTwinServer`). Ignored under 'process' isolation (the default), where
|
|
229
|
+
* `command` is spawned as usual — declare BOTH to let one config serve every dial setting.
|
|
230
|
+
*/
|
|
231
|
+
colocate?: { /** required unless `package` names the twin */ module?: string; export: string; /** the handlers file a scenario-carrying twin loads (R2a) */ scenarioPath?: string };
|
|
232
|
+
/** R17 — the external lockfile: pins the twin package's own version (its package.json). `up`
|
|
233
|
+
* refuses a mounted package that does not satisfy it, naming both; `init` emits it from the
|
|
234
|
+
* catalog the world was born against. Exact version or `^major.minor` range. */
|
|
235
|
+
version?: string;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export type WorldConfig = {
|
|
239
|
+
id: string;
|
|
240
|
+
/** Real external origins; omitted preserves legacy routing, [] explicitly closes egress. */
|
|
241
|
+
network?: WorldNetwork;
|
|
242
|
+
/** Internal normalized source format and saved discovery selection. */
|
|
243
|
+
manifestVersion?: 1 | 2;
|
|
244
|
+
selection?: WorldSelection;
|
|
245
|
+
description?: string;
|
|
246
|
+
/** A world with no app in front of it (`init --bare <org>/<world>`): a shared world for a team, or a
|
|
247
|
+
* world standing in for a vendor. `name` is how it is served: `/<org>/<world>/…`. */
|
|
248
|
+
bare?: { name: string };
|
|
249
|
+
/** The worlds this one pushes to and fetches from, by name (`volter remote add`); `origin` is the
|
|
250
|
+
* default. A URL a served world printed, or a path to a world's directory. Tokens are never here. */
|
|
251
|
+
remotes?: Record<string, string>;
|
|
252
|
+
/** Peak capacity for this World. Conservative defaults are used when omitted for compatibility;
|
|
253
|
+
* resource-intensive Worlds should always declare both values explicitly. */
|
|
254
|
+
resources?: WorldResourceRequirements;
|
|
255
|
+
/** Default process model for this world's twins ('process' when omitted). `upWorld`'s
|
|
256
|
+
* `isolation` option overrides it per boot. */
|
|
257
|
+
isolation?: WorldIsolation;
|
|
258
|
+
/** The world's env. A value of `$mint` (MINT_ENV) is minted at boot — a structurally valid
|
|
259
|
+
* throwaway credential for that name (fixture-env) — and lives only in the live env file, so a
|
|
260
|
+
* committed config never holds key material. */
|
|
261
|
+
env?: Record<string, string>;
|
|
262
|
+
/** Variables of the process that runs the world which never enter it — a name, a prefix ending in `*`, or `*` for all inherited variables
|
|
263
|
+
* (`HERMES_*`). The world seals what leaves over the wire; this seals what comes in through the environment:
|
|
264
|
+
* whatever steered the outer process (an agent's worker pinning its board and run) must not steer what runs
|
|
265
|
+
* inside, whose state is the world's own. Applied to every service the world starts and to `env`/`attach`. */
|
|
266
|
+
stripEnv?: string[];
|
|
267
|
+
services: WorldServiceConfig[];
|
|
268
|
+
share?: WorldShareConfig;
|
|
269
|
+
actors?: Record<string, unknown>;
|
|
270
|
+
fixtures?: Record<string, unknown>;
|
|
271
|
+
/** The birth stamp (R18), written by `init`: the catalog sha the world was born against and
|
|
272
|
+
* the platform protocol major it was born under. `up` reads the major back and refuses a
|
|
273
|
+
* world this platform can no longer read by construction (two majors back, or newer). */
|
|
274
|
+
catalog?: { sha: string; protocol?: string };
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
export type WorldServiceInstance = {
|
|
278
|
+
id: string;
|
|
279
|
+
type: WorldServiceType;
|
|
280
|
+
command: string[];
|
|
281
|
+
/** R17 — what the host actually mounted for a colocated twin: the package version read from
|
|
282
|
+
* the module's own package.json, and the protocol standing (R16) it declared. */
|
|
283
|
+
resolvedVersion?: string;
|
|
284
|
+
protocol?: { declared?: string; major: number; standing: string };
|
|
285
|
+
/** Absent for 'external' services — we do not assign them a port. */
|
|
286
|
+
port?: number;
|
|
287
|
+
/** Absent for 'external' services — their URL is discovered into env vars, not assigned by us. */
|
|
288
|
+
url?: string;
|
|
289
|
+
/** 0 for 'external' services — they have no process we own (the external tool manages it). */
|
|
290
|
+
pid: number;
|
|
291
|
+
log: string;
|
|
292
|
+
env: Record<string, string>;
|
|
293
|
+
/**
|
|
294
|
+
* Set only for 'external' services. Records the `down` command so teardown can stop the
|
|
295
|
+
* self-managed stack, plus the cwd it was started from (the marker that the stack was brought up).
|
|
296
|
+
*/
|
|
297
|
+
external?: {
|
|
298
|
+
down: string[];
|
|
299
|
+
cwd: string;
|
|
300
|
+
/** Env discovered during `up` (the injected vars), passed to the `down` command so teardown can
|
|
301
|
+
* reference connection info it needs (e.g. a stop command that takes the discovered URL). */
|
|
302
|
+
discoveredEnv?: Record<string, string>;
|
|
303
|
+
};
|
|
304
|
+
publicUrl?: string;
|
|
305
|
+
publicUrlEphemeral?: boolean;
|
|
306
|
+
publicReady?: boolean;
|
|
307
|
+
publicVerification?: {
|
|
308
|
+
path: string;
|
|
309
|
+
checkedAt: string;
|
|
310
|
+
hostname: string;
|
|
311
|
+
resolvedIp?: string;
|
|
312
|
+
status?: number;
|
|
313
|
+
body?: string;
|
|
314
|
+
};
|
|
315
|
+
tunnel?: {
|
|
316
|
+
provider: 'cloudflare-quick' | 'command';
|
|
317
|
+
pid: number;
|
|
318
|
+
log: string;
|
|
319
|
+
command: string[];
|
|
320
|
+
startedAt: string;
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* Set for a 'worker'-isolated co-located twin whose host-managed Worker thread crashed and
|
|
324
|
+
* was given up on (never respawned — still discoverable: the port answers nothing, but the
|
|
325
|
+
* dead twin is no longer silent). Not written directly by the runtime process: the co-located
|
|
326
|
+
* host CHILD records it in a sidecar file (`host-gaveup.json`, same instance dir) since it
|
|
327
|
+
* cannot safely rewrite instance.json out from under the parent; `readWorldInstance` merges
|
|
328
|
+
* the sidecar in here so every reader sees it as if it were in instance.json all along.
|
|
329
|
+
*/
|
|
330
|
+
workerGaveUp?: {
|
|
331
|
+
/** ISO timestamp of the give-up. */
|
|
332
|
+
at: string;
|
|
333
|
+
/** Exits observed for this twin under the no-respawn contract (always 1 — field kept for
|
|
334
|
+
* record compatibility with the sidecar/schema shape). */
|
|
335
|
+
exits: number;
|
|
336
|
+
detail: string;
|
|
337
|
+
};
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** Durable outcome of the most recent foreground consumer launched by `volter-world run`.
|
|
341
|
+
* Deliberately excludes argv and environment values: both can contain credentials. */
|
|
342
|
+
export type WorldRunOutcome = {
|
|
343
|
+
state: 'completed';
|
|
344
|
+
runnerPid: number;
|
|
345
|
+
startedAt: string;
|
|
346
|
+
finishedAt: string;
|
|
347
|
+
exitCode: number;
|
|
348
|
+
log: string;
|
|
349
|
+
signal?: NodeJS.Signals;
|
|
350
|
+
error?: string;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export type WorldRunRecord = WorldRunOutcome | {
|
|
354
|
+
state: 'running';
|
|
355
|
+
runnerPid: number;
|
|
356
|
+
consumerPid?: number;
|
|
357
|
+
startedAt: string;
|
|
358
|
+
log: string;
|
|
359
|
+
} | {
|
|
360
|
+
state: 'abrupt';
|
|
361
|
+
runnerPid: number;
|
|
362
|
+
consumerPid?: number;
|
|
363
|
+
startedAt: string;
|
|
364
|
+
observedAt: string;
|
|
365
|
+
log: string;
|
|
366
|
+
error: string;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
export type WorldInstance = {
|
|
370
|
+
name: string;
|
|
371
|
+
/** Snapshot of the selection in force when this instance booted. */
|
|
372
|
+
selection?: WorldSelection;
|
|
373
|
+
config: string;
|
|
374
|
+
configPath: string;
|
|
375
|
+
root: string;
|
|
376
|
+
createdAt: string;
|
|
377
|
+
mode: WorldMode;
|
|
378
|
+
dirs: {
|
|
379
|
+
instance: string;
|
|
380
|
+
logs: string;
|
|
381
|
+
data: string;
|
|
382
|
+
};
|
|
383
|
+
services: Record<string, WorldServiceInstance>;
|
|
384
|
+
env: Record<string, string>;
|
|
385
|
+
envFile: string;
|
|
386
|
+
pidsFile: string;
|
|
387
|
+
actors?: Record<string, unknown>;
|
|
388
|
+
fixtures?: Record<string, unknown>;
|
|
389
|
+
/** Legacy runtime ownership; finish teardown with that installation before migration. */
|
|
390
|
+
resources?: WorldResourceRequirements & { log: string; holderPid: number; owner?: string };
|
|
391
|
+
lifecycle?: { log: string; owner?: string; holderPid?: number };
|
|
392
|
+
lastRun?: WorldRunRecord;
|
|
393
|
+
/** The ORIGIN this world clones from and fetches (docs/concepts/the-model.md): a hosted twin's
|
|
394
|
+
* namespace — its canonical history. Absent = the placeholder is the only origin. */
|
|
395
|
+
origin?: { url: string; namespace: string; clonedAt?: string; fetchedAt?: string };
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
function assertStringArray(value: unknown, what: string, path: string): asserts value is string[] {
|
|
399
|
+
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) {
|
|
400
|
+
throw new Error(`${what} must be a non-empty string array in ${path}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function assertValidRegex(pattern: string, what: string, path: string): void {
|
|
405
|
+
try {
|
|
406
|
+
new RegExp(pattern);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
throw new Error(`${what} has an invalid regex (${error instanceof Error ? error.message : String(error)}) in ${path}`);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Shared validator for a readiness probe — used by both `external.readyWhen` and a process/twin
|
|
413
|
+
* service's `ready` object. Exactly one of command/httpUrl/stdoutMatch, correct types, and any
|
|
414
|
+
* stdoutMatch regex must compile (so a typo fails at config time, not mid-boot). */
|
|
415
|
+
function assertReadyWhen(ready: unknown, what: string, path: string): void {
|
|
416
|
+
if (!ready || typeof ready !== 'object' || Array.isArray(ready)) {
|
|
417
|
+
throw new Error(`${what} must be an object in ${path}`);
|
|
418
|
+
}
|
|
419
|
+
assertKeys(ready as Record<string, unknown>, ['command', 'args', 'httpUrl', 'stdoutMatch', 'timeoutMs', 'intervalMs'], what, path);
|
|
420
|
+
const r = ready as WorldExternalReadyWhen;
|
|
421
|
+
const probes = [r.command !== undefined, r.httpUrl !== undefined, r.stdoutMatch !== undefined].filter(Boolean).length;
|
|
422
|
+
if (probes !== 1) {
|
|
423
|
+
throw new Error(`${what} must set exactly one of command/httpUrl/stdoutMatch in ${path}`);
|
|
424
|
+
}
|
|
425
|
+
if (r.command !== undefined && typeof r.command !== 'string') throw new Error(`${what}.command must be a string in ${path}`);
|
|
426
|
+
if (r.args !== undefined && (!Array.isArray(r.args) || r.args.some((arg) => typeof arg !== 'string'))) throw new Error(`${what}.args must be a string array in ${path}`);
|
|
427
|
+
if (r.args !== undefined && r.command === undefined) throw new Error(`${what}.args requires command in ${path}`);
|
|
428
|
+
if (r.httpUrl !== undefined && typeof r.httpUrl !== 'string') throw new Error(`${what}.httpUrl must be a string in ${path}`);
|
|
429
|
+
if (r.stdoutMatch !== undefined) {
|
|
430
|
+
if (typeof r.stdoutMatch !== 'string') throw new Error(`${what}.stdoutMatch must be a string in ${path}`);
|
|
431
|
+
assertValidRegex(r.stdoutMatch, `${what}.stdoutMatch`, path);
|
|
432
|
+
}
|
|
433
|
+
for (const key of ['timeoutMs', 'intervalMs'] as const) {
|
|
434
|
+
if (r[key] !== undefined && (!Number.isInteger(r[key]) || r[key]! <= 0)) throw new Error(`${what}.${key} must be a positive integer in ${path}`);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function assertExternalServiceConfig(service: Partial<WorldServiceConfig>, path: string): void {
|
|
439
|
+
const id = service.id;
|
|
440
|
+
if (service.command !== undefined) {
|
|
441
|
+
throw new Error(`External service "${id}" must not set "command" (use external.up/down) in ${path}`);
|
|
442
|
+
}
|
|
443
|
+
if (service.port !== undefined) {
|
|
444
|
+
throw new Error(`External service "${id}" must not set "port" — the external tool manages its own ports in ${path}`);
|
|
445
|
+
}
|
|
446
|
+
if (service.portReason !== undefined) {
|
|
447
|
+
throw new Error(`External service "${id}" must not set "portReason" — it never sets a literal "port" in ${path}`);
|
|
448
|
+
}
|
|
449
|
+
const external = service.external;
|
|
450
|
+
if (!external || typeof external !== 'object' || Array.isArray(external)) {
|
|
451
|
+
throw new Error(`External service "${id}" must define an "external" object in ${path}`);
|
|
452
|
+
}
|
|
453
|
+
assertKeys(external as unknown as Record<string, unknown>, ['up', 'status', 'down', 'discover', 'readyWhen'], `External service "${id}" external`, path);
|
|
454
|
+
assertStringArray(external.up, `External service "${id}" external.up`, path);
|
|
455
|
+
assertStringArray(external.down, `External service "${id}" external.down`, path);
|
|
456
|
+
if (external.status !== undefined) assertStringArray(external.status, `External service "${id}" external.status`, path);
|
|
457
|
+
if (external.discover !== undefined) {
|
|
458
|
+
if (!Array.isArray(external.discover)) throw new Error(`External service "${id}" external.discover must be an array in ${path}`);
|
|
459
|
+
for (const mapping of external.discover) {
|
|
460
|
+
if (!mapping || typeof mapping !== 'object' || Array.isArray(mapping)) {
|
|
461
|
+
throw new Error(`External service "${id}" discover entries must be objects in ${path}`);
|
|
462
|
+
}
|
|
463
|
+
assertKeys(mapping as unknown as Record<string, unknown>, ['as', 'source', 'jsonPath', 'pattern'], `External service "${id}" discover entry`, path);
|
|
464
|
+
if (!mapping.as || typeof mapping.as !== 'string') {
|
|
465
|
+
throw new Error(`External service "${id}" discover entry must define string "as" in ${path}`);
|
|
466
|
+
}
|
|
467
|
+
const hasJson = typeof mapping.jsonPath === 'string';
|
|
468
|
+
const hasPattern = typeof mapping.pattern === 'string';
|
|
469
|
+
if (hasJson === hasPattern) {
|
|
470
|
+
throw new Error(`External service "${id}" discover "${mapping.as}" must set exactly one of jsonPath/pattern in ${path}`);
|
|
471
|
+
}
|
|
472
|
+
if (hasPattern) assertValidRegex(mapping.pattern as string, `External service "${id}" discover "${mapping.as}" pattern`, path);
|
|
473
|
+
if (mapping.source !== undefined && mapping.source !== 'status' && mapping.source !== 'up') {
|
|
474
|
+
throw new Error(`External service "${id}" discover "${mapping.as}" source must be "status" or "up" in ${path}`);
|
|
475
|
+
}
|
|
476
|
+
if (mapping.source === 'status' && external.status === undefined && hasJson) {
|
|
477
|
+
// jsonPath against status requires a status command to produce the JSON.
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (external.status === undefined && external.discover.some((m) => (m.source ?? 'status') === 'status')) {
|
|
481
|
+
throw new Error(`External service "${id}" has discover entries reading "status" but no external.status command in ${path}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (external.readyWhen !== undefined) {
|
|
485
|
+
assertReadyWhen(external.readyWhen, `External service "${id}" external.readyWhen`, path);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function assertLegacyWorldConfig(value: unknown, path: string): WorldConfig {
|
|
490
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
491
|
+
throw new Error(`World config must be an object: ${path}`);
|
|
492
|
+
}
|
|
493
|
+
const config = value as Partial<WorldConfig>;
|
|
494
|
+
if (!config.id || typeof config.id !== 'string') {
|
|
495
|
+
throw new Error(`World config must define string id: ${path}`);
|
|
496
|
+
}
|
|
497
|
+
if (!Array.isArray(config.services)) {
|
|
498
|
+
throw new Error(`World config must define services[]: ${path}`);
|
|
499
|
+
}
|
|
500
|
+
if (config.network !== undefined) config.network = normalizeWorldNetwork(config.network);
|
|
501
|
+
if (config.description !== undefined && typeof config.description !== 'string') throw new Error(`World config description must be a string in ${path}`);
|
|
502
|
+
if (config.env !== undefined && (!record(config.env) || Object.values(config.env).some((entry) => typeof entry !== 'string'))) throw new Error(`World config env must be a string→string object in ${path}`);
|
|
503
|
+
if (config.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined) throw new Error(`World network permission belongs in runtime.network, not environment values in ${path}`);
|
|
504
|
+
for (const key of ['actors', 'fixtures'] as const) if (config[key] !== undefined && !record(config[key])) throw new Error(`World config ${key} must be an object in ${path}`);
|
|
505
|
+
if (config.bare !== undefined && (typeof config.bare !== 'object' || typeof (config.bare as { name?: unknown }).name !== 'string' || !/^[a-z0-9_-]+\/[a-z0-9_-]+$/i.test((config.bare as { name: string }).name))) {
|
|
506
|
+
throw new Error(`World config bare.name must be <org>/<world> in ${path}`);
|
|
507
|
+
}
|
|
508
|
+
if (config.remotes !== undefined) {
|
|
509
|
+
if (typeof config.remotes !== 'object' || Array.isArray(config.remotes)) throw new Error(`World config remotes must be an object of name → url|path in ${path}`);
|
|
510
|
+
for (const [name, target] of Object.entries(config.remotes as Record<string, unknown>)) if (typeof target !== 'string' || target === '' || !/^[A-Za-z0-9._-]+$/.test(name)) throw new Error(`World config remote "${name}" must be a non-empty url or path in ${path}`);
|
|
511
|
+
}
|
|
512
|
+
if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
|
|
513
|
+
throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
|
|
514
|
+
}
|
|
515
|
+
if (config.catalog !== undefined) {
|
|
516
|
+
const c = config.catalog as Partial<{ sha: unknown; protocol: unknown }>;
|
|
517
|
+
if (typeof c !== 'object' || c === null || typeof c.sha !== 'string' || (c.protocol !== undefined && !(typeof c.protocol === 'string' && /^\d+$/.test(c.protocol)))) {
|
|
518
|
+
throw new Error(`World config catalog must be { sha: string, protocol?: "<major>" } (the birth stamp init writes) in ${path}`);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (config.resources !== undefined) {
|
|
522
|
+
if (!config.resources || typeof config.resources !== 'object' || Array.isArray(config.resources)) {
|
|
523
|
+
throw new Error(`World config resources must be an object in ${path}`);
|
|
524
|
+
}
|
|
525
|
+
for (const key of ['memoryMiB', 'writableStorageMiB'] as const) {
|
|
526
|
+
const amount = config.resources[key];
|
|
527
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
528
|
+
throw new Error(`World config resources.${key} must be a positive integer in ${path}`);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (config.stripEnv !== undefined && (!Array.isArray(config.stripEnv) || config.stripEnv.some((s) => typeof s !== 'string' || (s !== '*' && !/^[A-Za-z_][A-Za-z0-9_]*\*?$/.test(s))))) {
|
|
533
|
+
throw new Error(`World config stripEnv must be a list of variable names, prefixes ending in *, or * for all inherited variables in ${path}`);
|
|
534
|
+
}
|
|
535
|
+
const ids = new Set<string>();
|
|
536
|
+
for (const service of config.services as Partial<WorldServiceConfig>[]) {
|
|
537
|
+
if (!service || typeof service !== 'object') throw new Error(`Invalid service in ${path}`);
|
|
538
|
+
if (!service.id || typeof service.id !== 'string') throw new Error(`Service must define string id in ${path}`);
|
|
539
|
+
const exports = [service.injectEnv, ...Object.keys(service.injectEnvTemplates ?? {}), ...Object.keys(service.cliRedirect ?? {}), ...(Array.isArray(service.external?.discover) ? service.external.discover.map(entry => entry?.as) : [])];
|
|
540
|
+
if (service.env?.VOLTER_WORLD_NETWORK_POLICY !== undefined || exports.includes('VOLTER_WORLD_NETWORK_POLICY')) throw new Error(`Service ${service.id} cannot replace World network permission in ${path}`);
|
|
541
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/.test(service.id)) throw new Error(`Invalid service id "${service.id}" in ${path}`);
|
|
542
|
+
if (ids.has(service.id)) throw new Error(`Duplicate service id "${service.id}" in ${path}`);
|
|
543
|
+
ids.add(service.id);
|
|
544
|
+
if (service.type !== undefined && service.type !== 'twin' && service.type !== 'process' && service.type !== 'external') {
|
|
545
|
+
throw new Error(`Service "${service.id}" type must be "twin", "process", or "external" in ${path}`);
|
|
546
|
+
}
|
|
547
|
+
if (service.command !== undefined && typeof service.command !== 'string') throw new Error(`Service "${service.id}" command must be a string in ${path}`);
|
|
548
|
+
if (service.args !== undefined && (!Array.isArray(service.args) || service.args.some((arg) => typeof arg !== 'string'))) throw new Error(`Service "${service.id}" args must be a string array in ${path}`);
|
|
549
|
+
if (service.cwd !== undefined && typeof service.cwd !== 'string') throw new Error(`Service "${service.id}" cwd must be a string in ${path}`);
|
|
550
|
+
if (service.controlPlane !== undefined && typeof service.controlPlane !== 'boolean') throw new Error(`Service "${service.id}" controlPlane must be a boolean in ${path}`);
|
|
551
|
+
for (const key of ['rootArg', 'portArg'] as const) if (service[key] !== undefined && service[key] !== false && typeof service[key] !== 'string') throw new Error(`Service "${service.id}" ${key} must be a string or false in ${path}`);
|
|
552
|
+
if (service.port !== undefined && service.port !== 'auto' && !(Number.isInteger(service.port) && service.port > 0 && service.port <= 65535)) throw new Error(`Service "${service.id}" port must be "auto" or an integer from 1 to 65535 in ${path}`);
|
|
553
|
+
if (service.type === 'external') {
|
|
554
|
+
assertExternalServiceConfig(service, path);
|
|
555
|
+
} else {
|
|
556
|
+
if (service.package !== undefined && !(typeof service.package === 'string' && /^@volter\/twin-[a-z0-9][a-z0-9-]*$/.test(service.package))) {
|
|
557
|
+
throw new Error(`Service "${service.id}" package must name a twin package (@volter/twin-<vendor>) in ${path}`);
|
|
558
|
+
}
|
|
559
|
+
if (service.package === undefined && (!service.command || typeof service.command !== 'string')) {
|
|
560
|
+
throw new Error(`Service "${service.id}" must define command (or package) in ${path}`);
|
|
561
|
+
}
|
|
562
|
+
if (service.ready !== undefined && service.ready !== 'tcp') {
|
|
563
|
+
assertReadyWhen(service.ready, `Service "${service.id}" ready`, path);
|
|
564
|
+
}
|
|
565
|
+
if (service.preload !== undefined) {
|
|
566
|
+
if (!Array.isArray(service.preload) || service.preload.some((p) => typeof p !== 'string')) {
|
|
567
|
+
throw new Error(`Service "${service.id}" preload must be a string array in ${path}`);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
if (typeof service.port === 'number') {
|
|
571
|
+
if (!service.portReason || typeof service.portReason !== 'string') {
|
|
572
|
+
throw new Error(`Service "${service.id}" sets a literal "port" — this requires a "portReason" string recording why it can't be 'auto' in ${path}`);
|
|
573
|
+
}
|
|
574
|
+
} else if (service.portReason !== undefined) {
|
|
575
|
+
throw new Error(`Service "${service.id}" sets "portReason" without a literal numeric "port" in ${path}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (service.root !== undefined) {
|
|
579
|
+
const r = service.root as { url?: unknown; deploy?: unknown; refresh?: unknown };
|
|
580
|
+
if (!record(r)) throw new Error(`Service "${service.id}" root must be an object in ${path}`);
|
|
581
|
+
assertKeys(r as Record<string, unknown>, ['url', 'deploy', 'refresh', 'scope'], `Service "${service.id}" root`, path);
|
|
582
|
+
if (typeof r.url !== 'string' || r.url === '') throw new Error(`Service "${service.id}" root.url must be a non-empty string in ${path}`);
|
|
583
|
+
if (r.deploy !== undefined && r.deploy !== 'auto' && r.deploy !== 'gated' && r.deploy !== 'hold') throw new Error(`Service "${service.id}" root.deploy must be "auto", "gated" or "hold" in ${path}`);
|
|
584
|
+
if ((r as { scope?: unknown }).scope !== undefined && typeof (r as { scope?: unknown }).scope !== 'string') throw new Error(`Service "${service.id}" root.scope must be a string in ${path}`);
|
|
585
|
+
if (r.refresh !== undefined) {
|
|
586
|
+
if (!record(r.refresh)) throw new Error(`Service "${service.id}" root.refresh must be an object in ${path}`);
|
|
587
|
+
assertKeys(r.refresh, ['every', 'webhook'], `Service "${service.id}" root.refresh`, path);
|
|
588
|
+
if (r.refresh.every !== undefined && typeof r.refresh.every !== 'string') throw new Error(`Service "${service.id}" root.refresh.every must be a string in ${path}`);
|
|
589
|
+
if (r.refresh.webhook !== undefined && typeof r.refresh.webhook !== 'boolean') throw new Error(`Service "${service.id}" root.refresh.webhook must be a boolean in ${path}`);
|
|
590
|
+
}
|
|
591
|
+
if (r.deploy === undefined) (service.root as { deploy: string }).deploy = 'gated';
|
|
592
|
+
}
|
|
593
|
+
if (service.colocate !== undefined) {
|
|
594
|
+
if (service.type === 'external') {
|
|
595
|
+
throw new Error(`External service "${service.id}" must not set colocate (its lifecycle is delegated) in ${path}`);
|
|
596
|
+
}
|
|
597
|
+
const colocate = service.colocate as Partial<{ module: string; export: string; scenarioPath: string }>;
|
|
598
|
+
if (record(colocate)) assertKeys(colocate as Record<string, unknown>, ['module', 'export', 'scenarioPath'], `Service "${service.id}" colocate`, path);
|
|
599
|
+
if (colocate?.scenarioPath !== undefined && (typeof colocate.scenarioPath !== 'string' || colocate.scenarioPath === '')) {
|
|
600
|
+
throw new Error(`Service "${service.id}" colocate.scenarioPath must be a non-empty string in ${path}`);
|
|
601
|
+
}
|
|
602
|
+
if (!colocate || typeof colocate !== 'object' || Array.isArray(colocate)
|
|
603
|
+
|| (service.package === undefined ? (!colocate.module || typeof colocate.module !== 'string') : (colocate.module !== undefined && (typeof colocate.module !== 'string' || colocate.module === '')))
|
|
604
|
+
|| !colocate.export || typeof colocate.export !== 'string') {
|
|
605
|
+
throw new Error(`Service "${service.id}" colocate must be { module, export } (non-empty strings; module optional with package) in ${path}`);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (service.version !== undefined && !(typeof service.version === 'string' && /^\^?\d+\.\d+(\.\d+)?$/.test(service.version))) throw new Error(`Service "${service.id}" version must pin the twin package as "major.minor.patch" or "^major.minor" in ${path}`);
|
|
609
|
+
if (service.env !== undefined && (!record(service.env) || Object.values(service.env).some((entry) => typeof entry !== 'string'))) throw new Error(`Service "${service.id}" env must be a string→string object in ${path}`);
|
|
610
|
+
if (service.injectEnv !== undefined && typeof service.injectEnv !== 'string') {
|
|
611
|
+
throw new Error(`Service "${service.id}" injectEnv must be a string in ${path}`);
|
|
612
|
+
}
|
|
613
|
+
if (service.injectEnvTemplates !== undefined) {
|
|
614
|
+
if (typeof service.injectEnvTemplates !== 'object' || service.injectEnvTemplates === null || Array.isArray(service.injectEnvTemplates)
|
|
615
|
+
|| Object.values(service.injectEnvTemplates).some((v) => typeof v !== 'string')) {
|
|
616
|
+
throw new Error(`Service "${service.id}" injectEnvTemplates must be a string→string object in ${path}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (service.cliRedirect !== undefined) {
|
|
620
|
+
if (typeof service.cliRedirect !== 'object' || service.cliRedirect === null || Array.isArray(service.cliRedirect)
|
|
621
|
+
|| Object.values(service.cliRedirect).some((v) => typeof v !== 'string')) {
|
|
622
|
+
throw new Error(`Service "${service.id}" cliRedirect must be a string→string object in ${path}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
if (config.share !== undefined) {
|
|
627
|
+
if (!config.share || typeof config.share !== 'object' || Array.isArray(config.share)) {
|
|
628
|
+
throw new Error(`World config share must be an object in ${path}`);
|
|
629
|
+
}
|
|
630
|
+
const share = config.share as Partial<WorldShareConfig>;
|
|
631
|
+
assertKeys(config.share as unknown as Record<string, unknown>, ['provider', 'command', 'args', 'ephemeral', 'services'], 'World config share', path);
|
|
632
|
+
if (share.provider !== undefined && share.provider !== 'cloudflare-quick' && share.provider !== 'command') {
|
|
633
|
+
throw new Error(`World config share.provider must be "cloudflare-quick" or "command" in ${path}`);
|
|
634
|
+
}
|
|
635
|
+
if (share.provider === 'command' && (typeof share.command !== 'string' || !share.command)) {
|
|
636
|
+
throw new Error(`World config share.provider "command" requires a non-empty share.command in ${path}`);
|
|
637
|
+
}
|
|
638
|
+
if (share.command !== undefined) {
|
|
639
|
+
if (typeof share.command !== 'string' || !share.command) {
|
|
640
|
+
throw new Error(`World config share.command must be a non-empty string in ${path}`);
|
|
641
|
+
}
|
|
642
|
+
if (share.provider !== undefined && share.provider !== 'command') {
|
|
643
|
+
throw new Error(`World config share.command requires share.provider to be omitted or "command" in ${path}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (share.args !== undefined) {
|
|
647
|
+
if (share.command === undefined) {
|
|
648
|
+
throw new Error(`World config share.args is only allowed when share.command is set in ${path}`);
|
|
649
|
+
}
|
|
650
|
+
if (!Array.isArray(share.args) || share.args.some((arg) => typeof arg !== 'string')) {
|
|
651
|
+
throw new Error(`World config share.args must be a string array in ${path}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
if (share.ephemeral !== undefined && typeof share.ephemeral !== 'boolean') {
|
|
655
|
+
throw new Error(`World config share.ephemeral must be a boolean in ${path}`);
|
|
656
|
+
}
|
|
657
|
+
if (!Array.isArray(share.services)) throw new Error(`World config share.services must be an array in ${path}`);
|
|
658
|
+
for (const target of share.services as Partial<WorldShareServiceConfig>[]) {
|
|
659
|
+
if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error(`Invalid share service in ${path}`);
|
|
660
|
+
assertKeys(target as Record<string, unknown>, ['id', 'verifyPath'], 'Share service', path);
|
|
661
|
+
if (!target.id || typeof target.id !== 'string') throw new Error(`Share service must define string id in ${path}`);
|
|
662
|
+
if (!ids.has(target.id)) throw new Error(`Share service "${target.id}" does not match a configured service in ${path}`);
|
|
663
|
+
if (target.verifyPath !== undefined && target.verifyPath !== false && typeof target.verifyPath !== 'string') {
|
|
664
|
+
throw new Error(`Share service "${target.id}" verifyPath must be a string or false in ${path}`);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return config as WorldConfig;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
672
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function assertKeys(value: Record<string, unknown>, allowed: string[], what: string, path: string, allowComments = false): void {
|
|
676
|
+
const unexpected = Object.keys(value).filter((key) => !allowed.includes(key) && !(allowComments && key.startsWith('//')));
|
|
677
|
+
if (unexpected.length) throw new Error(`${what} has unknown field(s) ${unexpected.join(', ')} in ${path}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export const DEFAULT_WORLD_SELECTION: WorldSelection = {
|
|
681
|
+
include: [{ usage: 'application' }],
|
|
682
|
+
exclude: [],
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
export const LEGACY_WORLD_SELECTION: WorldSelection = {
|
|
686
|
+
include: WORLD_USAGES.map((usage) => ({ usage })),
|
|
687
|
+
exclude: [],
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
export function assertWorldSelection(value: unknown, path: string): asserts value is WorldSelection {
|
|
691
|
+
if (!record(value)) throw new Error(`discovery.selection must be an object in ${path}`);
|
|
692
|
+
assertKeys(value, ['include', 'exclude'], 'discovery.selection', path);
|
|
693
|
+
for (const list of ['include', 'exclude'] as const) {
|
|
694
|
+
if (!Array.isArray(value[list])) throw new Error(`discovery.selection.${list} must be an array in ${path}`);
|
|
695
|
+
for (const selector of value[list]) {
|
|
696
|
+
if (!record(selector)) throw new Error(`discovery.selection.${list} entries must be objects in ${path}`);
|
|
697
|
+
assertKeys(selector, ['usage', 'vendor'], `discovery.selection.${list} selector`, path);
|
|
698
|
+
if (selector.usage === undefined && selector.vendor === undefined) throw new Error(`discovery.selection.${list} selectors must not be empty in ${path}`);
|
|
699
|
+
if (selector.usage !== undefined && !WORLD_USAGES.includes(selector.usage as WorldUsage)) throw new Error(`Invalid discovery usage "${String(selector.usage)}" in ${path}`);
|
|
700
|
+
if (selector.vendor !== undefined && (typeof selector.vendor !== 'string' || selector.vendor.trim() === '')) throw new Error(`discovery selector vendor must be a non-empty string in ${path}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function serviceV2ToLegacy(value: unknown, path: string): WorldServiceConfig {
|
|
706
|
+
if (!record(value)) throw new Error(`World service must be an object in ${path}`);
|
|
707
|
+
assertKeys(value, ['id', 'type', 'description', 'source', 'execution', 'endpoint', 'bindings', 'root'], `Service ${String(value.id ?? '')}`, path, true);
|
|
708
|
+
const source = value.source === undefined ? {} : value.source;
|
|
709
|
+
const execution = value.execution === undefined ? {} : value.execution;
|
|
710
|
+
const endpoint = value.endpoint === undefined ? {} : value.endpoint;
|
|
711
|
+
const bindings = value.bindings === undefined ? {} : value.bindings;
|
|
712
|
+
if (!record(source) || !record(execution) || !record(endpoint) || !record(bindings)) throw new Error(`Service "${String(value.id ?? '')}" sections must be objects in ${path}`);
|
|
713
|
+
assertKeys(source, ['package', 'version'], 'service.source', path);
|
|
714
|
+
assertKeys(execution, ['process', 'colocate', 'lifecycle', 'cwd', 'environment', 'preload', 'controlPlane'], 'service.execution', path);
|
|
715
|
+
assertKeys(endpoint, ['port', 'portReason', 'ready'], 'service.endpoint', path);
|
|
716
|
+
assertKeys(bindings, ['injectEnv', 'injectEnvTemplates', 'cliRedirect', 'discover'], 'service.bindings', path);
|
|
717
|
+
const process = execution.process === undefined ? {} : execution.process;
|
|
718
|
+
const environment = execution.environment === undefined ? {} : execution.environment;
|
|
719
|
+
if (!record(process) || !record(environment)) throw new Error(`Service execution process/environment must be objects in ${path}`);
|
|
720
|
+
assertKeys(process, ['command', 'args', 'rootArg', 'portArg'], 'service.execution.process', path);
|
|
721
|
+
assertKeys(environment, ['values'], 'service.execution.environment', path);
|
|
722
|
+
if (typeof value.id !== 'string' || value.id === '') throw new Error(`World service must define string id in ${path}`);
|
|
723
|
+
if (value.description !== undefined && typeof value.description !== 'string') throw new Error(`Service "${value.id}" description must be a string in ${path}`);
|
|
724
|
+
if (value.description !== undefined && value['//'] !== undefined) throw new Error(`Service "${value.id}" must not set both description and // in ${path}`);
|
|
725
|
+
if (execution.cwd !== undefined && typeof execution.cwd !== 'string') throw new Error(`Service "${value.id}" execution.cwd must be a string in ${path}`);
|
|
726
|
+
if (execution.controlPlane !== undefined && typeof execution.controlPlane !== 'boolean') throw new Error(`Service "${value.id}" execution.controlPlane must be a boolean in ${path}`);
|
|
727
|
+
if (execution.colocate !== undefined) {
|
|
728
|
+
if (!record(execution.colocate)) throw new Error(`Service "${value.id}" execution.colocate must be an object in ${path}`);
|
|
729
|
+
assertKeys(execution.colocate, ['module', 'export', 'scenarioPath'], `Service "${value.id}" execution.colocate`, path);
|
|
730
|
+
}
|
|
731
|
+
if (execution.preload !== undefined && (!Array.isArray(execution.preload) || execution.preload.some((entry) => typeof entry !== 'string'))) throw new Error(`Service "${value.id}" execution.preload must be a string array in ${path}`);
|
|
732
|
+
if (execution.lifecycle !== undefined) {
|
|
733
|
+
if (!record(execution.lifecycle)) throw new Error(`Service "${value.id}" execution.lifecycle must be an object in ${path}`);
|
|
734
|
+
assertKeys(execution.lifecycle, ['up', 'status', 'down', 'readyWhen'], `Service "${value.id}" execution.lifecycle`, path);
|
|
735
|
+
}
|
|
736
|
+
if (bindings.discover !== undefined) {
|
|
737
|
+
if (!Array.isArray(bindings.discover)) throw new Error(`Service "${value.id}" bindings.discover must be an array in ${path}`);
|
|
738
|
+
for (const mapping of bindings.discover) {
|
|
739
|
+
if (!record(mapping)) throw new Error(`Service "${value.id}" bindings.discover entries must be objects in ${path}`);
|
|
740
|
+
assertKeys(mapping, ['as', 'source', 'jsonPath', 'pattern'], `Service "${value.id}" bindings.discover entry`, path);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const type = value.type;
|
|
744
|
+
if (type !== 'twin' && type !== 'process' && type !== 'external') throw new Error(`Service "${String(value.id ?? '')}" must explicitly set type in ${path}`);
|
|
745
|
+
const lifecycle = execution.lifecycle;
|
|
746
|
+
const external = lifecycle === undefined ? undefined : {
|
|
747
|
+
...lifecycle,
|
|
748
|
+
...(bindings.discover === undefined ? {} : { discover: bindings.discover }),
|
|
749
|
+
} as WorldExternalServiceConfig;
|
|
750
|
+
const legacy: WorldServiceConfig = {
|
|
751
|
+
...Object.fromEntries(Object.entries(value).filter(([key]) => key.startsWith('//'))),
|
|
752
|
+
...(typeof value.description === 'string' ? { '//': value.description } : {}),
|
|
753
|
+
id: value.id as string,
|
|
754
|
+
type,
|
|
755
|
+
...(source.package === undefined ? {} : { package: source.package as string }),
|
|
756
|
+
...(source.version === undefined ? {} : { version: source.version as string }),
|
|
757
|
+
...(process.command === undefined ? {} : { command: process.command as string }),
|
|
758
|
+
...(process.args === undefined ? {} : { args: process.args as string[] }),
|
|
759
|
+
...(process.rootArg === undefined ? {} : { rootArg: process.rootArg as string | false }),
|
|
760
|
+
...(process.portArg === undefined ? {} : { portArg: process.portArg as string | false }),
|
|
761
|
+
...(execution.colocate === undefined ? {} : { colocate: execution.colocate as WorldServiceConfig['colocate'] }),
|
|
762
|
+
...(execution.cwd === undefined ? {} : { cwd: execution.cwd as string }),
|
|
763
|
+
...(environment.values === undefined ? {} : { env: environment.values as Record<string, string> }),
|
|
764
|
+
...(execution.preload === undefined ? {} : { preload: execution.preload as string[] }),
|
|
765
|
+
...(execution.controlPlane === undefined ? {} : { controlPlane: execution.controlPlane as boolean }),
|
|
766
|
+
...(endpoint.port === undefined ? {} : { port: endpoint.port as number | 'auto' }),
|
|
767
|
+
...(endpoint.portReason === undefined ? {} : { portReason: endpoint.portReason as string }),
|
|
768
|
+
...(endpoint.ready === undefined ? {} : { ready: endpoint.ready as WorldServiceConfig['ready'] }),
|
|
769
|
+
...(bindings.injectEnv === undefined ? {} : { injectEnv: bindings.injectEnv as string }),
|
|
770
|
+
...(bindings.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: bindings.injectEnvTemplates as Record<string, string> }),
|
|
771
|
+
...(bindings.cliRedirect === undefined ? {} : { cliRedirect: bindings.cliRedirect as Record<string, string> }),
|
|
772
|
+
...(value.root === undefined ? {} : { root: value.root as WorldServiceConfig['root'] }),
|
|
773
|
+
...(external === undefined ? {} : { external }),
|
|
774
|
+
};
|
|
775
|
+
if (type !== 'external' && bindings.discover !== undefined) throw new Error(`Service "${legacy.id}" bindings.discover is only valid for external services in ${path}`);
|
|
776
|
+
if (type === 'twin' && ((legacy.package === undefined) === (legacy.command === undefined) || lifecycle !== undefined)) throw new Error(`Twin service "${legacy.id}" requires exactly one of source.package or execution.process.command and forbids lifecycle in ${path}`);
|
|
777
|
+
if (type === 'process' && (legacy.command === undefined || value.source !== undefined || execution.colocate !== undefined || lifecycle !== undefined || legacy.root !== undefined)) throw new Error(`Process service "${legacy.id}" has an invalid source/execution/root combination in ${path}`);
|
|
778
|
+
if (type === 'external' && (legacy.external === undefined || value.source !== undefined || execution.process !== undefined || execution.colocate !== undefined || value.endpoint !== undefined || execution.preload !== undefined || legacy.root !== undefined || bindings.injectEnv !== undefined || bindings.injectEnvTemplates !== undefined || bindings.cliRedirect !== undefined)) throw new Error(`External service "${legacy.id}" has an invalid source/execution/endpoint/bindings/root combination in ${path}`);
|
|
779
|
+
return legacy;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function normalizeV2(value: Record<string, unknown>, path: string): WorldConfig {
|
|
783
|
+
assertKeys(value, ['schemaVersion', 'metadata', 'discovery', 'services', 'runtime', 'serving', 'remotes', 'scenario', 'provenance'], 'World manifest', path, true);
|
|
784
|
+
for (const name of ['metadata', 'discovery', 'runtime', 'serving', 'scenario', 'provenance'] as const) if (value[name] !== undefined && !record(value[name])) throw new Error(`${name} must be an object in ${path}`);
|
|
785
|
+
const metadata = value.metadata as Record<string, unknown> | undefined;
|
|
786
|
+
if (!metadata) throw new Error(`World manifest must define metadata in ${path}`);
|
|
787
|
+
assertKeys(metadata, ['id', 'description'], 'metadata', path);
|
|
788
|
+
if (typeof metadata.id !== 'string' || metadata.id === '') throw new Error(`metadata.id must be a non-empty string in ${path}`);
|
|
789
|
+
if (metadata.description !== undefined && typeof metadata.description !== 'string') throw new Error(`metadata.description must be a string in ${path}`);
|
|
790
|
+
const discovery = (value.discovery ?? {}) as Record<string, unknown>;
|
|
791
|
+
const runtime = (value.runtime ?? {}) as Record<string, unknown>;
|
|
792
|
+
const serving = (value.serving ?? {}) as Record<string, unknown>;
|
|
793
|
+
const scenario = (value.scenario ?? {}) as Record<string, unknown>;
|
|
794
|
+
const provenance = (value.provenance ?? {}) as Record<string, unknown>;
|
|
795
|
+
assertKeys(discovery, ['selection'], 'discovery', path); assertKeys(runtime, ['isolation', 'environment', 'network'], 'runtime', path);
|
|
796
|
+
assertKeys(serving, ['mode', 'name', 'share'], 'serving', path); assertKeys(scenario, ['actors', 'fixtures'], 'scenario', path); assertKeys(provenance, ['catalog'], 'provenance', path);
|
|
797
|
+
const environment = runtime.environment ?? {};
|
|
798
|
+
if (!record(environment)) throw new Error(`runtime.environment must be an object in ${path}`);
|
|
799
|
+
assertKeys(environment, ['values', 'strip'], 'runtime.environment', path);
|
|
800
|
+
if (environment.values !== undefined && (!record(environment.values) || Object.values(environment.values).some((entry) => typeof entry !== 'string'))) throw new Error(`runtime.environment.values must be a string→string object in ${path}`);
|
|
801
|
+
if (scenario.actors !== undefined && !record(scenario.actors)) throw new Error(`scenario.actors must be an object in ${path}`);
|
|
802
|
+
if (scenario.fixtures !== undefined && !record(scenario.fixtures)) throw new Error(`scenario.fixtures must be an object in ${path}`);
|
|
803
|
+
const mode = serving.mode ?? 'app';
|
|
804
|
+
if (mode !== 'app' && mode !== 'bare') throw new Error(`serving.mode must be "app" or "bare" in ${path}`);
|
|
805
|
+
if (mode === 'bare' && typeof serving.name !== 'string') throw new Error(`Bare serving requires serving.name in ${path}`);
|
|
806
|
+
if (mode === 'app' && serving.name !== undefined) throw new Error(`App serving must omit serving.name in ${path}`);
|
|
807
|
+
const selection = discovery.selection ?? DEFAULT_WORLD_SELECTION;
|
|
808
|
+
assertWorldSelection(selection, path);
|
|
809
|
+
if (!Array.isArray(value.services)) throw new Error(`World manifest must define services[] in ${path}`);
|
|
810
|
+
const normalized: WorldConfig = {
|
|
811
|
+
...Object.fromEntries(Object.entries(value).filter(([key]) => key.startsWith('//'))),
|
|
812
|
+
id: metadata.id as string,
|
|
813
|
+
...(metadata.description === undefined ? {} : { description: metadata.description as string }),
|
|
814
|
+
manifestVersion: 2,
|
|
815
|
+
selection,
|
|
816
|
+
services: value.services.map((service) => serviceV2ToLegacy(service, path)),
|
|
817
|
+
...(runtime.isolation === undefined ? {} : { isolation: runtime.isolation as WorldIsolation }),
|
|
818
|
+
...(runtime.network === undefined ? {} : { network: normalizeWorldNetwork(runtime.network) }),
|
|
819
|
+
...(environment.values === undefined ? {} : { env: environment.values as Record<string, string> }),
|
|
820
|
+
...(environment.strip === undefined ? {} : { stripEnv: environment.strip as string[] }),
|
|
821
|
+
...(mode === 'bare' ? { bare: { name: serving.name as string } } : {}),
|
|
822
|
+
...(serving.share === undefined ? {} : { share: serving.share as WorldShareConfig }),
|
|
823
|
+
...(value.remotes === undefined ? {} : { remotes: value.remotes as Record<string, string> }),
|
|
824
|
+
...(scenario.actors === undefined ? {} : { actors: scenario.actors as Record<string, unknown> }),
|
|
825
|
+
...(scenario.fixtures === undefined ? {} : { fixtures: scenario.fixtures as Record<string, unknown> }),
|
|
826
|
+
...(provenance.catalog === undefined ? {} : { catalog: provenance.catalog as WorldConfig['catalog'] }),
|
|
827
|
+
};
|
|
828
|
+
return { ...assertLegacyWorldConfig(normalized, path), manifestVersion: 2, selection };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function assertWorldConfig(value: unknown, path: string): WorldConfig {
|
|
832
|
+
if (record(value) && value.schemaVersion !== undefined) {
|
|
833
|
+
if (value.schemaVersion !== WORLD_SCHEMA_VERSION) throw new Error(`Unsupported World schemaVersion ${String(value.schemaVersion)} in ${path}`);
|
|
834
|
+
if ('id' in value || 'env' in value || 'isolation' in value || 'share' in value || 'catalog' in value) throw new Error(`World manifest mixes format-1 and format-2 fields in ${path}`);
|
|
835
|
+
return normalizeV2(value, path);
|
|
836
|
+
}
|
|
837
|
+
const legacy = assertLegacyWorldConfig(value, path);
|
|
838
|
+
return { ...legacy, manifestVersion: 1 };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function worldSelectionIncludes(selection: WorldSelection, vendor: string, usage: WorldUsage): boolean {
|
|
842
|
+
const matches = (selector: WorldSelectionSelector): boolean =>
|
|
843
|
+
(selector.vendor === undefined || selector.vendor === vendor) && (selector.usage === undefined || selector.usage === usage);
|
|
844
|
+
return selection.include.some(matches) && !selection.exclude.some(matches);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function serviceToV2(service: WorldServiceConfig): Record<string, unknown> {
|
|
848
|
+
const process = {
|
|
849
|
+
...(service.command === undefined ? {} : { command: service.command }),
|
|
850
|
+
...(service.args === undefined ? {} : { args: service.args }),
|
|
851
|
+
...(service.rootArg === undefined ? {} : { rootArg: service.rootArg }),
|
|
852
|
+
...(service.portArg === undefined ? {} : { portArg: service.portArg }),
|
|
853
|
+
};
|
|
854
|
+
const execution = {
|
|
855
|
+
...(Object.keys(process).length ? { process } : {}),
|
|
856
|
+
...(service.colocate === undefined ? {} : { colocate: service.colocate }),
|
|
857
|
+
...(service.external === undefined ? {} : { lifecycle: {
|
|
858
|
+
up: service.external.up,
|
|
859
|
+
...(service.external.status === undefined ? {} : { status: service.external.status }),
|
|
860
|
+
down: service.external.down,
|
|
861
|
+
...(service.external.readyWhen === undefined ? {} : { readyWhen: service.external.readyWhen }),
|
|
862
|
+
} }),
|
|
863
|
+
...(service.cwd === undefined ? {} : { cwd: service.cwd }),
|
|
864
|
+
...(service.env === undefined ? {} : { environment: { values: service.env } }),
|
|
865
|
+
...(service.preload === undefined ? {} : { preload: service.preload }),
|
|
866
|
+
...(service.controlPlane === undefined ? {} : { controlPlane: service.controlPlane }),
|
|
867
|
+
};
|
|
868
|
+
const endpoint = {
|
|
869
|
+
...(service.port === undefined ? {} : { port: service.port }),
|
|
870
|
+
...(service.portReason === undefined ? {} : { portReason: service.portReason }),
|
|
871
|
+
...(service.ready === undefined ? {} : { ready: service.ready }),
|
|
872
|
+
};
|
|
873
|
+
const bindings = {
|
|
874
|
+
...(service.injectEnv === undefined ? {} : { injectEnv: service.injectEnv }),
|
|
875
|
+
...(service.injectEnvTemplates === undefined ? {} : { injectEnvTemplates: service.injectEnvTemplates }),
|
|
876
|
+
...(service.cliRedirect === undefined ? {} : { cliRedirect: service.cliRedirect }),
|
|
877
|
+
...(service.external?.discover === undefined ? {} : { discover: service.external.discover }),
|
|
878
|
+
};
|
|
879
|
+
return {
|
|
880
|
+
...Object.fromEntries(Object.entries(service as Record<string, unknown>).filter(([key]) => key.startsWith('//') && key !== '//')),
|
|
881
|
+
...(service['//'] === undefined ? {} : { description: service['//'] }),
|
|
882
|
+
id: service.id,
|
|
883
|
+
type: service.type ?? 'twin',
|
|
884
|
+
...(service.package === undefined && service.version === undefined ? {} : { source: {
|
|
885
|
+
...(service.package === undefined ? {} : { package: service.package }),
|
|
886
|
+
...(service.version === undefined ? {} : { version: service.version }),
|
|
887
|
+
} }),
|
|
888
|
+
...(Object.keys(execution).length ? { execution } : {}),
|
|
889
|
+
...(Object.keys(endpoint).length ? { endpoint } : {}),
|
|
890
|
+
...(Object.keys(bindings).length ? { bindings } : {}),
|
|
891
|
+
...(service.root === undefined ? {} : { root: service.root }),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/** Serialize normalized intent as the committed format-2 manifest. */
|
|
896
|
+
export function worldConfigDocument(config: WorldConfig, selection: WorldSelection = config.selection ?? DEFAULT_WORLD_SELECTION): WorldManifestV2 & Record<string, unknown> {
|
|
897
|
+
return {
|
|
898
|
+
schemaVersion: WORLD_SCHEMA_VERSION,
|
|
899
|
+
metadata: { id: config.id, ...(config.description === undefined ? {} : { description: config.description }) },
|
|
900
|
+
discovery: { selection },
|
|
901
|
+
...(config.isolation === undefined && config.env === undefined && config.stripEnv === undefined && config.network === undefined ? {} : { runtime: {
|
|
902
|
+
...(config.isolation === undefined ? {} : { isolation: config.isolation }),
|
|
903
|
+
...(config.network === undefined ? {} : { network: normalizeWorldNetwork(config.network) }),
|
|
904
|
+
...(config.env === undefined && config.stripEnv === undefined ? {} : { environment: {
|
|
905
|
+
...(config.env === undefined ? {} : { values: config.env }),
|
|
906
|
+
...(config.stripEnv === undefined ? {} : { strip: config.stripEnv }),
|
|
907
|
+
} }),
|
|
908
|
+
} }),
|
|
909
|
+
services: config.services.map(serviceToV2),
|
|
910
|
+
...(config.bare === undefined && config.share === undefined ? {} : { serving: {
|
|
911
|
+
...(config.bare === undefined ? {} : { mode: 'bare' as const, name: config.bare.name }),
|
|
912
|
+
...(config.share === undefined ? {} : { share: config.share }),
|
|
913
|
+
} }),
|
|
914
|
+
...(config.remotes === undefined ? {} : { remotes: config.remotes }),
|
|
915
|
+
...(config.actors === undefined && config.fixtures === undefined ? {} : { scenario: {
|
|
916
|
+
...(config.actors === undefined ? {} : { actors: config.actors }),
|
|
917
|
+
...(config.fixtures === undefined ? {} : { fixtures: config.fixtures }),
|
|
918
|
+
} }),
|
|
919
|
+
...(config.catalog === undefined ? {} : { provenance: { catalog: config.catalog } }),
|
|
920
|
+
...Object.fromEntries(Object.entries(config as Record<string, unknown>).filter(([key]) => key.startsWith('//'))),
|
|
921
|
+
};
|
|
922
|
+
}
|