@volter/world-runtime 2.0.31 → 2.0.32

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/src/cli.js CHANGED
@@ -111,10 +111,10 @@ function printHelp() {
111
111
  # merged, occurredAt-ordered feed of the twins' action ledgers — one
112
112
  # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
113
113
  # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
114
- # --requests: also merge the opt-in request journals — reads/404s,
114
+ # --requests: also merge the request journals — reads/404s,
115
115
  # plus WHICH credential arrived in WHICH header/query param, named +
116
- # fingerprinted, never the value — written by twins under
117
- # VOLTER_TWIN_REQUEST_JOURNAL=1)
116
+ # fingerprinted, never the value — kept by every twin and link
117
+ # unless VOLTER_TWIN_REQUEST_JOURNAL=0)
118
118
  volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
119
119
  # pull from the placeholder remote: the seed entry runs with every twin's
120
120
  # window open, so the defaults land as observed state (diff empty, nothing pending)
@@ -314,10 +314,11 @@ export declare class WorldDoors {
314
314
  origin: string;
315
315
  }>;
316
316
  private link;
317
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
318
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
317
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
318
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
319
319
  * answer (status, headers, body) is streamed back as it arrives. */
320
320
  private forward;
321
+ private forwardSealed;
321
322
  /** The instant of the twin's last completed refresh (its marker's `observedAt`), when it has a root. */
322
323
  private observedAt;
323
324
  /** The world's own doors under `/-/<org>/<world>/`. */
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
29
- import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin } from '@volter/world-core';
30
30
  import { APP_READ_ENDPOINT_ENV } from "./init.js";
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
@@ -278,7 +278,8 @@ export async function serveWorld(name, opts = {}) {
278
278
  // doors answer 503.
279
279
  let consoleOrigin = null;
280
280
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
281
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
281
+ // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
282
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
282
283
  const url = `http://${host}:${server.port}`;
283
284
  try {
284
285
  await mounted.boot(url);
@@ -850,7 +851,7 @@ export class WorldDoors {
850
851
  return foreign;
851
852
  const reads = request.method === 'GET' || request.method === 'HEAD';
852
853
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
853
- // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
854
+ // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
854
855
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
855
856
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
856
857
  const link = this.link(vendor);
@@ -934,10 +935,34 @@ export class WorldDoors {
934
935
  return held === null ? {} : JSON.parse(held);
935
936
  }
936
937
  link(name) { return this.links()[name] ?? null; }
937
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
938
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
938
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
939
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
939
940
  * answer (status, headers, body) is streamed back as it arrives. */
940
941
  async forward(request, url, rest, name, origin) {
942
+ // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
943
+ // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
944
+ const caller = new Headers(request.headers);
945
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
946
+ caller.delete(h);
947
+ // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
948
+ const authorization = caller.get('authorization');
949
+ const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
950
+ if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued())))
951
+ caller.delete('authorization');
952
+ // an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
953
+ // name, with the credential shape of what the caller sent on
954
+ const startedAt = Date.now();
955
+ let status = 500;
956
+ try {
957
+ const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
958
+ status = answer.status;
959
+ return answer;
960
+ }
961
+ finally {
962
+ journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
963
+ }
964
+ }
965
+ async forwardSealed(request, caller, url, rest, name, origin) {
941
966
  const base = validateRemoteOrigin(origin);
942
967
  // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
943
968
  let credential;
@@ -952,9 +977,7 @@ export class WorldDoors {
952
977
  const target = new URL(`${base.origin}/${rest}${url.search}`);
953
978
  if (target.origin !== base.origin)
954
979
  return Response.json({ error: 'the path left the link origin' }, { status: 400 });
955
- const headers = new Headers(request.headers);
956
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie'])
957
- headers.delete(h);
980
+ const headers = new Headers(caller);
958
981
  for (const [k, v] of Object.entries(credential.headers))
959
982
  headers.set(k, v);
960
983
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
@@ -1300,11 +1323,11 @@ export class WorldDoors {
1300
1323
  }
1301
1324
  if (kind === 'requests' && !name && request.method === 'GET') {
1302
1325
  // the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
1303
- // requests by twin and status class, the response time's p50/p95, the top routes
1326
+ // requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
1304
1327
  const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000;
1305
1328
  const since = Date.now() - sinceMs;
1306
1329
  const twins = [];
1307
- for (const vendor of Object.keys(this.up().services)) {
1330
+ for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
1308
1331
  const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
1309
1332
  const byStatus = {};
1310
1333
  const routes = new Map();
@@ -6,9 +6,9 @@ export type TailWorldOptions = {
6
6
  follow?: boolean;
7
7
  /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
8
8
  json?: boolean;
9
- /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
10
- * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
11
- * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
9
+ /** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
10
+ * twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
11
+ * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
12
12
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
13
13
  requests?: boolean;
14
14
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
package/dist/src/tail.js CHANGED
@@ -6,7 +6,7 @@
6
6
  // this just watches what the twins already record. A ledger dir may not exist until a service
7
7
  // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
8
  // notice, never an error.
9
- import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
9
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { stateDirName } from '@volter/world-core';
12
12
  import { statusWorld } from "./runtime.js";
@@ -22,6 +22,15 @@ function serviceLedgers(dataDir, service, file = 'actions.jsonl') {
22
22
  .map((entry) => join(stateRoot, entry.name, file))
23
23
  .sort();
24
24
  }
25
+ /** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
26
+ function linkNames(root) {
27
+ try {
28
+ return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ }
25
34
  /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
26
35
  * only past the last newline — a half-written trailing line stays unconsumed until the writer
27
36
  * finishes it, so a poll can never emit a torn row. */
@@ -105,9 +114,11 @@ function sleep(ms, signal) {
105
114
  export async function tailWorldActions(name, options = {}) {
106
115
  const root = options.root ?? process.cwd();
107
116
  const instance = statusWorld(name, root);
108
- const known = Object.keys(instance.services);
117
+ // a link (an opened seal) journals its requests as a twin does, under its own name
118
+ const links = linkNames(root);
119
+ const known = [...new Set([...Object.keys(instance.services), ...links])];
109
120
  for (const service of options.services ?? []) {
110
- if (!instance.services[service]) {
121
+ if (!known.includes(service)) {
111
122
  throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
112
123
  }
113
124
  }
@@ -143,6 +154,8 @@ export async function tailWorldActions(name, options = {}) {
143
154
  };
144
155
  emit(collect());
145
156
  for (const service of services) {
157
+ if (links.includes(service) && !instance.services[service])
158
+ continue; // a link records requests, never actions
146
159
  if (serviceLedgers(instance.dirs.data, service).length === 0) {
147
160
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
148
161
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/world-runtime",
3
- "version": "2.0.31",
3
+ "version": "2.0.32",
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",
@@ -67,14 +67,14 @@
67
67
  "node": ">=22.3"
68
68
  },
69
69
  "peerDependencies": {
70
- "@volter/world-core": "2.0.18",
71
- "@volter/world-console": "2.0.10"
70
+ "@volter/world-core": "2.0.19",
71
+ "@volter/world-console": "2.0.11"
72
72
  },
73
73
  "dependencies": {
74
74
  "@electric-sql/pglite": "0.5.8",
75
75
  "@electric-sql/pglite-pgvector": "0.0.9",
76
76
  "@volter/world-access": "2.0.2",
77
- "@volter/world-core": "2.0.18",
77
+ "@volter/world-core": "2.0.19",
78
78
  "pg-gateway": "0.3.0-beta.4",
79
79
  "smol-toml": "^1.8.0"
80
80
  },
@@ -84,6 +84,6 @@
84
84
  }
85
85
  },
86
86
  "optionalDependencies": {
87
- "@volter/twin-mongodb": "0.1.18"
87
+ "@volter/twin-mongodb": "0.1.19"
88
88
  }
89
89
  }
package/src/cli.ts CHANGED
@@ -112,10 +112,10 @@ function printHelp(): void {
112
112
  # merged, occurredAt-ordered feed of the twins' action ledgers — one
113
113
  # \`HH:MM:SS.mmm <service> <operation> <subjectId>\` line per action;
114
114
  # follows until Ctrl-C (--no-follow: dump and exit; --json: raw JSONL;
115
- # --requests: also merge the opt-in request journals — reads/404s,
115
+ # --requests: also merge the request journals — reads/404s,
116
116
  # plus WHICH credential arrived in WHICH header/query param, named +
117
- # fingerprinted, never the value — written by twins under
118
- # VOLTER_TWIN_REQUEST_JOURNAL=1)
117
+ # fingerprinted, never the value — kept by every twin and link
118
+ # unless VOLTER_TWIN_REQUEST_JOURNAL=0)
119
119
  volter-world seed <world> [--entry <file>] [--cwd <dir>] [--json] [--root <repo>]
120
120
  # pull from the placeholder remote: the seed entry runs with every twin's
121
121
  # window open, so the defaults land as observed state (diff empty, nothing pending)
@@ -26,7 +26,7 @@ import { getActiveWorldStore, withAncestryLock, captureHistory, historyAtInstant
26
26
  // to `.volter/token` and `.volter/token.read` (0600).
27
27
  // One writer per world by construction: the doors run in this process, serialized per twin.
28
28
  import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync , rmSync} from 'node:fs';
29
- import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
29
+ import { assertSafeChangesetName, blobDigest, destinationOf, openSealedAt, getActiveBlobStore, journalTwinRequest, loadCheck, mirrorShellUnder, readTwinRequestJournal, serveHttp, twinRequestCredentials, TWIN_PREFIX_HEADER, validateRemoteOrigin, type SealedCredential, type TwinStreamConnection, type TwinStreamSink } from '@volter/world-core';
30
30
  import { APP_READ_ENDPOINT_ENV } from './init.ts';
31
31
  import { grantOf, presentedSecrets, sameSecret, spentPasses, verifyPass, type TrustedIssuer, type WorldCredentials } from '@volter/world-access';
32
32
  import { createHash } from 'node:crypto';
@@ -252,7 +252,8 @@ export async function serveWorld(name: string, opts: { root?: string; port?: num
252
252
  // doors answer 503.
253
253
  let consoleOrigin: string | null = null;
254
254
  // a pushed blob (a release video) is one PUT: bodies up to 1 GiB, past a vendor's own media limits
255
- const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
255
+ // a front for the whole World: its twins and links keep their own journals (architecture: a multiplexing front journals nothing)
256
+ const server = await serveHttp({ hostname: host, port: opts.port ?? 0, maxRequestBodySize: 1024 * 1024 * 1024, twinRequestJournal: false, fetch: (request) => consoleRedirect(request, consoleOrigin) ?? mounted.handle(request) });
256
257
  const url = `http://${host}:${server.port}`;
257
258
  try { await mounted.boot(url); } catch (error) { await server.stop(true); throw error; }
258
259
  // the console, when it is installed beside the World, on an origin of its own (console-apart.ts)
@@ -765,7 +766,7 @@ export class WorldDoors {
765
766
  if (foreign) return foreign;
766
767
  const reads = request.method === 'GET' || request.method === 'HEAD';
767
768
  // A LINK: a path of this World that forwards to the vendor itself with the credential sealed beside
768
- // the World (the World holds the key; its callers hold the World's token). Stateless, no log.
769
+ // the World (the World holds the key; its callers hold the World's token). Journaled as a twin's request is.
769
770
  // a person's browser opening a page at the twin's place (a navigation, or the frame the console shows it in)
770
771
  const page = request.method === 'GET' && ['document', 'iframe'].includes(request.headers.get('sec-fetch-dest') ?? '');
771
772
  const link = this.link(vendor!);
@@ -835,10 +836,31 @@ export class WorldDoors {
835
836
  }
836
837
  private link(name: string): { origin: string } | null { return this.links()[name] ?? null; }
837
838
 
838
- /** Forward to the link's origin: the path under the link, the caller's headers with the World's
839
- * token removed and the sealed credential's headers replacing any of the same name. The vendor's
839
+ /** Forward to the link's origin: the path under the link, the caller's headers with every World
840
+ * credential removed and the sealed credential's headers replacing any of the same name. The vendor's
840
841
  * answer (status, headers, body) is streamed back as it arrives. */
841
842
  private async forward(request: Request, url: URL, rest: string, name: string, origin: string): Promise<Response> {
843
+ // what leaves for the vendor before the seal is applied: no World token in any header it can arrive
844
+ // under (an SDK's own `Authorization: Bearer` included), no cookie, no routing headers of this World
845
+ const caller = new Headers(request.headers);
846
+ for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) caller.delete(h);
847
+ // a secret this World minted (`tok_…`, live, revoked or expired) or one that opens it never leaves
848
+ const authorization = caller.get('authorization');
849
+ const offered = authorization ? presentedSecrets(new Headers({ authorization }), { token: TOKEN_HEADER, twinsKey: TWINS_KEY_HEADER }) : [];
850
+ if (offered.some((p) => p.startsWith('tok_')) || (offered.length && grantOf(offered, null, this.issued()))) caller.delete('authorization');
851
+ // an opened seal keeps the audit a twin keeps: the same line in the same journal under the link's
852
+ // name, with the credential shape of what the caller sent on
853
+ const startedAt = Date.now(); let status = 500;
854
+ try {
855
+ const answer = await this.forwardSealed(request, caller, url, rest, name, origin);
856
+ status = answer.status;
857
+ return answer;
858
+ } finally {
859
+ journalTwinRequest(name, { method: request.method, path: `/${rest}`, status, ms: Date.now() - startedAt, credentials: twinRequestCredentials(caller, url) }, this.controlRoot(name));
860
+ }
861
+ }
862
+
863
+ private async forwardSealed(request: Request, caller: Headers, url: URL, rest: string, name: string, origin: string): Promise<Response> {
842
864
  const base = validateRemoteOrigin(origin);
843
865
  // opened for this link's origin only: a credential sealed for another destination, or for none, refuses
844
866
  let credential: Awaited<ReturnType<typeof openSealedAt>>;
@@ -846,8 +868,7 @@ export class WorldDoors {
846
868
  if (credential === null) return Response.json({ error: `the ${name} link has no credential sealed` }, { status: 409 });
847
869
  const target = new URL(`${base.origin}/${rest}${url.search}`);
848
870
  if (target.origin !== base.origin) return Response.json({ error: 'the path left the link origin' }, { status: 400 });
849
- const headers = new Headers(request.headers);
850
- for (const h of [TOKEN_HEADER, TWINS_KEY_HEADER, TWIN_PREFIX_HEADER, 'host', 'x-forwarded-host', 'x-forwarded-proto', 'cookie']) headers.delete(h);
871
+ const headers = new Headers(caller);
851
872
  for (const [k, v] of Object.entries(credential.headers)) headers.set(k, v);
852
873
  const body = request.method === 'GET' || request.method === 'HEAD' ? undefined : await request.arrayBuffer();
853
874
  const controller = new AbortController();
@@ -1120,10 +1141,10 @@ export class WorldDoors {
1120
1141
  }
1121
1142
  if (kind === 'requests' && !name && request.method === 'GET') {
1122
1143
  // the world's request report (layer 8: Supabase's API report): the kernel's request journal per twin, summed over a period —
1123
- // requests by twin and status class, the response time's p50/p95, the top routes
1144
+ // requests by twin (and by link, journaled the same way) and status class, the response time's p50/p95, the top routes
1124
1145
  const sinceMs = Number(url.searchParams.get('hours') ?? '24') * 3_600_000; const since = Date.now() - sinceMs;
1125
1146
  const twins: Array<{ twin: string; requests: number; byStatus: Record<string, number>; p50Ms: number | null; p95Ms: number | null; topRoutes: Array<{ route: string; requests: number }> }> = [];
1126
- for (const vendor of Object.keys(this.up().services)) {
1147
+ for (const vendor of [...new Set([...Object.keys(this.up().services), ...Object.keys(this.links())])]) {
1127
1148
  const entries = readTwinRequestJournal(vendor, this.controlRoot(vendor)).filter((e) => !e.at || Date.parse(e.at) >= since);
1128
1149
  const byStatus: Record<string, number> = {}; const routes = new Map<string, number>(); const times: number[] = [];
1129
1150
  for (const e of entries) { const cls = `${String(e.status)[0]}xx`; byStatus[cls] = (byStatus[cls] ?? 0) + 1; const route = `${e.method} ${e.path.replace(/\/[0-9]+(?=\/|$)/g, '/{id}')}`; routes.set(route, (routes.get(route) ?? 0) + 1); if (typeof e.ms === 'number') times.push(e.ms); }
package/src/tail.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // this just watches what the twins already record. A ledger dir may not exist until a service
7
7
  // records its first action, so discovery re-runs on every poll and a missing ledger is a
8
8
  // notice, never an error.
9
- import { closeSync, existsSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
9
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { stateDirName } from '@volter/world-core';
12
12
  import { statusWorld } from './runtime.ts';
@@ -40,9 +40,9 @@ export type TailWorldOptions = {
40
40
  follow?: boolean;
41
41
  /** Raw JSONL passthrough with the world service id injected as `service` (default: human lines). */
42
42
  json?: boolean;
43
- /** Also merge each service's opt-in REQUEST journal into the feed (`requests.jsonl`, written
44
- * by twins running under VOLTER_TWIN_REQUEST_JOURNAL=1 — shape-only `{at, method, path,
45
- * status}` rows; control-plane/src/serve.ts). Reads are otherwise invisible here: an
43
+ /** Also merge each service's REQUEST journal into the feed (`requests.jsonl`, kept by every
44
+ * twin and link unless VOLTER_TWIN_REQUEST_JOURNAL=0 — shape-only `{at, method, path,
45
+ * status}` rows; world-core/src/serve.ts). Reads are otherwise invisible here: an
46
46
  * empty-catalog GET that 404s leaves no trace in an actions-only tail. */
47
47
  requests?: boolean;
48
48
  /** Poll interval while following (ledger appends are cross-process, so this polls rather than
@@ -68,6 +68,11 @@ function serviceLedgers(dataDir: string, service: string, file: 'actions.jsonl'
68
68
  .sort();
69
69
  }
70
70
 
71
+ /** The World's links by name (`<root>/<state-dir>/links.json`, served-world.ts). */
72
+ function linkNames(root: string): string[] {
73
+ try { return Object.keys(JSON.parse(readFileSync(join(root, stateDirName(), 'links.json'), 'utf8')) as Record<string, unknown>); } catch { return []; }
74
+ }
75
+
71
76
  /** Read the complete lines appended to `path` since the recorded offset, advancing the offset
72
77
  * only past the last newline — a half-written trailing line stays unconsumed until the writer
73
78
  * finishes it, so a poll can never emit a torn row. */
@@ -150,9 +155,11 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
150
155
  export async function tailWorldActions(name: string, options: TailWorldOptions = {}): Promise<void> {
151
156
  const root = options.root ?? process.cwd();
152
157
  const instance = statusWorld(name, root);
153
- const known = Object.keys(instance.services);
158
+ // a link (an opened seal) journals its requests as a twin does, under its own name
159
+ const links = linkNames(root);
160
+ const known = [...new Set([...Object.keys(instance.services), ...links])];
154
161
  for (const service of options.services ?? []) {
155
- if (!instance.services[service]) {
162
+ if (!known.includes(service)) {
156
163
  throw new Error(`World ${name} has no service "${service}"; it has: ${known.join(', ')}`);
157
164
  }
158
165
  }
@@ -189,6 +196,7 @@ export async function tailWorldActions(name: string, options: TailWorldOptions =
189
196
 
190
197
  emit(collect());
191
198
  for (const service of services) {
199
+ if (links.includes(service) && !instance.services[service]) continue; // a link records requests, never actions
192
200
  if (serviceLedgers(instance.dirs.data, service).length === 0) {
193
201
  notice(`World ${name}: service "${service}" has no action ledger yet${follow
194
202
  ? ' — watching; drive the app (or a vendor SDK) through the world to record actions'