Files
msd-core/get-shit-done/bin/lib/command-routing-hub.cjs
Tom Boucher 2d14eb8873 feat(observability): add DispatchLogger seam — ADR-0174 SDK retirement Phase 1.3 (#177) (#223)
* test(#177): add DispatchEvent factory failing tests

Red tests for makeDispatchEvent shape, traceId UUID v4, uniqueness,
parentTraceId-always-undefined (P1.3), args redaction toggle, ISO 8601
timestamp, and all result variant passthrough.

* feat(#177): introduce DispatchEvent factory

makeDispatchEvent produces an immutable event record per dispatch:
- traceId: crypto.randomUUID() (UUID v4)
- parentTraceId: always undefined (P1.4 wires composer)
- command, result, timestamp (ISO 8601)
- args only included when includeArgs === true (default: omitted)

* test(#177): add arg redaction policy failing tests

Red tests for shouldIncludeArgs (GSD_AUDIT_ARGS env gating) and
redactEvent (strips args from frozen events, preserves all other
fields, returns a new object, never mutates the source).

* feat(#177): introduce arg redaction policy

shouldIncludeArgs(): only GSD_AUDIT_ARGS==='1' opts in; all other
values (unset, '', '0', 'true') default to omitting args.

redactEvent(event): returns a shallow copy of the event, dropping the
args field unless opted in. Never mutates the (frozen) source event.

* test(#177): add DispatchLogger interface failing tests

Red tests covering:
- no-op logger: silent on all events, never throws
- default logger: silent on ok, one flattened JSON line to stderr on error
- default logger: audit file creation + append-only + redaction + config gate
- GSD_AUDIT env var and config.audit.enabled config gate
- GSD_AUDIT_ARGS opt-in for args inclusion
All tests use real fs under os.tmpdir() — no mocked appendFileSync.

* feat(#177): introduce DispatchLogger with default and no-op implementations

createNoOpLogger(): silent on all events — Hub default when no logger injected.
createDefaultLogger({ cwd, config }):
  - Silent on ok result
  - Flattened JSON line to stderr on error: { kind, traceId, ...typedPayload }
  - Append-only audit at .planning/.gsd-trace.jsonl when GSD_AUDIT=1 or config.audit.enabled
  - Args redacted by default; GSD_AUDIT_ARGS=1 opts in
  - Logger errors caught internally; never break dispatch callers

* test(#177): add Hub+logger integration failing tests

Red tests verifying:
- onEvent called exactly once per dispatch (ok, error, handler-throw, unknown)
- DispatchEvent shape: traceId uniqueness, command, result.kind, parentTraceId
- Logger errors contained (dispatch still returns Result, warn line to stderr)
- Hub defaults to no-op when no logger injected
- End-to-end with createDefaultLogger: silent on success, stderr on error, audit file

* feat(#177): wire DispatchLogger into CommandRoutingHub

Add optional logger param to createHub({ ..., logger }).
Defaults to createNoOpLogger() — silent, no behaviour change for callers
that don't inject a logger.

After every dispatch (success and error):
- Normalises HubResult { ok } to DispatchEvent { kind: 'ok'|error-kind }
- Calls makeDispatchEvent({ command, args, result }) to mint the event
- Calls logger.onEvent(event) exactly once
- Wraps in try/catch: logger errors emit { level:'warn', source:'DispatchLogger' }
  to stderr but never propagate to dispatch callers

* chore(#177): gitignore .planning/.gsd-trace.jsonl audit file

The audit trail is local-only, append-only, and must never be committed.
Slotted under the existing "Local scratch + Claude-test artifacts" block.

* docs(#177): document GSD_AUDIT, GSD_AUDIT_ARGS, config.audit.enabled

New ## Observability section at end of CONFIGURATION.md covering:
- Default silent/stderr behaviour overview
- Stderr error JSON format
- Audit file opt-in (env var and config key)
- Args redaction policy and GSD_AUDIT_ARGS opt-in

Also slots GSD_AUDIT and GSD_AUDIT_ARGS into the existing
## Environment Variables table (alphabetical order).

* chore(#177): add changeset for observability seam

type: Added — new DispatchLogger seam with default silent/stderr/audit behaviour.
2026-05-24 15:22:36 -04:00

388 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.
*/
function _notifyLogger(command, args, hubResult) {
try {
const eventResult = _normaliseResult(hubResult);
const event = makeDispatchEvent({ command, args, result: eventResult });
_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 = [] } = 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);
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,
};