* fix(#2620): inject the reference DispatchLogger on the live dispatch seam when observability is enabled The Command Routing Hub defaulted to createNoOpLogger and no caller ever injected createDefaultLogger, so GSD_AUDIT=1 wrote nothing and failed dispatches emitted no structured JSON to stderr — contradicting ADR-0174 §5/§6, CONTEXT.md's Dispatch Observability Module contract, and docs/CONFIGURATION.md. Inject the reference logger at both live createHub() sites, gated on the existing opt-in signal (newly exported isAuditEnabled). When observability is off no logger is injected, so the Hub keeps its no-op fallback and default output stays byte-for-byte identical. Enabling stderr-on-error unconditionally adds a second line to the --json-errors envelope that callers parse as exactly one JSON line, so that is deferred to its own increment under #2619. * chore(#2620): add changeset for the dispatch logger wiring fix * test(#2620): cover the phase seam and drop try/finally from the adapter test Two review findings from the #2621 round-1 review. The fix wires the logger at BOTH live createHub() seams, but only cjs-command-router-adapter was exercised. Adds a fail-first regression test for src/phase-command-router.cts:258 — verified RED against a tree with that hunk reverted (1 fail, exact assertion) and GREEN with it restored — plus a negative pin that no trace file appears when GSD_AUDIT is unset. The negative case passes pre-fix and is a pin, not fail-first. CONTRIBUTING.md:344 forbids try/finally inside test bodies; the new adapter test used it. Converted to the Pattern-2 t.after() form, switched to the centralized createTempDir helper, and removed the now-unused os require. * chore(#2620): scope the changeset to the activation path that actually ships The fragment claimed config.audit.enabled activates the audit trail. It cannot: both seams call isAuditEnabled() with zero arguments, so the config branch in _isAuditEnabled is unreachable from production, and src/config-schema.cts registers no audit key at all — a user setting it would be silently dropped. That string ships in the user-facing CHANGELOG. Scoped to GSD_AUDIT=1, which is what actually works. The missing schema key stays a disclosed deferred sub-defect on #2620. Also adds the (#2620) issue backlink the other fragments carry. * docs(#2620): correct the fork-leaked issue reference in the wiring comments Four files cited this fix as #26, the issue number from the fork where the change was first written. Upstream #26 is an unrelated closed SDK issue, and next already uses #26 with that meaning in src/validate.cts:17,29,42 and src/config.cts:474, so these references pointed somewhere real and wrong rather than merely dangling. Baked into permanent doc comments, they reach users compiled via the ADR-457 build-at-publish path. The changeset and tests/phase-command-router.test.cjs already cited #2620; this brings the remaining four files into line. Comment-only, no behaviour change. build:lib produces no generated drift. The rename is scoped to these four files so the pre-existing SDK #26 references in validate.cts, config.cts, health-validation.test.cjs and config.test.cjs are deliberately left untouched. --------- Co-authored-by: CI Rebase Check <ci@gsd-redux>
172 lines
6.5 KiB
TypeScript
172 lines
6.5 KiB
TypeScript
/**
|
|
* DispatchLogger interface + default implementation — issue #177 (ADR-0174 P1.3).
|
|
*
|
|
* Interface:
|
|
* { onEvent(event: DispatchEvent): void }
|
|
*
|
|
* Default behaviour (createDefaultLogger):
|
|
* 1. Silent on success — no stdout/stderr when result.kind === 'ok'.
|
|
* 2. Structured JSON to stderr on error — one line per dispatch error.
|
|
* 3. Opt-in audit file — when GSD_AUDIT=1 OR config.audit.enabled===true,
|
|
* appends every event (success + error) as one JSON line to
|
|
* .planning/.gsd-trace.jsonl relative to `cwd`. Creates .planning/ if absent.
|
|
* 4. Args redaction — args omitted by default; included when GSD_AUDIT_ARGS=1.
|
|
*
|
|
* No-op logger (createNoOpLogger):
|
|
* Silent on all events. Used as the Hub default when no logger is injected.
|
|
*
|
|
* ADR-457 build-at-publish: the hand-written bin/lib/observability/logger.cjs
|
|
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
|
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { redactEvent } from './redaction.cjs';
|
|
|
|
const AUDIT_FILE_NAME = '.gsd-trace.jsonl';
|
|
const PLANNING_DIR = '.planning';
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Safely serialise a value to JSON, falling back to a placeholder on circular refs.
|
|
*/
|
|
function _safeStringify(value: unknown): string {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return JSON.stringify({ _serializationError: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Determine whether observability is opt-in enabled — via the GSD_AUDIT env var
|
|
* or config.audit.enabled. Exported (#2620) so the live dispatch seam can decide
|
|
* whether to inject the reference logger at all: when observability is off we
|
|
* inject nothing (the Hub stays byte-for-byte silent, preserving the default
|
|
* dispatch output contract, incl. --json-errors); when on, the caller injects
|
|
* createDefaultLogger and gets the stderr-on-error line + opt-in file audit.
|
|
*/
|
|
function _isAuditEnabled(config?: { audit?: { enabled?: boolean } }): boolean {
|
|
if (process.env['GSD_AUDIT'] === '1') return true;
|
|
if (config && config.audit && config.audit.enabled === true) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build the redacted plain object for the audit file.
|
|
* Preserves the full DispatchEvent structure.
|
|
*/
|
|
function _toAuditRecord(event: Record<string, unknown>): Record<string, unknown> {
|
|
return redactEvent(event);
|
|
}
|
|
|
|
/**
|
|
* Build the flattened stderr error line.
|
|
*
|
|
* Per ADR-0174 P1.3 contract: { "kind": "<variant>", "traceId": "<uuid>", ...typedPayload }
|
|
* The result's kind is promoted to top-level and the typed payload fields are spread in.
|
|
* The `result` wrapper is removed.
|
|
*/
|
|
function _toStderrRecord(event: Record<string, unknown>): Record<string, unknown> {
|
|
const redacted = redactEvent(event);
|
|
const { result, ...eventWithoutResult } = redacted;
|
|
// Flatten: top-level gets kind + typed payload fields from result
|
|
const resultObj = result as Record<string, unknown>;
|
|
const { kind, ...typedPayload } = resultObj;
|
|
return Object.assign({}, eventWithoutResult, { kind }, typedPayload);
|
|
}
|
|
|
|
/**
|
|
* Append one JSON line to the audit file.
|
|
* Creates .planning/ directory if it does not exist.
|
|
*
|
|
* Uses synchronous fs API (crash-safe for v1 — dispatch is synchronous).
|
|
*/
|
|
function _appendAuditLine(cwd: string, event: Record<string, unknown>): void {
|
|
const planningDir = path.join(cwd, PLANNING_DIR);
|
|
// Ensure the directory exists
|
|
if (!fs.existsSync(planningDir)) {
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
}
|
|
const auditPath = path.join(planningDir, AUDIT_FILE_NAME);
|
|
fs.appendFileSync(auditPath, _safeStringify(event) + '\n', 'utf8');
|
|
}
|
|
|
|
// ─── Public factories ─────────────────────────────────────────────────────────
|
|
|
|
interface DispatchLogger {
|
|
onEvent(event: Record<string, unknown>): void;
|
|
}
|
|
|
|
/**
|
|
* Create a no-op logger. All events are silently dropped.
|
|
* This is the Hub's default when no logger is injected by the caller.
|
|
*/
|
|
function createNoOpLogger(): DispatchLogger {
|
|
return {
|
|
onEvent(_event: Record<string, unknown>): void {
|
|
// intentionally empty
|
|
},
|
|
};
|
|
}
|
|
|
|
interface DefaultLoggerOptions {
|
|
cwd?: string;
|
|
config?: { audit?: { enabled?: boolean } };
|
|
}
|
|
|
|
/**
|
|
* Create the default DispatchLogger.
|
|
*/
|
|
function createDefaultLogger({ cwd = process.cwd(), config }: DefaultLoggerOptions = {}): DispatchLogger {
|
|
return {
|
|
/**
|
|
* @param event - A DispatchEvent from the Hub.
|
|
*/
|
|
onEvent(event: Record<string, unknown>): void {
|
|
const resultObj = event && (event['result'] as Record<string, unknown> | undefined);
|
|
const isOk = resultObj && resultObj['kind'] === 'ok';
|
|
|
|
// ── Audit file (both ok and error) ────────────────────────────────────
|
|
if (_isAuditEnabled(config)) {
|
|
try {
|
|
const auditRecord = _toAuditRecord(event);
|
|
_appendAuditLine(cwd, auditRecord);
|
|
} catch (auditErr) {
|
|
// Audit errors must not surface to callers
|
|
process.stderr.write(
|
|
_safeStringify({
|
|
level: 'warn',
|
|
source: 'DispatchLogger',
|
|
message: 'audit file write failed: ' + String((auditErr as Error | null)?.message ?? auditErr),
|
|
}) + '\n'
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Stderr on error ───────────────────────────────────────────────────
|
|
if (!isOk) {
|
|
try {
|
|
const stderrRecord = _toStderrRecord(event);
|
|
process.stderr.write(_safeStringify(stderrRecord) + '\n');
|
|
} catch (stderrErr) {
|
|
// Last-resort: we cannot throw from the logger
|
|
process.stderr.write(
|
|
_safeStringify({
|
|
level: 'warn',
|
|
source: 'DispatchLogger',
|
|
message: 'stderr emit failed: ' + String((stderrErr as Error | null)?.message ?? stderrErr),
|
|
}) + '\n'
|
|
);
|
|
}
|
|
}
|
|
// ── Silent on success (no else branch needed) ─────────────────────────
|
|
},
|
|
};
|
|
}
|
|
|
|
export = { createDefaultLogger, createNoOpLogger, isAuditEnabled: _isAuditEnabled };
|