Files
msd-core/get-shit-done/bin/lib/command-routing-hub.cjs
Tom Boucher 5f3eb42864 feat(observability): propagate parentTraceId on DispatchEvent — ADR-0174 SDK retirement Phase 1.4 (#178) (#225)
* test(#178): update DispatchEvent factory tests to propagate parentTraceId

P1.3 test 'parentTraceId is always undefined' replaced with four P1.4
contracts: absent → undefined, string → propagated, null → undefined,
non-string → undefined (defensive normalization policy).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#178): propagate parentTraceId through DispatchEvent factory

Stop ignoring the parentTraceId parameter added as a forward-compat hook
in P1.3. Defensive normalization: only non-null strings are propagated;
null, non-string values, and absent callers all yield undefined, keeping
P1.3 behavior intact for all existing dispatch call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): add Hub-level parentTraceId propagation tests

Four new assertions: req.parentTraceId propagates to event, absent →
undefined (P1.3 regression), shared parentTraceId across multiple
dispatches, and unique traceId invariant despite shared parentTraceId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#178): plumb parentTraceId through Hub dispatch and _notifyLogger

dispatch() now reads req.parentTraceId and passes it to _notifyLogger,
which forwards it to makeDispatchEvent. Backward-compatible: callers
that omit parentTraceId emit events with parentTraceId: undefined,
identical to P1.3 behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): add trace correlation end-to-end test

Dispatches a root command then 3 children with parentTraceId=rootTraceId.
Reads the real .gsd-trace.jsonl audit file and verifies: 4 events total,
root has no parentTraceId, all children carry rootTraceId, all traceIds
unique, JS filter returns exactly the 3 children given the root's traceId.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#178): document traceId/parentTraceId in audit file

Update Observability section to note that audit events now carry both
traceId and parentTraceId, and explain the correlation filter pattern.
Note that leaf dispatches emit parentTraceId: undefined until the Phase 2
composer wires it automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* chore(#178): add changeset for trace correlation seam

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): cover invalid parentTraceId values in DispatchEvent factory

