@volter/world-runtime 2.0.55 → 3.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/dist/known-external-services.json +5 -4
- package/dist/src/actions-runner-cli.d.ts +2 -0
- package/dist/src/actions-runner-cli.js +25 -0
- package/dist/src/actions-runner.d.ts +18 -0
- package/dist/src/actions-runner.js +281 -0
- package/dist/src/ancestry.js +1 -1
- package/dist/src/app-url.js +1 -1
- package/dist/src/backings.d.ts +29 -0
- package/dist/src/backings.js +45 -0
- package/dist/src/branch.js +3 -2
- package/dist/src/browser-proxy-cli.js +1 -1
- package/dist/src/catalog-index.d.ts +38 -0
- package/dist/src/catalog-index.js +37 -0
- package/dist/src/catalog.d.ts +16 -3
- package/dist/src/catalog.js +69 -26
- package/dist/src/changeset.d.ts +1 -1
- package/dist/src/changeset.js +9 -11
- package/dist/src/cli-args.d.ts +4 -0
- package/dist/src/cli-args.js +18 -0
- package/dist/src/cli-commands.d.ts +1 -0
- package/dist/src/cli-commands.js +1216 -0
- package/dist/src/cli.js +8 -1300
- package/dist/src/configs.js +1 -1
- package/dist/src/console-apart.d.ts +2 -2
- package/dist/src/console-apart.js +5 -5
- package/dist/src/consumers.js +1 -1
- package/dist/src/covers.d.ts +1 -1
- package/dist/src/covers.js +94 -30
- package/dist/src/env-output.d.ts +1 -1
- package/dist/src/env-output.js +2 -2
- package/dist/src/fixture-env.js +18 -26
- package/dist/src/host-cli.js +4 -2
- package/dist/src/host-fault-fixture.js +6 -0
- package/dist/src/host-ready.d.ts +8 -2
- package/dist/src/host-ready.js +16 -4
- package/dist/src/host-worker.js +6 -1
- package/dist/src/host.d.ts +2 -0
- package/dist/src/host.js +9 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/init.d.ts +33 -23
- package/dist/src/init.js +228 -96
- package/dist/src/lifecycle-record.js +2 -2
- package/dist/src/local-branches.js +1 -1
- package/dist/src/origin.d.ts +1 -1
- package/dist/src/origin.js +12 -10
- package/dist/src/pack-facts.d.ts +30 -6
- package/dist/src/pack-facts.js +13 -3
- package/dist/src/pglite-backing.js +2 -1
- package/dist/src/placeholder.d.ts +1 -1
- package/dist/src/placeholder.js +12 -4
- package/dist/src/project-inspect.d.ts +1 -1
- package/dist/src/project-inspect.js +2 -2
- package/dist/src/redirect-proxy.d.ts +5 -0
- package/dist/src/redirect-proxy.js +8 -1
- package/dist/src/redis-backing.js +4 -2
- package/dist/src/reflect.js +1 -1
- package/dist/src/root.d.ts +11 -33
- package/dist/src/root.js +28 -59
- package/dist/src/runtime-test-support.js +1 -1
- package/dist/src/runtime.d.ts +7 -1
- package/dist/src/runtime.js +317 -53
- package/dist/src/schema.d.ts +27 -5
- package/dist/src/schema.js +13 -12
- package/dist/src/served-world.d.ts +11 -35
- package/dist/src/served-world.js +54 -141
- package/dist/src/tail.js +1 -1
- package/dist/src/up-cli.d.ts +3 -0
- package/dist/src/up-cli.js +55 -0
- package/dist/src/websocket-relay.d.ts +1 -1
- package/dist/src/world-origins.d.ts +1 -1
- package/dist/src/world-origins.js +1 -1
- package/dist/src/world-view.d.ts +1 -1
- package/dist/src/world-view.js +2 -2
- package/known-external-services.json +5 -4
- package/package.json +11 -11
- package/src/actions-runner-cli.ts +24 -0
- package/src/actions-runner.ts +244 -0
- package/src/ancestry.ts +1 -1
- package/src/app-url.ts +1 -1
- package/src/backings.ts +55 -0
- package/src/branch.ts +3 -2
- package/src/browser-proxy-cli.ts +1 -1
- package/src/catalog-index.ts +44 -0
- package/src/catalog.ts +67 -24
- package/src/changeset.ts +10 -13
- package/src/cli-args.ts +17 -0
- package/src/cli-commands.ts +1070 -0
- package/src/cli.ts +9 -1139
- package/src/configs.ts +1 -1
- package/src/console-apart.ts +5 -5
- package/src/consumers.ts +1 -1
- package/src/covers.ts +83 -30
- package/src/env-output.ts +2 -2
- package/src/fixture-env.ts +18 -26
- package/src/host-cli.ts +4 -2
- package/src/host-ready.ts +13 -4
- package/src/host-worker.ts +6 -2
- package/src/host.ts +11 -2
- package/src/index.ts +2 -2
- package/src/init.ts +222 -101
- package/src/lifecycle-record.ts +2 -2
- package/src/local-branches.ts +1 -1
- package/src/origin.ts +12 -10
- package/src/pack-facts.ts +22 -6
- package/src/pglite-backing.ts +2 -1
- package/src/placeholder.ts +13 -4
- package/src/project-inspect.ts +2 -2
- package/src/redirect-proxy.ts +9 -1
- package/src/redis-backing.ts +4 -2
- package/src/reflect.ts +1 -1
- package/src/root.ts +33 -65
- package/src/runtime-test-support.ts +1 -1
- package/src/runtime.ts +261 -55
- package/src/schema.ts +26 -13
- package/src/served-world.ts +50 -130
- package/src/tail.ts +1 -1
- package/src/up-cli.ts +55 -0
- package/src/websocket-relay.ts +1 -1
- package/src/world-origins.ts +1 -1
- package/src/world-view.ts +3 -3
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// `volter-world up`, loaded on its own (./cli.ts): it hands the boot to its owner process (./up-task.ts) before
|
|
2
|
+
// any other command's modules load, so a World's start never waits on them. Where each module is compiled as
|
|
3
|
+
// the process starts (a browser tab's engine compiles every module it loads), those modules were most of the
|
|
4
|
+
// CLI's time before the owner process began.
|
|
5
|
+
import { optionValue, ownerOption } from "./cli-args.js";
|
|
6
|
+
import { superviseWorldUp } from "./up-task.js";
|
|
7
|
+
export async function upCommand(subject, rest, root) {
|
|
8
|
+
if (rest.some(arg => arg === '--env-file' || arg.startsWith('--env-file=')))
|
|
9
|
+
throw new Error('--env-file is not an input for this command. Use --env-out=<new-output-path>; never pass credentials or custody as an output.');
|
|
10
|
+
if (!subject)
|
|
11
|
+
throw new Error('volter-world up: missing config');
|
|
12
|
+
const envFile = optionValue(rest, '--env-out');
|
|
13
|
+
if (!envFile)
|
|
14
|
+
throw new Error('volter-world up: missing required --env-out=<path>');
|
|
15
|
+
const name = optionValue(rest, '--name') || undefined;
|
|
16
|
+
const mode = optionValue(rest, '--mode', 'local');
|
|
17
|
+
const isolationArg = optionValue(rest, '--isolation');
|
|
18
|
+
if (isolationArg && isolationArg !== 'process' && isolationArg !== 'colocated' && isolationArg !== 'worker') {
|
|
19
|
+
throw new Error(`volter-world up: invalid --isolation "${isolationArg}" (want process|colocated|worker)`);
|
|
20
|
+
}
|
|
21
|
+
const isolation = (isolationArg || undefined);
|
|
22
|
+
// The boot runs in its own owner process (./up-task.ts): a closed terminal does not leave it half-done.
|
|
23
|
+
// --keep-state: boot over the state the World's directory already holds (a World carried somewhere else, e.g. a
|
|
24
|
+
// browser tab's image, comes up over its data instead of starting empty, as a fresh `up` does)
|
|
25
|
+
const keepState = rest.includes('--keep-state');
|
|
26
|
+
const instance = await superviseWorldUp(subject, { name, root, mode, envFile, forceEnvOutput: rest.includes('--force'), owner: ownerOption(rest), ...(isolation ? { isolation } : {}), ...(keepState ? { keepState: true } : {}) });
|
|
27
|
+
printReady(instance);
|
|
28
|
+
}
|
|
29
|
+
export function printReady(instance) {
|
|
30
|
+
process.stdout.write(`World ${instance.name} is ready (${instance.config})
|
|
31
|
+
|
|
32
|
+
Services:
|
|
33
|
+
${Object.values(instance.services).map((service) => ` ${service.id}: ${service.url ?? '(no listener — a World output)'}`).join('\n')}
|
|
34
|
+
|
|
35
|
+
This instance started CLEAN (a world's state lives only while it runs; the world dir is the
|
|
36
|
+
reproducible story) — set the clock and run your seed now, after every up.
|
|
37
|
+
|
|
38
|
+
The two moves: if the twin stores it, create it through the vendor's own API (seed as the
|
|
39
|
+
person, move the clock first for history). Everything else — judgment, lookups, faults — is
|
|
40
|
+
a handler in the world dir's handlers/<vendor>.json. EVERY twin explains itself at
|
|
41
|
+
GET <url>/twin; a scripted twin's GET <url>/twin/scenario lists handlers + misses.
|
|
42
|
+
|
|
43
|
+
Env:
|
|
44
|
+
${instance.envFile}
|
|
45
|
+
|
|
46
|
+
Instance:
|
|
47
|
+
${instance.dirs.instance}/instance.json
|
|
48
|
+
|
|
49
|
+
Stop:
|
|
50
|
+
volter-world down ${instance.name}
|
|
51
|
+
|
|
52
|
+
For a bounded job, use volter-world run ... -- <command> to tear down automatically on exit.
|
|
53
|
+
Persistent up does not expire or infer that your sessions are idle.
|
|
54
|
+
`);
|
|
55
|
+
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import type { ServeHttpOptions } from '@volter/world-core';
|
|
1
|
+
import type { ServeHttpOptions } from '@volter/world-core/runtime';
|
|
2
2
|
/** A message relay for the World's TLS fronts. Transport state is connection-local. */
|
|
3
3
|
export declare function websocketRelay(targetFor: (request: Request) => string | undefined): NonNullable<ServeHttpOptions['upgrade']>;
|
|
@@ -8,6 +8,6 @@ export declare function worldOfHost(hostname: string, served: Iterable<string>,
|
|
|
8
8
|
/** Whether a path under the console's base is a door of a World served here: a World whose org shares the console's
|
|
9
9
|
* name (`console/<world>`) has its doors at `/-/console/<world>/…`, and they are the World's, never the console's. */
|
|
10
10
|
export declare function worldDoorUnderConsole(path: string, served: (name: string) => boolean, consoleBase: string): boolean;
|
|
11
|
-
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its
|
|
11
|
+
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its vendors' screens) or to
|
|
12
12
|
* what every origin carries (`/-/ping`, the console): a World's origin serves no other World. */
|
|
13
13
|
export declare function pathOfWorld(path: string, served: string, consoleBase: string): boolean;
|
|
@@ -30,7 +30,7 @@ export function worldDoorUnderConsole(path, served, consoleBase) {
|
|
|
30
30
|
const m = /^\/-\/([^/]+)\/([^/]+)/.exec(path);
|
|
31
31
|
return Boolean(m && served(`${m[1]}/${m[2]}`));
|
|
32
32
|
}
|
|
33
|
-
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its
|
|
33
|
+
/** Whether a path on a World's origin belongs to that World (its wire, its doors, its vendors' screens) or to
|
|
34
34
|
* what every origin carries (`/-/ping`, the console): a World's origin serves no other World. */
|
|
35
35
|
export function pathOfWorld(path, served, consoleBase) {
|
|
36
36
|
return path === '/-/ping' || path === consoleBase || path.startsWith(`${consoleBase}/`) || path.startsWith(`/${served}/`) || path.startsWith(`/-/${served}/`) || path === `/${served}` || path === `/-/${served}`;
|
package/dist/src/world-view.d.ts
CHANGED
|
@@ -22,7 +22,7 @@ export declare function serveWorldView(name: string, opts?: {
|
|
|
22
22
|
host?: string;
|
|
23
23
|
console?: ViewConsole;
|
|
24
24
|
announce?: (line: string) => void; /** each World a browser steps into at an origin of its own (on by default on loopback) */
|
|
25
|
-
origins?: boolean; /** the World's own origin as a browser reaches it through a proxy on this machine (
|
|
25
|
+
origins?: boolean; /** the World's own origin as a browser reaches it through a proxy on this machine (an app that serves it as one of its own pages):
|
|
26
26
|
* a request the proxy forwards from that host (X-Forwarded-Host) is answered as at that origin, its local session included */
|
|
27
27
|
origin?: string;
|
|
28
28
|
}): Promise<WorldView>;
|
package/dist/src/world-view.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// THE LOCAL VIEW: one World served for a person to step into — its doors, its
|
|
1
|
+
// THE LOCAL VIEW: one World served for a person to step into — its doors, its vendors' screens, its branches
|
|
2
2
|
// ("as of" views) and the console, on one origin (docs/contributing/architecture.md, "Viewing a
|
|
3
3
|
// World"). `volter world view` is this; the runtime's `up` gains no step. A host that serves many
|
|
4
4
|
// Worlds (world-host) answers the same doors; this serves one, and the branches made of it.
|
|
5
5
|
import { existsSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { loadWorldConfig } from "./configs.js";
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
|
-
import { serveHttp, stateDirName } from '@volter/world-core';
|
|
8
|
+
import { serveHttp, stateDirName } from '@volter/world-core/runtime';
|
|
9
9
|
import { LocalBranches } from "./local-branches.js";
|
|
10
10
|
import { mountWorld } from "./served-world.js";
|
|
11
11
|
import { localWorldOrigin, pathOfWorld, worldDoorUnderConsole, worldOfHost } from "./world-origins.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"//": "PACKLESS-VENDOR REGISTRY — every external-service-shaped npm dep / env stem the catalog KNOWS but has no pack for, plus the not-a-service suppressions and the external-shape patterns. ONE home (absorbs covers.ts's inline NOT_AN_EXTERNAL_SERVICE / UNKNOWN_DEP_KNOWN_SERVICES / UNKNOWN_DEP_PATTERNS and the 2026-08-31 census rulings ledger — company-layer notes/census-rulings-2026-08-31.md). disposition 'demanded' = a real coverage gap, stays LOUD (the demand queue); 'acknowledged' = adjudicated acceptable, covers auto-acknowledges with the recorded reason. AN ENTRY DIES THE DAY ITS PACK IS BORN (scripts/packless-claims.
|
|
2
|
+
"//": "PACKLESS-VENDOR REGISTRY — every external-service-shaped npm dep / env stem the catalog KNOWS but has no pack for, plus the not-a-service suppressions and the external-shape patterns. ONE home (absorbs covers.ts's inline NOT_AN_EXTERNAL_SERVICE / UNKNOWN_DEP_KNOWN_SERVICES / UNKNOWN_DEP_PATTERNS and the 2026-08-31 census rulings ledger — company-layer notes/census-rulings-2026-08-31.md). disposition 'demanded' = a real coverage gap, stays LOUD (the demand queue); 'acknowledged' = adjudicated acceptable, covers auto-acknowledges with the recorded reason. AN ENTRY DIES THE DAY ITS PACK IS BORN (scripts/packless-claims.ts enforces disjointness with pack adoption + SDK_TWINS).",
|
|
3
3
|
"deps": {
|
|
4
4
|
"@1password/sdk": {
|
|
5
5
|
"disposition": "demanded",
|
|
@@ -407,11 +407,11 @@
|
|
|
407
407
|
},
|
|
408
408
|
"bitly": {
|
|
409
409
|
"disposition": "demanded",
|
|
410
|
-
"reason": "vendor bitly — the twin EXISTS (
|
|
410
|
+
"reason": "vendor bitly — the twin EXISTS (@volter/twin-bitly, born 2026-09-02) and serves this client's traffic through the api-ssl.bitly.com injector entry. The npm name stays listed here rather than in the pack's adoption.sdks because `bitly` is a COMMUNITY wrapper, not a Bitly-published client (Bitly ships no first-party npm SDK; Dub's own importer uses plain fetch), and adoption.sdks is for official clients only."
|
|
411
411
|
},
|
|
412
412
|
"paypal": {
|
|
413
413
|
"disposition": "demanded",
|
|
414
|
-
"reason": "vendor paypal — the twin EXISTS (
|
|
414
|
+
"reason": "vendor paypal — the twin EXISTS (@volter/twin-paypal, born 2026-09-13) and serves this consumer's payouts, webhooks and Log-in-with-PayPal traffic through the api(-m).(sandbox.)paypal.com and www.(sandbox.)paypal.com injector entries. The bare `paypal` npm name stays listed here rather than in the pack's adoption.sdks because it is NOT a PayPal-published client (PayPal's own npm clients are `paypal-rest-sdk`, which the pack DOES claim, and the @paypal/* checkout SDKs, which model Orders/Payments/Vault — surface this twin does not serve), and adoption.sdks is for official clients of the surface the pack can honor. The bitly precedent."
|
|
415
415
|
},
|
|
416
416
|
"googleads": {
|
|
417
417
|
"disposition": "demanded",
|
|
@@ -419,7 +419,7 @@
|
|
|
419
419
|
},
|
|
420
420
|
"tiktok": {
|
|
421
421
|
"disposition": "demanded",
|
|
422
|
-
"reason": "vendor tiktok — the twin EXISTS (
|
|
422
|
+
"reason": "vendor tiktok — the twin EXISTS (@volter/twin-tiktok, born 2026-09-13) and serves this vendor's traffic through the www.tiktok.com and open.tiktokapis.com injector entries. The npm name stays listed here rather than in the pack's adoption.sdks because `tiktok` is a COMMUNITY wrapper, not a TikTok-published client (TikTok ships no first-party npm SDK for Login Kit or the Display API; Dub's own integration uses plain fetch), and adoption.sdks is for official clients only."
|
|
423
423
|
}
|
|
424
424
|
},
|
|
425
425
|
"envStems": {
|
|
@@ -969,6 +969,7 @@
|
|
|
969
969
|
"@nocobase/sdk": "self-instance client; targets the app's own self-hosted deployment, which is local in a keyless world",
|
|
970
970
|
"@opentelemetry/instrumentation-aws-sdk": "OpenTelemetry instrumentation wrapper, not a service client",
|
|
971
971
|
"@payloadcms/sdk": "self-instance client; targets the app's own self-hosted deployment, which is local in a keyless world",
|
|
972
|
+
"@prisma/client": "the Prisma ORM client, not a service: its database is a connection, judged by the schema's datasource",
|
|
972
973
|
"@restatedev/restate-sdk": "self-instance client; targets the app's own self-hosted deployment, which is local in a keyless world",
|
|
973
974
|
"@rocket.chat/api-client": "Rocket.Chat monorepo internal package, not an external service",
|
|
974
975
|
"@rocket.chat/federation-sdk": "Rocket.Chat monorepo internal package, not an external service",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/world-runtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "World configs for twins: boot named local runtimes, allocate ports, generate world.env/instance.json, and run apps against fake-key twin worlds.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"twin",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
],
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
|
35
|
-
"url": "git+https://github.com/volter-ai/twin.git",
|
|
35
|
+
"url": "git+https://github.com/volter-ai/twin-world.git",
|
|
36
36
|
"directory": "packages/world-runtime"
|
|
37
37
|
},
|
|
38
38
|
"homepage": "https://world.volter.ai/docs/reference/cli",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"volter-world": "dist/src/cli.js",
|
|
48
48
|
"volter-world-browser-proxy": "dist/src/browser-proxy-cli.js",
|
|
49
49
|
"volter-world-host": "dist/src/host-cli.js",
|
|
50
|
-
"volter-world-infra": "dist/src/infra-cli.js"
|
|
50
|
+
"volter-world-infra": "dist/src/infra-cli.js",
|
|
51
|
+
"volter-world-actions-runner": "dist/src/actions-runner-cli.js"
|
|
51
52
|
},
|
|
52
53
|
"scripts": {
|
|
53
54
|
"test": "bun test src/*.test.ts",
|
|
@@ -67,23 +68,22 @@
|
|
|
67
68
|
"node": ">=22.3"
|
|
68
69
|
},
|
|
69
70
|
"peerDependencies": {
|
|
70
|
-
"@volter/world-core": "
|
|
71
|
-
"@volter/world-console": "
|
|
71
|
+
"@volter/world-core": "3.0.0",
|
|
72
|
+
"@volter/world-console": "3.0.0"
|
|
72
73
|
},
|
|
73
74
|
"dependencies": {
|
|
74
75
|
"@electric-sql/pglite": "0.5.8",
|
|
75
76
|
"@electric-sql/pglite-pgvector": "0.0.9",
|
|
76
|
-
"@volter/world-access": "
|
|
77
|
-
"@volter/world-core": "
|
|
77
|
+
"@volter/world-access": "3.0.0",
|
|
78
|
+
"@volter/world-core": "3.0.0",
|
|
78
79
|
"pg-gateway": "0.3.0-beta.4",
|
|
79
|
-
"smol-toml": "^1.8.0"
|
|
80
|
+
"smol-toml": "^1.8.0",
|
|
81
|
+
"yaml": "2.9.0"
|
|
80
82
|
},
|
|
81
83
|
"peerDependenciesMeta": {
|
|
82
84
|
"@volter/world-console": {
|
|
83
85
|
"optional": true
|
|
84
86
|
}
|
|
85
87
|
},
|
|
86
|
-
"optionalDependencies": {
|
|
87
|
-
"@volter/twin-mongodb": "0.1.37"
|
|
88
|
-
}
|
|
88
|
+
"optionalDependencies": {}
|
|
89
89
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// volter-world-actions-runner: the World's Actions runner as a process (architecture, "The World's Actions runner").
|
|
3
|
+
// volter-world-actions-runner --github <the World's GitHub twin> --token <a World user's token> --work <dir> [--once]
|
|
4
|
+
// It runs every queued workflow run, then (without --once) looks again every two seconds. Steps inherit this process's
|
|
5
|
+
// environment: start it with the World's (volter-world run), so every call a step makes reaches the World's twins.
|
|
6
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
7
|
+
import { mkdirSync } from 'node:fs';
|
|
8
|
+
import { runQueued } from './actions-runner.ts';
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const github = optionValue(args, '--github') ?? process.env.GITHUB_API_URL;
|
|
12
|
+
const token = optionValue(args, '--token') ?? process.env.GITHUB_TOKEN;
|
|
13
|
+
const work = optionValue(args, '--work') ?? '.volter/actions-runner';
|
|
14
|
+
if (!github || !token) {
|
|
15
|
+
process.stderr.write('usage: volter-world-actions-runner --github <url> --token <token> [--work <dir>] [--once]\n');
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
mkdirSync(work, { recursive: true });
|
|
19
|
+
const opts = { github, token, workRoot: work, env: {}, log: (line: string) => process.stdout.write(`${line}\n`) };
|
|
20
|
+
for (;;) {
|
|
21
|
+
const ran = await runQueued(opts);
|
|
22
|
+
if (hasFlag(args, '--once')) process.exit(0);
|
|
23
|
+
if (ran === 0) await new Promise((r) => setTimeout(r, 2000));
|
|
24
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// THE WORLD'S ACTIONS RUNNER — GitHub's hosted runner as a World service (docs/contributing/architecture.md, "The World's
|
|
2
|
+
// Actions runner"). It takes queued workflow runs from the World's GitHub twin (the Actions API lists them; the twin's
|
|
3
|
+
// runner door starts and completes them), checks each run's repository out at its commit over the twin's git plane,
|
|
4
|
+
// and runs its jobs' steps on this machine with GitHub's job environment and the World's own, so every call a step makes
|
|
5
|
+
// to a vendor goes to the World's twin of it. GitHub's own actions it performs itself; any other action fails its step
|
|
6
|
+
// by name.
|
|
7
|
+
//
|
|
8
|
+
// What it does not do (named, so a workflow that needs them fails plainly): matrices beyond their first combination,
|
|
9
|
+
// services and containers, composite and JavaScript actions from the marketplace, reusable workflows, concurrency groups.
|
|
10
|
+
import { spawn } from 'node:child_process';
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { parse as parseYaml } from 'yaml';
|
|
14
|
+
|
|
15
|
+
export type ActionsRunnerOptions = {
|
|
16
|
+
/** The World's GitHub twin (its API and its git plane at the same origin). */
|
|
17
|
+
github: string;
|
|
18
|
+
/** A token of the World's GitHub user, which lists the runs it may run. */
|
|
19
|
+
token: string;
|
|
20
|
+
/** Where job workspaces are made (under the World's data). */
|
|
21
|
+
workRoot: string;
|
|
22
|
+
/** The World's environment, which every step inherits (its twins' addresses and keys). */
|
|
23
|
+
env: Record<string, string>;
|
|
24
|
+
/** Written to as the runner works. */
|
|
25
|
+
log?: (line: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type Row = Record<string, unknown>;
|
|
29
|
+
type Step = { name?: string; id?: string; uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, unknown>; if?: string; shell?: string; 'working-directory'?: string; 'continue-on-error'?: boolean };
|
|
30
|
+
type Job = { name?: string; 'runs-on'?: unknown; needs?: string | string[]; env?: Record<string, unknown>; steps?: Step[]; if?: string; defaults?: { run?: { 'working-directory'?: string; shell?: string } }; strategy?: { matrix?: Record<string, unknown> } };
|
|
31
|
+
type Workflow = { name?: string; env?: Record<string, unknown>; jobs?: Record<string, Job> };
|
|
32
|
+
|
|
33
|
+
async function api(opts: ActionsRunnerOptions, method: string, path: string, body?: unknown, token = opts.token): Promise<{ status: number; body: Row }> {
|
|
34
|
+
const res = await fetch(`${opts.github.replace(/\/$/, '')}${path}`, {
|
|
35
|
+
method, headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', ...(body === undefined ? {} : { 'content-type': 'application/json' }) }, ...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
36
|
+
});
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
let parsed: Row = {};
|
|
39
|
+
try { parsed = text ? JSON.parse(text) as Row : {}; } catch { parsed = { text }; }
|
|
40
|
+
return { status: res.status, body: parsed };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The repositories the runner serves: the user's own and its organizations'. */
|
|
44
|
+
async function repositories(opts: ActionsRunnerOptions): Promise<string[]> {
|
|
45
|
+
const out = new Set<string>();
|
|
46
|
+
const mine = await api(opts, 'GET', '/user/repos?per_page=100');
|
|
47
|
+
for (const r of (mine.body as unknown as Row[]) ?? []) if (typeof r?.full_name === 'string') out.add(r.full_name);
|
|
48
|
+
const orgs = await api(opts, 'GET', '/user/orgs?per_page=100');
|
|
49
|
+
for (const o of (orgs.body as unknown as Row[]) ?? []) {
|
|
50
|
+
const repos = await api(opts, 'GET', `/orgs/${String(o.login)}/repos?per_page=100`);
|
|
51
|
+
for (const r of (repos.body as unknown as Row[]) ?? []) if (typeof r?.full_name === 'string') out.add(r.full_name);
|
|
52
|
+
}
|
|
53
|
+
return [...out].sort();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `${{ … }}` in a string, as far as workflows use it: secrets, env, github, inputs, matrix, steps' outputs, literals. */
|
|
57
|
+
function expand(text: string, scope: Row): string {
|
|
58
|
+
return text.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (_, expr: string) => {
|
|
59
|
+
const v = evaluate(expr, scope);
|
|
60
|
+
return v === undefined || v === null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function lookup(path: string, scope: Row): unknown {
|
|
64
|
+
return path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as Row)[k] : undefined), scope);
|
|
65
|
+
}
|
|
66
|
+
/** An expression: a literal, a context path, `a == b`, `a != b`, `a && b`, `a || b`, `!a`, and the status functions. */
|
|
67
|
+
function evaluate(expr: string, scope: Row): unknown {
|
|
68
|
+
const e = expr.trim();
|
|
69
|
+
if (e.includes('||')) return e.split('||').map((p) => evaluate(p, scope)).find(Boolean) ?? false;
|
|
70
|
+
if (e.includes('&&')) return e.split('&&').every((p) => Boolean(evaluate(p, scope)));
|
|
71
|
+
const cmp = /^(.+?)\s*(==|!=)\s*(.+)$/.exec(e);
|
|
72
|
+
if (cmp) { const a = evaluate(cmp[1]!, scope); const b = evaluate(cmp[3]!, scope); return cmp[2] === '==' ? String(a) === String(b) : String(a) !== String(b); }
|
|
73
|
+
if (e.startsWith('!')) return !evaluate(e.slice(1), scope);
|
|
74
|
+
if (/^'.*'$/.test(e)) return e.slice(1, -1).replace(/''/g, "'");
|
|
75
|
+
if (/^-?\d+(\.\d+)?$/.test(e)) return Number(e);
|
|
76
|
+
if (e === 'true' || e === 'false') return e === 'true';
|
|
77
|
+
if (e === 'always()') return true;
|
|
78
|
+
if (e === 'success()') return scope.__failed !== true;
|
|
79
|
+
if (e === 'failure()') return scope.__failed === true;
|
|
80
|
+
if (e === 'cancelled()') return false;
|
|
81
|
+
const fn = /^(startsWith|endsWith|contains)\((.+),(.+)\)$/.exec(e);
|
|
82
|
+
if (fn) { const a = String(evaluate(fn[2]!, scope) ?? ''); const b = String(evaluate(fn[3]!, scope) ?? ''); return fn[1] === 'startsWith' ? a.startsWith(b) : fn[1] === 'endsWith' ? a.endsWith(b) : a.includes(b); }
|
|
83
|
+
return lookup(e, scope);
|
|
84
|
+
}
|
|
85
|
+
const condition = (cond: string | undefined, scope: Row): boolean => (cond === undefined ? scope.__failed !== true : Boolean(evaluate(cond.replace(/^\$\{\{\s*|\s*\}\}$/g, ''), scope)));
|
|
86
|
+
|
|
87
|
+
/** A `KEY=value` file (GITHUB_ENV, GITHUB_OUTPUT), heredoc form (`KEY<<EOF … EOF`) included. */
|
|
88
|
+
function readKv(file: string): Record<string, string> {
|
|
89
|
+
if (!existsSync(file)) return {};
|
|
90
|
+
const out: Record<string, string> = {};
|
|
91
|
+
const lines = readFileSync(file, 'utf8').split('\n');
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const line = lines[i]!;
|
|
94
|
+
const heredoc = /^([^=<]+)<<(.+)$/.exec(line);
|
|
95
|
+
if (heredoc) {
|
|
96
|
+
const value: string[] = [];
|
|
97
|
+
for (i++; i < lines.length && lines[i] !== heredoc[2]; i++) value.push(lines[i]!);
|
|
98
|
+
out[heredoc[1]!] = value.join('\n');
|
|
99
|
+
} else if (line.includes('=')) out[line.slice(0, line.indexOf('='))] = line.slice(line.indexOf('=') + 1);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function sh(command: string, cwd: string, env: Record<string, string>, shell: string, log: (l: string) => void): Promise<number> {
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
const file = join(cwd, `.volter-step-${Date.now()}-${Math.random().toString(36).slice(2)}.sh`);
|
|
107
|
+
writeFileSync(file, command);
|
|
108
|
+
const argv = shell === 'pwsh' ? ['pwsh', ['-File', file]] as const : shell === 'sh' ? ['sh', ['-e', file]] as const : ['bash', ['--noprofile', '--norc', '-eo', 'pipefail', file]] as const;
|
|
109
|
+
const child = spawn(argv[0], [...argv[1]], { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
|
|
110
|
+
child.stdout.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) if (l) log(` ${l}`); });
|
|
111
|
+
child.stderr.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) if (l) log(` ${l}`); });
|
|
112
|
+
child.on('close', (code) => { rmSync(file, { force: true }); resolve(code ?? 1); });
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** One of GitHub's own actions, performed by the runner; an action it cannot perform fails by name. */
|
|
117
|
+
function builtin(step: Step, env: Record<string, string>, workspace: string): { ok: boolean; message: string } {
|
|
118
|
+
const uses = String(step.uses);
|
|
119
|
+
const name = uses.replace(/@.*$/, '');
|
|
120
|
+
if (name === 'actions/checkout') return { ok: true, message: 'checked out (the workspace is the run\'s commit)' };
|
|
121
|
+
if (name === 'actions/setup-node') {
|
|
122
|
+
// setup-node with a registry-url writes an .npmrc naming it and NODE_AUTH_TOKEN, and points npm at it
|
|
123
|
+
const registry = typeof step.with?.['registry-url'] === 'string' ? String(step.with['registry-url']) : undefined;
|
|
124
|
+
if (registry) {
|
|
125
|
+
const npmrc = join(workspace, '..', '.npmrc-setup-node');
|
|
126
|
+
const host = registry.replace(/^https?:/, '').replace(/\/?$/, '/');
|
|
127
|
+
writeFileSync(npmrc, `registry=${registry}\n${host}:_authToken=\${NODE_AUTH_TOKEN}\nalways-auth=true\n`);
|
|
128
|
+
appendFileSync(env.GITHUB_ENV!, `NPM_CONFIG_USERCONFIG=${npmrc}\n`);
|
|
129
|
+
}
|
|
130
|
+
return { ok: true, message: 'node: the World machine\'s' };
|
|
131
|
+
}
|
|
132
|
+
if (name === 'oven-sh/setup-bun') return { ok: true, message: 'bun: the World machine\'s' };
|
|
133
|
+
if (name === 'actions/upload-artifact' || name === 'actions/download-artifact' || name === 'actions/cache') return { ok: true, message: `${name}: recorded by the GitHub twin` };
|
|
134
|
+
return { ok: false, message: `${uses} is not an action the World's runner performs (GitHub's own: checkout, setup-node, setup-bun, upload-artifact, download-artifact, cache)` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** One run: started, each job's steps run in order of `needs`, completed with its conclusion. */
|
|
138
|
+
export async function runOnce(opts: ActionsRunnerOptions, repo: string, run: Row): Promise<'success' | 'failure'> {
|
|
139
|
+
const log = opts.log ?? (() => {});
|
|
140
|
+
const id = Number(run.id);
|
|
141
|
+
const started = await api(opts, 'POST', `/_twin/repos/${repo}/actions/runs/${id}/start`);
|
|
142
|
+
if (started.status !== 200) { log(`run ${repo}#${id}: not started (${started.status})`); return 'failure'; }
|
|
143
|
+
const jobToken = String(started.body.token);
|
|
144
|
+
const secrets = (started.body.secrets as Record<string, string> | undefined) ?? {};
|
|
145
|
+
// a workflow granting id-token: write: where the job asks GitHub's OIDC provider for its token, and with what
|
|
146
|
+
const idTokenRequest = started.body.id_token_request as { url: string; token: string } | undefined;
|
|
147
|
+
// the repository's configuration variables (`vars`), its organization's under them
|
|
148
|
+
const vars: Record<string, string> = {};
|
|
149
|
+
for (const path of [`/orgs/${repo.split('/')[0]}/actions/variables?per_page=30`, `/repos/${repo}/actions/variables?per_page=30`]) {
|
|
150
|
+
const v = await api(opts, 'GET', path);
|
|
151
|
+
for (const x of (v.body.variables as Row[] | undefined) ?? []) vars[String(x.name)] = String(x.value);
|
|
152
|
+
}
|
|
153
|
+
const file = await api(opts, 'GET', `/repos/${repo}/contents/${encodeURI(String(run.path))}?ref=${String(run.head_sha)}`);
|
|
154
|
+
// the repository's and its owner's ids, which a hosted runner's job environment names
|
|
155
|
+
const repoInfo = (await api(opts, 'GET', `/repos/${repo}`)).body;
|
|
156
|
+
const workflow = parseYaml(Buffer.from(String(file.body.content ?? ''), 'base64').toString('utf8')) as Workflow;
|
|
157
|
+
const root = mkdtempSync(join(opts.workRoot, `run-${id}-`));
|
|
158
|
+
const workspace = join(root, 'workspace');
|
|
159
|
+
const clone = `${opts.github.replace(/\/$/, '').replace(/^(https?:\/\/)/, `$1x-access-token:${jobToken}@`)}/${repo}.git`;
|
|
160
|
+
const git = async (args: string[], cwd: string): Promise<number> => sh(`git ${args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(' ')}`, cwd, { ...process.env as Record<string, string>, ...opts.env }, 'bash', log);
|
|
161
|
+
let conclusion: 'success' | 'failure' = 'success';
|
|
162
|
+
try {
|
|
163
|
+
mkdirSync(root, { recursive: true });
|
|
164
|
+
if (await git(['clone', '--quiet', clone, workspace], root) !== 0 || await git(['checkout', '--quiet', String(run.head_sha)], workspace) !== 0) throw new Error('the repository could not be checked out from the GitHub twin');
|
|
165
|
+
const jobs = Object.entries(workflow.jobs ?? {});
|
|
166
|
+
const done = new Map<string, 'success' | 'failure' | 'skipped'>();
|
|
167
|
+
const ready = (j: Job): boolean => ([] as string[]).concat(j.needs ?? []).every((n) => done.has(n));
|
|
168
|
+
while (done.size < jobs.length) {
|
|
169
|
+
const next = jobs.find(([k, j]) => !done.has(k) && ready(j));
|
|
170
|
+
if (!next) break;
|
|
171
|
+
const [key, job] = next;
|
|
172
|
+
const needsFailed = ([] as string[]).concat(job.needs ?? []).some((n) => done.get(n) !== 'success');
|
|
173
|
+
const matrix = Object.fromEntries(Object.entries(job.strategy?.matrix ?? {}).filter(([k]) => k !== 'include' && k !== 'exclude').map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
|
|
174
|
+
const github = { server_url: opts.github, api_url: opts.github, repository: repo, repository_owner: repo.split('/')[0], sha: run.head_sha, ref: `refs/heads/${String(run.head_branch)}`, ref_name: run.head_branch, event_name: run.event, run_id: id, run_number: run.run_number, workflow: workflow.name, job: key, token: jobToken, workspace, actor: run.actor && typeof run.actor === 'object' ? (run.actor as Row).login : 'github-actions' };
|
|
175
|
+
const scope: Row = { github, secrets: { ...secrets, GITHUB_TOKEN: jobToken }, vars, matrix, needs: {}, steps: {}, env: {}, __failed: needsFailed };
|
|
176
|
+
if (needsFailed || !condition(job.if, scope)) { done.set(key, 'skipped'); log(`job ${key}: skipped`); continue; }
|
|
177
|
+
log(`job ${key}: ${job.name ?? key}`);
|
|
178
|
+
const files = { env: join(root, `${key}.env`), output: join(root, `${key}.output`), path: join(root, `${key}.path`) };
|
|
179
|
+
// a hosted runner is a fresh machine: no package manifest cached from an earlier job (a release just published
|
|
180
|
+
// may be newer than it). The tarballs this machine holds are kept, being content-addressed by version; bun's
|
|
181
|
+
// manifests are not read, by a global bunfig for the job
|
|
182
|
+
const config = join(root, `${key}.config`);
|
|
183
|
+
mkdirSync(config, { recursive: true });
|
|
184
|
+
writeFileSync(join(config, '.bunfig.toml'), '[install.cache]\ndisableManifest = true\n');
|
|
185
|
+
for (const f of Object.values(files)) writeFileSync(f, '');
|
|
186
|
+
const baseEnv: Record<string, string> = {
|
|
187
|
+
...process.env as Record<string, string>, ...opts.env, CI: 'true', GITHUB_ACTIONS: 'true', GITHUB_REPOSITORY: repo, GITHUB_REPOSITORY_OWNER: repo.split('/')[0]!,
|
|
188
|
+
GITHUB_SHA: String(run.head_sha), GITHUB_REF: String(github.ref), GITHUB_REF_NAME: String(run.head_branch), GITHUB_EVENT_NAME: String(run.event), GITHUB_RUN_ID: String(id),
|
|
189
|
+
GITHUB_RUN_NUMBER: String(run.run_number ?? 1), GITHUB_WORKFLOW: String(workflow.name ?? ''), GITHUB_JOB: key, GITHUB_WORKSPACE: workspace, GITHUB_SERVER_URL: opts.github,
|
|
190
|
+
GITHUB_API_URL: opts.github, GITHUB_ENV: files.env, GITHUB_OUTPUT: files.output, GITHUB_PATH: files.path, RUNNER_OS: process.platform === 'darwin' ? 'macOS' : 'Linux', RUNNER_TEMP: root,
|
|
191
|
+
GITHUB_TOKEN: jobToken, XDG_CONFIG_HOME: config,
|
|
192
|
+
// what else a GitHub-hosted runner's job environment holds (npm's provenance reads each of these)
|
|
193
|
+
GITHUB_WORKFLOW_REF: `${repo}/${String(run.path)}@${String(github.ref)}`, GITHUB_RUN_ATTEMPT: '1', RUNNER_ENVIRONMENT: 'github-hosted',
|
|
194
|
+
GITHUB_REPOSITORY_ID: String(repoInfo.id ?? ''), GITHUB_REPOSITORY_OWNER_ID: String((repoInfo.owner as Row | undefined)?.id ?? ''),
|
|
195
|
+
...(idTokenRequest ? { ACTIONS_ID_TOKEN_REQUEST_URL: idTokenRequest.url, ACTIONS_ID_TOKEN_REQUEST_TOKEN: idTokenRequest.token } : {}),
|
|
196
|
+
};
|
|
197
|
+
let failed = false;
|
|
198
|
+
for (const [i, step] of (job.steps ?? []).entries()) {
|
|
199
|
+
const env: Record<string, string> = { ...baseEnv, ...readKv(files.env) };
|
|
200
|
+
const extraPath = readFileSync(files.path, 'utf8').split('\n').filter(Boolean).reverse();
|
|
201
|
+
if (extraPath.length) env.PATH = `${extraPath.join(':')}:${env.PATH ?? ''}`;
|
|
202
|
+
const stepScope: Row = { ...scope, env: { ...Object.fromEntries(Object.entries({ ...workflow.env, ...job.env, ...step.env }).map(([k, v]) => [k, expand(String(v), scope)])) }, __failed: failed };
|
|
203
|
+
for (const [k, v] of Object.entries(stepScope.env as Row)) env[k] = String(v);
|
|
204
|
+
if (!condition(step.if, stepScope)) { log(` step ${i + 1} ${step.name ?? step.uses ?? 'run'}: skipped`); continue; }
|
|
205
|
+
log(` step ${i + 1}: ${step.name ?? step.uses ?? String(step.run).split('\n')[0]}`);
|
|
206
|
+
let ok: boolean;
|
|
207
|
+
if (step.uses) {
|
|
208
|
+
// its inputs are expressions too (`registry-url: ${{ vars.NPM_REGISTRY }}`)
|
|
209
|
+
const inputs = Object.fromEntries(Object.entries(step.with ?? {}).map(([k, v]) => [k, typeof v === 'string' ? expand(v, stepScope) : v]));
|
|
210
|
+
const r = builtin({ ...step, with: inputs }, env, workspace);
|
|
211
|
+
log(` ${r.message}`);
|
|
212
|
+
ok = r.ok;
|
|
213
|
+
} else {
|
|
214
|
+
const cwd = join(workspace, step['working-directory'] ?? job.defaults?.run?.['working-directory'] ?? '');
|
|
215
|
+
ok = (await sh(expand(String(step.run ?? ''), stepScope), cwd, env, step.shell ?? job.defaults?.run?.shell ?? 'bash', log)) === 0;
|
|
216
|
+
}
|
|
217
|
+
if (step.id) (scope.steps as Row)[step.id] = { outputs: readKv(files.output), outcome: ok ? 'success' : 'failure', conclusion: ok || step['continue-on-error'] ? 'success' : 'failure' };
|
|
218
|
+
if (!ok && !step['continue-on-error']) failed = true;
|
|
219
|
+
}
|
|
220
|
+
done.set(key, failed ? 'failure' : 'success');
|
|
221
|
+
(scope.needs as Row)[key] = { result: failed ? 'failure' : 'success', outputs: readKv(files.output) };
|
|
222
|
+
if (failed) conclusion = 'failure';
|
|
223
|
+
}
|
|
224
|
+
} catch (error) {
|
|
225
|
+
log(`run ${repo}#${id}: ${(error as Error).message}`);
|
|
226
|
+
conclusion = 'failure';
|
|
227
|
+
} finally {
|
|
228
|
+
await api(opts, 'POST', `/_twin/repos/${repo}/actions/runs/${id}/complete`, { conclusion });
|
|
229
|
+
rmSync(root, { recursive: true, force: true });
|
|
230
|
+
}
|
|
231
|
+
log(`run ${repo}#${id}: ${conclusion}`);
|
|
232
|
+
return conclusion;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Every queued run of every repository, one at a time, oldest first. */
|
|
236
|
+
export async function runQueued(opts: ActionsRunnerOptions): Promise<number> {
|
|
237
|
+
let ran = 0;
|
|
238
|
+
for (const repo of await repositories(opts)) {
|
|
239
|
+
const runs = await api(opts, 'GET', `/repos/${repo}/actions/runs?status=queued&per_page=100`);
|
|
240
|
+
const queued = ((runs.body.workflow_runs as Row[] | undefined) ?? []).sort((a, b) => Number(a.id) - Number(b.id));
|
|
241
|
+
for (const run of queued) { await runOnce(opts, repo, run); ran += 1; }
|
|
242
|
+
}
|
|
243
|
+
return ran;
|
|
244
|
+
}
|
package/src/ancestry.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// carry parent-side pins, including clones across checkouts, without a global World registry.
|
|
3
3
|
import { existsSync, readdirSync } from 'node:fs';
|
|
4
4
|
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core';
|
|
5
|
+
import { assertStateRemovable, readBranchMeta, stateDirName, withAncestryLock, withStateRemoval, getActiveWorldStore, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
|
|
6
6
|
|
|
7
7
|
function checkWorldAncestry(root: string, path: string): void {
|
|
8
8
|
const base = join(root, stateDirName(), 'worlds');
|
package/src/app-url.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// a re-`up` (which wipes the dir) or `down --purge` clears it: a recorded URL never outlives the
|
|
21
21
|
// instance it described.
|
|
22
22
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
23
|
-
import { withFileLock } from '@volter/world-core';
|
|
23
|
+
import { withFileLock } from '@volter/world-core/runtime';
|
|
24
24
|
import { join } from 'node:path';
|
|
25
25
|
import { spawnSync } from 'node:child_process';
|
|
26
26
|
import net from 'node:net';
|
package/src/backings.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { WorldServiceConfig } from './schema.ts';
|
|
2
|
+
|
|
3
|
+
export type ServerBackingFacts = {
|
|
4
|
+
serverBacking: { command: string };
|
|
5
|
+
adoption: { sdks: string[]; pypi: string[]; envStems: string[] };
|
|
6
|
+
hostsNone: string;
|
|
7
|
+
endpointEnv: { name: string; templates: Record<string, string>; note: string };
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A vendor whose open-source server a World runs as its service instead of a twin (architecture, "A vendor's own
|
|
12
|
+
* server as a backing"): the media plane no pack can hold as data. The service is the vendor's own binary on an 'auto'
|
|
13
|
+
* port, loopback only; the application's key and secret are the ones the server is started with, so its SDK signs
|
|
14
|
+
* what the server verifies. What the server does not do on its own (LiveKit's egress, a separate service) the
|
|
15
|
+
* service's note names.
|
|
16
|
+
*/
|
|
17
|
+
export const VENDOR_SERVER_BACKINGS: Record<string, { service: WorldServiceConfig; env: Record<string, string>; facts: ServerBackingFacts }> = (() => {
|
|
18
|
+
const key = 'twin-livekit-key';
|
|
19
|
+
// livekit-server wants a secret of at least 32 characters
|
|
20
|
+
const secret = 'twin-livekit-secret-for-the-worlds-own-server';
|
|
21
|
+
return {
|
|
22
|
+
livekit: {
|
|
23
|
+
service: {
|
|
24
|
+
'//': 'LiveKit\'s own server (livekit-server, Apache-2.0; `brew install livekit` or LiveKit\'s install script puts it '
|
|
25
|
+
+ 'on PATH), loopback only, rooms in its memory until `down`. Egress is a separate LiveKit service this does not run: '
|
|
26
|
+
+ 'EgressService calls answer its error. Its webhooks go where the application says: add `--config-body` '
|
|
27
|
+
+ `"webhook: {api_key: ${key}, urls: [<the app's webhook URL>]}" to args.`,
|
|
28
|
+
id: 'livekit',
|
|
29
|
+
type: 'twin',
|
|
30
|
+
command: 'livekit-server',
|
|
31
|
+
// media on the loopback address over UDP from LiveKit's default range; ICE over TCP off (its fixed 7881 would
|
|
32
|
+
// collide with a second server)
|
|
33
|
+
args: ['--bind', '127.0.0.1', '--node-ip', '127.0.0.1', '--keys', `${key}: ${secret}`, '--rtc.tcp_port', '0'],
|
|
34
|
+
port: 'auto',
|
|
35
|
+
rootArg: false,
|
|
36
|
+
controlPlane: true,
|
|
37
|
+
ready: { httpUrl: '${url}/' },
|
|
38
|
+
injectEnvTemplates: {
|
|
39
|
+
LIVEKIT_URL: 'ws://${host}:${port}',
|
|
40
|
+
LIVEKIT_API_KEY: key,
|
|
41
|
+
LIVEKIT_API_SECRET: secret,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
env: { LIVEKIT_API_KEY: key, LIVEKIT_API_SECRET: secret },
|
|
45
|
+
facts: {
|
|
46
|
+
// Current consumers: RH2's package.json and server SDK imports; LiveKit's server is the backing, not a pack.
|
|
47
|
+
serverBacking: { command: 'livekit-server' },
|
|
48
|
+
adoption: { sdks: ['livekit-client', 'livekit-server-sdk', '@livekit/rtc-node'], pypi: ['livekit', 'livekit-agents', 'livekit-api'], envStems: ['LIVEKIT'] },
|
|
49
|
+
hostsNone: 'The World runs the vendor server on its declared endpoint; SDKs read LIVEKIT_URL.',
|
|
50
|
+
endpointEnv: { name: 'LIVEKIT_URL', templates: { LIVEKIT_URL: 'ws://${host}:${port}', LIVEKIT_API_KEY: key, LIVEKIT_API_SECRET: secret }, note: 'Credentials and URL are the same ones the World starts its LiveKit server with.' },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
|
package/src/branch.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { cpSync, existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
11
|
import { fileURLToPath } from 'node:url';
|
|
12
|
-
import { captureHistory, historyAtInstant, forkTwin, stateGeneration,
|
|
12
|
+
import { captureHistory, historyAtInstant, forkTwin, stateGeneration, withHistoryLocks, worldPaths, worldStateRoot } from '@volter/world-core/runtime';
|
|
13
13
|
import { downWorld, saveWorldInstance, statusWorld, upWorld } from './runtime.ts';
|
|
14
14
|
import type { WorldInstance } from './schema.ts';
|
|
15
15
|
import { parseInfraDefinition, pgliteDown, pgliteUp } from './pglite-backing.ts';
|
|
@@ -32,7 +32,8 @@ export async function branchWorld(base: string, name: string, options: BranchOpt
|
|
|
32
32
|
const at = (options.now ?? new Date()).toISOString();
|
|
33
33
|
// Capture source identity before awaiting child compute. A concurrent reset may win;
|
|
34
34
|
// in that case forking fails and the task-owned child is torn down, never an empty success.
|
|
35
|
-
const
|
|
35
|
+
const baseDirs = Object.keys(from.services).flatMap((service) => { const fromRoot = join(from.dirs.data, service); return stateServices(fromRoot).map((state) => worldPaths(state, fromRoot).dir); });
|
|
36
|
+
const source = withHistoryLocks(baseDirs, () => Object.keys(from.services).sort().map(service => {
|
|
36
37
|
const fromRoot = join(from.dirs.data, service);
|
|
37
38
|
return { service, fromRoot, states: stateServices(fromRoot).map(state => {
|
|
38
39
|
let view = options.at?.views?.[service] ?? options.at?.views?.[state] ?? captureHistory(state, fromRoot).view;
|
package/src/browser-proxy-cli.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
|
6
6
|
// NOT in the vendor-agnostic kernel — control-plane must never name a vendor (A2). The kernel
|
|
7
7
|
// only exports the vendor-INDEPENDENT `createTwinProxy` utility; choosing which vendors a given
|
|
8
8
|
// app world proxies is the orchestrator's job.
|
|
9
|
-
import { createTwinProxy } from '@volter/world-core';
|
|
9
|
+
import { createTwinProxy } from '@volter/world-core/runtime';
|
|
10
10
|
|
|
11
11
|
const port = Number(process.env.PORT || 0);
|
|
12
12
|
const target = process.env.TARGET_URL;
|