23 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 335 | Extract a ConfigStore from the runtime free-functions |
Extract a ConfigStore from the runtime free-functions
Problem Statement
The extension's mutable config lives as a reassigned field (config) on the ExtensionRuntime god object, and its operations are free functions that take that god object as their first argument: refreshExtensionConfig(runtime, ctx), saveExtensionConfig(runtime, next, ctx), and logResolvedConfigPaths(runtime).
Because the value is reassigned on every refresh/save and the operations are free functions, every consumer captures a () => runtime.config closure to read the live value — four such closures across index.ts and the runtime factory.
Config has no owner.
This is Phase 4 Step 2 (Track B: De-god the runtime) from docs/architecture/architecture.md — the first link in the chain ConfigStore → injectable logger → dissolve runtime → collapse index.ts closures.
It is behavior-preserving.
Goals
- Introduce
src/config-store.ts— aConfigStoreclass that privately ownsconfig+lastConfigWarningand exposescurrent()/refresh(ctx?)/save(next, ctx)/logResolvedPaths(). - Convert the three
(runtime, …)config free functions intoConfigStoremethods; the runtime no longer carriesconfig/lastConfigWarning. - Give the config consumers (
PermissionSession,PermissionPrompter, the/permission-systemcommand controller) aConfigStore/ConfigReaderreference so they callstore.current()instead of capturing() => runtime.config. - Behavior-preserving: no observable change to config loading, saving, warnings, status sync, or resolved-path logging.
Non-Goals
- Moving the runtime context (
runtimeContext) ownership into the store. The roadmap deliberately scopes this store toconfig+lastConfigWarning; context unification ontoPermissionSessionis Step 4 (#337). Until then the store reads/writes the still-runtime-owned context through a narrow transitional seam. - Making the logger injectable / removing
createSessionLogger(runtime)— Step 3 (#336). - Dissolving
ExtensionRuntimeor fixing thepermissionManager/sessionRulessplit-brain — Step 4 (#337). - Collapsing the remaining
index.tsclosure bags and.bindlogging adapters into object references, and shrinking thePermissionPrompter/ command / RPC deps bags beyond the config fields — Step 5 (#338). - Any
PermissionSessiongod-object decomposition (Steps 6-8).
Background
Relevant modules:
src/runtime.ts— definesExtensionRuntime(extendsExtensionPaths+SessionState, addsconfig,lastConfigWarning,writeDebugLog,writeReviewLog), the three config free functions, and thecreateExtensionRuntime()factory.refreshExtensionConfigreads/writesruntime.runtimeContext, readsruntime.agentDir, mutatesruntime.config+runtime.lastConfigWarning, syncs status, notifies, and writes the debug log.saveExtensionConfigis self-contained on its passedExtensionCommandContext(noruntime.runtimeContextread), mutatesruntime.config+runtime.lastConfigWarning, and writes the debug log.logResolvedConfigPathsreadsruntime.permissionManager.getResolvedPolicyPaths()andruntime.runtimeContext?.cwd, then writes the review + debug logs.src/index.ts— the composition root. Four() => runtime.configclosures (prompter,sessionruntimeDeps, the command controller, and the loggergetConfiginside the factory), plus the(ctx) => refreshExtensionConfig(runtime, ctx)/() => logResolvedConfigPaths(runtime)session wrappers, the(next, ctx) => saveExtensionConfig(runtime, next, ctx)command wrapper, theshouldAutoApprove: () => shouldAutoApprovePermissionState("ask", runtime.config)forwarding dep, andrefreshExtensionConfig(runtime)(the initial pre-session refresh).src/permission-session.ts—PermissionSessionRuntimeDepscarriesrefreshExtensionConfig(ctx?),logResolvedConfigPaths(),getConfig()(pluscanRequestPermissionConfirmation+promptPermission). The session'sconfiggetter,refreshConfig, andlogResolvedConfigPathsdelegate to those three members;getInfrastructureReadDirsandgetToolPreviewLimitsreadthis.config.src/permission-prompter.ts—PermissionPrompterDeps.getConfig()feeds the yolo-mode auto-approve check.src/config-modal.ts—PermissionSystemConfigController.getConfig()+setConfig(next, ctx)(plusgetConfigPath,getComposedRules).src/session-logger.ts—createSessionLogger(runtime)readsruntime.writeDebugLog/writeReviewLog/runtimeContext; it does not readruntime.config, so moving config out does not affect it.src/permission-manager.ts—getResolvedPolicyPaths(): ResolvedPolicyPaths.
Constraints from AGENTS.md / the package skill:
- Keep schema, example config,
docs/configuration.md,README.md, and the loader aligned — not triggered here; this is a pure internal restructure with no config-format change. - Inject the new collaborator with a narrow interface type, not the concrete class, so test mocks need no
as unknown ascast (concrete class types leak private fields to the structural checker). - Do not read
process.env/getAgentDir()inside the store —agentDiris passed in. - Business logic at the edges: the store still calls the existing
loadAndMergeConfigs/loadUnifiedConfig/normalizePermissionSystemConfig/syncPermissionSystemStatus/buildResolvedConfigLogEntryfree functions (full IO injection is out of scope; the constructibility win here is config ownership and a substitutable store, not IO injection).
Why the context seam stays transitional
runtime.runtimeContext is written only by refreshExtensionConfig today and read by refreshExtensionConfig, logResolvedConfigPaths, the deprecated RPC handler (getRuntimeContext), and the runtime/session loggers' warn.
The store's refresh(ctx?) must keep setting it (so the RPC + loggers see the current context), and logResolvedPaths() must keep reading its cwd.
Because Step 4 unifies the context onto PermissionSession (not onto the store), this Step does not move context ownership into ConfigStore.
Instead the store takes a narrow RuntimeContextRef (get/set) backed by the still-runtime-owned runtimeContext field.
This get/set pair is the runtime-context seam — not one of the four () => runtime.config closures this Step removes — and it dissolves in Step 4.
Design Overview
ConfigStore and its collaborators
ConfigStore privately owns config and lastConfigWarning and holds four narrow collaborators — none of them the whole runtime:
/** Read-only view of the current config — for consumers that only read. */
export interface ConfigReader {
current(): PermissionSystemExtensionConfig;
}
/** Transitional get/set seam over the runtime-owned context (retired in Step 4 / #337). */
export interface RuntimeContextRef {
get(): ExtensionContext | null;
set(ctx: ExtensionContext): void;
}
/** Narrow logging sink — replaced by an injected logger in Step 3 (#336). */
export interface ConfigStoreLogger {
writeDebugLog(event: string, details?: Record<string, unknown>): void;
writeReviewLog(event: string, details?: Record<string, unknown>): void;
}
/** Narrow view of the manager's resolved policy paths (for logResolvedPaths). */
export interface ResolvedPolicyPathProvider {
getResolvedPolicyPaths(): ResolvedPolicyPaths;
}
export interface ConfigStoreDeps {
agentDir: string;
context: RuntimeContextRef;
policyPaths: ResolvedPolicyPathProvider;
logger: ConfigStoreLogger;
}
export class ConfigStore implements ConfigReader {
private config: PermissionSystemExtensionConfig;
private lastConfigWarning: string | null = null;
constructor(private readonly deps: ConfigStoreDeps) {
this.config = { ...DEFAULT_EXTENSION_CONFIG };
}
current(): PermissionSystemExtensionConfig { return this.config; }
refresh(ctx?: ExtensionContext): void { /* refreshExtensionConfig body */ }
save(next: PermissionSystemExtensionConfig, ctx: ExtensionCommandContext): void { /* saveExtensionConfig body */ }
logResolvedPaths(): void { /* logResolvedConfigPaths body */ }
}
Each method body is the corresponding free-function body with runtime.config / runtime.lastConfigWarning → this.config / this.lastConfigWarning, runtime.runtimeContext → this.deps.context.get() (and the one assignment → this.deps.context.set(ctx)), runtime.agentDir → this.deps.agentDir, runtime.permissionManager.getResolvedPolicyPaths() → this.deps.policyPaths.getResolvedPolicyPaths(), and runtime.writeDebugLog / writeReviewLog → this.deps.logger.*.
save is unchanged apart from the field/logging redirection — it uses its own ExtensionCommandContext, not the context seam.
Construction in the factory (this Step)
The store is constructed inside createExtensionRuntime(), where the runtime's context field, manager, and logger sink are in scope without index-level closures, and exposed as runtime.configStore.
The existing logger ↔ config temporal coupling is preserved (Step 3 fixes it):
const runtime = { ...paths, runtimeContext: null, permissionManager, /* … */,
writeDebugLog: () => {}, writeReviewLog: () => {} } as ExtensionRuntime;
const configStore = new ConfigStore({
agentDir,
context: {
get: () => runtime.runtimeContext,
set: (ctx) => { runtime.runtimeContext = ctx; },
},
policyPaths: runtime.permissionManager,
logger: {
writeDebugLog: (e, d) => runtime.writeDebugLog(e, d),
writeReviewLog: (e, d) => runtime.writeReviewLog(e, d),
},
});
runtime.configStore = configStore;
const logger = createPermissionSystemLogger({
getConfig: () => configStore.current(), // was () => runtime.config
/* … */
});
runtime.writeDebugLog = /* … */; runtime.writeReviewLog = /* … */;
The store's logger sink defers to runtime.writeDebugLog (assigned after the logger is built but before any store method runs at session time) — the same deferred-binding pattern the factory already uses.
Consumer call sites (Tell-Don't-Ask)
PermissionSession holds the store directly; its config members leave PermissionSessionRuntimeDeps:
// permission-session.ts
get config() { return this.configStore.current(); }
refreshConfig(ctx?) { this.configStore.refresh(ctx); }
logResolvedConfigPaths() { this.configStore.logResolvedPaths(); }
PermissionPrompter and the forwarding shouldAutoApprove read through ConfigReader; the command controller holds the store for current() + save().
None re-capture runtime.config.
Edge cases
refresh()with noctx→ context seam unchanged;cwd/hasUIread whatever the seam currently holds (matches today'sruntime.runtimeContextread).refresh(ctx)before any context exists →context.set(ctx)then read back — identical to the current first-assignment path.logResolvedPaths()readscontext.get()?.cwd— null before the firstrefresh(ctx), exactly as today.- Warning dedup (
lastConfigWarning) and the!warningreset move verbatim into the store's private field.
Extracted-module upstream check
config-store.ts imports only the existing config IO/reporting free functions, node:fs / node:path helpers, DEFAULT_EXTENSION_CONFIG + normalizePermissionSystemConfig (extension-config.ts), the config-paths helpers, and SDK context types — the same imports runtime.ts already has for these bodies.
It does not import runtime.ts (no cycle): the runtime imports the store, not vice versa.
No output-argument mutation is carried over — the store mutates only its own private fields; the one external write (runtimeContext) goes through the explicit RuntimeContextRef.set seam rather than reaching into a passed bag.
Module-Level Changes
src/config-store.ts(new) —ConfigStoreclass +ConfigReader,RuntimeContextRef,ConfigStoreLogger,ResolvedPolicyPathProvider,ConfigStoreDepsinterfaces. Holds the three former free-function bodies as methods pluscurrent().src/runtime.ts- Construct
ConfigStoreincreateExtensionRuntime(); addconfigStore: ConfigStoretoExtensionRuntime. - Remove
configandlastConfigWarningfromExtensionRuntime(andSessionStateif declared there); the loggergetConfigreadsconfigStore.current(). - Convert
refreshExtensionConfig/saveExtensionConfig/logResolvedConfigPathsinto thin delegators toruntime.configStoreduring migration, then delete them in the final step. - Drop config-IO imports that move solely into
config-store.tsonce the free functions are deleted.
- Construct
src/permission-session.ts- Add constructor param
configStore: ConfigStore(or a session-narrow interface); store it. - Remove
refreshExtensionConfig,logResolvedConfigPaths,getConfigfromPermissionSessionRuntimeDeps(leavingcanRequestPermissionConfirmation+promptPermission). configgetter /refreshConfig/logResolvedConfigPathsdelegate tothis.configStore.
- Add constructor param
src/permission-prompter.ts- Replace
PermissionPrompterDeps.getConfig(): Configwithconfig: ConfigReader;promptreadsthis.deps.config.current().
- Replace
src/config-modal.ts- Point
PermissionSystemConfigControllerat the store:current()+save()(replacinggetConfig/setConfig); keepgetConfigPath/getComposedRules.
- Point
src/index.ts- Pass
runtime.configStoreintoPermissionSession,PermissionPrompter(asconfig), and the command controller; remove the four() => runtime.configclosures, the two session free-function wrappers, and the commandsetConfigwrapper. shouldAutoApprovereadsruntime.configStore.current().- The initial
refreshExtensionConfig(runtime)becomesruntime.configStore.refresh().
- Pass
test/config-store.test.ts(new) — unit tests for the store with injected fakes.test/runtime.test.ts— delete therefreshExtensionConfigdescribe block (behavior now owned byconfig-store.test.ts); adjust anycreateExtensionRuntimeassertion that readruntime.config/runtime.lastConfigWarning.test/permission-session.test.ts— inject a fakeConfigStore; drop the three config members from the runtimeDeps fixture; re-point therefreshConfig/logResolvedConfigPathsdelegation assertions at the store fake.test/permission-prompter.test.ts— replace thegetConfigstub with aconfig: ConfigReaderfake.test/config-modal.test.ts— replace thegetConfig/setConfigcontroller stubs with aConfigStorefake (current/save).
Grep confirms runtime.config is read only in src/index.ts (5 lines) and src/runtime.ts; lastConfigWarning only in src/runtime.ts; the three free functions only in src/index.ts, src/runtime.ts, src/permission-session.ts, and their tests.
session-logger.test.ts's as unknown as ExtensionRuntime mock does not set config, so removing the field does not break it.
The package skill (.pi/skills/package-pi-permission-system/SKILL.md) does not name refreshExtensionConfig / saveExtensionConfig / logResolvedConfigPaths.
Doc updates: docs/architecture/architecture.md already names config-store.ts and the ConfigStore outcome in the Step 2 narrative and the module-structure list; the constructibility metrics table is a phase-start snapshot, not a live count, so no edit is required by this Step (the /retro for the phase will refresh it).
Test Impact Analysis
- New unit tests enabled:
config-store.test.tsconstructsConfigStoredirectly with plain fakes for the context seam, policy-path provider, and logger sink — noas unknown as ExtensionRuntime, novi.mock("../src/runtime"). It exercisescurrent(),refresh(ctx?)(config update, warning set/clear/dedup, status sync gated onhasUI, debug log),save(next, ctx)(success write, error notify + early return, debug log), andlogResolvedPaths()(review + debug entries from the injected policy-path provider). - Redundant tests: the
runtime.test.tsrefreshExtensionConfigdescribe block — the function under test becomes a delegator and is then deleted; its behavior is covered at the layer that now owns it. Removed in the step that turns the free functions into delegators. - Tests that must stay: the
permission-session.test.tsconfig-delegation tests (now assert delegation to the injectedConfigStorefake rather than the runtimeDeps stub — same contract, different collaborator); thepermission-prompter.test.tsyolo auto-approve tests (now via theConfigReaderfake); theconfig-modal.test.tsget/set tests (now via theConfigStorefake); thecreateExtensionRuntimepath-derivation tests (unaffected).
TDD Order
-
Add
ConfigStore+ interfaces withconfig-store.test.ts—feat:- Red:
test/config-store.test.ts— constructnew ConfigStore({ agentDir, context: fakeRef, policyPaths: fakeProvider, logger: fakeSink }); assertcurrent(),refresh(load/normalize, warning set/clear/dedup, status sync onhasUI, debug log,context.setonctx),save(write path, error-notify + early return, debug log),logResolvedPaths(review + debug entries). Mock the config-IO/status/reporter modules asruntime.test.tsdoes today. - Green: implement the class and interfaces by lifting the three free-function bodies and redirecting field/context/logger access.
- Additive: no production consumer yet (gains consumers in steps 2-5 of this plan — not a speculative export;
pnpm fallow dead-coderuns clean at plan completion). - Run
pnpm run check+config-store.test.ts. - Commit:
feat: add ConfigStore owning extension config state.
- Red:
-
Construct
ConfigStorein the factory; back runtime config with it —refactor:- Build the store in
createExtensionRuntime(); addruntime.configStore; removeconfig(replace its readers inruntime.tswithconfigStore.current(); expose a temporaryget config()getter on the runtime object so the still-unmigratedindex.tsconsumers compile) and remove thelastConfigWarningfield (no external reader). - Logger
getConfig→() => configStore.current(); the three free functions become one-line delegators toruntime.configStore. - Delete the redundant
refreshExtensionConfigblock fromruntime.test.ts. - Internal to
runtime.ts;index.tsand the other consumers are untouched this step. - Run
pnpm run check+ the full suite. - Commit:
refactor: back ExtensionRuntime config with ConfigStore.
- Build the store in
-
Inject
ConfigStoreintoPermissionSession—refactor:- Coupled step (constructor-signature change; single production call site
index.ts+ thecreateSessiontest helper). - Red:
permission-session.test.ts— inject aConfigStorefake; droprefreshExtensionConfig/logResolvedConfigPaths/getConfigfrom the runtimeDeps fixture; re-point the delegation assertions at the store fake. - Green: add the
configStoreconstructor param; delegate theconfiggetter /refreshConfig/logResolvedConfigPathsto it; remove the three members fromPermissionSessionRuntimeDeps; updateindex.tsto passruntime.configStoreand drop the two session config wrappers. - Run
pnpm run check(shared-interface change) + the full suite. - Commit:
refactor: inject ConfigStore into PermissionSession.
- Coupled step (constructor-signature change; single production call site
-
Point
PermissionPrompter(and forwarding) atConfigReader—refactor:- Coupled step (deps-interface change; single call site
index.ts+permission-prompter.test.ts). - Red:
permission-prompter.test.ts— replace thegetConfigstub with aconfig: ConfigReaderfake. - Green:
PermissionPrompterDeps.getConfig()→config: ConfigReader;promptreadsthis.deps.config.current();index.tspassesconfig: runtime.configStoreand rewritesshouldAutoApproveto readruntime.configStore.current(). - Run
pnpm run check+ the full suite. - Commit:
refactor: read config from ConfigReader in PermissionPrompter.
- Coupled step (deps-interface change; single call site
-
Point the
/permission-systemcommand at theConfigStore—refactor:- Coupled step (controller-interface change; single call site
index.ts+config-modal.test.ts). - Red:
config-modal.test.ts— replace thegetConfig/setConfigcontroller stubs with aConfigStorefake (current/save). - Green:
PermissionSystemConfigControllerreadscurrent()+save();index.tspassesruntime.configStore; drop thegetConfigclosure and thesetConfigwrapper. - Run
pnpm run check+ the full suite. - Commit:
refactor: drive the permission-system command from ConfigStore.
- Coupled step (controller-interface change; single call site
-
Remove the runtime config free functions and the transitional getter —
refactor:- All consumers now read the store; delete
refreshExtensionConfig/saveExtensionConfig/logResolvedConfigPathsand the temporaryget config()getter fromruntime.ts; change the initialrefreshExtensionConfig(runtime)inindex.tstoruntime.configStore.refresh(); drop now-unused imports. - Run
pnpm run check, the full suite, andpnpm fallow dead-codeto confirm no orphaned exports. - Commit:
refactor: remove runtime config free-functions.
- All consumers now read the store; delete
Risks and Mitigations
- Risk: the transitional
RuntimeContextRefget/set seam changes when/whereruntime.runtimeContextis written. Mitigation:setis called exactly whererefreshExtensionConfigassigned today (and only there);getreads the same field; the RPC + loggers read the unchanged field. Covered by theconfig-store.test.tscontext.setassertion and the surviving composition-root test. - Risk: removing
config/lastConfigWarningfromExtensionRuntimebreaks an importer not found by grep. Mitigation: grep acrosssrc/+test/is clean (config inindex.ts+runtime.ts; warning inruntime.tsonly); step 6 runspnpm fallow dead-codeas a backstop; the temporary getter keepsindex.tscompiling across steps 2-5. - Risk: dropping the
getConfigcallbacks surfaces incomplete mock returns the call shape previously hid. Mitigation: the consumer-test fakes return fullPermissionSystemExtensionConfigshapes via the existingDEFAULT_EXTENSION_CONFIGclone; the narrowConfigReaderonly requirescurrent(). - Risk: the logger ↔ config temporal coupling (store built before the logger that the store's sink defers to) misfires.
Mitigation: identical deferred-binding pattern to today's
writeDebugLog/writeReviewLogreassignment; store methods only run at session time, after the reassignment; Step 3 removes the coupling entirely.
Open Questions
- Should
runtimeContextownership move onto the store rather than staying a transitional seam? Deferred to Step 4 (#337), which unifies context ontoPermissionSession; owning it in the store now would pre-empt and then re-do that work. - Should the remaining
() => configStore.current()adapters (the loggergetConfig, the forwardingshouldAutoApprove) collapse to bare references? The logger one is retired in Step 3 (#336); the index-level deps-bag collapse is Step 5 (#338).