Extract TaskBackend + CodeBackend protocols (#410)#411
Conversation
Decouple gh/glab shell-outs from call sites by introducing two distinct provider-backed protocols — task work (issues) and code work (PRs/labels) are independent dimensions. A Corveil-tasked session can pair with a GitHub/GitLab CodeBackend; non-coding tasks need no CodeBackend at all. - New `TaskBackend` / `CodeBackend` protocols with capability flags (replaces `if session.provider == .github` guards) - New `ShellRunner` protocol + `ProcessShellRunner` (injectable for tests) - GitHub, GitLab, and Corveil-stub backends behind the protocols - `ProviderManager` becomes a factory: `taskBackend(for:)` / `codeBackend(for:)` - `SessionService` recovery paths migrated to backend abstraction - ADR 0005 documents the spec - 15 new backend unit tests with FakeShellRunner 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: F67AE737-3B2D-4C3B-8E44-BC4006BAAE11
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Clean, well-documented refactor. The protocol split (TaskBackend / CodeBackend), ShellRunner injection, capability flags, and the Corveil stub all hang together, and ADR 0005 explains the why well. swift test on CrowProvider is green — all 15 new BackendsTests pass plus the 35 ProviderManagerTests. (Full swift build couldn't run here: Frameworks/GhosttyKit.xcframework has no binary artifact in this checkout — an environment limitation, not a PR defect.)
Two should-fix items below before merge; both are mechanical.
Security Review
Strengths:
- No command injection:
ProcessShellRunnerspawns/usr/bin/envwith anargumentsarray (nosh -c), and every backend passesurl/repo/branch/labels as discrete argv elements — never concatenated into a shell string. Untrusted URL/branch values cannot break out. - The pipe is drained (
readDataToEndOfFile()) beforewaitUntilExit(), avoiding the >64KB deadlock — the comment is accurate. GITLAB_HOSTis passed via the env dictionary, not interpolated.
Concerns: none.
Code Quality
Yellow — GitHubTaskBackend declares .projectBoardStatus but setTaskStatus throws .unimplemented (GitHubTaskBackend.swift:62-71)
The TaskBackend protocol contract states the capability gates the method: "Gates setTaskStatus. Without this capability that method throws." GitHub declares the capability and throws, which directly contradicts that contract. The whole point of capability flags is to let callers do if backend.capabilities.contains(.projectBoardStatus) { try await backend.setTaskStatus(...) } — a future caller that trusts the flag will hit a runtime throw on GitHub. No live caller exercises this yet (confirmed: only SessionService uses the new API, and only fetchTask/linkedPR), so it's latent — but it's a landmine being committed. Safer to not declare .projectBoardStatus until the migration lands; then capability-gated callers correctly skip it and the flag stays honest. (.batchedQuery is documented as informational, so that one is fine.)
Yellow — findPRLink runs gh pr list twice when no PR exists (SessionService.swift:1151-1164)
The new backend path is an if let … pr = try? … linkedPR(…) whose body returns. When linkedPR returns nil (the common "no PR found" recovery case), the if let pr fails and execution falls through to the legacy guard let prOutput = try? await shell("gh", "pr", "list", …) block — which issues the identical gh pr list command a second time. Since providerManager is now always wired in production (AppDelegate.swift:431), every no-PR recovery pays for a redundant network round-trip. The fetchTask path just above (:1129-1138) gets this right with if manager … else if legacy …; findPRLink should mirror that structure (legacy only as the providerManager == nil fallback) rather than an unconditional fall-through.
Green (consider)
detectProviderNonisolated(ProviderManager.swift:55-71) duplicatesdetectProviderverbatim with a "keep both in sync" comment. Extracting the shared body into onenonisolatedstatic the actor method wraps would remove the drift risk entirely.GitHubCodeBackend.ensureMergeLabel(GitHubCodeBackend.swift:51) re-throws asnonZeroExit(exitCode: 1, …), discarding the real exit code. Re-throw the original error to preserve it.GitLabTaskBackend.fetchTaskderives the title fromoutput.components(separatedBy: .newlines).first— the first line ofglab issue viewtext output, which may be a header rather than the title. Acceptable for v1; worth a follow-up if GitLab issue titles matter downstream.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Recommendation: Request Changes — driven by [0 Red, 2 Yellow, 3 Green] findings. Both Yellow items are small, localized fixes; addressing them (plus the optional Green cleanups) in this round avoids a follow-up trip.
- Drop `.projectBoardStatus` from GitHubTaskBackend.capabilities. Capability flags are a contract; declaring while `setTaskStatus` throws would lie to capability-gated callers. Re-added when the GraphQL migration lands. - Fix duplicate `gh pr list` in SessionService.findPRLink — the legacy fallback now only runs when no ProviderManager is wired, mirroring the parseTicketInfo path. - Dedupe ProviderManager.detectProvider / *Nonisolated into one nonisolated static `detect(url:additionalGitLabHosts:)` so the matching logic can't drift. - GitHubCodeBackend.ensureMergeLabel: preserve the original ShellRunnerError on rethrow (drop the synthetic exitCode:1). 🐦⬛ Generated with Claude Code, orchestrated by Crow Co-Authored-By: Claude <noreply@anthropic.com> Crow-Session: F67AE737-3B2D-4C3B-8E44-BC4006BAAE11
dhilgaertner
left a comment
There was a problem hiding this comment.
Code & Security Review
Reviewed the full diff (18 files, +904/-15) for PR #411 — extraction of TaskBackend + CodeBackend protocols behind a ShellRunner-injectable factory. This is a clean, behavior-preserving refactor with strong test coverage.
Verification
swift test --package-path Packages/CrowProvider→ 50 tests green (15 newBackendsTests+ 35 existingProviderManagerTests).- Grepped every
switchoverProviderrepo-wide — all sites (ClaudeLauncher,CodexLauncher,ProviderManager×4,IssueTracker) now handle the new.corveilcase, so the additive enum case introduces no exhaustiveness compile breaks. (Full app target couldn't be built locally —GhosttyKit.xcframeworkbinary is absent in the review checkout — but package tests + switch audit cover the changed logic.)
Security Review
Strengths:
- All CLI invocations go through
Process.arguments(argv), never a shell string — URL/branch/repo values can't inject commands. - No secrets logged;
NSLogonly emits PR number + branch. GITLAB_HOSTis sourced from config, merged viaShellEnvironment.merging.
No security concerns.
Code Quality
Strengths:
ProcessShellRunnerdrains the pipe beforewaitUntilExit()on a detached task — this is strictly more correct than the legacyProviderManager.shell(line 292-294), which waits-then-reads and can deadlock on >64KB output. The new abstraction quietly fixes that latent bug.Sendablevalue-type backends +nonisolated letfactory state read across the actor boundary is sound; nonisolated factory methods only touch immutable Sendable storage.- Capability flags correctly model contract-vs-implementation:
.projectBoardStatusis deliberately withheld whilesetTaskStatusthrows, and the test asserts that, so capability-gated callers aren't lied to. ensureMergeLabelswallows only the "already exists" non-zero exit and rethrows everything else — correct narrow catch.- Recovery-path migrations (
parseTicketInfotitle fetch,findPRLink) preserve production behavior and avoid double-issuing the CLI on the common "no PR" path.
Summary Table
| Color | Meaning | Verdict effect |
|---|---|---|
| Red | Must fix | Request changes |
| Yellow | Should fix | Request changes |
| Green | Consider | Approve allowed |
Consider (Green — non-blocking)
- 🟢
GitLabCodeBackend.linkedPR(glab mr list -F json) andGitLabTaskBackend.setLabelshave no command-vector tests, unlike their GitHub counterparts. Low risk today since the only wiredCodeBackendcaller (findPRLink) hardcodes.githuband the wiredTaskBackendcaller exercisesfetchTask(tested) — but add command-vector coverage when these GitLab paths get wired up. - 🟢
findPRLinkroutes through the newCodeBackendabstraction but still hardcodes.githubrather than detecting the repo's provider. Behavior is identical to pre-PR (was alwaysgh pr list), and the description scopes provider-guard migration as an explicit follow-up — just flagging that GitLab MR linking remains unsupported until then. - 🟢
ProcessShellRunnermerges stderr into the stdout pipe that backends then JSON-parse; a straygh/glabstderr warning could corrupt a parse. This matches the pre-existingshell()behavior, so no regression.
Recommendation: Approve — driven by [0 Red, 0 Yellow, 3 Green] findings. Well-scoped, well-tested, behavior-preserving, and it incidentally hardens a deadlock-prone shell path.
Closes #414 ## Summary Follow-up to #410 / ADR 0005. Adds `Session.codeProvider: Provider?` so a Corveil-tasked session can pair with a GitHub or GitLab `CodeBackend` — the protocol split from #411 already supported this in principle; the model now represents it. `nil` means "follow `provider`"; callers resolve with `session.codeProvider ?? session.provider`. No persisted-state migration: legacy sessions decode `codeProvider = nil` and the lookup-site fallback routes them to the same backend they used pre-split. ## Changes - **`Session` model** — new optional field, init parameter, and `decodeIfPresent` in the custom decoder (same pattern as `lastReviewedHeadSha` / `autoMergeEnabledAt`). - **`SessionService.findPRLink`** — takes an explicit `provider:` parameter; sole caller in `recoverOrphan` passes `session.codeProvider ?? session.provider ?? .github`. The in-line `gh pr list` shell-out fallback (only reached when no `ProviderManager` is wired) stays GitHub-only — unchanged. - **Tests** - `SessionModelTests` — extends round-trip / nil-defaults / default-values tests; adds `sessionBackwardCompatDecodingWithoutCodeProvider` for legacy JSON. - `SessionRepositoryTests.savePreservesAllOptionalFields` — round-trips `codeProvider: .gitlab` alongside `provider: .github` to prove the field is independently persisted. `IssueTracker` is intentionally unchanged: all three of its `session.provider` reads are task-backend / issue-lifecycle decisions, not `codeBackend` lookups. ## Test plan - [x] `swift test --package-path Packages/CrowCore --filter SessionModelTests` — 16 tests pass (incl. new backward-compat decode). - [x] `swift test --package-path Packages/CrowPersistence --filter SessionRepositoryTests` — 11 tests pass. - [x] `swift test --package-path Packages/CrowProvider --filter BackendsTests` — sanity check on factory; 15 tests pass. - [x] `swift build --arch arm64` — clean build, no callers broke. - [ ] Manual smoke: open existing `state.json` from a pre-CROW-414 Crow build — confirm sessions hydrate with `codeProvider == nil` and PR discovery still finds the right backend. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
Summary
gh/glabshell-outs from call sites behind two distinct protocols:TaskBackend(issues/labels/status) andCodeBackend(PRs/merge labels). The split exists because tasks and code are independent dimensions — a Corveil-tasked session can pair with a GitHub/GitLabCodeBackend, and a non-coding task needs noCodeBackendat all.ShellRunnerprotocol so backends are unit-testable without a real shell. Production usesProcessShellRunner; tests use a recording fake.ProviderManagerbecomes a factory (taskBackend(for:)/codeBackend(for:)/taskBackend(forURL:)), backends areSendablevalue types handed across the actor boundary.TaskCapability/CodeCapability) replaceif session.provider == .githubguards.Corveiltask backend proves the abstraction holds for a third provider — every method throws.unimplementedcleanly.SessionServicerecovery paths (ticket-title fetch, PR-link lookup) migrate to the new abstraction with a fallback to the inline shell-out when noProviderManageris wired (no behavior change in production).Closes #410.
Architecture
See ADR 0005 — task/code separation, capability flags, ShellRunner injection, Corveil-as-task-only.
Test plan
swift build --arch arm64cleanCrowCore+ 35ProviderManagerTests+ 152 rootCrowTestsall greenBackendsTestscovering each backend's command vector, capability flags, error paths, and the Corveil stubFollow-ups (filed separately, no `crow:auto`)
🐦⬛ Generated with Claude Code, orchestrated by Crow