Files
my-pi/pi-permission-system/docs/plans/0336-inject-session-logger.md
T

271 lines
20 KiB
Markdown

---
issue: 336
issue_title: "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 `createSessionLogger` to take narrow dependencies — the logs directory, a config reader (for the debug/review write toggles), and a notify sink — instead of the whole `ExtensionRuntime`.
- Fold the JSONL-writer composition (`createPermissionSystemLogger`), the warning-dedup reporter, and the `warn` notify path into the single `createSessionLogger` factory, so one object owns the complete `SessionLogger` contract (`debug` / `review` / `warn`).
- Remove `writeDebugLog` / `writeReviewLog` from `ExtensionRuntime`; expose the built logger as `runtime.logger` instead.
- Remove the `runtime.writeDebugLog` / `runtime.runtimeContext?.ui.notify` reach-through from `session-logger.ts`, and drop the five `.bind(runtime)` logging adapters in `index.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 transitional `RuntimeContextRef` seam introduced in [#335]; context unification onto `PermissionSession` is Step 4 ([#337]).
- Dissolving `ExtensionRuntime` or moving the logger / `ConfigStore` construction out of the factory and into `index.ts` — Step 4 ([#337]).
The logger is built in the factory because it is mutually entangled with `ConfigStore`, which the factory owns until [#337].
- Collapsing the remaining `index.ts` deps bags and unifying the `writeReviewLog` field naming across the forwarder / prompter / RPC consumers — Step 5 ([#338]).
- Renaming the `SessionLogger` interface methods (`debug` / `review` / `warn`) or the consumer fields (`writeReviewLog` / `writeDebugLog`).
## Background
Relevant modules:
- `src/session-logger.ts` — defines the `SessionLogger` interface (`debug` / `review` / `warn`, all `void`) and `createSessionLogger(runtime)`, which delegates `debug` / `review` to `runtime.writeDebugLog` / `writeReviewLog` and `warn` to `runtime.runtimeContext?.ui.notify(message, "warning")`.
- `src/logging.ts``createPermissionSystemLogger({ getConfig, debugLogPath, reviewLogPath, ensureLogsDirectory })` returns a `PermissionSystemLogger` whose `debug` / `review` write a JSONL line (gated on `config.debugLog` / `config.permissionReviewLog`) and return a warning string on failure.
This module has no `ExtensionRuntime` reference today.
- `src/runtime.ts``createExtensionRuntime()` builds the `PermissionSystemLogger`, owns the `reportedLoggingWarnings` dedup `Set` + `reportLoggingWarning` helper (which calls `runtime.runtimeContext?.ui.notify`), and assigns `runtime.writeDebugLog` / `writeReviewLog` (the stub-then-reassign pattern).
`ConfigStore` is constructed here too, with a deferred-binding `ConfigStoreLogger` that points at `runtime.writeDebugLog` / `writeReviewLog`.
The `contextRef: RuntimeContextRef` seam (`get`/`set` over `runtime.runtimeContext`) already exists for `ConfigStore`.
- `src/index.ts` — calls `createSessionLogger(runtime)` once (passed as `PermissionSession`'s `logger`), and threads `runtime.writeReviewLog.bind(runtime)` / `runtime.writeDebugLog.bind(runtime)` into the `PermissionForwarder` logger + `writeReviewLog`, the `PermissionPrompter`, and the RPC handlers (five `.bind` sites total).
- `src/config-store.ts``ConfigStore` implements `ConfigReader` (`current()`); its `ConfigStoreLogger` dep is `{ writeDebugLog, writeReviewLog }`.
Its `refresh` / `save` / `logResolvedPaths` write through that sink.
- `src/decision-reporter.ts``GateDecisionReporter` holds a `SessionLogger` and calls `.review`; built in `index.ts` from `session.logger`.
Unchanged.
- `src/handlers/lifecycle.ts` — the sole `SessionLogger.warn` caller (`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 deps `writeReviewLog`, and `forwardingDeps.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 ExtensionRuntime` cast.
- Do not read `getAgentDir()` / `process.*` inside the factory function — `globalLogsDir` is passed in.
- Keep business logic at the edges: `createSessionLogger` composes the existing `createPermissionSystemLogger` rather than re-implementing JSONL writing.
- The package skill does not name `createSessionLogger`, `writeDebugLog`, or `writeReviewLog`, 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.
```typescript
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:
```typescript
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:
```typescript
// 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)
- `warn` is never deduplicated (explicit warnings always notify); only IO-failure warnings flow through the dedup `Set`.
- The notify sink is a no-op when `runtimeContext` is null (early-session) — `?.ui.notify` short-circuits, exactly as today.
- The dedup `Set` lives for the lifetime of the logger (one per `createExtensionRuntime` call), matching today's per-runtime `Set`.
- 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 `createSessionLogger` to accept `SessionLoggerDeps` (`globalLogsDir`, `getConfig`, `notify`); compose `createPermissionSystemLogger`, own the dedup `Set` + reporter, and implement `warn` via `notify`.
- Add the `SessionLoggerDeps` interface export.
- Drop the `import type { ExtensionRuntime }`.
- `src/runtime.ts`
- Remove `writeDebugLog` / `writeReviewLog` from the `ExtensionRuntime` interface; add `logger: SessionLogger`.
- In the factory: build the logger via `createSessionLogger({ globalLogsDir, getConfig: () => configStore.current(), notify })` before `ConfigStore`; pass `{ writeDebugLog: logger.debug, writeReviewLog: logger.review }` as the `ConfigStoreLogger`; set `runtime.logger`.
- Delete the `() => {}` stubs, the post-construction `writeDebugLog`/`writeReviewLog` reassignment, the `reportedLoggingWarnings` `Set`, `reportLoggingWarning`, and the `createPermissionSystemLogger` import (now unused here); import `createSessionLogger` + `SessionLogger` from `session-logger.ts`.
- `src/index.ts`
- Drop `import { createSessionLogger }`.
- Pass `runtime.logger` to `PermissionSession`.
- Replace the five `runtime.writeReviewLog.bind(runtime)` / `runtime.writeDebugLog.bind(runtime)` adapters with `runtime.logger.review` / `runtime.logger.debug`.
- `test/session-logger.test.ts` — rewrite for the new signature (plain fakes: `getConfig`, `notify`, a temp `globalLogsDir`); cover the toggles, the success write, the IO-failure warning + dedup, and `warn` direct-notify.
- `test/runtime.test.ts` — re-point the logger mock from `../src/logging` to `../src/session-logger` (mock `createSessionLogger`); delete the `writeDebugLog` / `writeReviewLog` delegation + dedup + notify tests (now owned by `session-logger.test.ts`); assert `runtime.logger` is the object the factory built and that `createSessionLogger` is called with a `globalLogsDir`-derived value and a `getConfig` reading `configStore.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
1. New unit tests enabled: `session-logger.test.ts` now constructs `createSessionLogger` with plain fakes — no `as 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-writable `globalLogsDir` or by asserting on a fake notify), warning deduplication, and the un-deduplicated `warn` path — all without the runtime god object.
2. Redundant tests: the `runtime.test.ts` `writeDebugLog` / `writeReviewLog` delegation, dedup, and notify tests become redundant — the behavior they covered now lives in `createSessionLogger` and is tested in `session-logger.test.ts`.
They are deleted when the runtime stops owning those methods.
3. Tests that must stay: the `runtime.test.ts` path-derivation, `piInfrastructureDirs`, default-state, and `configStore.current()` tests (unaffected); the `config-store.test.ts` logging-sink assertions (the sink contract — `{ writeDebugLog, writeReviewLog }` — is unchanged); the `logging.test.ts` / `config-reporter.test.ts` `createPermissionSystemLogger` tests (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.
1. **Inject the new `createSessionLogger`; expose `runtime.logger`**`refactor:`
- Coupled step: `createSessionLogger`'s signature changes (sole call site `index.ts` line 72), so it cannot land in isolation.
- Red: rewrite `test/session-logger.test.ts` for `SessionLoggerDeps` — assert toggle gating, success write (temp dir), IO-failure warning + dedup, and `warn` direct-notify.
- Green:
- Rewrite `createSessionLogger` to `SessionLoggerDeps` (compose the writer, own the dedup `Set`, implement `warn`).
- In `runtime.ts`, build the logger via the new factory and set `runtime.logger`; keep `writeDebugLog` / `writeReviewLog` on the runtime *as thin delegators* (`(e, d) => logger.debug(e, d)` / `review`) so `index.ts`'s `.bind` sites still compile; point the `ConfigStoreLogger` at `{ writeDebugLog: logger.debug, writeReviewLog: logger.review }`; delete the stubs, the reassignment, the dedup `Set`, and `reportLoggingWarning`.
- In `index.ts`, swap `createSessionLogger(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 in `session-logger.test.ts`) but keep `writeDebugLog`/`writeReviewLog` delegation smoke checks if still present this step.
- Run `pnpm run check` + the full suite.
- Commit: `refactor: build an injectable SessionLogger in the runtime factory`.
2. **Remove the runtime logging methods and the `.bind` adapters**`refactor:`
- Coupled step: removing the two fields breaks every `.bind` site at the type level; fold all consumer + test edits in.
- Green:
- `index.ts`: replace the five `runtime.writeReviewLog.bind(runtime)` / `runtime.writeDebugLog.bind(runtime)` with `runtime.logger.review` / `runtime.logger.debug`.
- `runtime.ts`: remove `writeDebugLog` / `writeReviewLog` from the `ExtensionRuntime` interface and the factory (the `ConfigStoreLogger` already uses `logger.debug` / `logger.review`).
- `test/runtime.test.ts`: delete the `writeDebugLog` / `writeReviewLog` tests; add/keep the `runtime.logger` assertion and the `createSessionLogger` call-args assertion.
- Run `pnpm run check`, the full suite, and `pnpm fallow dead-code` to confirm no orphaned exports (e.g., an now-unused `logging.ts` re-export).
- Commit: `refactor: drop runtime logging methods and index .bind adapters`.
## Risks and Mitigations
- Risk: the lazy `getConfig: () => configStore.current()` thunk is invoked before `configStore` is assigned, throwing a TDZ / undefined error.
Mitigation: `getConfig` is only called at log *write* time (inside `writer.debug` / `review`), never during construction; `configStore` is assigned synchronously on the next statement, and the first log write happens at session time.
Covered by the `runtime.test.ts` "getConfig reads configStore.current()" assertion.
- Risk: passing `logger.review` / `logger.debug` as bare references loses `this` and misfires.
Mitigation: the methods are arrow-function closures over `writer` / `report` / `deps` with no `this` dependency; the existing `decision-reporter.ts` already passes `session.logger` around as a value.
- Risk: the dedup `Set` semantics change when moved from the runtime into the logger.
Mitigation: identical `Set`-membership logic, identical per-runtime lifetime (one logger per factory call); the dedup tests move verbatim to `session-logger.test.ts`.
- Risk: a logging consumer outside grep's reach breaks when the runtime fields are removed.
Mitigation: grep is clean (`runtime.writeDebugLog` / `writeReviewLog` only in `runtime.ts`, `index.ts`, `runtime.test.ts`); Step 2 runs `pnpm fallow dead-code` as a backstop; Step 1's delegators keep `index.ts` green until Step 2.
## Open Questions
- Should the `writeReviewLog` / `writeDebugLog` consumer field names unify to `review` / `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 dissolves `ExtensionRuntime` and relocates `ConfigStore` + logger construction to the composition root together.
[#335]: https://github.com/gotgenes/pi-packages/issues/335
[#337]: https://github.com/gotgenes/pi-packages/issues/337
[#338]: https://github.com/gotgenes/pi-packages/issues/338