mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
188 lines
14 KiB
Markdown
188 lines
14 KiB
Markdown
---
|
|
issue: 315
|
|
issue_title: "Introduce a PermissionForwarder collaborator that owns forwarding state"
|
|
---
|
|
|
|
# Introduce a PermissionForwarder collaborator
|
|
|
|
## Problem Statement
|
|
|
|
The forwarding subsystem is half-converted to a class-based design.
|
|
The polling *lifecycle* already has an owner (`ForwardingManager`), but the forwarding *behavior* still lives as three free functions in `src/forwarded-permissions/polling.ts` (`confirmPermission`, `waitForForwardedPermissionApproval`, `processForwardedPermissionRequests`).
|
|
Each of those functions reaches into an 8-member `PermissionForwardingDeps` bag, and that bag is assembled in two places: once in `index.ts` and again, independently and with divergent values, in `PermissionPrompter.buildForwardingDeps()`.
|
|
That is an anemic design — the forwarding state has no owner, so callers thread a bag and reach into it instead of telling an object what to do.
|
|
|
|
This issue is the first of a three-step lift-and-shift: introduce the class (this issue), fold the prompter's duplicate bag into it ([#316]), then inline the polling logic and delete the interface ([#317]).
|
|
|
|
## Goals
|
|
|
|
- Add a `PermissionForwarder` class that owns the forwarding dependency set and exposes two behavior methods: `requestApproval` and `processInbox`.
|
|
- Wire `ForwardingManager` to tell a `PermissionForwarder` (`forwarder.processInbox(ctx)` per tick) instead of threading a `PermissionForwardingDeps` bag.
|
|
- Construct exactly one forwarder instance in `index.ts` and inject it into `ForwardingManager`.
|
|
- Preserve behavior exactly — the methods delegate to the existing `polling.ts` free functions this issue (lift-and-shift, not behavior change).
|
|
|
|
## Non-Goals
|
|
|
|
- Do **not** touch `PermissionPrompter.buildForwardingDeps()` or its second `PermissionForwardingDeps` synthesis — that is [#316].
|
|
- Do **not** inline the polling-function bodies into the forwarder or delete the `PermissionForwardingDeps` interface — that is [#317].
|
|
- Do **not** change the `confirmPermission` / `processForwardedPermissionRequests` signatures; they keep accepting `PermissionForwardingDeps` so the prompter (untouched this issue) still calls them directly.
|
|
- Do **not** change the `polling.ts` module or its `permission-forwarding.test.ts` coverage — the free functions are unchanged.
|
|
|
|
## Background
|
|
|
|
Relevant modules:
|
|
|
|
- `src/forwarded-permissions/polling.ts` — declares `PermissionForwardingDeps` (8 members) and the three free functions.
|
|
`confirmPermission(ctx, message, deps, options?, forwarded?)` returns `Promise<PermissionPromptDecision>`; it branches UI-present vs. subagent-forwarding and delegates to `waitForForwardedPermissionApproval`.
|
|
`processForwardedPermissionRequests(ctx, deps)` returns `Promise<void>` and drains the parent's request inbox.
|
|
- `src/forwarding-manager.ts` — `ForwardingManager` owns the poll timer, current context, and processing lock.
|
|
Today its constructor takes `(subagentSessionsDir, forwardingDeps: PermissionForwardingDeps, registry?)` and the tick calls `processForwardedPermissionRequests(this.context, this.forwardingDeps)`.
|
|
It already exposes a narrow `ForwardingController` interface (`start`/`stop`) that `PermissionSession` depends on — the package's established convention for collaborator seams.
|
|
- `src/index.ts` — the composition root assembles the `forwardingDeps` bag literal and threads it into `new ForwardingManager(...)`.
|
|
|
|
Constraints from AGENTS.md and the loaded skills:
|
|
|
|
- ES2024 target; pnpm only.
|
|
- Import siblings via `#src/` / `#test/` path aliases, not relative paths.
|
|
- When a shared interface references a collaborator, use a **narrow interface type**, not the concrete class — so test mocks need no casts (code-design / design-review).
|
|
- Lift-and-shift sequencing: introduce-new-alongside-old, remove-old-last (architecture roadmap, Phase 3, Step 2).
|
|
|
|
## Design Overview
|
|
|
|
### The collaborator
|
|
|
|
`PermissionForwarder` is the missing owner for the forwarding dependency set.
|
|
For this lift-and-shift step it holds the existing `PermissionForwardingDeps` bag privately and delegates each method to the matching free function:
|
|
|
|
```typescript
|
|
/** Narrow seam: what ForwardingManager needs from the forwarder. */
|
|
export interface InboxProcessor {
|
|
processInbox(ctx: ExtensionContext): Promise<void>;
|
|
}
|
|
|
|
export class PermissionForwarder implements InboxProcessor {
|
|
constructor(private readonly deps: PermissionForwardingDeps) {}
|
|
|
|
requestApproval(
|
|
ctx: ExtensionContext,
|
|
message: string,
|
|
options?: RequestPermissionOptions,
|
|
forwarded?: ForwardedPromptDisplay,
|
|
): Promise<PermissionPromptDecision> {
|
|
return confirmPermission(ctx, message, this.deps, options, forwarded);
|
|
}
|
|
|
|
processInbox(ctx: ExtensionContext): Promise<void> {
|
|
return processForwardedPermissionRequests(ctx, this.deps);
|
|
}
|
|
}
|
|
```
|
|
|
|
Both methods `return` the delegate promise directly (no `async`/`await`), so `@typescript-eslint/require-await` does not fire.
|
|
|
|
### Decision: reuse `PermissionForwardingDeps` as the constructor parameter
|
|
|
|
The issue frames the forwarder as *owning* the individual fields (`forwardingDir`, `subagentSessionsDir`, `registry`, `events`, `logger`, `shouldAutoApprove`).
|
|
Those six plus the two the issue omits (`writeReviewLog`, `requestPermissionDecisionFromUi`) are exactly the eight members of `PermissionForwardingDeps`, and the delegated free functions still require the full bag this issue.
|
|
Defining a separate `PermissionForwarderDeps` interface now would duplicate `PermissionForwardingDeps` field-for-field with no benefit, then be deleted in [#317].
|
|
So the constructor reuses `PermissionForwardingDeps`; `shouldAutoApprove` arrives as a constructor-supplied policy (it is set once at construction, never re-assigned).
|
|
The "owns individual fields" end state is realized in [#317], when the bag interface is deleted and the polling bodies are inlined as methods reading `this`.
|
|
|
|
### The narrow seam
|
|
|
|
`ForwardingManager` only ever calls `processInbox`, so it depends on the one-method `InboxProcessor` interface, not the concrete `PermissionForwarder`.
|
|
This mirrors the existing `ForwardingController` seam the package already uses for `PermissionSession → ForwardingManager`, keeps `forwarding-manager.test.ts` free of `as unknown as` casts (it can inject a plain `{ processInbox: vi.fn() }` mock), and does not constrain [#316]/[#317].
|
|
`requestApproval` is not on the seam — it exists for [#316], when the prompter will consume it via a separate narrow `ApprovalRequester` interface.
|
|
|
|
### ForwardingManager call site
|
|
|
|
```typescript
|
|
constructor(
|
|
private readonly subagentSessionsDir: string,
|
|
private readonly forwarder: InboxProcessor,
|
|
private readonly registry?: SubagentSessionRegistry,
|
|
) {}
|
|
|
|
// inside the tick:
|
|
void this.forwarder.processInbox(this.context).finally(() => {
|
|
this.processing = false;
|
|
});
|
|
```
|
|
|
|
`subagentSessionsDir` and `registry` stay (still used for `isSubagentExecutionContext`); only the `forwardingDeps` field is replaced by `forwarder`.
|
|
|
|
### index.ts wiring
|
|
|
|
The `forwardingDeps` bag literal stays in `index.ts` this issue (it feeds the forwarder constructor); [#317] removes it.
|
|
The change is to construct the forwarder and pass it instead of the bag:
|
|
|
|
```typescript
|
|
const forwardingDeps: PermissionForwardingDeps = { /* unchanged */ };
|
|
const forwarder = new PermissionForwarder(forwardingDeps);
|
|
// ...
|
|
new ForwardingManager(runtime.subagentSessionsDir, forwarder, subagentRegistry),
|
|
```
|
|
|
|
`PermissionPrompter` construction is untouched.
|
|
|
|
### Edge cases
|
|
|
|
- Behavior is byte-for-byte unchanged: the same `ctx`, `deps`, `options`, and `forwarded` values reach the same free functions.
|
|
- `composition-root.test.ts` runs the real `index.ts` through `make-fake-pi.ts`; it constructs the real forwarder and must stay green.
|
|
- `runtime.test.ts` mocks `polling` defensively but never constructs `ForwardingManager`; no change expected (verify it still compiles).
|
|
|
|
## Module-Level Changes
|
|
|
|
- `src/forwarded-permissions/permission-forwarder.ts` (new) — `InboxProcessor` interface and `PermissionForwarder` class; imports `confirmPermission`, `processForwardedPermissionRequests`, and `PermissionForwardingDeps` from `./polling`, plus the SDK `ExtensionContext` and the `RequestPermissionOptions` / `PermissionPromptDecision` / `ForwardedPromptDisplay` types the method signatures reference.
|
|
- `src/forwarding-manager.ts` — replace the `forwardingDeps: PermissionForwardingDeps` constructor field with `forwarder: InboxProcessor`; change the tick to call `this.forwarder.processInbox(this.context)`; drop the now-unused `processForwardedPermissionRequests` / `PermissionForwardingDeps` imports and add the `InboxProcessor` import.
|
|
- `src/index.ts` — construct `const forwarder = new PermissionForwarder(forwardingDeps)` and pass `forwarder` to `new ForwardingManager(...)` in place of `forwardingDeps`; add the `PermissionForwarder` import (the `PermissionForwardingDeps` import stays — the bag literal is still built here until [#317]).
|
|
- `test/permission-forwarder.test.ts` (new) — unit tests for delegation (see Test Impact Analysis).
|
|
- `test/forwarding-manager.test.ts` — replace `makeForwardingDeps()` + the `vi.mock("../src/forwarded-permissions/polling")` setup with an injected `{ processInbox: vi.fn() }` forwarder mock; update tick assertions from `mockProcessForwardedPermissionRequests` to the mock's `processInbox`; drop the `as unknown as PermissionForwardingDeps` cast.
|
|
|
|
No architecture-doc layout/metric tables reference these specific files by path beyond the Phase 3 roadmap entry (which already names them and predicts this outcome), so no architecture-doc edit is required for this step.
|
|
|
|
## Test Impact Analysis
|
|
|
|
1. New tests the extraction enables.
|
|
`test/permission-forwarder.test.ts` can unit-test the forwarder in isolation by mocking `./polling`: assert `requestApproval(ctx, msg, options, forwarded)` calls `confirmPermission(ctx, msg, deps, options, forwarded)` and returns its result; assert `processInbox(ctx)` calls `processForwardedPermissionRequests(ctx, deps)`.
|
|
Previously there was no class to test — the delegation logic did not exist as a unit.
|
|
2. Tests that become simpler.
|
|
`forwarding-manager.test.ts` currently fabricates a full `PermissionForwardingDeps` via `makeForwardingDeps()` and casts it with `as unknown as`.
|
|
With the `InboxProcessor` seam it injects a one-method mock and asserts `processInbox` is called with the latest context — the cast and the fake bag disappear.
|
|
3. Tests that stay as-is.
|
|
`permission-forwarding.test.ts` exercises the `polling.ts` free functions directly; those functions are unchanged, so its coverage stays exactly as-is.
|
|
`composition-root.test.ts` keeps verifying end-to-end wiring through the real `index.ts`.
|
|
|
|
## TDD Order
|
|
|
|
1. Add the `PermissionForwarder` collaborator (red → green → commit).
|
|
Surface: new `test/permission-forwarder.test.ts` with `vi.mock("#src/forwarded-permissions/polling", ...)` (hoisted `vi.fn()` stubs reset in `beforeEach`).
|
|
Covers: `requestApproval` delegates to `confirmPermission` with the stored deps and forwards the return value; `processInbox` delegates to `processForwardedPermissionRequests`.
|
|
Implement `src/forwarded-permissions/permission-forwarder.ts` to pass.
|
|
This step is purely additive — no existing module changes, so the suite stays green.
|
|
Commit: `refactor: add PermissionForwarder collaborator delegating to polling (#315)`.
|
|
2. Wire `ForwardingManager` and `index.ts` to the forwarder (red → green → commit).
|
|
This is one atomic step: the constructor signature change forces the `index.ts` call site and the `forwarding-manager.test.ts` mock to update in the same commit (the type checker rejects splitting them).
|
|
Surface: update `test/forwarding-manager.test.ts` to inject a `{ processInbox: vi.fn() }` `InboxProcessor` mock and assert `processInbox` is called per tick / with the latest context / skipped while processing; then change `src/forwarding-manager.ts` to hold `InboxProcessor` and call `forwarder.processInbox`; then update `src/index.ts` to construct and inject the forwarder.
|
|
Run `pnpm run check` immediately after (shared-interface change) and the full `pnpm -r run test` (the wiring touches the composition-root suite).
|
|
Commit: `refactor: wire ForwardingManager and index to PermissionForwarder (#315)`.
|
|
|
|
## Risks and Mitigations
|
|
|
|
- Risk: a behavior change sneaks in during the rewire.
|
|
Mitigation: lift-and-shift only — the forwarder passes the identical `deps` straight through; `permission-forwarding.test.ts` and `composition-root.test.ts` (unchanged) guard the round-trip behavior.
|
|
- Risk: `forwarding-manager.test.ts` rewrite changes what is actually asserted.
|
|
Mitigation: keep the same test scenarios (idempotent start, context update, processing-lock skip, no-UI/subagent stop) and only swap the polling-module mock for the injected `InboxProcessor` mock.
|
|
- Risk: leaving `PermissionForwarder` briefly unconsumed by production after Step 1.
|
|
Mitigation: the test imports it immediately, and Step 2 lands the production consumer in the same PR; `fallow dead-code` (run at pre-completion) evaluates the final state, which has an `index.ts` consumer.
|
|
- Risk: a stale `processForwardedPermissionRequests` / `PermissionForwardingDeps` import lingers in `forwarding-manager.ts`.
|
|
Mitigation: remove them in Step 2; `pnpm run lint` (no-unused) catches any miss.
|
|
|
|
## Open Questions
|
|
|
|
- None blocking.
|
|
The `requestApproval` method is unused by production until [#316]; it is introduced now because the issue specifies the forwarder's two-method surface up front, and [#316] consumes it via a narrow `ApprovalRequester` interface.
|
|
|
|
[#316]: https://github.com/gotgenes/pi-packages/issues/316
|
|
[#317]: https://github.com/gotgenes/pi-packages/issues/317
|