@volter/twin-world 0.1.0 → 0.1.2
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/known-external-services.json +1192 -0
- package/package.json +12 -5
- package/src/app-url.ts +175 -0
- package/src/attach.ts +113 -0
- package/src/browser-proxy-cli.ts +2 -1
- package/src/changeset.ts +416 -0
- package/src/cli.ts +589 -10
- package/src/covers.ts +724 -0
- package/src/fixture-env.ts +207 -0
- package/src/host-cli.ts +2 -1
- package/src/host-worker.ts +4 -3
- package/src/host.ts +26 -4
- package/src/index.ts +97 -1
- package/src/init.ts +1142 -0
- package/src/inject-map.ts +70 -0
- package/src/managed-infra-cli.ts +208 -0
- package/src/pack-facts.ts +136 -0
- package/src/pglite-backing.ts +125 -0
- package/src/pglite-host.mjs +147 -0
- package/src/prerequisites.ts +15 -58
- package/src/project-inspect.ts +683 -0
- package/src/redirect-proxy.ts +109 -85
- package/src/reflect.ts +443 -0
- package/src/resource-holder.ts +11 -0
- package/src/resources.ts +160 -0
- package/src/runtime-test-support.ts +208 -0
- package/src/runtime.ts +667 -106
- package/src/schema.ts +67 -0
- package/src/serve.ts +102 -0
- package/src/tail.ts +203 -0
package/src/schema.ts
CHANGED
|
@@ -4,6 +4,7 @@ export type WorldMode = 'local' | 'share' | 'sealed';
|
|
|
4
4
|
/**
|
|
5
5
|
* Process model for the world's twins — a DIAL, not a binary (see src/host.ts):
|
|
6
6
|
* - 'process' (default): one OS process per service — the existing spawn path. Strongest
|
|
7
|
+
* service-to-service process separation (not a network or Machine boundary),
|
|
7
8
|
* isolation; the oracle, and the only choice for share/sealed/hosted worlds
|
|
8
9
|
* (enforced by `upWorld` — see runtime.ts, not just documented here; TWIN-67).
|
|
9
10
|
* - 'colocated': every service declaring `colocate` runs in ONE `volter-world-host` child
|
|
@@ -94,10 +95,28 @@ export type WorldShareConfig = {
|
|
|
94
95
|
* service's local URL. When set, provider defaults to 'command'. */
|
|
95
96
|
command?: string;
|
|
96
97
|
args?: string[];
|
|
98
|
+
/** Lifecycle, independent of provider type. Cloudflare Quick Tunnels are always ephemeral;
|
|
99
|
+
* custom providers state the same fact explicitly when teardown retires the URL. */
|
|
100
|
+
ephemeral?: boolean;
|
|
97
101
|
services: WorldShareServiceConfig[];
|
|
98
102
|
};
|
|
99
103
|
|
|
104
|
+
/** Peak capacity reserved for the complete World, including a foreground command run through it.
|
|
105
|
+
* Values are deliberately ordinary config data, not a resource-control DSL. */
|
|
106
|
+
export type WorldResourceRequirements = {
|
|
107
|
+
memoryMiB: number;
|
|
108
|
+
writableStorageMiB: number;
|
|
109
|
+
};
|
|
110
|
+
|
|
100
111
|
export type WorldServiceConfig = {
|
|
112
|
+
/**
|
|
113
|
+
* A free-text NOTE about this service, ignored by the runtime — JSON has no comments, and a
|
|
114
|
+
* generated config needs somewhere to say WHY it is shaped the way it is. `volter-world init`
|
|
115
|
+
* writes the wiring rationale here (which `*_TWIN_URL` the injector actually reads, or why a
|
|
116
|
+
* vendor has no endpoint env at all), so an operator reading the emitted file is not left to
|
|
117
|
+
* reverse-engineer the decision. Hand-written configs may use it freely.
|
|
118
|
+
*/
|
|
119
|
+
'//'?: string;
|
|
101
120
|
id: string;
|
|
102
121
|
type?: WorldServiceType;
|
|
103
122
|
/** Required for 'twin'/'process' services. Omitted for 'external' (the tool's `up`/`down` hold the commands). */
|
|
@@ -181,6 +200,9 @@ export type WorldServiceConfig = {
|
|
|
181
200
|
export type WorldConfig = {
|
|
182
201
|
id: string;
|
|
183
202
|
description?: string;
|
|
203
|
+
/** Peak capacity for this World. Conservative defaults are used when omitted for compatibility;
|
|
204
|
+
* resource-intensive Worlds should always declare both values explicitly. */
|
|
205
|
+
resources?: WorldResourceRequirements;
|
|
184
206
|
/** Default process model for this world's twins ('process' when omitted). `upWorld`'s
|
|
185
207
|
* `isolation` option overrides it per boot. */
|
|
186
208
|
isolation?: WorldIsolation;
|
|
@@ -250,6 +272,35 @@ export type WorldServiceInstance = {
|
|
|
250
272
|
};
|
|
251
273
|
};
|
|
252
274
|
|
|
275
|
+
/** Durable outcome of the most recent foreground consumer launched by `volter-world run`.
|
|
276
|
+
* Deliberately excludes argv and environment values: both can contain credentials. */
|
|
277
|
+
export type WorldRunOutcome = {
|
|
278
|
+
state: 'completed';
|
|
279
|
+
runnerPid: number;
|
|
280
|
+
startedAt: string;
|
|
281
|
+
finishedAt: string;
|
|
282
|
+
exitCode: number;
|
|
283
|
+
log: string;
|
|
284
|
+
signal?: NodeJS.Signals;
|
|
285
|
+
error?: string;
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
export type WorldRunRecord = WorldRunOutcome | {
|
|
289
|
+
state: 'running';
|
|
290
|
+
runnerPid: number;
|
|
291
|
+
consumerPid?: number;
|
|
292
|
+
startedAt: string;
|
|
293
|
+
log: string;
|
|
294
|
+
} | {
|
|
295
|
+
state: 'abrupt';
|
|
296
|
+
runnerPid: number;
|
|
297
|
+
consumerPid?: number;
|
|
298
|
+
startedAt: string;
|
|
299
|
+
observedAt: string;
|
|
300
|
+
log: string;
|
|
301
|
+
error: string;
|
|
302
|
+
};
|
|
303
|
+
|
|
253
304
|
export type WorldInstance = {
|
|
254
305
|
name: string;
|
|
255
306
|
config: string;
|
|
@@ -268,6 +319,8 @@ export type WorldInstance = {
|
|
|
268
319
|
pidsFile: string;
|
|
269
320
|
actors?: Record<string, unknown>;
|
|
270
321
|
fixtures?: Record<string, unknown>;
|
|
322
|
+
resources?: WorldResourceRequirements & { log: string; holderPid: number };
|
|
323
|
+
lastRun?: WorldRunRecord;
|
|
271
324
|
};
|
|
272
325
|
|
|
273
326
|
function assertStringArray(value: unknown, what: string, path: string): asserts value is string[] {
|
|
@@ -367,6 +420,17 @@ export function assertWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
367
420
|
if (config.isolation !== undefined && config.isolation !== 'process' && config.isolation !== 'colocated' && config.isolation !== 'worker') {
|
|
368
421
|
throw new Error(`World config isolation must be "process", "colocated", or "worker" in ${path}`);
|
|
369
422
|
}
|
|
423
|
+
if (config.resources !== undefined) {
|
|
424
|
+
if (!config.resources || typeof config.resources !== 'object' || Array.isArray(config.resources)) {
|
|
425
|
+
throw new Error(`World config resources must be an object in ${path}`);
|
|
426
|
+
}
|
|
427
|
+
for (const key of ['memoryMiB', 'writableStorageMiB'] as const) {
|
|
428
|
+
const amount = config.resources[key];
|
|
429
|
+
if (!Number.isInteger(amount) || amount <= 0) {
|
|
430
|
+
throw new Error(`World config resources.${key} must be a positive integer in ${path}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
370
434
|
const ids = new Set<string>();
|
|
371
435
|
for (const service of config.services as Partial<WorldServiceConfig>[]) {
|
|
372
436
|
if (!service || typeof service !== 'object') throw new Error(`Invalid service in ${path}`);
|
|
@@ -457,6 +521,9 @@ export function assertWorldConfig(value: unknown, path: string): WorldConfig {
|
|
|
457
521
|
throw new Error(`World config share.args must be a string array in ${path}`);
|
|
458
522
|
}
|
|
459
523
|
}
|
|
524
|
+
if (share.ephemeral !== undefined && typeof share.ephemeral !== 'boolean') {
|
|
525
|
+
throw new Error(`World config share.ephemeral must be a boolean in ${path}`);
|
|
526
|
+
}
|
|
460
527
|
if (!Array.isArray(share.services)) throw new Error(`World config share.services must be an array in ${path}`);
|
|
461
528
|
for (const target of share.services as Partial<WorldShareServiceConfig>[]) {
|
|
462
529
|
if (!target || typeof target !== 'object' || Array.isArray(target)) throw new Error(`Invalid share service in ${path}`);
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Remote worlds (docs/ATTACH.md): a served world publishes its manifest over
|
|
2
|
+
// HTTP and opens ONE advertised TLS door (the reflect front terminating the
|
|
3
|
+
// advertised hostname alongside vendor hosts). The manifest an attacher
|
|
4
|
+
// fetches has every vendor rewritten to the advertised origin — the injector
|
|
5
|
+
// preserves the vendor Host header, the door routes by it, the twin answers.
|
|
6
|
+
// Read-only publication: the manifest endpoint tracks no attachers and takes
|
|
7
|
+
// no writes. An optional attach token protects manifest publication.
|
|
8
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import { createServer as createHttpServer, type Server as HttpServer } from 'node:http';
|
|
10
|
+
import { deriveWorldManifest, type WorldManifest } from './attach.ts';
|
|
11
|
+
import { statusWorld } from './runtime.ts';
|
|
12
|
+
|
|
13
|
+
export const MANIFEST_PATH = '/.well-known/volter-world';
|
|
14
|
+
|
|
15
|
+
/** The manifest a REMOTE attacher sees: every vendor behind the one advertised
|
|
16
|
+
* door. The serving side keeps routing against its own loopback env. */
|
|
17
|
+
export function advertiseWorldManifest(name: string, root: string, advertisedOrigin: string): WorldManifest {
|
|
18
|
+
const local = deriveWorldManifest(name, statusWorld(name, root).env);
|
|
19
|
+
const vendors: Record<string, string> = {};
|
|
20
|
+
// /__vendor/<id> keeps the fetch flow routable through the one door (the
|
|
21
|
+
// injector's fetch patch rewrites URLs wholesale and loses the vendor Host);
|
|
22
|
+
// the http/https flow still Host-routes and ignores the origin's path.
|
|
23
|
+
for (const vendor of Object.keys(local.vendors)) vendors[vendor] = `${advertisedOrigin}/__vendor/${vendor}`;
|
|
24
|
+
return { ...local, vendors };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ManifestServerOptions {
|
|
28
|
+
manifest: () => WorldManifest;
|
|
29
|
+
host?: string;
|
|
30
|
+
port?: number;
|
|
31
|
+
/** when set, GET requires `Authorization: Bearer <token>` — the attach token
|
|
32
|
+
* for a shared world (docs/ATTACH.md). Constant-time-ish compare via length
|
|
33
|
+
* gate + timingSafeEqual to avoid trivial token oracles. */
|
|
34
|
+
token?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ManifestServerHandle {
|
|
38
|
+
port: number;
|
|
39
|
+
close(): Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startManifestServer(options: ManifestServerOptions): Promise<ManifestServerHandle> {
|
|
43
|
+
const server: HttpServer = createHttpServer((request, response) => {
|
|
44
|
+
if (options.token !== undefined) {
|
|
45
|
+
const presented = (request.headers.authorization ?? '').replace(/^Bearer\s+/i, '');
|
|
46
|
+
const expected = Buffer.from(options.token);
|
|
47
|
+
const actual = Buffer.from(presented);
|
|
48
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
49
|
+
response.writeHead(401, { 'content-type': 'text/plain' });
|
|
50
|
+
response.end('attach token required\n');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (request.method !== 'GET' || (request.url !== MANIFEST_PATH && request.url !== '/')) {
|
|
55
|
+
response.writeHead(404, { 'content-type': 'text/plain' });
|
|
56
|
+
response.end('volter-world: GET ' + MANIFEST_PATH + '\n');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
60
|
+
response.end(`${JSON.stringify(options.manifest(), null, 2)}\n`);
|
|
61
|
+
});
|
|
62
|
+
await new Promise<void>((resolveListen, reject) => {
|
|
63
|
+
server.once('error', reject);
|
|
64
|
+
server.listen(options.port ?? 0, options.host ?? '0.0.0.0', () => resolveListen());
|
|
65
|
+
});
|
|
66
|
+
const address = server.address();
|
|
67
|
+
return {
|
|
68
|
+
port: typeof address === 'object' && address ? address.port : (options.port ?? 0),
|
|
69
|
+
async close() {
|
|
70
|
+
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Fetch a remote world's manifest. Accepts the manifest URL itself or the
|
|
76
|
+
* world's base URL (the well-known path is appended). */
|
|
77
|
+
export async function fetchRemoteManifest(ref: string, options: { token?: string } = {}): Promise<WorldManifest> {
|
|
78
|
+
const base = ref.replace(/\/$/, '');
|
|
79
|
+
const url = base.endsWith(MANIFEST_PATH) ? base : `${base}${MANIFEST_PATH}`;
|
|
80
|
+
const response = await fetch(url, options.token === undefined ? undefined : { headers: { authorization: `Bearer ${options.token}` } });
|
|
81
|
+
if (!response.ok) throw new Error(`remote world manifest: ${url} → ${response.status}`);
|
|
82
|
+
const manifest = (await response.json()) as WorldManifest;
|
|
83
|
+
if (typeof manifest.name !== 'string' || typeof manifest.vendors !== 'object' || manifest.vendors === null) {
|
|
84
|
+
throw new Error(`remote world manifest: ${url} returned no manifest`);
|
|
85
|
+
}
|
|
86
|
+
return manifest;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The env a remote env-attachment exports: vendor twin URLs (the advertised
|
|
90
|
+
* door), suggested fake credentials, CA trust, and the injector preload. */
|
|
91
|
+
export function remoteAttachEnv(
|
|
92
|
+
manifest: WorldManifest,
|
|
93
|
+
options: { caFile?: string; injectPath: string },
|
|
94
|
+
): Record<string, string> {
|
|
95
|
+
const env: Record<string, string> = { ...manifest.env };
|
|
96
|
+
for (const [vendor, origin] of Object.entries(manifest.vendors)) {
|
|
97
|
+
env[`${vendor.toUpperCase()}_TWIN_URL`] = origin;
|
|
98
|
+
}
|
|
99
|
+
env.NODE_OPTIONS = `--require ${options.injectPath}`;
|
|
100
|
+
if (options.caFile !== undefined) env.NODE_EXTRA_CA_CERTS = options.caFile;
|
|
101
|
+
return env;
|
|
102
|
+
}
|
package/src/tail.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// `volter-world tail` — the live observability verb: one merged, causally-ordered (by
|
|
2
|
+
// `occurredAt`) feed of a world's twin action ledgers. Strictly READ-ONLY over data at rest
|
|
3
|
+
// (the per-service `actions.jsonl` files the control plane appends to — see
|
|
4
|
+
// control-plane/src/actions.ts, plus — under `--requests` — the opt-in
|
|
5
|
+
// `requests.jsonl` request journals serve.ts writes, shape + credential shape); the runtime stays a lifecycle primitive,
|
|
6
|
+
// this just watches what the twins already record. A ledger dir may not exist until a service
|
|
7
|
+
// records its first action, so discovery re-runs on every poll and a missing ledger is a
|
|
8
|
+
// notice, never an error.
|
|
9
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { stateDirName } from '@volter/twin';
|
|
12
|
+
import { statusWorld } from './runtime.ts';
|
|
13
|
+
|
|
14
|
+
/** The slice of a ledger row `tail` renders. Rows are vendor-recorded TwinActions
|
|
15
|
+
* (control-plane/src/actions.ts); everything else passes through untouched under `--json`. */
|
|
16
|
+
type TailRecord = Record<string, unknown> & {
|
|
17
|
+
occurredAt?: string;
|
|
18
|
+
/** request-journal rows (requests.jsonl) carry `at` instead of `occurredAt`. */
|
|
19
|
+
at?: string;
|
|
20
|
+
op?: string;
|
|
21
|
+
operation?: string;
|
|
22
|
+
method?: string;
|
|
23
|
+
path?: string;
|
|
24
|
+
status?: number;
|
|
25
|
+
/** credential SHAPE on a request row: which header/query name carried a credential, and a
|
|
26
|
+
* truncated sha256 of the value. Never the value — see control-plane/src/serve.ts. */
|
|
27
|
+
credentials?: Array<{ name?: string; in?: string; scheme?: string; fp?: string; empty?: true }>;
|
|
28
|
+
subject?: { type?: string; id?: string };
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type TailEntry = { service: string; record: TailRecord; source: 'actions' | 'requests' };
|
|
32
|
+
|
|
33
|
+
export type TailWorldOptions = {
|
|
34
|
+
root?: string;
|
|
35
|
+
/** Restrict the feed to these world service ids (default: every service in the world). */
|
|
36
|
+
services?: string[];
|
|
37
|
+
/** Keep watching for new entries after the initial dump (default true; `--no-follow` clears it). */
|
|
38
|
+
follow?: boolean;
|
|
39
|
+
/** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
|
|
40
|
+
json?: boolean;
|
|
41
|
+
/** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
|
|
42
|
+
* by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
|
|
43
|
+
* status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
|
|
44
|
+
* empty-catalog GET that 404s leaves no trace in an actions-only tail. */
|
|
45
|
+
requests?: boolean;
|
|
46
|
+
/** Poll interval while following (ledger appends are cross-process, so this polls rather than
|
|
47
|
+
* fs.watch — watch descriptors can't see a file that doesn't exist yet). */
|
|
48
|
+
pollMs?: number;
|
|
49
|
+
/** Entry sink — one already-formatted line per call (default: process.stdout). */
|
|
50
|
+
write?: (line: string) => void;
|
|
51
|
+
/** Diagnostics sink for the one-time "no ledger yet" notices (default: process.stderr). */
|
|
52
|
+
notice?: (line: string) => void;
|
|
53
|
+
/** Aborting stops a following tail (the CLI wires Ctrl-C here). */
|
|
54
|
+
signal?: AbortSignal;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Every action ledger a world service currently owns. One world service can record actions
|
|
58
|
+
* under one or more STATE services (see runtime.ts `worldServiceStateService`), so this returns
|
|
59
|
+
* every `<data>/<service>/<state-dir>/world/<state-service>/actions.jsonl` present right now. */
|
|
60
|
+
function serviceLedgers(dataDir: string, service: string, file: 'actions.jsonl' | 'requests.jsonl' = 'actions.jsonl'): string[] {
|
|
61
|
+
const stateRoot = join(dataDir, service, stateDirName(), 'world');
|
|
62
|
+
if (!existsSync(stateRoot)) return [];
|
|
63
|
+
return readdirSync(stateRoot, { withFileTypes: true })
|
|
64
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(stateRoot, entry.name, file)))
|
|
65
|
+
.map((entry) => join(stateRoot, entry.name, file))
|
|
66
|
+
.sort();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read the complete lines appended to `path` since the recorded offset, advancing the offset
|
|
70
|
+
* only past the last newline — a half-written trailing line stays unconsumed until the writer
|
|
71
|
+
* finishes it, so a poll can never emit a torn row. */
|
|
72
|
+
function readNewLines(path: string, offsets: Map<string, number>): string[] {
|
|
73
|
+
let offset = offsets.get(path) ?? 0;
|
|
74
|
+
let size: number;
|
|
75
|
+
try {
|
|
76
|
+
size = statSync(path).size;
|
|
77
|
+
} catch {
|
|
78
|
+
return []; // vanished between discovery and read (e.g. a purge mid-tail) — next poll rediscovers
|
|
79
|
+
}
|
|
80
|
+
if (size < offset) offset = 0; // rewritten shorter (scrub/rebuild) — replay rather than silently skip
|
|
81
|
+
if (size === offset) return [];
|
|
82
|
+
const fd = openSync(path, 'r');
|
|
83
|
+
const buffer = Buffer.alloc(size - offset);
|
|
84
|
+
try {
|
|
85
|
+
readSync(fd, buffer, 0, buffer.length, offset);
|
|
86
|
+
} finally {
|
|
87
|
+
closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
const lastNewline = buffer.lastIndexOf(0x0a);
|
|
90
|
+
if (lastNewline < 0) return [];
|
|
91
|
+
offsets.set(path, offset + lastNewline + 1);
|
|
92
|
+
return buffer.subarray(0, lastNewline).toString('utf8').split('\n').filter((line) => line.trim() !== '');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Local wall-clock `HH:MM:SS.mmm` — the operator is watching a live feed on this machine. */
|
|
96
|
+
function formatOccurredAt(occurredAt: string | undefined): string {
|
|
97
|
+
const date = occurredAt === undefined ? new Date(NaN) : new Date(occurredAt);
|
|
98
|
+
if (Number.isNaN(date.getTime())) return '??:??:??.???';
|
|
99
|
+
const pad = (value: number, width = 2) => String(value).padStart(width, '0');
|
|
100
|
+
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The instant an entry sorts (and renders) by: actions carry `occurredAt`, request rows `at`. */
|
|
104
|
+
function entryInstant(entry: TailEntry): string | undefined {
|
|
105
|
+
return entry.record.occurredAt ?? entry.record.at;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatEntry(entry: TailEntry, json: boolean): string {
|
|
109
|
+
if (json) return JSON.stringify({ ...entry.record, service: entry.service });
|
|
110
|
+
const { record } = entry;
|
|
111
|
+
if (entry.source === 'requests') {
|
|
112
|
+
// a served request, not an action: `HH:MM:SS.mmm <service> <METHOD> <path> <status>`, and —
|
|
113
|
+
// when the caller presented one — which credential arrived, named and fingerprinted. The
|
|
114
|
+
// fingerprint is short on purpose: enough to see "these two headers carried DIFFERENT keys"
|
|
115
|
+
// and "this is the same key as the line above" at a glance, never enough to be a secret.
|
|
116
|
+
const creds = (record.credentials ?? [])
|
|
117
|
+
.map((c) => `${c.name ?? '?'}${c.in === 'query' ? '(query)' : ''}=${c.empty ? 'EMPTY' : `${c.scheme ? `${c.scheme} ` : ''}${(c.fp ?? '').replace(/^sha256:/, '').slice(0, 8)}`}`)
|
|
118
|
+
.join(' ');
|
|
119
|
+
return `${formatOccurredAt(entryInstant(entry))} ${entry.service} ${record.method ?? '?'} ${record.path ?? '?'} ${record.status ?? '?'}${creds ? ` [${creds}]` : ''}`;
|
|
120
|
+
}
|
|
121
|
+
return `${formatOccurredAt(record.occurredAt)} ${entry.service} ${record.operation ?? record.op ?? '?'} ${record.subject?.id ?? '?'}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Stable causal order: the entry instant first; ties keep discovery order (service, file, line). */
|
|
125
|
+
function byOccurredAt(a: TailEntry, b: TailEntry): number {
|
|
126
|
+
return (entryInstant(a) ?? '') < (entryInstant(b) ?? '') ? -1
|
|
127
|
+
: (entryInstant(a) ?? '') > (entryInstant(b) ?? '') ? 1 : 0;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const finish = (): void => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
signal?.removeEventListener('abort', finish);
|
|
135
|
+
resolve();
|
|
136
|
+
};
|
|
137
|
+
const timer = setTimeout(finish, ms);
|
|
138
|
+
signal?.addEventListener('abort', finish, { once: true });
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stream a world's action ledgers as one merged feed: dump everything recorded so far in
|
|
144
|
+
* `occurredAt` order, then (unless `follow` is false) poll for appends until `signal` aborts.
|
|
145
|
+
* New entries within each poll batch are `occurredAt`-sorted too, so concurrent services still
|
|
146
|
+
* read causally; cross-batch order is arrival order, as in any live tail.
|
|
147
|
+
*/
|
|
148
|
+
export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
|
|
149
|
+
const root = options.root ?? process.cwd();
|
|
150
|
+
const instance = statusWorld(name, root);
|
|
151
|
+
const known = Object.keys(instance.services);
|
|
152
|
+
for (const service of options.services ?? []) {
|
|
153
|
+
if (!instance.services[service]) {
|
|
154
|
+
throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const services = options.services?.length ? options.services : known;
|
|
158
|
+
const follow = options.follow ?? true;
|
|
159
|
+
const json = options.json ?? false;
|
|
160
|
+
const pollMs = options.pollMs ?? 200;
|
|
161
|
+
const write = options.write ?? ((line: string) => process.stdout.write(`${line}\n`));
|
|
162
|
+
const notice = options.notice ?? ((line: string) => process.stderr.write(`${line}\n`));
|
|
163
|
+
|
|
164
|
+
const offsets = new Map<string, number>();
|
|
165
|
+
const sources: Array<'actions' | 'requests'> = options.requests ? ['actions', 'requests'] : ['actions'];
|
|
166
|
+
const collect = (): TailEntry[] => {
|
|
167
|
+
const entries: TailEntry[] = [];
|
|
168
|
+
for (const service of services) {
|
|
169
|
+
for (const source of sources) {
|
|
170
|
+
for (const ledger of serviceLedgers(instance.dirs.data, service, source === 'actions' ? 'actions.jsonl' : 'requests.jsonl')) {
|
|
171
|
+
for (const line of readNewLines(ledger, offsets)) {
|
|
172
|
+
try {
|
|
173
|
+
entries.push({ service, record: JSON.parse(line) as TailRecord, source });
|
|
174
|
+
} catch {
|
|
175
|
+
// a corrupt row must not kill a live feed; the ledger itself is untouched
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return entries.sort(byOccurredAt);
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const emit = (entries: TailEntry[]): void => {
|
|
185
|
+
for (const entry of entries) write(formatEntry(entry, json));
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
emit(collect());
|
|
189
|
+
for (const service of services) {
|
|
190
|
+
if (serviceLedgers(instance.dirs.data, service).length === 0) {
|
|
191
|
+
notice(`World ${name}: service "${service}" has no action ledger yet${follow
|
|
192
|
+
? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
|
|
193
|
+
: '; drive the app (or a vendor SDK) through the world to record actions, then tail again'}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!follow) return;
|
|
197
|
+
|
|
198
|
+
while (!options.signal?.aborted) {
|
|
199
|
+
await sleep(pollMs, options.signal);
|
|
200
|
+
if (options.signal?.aborted) return;
|
|
201
|
+
emit(collect());
|
|
202
|
+
}
|
|
203
|
+
}
|