15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 362 | Convert `createSessionLogger` factory into a `SessionLogger` class |
Convert the createSessionLogger factory into a PermissionSessionLogger class
Problem Statement
createSessionLogger(deps) (src/session-logger.ts) returns an object literal that closes over a mutable reported: Set<string> (the IO-failure-warning dedup) plus the composed JSONL writer.
This is a bag of state and closures masquerading as a factory — the exact pattern Phase 3 and Phase 4 converted everywhere else.
fallow cannot see the smell because the mutable Set is hidden inside the closure, so the syntactic surface stays clean (health 76, 0% dead exports) while the design smell persists.
This is Phase 5 Step 1 (Track A: logger state + composition-root coupling) from docs/architecture/architecture.md.
It is the foundation of Track A — Step 2 (#363) dissolves the index.ts forward-reference construction cycle that the old factory forced, and depends on this reshape.
The change is behavior-preserving.
Goals
- Replace the
createSessionLoggerfactory with aPermissionSessionLoggerclass that privately ownsreported(the dedupSet) and the composedPermissionSystemLoggerwriter, and implements the existingSessionLoggerinterface (debug/review/warn). - Construct it as
new PermissionSessionLogger(deps)at the sole production call site (src/index.ts) and intest/session-logger.test.ts. - Behavior-preserving: the dedup semantics, the config-toggle gating, and the notify routing are unchanged.
Non-Goals
- Dissolving the
index.tsforward-reference cycle (let configStore = null as unknown as ConfigStore, thelet sessionNotifyholder, and thegetRuntimeContext()?.ui.notifyreach-through). That is Step 2 (#363); this plan keeps the construction order and thenotifyclosure exactly as they are today. - Dropping the relay-only
loggerfield fromPermissionSessionor injecting the logger directly into the lifecycle handler / reporter — Step 3 (#364). - Renaming the
SessionLogger/DebugReviewLogger/ReviewLoggerinterfaces or their methods (debug/review/warn). These are the injection seams the consumers depend on and they stay untouched. - Changing the
PermissionSystemLoggerJSONL writer (logging.ts) or any consumer-side logger wiring (ConfigStore,PermissionForwarder,PermissionPrompter, RPC handlers,GateDecisionReporter,lifecycle.ts).
Background
Relevant modules:
src/session-logger.ts— declares the three narrowing seams (ReviewLogger { review },DebugReviewLogger extends ReviewLogger { debug },SessionLogger extends DebugReviewLogger { warn }), theSessionLoggerDepsinterface (globalLogsDir,getConfig,notify), and thecreateSessionLogger(deps)factory. The factory composescreatePermissionSystemLogger, owns thereporteddedupSetplus areportOnceclosure, and returns an object literal whosedebug/reviewroute IO-failure warnings throughreportOnceand whosewarncallsdeps.notifydirectly.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. Exports thePermissionSystemLoggerinterface — this plan adds a type import for the new private field.src/index.ts— the composition root. Builds the logger viacreateSessionLogger({ globalLogsDir, getConfig: () => configStore.current(), notify: (message) => sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning") })and injects the resulting object intoConfigStore,PermissionForwarder,PermissionPrompter, the RPC handlers,PermissionSession, and (assession.logger)GateDecisionReporter.- Consumers all store the injected logger object and invoke
this.logger.review(...)/.debug(...)/.warn(...)on it (decision-reporter.ts,config-store.ts,permission-prompter.ts,permission-forwarder.ts,permission-event-rpc.ts,handlers/lifecycle.ts). None destructure the logger or pass a barelogger.reviewreference.
Constraints from AGENTS.md / the package skill:
- The codebase convention is
interface(the seam) + a distinctly-named concreteclass:DecisionReporter→GateDecisionReporter,PermissionsService→LocalPermissionsService,ScopedPermissionManager→PermissionManager. TheSessionLoggerinterface is the widely-injected seam and must stay; the class therefore takes a distinct, domain-qualified name —PermissionSessionLogger(mirroringPermissionServiceLifecycle/PermissionForwarder/PermissionResolver). - Class collaborators use TS
private readonlyfields, matchingGateDecisionReporter(private readonly logger: SessionLogger). - Do not read
process.*/getAgentDir()inside the class —globalLogsDirarrives viaSessionLoggerDeps. - The package skill does not reference
createSessionLogger, so no skill edit is required (grep-confirmed against.pi/skills/package-pi-permission-system/SKILL.md).
Design Overview
The PermissionSessionLogger class
The class is a one-for-one reshape of the factory: the constructor composes the JSONL writer and seeds the dedup Set; the three methods carry the same bodies the object-literal closures had today.
Because the SessionLogger interface is unchanged, every consumer keeps injecting the same seam.
import type { PermissionSystemLogger } from "./logging";
export class PermissionSessionLogger implements SessionLogger {
private readonly writer: PermissionSystemLogger;
private readonly reported = new Set<string>();
private readonly notify: (message: string) => void;
constructor(deps: SessionLoggerDeps) {
this.writer = createPermissionSystemLogger({
getConfig: deps.getConfig,
debugLogPath: join(deps.globalLogsDir, DEBUG_LOG_FILENAME),
reviewLogPath: join(deps.globalLogsDir, REVIEW_LOG_FILENAME),
ensureLogsDirectory: () =>
ensurePermissionSystemLogsDirectory(deps.globalLogsDir),
});
this.notify = deps.notify;
}
debug(event: string, details?: Record<string, unknown>): void {
const warning = this.writer.debug(event, details);
if (warning) this.reportOnce(warning);
}
review(event: string, details?: Record<string, unknown>): void {
const warning = this.writer.review(event, details);
if (warning) this.reportOnce(warning);
}
warn(message: string): void {
this.notify(message);
}
private reportOnce(warning: string): void {
if (this.reported.has(warning)) return;
this.reported.add(warning);
this.notify(warning);
}
}
SessionLoggerDeps, the three seam interfaces, and the module's existing imports (join, DEBUG_LOG_FILENAME / REVIEW_LOG_FILENAME, ensurePermissionSystemLogsDirectory + PermissionSystemExtensionConfig, createPermissionSystemLogger) all stay; the only new import is the PermissionSystemLogger type, for the private writer field.
Construction at the composition root
index.ts swaps the factory call for new, leaving the dependency expressions byte-for-byte identical:
const logger = new PermissionSessionLogger({
globalLogsDir: paths.globalLogsDir,
getConfig: () => configStore.current(),
notify: (message) =>
sessionNotify?.getRuntimeContext()?.ui.notify(message, "warning"),
});
The let configStore = null as unknown as ConfigStore forward reference and the let sessionNotify holder remain — dissolving them is #363's job and depends on this step landing first.
this-binding safety
The factory returned arrow-function closures (no this), so the #336 design noted consumers could pass logger.review as a bare reference.
A class's instance methods are this-sensitive, so this would be a regression risk — but every consumer invokes the logger through its stored object reference (this.logger.review(...), this.deps.logger.debug(...)), never as a bare value (grep-confirmed across all six consumers).
Object-reference invocation preserves this, so no .bind is needed and @typescript-eslint/unbound-method is not triggered (it fires only on bare method references, which do not exist here).
Edge cases (all preserved)
warnis never deduplicated; only IO-failure warnings flow throughreportOnce.- The notify sink is a no-op when
sessionNotify/runtimeContextis null (early-session) — the optional chain short-circuits, exactly as today. - The dedup
Setlives for the lifetime of the instance (one pernew PermissionSessionLogger), matching the former per-factory-callSet. - The debug/review toggles are read at write time via the
getConfigthunk, so a mid-session config reload changes logging behavior with no rebuild — unchanged.
Module-Level Changes
src/session-logger.ts- Replace the
createSessionLoggerfunction withexport class PermissionSessionLogger implements SessionLogger(constructor composes the writer + seeds the dedupSet;debug/review/warnmethods; privatereportOnce). - Add
import type { PermissionSystemLogger } from "./logging". - Keep the
SessionLoggerDepsinterface and the three seam interfaces unchanged.
- Replace the
src/index.ts- Change the import from
createSessionLoggertoPermissionSessionLogger. - Change
createSessionLogger({...})tonew PermissionSessionLogger({...}); leave the dependency object and the surrounding forward-reference wiring untouched.
- Change the import from
test/session-logger.test.ts- Change the import from
createSessionLoggertoPermissionSessionLogger. - Change every
createSessionLogger(deps)tonew PermissionSessionLogger(deps)(mechanical, ~10 call sites). - Rename the top-level
describe("createSessionLogger", …)todescribe("PermissionSessionLogger", …). - Assertions are unchanged — behavior is preserved.
- Change the import from
docs/architecture/architecture.md- Update the
session-logger.tslayout line (currently "SessionLogger interface + createSessionLogger(deps) factory; …") to describe thePermissionSessionLoggerclass, and append[#362]to the file's reference-link definitions.
- Update the
Grep confirms createSessionLogger appears only in src/session-logger.ts (def), src/index.ts (sole call), and test/session-logger.test.ts; SessionLoggerDeps only in those same files.
The SessionLogger / DebugReviewLogger / ReviewLogger interfaces are unchanged, so decision-reporter.ts, config-store.ts, permission-prompter.ts, permission-forwarder.ts, permission-event-rpc.ts, handlers/lifecycle.ts, and the makeLogger test fixture need no edits.
The Phase 5 health-metrics table and the roadmap Step 1 prose are phase-start snapshots, not live counts (per the #336 convention); the ✓ complete mark on the roadmap step is appended at ship time, not during planning.
Test Impact Analysis
- New unit tests enabled: none.
This is a behavior-preserving reshape of the same surface (
debug/review/warnover the same deps), not an extraction that exposes a previously-untestable seam.test/session-logger.test.tsalready constructs the logger from plain fakes (getConfig,notify, a tempglobalLogsDir) with no casts, and that remains true withnew PermissionSessionLogger(deps). - Redundant tests: none.
No lower-level test supersedes an existing one; the existing toggle-gating, success-write, IO-failure-dedup, and un-deduplicated-
warncases all stay and exercise the same behavior through the new constructor. - Tests that must stay as-is: all of
test/session-logger.test.ts(the construction expression changes; the assertions do not).logging.test.ts(the JSONL writer is unchanged and still composed by the class) and every consumer test (the injected seam is unchanged) are unaffected.
TDD Order
This is a single behavior-preserving refactor.
The export changes name and call form (createSessionLogger(x) → new PermissionSessionLogger(x)), which breaks the sole production call site and the test file at the type level together — so per the "removing an export breaks all importers in one commit" rule, the class, the index.ts call-site update, and the test-construction updates land in one step.
- Convert the factory to
PermissionSessionLogger—refactor:- Red: in
test/session-logger.test.ts, change the import toPermissionSessionLogger, rewrite everycreateSessionLogger(deps)tonew PermissionSessionLogger(deps), and rename the top-leveldescribe. The suite fails to compile because the class does not exist yet. - Green:
- In
session-logger.ts, replacecreateSessionLoggerwith thePermissionSessionLoggerclass (constructor composes the writer + seeds the dedupSet;debug/review/warn+ privatereportOnce); add thePermissionSystemLoggertype import. - In
index.ts, swap the import and the construction expression tonew PermissionSessionLogger({...}). - Update the
docs/architecture/architecture.mdsession-logger.tslayout line.
- In
- Run
pnpm run check, the full test suite, andpnpm fallow dead-code(confirm no orphanedcreateSessionLoggerexport remains and no new dead export appears). - Commit:
refactor: convert createSessionLogger factory to PermissionSessionLogger class.
- Red: in
Risks and Mitigations
- Risk: a class instance method loses
thiswhen a consumer passeslogger.reviewas a bare reference (a regression from the former arrow-closure object). Mitigation: grep-confirmed that all six consumers invoke the logger through its stored object reference, never as a bare value; object-reference calls preservethis.pnpm run check(which runs@typescript-eslint/unbound-method) is the backstop — it fires on any bare method reference. - Risk: the
getConfig: () => configStore.current()thunk runs beforeconfigStoreis assigned and throws. Mitigation: unchanged from today —getConfigis invoked only at log-write time insidewriter.debug/review, never during construction;configStoreis assigned on the next statement. This plan does not touch that ordering. - Risk: the dedup
Setsemantics shift when moved from a closure into a private field. Mitigation: identical membership logic, identical per-instance lifetime (oneSetpernew PermissionSessionLogger, matching one per former factory call); the existing dedup tests pass unchanged. - Risk: a hidden consumer breaks when the
createSessionLoggerexport is removed. Mitigation: grep is clean (three files only); the one-commit fold keeps every importer green;pnpm fallow dead-codeis the backstop.
Open Questions
- None.
The class name (
PermissionSessionLogger) was resolved during planning against the package's interface/class naming convention. The forward-reference cycle that the old factory forced is intentionally left in place for #363.