mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
---
|
||||
issue: 338
|
||||
issue_title: "Collapse the index.ts closure bags into object references"
|
||||
---
|
||||
|
||||
# Collapse the `index.ts` closure bags into object references
|
||||
|
||||
## Problem Statement
|
||||
|
||||
`index.ts` is the composition root, and it carries roughly twenty `() =>` / `.bind` adapter closures.
|
||||
These are not intrinsic wiring — they are scar tissue from the now-dissolved runtime god object.
|
||||
`() => runtime.config` thunks existed because config was mutable shared state; `runtime.writeReviewLog.bind(runtime)` adapters existed because the logging operations were free functions; `(ctx) => refreshExtensionConfig(runtime, ctx)` wrapped each runtime free-function.
|
||||
|
||||
With Phase 4 Steps 2–4 done (`ConfigStore` owns config, the logger is an injectable object, the runtime ops are methods, and `PermissionManager` / `SessionRules` are single shared instances), each consumer can now receive the real collaborator object and call its methods directly.
|
||||
The adapter closures collapse to plain object references.
|
||||
|
||||
This is Phase 4 Step 5 (Track B).
|
||||
It is behavior-preserving: only the shape of the dependency wiring changes, not what the extension does.
|
||||
|
||||
## Goals
|
||||
|
||||
- Replace the logging/config adapter closures in `index.ts` with direct references to the shared `logger`, `ConfigStore`, `PermissionManager`, `SessionRules`, and `PermissionSession` collaborators.
|
||||
- Shrink the deps interfaces on `PermissionPrompter`, `PermissionSession`, the command controller, the RPC handlers, `ConfigStore`, and `PermissionForwarder` so each accepts the collaborator object (or a narrow interface over it) instead of a bag of adapter functions.
|
||||
- Unify all logging on the single `SessionLogger` object via narrow `ReviewLogger` / `DebugReviewLogger` seam interfaces, eliminating the duplicated `writeReviewLog` field on the forwarder.
|
||||
- Keep the suite green and verify the composition root via `test/composition-root.test.ts`.
|
||||
|
||||
This is not a breaking change to any published API: every interface touched is internal to the package.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Extracting the `PromptingGateway` that owns the stored context, `canConfirm()`, and the prompt twins — that is Phase 4 Step 6 ([#339]).
|
||||
The `canRequestPermissionConfirmation` closure on `PermissionSessionRuntimeDeps` legitimately remains until then.
|
||||
- Extracting the `PermissionResolver` collaborator ([#340]) or slimming `PermissionSession` to a state/lifecycle owner ([#341]).
|
||||
- Removing the two forward-reference cycle closures in the logger construction (`getConfig`, `notify`) — see Design Overview; per project direction these stay as idiomatic forward-reference closures (the pi-subagents pattern), not setter-injected mutations.
|
||||
- Any change to log file format, config schema, or permission semantics.
|
||||
|
||||
## Background
|
||||
|
||||
Relevant modules and their current adapter-closure relationships in `src/index.ts`:
|
||||
|
||||
- `createSessionLogger({ globalLogsDir, getConfig, notify })` — the logger reads config toggles via `getConfig: () => configStore.current()` and surfaces UI warnings via `notify: (m) => sessionNotify?.getRuntimeContext()?.ui.notify(...)`.
|
||||
Both are forward-reference closures: the logger is built before `configStore` and before `session`.
|
||||
- `ConfigStore` — its `ConfigStoreLogger` dep (`{ writeDebugLog, writeReviewLog }`) is fed two arrow adapters wrapping `logger.debug` / `logger.review`.
|
||||
- `PermissionForwarder` — its deps carry `logger: ForwardedPermissionLogger` (two adapters), a duplicated top-level `writeReviewLog` adapter, and `shouldAutoApprove: () => shouldAutoApprovePermissionState("ask", configStore.current())`.
|
||||
- `PermissionPrompter` — `writeReviewLog: (e, d) => logger.review(e, d)`.
|
||||
- `PermissionSession` runtime deps — `promptPermission: (ctx, d) => prompter.prompt(ctx, d)` and `canRequestPermissionConfirmation: (ctx) => canResolveAskPermissionRequest({...})`.
|
||||
- The `/permission-system` command controller — `getConfigPath: () => getGlobalConfigPath(agentDir)` and `getComposedRules: () => permissionManager.getComposedConfigRules(session.lastKnownActiveAgentName ?? undefined)`.
|
||||
- The RPC handlers — `getPermissionManager`, `getSessionRules`, `getRuntimeContext`, and `writeReviewLog` closures.
|
||||
- `toolRegistry` — `getAll: () => pi.getAllTools()`, `setActive: (n) => pi.setActiveTools(n)`.
|
||||
- Six `pi.on(...)` event handler arrows.
|
||||
|
||||
Why the closures are now collapsible:
|
||||
|
||||
- The config-read thunks returned `configStore.current()`; consumers can hold the `ConfigStore` object (a `ConfigReader`) and call `.current()` themselves.
|
||||
- The logging adapters wrapped `logger.review` / `logger.debug` to bridge method-name mismatches and to avoid `@typescript-eslint/unbound-method`.
|
||||
Passing the `logger` object and calling `this.deps.logger.review(...)` as a method avoids the unbound-method rule (it is a method call on a stored object, not a bare value) and removes the adapter.
|
||||
- The RPC `getPermissionManager` / `getSessionRules` / `getRuntimeContext` closures returned single shared instances (after [#334] / [#337]); passing the objects and calling their methods at handle time preserves the same liveness.
|
||||
|
||||
Constraint from AGENTS.md and the package skill: keep schema/example/docs aligned (not touched here — no config change); the `session-created` handler must stay synchronous (not touched); use `#src/` path aliases for sibling imports.
|
||||
|
||||
## Design Overview
|
||||
|
||||
### The two logger cycles stay as forward-reference closures
|
||||
|
||||
The logger participates in two genuine construction cycles:
|
||||
|
||||
- Logger ↔ `ConfigStore`: the logger reads the debug/review toggles from config; `ConfigStore` writes the debug log.
|
||||
- Logger ↔ `PermissionSession`: the logger surfaces UI warnings through the session's runtime context; the session is constructed with the logger as a constructor argument.
|
||||
|
||||
Per project direction (matching the pi-subagents composition root), these are resolved with forward-reference closures that capture the not-yet-assigned variable by reference — not with setter methods on the logger and not by restructuring the toggle-read into a push model.
|
||||
So `getConfig` and `notify` remain as the only two closures in the logger construction, and the logger object itself is built before `configStore` and `session`.
|
||||
|
||||
Crucially, because the logger object is fully built first, every *other* consumer (`ConfigStore`, `PermissionForwarder`, `PermissionPrompter`, the RPC handlers, `PermissionSession`) receives the `logger` object directly — their logging adapters all collapse.
|
||||
|
||||
### Unify logging on narrow seam interfaces
|
||||
|
||||
Add two narrow interfaces to `session-logger.ts` so consumers depend only on what they use (ISP), and `SessionLogger` satisfies all of them:
|
||||
|
||||
```typescript
|
||||
export interface ReviewLogger {
|
||||
review(event: string, details?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface DebugReviewLogger extends ReviewLogger {
|
||||
debug(event: string, details?: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface SessionLogger extends DebugReviewLogger {
|
||||
warn(message: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
Consumers depend on the narrowest slice:
|
||||
|
||||
- `PermissionPrompter`, RPC handlers → `ReviewLogger`.
|
||||
- `ConfigStore`, `PermissionForwarder` (and `forwarded-permissions/io.ts`) → `DebugReviewLogger`.
|
||||
|
||||
This lets `index.ts` pass the one `logger` object everywhere with zero adapters.
|
||||
`ConfigStoreLogger` is deleted; `ForwardedPermissionLogger` is replaced by `DebugReviewLogger` (its `writeReviewLog` / `writeDebugLog` methods rename to `review` / `debug`).
|
||||
|
||||
### Resulting deps shapes
|
||||
|
||||
```typescript
|
||||
// config-store.ts
|
||||
interface ConfigStoreDeps {
|
||||
agentDir: string;
|
||||
policyPaths: ResolvedPolicyPathProvider;
|
||||
logger: DebugReviewLogger; // was ConfigStoreLogger
|
||||
}
|
||||
|
||||
// permission-prompter.ts
|
||||
interface PermissionPrompterDeps {
|
||||
config: ConfigReader;
|
||||
logger: ReviewLogger; // was writeReviewLog(event, details)
|
||||
events: PermissionEventBus;
|
||||
forwarder: ApprovalRequester;
|
||||
}
|
||||
|
||||
// forwarded-permissions/permission-forwarder.ts
|
||||
interface PermissionForwarderDeps {
|
||||
forwardingDir: string;
|
||||
subagentSessionsDir: string;
|
||||
registry?: SubagentSessionRegistry;
|
||||
events?: PermissionEventBus;
|
||||
logger: DebugReviewLogger; // merges old logger + duplicated writeReviewLog
|
||||
requestPermissionDecisionFromUi: /* unchanged bare function */;
|
||||
config: ConfigReader; // was shouldAutoApprove: () => boolean
|
||||
}
|
||||
|
||||
// permission-event-rpc.ts
|
||||
interface PermissionRpcDeps {
|
||||
permissionManager: Pick<PermissionManager, "checkPermission">; // was getPermissionManager()
|
||||
sessionRules: Pick<SessionRules, "getRuleset">; // was getSessionRules()
|
||||
session: { getRuntimeContext(): ExtensionContext | null }; // was getRuntimeContext()
|
||||
requestPermissionDecisionFromUi: /* unchanged */;
|
||||
logger: ReviewLogger; // was writeReviewLog
|
||||
}
|
||||
|
||||
// config-modal.ts
|
||||
interface PermissionSystemConfigController {
|
||||
config: CommandConfigStore;
|
||||
configPath: string; // was getConfigPath(): string
|
||||
permissionManager: { getComposedConfigRules(agentName?: string): Ruleset };
|
||||
session: { readonly lastKnownActiveAgentName: string | null };
|
||||
}
|
||||
|
||||
// permission-session.ts
|
||||
interface PermissionSessionRuntimeDeps {
|
||||
canRequestPermissionConfirmation(ctx: ExtensionContext): boolean; // kept (Step 6 / #339)
|
||||
prompter: PermissionPrompterApi; // was promptPermission(ctx, details)
|
||||
}
|
||||
```
|
||||
|
||||
`requestPermissionDecisionFromUi` is already passed as a bare imported function reference (not a closure) in both the forwarder and RPC deps; it stays.
|
||||
|
||||
### Consumer call-site sketches (verify Tell-Don't-Ask / LoD)
|
||||
|
||||
The command controller computes the composed rules from the two injected references rather than receiving a pre-bound thunk:
|
||||
|
||||
```typescript
|
||||
// config-modal.ts handleArgs, "show" branch
|
||||
const rules = controller.permissionManager.getComposedConfigRules(
|
||||
controller.session.lastKnownActiveAgentName ?? undefined,
|
||||
);
|
||||
```
|
||||
|
||||
The RPC check handler asks each injected collaborator directly:
|
||||
|
||||
```typescript
|
||||
const sessionRules = deps.sessionRules.getRuleset();
|
||||
const result = deps.permissionManager.checkPermission(surface, input, agentName ?? undefined, sessionRules);
|
||||
// ...
|
||||
deps.logger.review("permission_request.rpc_prompt", { ... });
|
||||
```
|
||||
|
||||
`PermissionForwarder` reads config and logs through its own injected objects (the duplicated review field is gone):
|
||||
|
||||
```typescript
|
||||
if (shouldAutoApprovePermissionState("ask", this.config.current())) {
|
||||
this.logger.review("forwarded_permission.auto_approved", details);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
These are all single-level method calls on injected collaborators (ask the object for what you need), not reach-through chains.
|
||||
|
||||
### `index.ts` closure budget after this step
|
||||
|
||||
| Closure / adapter | Count | Disposition |
|
||||
| ------------------------------------------ | ----- | ------------------------------------------------------------------ |
|
||||
| `pi.on(...)` handlers | 6 | Legitimate event wiring (permanent) |
|
||||
| `toolRegistry` `getAll` / `setActive` | 2 | Legitimate SDK adapter (permanent) |
|
||||
| logger `getConfig` / `notify` | 2 | Forward-reference cycle closures (permanent; pi-subagents pattern) |
|
||||
| session `canRequestPermissionConfirmation` | 1 | Transitional — removed by Step 6 ([#339]) |
|
||||
|
||||
So `index.ts` drops from ~20 to 11 (10 after Step 6).
|
||||
The roadmap's "≤ 8" target assumed the two logger cycle closures would also collapse; with the no-setter / forward-reference-closure direction they remain as the idiomatic floor.
|
||||
The architecture metric and Step 5 outcome note are updated to record this (20 → 11) rather than leaving the optimistic ≤ 8.
|
||||
|
||||
### Design-review checklist
|
||||
|
||||
| Smell | Location | Finding | Resolution |
|
||||
| ------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
||||
| Dependency width | every consumer deps bag | Adapter-function fields replaced by collaborator objects; forwarder loses `writeReviewLog` + `shouldAutoApprove` | Narrower, object-based deps |
|
||||
| LoD violation | logger `notify` chain `session.getRuntimeContext().ui.notify` | Reach-through remains inside the kept cycle closure | Out of scope; addressed when prompting/context ownership moves (Steps 6/8) |
|
||||
| Output arguments | none | No writes back into received deps | — |
|
||||
| Parameter relay | adapter closures relaying to one method | Removed by passing the object | Fixed here |
|
||||
| Test mock depth | consumer tests | Bare-function mocks become `{ review: vi.fn() }` object mocks; no new casts | Improved |
|
||||
| Missing abstraction | scattered logging adapters | The shared `SessionLogger` object + `ReviewLogger` / `DebugReviewLogger` seams | Introduced here |
|
||||
|
||||
## Module-Level Changes
|
||||
|
||||
- `src/session-logger.ts` — add `ReviewLogger` and `DebugReviewLogger`; make `SessionLogger extends DebugReviewLogger`.
|
||||
- `src/config-store.ts` — `ConfigStoreDeps.logger: DebugReviewLogger`; delete the `ConfigStoreLogger` interface; change internal `this.deps.logger.writeDebugLog` / `writeReviewLog` calls to `.debug` / `.review`.
|
||||
- `src/permission-prompter.ts` — replace `writeReviewLog` dep with `logger: ReviewLogger`; `writeReviewEntry` calls `this.deps.logger.review(...)`.
|
||||
- `src/permission-event-rpc.ts` — reshape `PermissionRpcDeps` to `{ permissionManager, sessionRules, session, requestPermissionDecisionFromUi, logger }`; update `handleCheckRpc` / `handlePromptRpc` bodies (`deps.getPermissionManager()` → `deps.permissionManager`, `deps.getSessionRules()` → `deps.sessionRules.getRuleset()`, `deps.getRuntimeContext()` → `deps.session.getRuntimeContext()`, `deps.writeReviewLog` → `deps.logger.review`).
|
||||
- `src/forwarded-permissions/io.ts` — rename `ForwardedPermissionLogger` to use `review` / `debug` (alias to `DebugReviewLogger` imported from `#src/session-logger`); update the 4 internal `logger?.writeReviewLog` / `writeDebugLog` calls.
|
||||
- `src/forwarded-permissions/permission-forwarder.ts` — merge `logger` + the duplicated `writeReviewLog` into one `logger: DebugReviewLogger`; replace `shouldAutoApprove` with `config: ConfigReader`; update internals (`this.writeReviewLog(...)` → `this.logger.review(...)`, `this.shouldAutoApprove()` → `shouldAutoApprovePermissionState("ask", this.config.current())`); import `shouldAutoApprovePermissionState` and `ConfigReader`.
|
||||
- `src/config-modal.ts` — `PermissionSystemConfigController`: replace `getConfigPath(): string` with `configPath: string`; replace optional `getComposedRules?()` with `permissionManager` + `session` narrow refs; `handleArgs` computes the composition inline.
|
||||
- `src/permission-session.ts` — `PermissionSessionRuntimeDeps`: replace `promptPermission` with `prompter: PermissionPrompterApi`; `prompt()` calls `this.runtimeDeps.prompter.prompt(ctx, details)`.
|
||||
- `src/index.ts` — collapse the logging/config/rule adapter closures into direct `logger` / `configStore` / `permissionManager` / `sessionRules` / `session` / `prompter` references; precompute `configPath: getGlobalConfigPath(agentDir)`; keep the two logger cycle closures, `canRequestPermissionConfirmation`, `toolRegistry`, and the six `pi.on` handlers.
|
||||
- `docs/architecture/architecture.md` — update the `index.ts` closures + `.bind` adapters metric (20 → 11 with the budget breakdown) and the Step 5 outcome note; the `✓ complete` mark is added during shipping per the package skill.
|
||||
- Tests updated alongside their consumers (see TDD Order): `test/config-store.test.ts`, `test/permission-prompter.test.ts`, `test/permission-event-rpc.test.ts`, `test/forwarded-permissions/io.test.ts`, `test/permission-forwarder.test.ts`, `test/config-modal.test.ts`, `test/permission-session.test.ts`, `test/composition-root.test.ts`.
|
||||
|
||||
`ConfigStoreLogger` and `ForwardedPermissionLogger` are referenced only in historical `docs/plans/` and `docs/retro/` files (and not in `.pi/skills/`); historical docs are left untouched.
|
||||
|
||||
## Test Impact Analysis
|
||||
|
||||
1. New tests enabled — minimal; this is interface reshaping, not new extraction.
|
||||
Consumer unit tests now build simple object mocks (`{ review: vi.fn() }`, `{ debug: vi.fn(), review: vi.fn() }`, `{ getRuleset: vi.fn() }`) instead of bare-function mocks, which is closer to how production wires them and removes a small amount of indirection.
|
||||
2. Redundant tests — none become redundant; no test is deleted.
|
||||
Existing assertions on `writeReviewLog(...)` calls migrate to `logger.review(...)` on the object mock.
|
||||
3. Tests that must stay — all behavioral tests for the forwarder, prompter, RPC, config-store, config-modal, and session continue to exercise the same behavior through the reshaped deps.
|
||||
`test/composition-root.test.ts` (handler-registration completeness, single-source-of-truth, teardown, subagent registry sharing) is the primary behavior-preserving guard and stays green throughout; extend it to assert the command and RPC paths operate against the injected objects.
|
||||
|
||||
## TDD Order
|
||||
|
||||
Each cycle reshapes one consumer's deps interface and folds its test updates and the matching `index.ts` wiring change into the same commit, because an interface-shape change breaks the consumer's construction at the type level immediately (AGENTS.md rule).
|
||||
|
||||
1. `refactor:` add narrow logger seams and migrate `ConfigStore`.
|
||||
Surface: `test/config-store.test.ts`.
|
||||
Add `ReviewLogger` / `DebugReviewLogger` to `session-logger.ts`; change `ConfigStoreDeps.logger` to `DebugReviewLogger`; delete `ConfigStoreLogger`; update internal calls; update the 7 test mock references (`{ writeDebugLog, writeReviewLog }` → `{ debug, review }`); pass `logger` to `ConfigStore` in `index.ts` (removes 2 closures).
|
||||
Commit: `refactor: pass the session logger directly to ConfigStore`.
|
||||
2. `refactor:` migrate `PermissionPrompter`.
|
||||
Surface: `test/permission-prompter.test.ts`.
|
||||
Replace `writeReviewLog` dep with `logger: ReviewLogger`; update the ~28 test references and assertions; pass `logger` in `index.ts` (removes 1 closure).
|
||||
Commit: `refactor: inject the session logger into PermissionPrompter`.
|
||||
3. `refactor:` migrate the RPC handlers.
|
||||
Surface: `test/permission-event-rpc.test.ts`, `test/composition-root.test.ts`.
|
||||
Reshape `PermissionRpcDeps` to object references; update handler bodies and test mocks; pass `permissionManager` / `sessionRules` / `session` / `logger` in `index.ts` (removes 4 closures); add a composition-root assertion that an RPC check resolves against the injected manager + rules.
|
||||
Commit: `refactor: inject collaborators into the permission RPC handlers`.
|
||||
4. `refactor:` migrate `PermissionForwarder` and the forwarding IO logger.
|
||||
Surface: `test/forwarded-permissions/io.test.ts`, `test/permission-forwarder.test.ts`.
|
||||
Rename `ForwardedPermissionLogger` → `DebugReviewLogger` (4 `io.ts` calls + 12 io-test refs + 2 forwarder-test refs); merge `logger` + duplicated `writeReviewLog` into one `logger`; replace `shouldAutoApprove` with `config: ConfigReader`; update forwarder internals; pass `logger` + `configStore` in `index.ts` (removes 4 closures).
|
||||
Commit: `refactor: inject the session logger and config reader into PermissionForwarder`.
|
||||
5. `refactor:` migrate the `/permission-system` command controller.
|
||||
Surface: `test/config-modal.test.ts`.
|
||||
Replace `getConfigPath()` with `configPath: string` and `getComposedRules?()` with `permissionManager` + `session` narrow refs; compute the composition inline in `handleArgs`; update the test controller mocks; pass `configPath` + objects in `index.ts` (removes 2 closures).
|
||||
Commit: `refactor: pass config path and rule sources to the command controller`.
|
||||
6. `refactor:` migrate `PermissionSession` prompting dep.
|
||||
Surface: `test/permission-session.test.ts`, `test/composition-root.test.ts`.
|
||||
Replace `promptPermission` with `prompter: PermissionPrompterApi`; `prompt()` delegates to `this.runtimeDeps.prompter.prompt`; update the session test mock; pass the `prompter` object in `index.ts` (removes 1 closure); leave `canRequestPermissionConfirmation` for Step 6.
|
||||
Commit: `refactor: inject the prompter into PermissionSession as an object reference`.
|
||||
7. `docs:` update the architecture roadmap.
|
||||
Update the `index.ts` closures metric (20 → 11 with the budget breakdown) and the Step 5 outcome note explaining the retained cycle closures.
|
||||
Commit: `docs: update architecture metrics for collapsed index.ts wiring (#338)`.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Import cycle from the forwarder importing `yolo-mode` and `ConfigReader`.
|
||||
Mitigation: verified `yolo-mode.ts` imports only `extension-config` and `types`, and `config-store.ts` does not import the forwarder — no cycle.
|
||||
Re-run `pnpm run check` after cycle 4.
|
||||
- The `ForwardedPermissionLogger` rename ripples through `io.ts` and two test files.
|
||||
Mitigation: only 4 internal `io.ts` call sites; the rename is mechanical and confined to cycle 4 with its tests in the same commit.
|
||||
- `@typescript-eslint/unbound-method` re-triggering.
|
||||
Mitigation: consumers call `this.deps.logger.review(...)` as a method on a stored object (not a bare reference), which the rule allows; no arrow wrappers are reintroduced.
|
||||
- Behavior drift in the RPC / command paths (they now call methods on objects instead of pre-bound thunks).
|
||||
Mitigation: the objects are the same single shared instances the thunks returned (post-[#334] / [#337]); `composition-root.test.ts` asserts the end-to-end paths.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Whether to add `globalConfigPath` to `ExtensionPaths` rather than recomputing `getGlobalConfigPath(agentDir)` for the command's `configPath`.
|
||||
Deferred: recomputing once at construction is a value reference, not a closure, and adding it to `ExtensionPaths` is out of scope for this step.
|
||||
|
||||
[#334]: https://github.com/gotgenes/pi-packages/issues/334
|
||||
[#337]: https://github.com/gotgenes/pi-packages/issues/337
|
||||
[#339]: https://github.com/gotgenes/pi-packages/issues/339
|
||||
[#340]: https://github.com/gotgenes/pi-packages/issues/340
|
||||
[#341]: https://github.com/gotgenes/pi-packages/issues/341
|
||||
Reference in New Issue
Block a user