@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/dist/src/root.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
// A TWIN'S ROOT (docs/concepts/the-model.md): the vendor's real account
|
|
10
|
+
// behind a twin on a shared world. `volter twin <vendor> root <url> --deploy <policy>` records it,
|
|
11
|
+
// key-free, in `.volter/world.json` (committed) and, at boot, in the twin's state dir (core's
|
|
12
|
+
// root.json — where the write path reads it). `volter twin <vendor> credential` seals the vendor's
|
|
13
|
+
// credential beside the world under `.volter/credentials/<vendor>.json`, envelope-encrypted with
|
|
14
|
+
// a key that lives in the user's config directory and nowhere else (rule 5: never in the repo,
|
|
15
|
+
// never in a pack, never an env var). `volter world deploy` performs landed entries against each
|
|
16
|
+
// root through the pack's adapters over the kernel executor, with the world's checks in front;
|
|
17
|
+
// `deploy: auto` does the same the moment an entry is appended (the host installs the hook).
|
|
18
|
+
import { packEntry } from "./catalog.js";
|
|
19
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { authStrategyFor, getActiveWorldStore, getPack, sealingKey, buildRemoteExecute, clearRoot, openSealedCredential, readRoot, registerStateSystem, sealCredential, stateDirName, stateSystemFor, validateRemoteOrigin, writeRoot, } from '@volter/world-core';
|
|
22
|
+
import { loadWorldConfig, writeWorldConfig } from "./configs.js";
|
|
23
|
+
import { changesetReadiness, collectObservations, foldObservations, loadChecks, openRootCredential, performEntries, rootCustody, protocolStanding } from '@volter/world-core';
|
|
24
|
+
import { findWorldChangeset, listWorldChangesets } from "./changeset.js";
|
|
25
|
+
import { statusWorld } from "./runtime.js";
|
|
26
|
+
import { findInstalledPackage, twinPackageName } from "./catalog.js";
|
|
27
|
+
// ── the user's sealing key ──────────────────────────────────────────────────────────────────
|
|
28
|
+
/** `$XDG_CONFIG_HOME/volter/kek`, else `~/.config/volter/kek`: the key every credential this user
|
|
29
|
+
* seals is wrapped under. Made on first use, 0600, never printed. */
|
|
30
|
+
export { userKek, userKekPath } from '@volter/world-core';
|
|
31
|
+
// ── the sealed credential, beside the world ─────────────────────────────────────────────────
|
|
32
|
+
export function credentialPath(worldRoot, vendor) {
|
|
33
|
+
return join(resolve(worldRoot), stateDirName(), 'credentials', `${vendor}.json`);
|
|
34
|
+
}
|
|
35
|
+
export async function sealTwinCredential(worldRoot, vendor, payload, at = new Date().toISOString()) {
|
|
36
|
+
const sealed = await sealCredential(sealingKey(), payload, at, vendor);
|
|
37
|
+
getActiveWorldStore().write(credentialPath(worldRoot, vendor), `${JSON.stringify(sealed, null, 2)}\n`, { secret: true });
|
|
38
|
+
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint };
|
|
39
|
+
}
|
|
40
|
+
export function sealedCredentialInfo(worldRoot, vendor) {
|
|
41
|
+
const text = getActiveWorldStore().read(credentialPath(worldRoot, vendor));
|
|
42
|
+
if (text === null)
|
|
43
|
+
return null;
|
|
44
|
+
const sealed = JSON.parse(text);
|
|
45
|
+
return { placedAt: sealed.placedAt, fingerprint: sealed.fingerprint };
|
|
46
|
+
}
|
|
47
|
+
/** The signing secret sealed beside the credential (`{ headers, signingSecret }`), for ingest; null when none. */
|
|
48
|
+
export async function twinSigningSecret(worldRoot, vendor) {
|
|
49
|
+
const text = getActiveWorldStore().read(credentialPath(worldRoot, vendor));
|
|
50
|
+
if (text === null)
|
|
51
|
+
return null;
|
|
52
|
+
const payload = await openSealedCredential(sealingKey(), JSON.parse(text), vendor);
|
|
53
|
+
return typeof payload.signingSecret === 'string' && payload.signingSecret.length >= 8 ? payload.signingSecret : null;
|
|
54
|
+
}
|
|
55
|
+
/** A bare token becomes the vendor's own header. A JSON object is taken as the payload. */
|
|
56
|
+
export function credentialPayloadFrom(input, vendor) {
|
|
57
|
+
const text = input.trim();
|
|
58
|
+
if (text.startsWith('{'))
|
|
59
|
+
return JSON.parse(text);
|
|
60
|
+
const header = HEADER_BY_VENDOR[vendor] ?? ((token) => ({ authorization: `Bearer ${token}` }));
|
|
61
|
+
return { headers: header(text) };
|
|
62
|
+
}
|
|
63
|
+
const HEADER_BY_VENDOR = {
|
|
64
|
+
jira: (token) => ({ authorization: `Basic ${Buffer.from(`agent@acme.example:${token}`).toString('base64')}` }),
|
|
65
|
+
openai: (token) => ({ authorization: `Bearer ${token}` }),
|
|
66
|
+
};
|
|
67
|
+
export function setTwinRoot(worldRoot, vendor, config, opts = {}) {
|
|
68
|
+
const { path, config: world } = loadWorldConfig(opts.config ?? defaultConfigPath(worldRoot), worldRoot);
|
|
69
|
+
const service = world.services.find((s) => s.id === vendor);
|
|
70
|
+
if (!service)
|
|
71
|
+
throw new Error(`no twin "${vendor}" in ${path}`);
|
|
72
|
+
if (config) {
|
|
73
|
+
validateRemoteOrigin(config.url, { allowInsecureLoopback: true });
|
|
74
|
+
service.root = config;
|
|
75
|
+
}
|
|
76
|
+
else
|
|
77
|
+
delete service.root;
|
|
78
|
+
writeWorldConfig(path, world);
|
|
79
|
+
return { path };
|
|
80
|
+
}
|
|
81
|
+
function defaultConfigPath(worldRoot) {
|
|
82
|
+
const inRepo = join(resolve(worldRoot), stateDirName(), 'world.json');
|
|
83
|
+
return getActiveWorldStore().exists(inRepo) ? inRepo : 'world';
|
|
84
|
+
}
|
|
85
|
+
/** Every state service dir under a twin's control root (its own id first, then what it recorded under). */
|
|
86
|
+
function stateDirsOf(controlRoot) {
|
|
87
|
+
const stateRoot = join(controlRoot, stateDirName(), 'world');
|
|
88
|
+
const store = getActiveWorldStore();
|
|
89
|
+
return store.list(stateRoot).filter((name) => store.stat(join(stateRoot, name))?.isDirectory).sort();
|
|
90
|
+
}
|
|
91
|
+
/** At boot: every twin with a root gets root.json in its state dir(s), so core's write path and the
|
|
92
|
+
* deploy read it there; a twin whose root was cleared loses it. */
|
|
93
|
+
export function materializeRoots(config, dataDir, worldRoot) {
|
|
94
|
+
for (const service of config.services) {
|
|
95
|
+
if (service.type === 'external')
|
|
96
|
+
continue; // a root belongs to a service the world owns
|
|
97
|
+
const controlRoot = join(dataDir, service.id);
|
|
98
|
+
const root = service.root;
|
|
99
|
+
const states = new Set([service.id, ...stateDirsOf(controlRoot)]);
|
|
100
|
+
for (const state of states) {
|
|
101
|
+
if (root)
|
|
102
|
+
writeRoot(state, { ...root, credential: credentialPath(worldRoot, service.id) }, controlRoot);
|
|
103
|
+
else
|
|
104
|
+
clearRoot(state, controlRoot);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** The root a state service under `controlRoot` is bound to — its own root.json, else a sibling
|
|
109
|
+
* state's (slack records under `chat`; the root was written under `slack`). */
|
|
110
|
+
export function rootForControlRoot(controlRoot, service) {
|
|
111
|
+
const own = readRoot(service, controlRoot);
|
|
112
|
+
if (own)
|
|
113
|
+
return own;
|
|
114
|
+
for (const state of stateDirsOf(controlRoot)) {
|
|
115
|
+
const r = readRoot(state, controlRoot);
|
|
116
|
+
if (r)
|
|
117
|
+
return r;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// ── the pack's adapters ─────────────────────────────────────────────────────────────────────
|
|
122
|
+
async function packModule(vendor, from) {
|
|
123
|
+
// a host that registered its packs (a hosted World bundles the catalog) resolves without importing
|
|
124
|
+
const registered = getPack(vendor);
|
|
125
|
+
if (registered)
|
|
126
|
+
return { pack: registered };
|
|
127
|
+
const entry = packEntryPath(vendor, from);
|
|
128
|
+
return entry ? (await import(__rewriteRelativeImportExtension(entry))) : null;
|
|
129
|
+
}
|
|
130
|
+
/** The pack's module itself, installed beside the World or in this checkout: its every export (a
|
|
131
|
+
* mirror's shell and client), where the registry holds only the descriptor. */
|
|
132
|
+
function packEntryPath(vendor, from) {
|
|
133
|
+
const installed = findInstalledPackage(from, twinPackageName(vendor));
|
|
134
|
+
if (installed)
|
|
135
|
+
return packEntry(installed);
|
|
136
|
+
const checkout = resolve(import.meta.dir, '..', '..', 'twin', vendor, 'src', 'index.ts');
|
|
137
|
+
return existsSync(checkout) ? checkout : null;
|
|
138
|
+
}
|
|
139
|
+
/** The pack's mirror module (`src/<vendor>-mirror-ui.ts`: its shell, client and stylesheet), read as the
|
|
140
|
+
* hosted mirror build reads it (apps/cloud/scripts/mirrors.ts); a pack's index need not re-export them. */
|
|
141
|
+
export async function packMirrorExports(vendor, from) {
|
|
142
|
+
const entry = packEntryPath(vendor, from);
|
|
143
|
+
if (!entry)
|
|
144
|
+
return null;
|
|
145
|
+
const dir = dirname(entry);
|
|
146
|
+
const file = existsSync(dir) ? readdirSync(dir).find((f) => /-mirror-ui\.(ts|js)$/.test(f) && !f.includes('.test.')) : undefined;
|
|
147
|
+
return (await import(__rewriteRelativeImportExtension(file ? join(dir, file) : entry)));
|
|
148
|
+
}
|
|
149
|
+
export async function adaptersFor(vendor, from) {
|
|
150
|
+
const registered = stateSystemFor(vendor);
|
|
151
|
+
// a registration is the whole state system for that twin (a stub in a test, a pack's own
|
|
152
|
+
// descriptor); only an unregistered twin is resolved from the pack's anchored exports
|
|
153
|
+
if (registered)
|
|
154
|
+
return registered;
|
|
155
|
+
const mod = await packModule(vendor, from);
|
|
156
|
+
if (!mod)
|
|
157
|
+
return {};
|
|
158
|
+
const pack = mod.pack;
|
|
159
|
+
if (pack?.stateSystem?.perform) {
|
|
160
|
+
registerStateSystem(vendor, pack.stateSystem);
|
|
161
|
+
return pack.stateSystem;
|
|
162
|
+
}
|
|
163
|
+
// a protocol 2 pack NAMES its adapters (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin); only a protocol 1
|
|
164
|
+
// pack is resolved from its anchored exports
|
|
165
|
+
if (protocolStanding(pack?.protocol).major >= 2)
|
|
166
|
+
throw new Error(`the ${vendor} pack declares protocol 2 but names no adapters — \`stateSystem: { perform, refresh?, ingest? }\` on its descriptor`);
|
|
167
|
+
const name = (re) => Object.keys(mod).find((k) => re.test(k) && typeof mod[k] === 'function');
|
|
168
|
+
const performName = name(/^perform\w+Action$/);
|
|
169
|
+
const refreshName = name(/^refresh\w+$/) ?? name(/^sync\w+FromRemote$/);
|
|
170
|
+
const ingestName = name(/^ingest\w+Event$/);
|
|
171
|
+
const out = { ...(performName ? { perform: mod[performName] } : {}), ...(refreshName ? { refresh: mod[refreshName] } : {}), ...(ingestName ? { ingest: mod[ingestName] } : {}) };
|
|
172
|
+
if (out.perform)
|
|
173
|
+
registerStateSystem(vendor, out);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
// ── the world's checks ──────────────────────────────────────────────────────────────────────
|
|
177
|
+
/** `.volter/checks/*.ts|js`: each exports `check` (or default) as `{ name, run }`. The shipped
|
|
178
|
+
* no-secrets check runs first, always. */
|
|
179
|
+
export const loadWorldChecks = loadChecks;
|
|
180
|
+
/** The pack's declared refresh posture, read from its descriptor. */
|
|
181
|
+
export async function refreshPosture(vendor, from) {
|
|
182
|
+
const mod = await packModule(vendor, from);
|
|
183
|
+
return mod?.pack?.refresh;
|
|
184
|
+
}
|
|
185
|
+
/** `<N>(s|m|h|d)` as milliseconds, or null when malformed. */
|
|
186
|
+
export function spanMs(text) {
|
|
187
|
+
const m = /^(\d+(?:\.\d+)?)(s|m|h|d)$/.exec(text.trim());
|
|
188
|
+
return m ? Number(m[1]) * { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]] : null;
|
|
189
|
+
}
|
|
190
|
+
/** PERFORM a twin's waiting entries against its root: the kernel's head (`performEntries`) with the
|
|
191
|
+
* pack's adapters and the world's checks. A refresh first, when the pack has one, so a deploy sees
|
|
192
|
+
* the vendor as it stands. */
|
|
193
|
+
export async function deployTwin(opts) {
|
|
194
|
+
const adapters = await adaptersFor(opts.vendor, opts.worldRoot);
|
|
195
|
+
if (!adapters.perform)
|
|
196
|
+
throw new Error(`the ${opts.vendor} twin has no perform adapter — it cannot deploy to a vendor yet`);
|
|
197
|
+
let refreshed = false;
|
|
198
|
+
// a refresh first, best effort: the vendor being unreachable is what the PERFORM records as `failed`
|
|
199
|
+
if (adapters.refresh) {
|
|
200
|
+
try {
|
|
201
|
+
const r = await refreshTwin({ ...opts, force: true });
|
|
202
|
+
refreshed = r.refreshed;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
refreshed = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const report = await performEntries({ service: opts.state, root: opts.controlRoot, adapters, checks: opts.checks ?? (await loadChecks(opts.worldRoot)), ...(opts.actionIds ? { actionIds: opts.actionIds } : {}), ...(opts.at ? { at: opts.at } : {}) });
|
|
209
|
+
return { service: opts.vendor, state: opts.state, refreshed, report };
|
|
210
|
+
}
|
|
211
|
+
/** REFRESH one twin from its root: the pack's refresh adapter over the executor observes resources;
|
|
212
|
+
* the kernel folds them onto the root's log (company contract "Refresh is the kernel's fold"). On
|
|
213
|
+
* demand it is throttled by the root's `refresh.atMost`, else the pack's `onDemand.atMost`. */
|
|
214
|
+
export async function refreshTwin(opts) {
|
|
215
|
+
const origin = validateRemoteOrigin(opts.root.url, { allowInsecureLoopback: true });
|
|
216
|
+
const adapters = await adaptersFor(opts.vendor, opts.worldRoot);
|
|
217
|
+
// the sealed credential's keyed fingerprint, as a perform's context carries it: a pack keys its budget by it
|
|
218
|
+
const credentialFingerprint = sealedCredentialInfo(opts.worldRoot, opts.vendor)?.fingerprint;
|
|
219
|
+
if (!adapters.refresh)
|
|
220
|
+
return { refreshed: false, reason: `the ${opts.vendor} twin has no refresh adapter` };
|
|
221
|
+
const marker = join(opts.controlRoot, stateDirName(), 'world', opts.vendor, 'refresh.json');
|
|
222
|
+
const atMost = opts.root.refresh?.atMost ?? (await refreshPosture(opts.vendor, opts.worldRoot))?.onDemand?.atMost;
|
|
223
|
+
if (!opts.force && atMost) {
|
|
224
|
+
const span = spanMs(atMost);
|
|
225
|
+
const held = getActiveWorldStore().read(marker);
|
|
226
|
+
const last = held !== null ? JSON.parse(held).at : undefined;
|
|
227
|
+
if (span !== null && last && Date.now() - Date.parse(last) < span)
|
|
228
|
+
return { refreshed: false, reason: `refreshed at ${last}; the ${opts.vendor} twin refreshes at most every ${atMost} (--force to refresh now)` };
|
|
229
|
+
}
|
|
230
|
+
const credential = await openRootCredential(opts.root, opts.vendor);
|
|
231
|
+
const adapterOrigin = opts.root.scope ? `${origin.href.replace(/\/+$/, '')}/${opts.root.scope.replace(/^\/+/, '')}` : origin.href;
|
|
232
|
+
const at = new Date().toISOString();
|
|
233
|
+
const store = getActiveWorldStore();
|
|
234
|
+
let value;
|
|
235
|
+
let observations;
|
|
236
|
+
try {
|
|
237
|
+
({ value, observations } = await collectObservations(() => adapters.refresh(buildRemoteExecute(origin, credential, authStrategyFor(opts.vendor), getPack(opts.vendor)?.hosts, { custody: rootCustody(opts.root, opts.vendor) }), { root: opts.controlRoot, origin: adapterOrigin, ...(credentialFingerprint ? { credential: credentialFingerprint } : {}) })));
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
// the vendor was asked and did not answer: the marker keeps the last fold's numbers and records the
|
|
241
|
+
// failure — its reason and how many in a row — so the status door and the twin's status say so
|
|
242
|
+
const held = store.read(marker);
|
|
243
|
+
const previous = held !== null ? JSON.parse(held) : {};
|
|
244
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
245
|
+
store.write(marker, `${JSON.stringify({ ...previous, at: new Date().toISOString(), failed: { at, reason, count: (previous.failed?.count ?? 0) + 1 } })}\n`);
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
const complete = value?.complete;
|
|
249
|
+
const report = foldObservations(observations, { root: opts.controlRoot, at, ...(complete ? { complete } : {}) });
|
|
250
|
+
// the marker says when the vendor was LAST asked — the refresh's end, so `atMost` counts from there;
|
|
251
|
+
// a fold that completes clears any recorded failure
|
|
252
|
+
store.write(marker, `${JSON.stringify({ at: new Date().toISOString(), observedAt: at, ...report })}\n`);
|
|
253
|
+
return { refreshed: true, report };
|
|
254
|
+
}
|
|
255
|
+
/** DEPLOY a world: perform landed entries against each root twin's vendor, by policy. A named
|
|
256
|
+
* changeset deploys its entries (a gated root needs it verified and approved); no name deploys
|
|
257
|
+
* everything deployable — under `gated`, the entries of every ready changeset. */
|
|
258
|
+
export async function deployWorld(name, opts = {}) {
|
|
259
|
+
const worldRoot = resolve(opts.root ?? process.cwd());
|
|
260
|
+
const inst = opts.instance ?? statusWorld(name, worldRoot);
|
|
261
|
+
const out = [];
|
|
262
|
+
if (!opts.changeset) {
|
|
263
|
+
// no name: every ready changeset, by name, in order — so each outcome says which it was for
|
|
264
|
+
const ready = listWorldChangesets({ root: worldRoot, world: name }).filter((l) => changesetReadiness(l.changeset).ready && !l.changeset.actions.every((e) => { const r = rootForControlRoot(join(inst.dirs.data, e.service), e.service); return r === null; })).sort((a, b) => a.changeset.createdAt.localeCompare(b.changeset.createdAt));
|
|
265
|
+
const anyGated = Object.keys(inst.services).some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
|
|
266
|
+
if (anyGated || ready.length) {
|
|
267
|
+
for (const l of ready)
|
|
268
|
+
out.push(...(await deployWorld(name, { ...opts, changeset: l.changeset.name })).map((o) => ({ ...o, changeset: l.changeset.name })));
|
|
269
|
+
if (anyGated)
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const located = opts.changeset ? [findWorldChangeset(opts.changeset, { root: worldRoot, world: name })] : listWorldChangesets({ root: worldRoot, world: name });
|
|
274
|
+
const ready = new Map();
|
|
275
|
+
for (const l of located) {
|
|
276
|
+
const readiness = changesetReadiness(l.changeset);
|
|
277
|
+
if (!readiness.ready) {
|
|
278
|
+
const gated = [...new Set(l.changeset.actions.map((e) => e.service))].some((s) => rootForControlRoot(join(inst.dirs.data, s), s)?.deploy === 'gated');
|
|
279
|
+
if (opts.changeset && gated)
|
|
280
|
+
throw new Error(`changeset ${opts.changeset} is not ready: ${readiness.reasons.join('; ')}`);
|
|
281
|
+
if (!opts.changeset)
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
for (const e of l.changeset.actions)
|
|
285
|
+
ready.set(e.service, [...(ready.get(e.service) ?? []), e.action.id]);
|
|
286
|
+
}
|
|
287
|
+
const checks = opts.checks ?? (await loadWorldChecks(worldRoot));
|
|
288
|
+
for (const service of Object.keys(inst.services).sort()) {
|
|
289
|
+
const controlRoot = join(inst.dirs.data, service);
|
|
290
|
+
const root = rootForControlRoot(controlRoot, service);
|
|
291
|
+
if (!root)
|
|
292
|
+
continue;
|
|
293
|
+
for (const state of new Set([service, ...stateDirsOf(controlRoot)])) {
|
|
294
|
+
if (!getActiveWorldStore().exists(join(controlRoot, stateDirName(), 'world', state, 'actions.jsonl')))
|
|
295
|
+
continue;
|
|
296
|
+
const ids = root.deploy === 'gated' || opts.changeset ? ready.get(service) : undefined;
|
|
297
|
+
if ((root.deploy === 'gated' || opts.changeset) && (!ids || ids.length === 0))
|
|
298
|
+
continue;
|
|
299
|
+
out.push(await deployTwin({ worldRoot, vendor: service, controlRoot, state, root, checks, ...(ids ? { actionIds: ids } : {}) }));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// a named changeset that performed nothing says why, rather than reading as a quiet success
|
|
303
|
+
if (opts.changeset && out.length === 0) {
|
|
304
|
+
const unrooted = [...ready.keys()].filter((service) => !rootForControlRoot(join(inst.dirs.data, service), service));
|
|
305
|
+
if (unrooted.length)
|
|
306
|
+
throw new Error(`changeset ${opts.changeset} has nothing to perform: ${unrooted.join(', ')} ${unrooted.length === 1 ? 'has' : 'have'} no root on this world (\`volter twin <vendor> root <url>\`)`);
|
|
307
|
+
}
|
|
308
|
+
return out;
|
|
309
|
+
}
|
|
310
|
+
// ── deploy: auto ────────────────────────────────────────────────────────────────────────────
|
|
311
|
+
// The auto-deploy hook is GONE (2026-09-06): the kernel's head performs an `auto` write in the
|
|
312
|
+
// process that serves the twin (core head.ts), and the push door performs what it lands.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { runWorldOwned } from "./runtime.js";
|
|
2
|
+
// Only this process owns the IPC endpoint; child stdio explicitly excludes fd 3. A lost
|
|
3
|
+
// caller requests cancellation, including while startup is still settling. No service restarts.
|
|
4
|
+
if (!process.send)
|
|
5
|
+
throw new Error('World task owner requires its private lifetime channel');
|
|
6
|
+
const abort = new AbortController();
|
|
7
|
+
let started = false;
|
|
8
|
+
process.on('disconnect', () => {
|
|
9
|
+
abort.abort('SIGTERM');
|
|
10
|
+
if (!started)
|
|
11
|
+
process.exit(0);
|
|
12
|
+
});
|
|
13
|
+
// A dead caller may also close the output reader. Reporting failure must not kill cleanup.
|
|
14
|
+
process.stdout.on('error', () => { });
|
|
15
|
+
process.stderr.on('error', () => { });
|
|
16
|
+
process.on('message', async (message) => {
|
|
17
|
+
const request = message;
|
|
18
|
+
if (request.type === 'cancel') {
|
|
19
|
+
abort.abort(request.signal);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (request.type !== 'start' || started)
|
|
23
|
+
return;
|
|
24
|
+
started = true;
|
|
25
|
+
let reply;
|
|
26
|
+
try {
|
|
27
|
+
reply = { result: await runWorldOwned(request.configId, request.command, request.options, abort.signal) };
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
reply = { error: error instanceof Error ? error.message : String(error) };
|
|
31
|
+
}
|
|
32
|
+
if ('error' in reply)
|
|
33
|
+
process.stderr.write(`${reply.error}\n`);
|
|
34
|
+
if (process.connected)
|
|
35
|
+
process.send(reply, () => process.exit('error' in reply ? 1 : 0));
|
|
36
|
+
else
|
|
37
|
+
process.exit('error' in reply ? 1 : 0);
|
|
38
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { RunWorldOptions, runWorldOwned } from './runtime.js';
|
|
2
|
+
export type RunTaskRequest = {
|
|
3
|
+
type: 'start';
|
|
4
|
+
configId: string;
|
|
5
|
+
command: string[];
|
|
6
|
+
options: RunWorldOptions;
|
|
7
|
+
};
|
|
8
|
+
type RunTaskResult = Awaited<ReturnType<typeof runWorldOwned>>;
|
|
9
|
+
export type RunTaskReply = {
|
|
10
|
+
result: RunTaskResult;
|
|
11
|
+
} | {
|
|
12
|
+
error: string;
|
|
13
|
+
};
|
|
14
|
+
/** A task lifetime handle, not a service restarter. The private IPC descriptor is never passed
|
|
15
|
+
* to services or commands. EOF survives caller SIGKILL without relying on PID reuse or idle time.
|
|
16
|
+
* The task process is outside the caller's POSIX process group and owns boot through teardown. */
|
|
17
|
+
export declare function superviseWorldRun(configId: string, command: string[], options: RunWorldOptions): Promise<RunTaskResult>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { offSignal } from "./signals.js";
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { siblingScript } from "./sibling.js";
|
|
5
|
+
/** A task lifetime handle, not a service restarter. The private IPC descriptor is never passed
|
|
6
|
+
* to services or commands. EOF survives caller SIGKILL without relying on PID reuse or idle time.
|
|
7
|
+
* The task process is outside the caller's POSIX process group and owns boot through teardown. */
|
|
8
|
+
export function superviseWorldRun(configId, command, options) {
|
|
9
|
+
return new Promise((done, reject) => {
|
|
10
|
+
const child = spawn(process.execPath, [siblingScript(import.meta.url, 'run-task-worker')], {
|
|
11
|
+
cwd: process.cwd(), env: process.env, detached: process.platform !== 'win32',
|
|
12
|
+
stdio: ['inherit', 'inherit', 'inherit', 'ipc'], serialization: 'json',
|
|
13
|
+
});
|
|
14
|
+
let reply;
|
|
15
|
+
let failure;
|
|
16
|
+
const cancel = (signal) => {
|
|
17
|
+
if (child.connected)
|
|
18
|
+
child.send({ type: 'cancel', signal }, error => { if (error)
|
|
19
|
+
failure ??= error; });
|
|
20
|
+
};
|
|
21
|
+
const onInt = () => cancel('SIGINT');
|
|
22
|
+
const onTerm = () => cancel('SIGTERM');
|
|
23
|
+
process.on('SIGINT', onInt);
|
|
24
|
+
process.on('SIGTERM', onTerm);
|
|
25
|
+
child.on('error', error => { failure = error; });
|
|
26
|
+
child.on('message', message => { reply = message; });
|
|
27
|
+
child.once('spawn', () => {
|
|
28
|
+
const request = { type: 'start', configId, command, options: { ...options, root: resolve(options.root ?? process.cwd()) } };
|
|
29
|
+
child.send(request, error => { if (error) {
|
|
30
|
+
failure = error;
|
|
31
|
+
if (child.connected)
|
|
32
|
+
child.disconnect();
|
|
33
|
+
} });
|
|
34
|
+
});
|
|
35
|
+
child.once('close', (code, signal) => {
|
|
36
|
+
offSignal('SIGINT', onInt);
|
|
37
|
+
offSignal('SIGTERM', onTerm);
|
|
38
|
+
if (failure)
|
|
39
|
+
reject(failure);
|
|
40
|
+
else if (reply && 'error' in reply)
|
|
41
|
+
reject(new Error(reply.error));
|
|
42
|
+
else if (reply && code === 0)
|
|
43
|
+
done(reply.result);
|
|
44
|
+
else
|
|
45
|
+
reject(new Error(`World task owner exited without a completed teardown (${signal ?? code}); inspect World status and retained logs`));
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Test-only seam (see runtime.ts `proxyDaemonDeadlineMs`): force the daemon-poll deadline to `ms`
|
|
2
|
+
* for the duration of `fn`, then restore whatever was there before — deterministic, no sleeps. */
|
|
3
|
+
export declare function withProxyDeadline<T>(ms: number, fn: () => Promise<T> | T): Promise<T>;
|
|
4
|
+
/** Best-effort cleanup for the forced-timeout tests below: with the deadline forced to 0, the
|
|
5
|
+
* detached proxy-daemon child is still spawned and may go on to write `proxy.json` a little later
|
|
6
|
+
* (asynchronously, after our synchronous poll already gave up) — kill it so it doesn't linger as an
|
|
7
|
+
* orphan process once the test's tmp root is discarded. Never gates test pass/fail. */
|
|
8
|
+
export declare function reapLeakedProxyDaemon(root: string, name: string): Promise<void>;
|
|
9
|
+
/** A hostname under the IANA-reserved `.invalid` TLD (RFC 2606) — guaranteed to NEVER resolve, on
|
|
10
|
+
* any network, forever. Used as the "untwinned public host" below: it is unambiguously non-local
|
|
11
|
+
* (so `isLocalOrPrivateHost` never exempts it) and unambiguously not a vendor (so it is never
|
|
12
|
+
* redirected to a twin), and a real (non-strict-egress) attempt to reach it fails FAST on DNS
|
|
13
|
+
* resolution — offline and deterministic, no dependency on real internet access. */
|
|
14
|
+
export declare const UNTWINNED_HOST = "definitely-untwinned.invalid";
|
|
15
|
+
/** Drive a raw CONNECT through the ambient redirect proxy and collect whatever bytes come back
|
|
16
|
+
* before the socket closes (or `timeoutMs` elapses, as a safety net — a blind-tunnel attempt to an
|
|
17
|
+
* unresolvable host closes with NO data at all, so this never actually waits the full timeout). */
|
|
18
|
+
export declare function connectThroughProxy(port: number, host: string, targetHost: string, targetPort?: number, timeoutMs?: number): Promise<string>;
|
|
19
|
+
/** Force `opensslAvailable()` to false via its TEST-ONLY seam (see `redirect-proxy.ts`). A
|
|
20
|
+
* PATH-shim (fake `openssl` binary prepended to PATH) was tried first and does NOT reliably work
|
|
21
|
+
* here: `opensslAvailable()`'s `spawnSync('openssl', …)` call passes no explicit `env`, and this
|
|
22
|
+
* runtime resolves the executable against a PATH snapshotted at process start rather than a
|
|
23
|
+
* same-process `process.env.PATH` mutation made mid-test (verified: a fresh child process DOES
|
|
24
|
+
* see a shimmed PATH, but mutating PATH in the already-running test process does not change what
|
|
25
|
+
* THIS process's own `spawnSync` resolves) — so the deterministic, seam-based override is used. */
|
|
26
|
+
export declare function withNoOpenssl(): {
|
|
27
|
+
restore: () => void;
|
|
28
|
+
};
|
|
29
|
+
export declare const worlds: Array<{
|
|
30
|
+
root: string;
|
|
31
|
+
name: string;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Register the teardown hook for the CALLING test file. Every file that pushes to `worlds` must
|
|
35
|
+
* call this at top level.
|
|
36
|
+
*
|
|
37
|
+
* It has to be a function call rather than a module-scope `afterEach`, and that distinction was an
|
|
38
|
+
* eight-file process leak. A hook registered while this module's body runs belongs to whichever
|
|
39
|
+
* file happened to import it FIRST; module caching means the body never runs again, so every other
|
|
40
|
+
* file got no hook at all. Each file individually looked clean — it was the first importer — and
|
|
41
|
+
* only a whole-directory run leaked, which is exactly the shape that reads as flakiness. The
|
|
42
|
+
* stray proxy daemons and tunnel stubs then held ports until unrelated HTTP tests started failing.
|
|
43
|
+
*
|
|
44
|
+
* Teardown also settles ALL worlds before reporting: the list is spliced empty up front, so a bare
|
|
45
|
+
* `for … await` loop that rejected on the first world abandoned the rest with nothing left holding
|
|
46
|
+
* a reference to them. Nothing about a world that failed to stop makes the next one less important
|
|
47
|
+
* to stop.
|
|
48
|
+
*/
|
|
49
|
+
export declare function useWorldCleanup(): void;
|
|
50
|
+
export declare function writeRaceConfig(root: string, id: string): void;
|
|
51
|
+
/** Bounded wait (no fixed sleeps) for SIGTERM'd services to actually exit after downWorld. */
|
|
52
|
+
export declare function waitForWorldDown(name: string, root: string): Promise<boolean>;
|
|
53
|
+
export declare function pidAlive(pid: number): boolean;
|
|
54
|
+
export declare function writeSingleTwinConfig(root: string, id: string): void;
|
|
55
|
+
/** Twin + a second (non-twin) service — the proxy-attempt loop tries the ambient proxy again
|
|
56
|
+
* before starting each subsequent service, so this reaches the ambient proxy regardless of
|
|
57
|
+
* whether the single/last-service re-evaluation (TWIN-64) has landed yet — kept independent of
|
|
58
|
+
* that fix so this CONNECT-level test exercises TWIN-60's wiring in isolation. */
|
|
59
|
+
export declare function writeTwinPlusAppConfig(root: string, id: string): void;
|