@feasibleone/blong-gogo 1.33.0 → 1.35.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/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.35.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.34.0...blong-gogo-v1.35.0) (2026-09-21)
4
+
5
+
6
+ ### Features
7
+
8
+ * semantic log integration ([b1c0d4a](https://github.com/feasibleone/blong/commit/b1c0d4ab32fb54cda54d20deeaf9162b7c4a3db4))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * build ([18ea6c6](https://github.com/feasibleone/blong/commit/18ea6c6c737e70ebb8715eba8bd19c7f459c2c41))
14
+ * decouple sequence diagrams from services ([9bc672a](https://github.com/feasibleone/blong/commit/9bc672a184ed07582452ede3a556aaf3ed0726d9))
15
+ * load ([492a0cf](https://github.com/feasibleone/blong/commit/492a0cf317e57cfea618337c8c2c572c220d8b30))
16
+ * MermaidRenderer loop ([afd21eb](https://github.com/feasibleone/blong/commit/afd21ebe87aa3e90c14a2b676414c55a38415744))
17
+ * sequence diagram leg capturing logic ([65f51cf](https://github.com/feasibleone/blong/commit/65f51cf98f4691caead56d9e4d22200fe40cc5bf))
18
+ * test failures ([6aad7e4](https://github.com/feasibleone/blong/commit/6aad7e444238b7ad9801de4017eaedadb55f16cc))
19
+
20
+ ## [1.34.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.33.0...blong-gogo-v1.34.0) (2026-09-16)
21
+
22
+
23
+ ### Features
24
+
25
+ * ACL ([0278431](https://github.com/feasibleone/blong/commit/027843153849ece048be0e61de51594f917a6277))
26
+
3
27
  ## [1.33.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.32.0...blong-gogo-v1.33.0) (2026-09-15)
4
28
 
5
29
 
package/bin/blong.ts CHANGED
@@ -55,6 +55,19 @@ if (isRealmCreate) {
55
55
  process.chdir(resolve(process.cwd(), realmName));
56
56
  }
57
57
 
58
+ // `blong grant <capability> [--ttl=15m] [--key=<jwk|ENV_VAR>]` — mint a
59
+ // short-lived capability token (see `src/grant.ts`). Like `blong realm`, this
60
+ // stays within the intent model rather than becoming a subcommand tree: `grant`
61
+ // is only a command when it is the first positional and no positional names an
62
+ // existing path (a suite is never run by a `grant` argument in practice, and
63
+ // `existsSync` is the same guard the realm-create branch uses).
64
+ const isGrantCommand = positionals[0] === 'grant' && !positionals.some(p => existsSync(resolve(p)));
65
+ if (isGrantCommand) {
66
+ const {runGrantCommand} = await import('../src/grant.ts');
67
+ await runGrantCommand(positionals, rawArgv);
68
+ process.exit(0);
69
+ }
70
+
58
71
  const parsed = minimist(process.argv.slice(2)) as {_: string[]};
59
72
  // The first positional arg is an optional file/folder target; the rest are intents.
60
73
  const [maybeTarget, ...rest] = parsed._;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -38,6 +38,7 @@
38
38
  "@feasibleone/blong-lib": "^1.0.0",
39
39
  "@feasibleone/blong-mle": "^1.0.0",
40
40
  "@feasibleone/blong-mock": "^1.0.0",
41
+ "@feasibleone/semantic-log": "^1.0.0",
41
42
  "@keycloak/keycloak-admin-client": "^26.6.2",
42
43
  "@kubernetes/client-node": "^1.4.0",
43
44
  "@octokit/rest": "^22.0.1",
@@ -90,7 +91,7 @@
90
91
  "tap": "^21.6.3",
91
92
  "typescript": "^6.0.3",
92
93
  "vite": "^8.2.1",
93
- "@feasibleone/blong-dev": "1.4.0"
94
+ "@feasibleone/blong-dev": "1.5.0"
94
95
  },
95
96
  "scripts": {
96
97
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -98,6 +99,7 @@
98
99
  "ci-coverage": "./run-coverage.sh",
99
100
  "ci-lint": "blong-dev lint",
100
101
  "ci-publish": "node ../../common/scripts/install-run-rush-pnpm.js publish --access public --provenance",
101
- "ci-test": "blong-dev test"
102
+ "ci-test": "blong-dev test",
103
+ "test": "blong-dev test"
102
104
  }
103
105
  }
@@ -13,8 +13,9 @@ import type net from 'node:net';
13
13
  import PQueue from 'p-queue';
14
14
 
15
15
  import ConfigRuntime from './ConfigRuntime.ts';
16
- import {isExpectedError} from './lib.ts';
16
+ import {isExpectedError, portLogCalls} from './lib.ts';
17
17
  import loop from './loop.ts';
18
+ import {adoptInbound, inboundIdentities, isFlowId} from './semanticContext.ts';
18
19
 
19
20
  const errorMap: IErrorMap = {
20
21
  'adapter.configValidation': 'Adapter config validation:\r\n{message}',
@@ -290,6 +291,26 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
290
291
 
291
292
  async drain(): Promise<void> {}
292
293
 
294
+ /**
295
+ * Every handler registered for a method, in load order.
296
+ *
297
+ * `findHandler` answers with the one function the proxy will call: the newest
298
+ * group shadows the earlier ones, which is deliberate — it is how a handler
299
+ * reaches its super. The shadowed implementations stay in `importedMap`, and
300
+ * this walks them. It exists for an entry point whose answer is a *composition*
301
+ * of what every provider says rather than a choice between them (the portal
302
+ * configuration is the first), where shadowing is the wrong default.
303
+ */
304
+ findHandlers(methodName: string): unknown[] {
305
+ const method = this.methodPath(this._methodId(methodName));
306
+ const handlers: unknown[] = [];
307
+ this.importedMap?.forEach(imported => {
308
+ if (Object.prototype.hasOwnProperty.call(imported, method))
309
+ handlers.push(imported[method]);
310
+ });
311
+ return handlers;
312
+ }
313
+
293
314
  findHandler(methodName: string): unknown {
294
315
  methodName = this.methodPath(this._methodId(methodName));
295
316
  return this.imported[methodName];
@@ -350,7 +371,38 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
350
371
  const method = ($meta && $meta.method) || 'exec';
351
372
  const handler = this.findHandler(method) || this.imported['exec'];
352
373
  if (handler instanceof Function) {
353
- return handler.apply(this, params);
374
+ const run = () => handler.apply(this, params);
375
+ // A call the caller declared is answered by writing one record here, inside the
376
+ // leg that arrived: that is what turns the caller's declaration into a receipt
377
+ // the ledger can match, and it happens only when a flow was traced at all, so an
378
+ // untraced request still pays nothing.
379
+ const answered = (): unknown => {
380
+ const inbound = inboundIdentities($meta as IMeta);
381
+ if (isFlowId(inbound.flow) && inbound.leg !== undefined) {
382
+ // The receipt is the receiver's record for the leg it was handed. It carries
383
+ // no declared target - the caller named the ends - so the ledger can tell a
384
+ // receipt from a declaration by the leg alone, in one process or many.
385
+ portLogCalls(this)?.received(inbound.leg);
386
+ }
387
+ return run();
388
+ };
389
+ // The callee adopts the identity its caller declared, so the records its
390
+ // handler writes belong to the caller's execution and its own calls are
391
+ // declared against the same one. Only adopt, never mint: the two places
392
+ // that mint a flow are the external request and the cross-process call,
393
+ // and a port that invented one would fill the ledger with runs nobody
394
+ // asked for.
395
+ //
396
+ // Measured (T-115): the identity *is* on `$meta` here — the gateway route
397
+ // publishes it before it makes the local call — while the callee's handler
398
+ // otherwise runs in whatever context its transport scheduled it in, where
399
+ // `declareCall` finds no flow. What this does *not* do is make a quiet call
400
+ // visible: a recorded call is a record written inside it, and the ports that
401
+ // make calls in a served request log nothing at that moment. The leg is
402
+ // bound correctly; whether it can be seen depends on the callee logging.
403
+ return isFlowId(inboundIdentities($meta as IMeta).flow)
404
+ ? adoptInbound($meta as IMeta, method, answered)
405
+ : run();
354
406
  } else {
355
407
  throw this.errors['adapter.methodNotFound']({params: {method}});
356
408
  }
package/src/BrowserLog.ts CHANGED
@@ -1,5 +1,5 @@
1
- import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
2
1
  import {withProgress} from '@feasibleone/blong-lib';
2
+ import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
3
3
  import type {Level, Logger as PinoLogger} from 'pino';
4
4
 
5
5
  // ── level constants (bunyan numeric levels) ──────────────────────────────────
@@ -160,6 +160,17 @@ function createLogger(
160
160
  };
161
161
  }
162
162
 
163
+ /**
164
+ * The browser's console logger, and the reason it has no call channel.
165
+ *
166
+ * A capability is carried in semantic-log's ambient scope, which is
167
+ * `AsyncLocalStorage` — Node-only and deliberately kept out of the page — and a
168
+ * page has no retention store and no cluster sink either, so a call record it
169
+ * could write would exist only to be printed among the messages a developer is
170
+ * already reading. `ILog.calls` is therefore absent here, and the framework's
171
+ * call sites fall silent: the same answer the server gives for a flow that opted
172
+ * out, for a better reason.
173
+ */
163
174
  export default class BrowserLog extends Internal implements ILog {
164
175
  #config: IBrowserLogConfig = {level: 'info', logByLevel: false};
165
176
  #logger: ISimpleLogger;
@@ -184,7 +195,10 @@ export default class BrowserLog extends Internal implements ILog {
184
195
  ) as unknown as PinoLogger<T>;
185
196
  }
186
197
 
187
- public logger(level: Level = this.#config.level ?? 'info', bindings: object): ILogger {
198
+ public logger(
199
+ level: Level | 'silent' = this.#config.level ?? 'info',
200
+ bindings: object,
201
+ ): ILogger {
188
202
  const child = this.#logger.child(bindings as Record<string, unknown>, {level});
189
203
  const result: ILogger = {
190
204
  trace: undefined,
package/src/Gateway.ts CHANGED
@@ -2,6 +2,7 @@ import type {
2
2
  ApiSchema,
3
3
  Errors,
4
4
  GatewaySchema,
5
+ ICallLog,
5
6
  IErrorFactory,
6
7
  IErrorMap,
7
8
  IGateway,
@@ -20,12 +21,24 @@ import {Type, type TSchema} from 'typebox';
20
21
  import Value from 'typebox/value';
21
22
  import {v4} from 'uuid';
22
23
 
24
+ import {TRACE_HEADER, withoutCapabilities} from '@feasibleone/semantic-log/capability';
25
+ import {CALLS_CAPABILITY, GRANT_HEADER} from './callTrace.ts';
26
+ import {grantedCalls, grantKeyFrom} from './grant.ts';
27
+ import {
28
+ callsFor,
29
+ declareCall,
30
+ enterCapability,
31
+ enterRequestFlow,
32
+ runInFlow,
33
+ } from './semanticContext.ts';
34
+
23
35
  import {type TypeBoxTypeProvider} from '@fastify/type-provider-typebox';
24
36
  import type {IResolution} from './Resolution.ts';
25
37
  import type {IRpcClient} from './RpcClient.ts';
26
38
  import jwt from './jwt.ts';
27
39
  import {isExpectedError, methodId, methodParts, snakeToCamel} from './lib.ts';
28
40
  import type {IConfig as IConfigMLE} from './mle.ts';
41
+ import isPublic from './public.ts';
29
42
  import swagger from './swagger.ts';
30
43
 
31
44
  const osName: string = [os.type(), os.platform(), os.release()].join(':');
@@ -169,6 +182,8 @@ export default class Gateway extends Internal implements IGateway {
169
182
  #resolution: IResolution;
170
183
  #log: ILog;
171
184
  #logger: ReturnType<ILog['child']> | undefined;
185
+ /** The gateway's own call channel: the framework's record of the calls it makes. */
186
+ #calls: ICallLog | undefined;
172
187
  #config: IConfig = {
173
188
  host: '0.0.0.0',
174
189
  port: 8080,
@@ -285,6 +300,13 @@ export default class Gateway extends Internal implements IGateway {
285
300
  return (
286
301
  [
287
302
  ['x-request-id'],
303
+ // The framework's own identity header (see `semanticContext.ts`). It
304
+ // travels in the body as well, but a request that never reached the
305
+ // route handler — the one the hooks rejected — is only readable from
306
+ // here, and this is what lets it keep the flow it was given. The
307
+ // capabilities decided for the flow travel inside it, in its `cap`
308
+ // field, which the earliest hook has already rewritten.
309
+ ['x-semantic-trace'],
288
310
  ['x-b3-traceid', () => v4().replace(/-/g, '')],
289
311
  ['x-b3-spanid'],
290
312
  ['x-b3-parentspanid'],
@@ -531,13 +553,23 @@ export default class Gateway extends Internal implements IGateway {
531
553
  if (!('result' in value)) {
532
554
  const req = this.#local.get(reqName);
533
555
  if (!req) return notfound();
534
- const [result, resultMeta] = (await req.method(params, meta)) ?? null;
556
+ // The outermost entry mints the flow: everything this
557
+ // request does downstream joins it, and the method it
558
+ // was addressed to is the flow's kind.
559
+ const [result, resultMeta] =
560
+ (await runInFlow(meta as IMeta, methodName, () =>
561
+ req.method(params, meta),
562
+ )) ?? null;
535
563
  this._applyMeta(reply, resultMeta as {httpResponse?: unknown});
536
564
  return result;
537
565
  } else if (id == null) {
538
566
  const pub = this.#local.get(pubName);
539
567
  if (!pub) return notfound();
540
- pub.method(params, meta).catch(() => {});
568
+ // A notification is an entry of its own, so it starts a
569
+ // flow rather than joining one nobody sent.
570
+ runInFlow(meta as IMeta, methodName, () =>
571
+ pub.method(params, meta),
572
+ ).catch(() => {});
541
573
  return {
542
574
  jsonrpc: '2.0',
543
575
  result: true,
@@ -545,7 +577,67 @@ export default class Gateway extends Internal implements IGateway {
545
577
  } else {
546
578
  const req = this.#local.get(reqName);
547
579
  if (!req) return notfound();
548
- const [result, resultMeta] = (await req.method(params, meta)) ?? null;
580
+ // The same entry as the branch above, and it is the ordinary
581
+ // request/response path — which is why it matters most: the
582
+ // `onRequest` hook enters the flow with `enterWith`, and that
583
+ // does not reach a handler the router schedules from its own
584
+ // async resource, so for a while only the *wildcard* branch
585
+ // ran inside a flow. Every call the handler made was then
586
+ // declared in no flow at all, which is why no served request
587
+ // ever produced a leg while tap runs did. `runInFlow` adopts
588
+ // the identity the hook published when the meta carries one,
589
+ // so this does not mint a second flow for the same request.
590
+ const [result, resultMeta] =
591
+ (await runInFlow(meta as IMeta, methodName, () =>
592
+ // Declare the hop this route is making. The caller is
593
+ // **`public`** - the surface that answered the request,
594
+ // named apart from the namespace it calls. The gateway realm
595
+ // is a namespace called `gateway`, so a leg whose caller was
596
+ // also called `gateway` drew the public surface and that
597
+ // namespace as one participant, and a realm answering a call
598
+ // to its own namespace read as a self-call. A leg id names a
599
+ // unit, and the API surface is a unit of its own - not the
600
+ // process either: one process hosts a whole suite, so naming
601
+ // the process here would draw every flow of a monolith as the
602
+ // same participant.
603
+ // `methodName` is already the dotted wire name the leg
604
+ // grammar wants, so the leg names the method the request
605
+ // asked for. The callee's receipt then carries the same leg,
606
+ // which is what makes the drawing a call rather than a
607
+ // participation.
608
+ declareCall(
609
+ 'public',
610
+ methodName,
611
+ () => {
612
+ // The caller's own records, written inside the leg it
613
+ // just declared: the callee answers with a receipt for
614
+ // the same leg, and a call is finished when it answers -
615
+ // so the start, the end and the failure all belong here.
616
+ // They go through the call channel rather than the level
617
+ // logger: a call is either wanted for the whole flow or
618
+ // it is not, and a request that records nothing pays for
619
+ // no record at all.
620
+ const calls = this.#calls;
621
+ calls?.start(methodName);
622
+ const answered = req.method(params, meta);
623
+ if (answered instanceof Promise) {
624
+ return Promise.resolve(answered).then(
625
+ value => {
626
+ calls?.end(methodName);
627
+ return value;
628
+ },
629
+ error => {
630
+ calls?.error(methodName, error);
631
+ throw error;
632
+ },
633
+ );
634
+ }
635
+ calls?.end(methodName);
636
+ return answered;
637
+ },
638
+ meta as IMeta,
639
+ ),
640
+ )) ?? null;
549
641
  this._applyMeta(reply, resultMeta as {httpResponse?: unknown});
550
642
  return {
551
643
  jsonrpc: '2.0',
@@ -602,10 +694,21 @@ export default class Gateway extends Internal implements IGateway {
602
694
  public async start(): Promise<IGateway> {
603
695
  const old = this.#server;
604
696
  this.#logger = this.#log.child({name: 'gateway'}, {level: this.#config.logLevel});
697
+ // The call channel of *this* component, so its records carry the component the
698
+ // way its level records do (`logger()` binds the name). Taken once, next to
699
+ // the logger it belongs to.
700
+ this.#calls = this.#log.logger(this.#config.logLevel ?? 'info', {name: 'gateway'}).calls;
605
701
  try {
606
702
  this.#server = fastify({
607
703
  loggerInstance: this.#logger,
608
704
  forceCloseConnections: true,
705
+ // fastify's own request logging is off, and the framework's records
706
+ // replace it: it writes the whole request at `info` — headers, the
707
+ // `Authorization` bearer among them — and a completion line, which is
708
+ // both more than a reader needs and less than the flow records say.
709
+ // Nothing else depends on those two lines: the request has a flow, the
710
+ // flow has its calls, and both are recorded where they belong.
711
+ disableRequestLogging: true,
609
712
  ajv: {
610
713
  customOptions: {
611
714
  allErrors: true,
@@ -646,6 +749,68 @@ export default class Gateway extends Internal implements IGateway {
646
749
  });
647
750
  },
648
751
  );
752
+ // The entry of a request is its earliest hook, not its route handler: the
753
+ // authentication and metering hooks run in between, and a call they reject
754
+ // has no route to be wrapped by, so its records would name no flow at all
755
+ // (T-105). The identity is published back onto the request headers, which is
756
+ // where the route handler reads it from when it adopts this same flow.
757
+ //
758
+ // Declared with the three-argument callback shape on purpose. Fastify picks the
759
+ // hook's style from its arity, and a plain function of one argument is awaited
760
+ // nowhere: every request then stopped in this hook, which looked for a day like a
761
+ // slow database rather than a signature - the MLE readiness probe timed out at
762
+ // startup and every realm's tap run failed with a different symptom each time.
763
+ //
764
+ // The metadata endpoints and the static assets are not calls: they are the
765
+ // framework's own plumbing (the MLE key endpoint, the API documentation, what a
766
+ // browser fetches), and minting an execution per probe filled the observed kinds
767
+ // with names like `login..well-known.mle` - a deployment's health checks read as
768
+ // traffic in an observation of its business.
769
+ this.#server.addHook(
770
+ 'onRequest',
771
+ async (request: FastifyRequest, _reply: FastifyReply): Promise<void> => {
772
+ const pathname = new URL(request.url, 'http://localhost').pathname;
773
+ if (isPublic(pathname) && pathname.includes('/.well-known/')) {
774
+ return;
775
+ }
776
+ // Only what the earliest hook can see: the route handler knows the
777
+ // real method and re-enters this flow with it, so a path that does
778
+ // not name one is left unlabelled rather than guessed at.
779
+ const kind = pathname.startsWith('/rpc/')
780
+ ? pathname.slice(5).split('/').filter(Boolean).join('.')
781
+ : '';
782
+ // A capability decides what this process may cost itself, so an
783
+ // outside caller does not get to set one: the field is stripped
784
+ // from the identity it arrived with, and the decision is made here
785
+ // and published in its place. The rest of the identity is adopted
786
+ // exactly as it arrived.
787
+ const identity = withoutCapabilities(
788
+ request.headers[TRACE_HEADER] as string | undefined,
789
+ );
790
+ if (identity.length > 0) {
791
+ request.headers[TRACE_HEADER] = identity;
792
+ } else {
793
+ delete request.headers[TRACE_HEADER];
794
+ }
795
+ // The grant, if one arrived, is verified here and now — once per
796
+ // request, at the earliest point there is — and what travels on is
797
+ // the capability it decided, not the token.
798
+ const grantedCallsToRecord = await grantedCalls(
799
+ request.headers[GRANT_HEADER] as string | undefined,
800
+ grantKeyFrom(this.#config),
801
+ );
802
+ // A grant wins over the configuration; with none, the configuration
803
+ // answers and the answer is published for the process that serves
804
+ // the call. Either way the flow is decided here, before the hooks
805
+ // that may reject the request: a flow the entry point refused to
806
+ // record is not recorded by the service it reaches.
807
+ enterCapability(
808
+ CALLS_CAPABILITY,
809
+ grantedCallsToRecord || callsFor({forward: request.headers} as IMeta, kind),
810
+ );
811
+ Object.assign(request.headers, enterRequestFlow(request.headers, kind));
812
+ },
813
+ );
649
814
  await this.#server.register(jwt, {
650
815
  cache: this.#config.jwt?.cache,
651
816
  verify: (token: string, options: {nonce?: string; audience: string}) =>
@@ -704,6 +869,13 @@ export default class Gateway extends Internal implements IGateway {
704
869
  }
705
870
 
706
871
  public async stop(): Promise<IGateway> {
872
+ // fastify's close() waits on the socket layer, and keep-alive connections made by a
873
+ // browser backend (or a leftover probe) hold it open indefinitely: the run ends with
874
+ // a tap "timeout!" against a live server long after every test passed, because the
875
+ // process never exits. Close the connections first so shutting down actually
876
+ // shuts down.
877
+ this.#server?.server.closeIdleConnections?.();
878
+ this.#server?.server.closeAllConnections?.();
707
879
  await this.#server?.close();
708
880
  this.#server = null;
709
881
  return this;
package/src/Log.ts CHANGED
@@ -1,12 +1,33 @@
1
1
  import {withProgress} from '@feasibleone/blong-lib';
2
- import {Internal, type ILog, type ILogger} from '@feasibleone/blong/types';
2
+ import {Internal, type ICallLog, type ILog, type ILogger} from '@feasibleone/blong/types';
3
+ import {createCallChannel} from '@feasibleone/semantic-log/capability';
3
4
  import {pino, type Logger, type LoggerOptions} from 'pino';
4
5
  import {monotonicFactory} from 'ulidx';
6
+ import {callRecord, configureCallTrace, type CallTraceConfig} from './callTrace.ts';
5
7
  import type {CacacheTransportOptions} from './pino-cacache.js';
6
8
 
7
9
  // echo -e "\u001B]8;;https://google.com\u001B\\Кликни тук\u001B]8;;\e\\"
8
10
 
9
11
  export interface LogConfig extends LoggerOptions {
12
+ /**
13
+ * Which implementation is active. This class serves every value except
14
+ * `semantic`, so it never acts on the key — it is declared because the merged
15
+ * `log` config is handed to whichever implementation the loader chose, and
16
+ * stripped before the rest reaches pino.
17
+ */
18
+ impl?: string;
19
+ /** The semantic implementation's store. Not a pino option; stripped below. */
20
+ cache?: unknown;
21
+ /**
22
+ * The call channel: whether a flow's calls are recorded, and whether they
23
+ * are shown.
24
+ *
25
+ * `stdout` is the whole switch under this implementation, because pino has
26
+ * no retention store and no cluster sink - a record it is not printing does
27
+ * not exist. The framework's `enabled`/`off` decision still applies first,
28
+ * so a flow that opted out records nothing whichever logger runs.
29
+ */
30
+ calls?: CallTraceConfig;
10
31
  /** When provided, log entries are cached to disk via cacache for later inspection. */
11
32
  cacache?: CacacheTransportOptions;
12
33
  }
@@ -84,6 +105,7 @@ export default class Log extends Internal implements ILog {
84
105
  public constructor(config: LogConfig) {
85
106
  super();
86
107
  this.merge(this.#config, config);
108
+ configureCallTrace(this.#config.calls);
87
109
 
88
110
  // Inject a monotonic ULID `id` into every log entry before it reaches any transport
89
111
 
@@ -103,11 +125,30 @@ export default class Log extends Internal implements ILog {
103
125
  };
104
126
  }
105
127
 
106
- // Remove the cacache option before passing to pino — it is not a pino option
107
- const {cacache: _cacacheConfig, ...pinoConfig} = this.#config;
128
+ // Remove the framework's own keys before passing the rest to pino — none
129
+ // of them is a pino option. `impl` chose this implementation (reaching
130
+ // here means it is not `semantic`) and `cache` configures the semantic
131
+ // implementation's store; `cacache` is this implementation's transport
132
+ // and is passed to it separately, through the transport targets below.
133
+ const {
134
+ cacache: _cacacheConfig,
135
+ impl: _impl,
136
+ cache: _semanticCache,
137
+ calls: _calls,
138
+ ...pinoConfig
139
+ } = this.#config;
108
140
  this.#logger = pino(pinoConfig);
109
141
  }
110
142
 
143
+ /**
144
+ * Attach the call channel's scope.
145
+ *
146
+ * Nothing to attach: the ambient scope a capability is carried in is
147
+ * semantic-log's own (`AsyncLocalStorage`), reached by the vocabulary the
148
+ * server bootstrap attaches, and the channel reads it directly.
149
+ */
150
+ public async init(): Promise<void> {}
151
+
111
152
  public child<T extends string>(...params: Parameters<Logger<never>['child']>): Logger<T> {
112
153
  return this.#logger.child(...params) as Logger<T>;
113
154
  }
@@ -142,6 +183,35 @@ export default class Log extends Internal implements ILog {
142
183
  return {
143
184
  ...result,
144
185
  progress: (label, promise, options) => withProgress(result, label, promise, options),
186
+ calls: this.#callsChannel(child),
145
187
  };
146
188
  }
189
+
190
+ /**
191
+ * The framework's own account of the calls made through this log.
192
+ *
193
+ * Written through the logger it is asked of, so a component's call records carry
194
+ * the component — the same `name` binding every other record from it does. Under
195
+ * pino the destination *is* the level: there is nothing to store, so a call
196
+ * record that is not printed has nowhere else to go. Whether the flow records at
197
+ * all is the capability's decision, asked before the writer is called, and it is
198
+ * the same one the semantic implementation asks.
199
+ */
200
+ #callsChannel(child: Logger): ICallLog {
201
+ return createCallChannel(({leg, phase, message, error, fields}) => {
202
+ child.info(
203
+ {
204
+ ...callRecord(leg, phase),
205
+ ...(error === undefined ? {} : {err: error}),
206
+ ...fields,
207
+ },
208
+ message,
209
+ );
210
+ });
211
+ }
212
+
213
+ /** The call channel, as a component that holds the log rather than a logger reaches it. */
214
+ public get calls(): ICallLog {
215
+ return this.#callsChannel(this.#logger);
216
+ }
147
217
  }
package/src/Registry.ts CHANGED
@@ -530,6 +530,13 @@ export default class Registry extends Internal implements IRegistry {
530
530
  );
531
531
  this.#stopped = true;
532
532
  this.#logger = undefined;
533
+ // The log implementation can hold something the process will not exit without: the
534
+ // semantic log's in-process cluster service listens on a socket (9455 by default), and
535
+ // a run that closes every other server still hangs on it — a tap suite that passed
536
+ // every test then reports "timeout!" against a live server. Stopping it here is what
537
+ // makes `stop()` mean "nothing of this platform is still running". Optional because
538
+ // the plain log implementation has nothing to stop.
539
+ await (this.#log as {stop?: () => Promise<void>})?.stop?.();
533
540
  return this;
534
541
  }
535
542
 
package/src/RpcServer.ts CHANGED
@@ -3,6 +3,7 @@ import {Internal, type ILog, type IManifest, type IMeta, type IRpcServer} from '
3
3
  import fastify, {type FastifyReply, type FastifyRequest, type RouteOptions} from 'fastify';
4
4
 
5
5
  import type {IResolution} from './Resolution.ts';
6
+ import {adoptInbound} from './semanticContext.ts';
6
7
 
7
8
  interface IConfig {
8
9
  port: number;
@@ -70,9 +71,15 @@ export default class RpcServer extends Internal implements IRpcServer {
70
71
  opcode: method.split('.').pop(),
71
72
  };
72
73
  attachCheckpoint?.(newMeta as IMeta);
73
- const result = await (callback as (...args: unknown[]) => Promise<unknown>).apply(
74
- object,
75
- [...params, newMeta],
74
+ // An inbound call joins the flow it was made in when it carries one,
75
+ // and is an outermost entry when it does not: a peer that sends no
76
+ // identity gets a flow of its own rather than a continuation of a flow
77
+ // nobody named.
78
+ const result = await adoptInbound(newMeta as IMeta, method, () =>
79
+ (callback as (...args: unknown[]) => Promise<unknown>).apply(object, [
80
+ ...params,
81
+ newMeta,
82
+ ]),
76
83
  );
77
84
  return {
78
85
  jsonrpc: '2.0',
@@ -161,6 +168,10 @@ export default class RpcServer extends Internal implements IRpcServer {
161
168
  }
162
169
 
163
170
  public async stop(): Promise<IRpcServer> {
171
+ // See Gateway.stop: leftover keep-alive connections would hold the process open and
172
+ // turn a green run into a tap "timeout!".
173
+ this.#server.server.closeIdleConnections?.();
174
+ this.#server.server.closeAllConnections?.();
164
175
  await this.#server.close();
165
176
  return this;
166
177
  }