fix(#2620): inject the reference DispatchLogger on the live dispatch seam when observability is enabled (#2621)

* 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>
This commit is contained in:
Rezolv
2026-07-28 18:02:17 -04:00
committed by GitHub
parent 16e59d0db5
commit 0997d4f443
6 changed files with 167 additions and 4 deletions

View File

@@ -629,3 +629,56 @@ describe('bug #3631 — SDK family routers forward --raw to output()', () => {
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Regression for #2620 — the live adapter must inject a real DispatchLogger
// (ADR-0174 §6: "DispatchLogger is injected; the Hub emits DispatchEvent on
// every dispatch path"). Before the fix, routeHubCommandFamily built the Hub
// with no logger, so it fell back to createNoOpLogger and GSD_AUDIT was inert.
// ────────────────────────────────────────────────────────────────────────
{
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { routeHubCommandFamily } = require('../gsd-core/bin/lib/cjs-command-router-adapter.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
describe('cjs-command-router-adapter — observability logger wiring (#2620)', () => {
test('a dispatch through the live adapter writes the opt-in audit trail when GSD_AUDIT=1', (t) => {
const tmp = createTempDir('gsd-obs-wire-');
const prev = process.env.GSD_AUDIT;
t.after(() => {
if (prev === undefined) delete process.env.GSD_AUDIT;
else process.env.GSD_AUDIT = prev;
cleanup(tmp);
});
process.env.GSD_AUDIT = '1';
routeHubCommandFamily({
family: 'unit',
args: ['unit', 'ok'],
subcommands: ['ok'],
handlers: { ok: () => {} },
unknownMessage: (subcommand, available) => `Unknown ${subcommand}. Available: ${available.join(', ')}`,
error: () => {},
cwd: tmp,
raw: false,
});
const auditPath = path.join(tmp, '.planning', '.gsd-trace.jsonl');
assert.ok(
fs.existsSync(auditPath),
'GSD_AUDIT=1 must produce .planning/.gsd-trace.jsonl — the adapter must build the Hub with a real DispatchLogger (ADR-0174 §6), not createNoOpLogger'
);
const lines = fs.readFileSync(auditPath, 'utf8').trim().split(/\r?\n/).filter(Boolean);
assert.ok(lines.length > 0, 'at least one dispatch event must be recorded');
assert.equal(lines.length, 1, 'exactly one dispatch event must be recorded');
const event = JSON.parse(lines[0]);
assert.equal(event.result.kind, 'ok', 'a successful dispatch is recorded with result.kind === "ok"');
assert.ok(typeof event.command === 'string' && event.command.length > 0, 'event carries the dispatched command');
assert.ok(typeof event.traceId === 'string' && event.traceId.length > 0, 'event carries a traceId');
assert.ok(typeof event.timestamp === 'string' && event.timestamp.length > 0, 'event carries an ISO timestamp');
});
});
}

View File

@@ -564,3 +564,79 @@ describe('bug-1437 — phase.list-plans is wired in gsd-tools', () => {
});
});
}
// ─── 6. Observability logger wiring (#2620) ──────────────────────────────────
//
// The fix wires the reference DispatchLogger at BOTH live createHub() seams.
// The sibling seam (cjs-command-router-adapter) is covered in its own file;
// this pins the phase seam so a later refactor that drops the isAuditEnabled()
// gate or the injection here cannot reintroduce the #2620 defect silently.
{
const fs = require('node:fs');
const path = require('node:path');
const { createTempDir, cleanup } = require('./helpers.cjs');
describe('phase-command-router — observability logger wiring (#2620)', () => {
test('a dispatch through the phase seam writes the opt-in audit trail when GSD_AUDIT=1', (t) => {
const tmp = createTempDir('gsd-obs-phase-');
const prevAudit = process.env.GSD_AUDIT;
t.after(() => {
if (prevAudit === undefined) delete process.env.GSD_AUDIT;
else process.env.GSD_AUDIT = prevAudit;
cleanup(tmp);
});
process.env.GSD_AUDIT = '1';
const phase = makePhase({ cmdPhaseNextDecimal: () => {} });
routePhaseCommand({
phase,
args: ['phase', 'next-decimal', '5'],
cwd: tmp,
raw: false,
error: (m) => { throw new Error(m); },
});
const auditPath = path.join(tmp, '.planning', '.gsd-trace.jsonl');
assert.ok(
fs.existsSync(auditPath),
'GSD_AUDIT=1 must produce .planning/.gsd-trace.jsonl — the phase router must build the Hub with a real DispatchLogger (ADR-0174 §6), not createNoOpLogger'
);
const lines = fs.readFileSync(auditPath, 'utf8').trim().split(/\r?\n/).filter(Boolean);
assert.ok(lines.length > 0, 'at least one dispatch event must be recorded');
assert.equal(lines.length, 1, 'exactly one dispatch event must be recorded');
const event = JSON.parse(lines[0]);
assert.equal(event.result.kind, 'ok', 'a successful dispatch is recorded with result.kind === "ok"');
assert.ok(typeof event.command === 'string' && event.command.length > 0, 'event carries the dispatched command');
assert.ok(typeof event.traceId === 'string' && event.traceId.length > 0, 'event carries a traceId');
assert.ok(typeof event.timestamp === 'string' && event.timestamp.length > 0, 'event carries an ISO timestamp');
});
test('no audit trail is written when GSD_AUDIT is unset (Hub stays silent via the no-op fallback)', (t) => {
const tmp = createTempDir('gsd-obs-phase-off-');
const prevAudit = process.env.GSD_AUDIT;
t.after(() => {
if (prevAudit === undefined) delete process.env.GSD_AUDIT;
else process.env.GSD_AUDIT = prevAudit;
cleanup(tmp);
});
delete process.env.GSD_AUDIT;
const phase = makePhase({ cmdPhaseNextDecimal: () => {} });
routePhaseCommand({
phase,
args: ['phase', 'next-decimal', '5'],
cwd: tmp,
raw: false,
error: (m) => { throw new Error(m); },
});
assert.equal(
fs.existsSync(path.join(tmp, '.planning', '.gsd-trace.jsonl')),
false,
'without the opt-in signal the seam must leave no audit artifact'
);
});
});
}