Adds 9 new test cases for UUID v4 validation of parentTraceId:
empty string, whitespace, non-UUID, oversized, UUID v1, missing-hyphen,
extra-char (all dropped to undefined), plus UPPERCASE and lowercase v4
(both propagated). Tests are intentionally red until the implementation
commit that follows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(#178): validate parentTraceId against UUID v4 before propagation

Adds UUID_V4_REGEX constant and isValidParentTraceId() helper to
event.cjs. makeDispatchEvent now silently coerces any parentTraceId that
fails the UUID v4 check (wrong version nibble, wrong variant, missing
hyphens, oversized, empty, etc.) to undefined. No stderr warn is emitted
— the factory remains pure and side-effect-free. Closes the correlation-
poisoning vector identified in the Codex adversarial review of PR #225.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): assert Hub silently drops invalid parentTraceId at the seam

Adds two tests to hub-logger-integration.test.cjs:
1. dispatch with 'junk' parentTraceId emits event with parentTraceId===undefined.
2. The logger-failure warn path is NOT triggered — the factory coerces the bad
   value before onEvent is called, confirmed by zero stderr output even when a
   logger that would throw on non-undefined parentTraceId is installed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(#178): assert invalid parentTraceId does not poison correlation siblings

Adds one test to trace-correlation.test.cjs: dispatches a root, a valid
child (parentTraceId = rootTraceId), and an invalid child (parentTraceId =
'junk'). Asserts: valid child carries correct parentTraceId, invalid child
has parentTraceId dropped to undefined, filtering by rootTraceId yields
exactly 1 event (the valid child only), and all 3 events have unique
traceIds. Uses an isolated Hub + tmpdir to avoid shared fixture interference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs(#178): document UUID v4 contract for parentTraceId

Appends one sentence to the Observability audit-trail paragraph in
CONFIGURATION.md: parentTraceId must be canonical UUID v4 (RFC 4122);
values that don't match are silently dropped from audit output. No section
restructuring — single sentence addition only.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 16:32:28 -04:00

389 lines
14 KiB
JavaScript

'use strict';
/**
* Command Routing Hub — issue #3788, simplified in #175, typed in #176, observability in #177.
*
* A pure-result dispatch hub that centralizes CJS routing,
* the error taxonomy, and the no-throw contract that all command-family routers
* currently duplicate independently.
*
* Design:
* createHub({ cjsRegistry, manifest }) -> hub
* hub.dispatch({ family, subcommand, args, cwd, raw }) -> Result
*
* Result = { ok: true, data }
* | { ok: false, kind: 'UnknownCommand', command: string }
* | { ok: false, kind: 'InvalidArgs', arg: string, reason: string }
* | { ok: false, kind: 'HandlerRefusal', reason: string }
* | { ok: false, kind: 'HandlerFailure', message: string, cause?: Error }
*
* Invariants:
* - Hub always routes through CJS handlers. There is no SDK path (#175).
* - Hub never prints to stdout/stderr, never calls process.exit.
* - Hub never throws — all internal throws are caught and converted to
* { ok: false, kind: 'HandlerFailure', message, cause }.
* - The kind taxonomy is closed. Callers switch on ERROR_KINDS values.
* - Each error variant carries ONLY its own typed payload (#176).
* No cross-variant `message`/`details` escape hatches.
*/
/**
* Closed error-kind enum. Export as a frozen object so callers can switch on
* ERROR_KINDS.UnknownCommand etc. without relying on bare string literals.
*
* #175: SdkLoadFailed and SdkDispatchFailed removed — Hub is CJS-only.
* #176: Field renamed errorKind → kind; payloads are typed per variant.
*
* @readonly
*/
const ERROR_KINDS = Object.freeze({
/** The requested family/subcommand combination is not present in the manifest. */
UnknownCommand: 'UnknownCommand',
/** The handler rejected the supplied arguments before executing. */
InvalidArgs: 'InvalidArgs',
/** A CJS handler returned an explicit refusal (e.g. unsupported subcommand). */
HandlerRefusal: 'HandlerRefusal',
/** A handler threw an unexpected exception. */
HandlerFailure: 'HandlerFailure',
});
// ─── Observability imports ────────────────────────────────────────────────────
const { makeDispatchEvent } = require('./observability/event.cjs');
const { createNoOpLogger } = require('./observability/logger.cjs');
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Safe JSON serialisation that never throws.
* @param {unknown} value
* @returns {string}
*/
function _safeJson(value) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
// ─── Typed-payload factories (#176) ──────────────────────────────────────────
// Each factory returns a frozen discriminated-union variant for its kind.
// No cross-variant fields bleed between variants.
// Finding 3: all factory returns are Object.freeze'd so callers cannot mutate
// the variant invariant.
/**
* @param {string} command - The unrecognised command string (family or family+subcommand).
* @returns {Readonly<{ ok: false, kind: 'UnknownCommand', command: string }>}
*/
function makeUnknownCommand(command) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.UnknownCommand, command });
}
/**
* @param {string} arg - The argument token that failed validation.
* @param {string} reason - Human-readable explanation of the failure.
* @returns {Readonly<{ ok: false, kind: 'InvalidArgs', arg: string, reason: string }>}
*/
function makeInvalidArgs(arg, reason) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.InvalidArgs, arg, reason });
}
/**
* @param {string} reason - Human-readable explanation for the refusal.
* @returns {Readonly<{ ok: false, kind: 'HandlerRefusal', reason: string }>}
*/
function makeHandlerRefusal(reason) {
return Object.freeze({ ok: false, kind: ERROR_KINDS.HandlerRefusal, reason });
}
/**
* @param {string} message - Human-readable description of the failure.
* @param {Error} [cause] - The original thrown Error, when available.
* Non-Error values (strings, plain objects, etc.) are wrapped in an Error
* with `.thrown` set to the original value. null/undefined → no cause field.
* @returns {{ ok: false, kind: 'HandlerFailure', message: string, cause?: Error }}
*/
function makeHandlerFailure(message, cause) {
const obj = { ok: false, kind: ERROR_KINDS.HandlerFailure, message };
if (cause != null) {
if (cause instanceof Error) {
obj.cause = cause;
} else {
// Finding 4: wrap non-Error cause so downstream .cause.stack never silently returns undefined
const wrapper = new Error('non-Error cause: ' + _safeJson(cause));
wrapper.thrown = cause;
obj.cause = wrapper;
}
}
return Object.freeze(obj);
}
// ─── Handler-return shape validator (Finding 1) ───────────────────────────────
/**
* Required payload fields per ok:false kind.
* `required` — fields that MUST be present (non-undefined) for the variant to be valid.
* `allowed` — the complete set of allowed fields (including ok, kind).
*
* @type {Record<string, { required: string[], allowed: Set<string> }>}
*/
const _VARIANT_SCHEMA = {
UnknownCommand: {
required: ['command'],
allowed: new Set(['ok', 'kind', 'command']),
},
InvalidArgs: {
required: ['arg', 'reason'],
allowed: new Set(['ok', 'kind', 'arg', 'reason']),
},
HandlerRefusal: {
required: ['reason'],
allowed: new Set(['ok', 'kind', 'reason']),
},
HandlerFailure: {
required: ['message'],
allowed: new Set(['ok', 'kind', 'message', 'cause']),
},
};
/**
* Validates a handler-returned { ok: false, ... } result against the typed schema.
*
* Returns null if valid, or a string describing the contract violation.
*
* @param {object} result
* @returns {string|null}
*/
function _validateErrResult(result) {
const { kind } = result;
const schema = _VARIANT_SCHEMA[kind];
// Unknown kind — not in the closed enum
if (!schema) {
return `handler returned unknown kind '${kind}': expected one of ${Object.keys(_VARIANT_SCHEMA).join(', ')}`;
}
// Missing required fields
for (const field of schema.required) {
if (result[field] === undefined) {
return (
`handler returned malformed Result variant: ` +
`kind '${kind}' requires field '${field}' but it is missing. ` +
`got: ${_safeJson(result)}`
);
}
}
// Extraneous fields outside the typed payload
for (const key of Object.keys(result)) {
if (!schema.allowed.has(key)) {
return (
`handler returned malformed Result variant: ` +
`kind '${kind}' does not allow field '${key}'. ` +
`expected fields: ${[...schema.allowed].join(', ')}. ` +
`got: ${_safeJson(result)}`
);
}
}
return null; // valid
}
/**
* @typedef {{ ok: true, data: unknown }} OkResult
* @typedef {{ ok: false, kind: 'UnknownCommand', command: string }} UnknownCommandResult
* @typedef {{ ok: false, kind: 'InvalidArgs', arg: string, reason: string }} InvalidArgsResult
* @typedef {{ ok: false, kind: 'HandlerRefusal', reason: string }} HandlerRefusalResult
* @typedef {{ ok: false, kind: 'HandlerFailure', message: string, cause?: Error }} HandlerFailureResult
* @typedef {UnknownCommandResult | InvalidArgsResult | HandlerRefusalResult | HandlerFailureResult} ErrResult
* @typedef {OkResult | ErrResult} HubResult
*/
/**
* @typedef {object} HubOptions
* @property {Record<string, Record<string, (ctx: object) => HubResult>>} [cjsRegistry] -
* Nested map of family -> subcommand -> handler.
* @property {Record<string, string[]>} [manifest] - Map of family -> known subcommands.
* Used for UnknownCommand detection.
* @property {{ onEvent(event: object): void }} [logger] -
* DispatchLogger to receive a DispatchEvent after every dispatch.
* Defaults to a no-op logger (silent). Use createDefaultLogger() for the
* reference implementation (stderr on error, opt-in file audit).
*/
/**
* Safe stringify for logger-failure warnings — avoids circular-ref crashes.
* @param {unknown} value
* @returns {string}
*/
function _safeJsonForWarn(value) {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/**
* Construct a CommandRoutingHub.
*
* @param {HubOptions} options
* @returns {{ dispatch: (req: object) => HubResult }}
*/
function createHub({ cjsRegistry, manifest, logger } = {}) {
const _cjsRegistry = cjsRegistry;
const _manifest = manifest;
// Default to no-op so callers that don't inject a logger get pure-silent behaviour.
// Consumers can opt into the reference impl by importing createDefaultLogger.
const _logger = (logger && typeof logger.onEvent === 'function')
? logger
: createNoOpLogger();
/**
* Normalise a HubResult into the DispatchEvent result shape.
*
* HubResult ok path: { ok: true, data } → { kind: 'ok', data }
* HubResult err paths: { ok: false, kind, ...payload } → { kind, ...payload }
*
* @param {object} hubResult
* @returns {object}
*/
function _normaliseResult(hubResult) {
if (hubResult.ok) {
return { kind: 'ok', data: hubResult.data };
}
// err variant: already has kind + typed payload
return hubResult;
}
/**
* Emit a DispatchEvent to the injected logger.
* Logger errors NEVER propagate — they are caught and emitted as a warn line to stderr.
*
* @param {string} command - The dispatched command string.
* @param {unknown} args - The raw args from the request.
* @param {object} hubResult - The HubResult.
* @param {string} [parentTraceId] - Optional parent trace ID from the request (P1.4).
*/
function _notifyLogger(command, args, hubResult, parentTraceId) {
try {
const eventResult = _normaliseResult(hubResult);
const event = makeDispatchEvent({ command, args, result: eventResult, parentTraceId });
_logger.onEvent(event);
} catch (logErr) {
// Logger must never break dispatch. Emit a degraded warn line.
try {
process.stderr.write(
_safeJsonForWarn({
level: 'warn',
source: 'DispatchLogger',
message: 'logger.onEvent failed: ' + String(logErr && logErr.message || logErr),
}) + '\n'
);
} catch {
// If even stderr.write fails, swallow silently — dispatch result is returned below.
}
}
}
/**
* Dispatch a command through the hub.
*
* @param {{ family: string, subcommand: string, args?: unknown[], cwd?: string, raw?: boolean }} req
* @returns {HubResult}
*/
function dispatch(req) {
const { family, subcommand, args = [], parentTraceId } = req || {};
const command = subcommand ? `${family} ${subcommand}` : String(family);
let result;
try {
result = _dispatch(req);
} catch (err) {
if (err instanceof Error) {
result = makeHandlerFailure(err.message, err);
} else {
// Finding 2: preserve non-Error throwables via a wrapper Error with .thrown
const wrapper = new Error('non-Error thrown: ' + _safeJson(err));
wrapper.thrown = err;
result = makeHandlerFailure(String(err), wrapper);
}
}
_notifyLogger(command, args, result, parentTraceId);
return result;
}
function _dispatch(req) {
const { family, subcommand, args = [], cwd, raw } = req;
// ── manifest check ────────────────────────────────────────────────────────
if (_manifest) {
const knownSubcommands = _manifest[family];
if (!knownSubcommands) {
return makeUnknownCommand(String(family));
}
if (subcommand && !knownSubcommands.includes(subcommand)) {
return makeUnknownCommand(`${family} ${subcommand}`);
}
}
return _dispatchCjs({ family, subcommand, args, cwd, raw });
}
function _dispatchCjs({ family, subcommand, args, cwd, raw }) {
if (!_cjsRegistry) {
return makeUnknownCommand(String(family));
}
const familyHandlers = _cjsRegistry[family];
if (!familyHandlers) {
return makeUnknownCommand(String(family));
}
const handler = subcommand ? familyHandlers[subcommand] : familyHandlers[''];
if (typeof handler !== 'function') {
return makeUnknownCommand(subcommand ? `${family} ${subcommand}` : String(family));
}
// Invoke the handler. It must return a HubResult or throw.
// If it throws, the outer try/catch in dispatch() catches it.
const result = handler({ family, subcommand, args, cwd, raw });
// If the handler returned a HubResult, validate ok:false variants against the typed schema.
if (result && typeof result === 'object' && 'ok' in result) {
if (!result.ok) {
// Finding 1: runtime-validate ok:false variant shape; coerce malformed to HandlerFailure
const violation = _validateErrResult(result);
if (violation !== null) {
return makeHandlerFailure(
'handler returned malformed Result variant: ' + violation,
new Error('expected ' + (result.kind || '<no kind>') + ', got ' + _safeJson(result))
);
}
}
return result;
}
// If the handler returned nothing (undefined), treat as success with no data.
if (result === undefined || result === null) {
return { ok: true, data: null };
}
// Any other return value is treated as the data payload.
return { ok: true, data: result };
}
return { dispatch };
}
module.exports = {
createHub,
ERROR_KINDS,
makeUnknownCommand,
makeInvalidArgs,
makeHandlerRefusal,
makeHandlerFailure,
};