20 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 336 | Make the logger injectable; drop createSessionLogger(runtime) |
Make the logger injectable; drop createSessionLogger(runtime)
Problem Statement
createSessionLogger(runtime) (src/session-logger.ts) captures the entire ExtensionRuntime and reaches through it to talk to strangers — runtime.writeDebugLog, runtime.writeReviewLog, and runtime.runtimeContext?.ui.notify — a Law-of-Demeter violation.
The runtime factory (src/runtime.ts) compounds the smell: it stubs writeDebugLog / writeReviewLog as () => {}, builds the JSONL writer plus a warning-dedup reporter, then reassigns the two methods afterward (a forward reference / temporal coupling).
The composition root (src/index.ts) then threads the same logging surface through five .bind(runtime) adapter closures.
This is Phase 4 Step 3 (Track B: De-god the runtime) from docs/architecture/architecture.md — the second link in the chain ConfigStore → injectable logger → dissolve runtime → collapse index.ts closures.
It depends on the ConfigStore extraction (Step 2, #335, complete) for the debug toggle.
It is behavior-preserving.
Goals
- Repurpose
createSessionLoggerto take narrow dependencies — the logs directory, a config reader (for the debug/review write toggles), and a notify sink — instead of the wholeExtensionRuntime. - Fold the JSONL-writer composition (
createPermissionSystemLogger), the warning-dedup reporter, and thewarnnotify path into the singlecreateSessionLoggerfactory, so one object owns the completeSessionLoggercontract (debug/review/warn). - Remove
writeDebugLog/writeReviewLogfromExtensionRuntime; expose the built logger asruntime.loggerinstead. - Remove the
runtime.writeDebugLog/runtime.runtimeContext?.ui.notifyreach-through fromsession-logger.ts, and drop the five.bind(runtime)logging adapters inindex.ts. - Behavior-preserving: no observable change to debug/review log writing, the config toggles, warning deduplication, or warning notification.
Non-Goals
- Moving the runtime context (
runtimeContext) ownership off the runtime. The notify sink keeps reading the still-runtime-owned context through the transitionalRuntimeContextRefseam introduced in #335; context unification ontoPermissionSessionis Step 4 (#337). - Dissolving
ExtensionRuntimeor moving the logger /ConfigStoreconstruction out of the factory and intoindex.ts— Step 4 (#337). The logger is built in the factory because it is mutually entangled withConfigStore, which the factory owns until #337. - Collapsing the remaining
index.tsdeps bags and unifying thewriteReviewLogfield naming across the forwarder / prompter / RPC consumers — Step 5 (#338). - Renaming the
SessionLoggerinterface methods (debug/review/warn) or the consumer fields (writeReviewLog/writeDebugLog).
Background
Relevant modules:
src/session-logger.ts— defines theSessionLoggerinterface (debug/review/warn, allvoid) andcreateSessionLogger(runtime), which delegatesdebug/reviewtoruntime.writeDebugLog/writeReviewLogandwarntoruntime.runtimeContext?.ui.notify(message, "warning").src/logging.ts—createPermissionSystemLogger({ getConfig, debugLogPath, reviewLogPath, ensureLogsDirectory })returns aPermissionSystemLoggerwhosedebug/reviewwrite a JSONL line (gated onconfig.debugLog/config.permissionReviewLog) and return a warning string on failure. This module has noExtensionRuntimereference today.src/runtime.ts—createExtensionRuntime()builds thePermissionSystemLogger, owns thereportedLoggingWarningsdedupSet+reportLoggingWarninghelper (which callsruntime.runtimeContext?.ui.notify), and assignsruntime.writeDebugLog/writeReviewLog(the stub-then-reassign pattern).ConfigStoreis constructed here too, with a deferred-bindingConfigStoreLoggerthat points atruntime.writeDebugLog/writeReviewLog. ThecontextRef: RuntimeContextRefseam (get/setoverruntime.runtimeContext) already exists forConfigStore.src/index.ts— callscreateSessionLogger(runtime)once (passed asPermissionSession'slogger), and threadsruntime.writeReviewLog.bind(runtime)/runtime.writeDebugLog.bind(runtime)into thePermissionForwarderlogger +writeReviewLog, thePermissionPrompter, and the RPC handlers (five.bindsites total).src/config-store.ts—ConfigStoreimplementsConfigReader(current()); itsConfigStoreLoggerdep is{ writeDebugLog, writeReviewLog }. Itsrefresh/save/logResolvedPathswrite through that sink.src/decision-reporter.ts—GateDecisionReporterholds aSessionLoggerand calls.review; built inindex.tsfromsession.logger. Unchanged.src/handlers/lifecycle.ts— the soleSessionLogger.warncaller (this.session.logger.warn(issue)). Unchanged.
Consumer logging-field shapes (all preserved):
ForwardedPermissionLogger(src/forwarded-permissions/io.ts):{ writeReviewLog, writeDebugLog }.PermissionPrompterDeps.writeReviewLog, the RPC handler depswriteReviewLog, andforwardingDeps.writeReviewLog: bare(event, details) => void.
Constraints from AGENTS.md / the package skill:
- Inject the new collaborator with a narrow interface, not the concrete runtime, so test doubles need no
as unknown as ExtensionRuntimecast. - Do not read
getAgentDir()/process.*inside the factory function —globalLogsDiris passed in. - Keep business logic at the edges:
createSessionLoggercomposes the existingcreatePermissionSystemLoggerrather than re-implementing JSONL writing. - The package skill does not name
createSessionLogger,writeDebugLog, orwriteReviewLog, so no skill edit is required.
The logger ↔ ConfigStore cycle
The logger needs the config (to read the debugLog / permissionReviewLog toggles at write time); ConfigStore needs the logger (to write config.loaded / config.saved / config.resolved entries).
Today this is broken with the stub-then-reassign forward reference.
This plan breaks it cleanly with a lazy config read: build the logger first with getConfig: () => configStore.current() (a thunk, called only at write time), then build ConfigStore with the fully-constructed logger's methods.
The logger object is complete when ConfigStore is constructed; only the config value is read lazily, which is correct because config changes across the session.
No method is stubbed-then-reassigned.
Design Overview
The injectable createSessionLogger
createSessionLogger becomes the single home for the full SessionLogger contract: it composes the JSONL writer, owns the warning-dedup Set, and routes both IO-failure warnings and explicit warn calls through the injected notify sink.
export interface SessionLogger {
debug(event: string, details?: Record<string, unknown>): void;
review(event: string, details?: Record<string, unknown>): void;
warn(message: string): void;
}
export interface SessionLoggerDeps {
/** Root logs directory; the debug + review log file paths derive from it. */
globalLogsDir: string;
/** Reads current config for the debug/review write toggles (call-time). */
getConfig: () => PermissionSystemExtensionConfig;
/** Surfaces a warning message to the user; read at call time. */
notify: (message: string) => void;
}
export function createSessionLogger(deps: SessionLoggerDeps): SessionLogger {
const writer = createPermissionSystemLogger({
getConfig: deps.getConfig,
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
ensureLogsDirectory: () =>
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
});
const reported = new Set<string>();
const report = (warning: string): void => {
if (reported.has(warning)) return;
reported.add(warning);
deps.notify(warning);
};
return {
debug: (event, details) => {
const warning = writer.debug(event, details);
if (warning) report(warning);
},
review: (event, details) => {
const warning = writer.review(event, details);
if (warning) report(warning);
},
warn: (message) => deps.notify(message),
};
}
The returned methods are standalone closures (no this), so consumers can pass logger.review / logger.debug as bare references with no .bind.
Construction in the factory
createExtensionRuntime() builds the logger before ConfigStore, using a lazy getConfig thunk and the existing contextRef seam for the notify sink:
let configStore: ConfigStore;
const logger = createSessionLogger({
globalLogsDir: paths.globalLogsDir,
getConfig: () => configStore.current(),
notify: (message) =>
runtime.runtimeContext?.ui.notify(message, "warning"),
});
configStore = new ConfigStore({
agentDir,
context: contextRef,
policyPaths: permissionManager,
logger: { writeDebugLog: logger.debug, writeReviewLog: logger.review },
});
runtime.configStore = configStore;
runtime.logger = logger;
ExtensionRuntime drops writeDebugLog / writeReviewLog and gains logger: SessionLogger.
The () => {} stubs, the post-construction reassignment, the reportedLoggingWarnings Set, the reportLoggingWarning helper, and the createPermissionSystemLogger import all leave runtime.ts.
Note: the notify sink reads runtime.runtimeContext at call time (matching today's reportLoggingWarning and warn behavior).
The contextRef seam may equally be used (contextRef.get()?.ui.notify(...)); both read the same field.
Consumer call sites (no .bind)
index.ts reads runtime.logger and passes the bound closures directly:
// PermissionSession logger arg
new PermissionSession(runtime, runtime.logger, /* … */);
// PermissionForwarder
logger: { writeReviewLog: runtime.logger.review, writeDebugLog: runtime.logger.debug },
writeReviewLog: runtime.logger.review,
// PermissionPrompter
writeReviewLog: runtime.logger.review,
// RPC handlers
writeReviewLog: runtime.logger.review,
The writeReviewLog / writeDebugLog field names on the consumer deps stay (they are mapped to logger.review / logger.debug values); unifying the naming is deferred to #338.
Edge cases (all preserved)
warnis never deduplicated (explicit warnings always notify); only IO-failure warnings flow through the dedupSet.- The notify sink is a no-op when
runtimeContextis null (early-session) —?.ui.notifyshort-circuits, exactly as today. - The dedup
Setlives for the lifetime of the logger (one percreateExtensionRuntimecall), matching today's per-runtimeSet. - The debug/review toggles are read at write time via
getConfig, so a config reload mid-session changes logging behavior with no rebuild — unchanged.
Extracted-module upstream check
session-logger.ts gains imports for join (node:path), DEBUG_LOG_FILENAME / REVIEW_LOG_FILENAME (config-paths.ts), ensurePermissionSystemLogsDirectory + PermissionSystemExtensionConfig (extension-config.ts), and createPermissionSystemLogger (logging.ts) — all of which runtime.ts already imports for this work; they move, not duplicate.
session-logger.ts no longer imports runtime.ts (the LoD reach-through is gone), so the dependency edge runtime.ts → session-logger.ts is one-way with no cycle.
No output-argument mutation is carried over: the logger mutates only its private dedup Set; the notify sink is an injected callback, not a reached-into bag.
Module-Level Changes
src/session-logger.ts- Rewrite
createSessionLoggerto acceptSessionLoggerDeps(globalLogsDir,getConfig,notify); composecreatePermissionSystemLogger, own the dedupSet+ reporter, and implementwarnvianotify. - Add the
SessionLoggerDepsinterface export. - Drop the
import type { ExtensionRuntime }.
- Rewrite
src/runtime.ts- Remove
writeDebugLog/writeReviewLogfrom theExtensionRuntimeinterface; addlogger: SessionLogger. - In the factory: build the logger via
createSessionLogger({ globalLogsDir, getConfig: () => configStore.current(), notify })beforeConfigStore; pass{ writeDebugLog: logger.debug, writeReviewLog: logger.review }as theConfigStoreLogger; setruntime.logger. - Delete the
() => {}stubs, the post-constructionwriteDebugLog/writeReviewLogreassignment, thereportedLoggingWarningsSet,reportLoggingWarning, and thecreatePermissionSystemLoggerimport (now unused here); importcreateSessionLogger+SessionLoggerfromsession-logger.ts.
- Remove
src/index.ts- Drop
import { createSessionLogger }. - Pass
runtime.loggertoPermissionSession. - Replace the five
runtime.writeReviewLog.bind(runtime)/runtime.writeDebugLog.bind(runtime)adapters withruntime.logger.review/runtime.logger.debug.
- Drop
test/session-logger.test.ts— rewrite for the new signature (plain fakes:getConfig,notify, a tempglobalLogsDir); cover the toggles, the success write, the IO-failure warning + dedup, andwarndirect-notify.test/runtime.test.ts— re-point the logger mock from../src/loggingto../src/session-logger(mockcreateSessionLogger); delete thewriteDebugLog/writeReviewLogdelegation + dedup + notify tests (now owned bysession-logger.test.ts); assertruntime.loggeris the object the factory built and thatcreateSessionLoggeris called with aglobalLogsDir-derived value and agetConfigreadingconfigStore.current().
Grep confirms runtime.writeDebugLog / runtime.writeReviewLog are referenced only in src/runtime.ts, src/index.ts, and test/runtime.test.ts; createSessionLogger only in src/index.ts (call), src/session-logger.ts (def), and test/session-logger.test.ts.
The SessionLogger interface (debug / review / warn) is unchanged, so decision-reporter.ts, handlers/lifecycle.ts, permission-session.ts, session-lifecycle-session.ts, handler-fixtures.ts, and gate-fixtures.ts need no edits.
Doc updates: docs/architecture/architecture.md line 567 describes session-logger.ts as the "SessionLogger interface + createSessionLogger() factory" — still accurate, optionally clarified to "(composes the JSONL writer, warning dedup, and notify sink)".
The Phase 4 health-metrics table is a phase-start snapshot, not a live count (per the #335 plan), so it is not edited here.
The roadmap Step 3 ✓ complete mark is appended at ship time, not during planning.
Test Impact Analysis
- New unit tests enabled:
session-logger.test.tsnow constructscreateSessionLoggerwith plain fakes — noas unknown as ExtensionRuntime. It can exercise the toggle gating, JSONL write success (temp dir), the IO-failure warning path (drive a failure via a non-writableglobalLogsDiror by asserting on a fake notify), warning deduplication, and the un-deduplicatedwarnpath — all without the runtime god object. - Redundant tests: the
runtime.test.tswriteDebugLog/writeReviewLogdelegation, dedup, and notify tests become redundant — the behavior they covered now lives increateSessionLoggerand is tested insession-logger.test.ts. They are deleted when the runtime stops owning those methods. - Tests that must stay: the
runtime.test.tspath-derivation,piInfrastructureDirs, default-state, andconfigStore.current()tests (unaffected); theconfig-store.test.tslogging-sink assertions (the sink contract —{ writeDebugLog, writeReviewLog }— is unchanged); thelogging.test.ts/config-reporter.test.tscreatePermissionSystemLoggertests (the JSONL writer is unchanged and is now composed, not bypassed).
TDD Order
This is a signature change on createSessionLogger plus removal of two ExtensionRuntime fields, which break every consumer at the type level simultaneously.
Lift-and-shift across two steps keeps each commit small and the repo green between them: Step 1 introduces the new logger object and exposes runtime.logger while keeping the old runtime methods as thin delegators; Step 2 removes the old methods and the .bind adapters.
-
Inject the new
createSessionLogger; exposeruntime.logger—refactor:- Coupled step:
createSessionLogger's signature changes (sole call siteindex.tsline 72), so it cannot land in isolation. - Red: rewrite
test/session-logger.test.tsforSessionLoggerDeps— assert toggle gating, success write (temp dir), IO-failure warning + dedup, andwarndirect-notify. - Green:
- Rewrite
createSessionLoggertoSessionLoggerDeps(compose the writer, own the dedupSet, implementwarn). - In
runtime.ts, build the logger via the new factory and setruntime.logger; keepwriteDebugLog/writeReviewLogon the runtime as thin delegators ((e, d) => logger.debug(e, d)/review) soindex.ts's.bindsites still compile; point theConfigStoreLoggerat{ writeDebugLog: logger.debug, writeReviewLog: logger.review }; delete the stubs, the reassignment, the dedupSet, andreportLoggingWarning. - In
index.ts, swapcreateSessionLogger(runtime)→runtime.logger(drop the import). - In
test/runtime.test.ts, re-point the mock to../src/session-logger; move the dedup/notify/delegation assertions out (now insession-logger.test.ts) but keepwriteDebugLog/writeReviewLogdelegation smoke checks if still present this step.
- Rewrite
- Run
pnpm run check+ the full suite. - Commit:
refactor: build an injectable SessionLogger in the runtime factory.
- Coupled step:
-
Remove the runtime logging methods and the
.bindadapters —refactor:- Coupled step: removing the two fields breaks every
.bindsite at the type level; fold all consumer + test edits in. - Green:
index.ts: replace the fiveruntime.writeReviewLog.bind(runtime)/runtime.writeDebugLog.bind(runtime)withruntime.logger.review/runtime.logger.debug.runtime.ts: removewriteDebugLog/writeReviewLogfrom theExtensionRuntimeinterface and the factory (theConfigStoreLoggeralready useslogger.debug/logger.review).test/runtime.test.ts: delete thewriteDebugLog/writeReviewLogtests; add/keep theruntime.loggerassertion and thecreateSessionLoggercall-args assertion.
- Run
pnpm run check, the full suite, andpnpm fallow dead-codeto confirm no orphaned exports (e.g., an now-unusedlogging.tsre-export). - Commit:
refactor: drop runtime logging methods and index .bind adapters.
- Coupled step: removing the two fields breaks every
Risks and Mitigations
- Risk: the lazy
getConfig: () => configStore.current()thunk is invoked beforeconfigStoreis assigned, throwing a TDZ / undefined error. Mitigation:getConfigis only called at log write time (insidewriter.debug/review), never during construction;configStoreis assigned synchronously on the next statement, and the first log write happens at session time. Covered by theruntime.test.ts"getConfig reads configStore.current()" assertion. - Risk: passing
logger.review/logger.debugas bare references losesthisand misfires. Mitigation: the methods are arrow-function closures overwriter/report/depswith nothisdependency; the existingdecision-reporter.tsalready passessession.loggeraround as a value. - Risk: the dedup
Setsemantics change when moved from the runtime into the logger. Mitigation: identicalSet-membership logic, identical per-runtime lifetime (one logger per factory call); the dedup tests move verbatim tosession-logger.test.ts. - Risk: a logging consumer outside grep's reach breaks when the runtime fields are removed.
Mitigation: grep is clean (
runtime.writeDebugLog/writeReviewLogonly inruntime.ts,index.ts,runtime.test.ts); Step 2 runspnpm fallow dead-codeas a backstop; Step 1's delegators keepindex.tsgreen until Step 2.
Open Questions
- Should the
writeReviewLog/writeDebugLogconsumer field names unify toreview/debug(so the logger satisfies the deps directly with no name mapping)? Deferred to #338 (the index.ts deps-bag collapse), which owns the consumer-deps churn. - Should the logger construction move from the factory into
index.ts? Deferred to #337, which dissolvesExtensionRuntimeand relocatesConfigStore+ logger construction to the composition root together.