mirror of
https://github.com/coleam00/Archon
synced 2026-04-21 13:37:41 +00:00
Add isolation provider abstraction for worktree management (#87)
* Add isolation provider abstraction for worktree management Introduces IIsolationProvider interface to abstract workflow isolation: - WorktreeProvider implementation with semantic branch naming - Database migration for isolation_env_id and isolation_provider columns - GitHub adapter migrated to use provider for create/destroy - /worktree commands updated to use provider - Manual testing guide and migration status docs * Fix worktree base location defaults and add isolation provider docs - Default to ~/tmp/worktrees locally (matches worktree-manager skill) - Default to /workspace/worktrees in Docker (inside mounted volume) - Docker ignores WORKTREE_BASE override (end user protection) - Local allows WORKTREE_BASE override for developers - Add IIsolationProvider section to architecture.md - Add docs/worktree-orchestration.md with lifecycle diagrams
This commit is contained in:
parent
509978ec2e
commit
44eef594b9
17 changed files with 3001 additions and 96 deletions
856
.agents/plans/isolation-provider-abstraction-bun.plan.md
Normal file
856
.agents/plans/isolation-provider-abstraction-bun.plan.md
Normal file
|
|
@ -0,0 +1,856 @@
|
|||
# Feature: Isolation Provider Abstraction
|
||||
|
||||
The following plan should be complete, but validate documentation and codebase patterns before implementing.
|
||||
|
||||
Pay special attention to naming of existing utils, types, and modules. Import from the right files.
|
||||
|
||||
## Feature Description
|
||||
|
||||
Introduce an `IIsolationProvider` interface to abstract workflow isolation mechanisms. Git worktrees remain the default and first-class implementation, but the abstraction enables future isolation strategies (containers, cloud VMs) while providing a consistent API for all platform adapters.
|
||||
|
||||
This is a refactoring of existing worktree functionality into a provider pattern, not adding new features.
|
||||
|
||||
## User Story
|
||||
|
||||
As a platform maintainer
|
||||
I want to abstract isolation mechanisms behind a provider interface
|
||||
So that I can add new isolation strategies (containers, VMs) without modifying platform adapters
|
||||
|
||||
## Problem Statement
|
||||
|
||||
1. **Code Duplication**: Platform adapters (GitHub, Slack, Discord) directly call git utility functions, mixing platform-specific logic with isolation implementation details.
|
||||
2. **Tight Coupling**: `createWorktreeForIssue()` is called directly from adapters, making it hard to swap isolation strategies.
|
||||
3. **Scattered Logic**: Worktree creation, adoption, and cleanup logic is spread across `src/utils/git.ts`, `src/adapters/github.ts`, and `src/handlers/command-handler.ts`.
|
||||
|
||||
## Solution Statement
|
||||
|
||||
Apply the **Strategy Pattern** with a **Factory** to create a provider abstraction:
|
||||
1. Define `IIsolationProvider` interface with create/destroy/get/list methods
|
||||
2. Implement `WorktreeProvider` that encapsulates all git worktree logic
|
||||
3. Create factory function `getIsolationProvider()` for centralized instantiation
|
||||
4. Migrate platform adapters to use the provider via orchestrator
|
||||
|
||||
## Feature Metadata
|
||||
|
||||
**Feature Type**: Refactor
|
||||
**Estimated Complexity**: Medium
|
||||
**Primary Systems Affected**:
|
||||
- `src/utils/git.ts` (functions moved to provider)
|
||||
- `src/adapters/github.ts` (delegation to orchestrator)
|
||||
- `src/orchestrator/orchestrator.ts` (new `ensureIsolation()` function)
|
||||
- `src/handlers/command-handler.ts` (use provider for `/worktree` commands)
|
||||
- `src/db/conversations.ts` (new columns)
|
||||
**Dependencies**: None (uses existing git via child_process)
|
||||
|
||||
---
|
||||
|
||||
## CONTEXT REFERENCES
|
||||
|
||||
### Relevant Codebase Files IMPORTANT: READ THESE BEFORE IMPLEMENTING!
|
||||
|
||||
- `src/types/index.ts` (lines 1-117) - Why: Current interface definitions; add new isolation types here
|
||||
- `src/utils/git.ts` (lines 1-272) - Why: Contains all worktree logic to migrate into provider
|
||||
- `src/utils/git.test.ts` (lines 1-455) - Why: Existing Bun test patterns to mirror for provider tests
|
||||
- `src/adapters/github.ts` (lines 616-700) - Why: Current worktree creation flow to refactor
|
||||
- `src/adapters/github.test.ts` (lines 1-500) - Why: Test patterns using Bun mock.module
|
||||
- `src/orchestrator/orchestrator.ts` (lines 231-266) - Why: CWD resolution logic to integrate with
|
||||
- `src/db/conversations.ts` (lines 1-126) - Why: Database operations to extend
|
||||
- `src/handlers/command-handler.ts` (lines 920-1130) - Why: `/worktree` commands to update
|
||||
|
||||
### New Files to Create
|
||||
|
||||
- `src/isolation/types.ts` - Interface definitions for isolation abstraction
|
||||
- `src/isolation/providers/worktree.ts` - WorktreeProvider implementation
|
||||
- `src/isolation/providers/worktree.test.ts` - Unit tests for WorktreeProvider
|
||||
- `src/isolation/index.ts` - Factory function and exports
|
||||
- `migrations/005_isolation_abstraction.sql` - Database schema update
|
||||
|
||||
### Relevant Documentation YOU SHOULD READ BEFORE IMPLEMENTING!
|
||||
|
||||
- [Bun Mock Functions](https://bun.com/guides/test/mock-functions)
|
||||
- Specific section: Creating mock functions with `mock()`
|
||||
- Why: Pattern for mocking provider methods in tests
|
||||
- [Bun SpyOn Guide](https://bun.com/guides/test/spy-on)
|
||||
- Specific section: Spying on object methods
|
||||
- Why: Pattern for spying on git commands in tests
|
||||
- [Bun Module Mocking](https://github.com/oven-sh/bun/discussions/6236)
|
||||
- Specific section: `mock.module()` usage
|
||||
- Why: Pattern for mocking provider in adapter tests
|
||||
- [TypeScript Provider Pattern](https://www.webdevtutor.net/blog/typescript-provider-pattern)
|
||||
- Why: Interface abstraction best practices
|
||||
- [Design Patterns - Strategy Pattern](https://blog.logrocket.com/understanding-design-patterns-typescript-node-js/)
|
||||
- Why: Runtime algorithm selection pattern we're implementing
|
||||
|
||||
### Patterns to Follow
|
||||
|
||||
**Naming Conventions:**
|
||||
```typescript
|
||||
// Interfaces: I-prefix
|
||||
export interface IIsolationProvider { ... }
|
||||
export interface IsolationRequest { ... } // Data types: no prefix
|
||||
export interface IsolatedEnvironment { ... }
|
||||
|
||||
// Implementation classes: PascalCase
|
||||
export class WorktreeProvider implements IIsolationProvider { ... }
|
||||
```
|
||||
|
||||
**Error Handling (from src/utils/git.ts):**
|
||||
```typescript
|
||||
try {
|
||||
await execFileAsync('git', [...]);
|
||||
} catch (error) {
|
||||
const err = error as Error & { stderr?: string };
|
||||
if (err.stderr?.includes('already exists')) {
|
||||
// Handle specific case
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
**Bun Test Patterns (from src/utils/git.test.ts):**
|
||||
```typescript
|
||||
import { describe, test, expect, beforeEach, afterEach, mock, spyOn, type Mock } from 'bun:test';
|
||||
|
||||
describe('WorktreeProvider', () => {
|
||||
let execSpy: Mock<typeof git.execFileAsync>;
|
||||
|
||||
beforeEach(() => {
|
||||
execSpy = spyOn(git, 'execFileAsync');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
execSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('creates worktree for issue workflow', async () => {
|
||||
execSpy.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Database Update Pattern (from src/db/conversations.ts):**
|
||||
```typescript
|
||||
export async function updateConversation(
|
||||
id: string,
|
||||
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'worktree_path' | 'isolation_env_id' | 'isolation_provider'>>
|
||||
): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: (string | null)[] = [];
|
||||
let i = 1;
|
||||
// Dynamic field building...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IMPLEMENTATION PLAN
|
||||
|
||||
### Phase 1: Core Abstraction
|
||||
|
||||
Create the isolation provider interface and types without modifying existing code.
|
||||
|
||||
**Tasks:**
|
||||
- Define `IIsolationProvider` interface with lifecycle methods
|
||||
- Define `IsolationRequest` and `IsolatedEnvironment` data types
|
||||
- Implement `WorktreeProvider` class migrating logic from `git.ts`
|
||||
- Create factory function `getIsolationProvider()`
|
||||
|
||||
### Phase 2: Database Schema
|
||||
|
||||
Add new columns for provider abstraction while maintaining backwards compatibility.
|
||||
|
||||
**Tasks:**
|
||||
- Create migration adding `isolation_env_id` and `isolation_provider` columns
|
||||
- Update `Conversation` type definition
|
||||
- Extend `updateConversation()` to support new columns
|
||||
- Add `getConversationByIsolationEnvId()` query
|
||||
|
||||
### Phase 3: Integration
|
||||
|
||||
Wire up the provider into orchestrator and adapters.
|
||||
|
||||
**Tasks:**
|
||||
- Add `ensureIsolation()` function to orchestrator
|
||||
- Update GitHub adapter to delegate worktree creation
|
||||
- Update `/worktree` commands to use provider
|
||||
- Deprecate direct calls to `createWorktreeForIssue()`
|
||||
|
||||
### Phase 4: Testing & Validation
|
||||
|
||||
Comprehensive test coverage for the new provider.
|
||||
|
||||
**Tasks:**
|
||||
- Unit tests for `WorktreeProvider` (branch naming, create, adopt, destroy)
|
||||
- Integration tests via test adapter
|
||||
- Verify existing tests still pass
|
||||
|
||||
---
|
||||
|
||||
## STEP-BY-STEP TASKS
|
||||
|
||||
IMPORTANT: Execute every task in order, top to bottom. Each task is atomic and independently testable.
|
||||
|
||||
### Phase 1: Core Abstraction
|
||||
|
||||
#### CREATE src/isolation/types.ts
|
||||
|
||||
- **IMPLEMENT**: Define isolation interfaces
|
||||
- **PATTERN**: Follow `src/types/index.ts` interface style
|
||||
- **IMPORTS**: None (pure type definitions)
|
||||
- **GOTCHA**: Use `readonly` for provider type to prevent mutation
|
||||
- **VALIDATE**: `bun run type-check`
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* Semantic context for creating isolated environments
|
||||
* Platform-agnostic - describes WHAT needs isolation, not HOW
|
||||
*/
|
||||
export interface IsolationRequest {
|
||||
codebaseId: string;
|
||||
canonicalRepoPath: string; // Main repo path, never a worktree
|
||||
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
|
||||
identifier: string; // "42", "feature-auth", thread hash, etc.
|
||||
prBranch?: string; // PR-specific (for reproducible reviews)
|
||||
prSha?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating an isolated environment
|
||||
*/
|
||||
export interface IsolatedEnvironment {
|
||||
id: string;
|
||||
provider: 'worktree' | 'container' | 'vm' | 'remote';
|
||||
workingPath: string;
|
||||
branchName?: string;
|
||||
status: 'active' | 'suspended' | 'destroyed';
|
||||
createdAt: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface - git worktrees are DEFAULT implementation
|
||||
*/
|
||||
export interface IIsolationProvider {
|
||||
readonly providerType: string;
|
||||
create(request: IsolationRequest): Promise<IsolatedEnvironment>;
|
||||
destroy(envId: string, options?: { force?: boolean }): Promise<void>;
|
||||
get(envId: string): Promise<IsolatedEnvironment | null>;
|
||||
list(codebaseId: string): Promise<IsolatedEnvironment[]>;
|
||||
adopt?(path: string): Promise<IsolatedEnvironment | null>;
|
||||
healthCheck(envId: string): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
#### CREATE src/isolation/providers/worktree.ts
|
||||
|
||||
- **IMPLEMENT**: WorktreeProvider class with all git worktree operations
|
||||
- **PATTERN**: Mirror `src/utils/git.ts` function signatures and error handling
|
||||
- **IMPORTS**: `execFileAsync`, `mkdirAsync` from `src/utils/git.ts`, crypto for hashing
|
||||
- **GOTCHA**: Preserve PR ref-based fetching for fork support (`pull/${number}/head`)
|
||||
- **GOTCHA**: Preserve adoption logic for skill-app worktree symbiosis
|
||||
- **GOTCHA**: Use `join()` from `path` for cross-platform path construction
|
||||
- **VALIDATE**: `bun run type-check && bun test src/isolation/providers/worktree.test.ts`
|
||||
|
||||
Key methods to implement:
|
||||
1. `generateBranchName(request)` - Semantic branch naming (issue-42, pr-42, thread-abc123)
|
||||
2. `generateEnvId(request)` - Unique environment ID
|
||||
3. `getWorktreePath(request, branchName)` - Path construction with WORKTREE_BASE support
|
||||
4. `findExisting(request, branchName)` - Check for adoption opportunities
|
||||
5. `create(request)` - Main creation logic (migrate from `createWorktreeForIssue`)
|
||||
6. `destroy(envId, options)` - Removal with force option
|
||||
7. `get(envId)` - Get environment by ID
|
||||
8. `list(codebaseId)` - List all environments for codebase
|
||||
9. `healthCheck(envId)` - Check if worktree still exists
|
||||
|
||||
#### CREATE src/isolation/providers/worktree.test.ts
|
||||
|
||||
- **IMPLEMENT**: Unit tests for WorktreeProvider
|
||||
- **PATTERN**: Follow `src/utils/git.test.ts` exactly - use `spyOn` for git commands
|
||||
- **IMPORTS**: `describe, test, expect, beforeEach, afterEach, spyOn, type Mock` from `bun:test`
|
||||
- **GOTCHA**: Use `spyOn` on exported functions, not mock.module (to preserve module structure)
|
||||
- **VALIDATE**: `bun test src/isolation/providers/worktree.test.ts`
|
||||
|
||||
Test cases to implement:
|
||||
1. `generateBranchName` - issue-N, pr-N, thread-{hash}, task-{slug}
|
||||
2. `generateBranchName` - consistent hash for same identifier
|
||||
3. `create` - creates worktree for issue workflow
|
||||
4. `create` - creates worktree for PR with SHA (reproducible reviews)
|
||||
5. `create` - creates worktree for PR without SHA (fallback)
|
||||
6. `create` - adopts existing worktree if found
|
||||
7. `create` - adopts worktree by PR branch name (skill symbiosis)
|
||||
8. `destroy` - removes worktree
|
||||
9. `destroy` - throws on uncommitted changes without force
|
||||
10. `get` - returns null for non-existent environment
|
||||
11. `list` - returns all worktrees for codebase
|
||||
|
||||
#### CREATE src/isolation/index.ts
|
||||
|
||||
- **IMPLEMENT**: Factory function and re-exports
|
||||
- **PATTERN**: Follow `src/clients/factory.ts` pattern
|
||||
- **IMPORTS**: WorktreeProvider, IIsolationProvider
|
||||
- **GOTCHA**: Singleton pattern for provider instance
|
||||
- **VALIDATE**: `bun run type-check`
|
||||
|
||||
```typescript
|
||||
import { WorktreeProvider } from './providers/worktree';
|
||||
import type { IIsolationProvider, IsolationRequest, IsolatedEnvironment } from './types';
|
||||
|
||||
export type { IIsolationProvider, IsolationRequest, IsolatedEnvironment };
|
||||
|
||||
let provider: IIsolationProvider | null = null;
|
||||
|
||||
export function getIsolationProvider(): IIsolationProvider {
|
||||
if (!provider) {
|
||||
provider = new WorktreeProvider();
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
// For testing - reset singleton
|
||||
export function resetIsolationProvider(): void {
|
||||
provider = null;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Database Schema
|
||||
|
||||
#### CREATE migrations/005_isolation_abstraction.sql
|
||||
|
||||
- **IMPLEMENT**: Add isolation columns to conversations table
|
||||
- **PATTERN**: Follow `migrations/003_add_worktree.sql`
|
||||
- **GOTCHA**: Keep `worktree_path` for backwards compatibility during transition
|
||||
- **VALIDATE**: `psql $DATABASE_URL < migrations/005_isolation_abstraction.sql`
|
||||
|
||||
```sql
|
||||
-- Add isolation provider abstraction columns
|
||||
-- Version: 5.0
|
||||
-- Description: Abstract isolation mechanisms (worktrees, containers, VMs)
|
||||
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS isolation_env_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS isolation_provider VARCHAR(50) DEFAULT 'worktree';
|
||||
|
||||
-- Migrate existing worktree_path data
|
||||
UPDATE remote_agent_conversations
|
||||
SET isolation_env_id = worktree_path,
|
||||
isolation_provider = 'worktree'
|
||||
WHERE worktree_path IS NOT NULL
|
||||
AND isolation_env_id IS NULL;
|
||||
|
||||
-- Create index for lookups by isolation environment
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_isolation
|
||||
ON remote_agent_conversations(isolation_env_id, isolation_provider);
|
||||
|
||||
-- Note: Keep worktree_path for backwards compatibility during transition
|
||||
-- Future migration will DROP COLUMN worktree_path after full migration
|
||||
COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS
|
||||
'Unique identifier for the isolated environment (worktree path, container ID, etc.)';
|
||||
COMMENT ON COLUMN remote_agent_conversations.isolation_provider IS
|
||||
'Type of isolation provider (worktree, container, vm, remote)';
|
||||
```
|
||||
|
||||
#### UPDATE src/types/index.ts
|
||||
|
||||
- **IMPLEMENT**: Add isolation fields to Conversation interface
|
||||
- **PATTERN**: Existing optional fields use `| null`
|
||||
- **IMPORTS**: None
|
||||
- **GOTCHA**: Keep `worktree_path` for backwards compatibility
|
||||
- **VALIDATE**: `bun run type-check`
|
||||
|
||||
Add after `worktree_path`:
|
||||
```typescript
|
||||
isolation_env_id: string | null;
|
||||
isolation_provider: string | null;
|
||||
```
|
||||
|
||||
#### UPDATE src/db/conversations.ts
|
||||
|
||||
- **IMPLEMENT**: Extend `updateConversation()` to support new columns
|
||||
- **PATTERN**: Follow existing dynamic field building pattern
|
||||
- **IMPORTS**: None (already imports Conversation from types)
|
||||
- **GOTCHA**: Handle both old (`worktree_path`) and new (`isolation_env_id`) fields
|
||||
- **VALIDATE**: `bun test src/db/conversations.test.ts`
|
||||
|
||||
Add to `updateConversation` function:
|
||||
```typescript
|
||||
if (updates.isolation_env_id !== undefined) {
|
||||
fields.push(`isolation_env_id = $${String(i++)}`);
|
||||
values.push(updates.isolation_env_id);
|
||||
}
|
||||
if (updates.isolation_provider !== undefined) {
|
||||
fields.push(`isolation_provider = $${String(i++)}`);
|
||||
values.push(updates.isolation_provider);
|
||||
}
|
||||
```
|
||||
|
||||
Also update the `updates` type parameter to include new fields.
|
||||
|
||||
#### ADD getConversationByIsolationEnvId to src/db/conversations.ts
|
||||
|
||||
- **IMPLEMENT**: Query conversation by isolation environment ID
|
||||
- **PATTERN**: Follow `getConversationByWorktreePath()` pattern
|
||||
- **IMPORTS**: None
|
||||
- **VALIDATE**: `bun test src/db/conversations.test.ts`
|
||||
|
||||
```typescript
|
||||
export async function getConversationByIsolationEnvId(
|
||||
envId: string
|
||||
): Promise<Conversation | null> {
|
||||
const result = await pool.query<Conversation>(
|
||||
'SELECT * FROM remote_agent_conversations WHERE isolation_env_id = $1 LIMIT 1',
|
||||
[envId]
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: Integration
|
||||
|
||||
#### UPDATE src/orchestrator/orchestrator.ts
|
||||
|
||||
- **IMPLEMENT**: Add `ensureIsolation()` function
|
||||
- **PATTERN**: Follow existing `handleMessage` flow
|
||||
- **IMPORTS**: `getIsolationProvider` from `../isolation`, `IsolationRequest`
|
||||
- **GOTCHA**: Only create isolation when codebase is configured
|
||||
- **GOTCHA**: Update both `worktree_path` (backwards compat) AND new isolation fields
|
||||
- **VALIDATE**: `bun test src/orchestrator/orchestrator.test.ts`
|
||||
|
||||
Add new function before `handleMessage`:
|
||||
```typescript
|
||||
import { getIsolationProvider } from '../isolation';
|
||||
import type { IsolationRequest } from '../isolation/types';
|
||||
|
||||
async function ensureIsolation(
|
||||
conversation: Conversation,
|
||||
codebase: Codebase | null,
|
||||
platform: string,
|
||||
conversationId: string
|
||||
): Promise<Conversation> {
|
||||
// Skip if already isolated
|
||||
if (conversation.isolation_env_id || conversation.worktree_path) {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
// Skip if no codebase configured
|
||||
if (!conversation.codebase_id || !codebase) {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
// Determine workflow type from platform context
|
||||
const workflowType = inferWorkflowType(platform, conversationId);
|
||||
const identifier = extractIdentifier(platform, conversationId);
|
||||
|
||||
const provider = getIsolationProvider();
|
||||
const env = await provider.create({
|
||||
codebaseId: conversation.codebase_id,
|
||||
canonicalRepoPath: codebase.default_cwd,
|
||||
workflowType,
|
||||
identifier,
|
||||
description: `${platform} ${workflowType} ${conversationId}`,
|
||||
});
|
||||
|
||||
// Update conversation with isolation info (both old and new fields for compatibility)
|
||||
await db.updateConversation(conversation.id, {
|
||||
isolation_env_id: env.id,
|
||||
isolation_provider: env.provider,
|
||||
worktree_path: env.workingPath, // Backwards compatibility
|
||||
cwd: env.workingPath,
|
||||
});
|
||||
|
||||
// Reload and return updated conversation
|
||||
const updated = await db.getConversationByPlatformId(platform, conversationId);
|
||||
return updated ?? conversation;
|
||||
}
|
||||
|
||||
function inferWorkflowType(
|
||||
platform: string,
|
||||
conversationId: string
|
||||
): IsolationRequest['workflowType'] {
|
||||
if (platform === 'github') {
|
||||
// GitHub: owner/repo#42 - could be issue or PR
|
||||
// Detection is done in adapter, here we default to issue
|
||||
return 'issue';
|
||||
}
|
||||
// Slack, Discord, Telegram: all are threads
|
||||
return 'thread';
|
||||
}
|
||||
|
||||
function extractIdentifier(platform: string, conversationId: string): string {
|
||||
if (platform === 'github') {
|
||||
// Extract number from owner/repo#42
|
||||
const match = /#(\d+)$/.exec(conversationId);
|
||||
return match?.[1] ?? conversationId;
|
||||
}
|
||||
// For thread platforms, use conversation ID (will be hashed by provider)
|
||||
return conversationId;
|
||||
}
|
||||
```
|
||||
|
||||
**NOTE**: Do NOT call `ensureIsolation` from orchestrator yet. The GitHub adapter already handles isolation explicitly. We will integrate gradually.
|
||||
|
||||
#### UPDATE src/adapters/github.ts
|
||||
|
||||
- **IMPLEMENT**: Use provider for worktree creation instead of direct git.ts calls
|
||||
- **PATTERN**: Keep existing flow but delegate to provider
|
||||
- **IMPORTS**: `getIsolationProvider` from `../isolation`
|
||||
- **GOTCHA**: Preserve linked issue worktree sharing logic
|
||||
- **GOTCHA**: Preserve PR head branch and SHA fetching for reproducible reviews
|
||||
- **VALIDATE**: `bun test src/adapters/github.test.ts`
|
||||
|
||||
Replace the worktree creation section (around line 651-699) with:
|
||||
```typescript
|
||||
// If no shared worktree found, create new one
|
||||
if (!worktreePath) {
|
||||
try {
|
||||
// For PRs, fetch the head branch name and SHA from GitHub API
|
||||
if (isPR) {
|
||||
try {
|
||||
const { data: prData } = await this.octokit.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: number,
|
||||
});
|
||||
prHeadBranch = prData.head.ref;
|
||||
prHeadSha = prData.head.sha;
|
||||
console.log(
|
||||
`[GitHub] PR #${String(number)} head branch: ${prHeadBranch}, SHA: ${prHeadSha}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'[GitHub] Failed to fetch PR head branch, will create new branch instead:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = getIsolationProvider();
|
||||
const env = await provider.create({
|
||||
codebaseId: codebase.id,
|
||||
canonicalRepoPath: repoPath,
|
||||
workflowType: isPR ? 'pr' : 'issue',
|
||||
identifier: String(number),
|
||||
prBranch: prHeadBranch,
|
||||
prSha: prHeadSha,
|
||||
description: `GitHub ${isPR ? 'PR' : 'issue'} #${String(number)}`,
|
||||
});
|
||||
|
||||
worktreePath = env.workingPath;
|
||||
console.log(`[GitHub] Created worktree: ${worktreePath}`);
|
||||
|
||||
// Update conversation with isolation info
|
||||
await db.updateConversation(existingConv.id, {
|
||||
codebase_id: codebase.id,
|
||||
cwd: worktreePath,
|
||||
worktree_path: worktreePath,
|
||||
isolation_env_id: env.id,
|
||||
isolation_provider: env.provider,
|
||||
});
|
||||
} catch (error) {
|
||||
// ... existing error handling ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also update `cleanupWorktree` to use provider:
|
||||
```typescript
|
||||
private async cleanupWorktree(owner: string, repo: string, number: number): Promise<void> {
|
||||
// ... existing conversation lookup ...
|
||||
|
||||
const provider = getIsolationProvider();
|
||||
|
||||
// Clear isolation reference from THIS conversation first
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: null,
|
||||
isolation_env_id: null,
|
||||
isolation_provider: null,
|
||||
cwd: codebase.default_cwd,
|
||||
});
|
||||
|
||||
// Check if OTHER conversations still use this environment
|
||||
const otherConv = await db.getConversationByIsolationEnvId(worktreePath);
|
||||
if (otherConv) {
|
||||
console.log(`[GitHub] Keeping worktree, still used by ${otherConv.platform_conversation_id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Safe to destroy
|
||||
try {
|
||||
await provider.destroy(worktreePath);
|
||||
console.log(`[GitHub] Removed worktree: ${worktreePath}`);
|
||||
} catch (error) {
|
||||
// ... existing error handling ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### UPDATE src/handlers/command-handler.ts
|
||||
|
||||
- **IMPLEMENT**: Use provider for `/worktree` commands
|
||||
- **PATTERN**: Keep existing command structure
|
||||
- **IMPORTS**: `getIsolationProvider` from `../isolation`
|
||||
- **GOTCHA**: `/worktree create` should NOT deactivate session (preserve context)
|
||||
- **VALIDATE**: `bun test src/handlers/command-handler.test.ts`
|
||||
|
||||
Update `/worktree create` case:
|
||||
```typescript
|
||||
case 'create': {
|
||||
const branchName = args[1];
|
||||
if (!branchName) {
|
||||
return { success: false, message: 'Usage: /worktree create <branch-name>' };
|
||||
}
|
||||
|
||||
// Check if already using a worktree
|
||||
if (conversation.worktree_path || conversation.isolation_env_id) {
|
||||
const shortPath = shortenPath(conversation.worktree_path ?? conversation.isolation_env_id ?? '', mainPath);
|
||||
return {
|
||||
success: false,
|
||||
message: `Already using worktree: ${shortPath}\n\nRun /worktree remove first.`,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = getIsolationProvider();
|
||||
|
||||
try {
|
||||
const env = await provider.create({
|
||||
codebaseId: conversation.codebase_id!,
|
||||
canonicalRepoPath: mainPath,
|
||||
workflowType: 'task',
|
||||
identifier: branchName,
|
||||
description: `Manual worktree: ${branchName}`,
|
||||
});
|
||||
|
||||
// Update conversation to use this worktree
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: env.workingPath,
|
||||
isolation_env_id: env.id,
|
||||
isolation_provider: env.provider,
|
||||
cwd: env.workingPath,
|
||||
});
|
||||
|
||||
// NOTE: Do NOT deactivate session - preserve AI context
|
||||
|
||||
const shortPath = shortenPath(env.workingPath, mainPath);
|
||||
return {
|
||||
success: true,
|
||||
message: `Worktree created!\n\nBranch: ${branchName}\nPath: ${shortPath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., bun install).`,
|
||||
modified: true,
|
||||
};
|
||||
} catch (error) {
|
||||
// ... existing error handling ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update `/worktree remove` case similarly.
|
||||
|
||||
### Phase 4: Testing & Validation
|
||||
|
||||
#### ADD tests to src/db/conversations.test.ts
|
||||
|
||||
- **IMPLEMENT**: Tests for new isolation columns
|
||||
- **PATTERN**: Follow existing test patterns
|
||||
- **VALIDATE**: `bun test src/db/conversations.test.ts`
|
||||
|
||||
Add test cases:
|
||||
```typescript
|
||||
describe('isolation fields', () => {
|
||||
test('updateConversation updates isolation fields', async () => {
|
||||
// Create conversation
|
||||
// Update with isolation_env_id and isolation_provider
|
||||
// Verify fields are set
|
||||
});
|
||||
|
||||
test('getConversationByIsolationEnvId returns correct conversation', async () => {
|
||||
// Create conversation with isolation_env_id
|
||||
// Query by ID
|
||||
// Verify correct conversation returned
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### VERIFY all existing tests pass
|
||||
|
||||
- **VALIDATE**: `bun test`
|
||||
- **GOTCHA**: Some tests may need updates for new Conversation fields
|
||||
|
||||
---
|
||||
|
||||
## TESTING STRATEGY
|
||||
|
||||
### Unit Tests
|
||||
|
||||
**WorktreeProvider Tests** (`src/isolation/providers/worktree.test.ts`):
|
||||
- Test branch naming for all workflow types
|
||||
- Test hash consistency for thread identifiers
|
||||
- Test create flow for issues, PRs (with/without SHA)
|
||||
- Test adoption of existing worktrees
|
||||
- Test destroy with force flag
|
||||
- Test list filtering by codebase
|
||||
|
||||
**Database Tests** (`src/db/conversations.test.ts`):
|
||||
- Test updateConversation with new fields
|
||||
- Test getConversationByIsolationEnvId query
|
||||
|
||||
### Integration Tests
|
||||
|
||||
**Via Test Adapter:**
|
||||
```bash
|
||||
# Start app
|
||||
docker-compose --profile with-db up -d postgres
|
||||
bun run dev
|
||||
|
||||
# Test isolation creation
|
||||
curl -X POST http://localhost:3000/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"test-123","message":"/worktree create test-branch"}'
|
||||
|
||||
# Verify worktree created
|
||||
curl http://localhost:3000/test/messages/test-123 | jq
|
||||
|
||||
# Verify /worktree list shows it
|
||||
curl -X POST http://localhost:3000/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"test-123","message":"/worktree list"}'
|
||||
```
|
||||
|
||||
### Edge Cases
|
||||
|
||||
1. **Worktree adoption**: Skill creates worktree, app adopts it on PR event
|
||||
2. **Linked issue/PR sharing**: PR shares worktree with linked issue
|
||||
3. **Stale worktree path**: Worktree deleted externally, orchestrator handles gracefully
|
||||
4. **Concurrent creation**: Two platforms create isolation for same identifier
|
||||
|
||||
---
|
||||
|
||||
## VALIDATION COMMANDS
|
||||
|
||||
Execute every command to ensure zero regressions and 100% feature correctness.
|
||||
|
||||
### Level 1: Syntax & Style
|
||||
|
||||
```bash
|
||||
# TypeScript type checking (MUST pass with 0 errors)
|
||||
bun run type-check
|
||||
|
||||
# ESLint (MUST pass with 0 errors)
|
||||
bun run lint
|
||||
|
||||
# Prettier formatting check
|
||||
bun run format:check
|
||||
```
|
||||
|
||||
**Expected**: All commands pass with exit code 0
|
||||
|
||||
### Level 2: Unit Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
bun test
|
||||
|
||||
# Run specific test files
|
||||
bun test src/isolation/providers/worktree.test.ts
|
||||
bun test src/db/conversations.test.ts
|
||||
|
||||
# Run with coverage
|
||||
bun test --coverage
|
||||
```
|
||||
|
||||
**Expected**: All tests pass
|
||||
|
||||
### Level 3: Integration Tests
|
||||
|
||||
```bash
|
||||
# Start postgres
|
||||
docker-compose --profile with-db up -d postgres
|
||||
|
||||
# Run migration
|
||||
psql $DATABASE_URL < migrations/005_isolation_abstraction.sql
|
||||
|
||||
# Start app
|
||||
bun run dev
|
||||
|
||||
# Test via test adapter (in another terminal)
|
||||
curl -X POST http://localhost:3000/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"iso-test","message":"/status"}'
|
||||
```
|
||||
|
||||
### Level 4: Manual Validation
|
||||
|
||||
1. **GitHub webhook test**: Create issue, verify worktree created with provider
|
||||
2. **Worktree commands**: Test `/worktree create`, `/worktree list`, `/worktree remove`
|
||||
3. **Session preservation**: After `/worktree create`, verify AI context preserved
|
||||
|
||||
---
|
||||
|
||||
## ACCEPTANCE CRITERIA
|
||||
|
||||
- [x] `IIsolationProvider` interface defined in `src/isolation/types.ts`
|
||||
- [ ] `WorktreeProvider` implements all interface methods
|
||||
- [ ] `WorktreeProvider` passes all unit tests
|
||||
- [ ] Database migration adds isolation columns
|
||||
- [ ] `Conversation` type includes new fields
|
||||
- [ ] `updateConversation()` supports new fields
|
||||
- [ ] GitHub adapter uses provider for worktree creation
|
||||
- [ ] `/worktree` commands use provider
|
||||
- [ ] All existing tests pass
|
||||
- [ ] No type errors (`bun run type-check`)
|
||||
- [ ] No lint errors (`bun run lint`)
|
||||
- [ ] Proper formatting (`bun run format:check`)
|
||||
|
||||
---
|
||||
|
||||
## COMPLETION CHECKLIST
|
||||
|
||||
- [ ] All tasks completed in order
|
||||
- [ ] Each task validation passed immediately
|
||||
- [ ] All validation commands executed successfully:
|
||||
- [ ] Level 1: type-check, lint, format:check
|
||||
- [ ] Level 2: bun test with coverage
|
||||
- [ ] Level 3: Integration test via test adapter
|
||||
- [ ] Level 4: Manual GitHub webhook test
|
||||
- [ ] Full test suite passes (bun test)
|
||||
- [ ] No linting errors (bun run lint)
|
||||
- [ ] No formatting errors (bun run format:check)
|
||||
- [ ] No type checking errors (bun run type-check)
|
||||
- [ ] All acceptance criteria met
|
||||
- [ ] Code reviewed for quality and maintainability
|
||||
|
||||
---
|
||||
|
||||
## NOTES
|
||||
|
||||
### Design Decisions
|
||||
|
||||
1. **Strategy Pattern over Factory**: Using Strategy pattern with factory for provider selection enables runtime swapping and easier testing.
|
||||
|
||||
2. **Backwards Compatibility**: Keep `worktree_path` column during transition. Both old and new fields updated together.
|
||||
|
||||
3. **Session Preservation**: `/worktree create` does NOT deactivate session - preserves AI context for better UX.
|
||||
|
||||
4. **Semantic Identifiers**: Environment IDs use semantic prefixes (`issue-42`, `pr-42`, `thread-abc123`) for readability.
|
||||
|
||||
5. **No Orchestrator Auto-Isolation Yet**: GitHub adapter already handles isolation explicitly. Orchestrator `ensureIsolation()` prepared but not wired in - can be enabled for Slack/Discord later.
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
1. Phase 1-2: Create abstraction without breaking existing code
|
||||
2. Phase 3: Gradually migrate adapters to use provider
|
||||
3. Future: Remove `worktree_path` column after full migration
|
||||
|
||||
### Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|------|--------|------------|
|
||||
| Migration breaks existing worktrees | High | Keep `worktree_path` during transition, dual-read |
|
||||
| Test failures from new fields | Medium | Update test fixtures incrementally |
|
||||
| Performance overhead | Low | Provider is thin wrapper, minimal overhead |
|
||||
|
||||
### External Documentation References
|
||||
|
||||
- [Bun Mock Functions](https://bun.com/guides/test/mock-functions)
|
||||
- [Bun SpyOn](https://bun.com/guides/test/spy-on)
|
||||
- [TypeScript Provider Pattern](https://www.webdevtutor.net/blog/typescript-provider-pattern)
|
||||
- [Design Patterns in TypeScript](https://blog.logrocket.com/understanding-design-patterns-typescript-node-js/)
|
||||
390
.agents/plans/isolation-provider-manual-testing.md
Normal file
390
.agents/plans/isolation-provider-manual-testing.md
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
# Isolation Provider Manual Testing Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 1. Start the Application
|
||||
```bash
|
||||
# Terminal 1: Start postgres
|
||||
docker-compose --profile with-db up -d postgres
|
||||
|
||||
# Terminal 2: Run app with hot reload
|
||||
bun run dev
|
||||
```
|
||||
|
||||
### 2. Apply Database Migration
|
||||
```bash
|
||||
psql $DATABASE_URL < migrations/005_isolation_abstraction.sql
|
||||
```
|
||||
|
||||
### 3. Verify App is Running
|
||||
```bash
|
||||
curl http://localhost:3090/health
|
||||
# Expected: {"status":"ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Adapter (curl)
|
||||
|
||||
Use for quick validation without Slack/GitHub setup.
|
||||
|
||||
### Setup
|
||||
```bash
|
||||
# Clone a test repo
|
||||
curl -X POST http://localhost:3090/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"test-worktree","message":"/clone https://github.com/anthropics/anthropic-cookbook"}'
|
||||
|
||||
# Wait for clone to complete (~10s)
|
||||
sleep 10
|
||||
|
||||
# Check status
|
||||
curl http://localhost:3090/test/messages/test-worktree | jq '.messages[-1].message'
|
||||
```
|
||||
|
||||
### Test Worktree Create
|
||||
```bash
|
||||
curl -X POST http://localhost:3090/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"test-worktree","message":"/worktree create my-feature"}'
|
||||
|
||||
sleep 2
|
||||
curl http://localhost:3090/test/messages/test-worktree | jq -r '.messages[-1].message'
|
||||
```
|
||||
**Expected:**
|
||||
```
|
||||
Worktree created!
|
||||
|
||||
Branch: task-my-feature
|
||||
Path: /Users/.../worktrees/anthropic-cookbook/task-my-feature
|
||||
|
||||
This conversation now works in isolation.
|
||||
Run dependency install if needed (e.g., bun install).
|
||||
```
|
||||
|
||||
### Test Worktree List
|
||||
```bash
|
||||
curl -X POST http://localhost:3090/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"test-worktree","message":"/worktree list"}'
|
||||
|
||||
sleep 1
|
||||
curl http://localhost:3090/test/messages/test-worktree | jq -r '.messages[-1].message'
|
||||
```
|
||||
**Expected:** Shows worktree with `<- active` marker
|
||||
|
||||
### Test Worktree Remove
|
||||
```bash
|
||||
curl -X POST http://localhost:3090/test/message \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"conversationId":"test-worktree","message":"/worktree remove"}'
|
||||
|
||||
sleep 2
|
||||
curl http://localhost:3090/test/messages/test-worktree | jq -r '.messages[-1].message'
|
||||
```
|
||||
**Expected:** "Worktree removed... Switched back to main repo."
|
||||
|
||||
### Verify Database
|
||||
```bash
|
||||
psql $DATABASE_URL -c "
|
||||
SELECT platform_conversation_id,
|
||||
COALESCE(worktree_path, 'NULL') as worktree_path,
|
||||
COALESCE(isolation_env_id, 'NULL') as isolation_env_id,
|
||||
COALESCE(isolation_provider, 'NULL') as isolation_provider
|
||||
FROM remote_agent_conversations
|
||||
WHERE platform_conversation_id = 'test-worktree';"
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
```bash
|
||||
curl -X DELETE http://localhost:3090/test/messages/test-worktree
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Slack Testing
|
||||
|
||||
### Prerequisites
|
||||
- Bot configured with `SLACK_BOT_TOKEN` and `SLACK_APP_TOKEN`
|
||||
- Bot added to a test channel
|
||||
- Bot mention name: `@Dylan` (or your configured name)
|
||||
|
||||
### Test 1: Setup Repository
|
||||
|
||||
In any Slack channel or thread:
|
||||
```
|
||||
@Dylan /clone https://github.com/anthropics/anthropic-cookbook
|
||||
```
|
||||
**Expected:** "Repository cloned successfully!"
|
||||
|
||||
### Test 2: Create Worktree
|
||||
```
|
||||
@Dylan /worktree create slack-feature-test
|
||||
```
|
||||
**Expected:**
|
||||
```
|
||||
Worktree created!
|
||||
|
||||
Branch: task-slack-feature-test
|
||||
Path: .../worktrees/anthropic-cookbook/task-slack-feature-test
|
||||
|
||||
This conversation now works in isolation.
|
||||
Run dependency install if needed (e.g., bun install).
|
||||
```
|
||||
|
||||
### Test 3: Verify Isolation
|
||||
```
|
||||
@Dylan /status
|
||||
```
|
||||
**Expected:** Shows current working directory as the worktree path
|
||||
|
||||
### Test 4: List Worktrees
|
||||
```
|
||||
@Dylan /worktree list
|
||||
```
|
||||
**Expected:** Shows all worktrees with `<- active` marker on current one
|
||||
|
||||
### Test 5: Work in Isolation
|
||||
```
|
||||
@Dylan /command-invoke prime
|
||||
```
|
||||
**Expected:** AI works within the isolated worktree
|
||||
|
||||
### Test 6: Remove Worktree
|
||||
```
|
||||
@Dylan /worktree remove
|
||||
```
|
||||
**Expected:** "Worktree removed... Switched back to main repo."
|
||||
|
||||
### Test 7: Verify Cleanup
|
||||
```
|
||||
@Dylan /status
|
||||
```
|
||||
**Expected:** CWD back to main repository path
|
||||
|
||||
---
|
||||
|
||||
## GitHub Testing
|
||||
|
||||
### Prerequisites
|
||||
- GitHub App or webhook configured
|
||||
- `GITHUB_TOKEN` set for CLI operations
|
||||
- Bot mention: `@Dylan` (configured via `GITHUB_BOT_MENTION`)
|
||||
|
||||
### Test 1: Issue Workflow - Auto Worktree Creation
|
||||
|
||||
1. **Create a new issue** in your test repository
|
||||
|
||||
2. **Trigger the bot** by commenting:
|
||||
```
|
||||
@Dylan /command-invoke plan
|
||||
```
|
||||
|
||||
3. **Expected behavior:**
|
||||
- Bot creates worktree `issue-{number}`
|
||||
- Bot responds with plan in isolated environment
|
||||
- Check logs: `[GitHub] Created worktree: .../issue-{number}`
|
||||
|
||||
4. **Verify isolation:**
|
||||
```
|
||||
@Dylan /status
|
||||
```
|
||||
**Expected:** Shows worktree path like `/worktrees/repo/issue-42`
|
||||
|
||||
5. **Close the issue**
|
||||
|
||||
6. **Expected cleanup:**
|
||||
- Worktree automatically removed
|
||||
- Check logs: `[GitHub] Removed worktree: ...`
|
||||
|
||||
### Test 2: PR Workflow - SHA-based Review
|
||||
|
||||
1. **Create a PR** (or use existing)
|
||||
|
||||
2. **Trigger review:**
|
||||
```
|
||||
@Dylan /command-invoke review
|
||||
```
|
||||
|
||||
3. **Expected behavior:**
|
||||
- Bot fetches PR head SHA
|
||||
- Creates worktree at exact commit: `pr-{number}-review`
|
||||
- Bot can review the exact code state
|
||||
|
||||
4. **Check the worktree:**
|
||||
```
|
||||
@Dylan /worktree list
|
||||
```
|
||||
**Expected:** Shows `pr-{number}-review` as active
|
||||
|
||||
5. **Merge or close the PR**
|
||||
|
||||
6. **Expected cleanup:** Worktree removed automatically
|
||||
|
||||
### Test 3: Linked Issue/PR Worktree Sharing
|
||||
|
||||
1. **Create issue #X**
|
||||
|
||||
2. **Start work on issue:**
|
||||
```
|
||||
@Dylan /command-invoke plan
|
||||
```
|
||||
**Expected:** Creates worktree `issue-X`
|
||||
|
||||
3. **Note the worktree path** from the response or logs
|
||||
|
||||
4. **Create a PR** with description containing `Fixes #X` or `Closes #X`
|
||||
|
||||
5. **Comment on the PR:**
|
||||
```
|
||||
@Dylan /status
|
||||
```
|
||||
|
||||
6. **Expected:** PR uses the SAME worktree as the linked issue
|
||||
- Check logs: `[GitHub] PR #Y linked to issue #X, sharing worktree: ...`
|
||||
|
||||
7. **Close the issue** (PR still open)
|
||||
|
||||
8. **Expected:** Worktree NOT removed (PR still using it)
|
||||
|
||||
9. **Merge/close the PR**
|
||||
|
||||
10. **Expected:** Worktree removed (no more references)
|
||||
|
||||
### Test 4: Fork PR Handling
|
||||
|
||||
1. **Have someone create a PR from a fork**
|
||||
|
||||
2. **Comment:**
|
||||
```
|
||||
@Dylan /command-invoke review
|
||||
```
|
||||
|
||||
3. **Expected:**
|
||||
- Uses GitHub PR refs (`pull/{number}/head`) to fetch fork code
|
||||
- Creates worktree successfully even though branch is from fork
|
||||
- Check logs for: `fetch origin pull/{number}/head`
|
||||
|
||||
---
|
||||
|
||||
## Database Verification Commands
|
||||
|
||||
### Check All Active Worktrees
|
||||
```bash
|
||||
psql $DATABASE_URL -c "
|
||||
SELECT platform_type,
|
||||
platform_conversation_id,
|
||||
worktree_path,
|
||||
isolation_env_id,
|
||||
isolation_provider
|
||||
FROM remote_agent_conversations
|
||||
WHERE isolation_env_id IS NOT NULL
|
||||
OR worktree_path IS NOT NULL
|
||||
ORDER BY updated_at DESC;"
|
||||
```
|
||||
|
||||
### Verify Both Fields Populated
|
||||
```bash
|
||||
psql $DATABASE_URL -c "
|
||||
SELECT
|
||||
COUNT(*) as total_with_isolation,
|
||||
COUNT(worktree_path) as has_worktree_path,
|
||||
COUNT(isolation_env_id) as has_isolation_env_id,
|
||||
COUNT(isolation_provider) as has_isolation_provider
|
||||
FROM remote_agent_conversations
|
||||
WHERE worktree_path IS NOT NULL
|
||||
OR isolation_env_id IS NOT NULL;"
|
||||
```
|
||||
**Expected:** All three counts should be equal (both fields populated)
|
||||
|
||||
### Check Specific Conversation
|
||||
```bash
|
||||
psql $DATABASE_URL -c "
|
||||
SELECT * FROM remote_agent_conversations
|
||||
WHERE platform_conversation_id = 'owner/repo#42';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Expected Branch Naming
|
||||
|
||||
| Workflow | Branch Name |
|
||||
|----------|-------------|
|
||||
| Issue #42 | `issue-42` |
|
||||
| PR #42 (no existing branch) | `pr-42` |
|
||||
| PR #42 review (with SHA) | `pr-42-review` |
|
||||
| Manual `/worktree create foo` | `task-foo` |
|
||||
| Slack/Discord thread (future) | `thread-{8-char-hash}` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Worktree Creation Fails
|
||||
```
|
||||
Failed to create isolated worktree for branch `issue-42`
|
||||
```
|
||||
**Causes:**
|
||||
- Branch already exists: Try with different issue number
|
||||
- Git repo not clean: Check for uncommitted changes in main repo
|
||||
- Permission issues: Check filesystem permissions
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# List existing worktrees
|
||||
git -C /path/to/repo worktree list
|
||||
|
||||
# Remove stale worktree
|
||||
git -C /path/to/repo worktree remove /path/to/worktree --force
|
||||
```
|
||||
|
||||
### Database Column Missing
|
||||
```
|
||||
column "isolation_env_id" does not exist
|
||||
```
|
||||
**Fix:** Apply migration
|
||||
```bash
|
||||
psql $DATABASE_URL < migrations/005_isolation_abstraction.sql
|
||||
```
|
||||
|
||||
### Worktree Not Cleaned Up on Close
|
||||
Check server logs for errors. Common issues:
|
||||
- Uncommitted changes in worktree (won't force-delete)
|
||||
- Another conversation still references the worktree
|
||||
|
||||
**Manual cleanup:**
|
||||
```bash
|
||||
git -C /path/to/main/repo worktree remove /path/to/worktree --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Server Log Messages to Watch
|
||||
|
||||
### Successful Creation
|
||||
```
|
||||
[GitHub] PR #42 head branch: feature/auth, SHA: abc123
|
||||
[GitHub] Created worktree: /path/to/worktrees/repo/pr-42-review
|
||||
```
|
||||
|
||||
### Worktree Adoption (Skill Symbiosis)
|
||||
```
|
||||
[WorktreeProvider] Adopting existing worktree: /path/to/worktrees/repo/feature-auth
|
||||
```
|
||||
|
||||
### Linked Issue Sharing
|
||||
```
|
||||
[GitHub] PR #43 linked to issue #42, sharing worktree: /path/to/worktrees/repo/issue-42
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
```
|
||||
[GitHub] Deactivated session abc123 for worktree cleanup
|
||||
[GitHub] Removed worktree: /path/to/worktrees/repo/issue-42
|
||||
[GitHub] Cleanup complete for owner/repo#42
|
||||
```
|
||||
|
||||
### Shared Worktree Preserved
|
||||
```
|
||||
[GitHub] Keeping worktree /path/..., still used by owner/repo#43
|
||||
```
|
||||
145
.agents/plans/isolation-provider-migration-status.md
Normal file
145
.agents/plans/isolation-provider-migration-status.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# Isolation Provider Migration Status
|
||||
|
||||
## Overview
|
||||
|
||||
This document tracks the migration from direct git worktree usage to the `IIsolationProvider` abstraction.
|
||||
|
||||
## Completed (Phase 1)
|
||||
|
||||
### Core Abstraction
|
||||
- [x] `src/isolation/types.ts` - Interfaces (`IsolationRequest`, `IsolatedEnvironment`, `IIsolationProvider`)
|
||||
- [x] `src/isolation/providers/worktree.ts` - `WorktreeProvider` implementation
|
||||
- [x] `src/isolation/providers/worktree.test.ts` - Unit tests (25 tests passing)
|
||||
- [x] `src/isolation/index.ts` - Factory with singleton pattern
|
||||
|
||||
### Database
|
||||
- [x] `migrations/005_isolation_abstraction.sql` - Adds `isolation_env_id` and `isolation_provider` columns
|
||||
- [x] `src/types/index.ts` - Added new fields to `Conversation` type
|
||||
- [x] `src/db/conversations.ts` - Updated `updateConversation()`, added `getConversationByIsolationEnvId()`
|
||||
|
||||
### Adapters
|
||||
- [x] `src/adapters/github.ts` - Migrated to use provider for create/destroy
|
||||
- [x] `src/handlers/command-handler.ts` - `/worktree` commands use provider
|
||||
|
||||
---
|
||||
|
||||
## Not Yet Migrated
|
||||
|
||||
### Orchestrator CWD Resolution
|
||||
**File:** `src/orchestrator/orchestrator.ts`
|
||||
|
||||
The orchestrator still references `worktree_path` directly:
|
||||
|
||||
```typescript
|
||||
// Line 151: Resume session CWD
|
||||
const cwd = conversation.worktree_path ?? conversation.cwd ?? codebase.default_cwd;
|
||||
|
||||
// Line 240: New session CWD
|
||||
let cwd = conversation.worktree_path ?? conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
|
||||
|
||||
// Lines 256-259: Stale worktree cleanup
|
||||
if (conversation.worktree_path) {
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: null,
|
||||
cwd: codebase?.default_cwd ?? '/workspace',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
```typescript
|
||||
// Should become:
|
||||
const cwd = conversation.isolation_env_id ?? conversation.worktree_path ?? conversation.cwd ?? codebase?.default_cwd ?? '/workspace';
|
||||
|
||||
// And cleanup should clear both:
|
||||
if (conversation.isolation_env_id || conversation.worktree_path) {
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: null,
|
||||
isolation_env_id: null,
|
||||
isolation_provider: null,
|
||||
cwd: codebase?.default_cwd ?? '/workspace',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### GitHub Linked Issue Sharing
|
||||
**File:** `src/adapters/github.ts`
|
||||
|
||||
Lines 627-647 still query by `worktree_path` for linked issue detection:
|
||||
|
||||
```typescript
|
||||
const issueConv = await db.getConversationByPlatformId('github', issueConvId);
|
||||
if (issueConv?.worktree_path) {
|
||||
worktreePath = issueConv.worktree_path;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Required changes:**
|
||||
- Should also check `isolation_env_id` field
|
||||
- Or use `getConversationByIsolationEnvId()` for lookups
|
||||
|
||||
### Other Platform Adapters (Phase 3)
|
||||
|
||||
These adapters do NOT currently create worktrees automatically:
|
||||
|
||||
| Adapter | File | Auto-Isolation |
|
||||
|---------|------|----------------|
|
||||
| Slack | `src/adapters/slack.ts` | No |
|
||||
| Discord | `src/adapters/discord.ts` | No |
|
||||
| Telegram | `src/adapters/telegram.ts` | No |
|
||||
|
||||
**To enable (Phase 3):**
|
||||
1. Add provider call in message handler
|
||||
2. Use workflow type `thread` with conversation ID as identifier
|
||||
3. Branch naming: `thread-{8-char-hash}`
|
||||
|
||||
### Database Column Cleanup (Future)
|
||||
|
||||
The `worktree_path` column is kept for backwards compatibility. After full migration:
|
||||
|
||||
```sql
|
||||
-- Future migration (after all code uses isolation_env_id)
|
||||
ALTER TABLE remote_agent_conversations DROP COLUMN worktree_path;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
### Before Dropping worktree_path
|
||||
|
||||
- [ ] All orchestrator references updated to `isolation_env_id`
|
||||
- [ ] GitHub linked issue sharing uses `isolation_env_id`
|
||||
- [ ] All tests updated to use new fields
|
||||
- [ ] Production data migrated (already done via migration 005)
|
||||
- [ ] Monitoring confirms no code paths use `worktree_path` exclusively
|
||||
|
||||
### Phase 3: Platform Parity
|
||||
|
||||
- [ ] Slack adapter auto-creates worktrees for threads
|
||||
- [ ] Discord adapter auto-creates worktrees for threads
|
||||
- [ ] Telegram adapter auto-creates worktrees for chats
|
||||
- [ ] Test adapter supports isolation (for E2E testing)
|
||||
|
||||
---
|
||||
|
||||
## Files Still Referencing worktree_path
|
||||
|
||||
```
|
||||
src/types/index.ts # Type definition (keep for now)
|
||||
src/handlers/command-handler.ts # Backwards compat checks
|
||||
src/handlers/command-handler.test.ts # Test fixtures
|
||||
src/db/conversations.ts # Update function, query function
|
||||
src/adapters/github.ts # Linked issue sharing, cleanup
|
||||
src/orchestrator/orchestrator.ts # CWD resolution (NEEDS UPDATE)
|
||||
src/orchestrator/orchestrator.test.ts # Test fixtures
|
||||
src/db/conversations.test.ts # Test fixtures
|
||||
src/adapters/github.test.ts # Test fixtures
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing the Migration
|
||||
|
||||
See: `.agents/plans/isolation-provider-manual-testing.md`
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Comprehensive guide to understanding and extending the Remote Coding Agent platform.
|
||||
|
||||
**Navigation:** [Overview](#system-overview) • [Adding Platforms](#adding-platform-adapters) • [Adding AI Assistants](#adding-ai-assistant-clients) • [Commands](#command-system) • [Streaming](#streaming-modes) • [Database](#database-schema)
|
||||
**Navigation:** [Overview](#system-overview) • [Platforms](#adding-platform-adapters) • [AI Assistants](#adding-ai-assistant-clients) • [Isolation](#isolation-providers) • [Commands](#command-system) • [Streaming](#streaming-modes) • [Database](#database-schema)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -28,19 +28,19 @@ The Remote Coding Agent is a **platform-agnostic AI coding assistant orchestrato
|
|||
│ • Stream responses back to platforms │
|
||||
└──────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌───────┴────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────┐ ┌─────────────────────┐
|
||||
│ Command │ │ AI Assistant │
|
||||
│ Handler │ │ Clients │
|
||||
│ │ │ • IAssistantClient │
|
||||
│ (Slash │ │ • Factory pattern │
|
||||
│ commands) │ │ • Streaming API │
|
||||
└─────────────┘ └────────┬────────────┘
|
||||
│ │
|
||||
└────────┬─────────┘
|
||||
▼
|
||||
┌───────┼────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌───────────┐ ┌───────────────┐ ┌───────────────────┐
|
||||
│ Command │ │ AI Assistant │ │ Isolation │
|
||||
│ Handler │ │ Clients │ │ Providers │
|
||||
│ │ │ │ │ │
|
||||
│ (Slash │ │ IAssistant- │ │ IIsolationProvider│
|
||||
│ commands) │ │ Client │ │ (worktree, etc.) │
|
||||
└─────┬─────┘ └───────┬───────┘ └─────────┬─────────┘
|
||||
│ │ │
|
||||
└───────────────┼───────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ PostgreSQL (3 Tables) │
|
||||
│ • Codebases • Conversations • Sessions │
|
||||
|
|
@ -451,6 +451,205 @@ if (event.type === 'error') {
|
|||
|
||||
---
|
||||
|
||||
## Isolation Providers
|
||||
|
||||
Isolation providers create isolated working environments (worktrees, containers, VMs) for concurrent workflows. The default implementation uses git worktrees.
|
||||
|
||||
### IIsolationProvider Interface
|
||||
|
||||
**Location:** `src/isolation/types.ts`
|
||||
|
||||
```typescript
|
||||
export interface IIsolationProvider {
|
||||
readonly providerType: string;
|
||||
create(request: IsolationRequest): Promise<IsolatedEnvironment>;
|
||||
destroy(envId: string, options?: { force?: boolean }): Promise<void>;
|
||||
get(envId: string): Promise<IsolatedEnvironment | null>;
|
||||
list(codebaseId: string): Promise<IsolatedEnvironment[]>;
|
||||
adopt?(path: string): Promise<IsolatedEnvironment | null>;
|
||||
healthCheck(envId: string): Promise<boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### Request & Response Types
|
||||
|
||||
```typescript
|
||||
interface IsolationRequest {
|
||||
codebaseId: string;
|
||||
canonicalRepoPath: string; // Main repo path, never a worktree
|
||||
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
|
||||
identifier: string; // "42", "feature-auth", etc.
|
||||
prBranch?: string; // For PR adoption
|
||||
prSha?: string; // For reproducible PR reviews
|
||||
}
|
||||
|
||||
interface IsolatedEnvironment {
|
||||
id: string; // Worktree path (for worktree provider)
|
||||
provider: 'worktree' | 'container' | 'vm' | 'remote';
|
||||
workingPath: string; // Where AI should work
|
||||
branchName?: string;
|
||||
status: 'active' | 'suspended' | 'destroyed';
|
||||
createdAt: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
### WorktreeProvider Implementation
|
||||
|
||||
**Location:** `src/isolation/providers/worktree.ts`
|
||||
|
||||
```typescript
|
||||
export class WorktreeProvider implements IIsolationProvider {
|
||||
readonly providerType = 'worktree';
|
||||
|
||||
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
|
||||
// 1. Check for existing worktree (adoption)
|
||||
// 2. Generate branch name from workflowType + identifier
|
||||
// 3. Create git worktree at computed path
|
||||
// 4. Return IsolatedEnvironment
|
||||
}
|
||||
|
||||
async destroy(envId: string, options?: { force?: boolean }): Promise<void> {
|
||||
// git worktree remove <path> [--force]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Branch Naming Convention
|
||||
|
||||
| Workflow | Identifier | Generated Branch |
|
||||
|----------|------------|------------------|
|
||||
| issue | `"42"` | `issue-42` |
|
||||
| pr | `"123"` | `pr-123` |
|
||||
| pr + SHA | `"123"` | `pr-123-review` |
|
||||
| task | `"my-feature"` | `task-my-feature` |
|
||||
| thread | `"C123:ts.123"` | `thread-a1b2c3d4` (8-char hash) |
|
||||
|
||||
### Storage Location
|
||||
|
||||
```
|
||||
LOCAL: ~/tmp/worktrees/<project>/<branch>/ ← WORKTREE_BASE can override
|
||||
DOCKER: /workspace/worktrees/<project>/<branch>/ ← FIXED, no override
|
||||
```
|
||||
|
||||
**Logic in `getWorktreeBase()`:**
|
||||
1. Docker detected? → `/workspace/worktrees` (always, no override)
|
||||
2. `WORKTREE_BASE` set? → use it (local only)
|
||||
3. Default → `~/tmp/worktrees`
|
||||
|
||||
### Usage Pattern
|
||||
|
||||
**GitHub adapter** (`src/adapters/github.ts`):
|
||||
|
||||
```typescript
|
||||
const provider = getIsolationProvider();
|
||||
|
||||
// On @bot mention
|
||||
const env = await provider.create({
|
||||
codebaseId: codebase.id,
|
||||
canonicalRepoPath: repoPath,
|
||||
workflowType: isPR ? 'pr' : 'issue',
|
||||
identifier: String(number),
|
||||
prBranch: prHeadBranch,
|
||||
prSha: prHeadSha,
|
||||
});
|
||||
|
||||
// Update conversation
|
||||
await db.updateConversation(conv.id, {
|
||||
cwd: env.workingPath,
|
||||
isolation_env_id: env.id,
|
||||
isolation_provider: env.provider,
|
||||
});
|
||||
|
||||
// On issue/PR close
|
||||
await provider.destroy(isolationEnvId);
|
||||
```
|
||||
|
||||
**Command handler** (`/worktree create`):
|
||||
|
||||
```typescript
|
||||
const provider = getIsolationProvider();
|
||||
const env = await provider.create({
|
||||
workflowType: 'task',
|
||||
identifier: branchName,
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Worktree Adoption
|
||||
|
||||
The provider adopts existing worktrees before creating new ones:
|
||||
|
||||
1. **Path match**: If worktree exists at expected path → adopt
|
||||
2. **Branch match**: If PR's branch has existing worktree → adopt (skill symbiosis)
|
||||
|
||||
```typescript
|
||||
// Inside create()
|
||||
const existing = await this.findExisting(request, branchName, worktreePath);
|
||||
if (existing) {
|
||||
return existing; // metadata.adopted = true
|
||||
}
|
||||
// ... else create new
|
||||
```
|
||||
|
||||
### Database Fields
|
||||
|
||||
```sql
|
||||
remote_agent_conversations
|
||||
├── worktree_path -- LEGACY (kept for compatibility)
|
||||
├── isolation_env_id -- NEW: provider-assigned ID (worktree path)
|
||||
└── isolation_provider -- NEW: 'worktree' | 'container' | ...
|
||||
```
|
||||
|
||||
**Lookup pattern:**
|
||||
```typescript
|
||||
const envId = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
```
|
||||
|
||||
### Adding a New Isolation Provider
|
||||
|
||||
**1. Create provider:** `src/isolation/providers/your-provider.ts`
|
||||
|
||||
```typescript
|
||||
export class ContainerProvider implements IIsolationProvider {
|
||||
readonly providerType = 'container';
|
||||
|
||||
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
|
||||
// Spin up Docker container with repo mounted
|
||||
const containerId = await docker.createContainer({...});
|
||||
return {
|
||||
id: containerId,
|
||||
provider: 'container',
|
||||
workingPath: '/workspace',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
metadata: { request },
|
||||
};
|
||||
}
|
||||
|
||||
async destroy(envId: string): Promise<void> {
|
||||
await docker.removeContainer(envId);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Register in factory:** `src/isolation/index.ts`
|
||||
|
||||
```typescript
|
||||
export function getIsolationProvider(type?: string): IIsolationProvider {
|
||||
switch (type) {
|
||||
case 'container':
|
||||
return new ContainerProvider();
|
||||
default:
|
||||
return new WorktreeProvider();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**See also:** [Worktree Orchestration](./worktree-orchestration.md) for detailed flow diagrams.
|
||||
|
||||
---
|
||||
|
||||
## Command System
|
||||
|
||||
The command system allows users to define custom workflows in Git-versioned markdown files.
|
||||
|
|
@ -958,6 +1157,18 @@ Post single comment on issue with summary
|
|||
- [ ] Test session persistence across restarts
|
||||
- [ ] Test plan→execute transition (new session)
|
||||
|
||||
### Adding a New Isolation Provider
|
||||
|
||||
- [ ] Create `src/isolation/providers/your-provider.ts`
|
||||
- [ ] Implement `IIsolationProvider` interface
|
||||
- [ ] Handle `create()`, `destroy()`, `get()`, `list()`, `healthCheck()`
|
||||
- [ ] Optional: implement `adopt()` for existing environment discovery
|
||||
- [ ] Register in `src/isolation/index.ts` factory
|
||||
- [ ] Update database columns if needed (`isolation_provider` type)
|
||||
- [ ] Test creation and cleanup lifecycle
|
||||
- [ ] Test concurrent environments (multiple conversations)
|
||||
- [ ] Document in `docs/worktree-orchestration.md` (or create new doc)
|
||||
|
||||
### Modifying Command System
|
||||
|
||||
- [ ] Update `substituteVariables()` for new variable types
|
||||
|
|
@ -1035,7 +1246,7 @@ await handleMessage(adapter, conversationId, finalMessage);
|
|||
|
||||
## Key Takeaways
|
||||
|
||||
1. **Interfaces enable extensibility**: Both `IPlatformAdapter` and `IAssistantClient` allow adding platforms and AI assistants without modifying core logic
|
||||
1. **Interfaces enable extensibility**: `IPlatformAdapter`, `IAssistantClient`, and `IIsolationProvider` allow adding platforms, AI assistants, and isolation strategies without modifying core logic
|
||||
|
||||
2. **Async generators for streaming**: All AI clients return `AsyncGenerator<MessageChunk>` for unified streaming across different SDKs
|
||||
|
||||
|
|
@ -1047,15 +1258,18 @@ await handleMessage(adapter, conversationId, finalMessage);
|
|||
|
||||
6. **Plan→execute is special**: Only transition requiring new session (prevents token bloat during implementation)
|
||||
|
||||
7. **Factory pattern for clients**: Single factory function (`getAssistantClient()`) instantiates correct client based on conversation's `ai_assistant_type`
|
||||
7. **Factory pattern**: `getAssistantClient()` and `getIsolationProvider()` instantiate correct implementations based on configuration
|
||||
|
||||
8. **Error recovery**: Always provide `/reset` escape hatch for users when sessions get stuck
|
||||
|
||||
9. **Isolation adoption**: Providers check for existing environments before creating new ones (enables skill symbiosis)
|
||||
|
||||
---
|
||||
|
||||
**For detailed implementation examples, see:**
|
||||
|
||||
- Platform adapter: `src/adapters/telegram.ts`, `src/adapters/github.ts`
|
||||
- AI client: `src/clients/claude.ts`, `src/clients/codex.ts`
|
||||
- Isolation provider: `src/isolation/providers/worktree.ts`
|
||||
- Orchestrator: `src/orchestrator/orchestrator.ts`
|
||||
- Command handler: `src/handlers/command-handler.ts`
|
||||
|
|
|
|||
263
docs/worktree-orchestration.md
Normal file
263
docs/worktree-orchestration.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# Worktree Orchestration
|
||||
|
||||
## Storage Location
|
||||
|
||||
```
|
||||
LOCAL: ~/tmp/worktrees/<project>/<branch>/ ← WORKTREE_BASE can override
|
||||
DOCKER: /workspace/worktrees/<project>/<branch>/ ← FIXED, no override
|
||||
```
|
||||
|
||||
Detection order in `getWorktreeBase()`:
|
||||
```
|
||||
1. isDocker? → /workspace/worktrees (ALWAYS)
|
||||
2. WORKTREE_BASE set? → use it (local only)
|
||||
3. default → ~/tmp/worktrees
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ENTRY POINTS │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ GitHub Adapter │ Command Handler (/worktree) │
|
||||
│ - Issue/PR webhooks │ - /worktree create <branch> │
|
||||
│ - Auto-create on @bot │ - /worktree remove [--force] │
|
||||
│ - Auto-cleanup on close │ - /worktree list / orphans │
|
||||
└────────────┬─────────────┴────────────────┬─────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ ISOLATION PROVIDER │
|
||||
│ getIsolationProvider() → WorktreeProvider (singleton) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ create(request) → IsolatedEnvironment │
|
||||
│ destroy(envId) → void │
|
||||
│ get(envId) → IsolatedEnvironment | null │
|
||||
│ list(codebaseId) → IsolatedEnvironment[] │
|
||||
│ adopt(path) → IsolatedEnvironment | null │
|
||||
│ healthCheck(id) → boolean │
|
||||
└────────────┬────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ GIT OPERATIONS │
|
||||
│ git worktree add/remove/list │
|
||||
│ git fetch origin pull/<N>/head (for PRs) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Request Types & Branch Naming
|
||||
|
||||
```typescript
|
||||
interface IsolationRequest {
|
||||
codebaseId: string;
|
||||
canonicalRepoPath: string; // Main repo, never a worktree
|
||||
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
|
||||
identifier: string;
|
||||
prBranch?: string; // For PR adoption
|
||||
prSha?: string; // For reproducible reviews
|
||||
}
|
||||
```
|
||||
|
||||
| Workflow | Identifier | Branch Name |
|
||||
|----------|------------|-------------|
|
||||
| issue | `"42"` | `issue-42` |
|
||||
| pr | `"123"` | `pr-123` |
|
||||
| pr + SHA | `"123"` | `pr-123-review` |
|
||||
| task | `"my-feature"` | `task-my-feature` |
|
||||
| thread | `"C123:ts.123"` | `thread-a1b2c3d4` (hash) |
|
||||
|
||||
## Creation Flow
|
||||
|
||||
```
|
||||
IsolationRequest
|
||||
│
|
||||
▼
|
||||
┌──────────────┐ exists? ┌──────────────┐
|
||||
│ Check path │────────YES─────▶│ ADOPT │──▶ return existing
|
||||
│ worktreeExists() │ metadata.adopted=true
|
||||
└──────┬───────┘ └──────────────┘
|
||||
│ NO
|
||||
▼
|
||||
┌──────────────┐ found? ┌──────────────┐
|
||||
│ PR? Check │────────YES─────▶│ ADOPT │──▶ return existing
|
||||
│ branch match │ │ by branch │
|
||||
│ findWorktreeByBranch() └──────────────┘
|
||||
└──────┬───────┘
|
||||
│ NO
|
||||
▼
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ CREATE NEW WORKTREE │
|
||||
│ │
|
||||
│ Issue/Task: │
|
||||
│ git worktree add <path> -b <branch> │
|
||||
│ (falls back to existing branch if exists) │
|
||||
│ │
|
||||
│ PR with SHA: │
|
||||
│ git fetch origin pull/<N>/head │
|
||||
│ git worktree add <path> <sha> │
|
||||
│ git checkout -b pr-<N>-review <sha> │
|
||||
│ │
|
||||
│ PR without SHA: │
|
||||
│ git fetch origin pull/<N>/head:pr-<N>-review │
|
||||
│ git worktree add <path> pr-<N>-review │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## GitHub Lifecycle
|
||||
|
||||
```
|
||||
┌─────────────────── ISSUE/PR OPENED ───────────────────┐
|
||||
│ │
|
||||
│ @bot mention detected │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ Check for shared worktree │ │
|
||||
│ │ (linked issue/PR via "Fixes #X") │ │
|
||||
│ └──────────────┬──────────────────────┘ │
|
||||
│ │ │
|
||||
│ found? │ │
|
||||
│ ┌────YES─────┴─────NO────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ REUSE CREATE │
|
||||
│ existing provider.create() │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ UPDATE DATABASE │ │
|
||||
│ │ cwd = worktreePath │ │
|
||||
│ │ worktree_path = worktreePath │ │
|
||||
│ │ isolation_env_id = envId │ │
|
||||
│ │ isolation_provider = 'worktree'│ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ AI works in isolated worktree... │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────── ISSUE/PR CLOSED ───────────────────┐
|
||||
│ │
|
||||
│ cleanupPRWorktree() called │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 1. Clear THIS conversation's refs │ │
|
||||
│ │ worktree_path = NULL │ │
|
||||
│ │ isolation_env_id = NULL │ │
|
||||
│ │ cwd = main repo │ │
|
||||
│ └──────────────┬──────────────────────┘ │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌─────────────────────────────────────┐ │
|
||||
│ │ 2. Check: other conversations │ │
|
||||
│ │ using same worktree? │ │
|
||||
│ └──────────────┬──────────────────────┘ │
|
||||
│ │ │
|
||||
│ YES │ NO │
|
||||
│ ┌────────────┴───────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ KEEP DESTROY │
|
||||
│ (log: still provider.destroy(envId) │
|
||||
│ used by...) │
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌───────────────────┐ │
|
||||
│ │ uncommitted │ │
|
||||
│ │ changes? │ │
|
||||
│ └─────────┬─────────┘ │
|
||||
│ YES │ NO │
|
||||
│ ┌─────────┴─────────┐ │
|
||||
│ ▼ ▼ │
|
||||
│ FAIL git worktree │
|
||||
│ (notify user) remove <path> │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Shared Worktree (Linked Issue/PR)
|
||||
|
||||
```
|
||||
Issue #42: "Fix login bug"
|
||||
│
|
||||
│ User works on issue
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ worktree: issue-42 │
|
||||
│ conversations: │
|
||||
│ - owner/repo#42 │◀─── Issue references this
|
||||
└──────────────────────┘
|
||||
│
|
||||
│ User opens PR with "Fixes #42"
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ worktree: issue-42 │
|
||||
│ conversations: │
|
||||
│ - owner/repo#42 │◀─── Issue still references
|
||||
│ - owner/repo#99 │◀─── PR SHARES same worktree
|
||||
└──────────────────────┘
|
||||
│
|
||||
│ Issue #42 closed
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ worktree: issue-42 │ ← KEPT (PR still using)
|
||||
│ conversations: │
|
||||
│ - owner/repo#99 │
|
||||
└──────────────────────┘
|
||||
│
|
||||
│ PR #99 merged
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ worktree: REMOVED │ ← No more references
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
```sql
|
||||
conversations
|
||||
├── id
|
||||
├── platform_conversation_id -- "owner/repo#42"
|
||||
├── cwd -- Current working directory
|
||||
├── worktree_path -- LEGACY (keep for compatibility)
|
||||
├── isolation_env_id -- NEW: worktree path as ID
|
||||
└── isolation_provider -- NEW: 'worktree' | 'container' | ...
|
||||
```
|
||||
|
||||
Lookup pattern:
|
||||
```typescript
|
||||
const envId = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
```
|
||||
|
||||
## Skill Symbiosis
|
||||
|
||||
The worktree-manager Claude Code skill uses `~/.claude/worktree-registry.json`.
|
||||
|
||||
**Adoption scenarios:**
|
||||
1. **Path match**: Skill created worktree at expected path → adopted
|
||||
2. **Branch match**: Skill created worktree for PR's branch → adopted
|
||||
|
||||
```
|
||||
Skill creates: ~/tmp/worktrees/myapp/feature-auth/
|
||||
│
|
||||
PR opened for branch "feature/auth"
|
||||
│
|
||||
▼
|
||||
App checks: findWorktreeByBranch("feature/auth")
|
||||
matches "feature-auth" (slugified)
|
||||
│
|
||||
▼
|
||||
ADOPT existing worktree
|
||||
(no duplicate created)
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/isolation/types.ts` | `IIsolationProvider`, `IsolationRequest`, `IsolatedEnvironment` |
|
||||
| `src/isolation/providers/worktree.ts` | `WorktreeProvider` implementation |
|
||||
| `src/isolation/index.ts` | `getIsolationProvider()` factory |
|
||||
| `src/utils/git.ts` | `getWorktreeBase()`, `listWorktrees()`, low-level git ops |
|
||||
| `src/adapters/github.ts` | Webhook handling, `cleanupPRWorktree()` |
|
||||
| `src/handlers/command-handler.ts` | `/worktree` command handling |
|
||||
25
migrations/005_isolation_abstraction.sql
Normal file
25
migrations/005_isolation_abstraction.sql
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-- Add isolation provider abstraction columns
|
||||
-- Version: 5.0
|
||||
-- Description: Abstract isolation mechanisms (worktrees, containers, VMs)
|
||||
|
||||
ALTER TABLE remote_agent_conversations
|
||||
ADD COLUMN IF NOT EXISTS isolation_env_id VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS isolation_provider VARCHAR(50) DEFAULT 'worktree';
|
||||
|
||||
-- Migrate existing worktree_path data
|
||||
UPDATE remote_agent_conversations
|
||||
SET isolation_env_id = worktree_path,
|
||||
isolation_provider = 'worktree'
|
||||
WHERE worktree_path IS NOT NULL
|
||||
AND isolation_env_id IS NULL;
|
||||
|
||||
-- Create index for lookups by isolation environment
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_isolation
|
||||
ON remote_agent_conversations(isolation_env_id, isolation_provider);
|
||||
|
||||
-- Note: Keep worktree_path for backwards compatibility during transition
|
||||
-- Future migration will DROP COLUMN worktree_path after full migration
|
||||
COMMENT ON COLUMN remote_agent_conversations.isolation_env_id IS
|
||||
'Unique identifier for the isolated environment (worktree path, container ID, etc.)';
|
||||
COMMENT ON COLUMN remote_agent_conversations.isolation_provider IS
|
||||
'Type of isolation provider (worktree, container, vm, remote)';
|
||||
|
|
@ -15,7 +15,8 @@ import { promisify } from 'util';
|
|||
import { readdir, access } from 'fs/promises';
|
||||
import { join, resolve } from 'path';
|
||||
import { parseAllowedUsers, isGitHubUserAuthorized } from '../utils/github-auth';
|
||||
import { isWorktreePath, createWorktreeForIssue, removeWorktree } from '../utils/git';
|
||||
import { isWorktreePath } from '../utils/git';
|
||||
import { getIsolationProvider } from '../isolation';
|
||||
import { getLinkedIssueNumbers } from '../utils/github-graphql';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
|
@ -444,7 +445,14 @@ export class GitHubAdapter implements IPlatformAdapter {
|
|||
const conversationId = this.buildConversationId(owner, repo, number);
|
||||
const conversation = await db.getConversationByPlatformId('github', conversationId);
|
||||
|
||||
if (!conversation?.worktree_path) {
|
||||
// Check both old and new isolation fields for backwards compatibility
|
||||
if (!conversation) {
|
||||
console.log(`[GitHub] No conversation to cleanup for ${conversationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const isolationEnvId = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
if (!isolationEnvId) {
|
||||
console.log(`[GitHub] No worktree to cleanup for ${conversationId}`);
|
||||
return;
|
||||
}
|
||||
|
|
@ -458,34 +466,39 @@ export class GitHubAdapter implements IPlatformAdapter {
|
|||
}
|
||||
|
||||
const { codebase } = await this.getOrCreateCodebaseForRepo(owner, repo);
|
||||
const worktreePath = conversation.worktree_path;
|
||||
|
||||
// Clear worktree path from THIS conversation first
|
||||
// Clear isolation references from THIS conversation first
|
||||
// This must happen before checking for other users of the worktree
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: null,
|
||||
isolation_env_id: null,
|
||||
isolation_provider: null,
|
||||
cwd: codebase.default_cwd,
|
||||
});
|
||||
|
||||
// Check if any OTHER conversations still use this worktree (shared worktree case)
|
||||
const otherConv = await db.getConversationByWorktreePath(worktreePath);
|
||||
// Check both old and new fields for backwards compatibility
|
||||
const otherConvByWorktree = await db.getConversationByWorktreePath(isolationEnvId);
|
||||
const otherConvByEnvId = await db.getConversationByIsolationEnvId(isolationEnvId);
|
||||
const otherConv = otherConvByWorktree ?? otherConvByEnvId;
|
||||
if (otherConv) {
|
||||
console.log(
|
||||
`[GitHub] Keeping worktree ${worktreePath}, still used by ${otherConv.platform_conversation_id}`
|
||||
`[GitHub] Keeping worktree ${isolationEnvId}, still used by ${otherConv.platform_conversation_id}`
|
||||
);
|
||||
console.log(`[GitHub] Cleanup complete for ${conversationId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// No other conversations use this worktree - safe to remove
|
||||
// No other conversations use this worktree - safe to remove via provider
|
||||
const provider = getIsolationProvider();
|
||||
try {
|
||||
await removeWorktree(codebase.default_cwd, worktreePath);
|
||||
console.log(`[GitHub] Removed worktree: ${worktreePath}`);
|
||||
await provider.destroy(isolationEnvId);
|
||||
console.log(`[GitHub] Removed worktree: ${isolationEnvId}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
// Handle already-deleted worktree gracefully (e.g., manual cleanup)
|
||||
if (err.message.includes('is not a working tree')) {
|
||||
console.log(`[GitHub] Worktree already removed: ${worktreePath}`);
|
||||
console.log(`[GitHub] Worktree already removed: ${isolationEnvId}`);
|
||||
} else {
|
||||
console.error('[GitHub] Failed to remove worktree:', error);
|
||||
// Notify user about orphaned worktree (likely has uncommitted changes)
|
||||
|
|
@ -493,7 +506,7 @@ export class GitHubAdapter implements IPlatformAdapter {
|
|||
if (hasUncommittedChanges) {
|
||||
await this.sendMessage(
|
||||
conversationId,
|
||||
`Warning: Could not remove worktree at \`${worktreePath}\` because it contains uncommitted changes. You may want to manually commit or discard these changes.`
|
||||
`Warning: Could not remove worktree at \`${isolationEnvId}\` because it contains uncommitted changes. You may want to manually commit or discard these changes.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -647,7 +660,7 @@ ${userComment}`;
|
|||
}
|
||||
}
|
||||
|
||||
// If no shared worktree found, create new one
|
||||
// If no shared worktree found, create new one via provider
|
||||
if (!worktreePath) {
|
||||
try {
|
||||
// For PRs, fetch the head branch name and SHA from GitHub API
|
||||
|
|
@ -671,20 +684,28 @@ ${userComment}`;
|
|||
}
|
||||
}
|
||||
|
||||
worktreePath = await createWorktreeForIssue(
|
||||
repoPath,
|
||||
number,
|
||||
isPR,
|
||||
prHeadBranch,
|
||||
prHeadSha
|
||||
);
|
||||
// Use isolation provider for worktree creation
|
||||
const provider = getIsolationProvider();
|
||||
const env = await provider.create({
|
||||
codebaseId: codebase.id,
|
||||
canonicalRepoPath: repoPath,
|
||||
workflowType: isPR ? 'pr' : 'issue',
|
||||
identifier: String(number),
|
||||
prBranch: prHeadBranch,
|
||||
prSha: prHeadSha,
|
||||
description: `GitHub ${isPR ? 'PR' : 'issue'} #${String(number)}`,
|
||||
});
|
||||
|
||||
worktreePath = env.workingPath;
|
||||
console.log(`[GitHub] Created worktree: ${worktreePath}`);
|
||||
|
||||
// Update conversation with worktree path
|
||||
// Update conversation with isolation info (both old and new fields for compatibility)
|
||||
await db.updateConversation(existingConv.id, {
|
||||
codebase_id: codebase.id,
|
||||
cwd: worktreePath,
|
||||
worktree_path: worktreePath,
|
||||
isolation_env_id: env.id,
|
||||
isolation_provider: env.provider,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
|
|
|
|||
|
|
@ -92,7 +92,12 @@ export async function getOrCreateConversation(
|
|||
|
||||
export async function updateConversation(
|
||||
id: string,
|
||||
updates: Partial<Pick<Conversation, 'codebase_id' | 'cwd' | 'worktree_path'>>
|
||||
updates: Partial<
|
||||
Pick<
|
||||
Conversation,
|
||||
'codebase_id' | 'cwd' | 'worktree_path' | 'isolation_env_id' | 'isolation_provider'
|
||||
>
|
||||
>
|
||||
): Promise<void> {
|
||||
const fields: string[] = [];
|
||||
const values: (string | null)[] = [];
|
||||
|
|
@ -110,6 +115,14 @@ export async function updateConversation(
|
|||
fields.push(`worktree_path = $${String(i++)}`);
|
||||
values.push(updates.worktree_path);
|
||||
}
|
||||
if (updates.isolation_env_id !== undefined) {
|
||||
fields.push(`isolation_env_id = $${String(i++)}`);
|
||||
values.push(updates.isolation_env_id);
|
||||
}
|
||||
if (updates.isolation_provider !== undefined) {
|
||||
fields.push(`isolation_provider = $${String(i++)}`);
|
||||
values.push(updates.isolation_provider);
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return; // No updates
|
||||
|
|
@ -123,3 +136,17 @@ export async function updateConversation(
|
|||
values
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a conversation by isolation environment ID
|
||||
* Used for provider-based lookup and shared environment detection
|
||||
*/
|
||||
export async function getConversationByIsolationEnvId(
|
||||
envId: string
|
||||
): Promise<Conversation | null> {
|
||||
const result = await pool.query<Conversation>(
|
||||
'SELECT * FROM remote_agent_conversations WHERE isolation_env_id = $1 LIMIT 1',
|
||||
[envId]
|
||||
);
|
||||
return result.rows[0] ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,32 @@ mock.module('../utils/git', () => ({
|
|||
createWorktreeForIssue: mock(() => Promise.resolve('/workspace/worktrees/issue-1')),
|
||||
}));
|
||||
|
||||
// Mock isolation provider
|
||||
const mockIsolationCreate = mock(() =>
|
||||
Promise.resolve({
|
||||
id: '/workspace/my-repo/worktrees/task-feat-auth',
|
||||
provider: 'worktree',
|
||||
workingPath: '/workspace/my-repo/worktrees/task-feat-auth',
|
||||
branchName: 'task-feat-auth',
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
metadata: {},
|
||||
})
|
||||
);
|
||||
const mockIsolationDestroy = mock(() => Promise.resolve());
|
||||
|
||||
mock.module('../isolation', () => ({
|
||||
getIsolationProvider: () => ({
|
||||
providerType: 'worktree',
|
||||
create: mockIsolationCreate,
|
||||
destroy: mockIsolationDestroy,
|
||||
get: mock(() => Promise.resolve(null)),
|
||||
list: mock(() => Promise.resolve([])),
|
||||
adopt: mock(() => Promise.resolve(null)),
|
||||
healthCheck: mock(() => Promise.resolve(true)),
|
||||
}),
|
||||
}));
|
||||
|
||||
mock.module('child_process', () => ({
|
||||
exec: mock(() => {}),
|
||||
execFile: mockExecFile,
|
||||
|
|
@ -101,6 +127,9 @@ function clearAllMocks(): void {
|
|||
mockListWorktrees.mockClear();
|
||||
mockRemoveWorktree.mockClear();
|
||||
mockGetWorktreeBase.mockClear();
|
||||
// Isolation mocks
|
||||
mockIsolationCreate.mockClear();
|
||||
mockIsolationDestroy.mockClear();
|
||||
}
|
||||
|
||||
describe('CommandHandler', () => {
|
||||
|
|
@ -271,6 +300,8 @@ describe('CommandHandler', () => {
|
|||
codebase_id: null,
|
||||
cwd: null,
|
||||
worktree_path: null,
|
||||
isolation_env_id: null,
|
||||
isolation_provider: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
};
|
||||
|
|
@ -642,9 +673,10 @@ describe('CommandHandler', () => {
|
|||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toContain('Worktree created');
|
||||
expect(result.message).toContain('feat-auth');
|
||||
expect(result.message).toMatch(/worktrees[\\\/]feat-auth/);
|
||||
expect(result.message).toContain('task-feat-auth');
|
||||
expect(result.message).toMatch(/worktrees[\\\/]task-feat-auth/);
|
||||
expect(mockUpdateConversation).toHaveBeenCalled();
|
||||
expect(mockIsolationCreate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject if already using a worktree', async () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import * as sessionDb from '../db/sessions';
|
|||
import * as templateDb from '../db/command-templates';
|
||||
import { isPathWithinWorkspace } from '../utils/path-validation';
|
||||
import { listWorktrees, execFileAsync } from '../utils/git';
|
||||
import { getIsolationProvider } from '../isolation';
|
||||
|
||||
/**
|
||||
* Convert an absolute path to a relative path from the repository root
|
||||
|
|
@ -930,7 +931,6 @@ Session:
|
|||
}
|
||||
|
||||
const mainPath = codebase.default_cwd;
|
||||
const worktreesDir = join(mainPath, 'worktrees');
|
||||
|
||||
switch (subcommand) {
|
||||
case 'create': {
|
||||
|
|
@ -939,9 +939,10 @@ Session:
|
|||
return { success: false, message: 'Usage: /worktree create <branch-name>' };
|
||||
}
|
||||
|
||||
// Check if already using a worktree
|
||||
if (conversation.worktree_path) {
|
||||
const shortPath = shortenPath(conversation.worktree_path, mainPath);
|
||||
// Check if already using a worktree (check both old and new fields)
|
||||
const existingIsolation = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
if (existingIsolation) {
|
||||
const shortPath = shortenPath(existingIsolation, mainPath);
|
||||
return {
|
||||
success: false,
|
||||
message: `Already using worktree: ${shortPath}\n\nRun /worktree remove first.`,
|
||||
|
|
@ -956,19 +957,16 @@ Session:
|
|||
};
|
||||
}
|
||||
|
||||
const worktreePath = join(worktreesDir, branchName);
|
||||
|
||||
try {
|
||||
// Create worktree with new branch
|
||||
await execFileAsync('git', [
|
||||
'-C',
|
||||
mainPath,
|
||||
'worktree',
|
||||
'add',
|
||||
worktreePath,
|
||||
'-b',
|
||||
branchName,
|
||||
]);
|
||||
// Use isolation provider for worktree creation
|
||||
const provider = getIsolationProvider();
|
||||
const env = await provider.create({
|
||||
codebaseId: conversation.codebase_id,
|
||||
canonicalRepoPath: mainPath,
|
||||
workflowType: 'task',
|
||||
identifier: branchName,
|
||||
description: `Manual worktree: ${branchName}`,
|
||||
});
|
||||
|
||||
// Add to git safe.directory
|
||||
await execFileAsync('git', [
|
||||
|
|
@ -976,22 +974,23 @@ Session:
|
|||
'--global',
|
||||
'--add',
|
||||
'safe.directory',
|
||||
worktreePath,
|
||||
env.workingPath,
|
||||
]);
|
||||
|
||||
// Update conversation to use this worktree
|
||||
await db.updateConversation(conversation.id, { worktree_path: worktreePath });
|
||||
// Update conversation with isolation info (both old and new fields for compatibility)
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: env.workingPath,
|
||||
isolation_env_id: env.id,
|
||||
isolation_provider: env.provider,
|
||||
cwd: env.workingPath,
|
||||
});
|
||||
|
||||
// Reset session for fresh start
|
||||
const session = await sessionDb.getActiveSession(conversation.id);
|
||||
if (session) {
|
||||
await sessionDb.deactivateSession(session.id);
|
||||
}
|
||||
// NOTE: Do NOT deactivate session - preserve AI context per plan
|
||||
|
||||
const shortPath = shortenPath(worktreePath, mainPath);
|
||||
const shortPath = shortenPath(env.workingPath, mainPath);
|
||||
return {
|
||||
success: true,
|
||||
message: `Worktree created!\n\nBranch: ${branchName}\nPath: ${shortPath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., npm install).`,
|
||||
message: `Worktree created!\n\nBranch: ${env.branchName ?? branchName}\nPath: ${shortPath}\n\nThis conversation now works in isolation.\nRun dependency install if needed (e.g., bun install).`,
|
||||
modified: true,
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
@ -1017,6 +1016,9 @@ Session:
|
|||
const lines = stdout.trim().split('\n');
|
||||
let msg = 'Worktrees:\n\n';
|
||||
|
||||
// Check both old and new fields for current worktree
|
||||
const currentWorktree = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
|
||||
for (const line of lines) {
|
||||
// Extract the path (first part before whitespace)
|
||||
const parts = line.split(/\s+/);
|
||||
|
|
@ -1027,8 +1029,7 @@ Session:
|
|||
const restOfLine = parts.slice(1).join(' ');
|
||||
const shortenedLine = restOfLine ? `${shortPath} ${restOfLine}` : shortPath;
|
||||
|
||||
const isActive =
|
||||
conversation.worktree_path && line.startsWith(conversation.worktree_path);
|
||||
const isActive = currentWorktree && line.startsWith(currentWorktree);
|
||||
const marker = isActive ? ' <- active' : '';
|
||||
msg += `${shortenedLine}${marker}\n`;
|
||||
}
|
||||
|
|
@ -1041,26 +1042,24 @@ Session:
|
|||
}
|
||||
|
||||
case 'remove': {
|
||||
if (!conversation.worktree_path) {
|
||||
// Check both old and new fields
|
||||
const isolationEnvId = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
if (!isolationEnvId) {
|
||||
return { success: false, message: 'This conversation is not using a worktree.' };
|
||||
}
|
||||
|
||||
const worktreePath = conversation.worktree_path;
|
||||
const forceFlag = args[1] === '--force';
|
||||
|
||||
try {
|
||||
// Remove worktree (--force discards uncommitted changes)
|
||||
const gitArgs = ['-C', mainPath, 'worktree', 'remove'];
|
||||
if (forceFlag) {
|
||||
gitArgs.push('--force');
|
||||
}
|
||||
gitArgs.push(worktreePath);
|
||||
// Use isolation provider for removal
|
||||
const provider = getIsolationProvider();
|
||||
await provider.destroy(isolationEnvId, { force: forceFlag });
|
||||
|
||||
await execFileAsync('git', gitArgs);
|
||||
|
||||
// Clear worktree_path, keep cwd pointing to main repo
|
||||
// Clear all isolation references, set cwd to main repo
|
||||
await db.updateConversation(conversation.id, {
|
||||
worktree_path: null,
|
||||
isolation_env_id: null,
|
||||
isolation_provider: null,
|
||||
cwd: mainPath,
|
||||
});
|
||||
|
||||
|
|
@ -1070,7 +1069,7 @@ Session:
|
|||
await sessionDb.deactivateSession(session.id);
|
||||
}
|
||||
|
||||
const shortPath = shortenPath(worktreePath, mainPath);
|
||||
const shortPath = shortenPath(isolationEnvId, mainPath);
|
||||
return {
|
||||
success: true,
|
||||
message: `Worktree removed: ${shortPath}\n\nSwitched back to main repo.`,
|
||||
|
|
@ -1105,13 +1104,16 @@ Session:
|
|||
};
|
||||
}
|
||||
|
||||
// Check both old and new fields for current worktree
|
||||
const currentWorktree = conversation.isolation_env_id ?? conversation.worktree_path;
|
||||
|
||||
let msg = 'All worktrees (from git):\n\n';
|
||||
for (const wt of gitWorktrees) {
|
||||
const isMainRepo = wt.path === mainPath;
|
||||
if (isMainRepo) continue;
|
||||
|
||||
const shortPath = shortenPath(wt.path, mainPath);
|
||||
const isCurrent = wt.path === conversation.worktree_path;
|
||||
const isCurrent = wt.path === currentWorktree;
|
||||
const marker = isCurrent ? ' ← current' : '';
|
||||
msg += ` ${wt.branch} → ${shortPath}${marker}\n`;
|
||||
}
|
||||
|
|
|
|||
29
src/isolation/index.ts
Normal file
29
src/isolation/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Isolation Provider Factory
|
||||
*
|
||||
* Centralized factory for isolation providers.
|
||||
* Currently only supports WorktreeProvider (git worktrees).
|
||||
*/
|
||||
|
||||
import { WorktreeProvider } from './providers/worktree';
|
||||
import type { IIsolationProvider, IsolatedEnvironment, IsolationRequest } from './types';
|
||||
|
||||
export type { IIsolationProvider, IsolatedEnvironment, IsolationRequest };
|
||||
|
||||
let provider: IIsolationProvider | null = null;
|
||||
|
||||
/**
|
||||
* Get the isolation provider instance (singleton)
|
||||
* Currently only returns WorktreeProvider
|
||||
*/
|
||||
export function getIsolationProvider(): IIsolationProvider {
|
||||
provider ??= new WorktreeProvider();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the isolation provider (for testing)
|
||||
*/
|
||||
export function resetIsolationProvider(): void {
|
||||
provider = null;
|
||||
}
|
||||
424
src/isolation/providers/worktree.test.ts
Normal file
424
src/isolation/providers/worktree.test.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { describe, test, expect, beforeEach, afterEach, spyOn, type Mock } from 'bun:test';
|
||||
|
||||
import * as git from '../../utils/git';
|
||||
import type { IsolationRequest } from '../types';
|
||||
import { WorktreeProvider } from './worktree';
|
||||
|
||||
describe('WorktreeProvider', () => {
|
||||
let provider: WorktreeProvider;
|
||||
let execSpy: Mock<typeof git.execFileAsync>;
|
||||
let mkdirSpy: Mock<typeof git.mkdirAsync>;
|
||||
let worktreeExistsSpy: Mock<typeof git.worktreeExists>;
|
||||
let listWorktreesSpy: Mock<typeof git.listWorktrees>;
|
||||
let findWorktreeByBranchSpy: Mock<typeof git.findWorktreeByBranch>;
|
||||
let getCanonicalRepoPathSpy: Mock<typeof git.getCanonicalRepoPath>;
|
||||
|
||||
beforeEach(() => {
|
||||
provider = new WorktreeProvider();
|
||||
execSpy = spyOn(git, 'execFileAsync');
|
||||
mkdirSpy = spyOn(git, 'mkdirAsync');
|
||||
worktreeExistsSpy = spyOn(git, 'worktreeExists');
|
||||
listWorktreesSpy = spyOn(git, 'listWorktrees');
|
||||
findWorktreeByBranchSpy = spyOn(git, 'findWorktreeByBranch');
|
||||
getCanonicalRepoPathSpy = spyOn(git, 'getCanonicalRepoPath');
|
||||
|
||||
// Default mocks
|
||||
execSpy.mockResolvedValue({ stdout: '', stderr: '' });
|
||||
mkdirSpy.mockResolvedValue(undefined);
|
||||
worktreeExistsSpy.mockResolvedValue(false);
|
||||
listWorktreesSpy.mockResolvedValue([]);
|
||||
findWorktreeByBranchSpy.mockResolvedValue(null);
|
||||
getCanonicalRepoPathSpy.mockImplementation(async (path) => path);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
execSpy.mockRestore();
|
||||
mkdirSpy.mockRestore();
|
||||
worktreeExistsSpy.mockRestore();
|
||||
listWorktreesSpy.mockRestore();
|
||||
findWorktreeByBranchSpy.mockRestore();
|
||||
getCanonicalRepoPathSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('generateBranchName', () => {
|
||||
test('generates issue-N for issue workflows', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'issue',
|
||||
identifier: '42',
|
||||
};
|
||||
expect(provider.generateBranchName(request)).toBe('issue-42');
|
||||
});
|
||||
|
||||
test('generates pr-N for PR workflows', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'pr',
|
||||
identifier: '123',
|
||||
};
|
||||
expect(provider.generateBranchName(request)).toBe('pr-123');
|
||||
});
|
||||
|
||||
test('generates review-N for review workflows', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'review',
|
||||
identifier: '456',
|
||||
};
|
||||
expect(provider.generateBranchName(request)).toBe('review-456');
|
||||
});
|
||||
|
||||
test('generates thread-{hash} for thread workflows', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'thread',
|
||||
identifier: 'C123:1234567890.123456',
|
||||
};
|
||||
const name = provider.generateBranchName(request);
|
||||
expect(name).toMatch(/^thread-[a-f0-9]{8}$/);
|
||||
});
|
||||
|
||||
test('generates consistent hash for same identifier', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'thread',
|
||||
identifier: 'same-thread-id',
|
||||
};
|
||||
const name1 = provider.generateBranchName(request);
|
||||
const name2 = provider.generateBranchName(request);
|
||||
expect(name1).toBe(name2);
|
||||
});
|
||||
|
||||
test('generates different hashes for different identifiers', () => {
|
||||
const request1: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'thread',
|
||||
identifier: 'thread-1',
|
||||
};
|
||||
const request2: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'thread',
|
||||
identifier: 'thread-2',
|
||||
};
|
||||
expect(provider.generateBranchName(request1)).not.toBe(provider.generateBranchName(request2));
|
||||
});
|
||||
|
||||
test('generates task-{slug} for task workflows', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'task',
|
||||
identifier: 'add-dark-mode',
|
||||
};
|
||||
expect(provider.generateBranchName(request)).toBe('task-add-dark-mode');
|
||||
});
|
||||
|
||||
test('slugifies task identifiers properly', () => {
|
||||
const request: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'task',
|
||||
identifier: 'Add Dark Mode!!!',
|
||||
};
|
||||
expect(provider.generateBranchName(request)).toBe('task-add-dark-mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const baseRequest: IsolationRequest = {
|
||||
codebaseId: 'cb-123',
|
||||
canonicalRepoPath: '/workspace/repo',
|
||||
workflowType: 'issue',
|
||||
identifier: '42',
|
||||
};
|
||||
|
||||
test('creates worktree for issue workflow', async () => {
|
||||
const env = await provider.create(baseRequest);
|
||||
|
||||
expect(env.provider).toBe('worktree');
|
||||
expect(env.branchName).toBe('issue-42');
|
||||
expect(env.workingPath).toContain('issue-42');
|
||||
expect(env.status).toBe('active');
|
||||
|
||||
// Verify git worktree add was called with -b flag
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), '-b', 'issue-42']),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('creates worktree for PR with SHA (reproducible reviews)', async () => {
|
||||
const request: IsolationRequest = {
|
||||
...baseRequest,
|
||||
workflowType: 'pr',
|
||||
identifier: '42',
|
||||
prBranch: 'feature/auth',
|
||||
prSha: 'abc123def456',
|
||||
};
|
||||
|
||||
await provider.create(request);
|
||||
|
||||
// Verify fetch with PR ref
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'fetch', 'origin', 'pull/42/head']),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Verify worktree add with SHA
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), 'abc123def456']),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Verify checkout -b for tracking branch
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', expect.any(String), 'checkout', '-b', 'pr-42-review', 'abc123def456']),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('creates worktree for PR without SHA (uses PR ref)', async () => {
|
||||
const request: IsolationRequest = {
|
||||
...baseRequest,
|
||||
workflowType: 'pr',
|
||||
identifier: '42',
|
||||
prBranch: 'feature/auth',
|
||||
};
|
||||
|
||||
await provider.create(request);
|
||||
|
||||
// Verify fetch with PR ref and local branch creation
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'fetch', 'origin', 'pull/42/head:pr-42-review']),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Verify worktree add with the local branch
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), 'pr-42-review']),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('adopts existing worktree if found', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(true);
|
||||
|
||||
const env = await provider.create(baseRequest);
|
||||
|
||||
expect(env.metadata).toHaveProperty('adopted', true);
|
||||
expect(env.workingPath).toContain('issue-42');
|
||||
|
||||
// Verify no git worktree add was called
|
||||
const addCalls = execSpy.mock.calls.filter((call: unknown[]) => {
|
||||
const args = call[1] as string[];
|
||||
return args.includes('add');
|
||||
});
|
||||
expect(addCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('adopts worktree by PR branch name (skill symbiosis)', async () => {
|
||||
const request: IsolationRequest = {
|
||||
...baseRequest,
|
||||
workflowType: 'pr',
|
||||
identifier: '42',
|
||||
prBranch: 'feature/auth',
|
||||
};
|
||||
|
||||
// First check (expected path) returns false
|
||||
worktreeExistsSpy.mockResolvedValueOnce(false);
|
||||
// findWorktreeByBranch finds existing worktree
|
||||
findWorktreeByBranchSpy.mockResolvedValue('/workspace/worktrees/repo/feature-auth');
|
||||
|
||||
const env = await provider.create(request);
|
||||
|
||||
expect(env.workingPath).toBe('/workspace/worktrees/repo/feature-auth');
|
||||
expect(env.metadata).toHaveProperty('adopted', true);
|
||||
expect(env.metadata).toHaveProperty('adoptedFrom', 'branch');
|
||||
|
||||
// Verify no git commands for worktree creation
|
||||
const addCalls = execSpy.mock.calls.filter((call: unknown[]) => {
|
||||
const args = call[1] as string[];
|
||||
return args.includes('add');
|
||||
});
|
||||
expect(addCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('reuses existing branch if it already exists', async () => {
|
||||
let callCount = 0;
|
||||
execSpy.mockImplementation(async (_cmd: string, args: string[]) => {
|
||||
callCount++;
|
||||
// First worktree add call fails (branch exists)
|
||||
if (callCount === 1 && args.includes('-b')) {
|
||||
const error = new Error('fatal: A branch named issue-42 already exists.') as Error & { stderr?: string };
|
||||
error.stderr = 'fatal: A branch named issue-42 already exists.';
|
||||
throw error;
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
await provider.create(baseRequest);
|
||||
|
||||
// Verify first call attempted new branch
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), '-b', 'issue-42']),
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
// Verify second call used existing branch
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'add', expect.any(String), 'issue-42']),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('throws error if PR fetch fails', async () => {
|
||||
const request: IsolationRequest = {
|
||||
...baseRequest,
|
||||
workflowType: 'pr',
|
||||
identifier: '42',
|
||||
prBranch: 'feature/auth',
|
||||
};
|
||||
|
||||
execSpy.mockImplementation(async (_cmd: string, args: string[]) => {
|
||||
if (args.includes('fetch')) {
|
||||
throw new Error('fatal: unable to access repository');
|
||||
}
|
||||
return { stdout: '', stderr: '' };
|
||||
});
|
||||
|
||||
await expect(provider.create(request)).rejects.toThrow('Failed to create worktree for PR #42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', () => {
|
||||
test('removes worktree', async () => {
|
||||
const worktreePath = '/workspace/worktrees/repo/issue-42';
|
||||
|
||||
// Mock getCanonicalRepoPath to return the repo path
|
||||
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
|
||||
|
||||
await provider.destroy(worktreePath);
|
||||
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'remove', worktreePath]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test('uses force flag when specified', async () => {
|
||||
const worktreePath = '/workspace/worktrees/repo/issue-42';
|
||||
|
||||
// Mock getCanonicalRepoPath to return the repo path
|
||||
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
|
||||
|
||||
await provider.destroy(worktreePath, { force: true });
|
||||
|
||||
expect(execSpy).toHaveBeenCalledWith(
|
||||
'git',
|
||||
expect.arrayContaining(['-C', '/workspace/repo', 'worktree', 'remove', '--force', worktreePath]),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
test('returns null for non-existent environment', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(false);
|
||||
|
||||
const result = await provider.get('/workspace/worktrees/repo/nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('returns environment for existing worktree', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(true);
|
||||
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
|
||||
listWorktreesSpy.mockResolvedValue([
|
||||
{ path: '/workspace/repo', branch: 'main' },
|
||||
{ path: '/workspace/worktrees/repo/issue-42', branch: 'issue-42' },
|
||||
]);
|
||||
|
||||
const result = await provider.get('/workspace/worktrees/repo/issue-42');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.provider).toBe('worktree');
|
||||
expect(result?.branchName).toBe('issue-42');
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
test('returns all worktrees for codebase (excluding main)', async () => {
|
||||
listWorktreesSpy.mockResolvedValue([
|
||||
{ path: '/workspace/repo', branch: 'main' },
|
||||
{ path: '/workspace/worktrees/repo/issue-42', branch: 'issue-42' },
|
||||
{ path: '/workspace/worktrees/repo/pr-123', branch: 'pr-123' },
|
||||
]);
|
||||
|
||||
const result = await provider.list('/workspace/repo');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].branchName).toBe('issue-42');
|
||||
expect(result[1].branchName).toBe('pr-123');
|
||||
});
|
||||
|
||||
test('returns empty array when no worktrees', async () => {
|
||||
listWorktreesSpy.mockResolvedValue([{ path: '/workspace/repo', branch: 'main' }]);
|
||||
|
||||
const result = await provider.list('/workspace/repo');
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('healthCheck', () => {
|
||||
test('returns true for existing worktree', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(true);
|
||||
const result = await provider.healthCheck('/workspace/worktrees/repo/issue-42');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('returns false for non-existent worktree', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(false);
|
||||
const result = await provider.healthCheck('/workspace/worktrees/repo/nonexistent');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adopt', () => {
|
||||
test('adopts existing worktree', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(true);
|
||||
getCanonicalRepoPathSpy.mockResolvedValue('/workspace/repo');
|
||||
listWorktreesSpy.mockResolvedValue([
|
||||
{ path: '/workspace/repo', branch: 'main' },
|
||||
{ path: '/workspace/worktrees/repo/feature-auth', branch: 'feature/auth' },
|
||||
]);
|
||||
|
||||
const result = await provider.adopt('/workspace/worktrees/repo/feature-auth');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.provider).toBe('worktree');
|
||||
expect(result?.branchName).toBe('feature/auth');
|
||||
expect(result?.metadata).toHaveProperty('adopted', true);
|
||||
});
|
||||
|
||||
test('returns null for non-existent path', async () => {
|
||||
worktreeExistsSpy.mockResolvedValue(false);
|
||||
const result = await provider.adopt('/workspace/worktrees/repo/nonexistent');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
362
src/isolation/providers/worktree.ts
Normal file
362
src/isolation/providers/worktree.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
/**
|
||||
* Worktree Provider - Git worktree-based isolation
|
||||
*
|
||||
* Default isolation provider using git worktrees.
|
||||
* Migrated from src/utils/git.ts with consistent semantics.
|
||||
*/
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
import { basename, join } from 'path';
|
||||
|
||||
import {
|
||||
execFileAsync,
|
||||
findWorktreeByBranch,
|
||||
getCanonicalRepoPath,
|
||||
getWorktreeBase,
|
||||
listWorktrees,
|
||||
mkdirAsync,
|
||||
worktreeExists,
|
||||
} from '../../utils/git';
|
||||
import type { IIsolationProvider, IsolatedEnvironment, IsolationRequest } from '../types';
|
||||
|
||||
export class WorktreeProvider implements IIsolationProvider {
|
||||
readonly providerType = 'worktree';
|
||||
|
||||
/**
|
||||
* Create an isolated environment using git worktrees
|
||||
*/
|
||||
async create(request: IsolationRequest): Promise<IsolatedEnvironment> {
|
||||
const branchName = this.generateBranchName(request);
|
||||
const worktreePath = this.getWorktreePath(request, branchName);
|
||||
const envId = this.generateEnvId(request);
|
||||
|
||||
// Check for existing worktree (adoption)
|
||||
const existing = await this.findExisting(request, branchName, worktreePath);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Create new worktree
|
||||
await this.createWorktree(request, worktreePath, branchName);
|
||||
|
||||
return {
|
||||
id: envId,
|
||||
provider: 'worktree',
|
||||
workingPath: worktreePath,
|
||||
branchName,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
metadata: { request },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy an isolated environment
|
||||
* @param envId - The worktree path (used as environment ID)
|
||||
* @param options - Options including force flag
|
||||
*/
|
||||
async destroy(envId: string, options?: { force?: boolean }): Promise<void> {
|
||||
// For worktrees, envId is the worktree path
|
||||
const worktreePath = envId;
|
||||
|
||||
// Get canonical repo path to run git commands
|
||||
const repoPath = await getCanonicalRepoPath(worktreePath);
|
||||
|
||||
const gitArgs = ['-C', repoPath, 'worktree', 'remove'];
|
||||
if (options?.force) {
|
||||
gitArgs.push('--force');
|
||||
}
|
||||
gitArgs.push(worktreePath);
|
||||
|
||||
await execFileAsync('git', gitArgs, { timeout: 30000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment by ID (worktree path)
|
||||
*/
|
||||
async get(envId: string): Promise<IsolatedEnvironment | null> {
|
||||
const worktreePath = envId;
|
||||
|
||||
if (!(await worktreeExists(worktreePath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get branch name from worktree
|
||||
const repoPath = await getCanonicalRepoPath(worktreePath);
|
||||
const worktrees = await listWorktrees(repoPath);
|
||||
const wt = worktrees.find(w => w.path === worktreePath);
|
||||
|
||||
return {
|
||||
id: envId,
|
||||
provider: 'worktree',
|
||||
workingPath: worktreePath,
|
||||
branchName: wt?.branch,
|
||||
status: 'active',
|
||||
createdAt: new Date(), // Cannot determine actual creation time
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all environments for a codebase
|
||||
*/
|
||||
async list(codebaseId: string): Promise<IsolatedEnvironment[]> {
|
||||
// codebaseId is the canonical repo path for worktrees
|
||||
const repoPath = codebaseId;
|
||||
const worktrees = await listWorktrees(repoPath);
|
||||
|
||||
// Filter out main repo (first worktree is typically the main checkout)
|
||||
return worktrees
|
||||
.filter(wt => wt.path !== repoPath)
|
||||
.map(wt => ({
|
||||
id: wt.path,
|
||||
provider: 'worktree' as const,
|
||||
workingPath: wt.path,
|
||||
branchName: wt.branch,
|
||||
status: 'active' as const,
|
||||
createdAt: new Date(),
|
||||
metadata: {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt an existing worktree (for skill-app symbiosis)
|
||||
*/
|
||||
async adopt(path: string): Promise<IsolatedEnvironment | null> {
|
||||
if (!(await worktreeExists(path))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const repoPath = await getCanonicalRepoPath(path);
|
||||
const worktrees = await listWorktrees(repoPath);
|
||||
const wt = worktrees.find(w => w.path === path);
|
||||
|
||||
if (!wt) {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`[WorktreeProvider] Adopting existing worktree: ${path}`);
|
||||
return {
|
||||
id: path,
|
||||
provider: 'worktree',
|
||||
workingPath: path,
|
||||
branchName: wt.branch,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
metadata: { adopted: true },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if environment exists and is healthy
|
||||
*/
|
||||
async healthCheck(envId: string): Promise<boolean> {
|
||||
return worktreeExists(envId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate semantic branch name based on workflow type
|
||||
*/
|
||||
generateBranchName(request: IsolationRequest): string {
|
||||
switch (request.workflowType) {
|
||||
case 'issue':
|
||||
return `issue-${request.identifier}`;
|
||||
case 'pr':
|
||||
return `pr-${request.identifier}`;
|
||||
case 'review':
|
||||
return `review-${request.identifier}`;
|
||||
case 'thread':
|
||||
// Use short hash for arbitrary thread IDs (Slack, Discord)
|
||||
return `thread-${this.shortHash(request.identifier)}`;
|
||||
case 'task':
|
||||
return `task-${this.slugify(request.identifier)}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique environment ID
|
||||
*/
|
||||
generateEnvId(request: IsolationRequest): string {
|
||||
const branchName = this.generateBranchName(request);
|
||||
return this.getWorktreePath(request, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree path for request
|
||||
*/
|
||||
getWorktreePath(request: IsolationRequest, branchName: string): string {
|
||||
const projectName = basename(request.canonicalRepoPath);
|
||||
const worktreeBase = getWorktreeBase(request.canonicalRepoPath);
|
||||
return join(worktreeBase, projectName, branchName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find existing worktree for adoption
|
||||
*/
|
||||
private async findExisting(
|
||||
request: IsolationRequest,
|
||||
branchName: string,
|
||||
worktreePath: string
|
||||
): Promise<IsolatedEnvironment | null> {
|
||||
// Check if worktree already exists at expected path
|
||||
if (await worktreeExists(worktreePath)) {
|
||||
console.log(`[WorktreeProvider] Adopting existing worktree: ${worktreePath}`);
|
||||
return {
|
||||
id: worktreePath,
|
||||
provider: 'worktree',
|
||||
workingPath: worktreePath,
|
||||
branchName,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
metadata: { adopted: true, request },
|
||||
};
|
||||
}
|
||||
|
||||
// For PRs: also check if skill created a worktree with the PR's branch name
|
||||
if (request.workflowType === 'pr' && request.prBranch) {
|
||||
const existingByBranch = await findWorktreeByBranch(
|
||||
request.canonicalRepoPath,
|
||||
request.prBranch
|
||||
);
|
||||
if (existingByBranch) {
|
||||
console.log(
|
||||
`[WorktreeProvider] Adopting existing worktree for branch ${request.prBranch}: ${existingByBranch}`
|
||||
);
|
||||
return {
|
||||
id: existingByBranch,
|
||||
provider: 'worktree',
|
||||
workingPath: existingByBranch,
|
||||
branchName: request.prBranch,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
metadata: { adopted: true, adoptedFrom: 'branch', request },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the actual worktree
|
||||
*/
|
||||
private async createWorktree(
|
||||
request: IsolationRequest,
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
const repoPath = request.canonicalRepoPath;
|
||||
const projectName = basename(repoPath);
|
||||
const worktreeBase = getWorktreeBase(repoPath);
|
||||
const projectWorktreeDir = join(worktreeBase, projectName);
|
||||
|
||||
// Ensure worktree base directory exists
|
||||
await mkdirAsync(projectWorktreeDir, { recursive: true });
|
||||
|
||||
if (request.workflowType === 'pr' && request.prBranch) {
|
||||
// For PRs: fetch and checkout the PR's head branch
|
||||
await this.createFromPR(request, worktreePath);
|
||||
} else {
|
||||
// For issues, tasks, threads: create new branch
|
||||
await this.createNewBranch(repoPath, worktreePath, branchName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create worktree from PR (handles both SHA and branch-based)
|
||||
*/
|
||||
private async createFromPR(request: IsolationRequest, worktreePath: string): Promise<void> {
|
||||
const repoPath = request.canonicalRepoPath;
|
||||
const prNumber = request.identifier;
|
||||
|
||||
try {
|
||||
if (request.prSha) {
|
||||
// If SHA provided, use it for reproducible reviews (hybrid approach)
|
||||
// Fetch the specific commit SHA using PR refs (works for both fork and non-fork PRs)
|
||||
await execFileAsync('git', ['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head`], {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Create worktree at the specific SHA
|
||||
await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, request.prSha], {
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Create a local tracking branch so it's not detached HEAD
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', worktreePath, 'checkout', '-b', `pr-${prNumber}-review`, request.prSha],
|
||||
{
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Use GitHub's PR refs which work for both fork and non-fork PRs
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', repoPath, 'fetch', 'origin', `pull/${prNumber}/head:pr-${prNumber}-review`],
|
||||
{
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
|
||||
// Create worktree using the fetched PR ref
|
||||
await execFileAsync(
|
||||
'git',
|
||||
['-C', repoPath, 'worktree', 'add', worktreePath, `pr-${prNumber}-review`],
|
||||
{
|
||||
timeout: 30000,
|
||||
}
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
throw new Error(`Failed to create worktree for PR #${prNumber}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create worktree with new branch
|
||||
*/
|
||||
private async createNewBranch(
|
||||
repoPath: string,
|
||||
worktreePath: string,
|
||||
branchName: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Try to create with new branch
|
||||
await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, '-b', branchName], {
|
||||
timeout: 30000,
|
||||
});
|
||||
} catch (error) {
|
||||
const err = error as Error & { stderr?: string };
|
||||
// Branch already exists - use existing branch
|
||||
if (err.stderr?.includes('already exists')) {
|
||||
await execFileAsync('git', ['-C', repoPath, 'worktree', 'add', worktreePath, branchName], {
|
||||
timeout: 30000,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate short hash for thread identifiers
|
||||
*/
|
||||
private shortHash(input: string): string {
|
||||
const hash = createHash('sha256').update(input).digest('hex');
|
||||
return hash.substring(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slugify string for branch names
|
||||
*/
|
||||
private slugify(input: string): string {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
}
|
||||
}
|
||||
47
src/isolation/types.ts
Normal file
47
src/isolation/types.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Isolation Provider Abstraction Types
|
||||
*
|
||||
* Platform-agnostic interfaces for workflow isolation mechanisms.
|
||||
* Git worktrees are the default implementation, but the abstraction
|
||||
* enables future strategies (containers, VMs).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Semantic context for creating isolated environments
|
||||
* Platform-agnostic - describes WHAT needs isolation, not HOW
|
||||
*/
|
||||
export interface IsolationRequest {
|
||||
codebaseId: string;
|
||||
canonicalRepoPath: string; // Main repo path, never a worktree
|
||||
workflowType: 'issue' | 'pr' | 'review' | 'thread' | 'task';
|
||||
identifier: string; // "42", "feature-auth", thread hash, etc.
|
||||
prBranch?: string; // PR-specific (for reproducible reviews)
|
||||
prSha?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of creating an isolated environment
|
||||
*/
|
||||
export interface IsolatedEnvironment {
|
||||
id: string;
|
||||
provider: 'worktree' | 'container' | 'vm' | 'remote';
|
||||
workingPath: string;
|
||||
branchName?: string;
|
||||
status: 'active' | 'suspended' | 'destroyed';
|
||||
createdAt: Date;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface - git worktrees are DEFAULT implementation
|
||||
*/
|
||||
export interface IIsolationProvider {
|
||||
readonly providerType: string;
|
||||
create(request: IsolationRequest): Promise<IsolatedEnvironment>;
|
||||
destroy(envId: string, options?: { force?: boolean }): Promise<void>;
|
||||
get(envId: string): Promise<IsolatedEnvironment | null>;
|
||||
list(codebaseId: string): Promise<IsolatedEnvironment[]>;
|
||||
adopt?(path: string): Promise<IsolatedEnvironment | null>;
|
||||
healthCheck(envId: string): Promise<boolean>;
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ export interface Conversation {
|
|||
codebase_id: string | null;
|
||||
cwd: string | null;
|
||||
worktree_path: string | null;
|
||||
isolation_env_id: string | null;
|
||||
isolation_provider: string | null;
|
||||
ai_assistant_type: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ describe('git utilities', () => {
|
|||
|
||||
describe('getWorktreeBase', () => {
|
||||
const originalEnv = process.env.WORKTREE_BASE;
|
||||
const originalWorkspacePath = process.env.WORKSPACE_PATH;
|
||||
const originalHome = process.env.HOME;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
|
|
@ -78,24 +80,71 @@ describe('git utilities', () => {
|
|||
} else {
|
||||
process.env.WORKTREE_BASE = originalEnv;
|
||||
}
|
||||
if (originalWorkspacePath === undefined) {
|
||||
delete process.env.WORKSPACE_PATH;
|
||||
} else {
|
||||
process.env.WORKSPACE_PATH = originalWorkspacePath;
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns sibling worktrees dir by default', () => {
|
||||
test('returns ~/tmp/worktrees by default for local (non-Docker)', () => {
|
||||
delete process.env.WORKTREE_BASE;
|
||||
delete process.env.WORKSPACE_PATH;
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
expect(result).toBe(join('/workspace/my-repo', '..', 'worktrees'));
|
||||
// Default for local: ~/tmp/worktrees (matches worktree-manager skill)
|
||||
expect(result).toBe(join(homedir(), 'tmp', 'worktrees'));
|
||||
});
|
||||
|
||||
test('uses WORKTREE_BASE env var when set', () => {
|
||||
test('returns /workspace/worktrees for Docker environment', () => {
|
||||
delete process.env.WORKTREE_BASE;
|
||||
process.env.WORKSPACE_PATH = '/workspace';
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
// Docker: inside mounted volume
|
||||
expect(result).toBe('/workspace/worktrees');
|
||||
});
|
||||
|
||||
test('detects Docker by HOME=/root + WORKSPACE_PATH', () => {
|
||||
delete process.env.WORKTREE_BASE;
|
||||
process.env.HOME = '/root';
|
||||
process.env.WORKSPACE_PATH = '/app/workspace';
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
expect(result).toBe('/workspace/worktrees');
|
||||
});
|
||||
|
||||
test('uses WORKTREE_BASE for local (non-Docker)', () => {
|
||||
delete process.env.WORKSPACE_PATH; // Ensure not Docker
|
||||
delete process.env.HOME; // Reset HOME to actual value
|
||||
process.env.WORKTREE_BASE = '/custom/worktrees';
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
expect(result).toBe('/custom/worktrees');
|
||||
});
|
||||
|
||||
test('expands tilde to home directory', () => {
|
||||
test('ignores WORKTREE_BASE in Docker (end user protection)', () => {
|
||||
process.env.WORKTREE_BASE = '/custom/worktrees';
|
||||
process.env.WORKSPACE_PATH = '/workspace'; // Docker flag
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
// Docker ALWAYS uses fixed location, override IGNORED
|
||||
expect(result).toBe('/workspace/worktrees');
|
||||
});
|
||||
|
||||
test('expands tilde in WORKTREE_BASE (local only)', () => {
|
||||
delete process.env.WORKSPACE_PATH; // Ensure not Docker
|
||||
process.env.WORKTREE_BASE = '~/tmp/worktrees';
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
expect(result).toBe(join(homedir(), 'tmp/worktrees'));
|
||||
expect(result).toBe(join(homedir(), 'tmp', 'worktrees'));
|
||||
});
|
||||
|
||||
test('ignores WORKTREE_BASE with tilde in Docker', () => {
|
||||
process.env.WORKSPACE_PATH = '/workspace'; // Docker flag
|
||||
process.env.WORKTREE_BASE = '~/custom/worktrees';
|
||||
const result = git.getWorktreeBase('/workspace/my-repo');
|
||||
// Tilde never expanded in Docker because override is ignored entirely
|
||||
expect(result).toBe('/workspace/worktrees');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -30,21 +30,38 @@ export async function mkdirAsync(
|
|||
|
||||
/**
|
||||
* Get the base directory for worktrees
|
||||
* Uses WORKTREE_BASE env var if set, otherwise defaults to sibling of repo
|
||||
* - Docker: FIXED at /workspace/worktrees (end users can't override)
|
||||
* - Local: ~/tmp/worktrees by default, WORKTREE_BASE env var to override
|
||||
*/
|
||||
export function getWorktreeBase(repoPath: string): string {
|
||||
export function getWorktreeBase(_repoPath: string): string {
|
||||
// 1. Docker: FIXED location, no override for end users
|
||||
const isDocker =
|
||||
process.env.WORKSPACE_PATH === '/workspace' ||
|
||||
(process.env.HOME === '/root' && process.env.WORKSPACE_PATH);
|
||||
|
||||
if (isDocker) {
|
||||
return '/workspace/worktrees';
|
||||
}
|
||||
|
||||
// 2. Local: Check WORKTREE_BASE override (for developers with custom setups)
|
||||
const envBase = process.env.WORKTREE_BASE;
|
||||
if (envBase) {
|
||||
// Expand ~ to home directory using os.homedir() for cross-platform support
|
||||
if (envBase.startsWith('~')) {
|
||||
// Use join() to normalize path separators cross-platform
|
||||
const pathAfterTilde = envBase.slice(1).replace(/^[/\\]/, ''); // Remove leading ~ and any separator
|
||||
return join(homedir(), pathAfterTilde);
|
||||
}
|
||||
return envBase;
|
||||
return expandTilde(envBase);
|
||||
}
|
||||
// Default: sibling to repo (original behavior)
|
||||
return join(repoPath, '..', 'worktrees');
|
||||
|
||||
// 3. Local default: matches worktree-manager skill
|
||||
return join(homedir(), 'tmp', 'worktrees');
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand ~ to home directory
|
||||
*/
|
||||
function expandTilde(path: string): string {
|
||||
if (path.startsWith('~')) {
|
||||
const pathAfterTilde = path.slice(1).replace(/^[/\\]/, '');
|
||||
return join(homedir(), pathAfterTilde);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue