Merge branch 'main' into cw/unified-event-flow-conflict-1772795597661
# Conflicts: # apps/web/src/components/review/ReviewTab.tsx # apps/web/src/routes/initiatives/$id.tsx
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -30,6 +30,9 @@ workdir/*
|
|||||||
# Agent working directories
|
# Agent working directories
|
||||||
agent-workdirs/
|
agent-workdirs/
|
||||||
|
|
||||||
|
# Agent-generated screenshots
|
||||||
|
.screenshots/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Pre-implementation design docs are archived in `docs/archive/`.
|
|||||||
|
|
||||||
## Key Rules
|
## Key Rules
|
||||||
|
|
||||||
- **Database**: Never use raw SQL for schema initialization. Use `drizzle-kit generate` and the migration system. See [docs/database-migrations.md](docs/database-migrations.md).
|
- **Database migrations**: Edit `apps/server/db/schema.ts`, then run `npx drizzle-kit generate`. Multi-statement migrations need `--> statement-breakpoint` between statements. See [docs/database-migrations.md](docs/database-migrations.md).
|
||||||
- **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only.
|
- **Logging**: Use `createModuleLogger()` from `apps/server/logger/index.ts`. Keep `console.log` for CLI user-facing output only.
|
||||||
- **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`.
|
- **Hexagonal architecture**: Repository ports in `apps/server/db/repositories/*.ts`, Drizzle adapters in `apps/server/db/repositories/drizzle/*.ts`. All re-exported from `apps/server/db/index.ts`.
|
||||||
- **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`.
|
- **tRPC context**: Optional repos accessed via `require*Repository()` helpers in `apps/server/trpc/routers/_helpers.ts`.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { execFile } from 'node:child_process';
|
import { execFile } from 'node:child_process';
|
||||||
import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises';
|
import { readFile, readdir, rm, cp, mkdir } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||||
@@ -49,10 +49,35 @@ export class CleanupManager {
|
|||||||
*/
|
*/
|
||||||
private resolveAgentCwd(worktreeId: string): string {
|
private resolveAgentCwd(worktreeId: string): string {
|
||||||
const base = this.getAgentWorkdir(worktreeId);
|
const base = this.getAgentWorkdir(worktreeId);
|
||||||
|
|
||||||
|
// Fast path: .cw/output exists at the base level
|
||||||
|
if (existsSync(join(base, '.cw', 'output'))) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone agents use a workspace/ subdirectory
|
||||||
const workspaceSub = join(base, 'workspace');
|
const workspaceSub = join(base, 'workspace');
|
||||||
if (!existsSync(join(base, '.cw', 'output')) && existsSync(join(workspaceSub, '.cw'))) {
|
if (existsSync(join(workspaceSub, '.cw'))) {
|
||||||
return workspaceSub;
|
return workspaceSub;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initiative-based agents may have written .cw/ inside a project
|
||||||
|
// subdirectory (e.g. agent-workdirs/<name>/codewalk-district/.cw/).
|
||||||
|
// Probe immediate children for a .cw/output directory.
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(base, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && entry.name !== '.cw') {
|
||||||
|
const projectSub = join(base, entry.name);
|
||||||
|
if (existsSync(join(projectSub, '.cw', 'output'))) {
|
||||||
|
return projectSub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// base dir may not exist
|
||||||
|
}
|
||||||
|
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ describe('writeInputFiles', () => {
|
|||||||
name: 'Phase One',
|
name: 'Phase One',
|
||||||
content: 'First phase',
|
content: 'First phase',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
mergeBase: null,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
} as Phase;
|
} as Phase;
|
||||||
|
|||||||
@@ -397,6 +397,34 @@ export async function readDecisionFiles(agentWorkdir: string): Promise<ParsedDec
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParsedCommentResponse {
|
||||||
|
commentId: string;
|
||||||
|
body: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readCommentResponses(agentWorkdir: string): Promise<ParsedCommentResponse[]> {
|
||||||
|
const filePath = join(agentWorkdir, '.cw', 'output', 'comment-responses.json');
|
||||||
|
try {
|
||||||
|
const raw = await readFile(filePath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed
|
||||||
|
.filter((entry: unknown) => {
|
||||||
|
if (typeof entry !== 'object' || entry === null) return false;
|
||||||
|
const e = entry as Record<string, unknown>;
|
||||||
|
return typeof e.commentId === 'string' && typeof e.body === 'string';
|
||||||
|
})
|
||||||
|
.map((entry: Record<string, unknown>) => ({
|
||||||
|
commentId: String(entry.commentId),
|
||||||
|
body: String(entry.body),
|
||||||
|
resolved: typeof entry.resolved === 'boolean' ? entry.resolved : undefined,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function readPageFiles(agentWorkdir: string): Promise<ParsedPageFile[]> {
|
export async function readPageFiles(agentWorkdir: string): Promise<ParsedPageFile[]> {
|
||||||
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
|
const dirPath = join(agentWorkdir, '.cw', 'output', 'pages');
|
||||||
return readFrontmatterDir(dirPath, (data, body, filename) => {
|
return readFrontmatterDir(dirPath, (data, body, filename) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import type { TaskRepository } from '../db/repositories/task-repository.js';
|
|||||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||||
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
import type { LogChunkRepository } from '../db/repositories/log-chunk-repository.js';
|
||||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||||
|
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||||
import { generateUniqueAlias } from './alias.js';
|
import { generateUniqueAlias } from './alias.js';
|
||||||
import type {
|
import type {
|
||||||
EventBus,
|
EventBus,
|
||||||
@@ -42,7 +43,7 @@ import { getProvider } from './providers/registry.js';
|
|||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
import { getProjectCloneDir } from '../git/project-clones.js';
|
import { getProjectCloneDir } from '../git/project-clones.js';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { unlink, readFile, writeFile as writeFileAsync } from 'node:fs/promises';
|
import { unlink, readFile, writeFile as writeFileAsync, mkdir } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import type { AccountCredentialManager } from './credentials/types.js';
|
import type { AccountCredentialManager } from './credentials/types.js';
|
||||||
import { ProcessManager } from './process-manager.js';
|
import { ProcessManager } from './process-manager.js';
|
||||||
@@ -84,11 +85,12 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
private debug: boolean = false,
|
private debug: boolean = false,
|
||||||
processManagerOverride?: ProcessManager,
|
processManagerOverride?: ProcessManager,
|
||||||
private chatSessionRepository?: ChatSessionRepository,
|
private chatSessionRepository?: ChatSessionRepository,
|
||||||
|
private reviewCommentRepository?: ReviewCommentRepository,
|
||||||
) {
|
) {
|
||||||
this.signalManager = new FileSystemSignalManager();
|
this.signalManager = new FileSystemSignalManager();
|
||||||
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
|
this.processManager = processManagerOverride ?? new ProcessManager(workspaceRoot, projectRepository);
|
||||||
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
this.credentialHandler = new CredentialHandler(workspaceRoot, accountRepository, credentialManager);
|
||||||
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository);
|
this.outputHandler = new OutputHandler(repository, eventBus, changeSetRepository, phaseRepository, taskRepository, pageRepository, this.signalManager, chatSessionRepository, reviewCommentRepository);
|
||||||
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
this.cleanupManager = new CleanupManager(workspaceRoot, repository, projectRepository, eventBus, debug, this.signalManager);
|
||||||
this.lifecycleController = createLifecycleController({
|
this.lifecycleController = createLifecycleController({
|
||||||
repository,
|
repository,
|
||||||
@@ -330,32 +332,10 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
|
|
||||||
await this.repository.update(agentId, { pid, outputFilePath });
|
await this.repository.update(agentId, { pid, outputFilePath });
|
||||||
|
|
||||||
// Write spawn diagnostic file for post-execution verification
|
// Register agent and start polling BEFORE non-critical I/O so that a
|
||||||
const diagnostic = {
|
// diagnostic-write failure can never orphan a running process.
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
agentId,
|
|
||||||
alias,
|
|
||||||
intendedCwd: finalCwd,
|
|
||||||
worktreeId: agent.worktreeId,
|
|
||||||
provider: providerName,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
env: processEnv,
|
|
||||||
cwdExistsAtSpawn: existsSync(finalCwd),
|
|
||||||
initiativeId: initiativeId || null,
|
|
||||||
customCwdProvided: !!cwd,
|
|
||||||
accountId: accountId || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
await writeFileAsync(
|
|
||||||
join(finalCwd, '.cw', 'spawn-diagnostic.json'),
|
|
||||||
JSON.stringify(diagnostic, null, 2),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd };
|
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath, agentCwd: finalCwd };
|
||||||
this.activeAgents.set(agentId, activeEntry);
|
this.activeAgents.set(agentId, activeEntry);
|
||||||
log.info({ agentId, alias, pid, diagnosticWritten: true }, 'detached subprocess started with diagnostic');
|
|
||||||
|
|
||||||
// Emit spawned event
|
// Emit spawned event
|
||||||
if (this.eventBus) {
|
if (this.eventBus) {
|
||||||
@@ -375,6 +355,37 @@ export class MultiProviderAgentManager implements AgentManager {
|
|||||||
);
|
);
|
||||||
activeEntry.cancelPoll = cancel;
|
activeEntry.cancelPoll = cancel;
|
||||||
|
|
||||||
|
// Write spawn diagnostic file (non-fatal — .cw/ may not exist yet for
|
||||||
|
// agents spawned without inputContext, e.g. conflict-resolution agents)
|
||||||
|
try {
|
||||||
|
const diagnosticDir = join(finalCwd, '.cw');
|
||||||
|
await mkdir(diagnosticDir, { recursive: true });
|
||||||
|
const diagnostic = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
agentId,
|
||||||
|
alias,
|
||||||
|
intendedCwd: finalCwd,
|
||||||
|
worktreeId: agent.worktreeId,
|
||||||
|
provider: providerName,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
env: processEnv,
|
||||||
|
cwdExistsAtSpawn: existsSync(finalCwd),
|
||||||
|
initiativeId: initiativeId || null,
|
||||||
|
customCwdProvided: !!cwd,
|
||||||
|
accountId: accountId || null,
|
||||||
|
};
|
||||||
|
await writeFileAsync(
|
||||||
|
join(diagnosticDir, 'spawn-diagnostic.json'),
|
||||||
|
JSON.stringify(diagnostic, null, 2),
|
||||||
|
'utf-8'
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ agentId, alias, err: err instanceof Error ? err.message : String(err) }, 'failed to write spawn diagnostic');
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info({ agentId, alias, pid }, 'detached subprocess started');
|
||||||
|
|
||||||
return this.toAgentInfo(agent);
|
return this.toAgentInfo(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFile } from 'node:fs/promises';
|
import { readFile } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
||||||
import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js';
|
import type { ChangeSetRepository, CreateChangeSetEntryData } from '../db/repositories/change-set-repository.js';
|
||||||
@@ -15,6 +15,7 @@ import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
|||||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||||
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
import type { ChatSessionRepository } from '../db/repositories/chat-session-repository.js';
|
||||||
|
import type { ReviewCommentRepository } from '../db/repositories/review-comment-repository.js';
|
||||||
import type {
|
import type {
|
||||||
EventBus,
|
EventBus,
|
||||||
AgentStoppedEvent,
|
AgentStoppedEvent,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
readDecisionFiles,
|
readDecisionFiles,
|
||||||
readPageFiles,
|
readPageFiles,
|
||||||
readFrontmatterFile,
|
readFrontmatterFile,
|
||||||
|
readCommentResponses,
|
||||||
} from './file-io.js';
|
} from './file-io.js';
|
||||||
import { getProvider } from './providers/registry.js';
|
import { getProvider } from './providers/registry.js';
|
||||||
import { markdownToTiptapJson } from './markdown-to-tiptap.js';
|
import { markdownToTiptapJson } from './markdown-to-tiptap.js';
|
||||||
@@ -92,6 +94,7 @@ export class OutputHandler {
|
|||||||
private pageRepository?: PageRepository,
|
private pageRepository?: PageRepository,
|
||||||
private signalManager?: SignalManager,
|
private signalManager?: SignalManager,
|
||||||
private chatSessionRepository?: ChatSessionRepository,
|
private chatSessionRepository?: ChatSessionRepository,
|
||||||
|
private reviewCommentRepository?: ReviewCommentRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -230,10 +233,10 @@ export class OutputHandler {
|
|||||||
|
|
||||||
log.debug({ agentId }, 'detached agent completed');
|
log.debug({ agentId }, 'detached agent completed');
|
||||||
|
|
||||||
// Resolve actual agent working directory — standalone agents run in a
|
// Resolve actual agent working directory.
|
||||||
// "workspace/" subdirectory inside getAgentWorkdir, so prefer agentCwd
|
// The recorded agentCwd may be the parent dir (agent-workdirs/<name>/) while
|
||||||
// recorded at spawn time when available.
|
// the agent actually writes .cw/output/ inside a project subdirectory.
|
||||||
const agentWorkdir = active?.agentCwd ?? getAgentWorkdir(agent.worktreeId);
|
const agentWorkdir = this.resolveAgentWorkdir(active?.agentCwd ?? getAgentWorkdir(agent.worktreeId));
|
||||||
const outputDir = join(agentWorkdir, '.cw', 'output');
|
const outputDir = join(agentWorkdir, '.cw', 'output');
|
||||||
const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt');
|
const expectedPwdFile = join(agentWorkdir, '.cw', 'expected-pwd.txt');
|
||||||
const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json');
|
const diagnosticFile = join(agentWorkdir, '.cw', 'spawn-diagnostic.json');
|
||||||
@@ -851,6 +854,28 @@ export class OutputHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process comment responses from agent (for review/execute tasks)
|
||||||
|
if (this.reviewCommentRepository) {
|
||||||
|
try {
|
||||||
|
const commentResponses = await readCommentResponses(agentWorkdir);
|
||||||
|
for (const resp of commentResponses) {
|
||||||
|
try {
|
||||||
|
await this.reviewCommentRepository.createReply(resp.commentId, resp.body, 'agent');
|
||||||
|
if (resp.resolved) {
|
||||||
|
await this.reviewCommentRepository.resolve(resp.commentId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ agentId, commentId: resp.commentId, err: err instanceof Error ? err.message : String(err) }, 'failed to process comment response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (commentResponses.length > 0) {
|
||||||
|
log.info({ agentId, count: commentResponses.length }, 'processed agent comment responses');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ agentId, err: err instanceof Error ? err.message : String(err) }, 'failed to read comment responses');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const resultPayload: AgentResult = {
|
const resultPayload: AgentResult = {
|
||||||
success: true,
|
success: true,
|
||||||
message: resultMessage,
|
message: resultMessage,
|
||||||
@@ -1133,6 +1158,31 @@ export class OutputHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the actual agent working directory. The recorded agentCwd may be
|
||||||
|
* the parent (agent-workdirs/<name>/) but .cw/output/ could be inside a
|
||||||
|
* project subdirectory (e.g. codewalk-district/.cw/output/).
|
||||||
|
*/
|
||||||
|
private resolveAgentWorkdir(base: string): string {
|
||||||
|
if (existsSync(join(base, '.cw', 'output'))) return base;
|
||||||
|
|
||||||
|
// Standalone agents: workspace/ subdirectory
|
||||||
|
const workspaceSub = join(base, 'workspace');
|
||||||
|
if (existsSync(join(workspaceSub, '.cw'))) return workspaceSub;
|
||||||
|
|
||||||
|
// Initiative-based agents: probe project subdirectories
|
||||||
|
try {
|
||||||
|
for (const entry of readdirSync(base, { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory() && entry.name !== '.cw') {
|
||||||
|
const sub = join(base, entry.name);
|
||||||
|
if (existsSync(join(sub, '.cw', 'output'))) return sub;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* base may not exist */ }
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void {
|
private emitCrashed(agent: { id: string; name: string; taskId: string | null }, error: string): void {
|
||||||
if (this.eventBus) {
|
if (this.eventBus) {
|
||||||
const event: AgentCrashedEvent = {
|
const event: AgentCrashedEvent = {
|
||||||
|
|||||||
77
apps/server/agent/prompts/conflict-resolution.ts
Normal file
77
apps/server/agent/prompts/conflict-resolution.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Conflict resolution prompt — spawned when initiative branch has merge conflicts
|
||||||
|
* with the target branch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
SIGNAL_FORMAT,
|
||||||
|
SESSION_STARTUP,
|
||||||
|
GIT_WORKFLOW,
|
||||||
|
CONTEXT_MANAGEMENT,
|
||||||
|
} from './shared.js';
|
||||||
|
|
||||||
|
export function buildConflictResolutionPrompt(
|
||||||
|
sourceBranch: string,
|
||||||
|
targetBranch: string,
|
||||||
|
conflicts: string[],
|
||||||
|
): string {
|
||||||
|
const conflictList = conflicts.map(f => `- \`${f}\``).join('\n');
|
||||||
|
|
||||||
|
return `<role>
|
||||||
|
You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` into the initiative branch \`${sourceBranch}\` and resolve all merge conflicts. You are working on a temporary branch created from \`${sourceBranch}\`. After resolving conflicts and committing, you must advance the initiative branch pointer using \`git update-ref\`.
|
||||||
|
</role>
|
||||||
|
|
||||||
|
<conflict_details>
|
||||||
|
**Source branch (initiative):** \`${sourceBranch}\`
|
||||||
|
**Target branch (default):** \`${targetBranch}\`
|
||||||
|
|
||||||
|
**Conflicting files:**
|
||||||
|
${conflictList}
|
||||||
|
</conflict_details>
|
||||||
|
${SIGNAL_FORMAT}
|
||||||
|
${SESSION_STARTUP}
|
||||||
|
|
||||||
|
<resolution_protocol>
|
||||||
|
Follow these steps in order:
|
||||||
|
|
||||||
|
1. **Inspect divergence**: Run \`git log --oneline ${targetBranch}..${sourceBranch}\` and \`git log --oneline ${sourceBranch}..${targetBranch}\` to understand what each side changed.
|
||||||
|
|
||||||
|
2. **Review conflicting files**: For each conflicting file, read both versions:
|
||||||
|
- \`git show ${sourceBranch}:<file>\`
|
||||||
|
- \`git show ${targetBranch}:<file>\`
|
||||||
|
|
||||||
|
3. **Merge**: Run \`git merge ${targetBranch} --no-edit\`. This will produce conflict markers.
|
||||||
|
|
||||||
|
4. **Resolve each file**: For each conflicting file:
|
||||||
|
- Read the file to see conflict markers (\`<<<<<<<\`, \`=======\`, \`>>>>>>>\`)
|
||||||
|
- Understand both sides' intent from step 1-2
|
||||||
|
- Choose the correct resolution — keep both changes when they don't overlap, prefer the more complete version when they do
|
||||||
|
- If you genuinely cannot determine the correct resolution, signal "questions" explaining the ambiguity
|
||||||
|
|
||||||
|
5. **Verify**: Run \`git diff --check\` to confirm no conflict markers remain. Run the test suite to confirm nothing is broken.
|
||||||
|
|
||||||
|
6. **Commit**: Stage resolved files with \`git add <file>\` (never \`git add .\`), then \`git commit --no-edit\` to complete the merge commit.
|
||||||
|
|
||||||
|
7. **Update initiative branch**: Run \`git update-ref refs/heads/${sourceBranch} HEAD\` to advance the initiative branch to include the merge result. This is necessary because you are working on a temporary branch — this command propagates the merge commit to the actual initiative branch.
|
||||||
|
|
||||||
|
8. **Signal done**: Write signal.json with status "done".
|
||||||
|
</resolution_protocol>
|
||||||
|
${GIT_WORKFLOW}
|
||||||
|
${CONTEXT_MANAGEMENT}
|
||||||
|
|
||||||
|
<important>
|
||||||
|
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.
|
||||||
|
- After committing the merge, you MUST run \`git update-ref refs/heads/${sourceBranch} HEAD\` to advance the initiative branch pointer. Without this step, the initiative branch will not reflect the merge.
|
||||||
|
- Do NOT force-push or rebase. A merge commit is the correct approach.
|
||||||
|
- If tests fail after resolution, fix the code — don't skip tests.
|
||||||
|
- If a conflict is genuinely ambiguous (e.g., both sides rewrote the same function differently), signal "questions" with the specific ambiguity and your proposed resolution.
|
||||||
|
</important>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildConflictResolutionDescription(
|
||||||
|
sourceBranch: string,
|
||||||
|
targetBranch: string,
|
||||||
|
conflicts: string[],
|
||||||
|
): string {
|
||||||
|
return `Resolve ${conflicts.length} merge conflict(s) between ${sourceBranch} and ${targetBranch}: ${conflicts.join(', ')}`;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ ${CODEBASE_EXPLORATION}
|
|||||||
|
|
||||||
<output_format>
|
<output_format>
|
||||||
Write one file per task to \`.cw/output/tasks/{id}.md\`:
|
Write one file per task to \`.cw/output/tasks/{id}.md\`:
|
||||||
- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`type\` (auto|checkpoint:human-verify|checkpoint:decision|checkpoint:human-action), \`dependencies\` (list of task IDs that must complete before this task can start)
|
- Frontmatter: \`title\`, \`category\` (execute|research|discuss|plan|detail|refine|verify|merge|review), \`dependencies\` (list of task IDs that must complete before this task can start)
|
||||||
- Body: Detailed task description
|
- Body: Detailed task description
|
||||||
</output_format>
|
</output_format>
|
||||||
|
|
||||||
@@ -92,14 +92,6 @@ Each task is handled by a separate agent that must load the full codebase contex
|
|||||||
Bundle related changes into one task. "Add user validation" + "Add user API route" + "Add user route tests" is ONE task ("Add user creation endpoint with validation and tests"), not three.
|
Bundle related changes into one task. "Add user validation" + "Add user API route" + "Add user route tests" is ONE task ("Add user creation endpoint with validation and tests"), not three.
|
||||||
</task_sizing>
|
</task_sizing>
|
||||||
|
|
||||||
<checkpoint_tasks>
|
|
||||||
- \`checkpoint:human-verify\`: Visual changes, migrations, API contracts
|
|
||||||
- \`checkpoint:decision\`: Architecture choices affecting multiple phases
|
|
||||||
- \`checkpoint:human-action\`: External setup (DNS, credentials, third-party config)
|
|
||||||
|
|
||||||
~90% of tasks should be \`auto\`.
|
|
||||||
</checkpoint_tasks>
|
|
||||||
|
|
||||||
<existing_context>
|
<existing_context>
|
||||||
- Read ALL \`context/tasks/\` files before generating output
|
- Read ALL \`context/tasks/\` files before generating output
|
||||||
- Only create tasks for THIS phase (\`phase.md\`)
|
- Only create tasks for THIS phase (\`phase.md\`)
|
||||||
|
|||||||
@@ -14,13 +14,26 @@ import {
|
|||||||
} from './shared.js';
|
} from './shared.js';
|
||||||
|
|
||||||
export function buildExecutePrompt(taskDescription?: string): string {
|
export function buildExecutePrompt(taskDescription?: string): string {
|
||||||
|
const hasReviewComments = taskDescription?.includes('[comment:');
|
||||||
|
const reviewCommentsSection = hasReviewComments
|
||||||
|
? `
|
||||||
|
<review_comments>
|
||||||
|
You are addressing review feedback. Each comment is tagged with [comment:ID].
|
||||||
|
For EACH comment you address:
|
||||||
|
1. Fix the issue in code, OR explain why no change is needed.
|
||||||
|
2. Write \`.cw/output/comment-responses.json\`:
|
||||||
|
[{"commentId": "abc123", "body": "Fixed: added try-catch around token validation", "resolved": true}]
|
||||||
|
Set resolved:true when you fixed it, false when you're explaining why you didn't.
|
||||||
|
</review_comments>`
|
||||||
|
: '';
|
||||||
|
|
||||||
const taskSection = taskDescription
|
const taskSection = taskDescription
|
||||||
? `
|
? `
|
||||||
<task>
|
<task>
|
||||||
${taskDescription}
|
${taskDescription}
|
||||||
|
|
||||||
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
|
Read \`.cw/input/task.md\` for the full structured task with metadata, priority, and dependencies.
|
||||||
</task>`
|
</task>${reviewCommentsSection}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
return `<role>
|
return `<role>
|
||||||
|
|||||||
@@ -15,3 +15,4 @@ export { buildChatPrompt } from './chat.js';
|
|||||||
export type { ChatHistoryEntry } from './chat.js';
|
export type { ChatHistoryEntry } from './chat.js';
|
||||||
export { buildWorkspaceLayout } from './workspace.js';
|
export { buildWorkspaceLayout } from './workspace.js';
|
||||||
export { buildPreviewInstructions } from './preview.js';
|
export { buildPreviewInstructions } from './preview.js';
|
||||||
|
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';
|
||||||
|
|||||||
90
apps/server/cli/extract.test.ts
Normal file
90
apps/server/cli/extract.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the accounts module so the dynamic import is intercepted
|
||||||
|
vi.mock('../agent/accounts/index.js', () => ({
|
||||||
|
extractCurrentClaudeAccount: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { extractCurrentClaudeAccount } from '../agent/accounts/index.js';
|
||||||
|
const mockExtract = vi.mocked(extractCurrentClaudeAccount);
|
||||||
|
|
||||||
|
import { createCli } from './index.js';
|
||||||
|
|
||||||
|
describe('cw account extract', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
vi.spyOn(process, 'exit').mockImplementation((_code?: string | number | null) => undefined as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path — no email flag prints JSON to stdout', async () => {
|
||||||
|
mockExtract.mockResolvedValueOnce({
|
||||||
|
email: 'user@example.com',
|
||||||
|
accountUuid: 'uuid-1',
|
||||||
|
configJson: { hasCompletedOnboarding: true },
|
||||||
|
credentials: '{"claudeAiOauth":{"accessToken":"tok"}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = createCli();
|
||||||
|
await program.parseAsync(['node', 'cw', 'account', 'extract']);
|
||||||
|
|
||||||
|
expect(console.log).toHaveBeenCalledTimes(1);
|
||||||
|
const output = JSON.parse((console.log as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||||
|
expect(output.email).toBe('user@example.com');
|
||||||
|
expect(output.configJson).toBe('{"hasCompletedOnboarding":true}');
|
||||||
|
expect(output.credentials).toBe('{"claudeAiOauth":{"accessToken":"tok"}}');
|
||||||
|
expect(process.exit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('happy path — matching email flag succeeds', async () => {
|
||||||
|
mockExtract.mockResolvedValueOnce({
|
||||||
|
email: 'user@example.com',
|
||||||
|
accountUuid: 'uuid-1',
|
||||||
|
configJson: { hasCompletedOnboarding: true },
|
||||||
|
credentials: '{"claudeAiOauth":{"accessToken":"tok"}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = createCli();
|
||||||
|
await program.parseAsync(['node', 'cw', 'account', 'extract', '--email', 'user@example.com']);
|
||||||
|
|
||||||
|
expect(console.log).toHaveBeenCalledTimes(1);
|
||||||
|
const output = JSON.parse((console.log as ReturnType<typeof vi.fn>).mock.calls[0][0]);
|
||||||
|
expect(output.email).toBe('user@example.com');
|
||||||
|
expect(output.configJson).toBe('{"hasCompletedOnboarding":true}');
|
||||||
|
expect(output.credentials).toBe('{"claudeAiOauth":{"accessToken":"tok"}}');
|
||||||
|
expect(process.exit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email mismatch prints error and exits with code 1', async () => {
|
||||||
|
mockExtract.mockResolvedValueOnce({
|
||||||
|
email: 'other@example.com',
|
||||||
|
accountUuid: 'uuid-2',
|
||||||
|
configJson: { hasCompletedOnboarding: true },
|
||||||
|
credentials: '{"claudeAiOauth":{"accessToken":"tok"}}',
|
||||||
|
});
|
||||||
|
|
||||||
|
const program = createCli();
|
||||||
|
await program.parseAsync(['node', 'cw', 'account', 'extract', '--email', 'user@example.com']);
|
||||||
|
|
||||||
|
expect(console.error).toHaveBeenCalledWith(
|
||||||
|
"Account 'user@example.com' not found (active account is 'other@example.com')"
|
||||||
|
);
|
||||||
|
expect(process.exit).toHaveBeenCalledWith(1);
|
||||||
|
expect(console.log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extractCurrentClaudeAccount throws prints error and exits with code 1', async () => {
|
||||||
|
mockExtract.mockRejectedValueOnce(new Error('No Claude account found'));
|
||||||
|
|
||||||
|
const program = createCli();
|
||||||
|
await program.parseAsync(['node', 'cw', 'account', 'extract']);
|
||||||
|
|
||||||
|
expect(console.error).toHaveBeenCalledWith('Failed to extract account:', 'No Claude account found');
|
||||||
|
expect(process.exit).toHaveBeenCalledWith(1);
|
||||||
|
expect(console.log).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1334,6 +1334,32 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// cw account extract
|
||||||
|
accountCommand
|
||||||
|
.command('extract')
|
||||||
|
.description('Extract current Claude credentials for use with the UI (does not require server)')
|
||||||
|
.option('--email <email>', 'Verify extracted account matches this email')
|
||||||
|
.action(async (options: { email?: string }) => {
|
||||||
|
try {
|
||||||
|
const { extractCurrentClaudeAccount } = await import('../agent/accounts/index.js');
|
||||||
|
const extracted = await extractCurrentClaudeAccount();
|
||||||
|
if (options.email && extracted.email !== options.email) {
|
||||||
|
console.error(`Account '${options.email}' not found (active account is '${extracted.email}')`);
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const output = {
|
||||||
|
email: extracted.email,
|
||||||
|
configJson: JSON.stringify(extracted.configJson),
|
||||||
|
credentials: extracted.credentials,
|
||||||
|
};
|
||||||
|
console.log(JSON.stringify(output, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to extract account:', (error as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Preview command group
|
// Preview command group
|
||||||
const previewCommand = program
|
const previewCommand = program
|
||||||
.command('preview')
|
.command('preview')
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
options?.debug ?? false,
|
options?.debug ?? false,
|
||||||
undefined, // processManagerOverride
|
undefined, // processManagerOverride
|
||||||
repos.chatSessionRepository,
|
repos.chatSessionRepository,
|
||||||
|
repos.reviewCommentRepository,
|
||||||
);
|
);
|
||||||
log.info('agent manager created');
|
log.info('agent manager created');
|
||||||
|
|
||||||
@@ -212,6 +213,9 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
repos.phaseRepository,
|
repos.phaseRepository,
|
||||||
repos.agentRepository,
|
repos.agentRepository,
|
||||||
repos.pageRepository,
|
repos.pageRepository,
|
||||||
|
repos.projectRepository,
|
||||||
|
branchManager,
|
||||||
|
workspaceRoot,
|
||||||
);
|
);
|
||||||
const phaseDispatchManager = new DefaultPhaseDispatchManager(
|
const phaseDispatchManager = new DefaultPhaseDispatchManager(
|
||||||
repos.phaseRepository,
|
repos.phaseRepository,
|
||||||
|
|||||||
@@ -33,4 +33,10 @@ export interface ChangeSetRepository {
|
|||||||
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
|
findByInitiativeId(initiativeId: string): Promise<ChangeSet[]>;
|
||||||
findByAgentId(agentId: string): Promise<ChangeSet[]>;
|
findByAgentId(agentId: string): Promise<ChangeSet[]>;
|
||||||
markReverted(id: string): Promise<ChangeSet>;
|
markReverted(id: string): Promise<ChangeSet>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find applied changesets that have a 'create' entry for the given entity.
|
||||||
|
* Used to reconcile changeset status when entities are manually deleted.
|
||||||
|
*/
|
||||||
|
findAppliedByCreatedEntity(entityType: string, entityId: string): Promise<ChangeSetWithEntries[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* Implements ChangeSetRepository interface using Drizzle ORM.
|
* Implements ChangeSetRepository interface using Drizzle ORM.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, desc, asc } from 'drizzle-orm';
|
import { eq, desc, asc, and } from 'drizzle-orm';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { DrizzleDatabase } from '../../index.js';
|
import type { DrizzleDatabase } from '../../index.js';
|
||||||
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
|
import { changeSets, changeSetEntries, type ChangeSet } from '../../schema.js';
|
||||||
@@ -94,6 +94,32 @@ export class DrizzleChangeSetRepository implements ChangeSetRepository {
|
|||||||
.orderBy(desc(changeSets.createdAt));
|
.orderBy(desc(changeSets.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findAppliedByCreatedEntity(entityType: string, entityId: string): Promise<ChangeSetWithEntries[]> {
|
||||||
|
// Find changeset entries matching the entity
|
||||||
|
const matchingEntries = await this.db
|
||||||
|
.select({ changeSetId: changeSetEntries.changeSetId })
|
||||||
|
.from(changeSetEntries)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(changeSetEntries.entityType, entityType as any),
|
||||||
|
eq(changeSetEntries.entityId, entityId),
|
||||||
|
eq(changeSetEntries.action, 'create'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results: ChangeSetWithEntries[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const { changeSetId } of matchingEntries) {
|
||||||
|
if (seen.has(changeSetId)) continue;
|
||||||
|
seen.add(changeSetId);
|
||||||
|
const cs = await this.findByIdWithEntries(changeSetId);
|
||||||
|
if (cs && cs.status === 'applied') {
|
||||||
|
results.push(cs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
async markReverted(id: string): Promise<ChangeSet> {
|
async markReverted(id: string): Promise<ChangeSet> {
|
||||||
const [updated] = await this.db
|
const [updated] = await this.db
|
||||||
.update(changeSets)
|
.update(changeSets)
|
||||||
|
|||||||
@@ -23,7 +23,43 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
|
|||||||
lineNumber: data.lineNumber,
|
lineNumber: data.lineNumber,
|
||||||
lineType: data.lineType,
|
lineType: data.lineType,
|
||||||
body: data.body,
|
body: data.body,
|
||||||
author: data.author ?? 'you',
|
author: data.author ?? 'user',
|
||||||
|
parentCommentId: data.parentCommentId ?? null,
|
||||||
|
resolved: false,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(reviewComments)
|
||||||
|
.where(eq(reviewComments.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment> {
|
||||||
|
// Fetch parent comment to copy context fields
|
||||||
|
const parentRows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(reviewComments)
|
||||||
|
.where(eq(reviewComments.id, parentCommentId))
|
||||||
|
.limit(1);
|
||||||
|
const parent = parentRows[0];
|
||||||
|
if (!parent) {
|
||||||
|
throw new Error(`Parent comment not found: ${parentCommentId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const id = nanoid();
|
||||||
|
await this.db.insert(reviewComments).values({
|
||||||
|
id,
|
||||||
|
phaseId: parent.phaseId,
|
||||||
|
filePath: parent.filePath,
|
||||||
|
lineNumber: parent.lineNumber,
|
||||||
|
lineType: parent.lineType,
|
||||||
|
body,
|
||||||
|
author: author ?? 'user',
|
||||||
|
parentCommentId,
|
||||||
resolved: false,
|
resolved: false,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
@@ -44,6 +80,19 @@ export class DrizzleReviewCommentRepository implements ReviewCommentRepository {
|
|||||||
.orderBy(asc(reviewComments.createdAt));
|
.orderBy(asc(reviewComments.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, body: string): Promise<ReviewComment | null> {
|
||||||
|
await this.db
|
||||||
|
.update(reviewComments)
|
||||||
|
.set({ body, updatedAt: new Date() })
|
||||||
|
.where(eq(reviewComments.id, id));
|
||||||
|
const rows = await this.db
|
||||||
|
.select()
|
||||||
|
.from(reviewComments)
|
||||||
|
.where(eq(reviewComments.id, id))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async resolve(id: string): Promise<ReviewComment | null> {
|
async resolve(id: string): Promise<ReviewComment | null> {
|
||||||
await this.db
|
await this.db
|
||||||
.update(reviewComments)
|
.update(reviewComments)
|
||||||
|
|||||||
@@ -71,13 +71,13 @@ describe('DrizzleTaskRepository', () => {
|
|||||||
it('should accept custom type and priority', async () => {
|
it('should accept custom type and priority', async () => {
|
||||||
const task = await taskRepo.create({
|
const task = await taskRepo.create({
|
||||||
phaseId: testPhaseId,
|
phaseId: testPhaseId,
|
||||||
name: 'Checkpoint Task',
|
name: 'High Priority Task',
|
||||||
type: 'checkpoint:human-verify',
|
type: 'auto',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
order: 1,
|
order: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(task.type).toBe('checkpoint:human-verify');
|
expect(task.type).toBe('auto');
|
||||||
expect(task.priority).toBe('high');
|
expect(task.priority).toBe('high');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ export interface CreateReviewCommentData {
|
|||||||
lineType: 'added' | 'removed' | 'context';
|
lineType: 'added' | 'removed' | 'context';
|
||||||
body: string;
|
body: string;
|
||||||
author?: string;
|
author?: string;
|
||||||
|
parentCommentId?: string; // for replies
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReviewCommentRepository {
|
export interface ReviewCommentRepository {
|
||||||
create(data: CreateReviewCommentData): Promise<ReviewComment>;
|
create(data: CreateReviewCommentData): Promise<ReviewComment>;
|
||||||
|
createReply(parentCommentId: string, body: string, author?: string): Promise<ReviewComment>;
|
||||||
findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
|
findByPhaseId(phaseId: string): Promise<ReviewComment[]>;
|
||||||
|
update(id: string, body: string): Promise<ReviewComment | null>;
|
||||||
resolve(id: string): Promise<ReviewComment | null>;
|
resolve(id: string): Promise<ReviewComment | null>;
|
||||||
unresolve(id: string): Promise<ReviewComment | null>;
|
unresolve(id: string): Promise<ReviewComment | null>;
|
||||||
delete(id: string): Promise<void>;
|
delete(id: string): Promise<void>;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const phases = sqliteTable('phases', {
|
|||||||
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
|
status: text('status', { enum: ['pending', 'approved', 'in_progress', 'completed', 'blocked', 'pending_review'] })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default('pending'),
|
.default('pending'),
|
||||||
|
mergeBase: text('merge_base'),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
});
|
});
|
||||||
@@ -137,7 +138,7 @@ export const tasks = sqliteTable('tasks', {
|
|||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
description: text('description'),
|
description: text('description'),
|
||||||
type: text('type', {
|
type: text('type', {
|
||||||
enum: ['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action'],
|
enum: ['auto'],
|
||||||
})
|
})
|
||||||
.notNull()
|
.notNull()
|
||||||
.default('auto'),
|
.default('auto'),
|
||||||
@@ -616,11 +617,13 @@ export const reviewComments = sqliteTable('review_comments', {
|
|||||||
lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(),
|
lineType: text('line_type', { enum: ['added', 'removed', 'context'] }).notNull(),
|
||||||
body: text('body').notNull(),
|
body: text('body').notNull(),
|
||||||
author: text('author').notNull().default('you'),
|
author: text('author').notNull().default('you'),
|
||||||
|
parentCommentId: text('parent_comment_id').references((): ReturnType<typeof text> => reviewComments.id, { onDelete: 'cascade' }),
|
||||||
resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
|
resolved: integer('resolved', { mode: 'boolean' }).notNull().default(false),
|
||||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('review_comments_phase_id_idx').on(table.phaseId),
|
index('review_comments_phase_id_idx').on(table.phaseId),
|
||||||
|
index('review_comments_parent_id_idx').on(table.parentCommentId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type ReviewComment = InferSelectModel<typeof reviewComments>;
|
export type ReviewComment = InferSelectModel<typeof reviewComments>;
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ import type { AgentRepository } from '../db/repositories/agent-repository.js';
|
|||||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||||
import type { PageRepository } from '../db/repositories/page-repository.js';
|
import type { PageRepository } from '../db/repositories/page-repository.js';
|
||||||
|
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||||
import type { Task, Phase } from '../db/schema.js';
|
import type { Task, Phase } from '../db/schema.js';
|
||||||
import type { PageForSerialization } from '../agent/content-serializer.js';
|
import type { PageForSerialization } from '../agent/content-serializer.js';
|
||||||
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
|
import type { DispatchManager, QueuedTask, DispatchResult } from './types.js';
|
||||||
import { phaseBranchName, taskBranchName, isPlanningCategory, generateInitiativeBranch } from '../git/branch-naming.js';
|
import { phaseBranchName, taskBranchName, isPlanningCategory, generateInitiativeBranch } from '../git/branch-naming.js';
|
||||||
|
import { ensureProjectClone } from '../git/project-clones.js';
|
||||||
import { buildExecutePrompt } from '../agent/prompts/index.js';
|
import { buildExecutePrompt } from '../agent/prompts/index.js';
|
||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
|
|
||||||
@@ -68,12 +71,14 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
private phaseRepository?: PhaseRepository,
|
private phaseRepository?: PhaseRepository,
|
||||||
private agentRepository?: AgentRepository,
|
private agentRepository?: AgentRepository,
|
||||||
private pageRepository?: PageRepository,
|
private pageRepository?: PageRepository,
|
||||||
|
private projectRepository?: ProjectRepository,
|
||||||
|
private branchManager?: BranchManager,
|
||||||
|
private workspaceRoot?: string,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue a task for dispatch.
|
* Queue a task for dispatch.
|
||||||
* Fetches task dependencies and adds to internal queue.
|
* Fetches task dependencies and adds to internal queue.
|
||||||
* Checkpoint tasks are queued but won't auto-dispatch.
|
|
||||||
*/
|
*/
|
||||||
async queue(taskId: string): Promise<void> {
|
async queue(taskId: string): Promise<void> {
|
||||||
// Fetch task to verify it exists and get priority
|
// Fetch task to verify it exists and get priority
|
||||||
@@ -94,7 +99,7 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
|
|
||||||
this.taskQueue.set(taskId, queuedTask);
|
this.taskQueue.set(taskId, queuedTask);
|
||||||
|
|
||||||
log.info({ taskId, priority: task.priority, isCheckpoint: this.isCheckpointTask(task) }, 'task queued');
|
log.info({ taskId, priority: task.priority }, 'task queued');
|
||||||
|
|
||||||
// Emit TaskQueuedEvent
|
// Emit TaskQueuedEvent
|
||||||
const event: TaskQueuedEvent = {
|
const event: TaskQueuedEvent = {
|
||||||
@@ -112,7 +117,6 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
/**
|
/**
|
||||||
* Get next dispatchable task.
|
* Get next dispatchable task.
|
||||||
* Returns task with all dependencies complete, highest priority first.
|
* Returns task with all dependencies complete, highest priority first.
|
||||||
* Checkpoint tasks are excluded (require human action).
|
|
||||||
*/
|
*/
|
||||||
async getNextDispatchable(): Promise<QueuedTask | null> {
|
async getNextDispatchable(): Promise<QueuedTask | null> {
|
||||||
const queuedTasks = Array.from(this.taskQueue.values());
|
const queuedTasks = Array.from(this.taskQueue.values());
|
||||||
@@ -121,7 +125,7 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter to only tasks with all dependencies complete and not checkpoint tasks
|
// Filter to only tasks with all dependencies complete
|
||||||
const readyTasks: QueuedTask[] = [];
|
const readyTasks: QueuedTask[] = [];
|
||||||
|
|
||||||
log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks');
|
log.debug({ queueSize: queuedTasks.length }, 'evaluating dispatchable tasks');
|
||||||
@@ -133,14 +137,8 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a checkpoint task (requires human action)
|
|
||||||
const task = await this.taskRepository.findById(qt.taskId);
|
|
||||||
if (task && this.isCheckpointTask(task)) {
|
|
||||||
log.debug({ taskId: qt.taskId, type: task.type }, 'skipping checkpoint task');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip planning-category tasks (handled by architect flow)
|
// Skip planning-category tasks (handled by architect flow)
|
||||||
|
const task = await this.taskRepository.findById(qt.taskId);
|
||||||
if (task && isPlanningCategory(task.category)) {
|
if (task && isPlanningCategory(task.category)) {
|
||||||
log.debug({ taskId: qt.taskId, category: task.category }, 'skipping planning-category task');
|
log.debug({ taskId: qt.taskId, category: task.category }, 'skipping planning-category task');
|
||||||
continue;
|
continue;
|
||||||
@@ -237,6 +235,27 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
this.eventBus.emit(event);
|
this.eventBus.emit(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a blocked task.
|
||||||
|
* Resets status to pending, clears block state, and re-queues for dispatch.
|
||||||
|
*/
|
||||||
|
async retryBlockedTask(taskId: string): Promise<void> {
|
||||||
|
const task = await this.taskRepository.findById(taskId);
|
||||||
|
if (!task) throw new Error(`Task not found: ${taskId}`);
|
||||||
|
if (task.status !== 'blocked') throw new Error(`Task ${taskId} is not blocked (status: ${task.status})`);
|
||||||
|
|
||||||
|
// Clear blocked state
|
||||||
|
this.blockedTasks.delete(taskId);
|
||||||
|
|
||||||
|
// Reset DB status to pending
|
||||||
|
await this.taskRepository.update(taskId, { status: 'pending' });
|
||||||
|
|
||||||
|
log.info({ taskId }, 'retrying blocked task');
|
||||||
|
|
||||||
|
// Re-queue for dispatch
|
||||||
|
await this.queue(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch next available task to an agent.
|
* Dispatch next available task to an agent.
|
||||||
*/
|
*/
|
||||||
@@ -311,6 +330,29 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
} catch {
|
} catch {
|
||||||
// Non-fatal: fall back to default branching
|
// Non-fatal: fall back to default branching
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure branches exist in project clones before spawning worktrees
|
||||||
|
if (baseBranch && this.projectRepository && this.branchManager && this.workspaceRoot) {
|
||||||
|
try {
|
||||||
|
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
||||||
|
const initBranch = initiative?.branch;
|
||||||
|
if (initBranch) {
|
||||||
|
const projects = await this.projectRepository.findProjectsByInitiativeId(task.initiativeId);
|
||||||
|
for (const project of projects) {
|
||||||
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
|
// Ensure initiative branch exists (from project defaultBranch)
|
||||||
|
await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch);
|
||||||
|
// Ensure phase branch exists (from initiative branch)
|
||||||
|
const phBranch = phaseBranchName(initBranch, (await this.phaseRepository?.findById(task.phaseId!))?.name ?? '');
|
||||||
|
if (phBranch) {
|
||||||
|
await this.branchManager.ensureBranch(clonePath, phBranch, initBranch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log.warn({ taskId: task.id, err }, 'failed to ensure branches for task dispatch');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather initiative context for the agent's input files
|
// Gather initiative context for the agent's input files
|
||||||
@@ -428,14 +470,6 @@ export class DefaultDispatchManager implements DispatchManager {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a task is a checkpoint task.
|
|
||||||
* Checkpoint tasks require human action and don't auto-dispatch.
|
|
||||||
*/
|
|
||||||
private isCheckpointTask(task: Task): boolean {
|
|
||||||
return task.type.startsWith('checkpoint:');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store the completing agent's result summary on the task record.
|
* Store the completing agent's result summary on the task record.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ function createMockDispatchManager(): DispatchManager {
|
|||||||
dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }),
|
dispatchNext: vi.fn().mockResolvedValue({ success: false, reason: 'mock' }),
|
||||||
completeTask: vi.fn(),
|
completeTask: vi.fn(),
|
||||||
blockTask: vi.fn(),
|
blockTask: vi.fn(),
|
||||||
|
retryBlockedTask: vi.fn(),
|
||||||
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
|
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,14 @@ export interface DispatchManager {
|
|||||||
*/
|
*/
|
||||||
blockTask(taskId: string, reason: string): Promise<void>;
|
blockTask(taskId: string, reason: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a blocked task.
|
||||||
|
* Resets status to pending, removes from blocked map, and re-queues for dispatch.
|
||||||
|
*
|
||||||
|
* @param taskId - ID of the blocked task to retry
|
||||||
|
*/
|
||||||
|
retryBlockedTask(taskId: string): Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current queue state.
|
* Get current queue state.
|
||||||
* Returns all queued tasks with their dispatch readiness.
|
* Returns all queued tasks with their dispatch readiness.
|
||||||
|
|||||||
1
apps/server/drizzle/0031_add_phase_merge_base.sql
Normal file
1
apps/server/drizzle/0031_add_phase_merge_base.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE phases ADD COLUMN merge_base TEXT;
|
||||||
2
apps/server/drizzle/0032_add_comment_threading.sql
Normal file
2
apps/server/drizzle/0032_add_comment_threading.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE review_comments ADD COLUMN parent_comment_id TEXT REFERENCES review_comments(id) ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
CREATE INDEX review_comments_parent_id_idx ON review_comments(parent_comment_id);
|
||||||
5
apps/server/drizzle/0033_drop_approval_columns.sql
Normal file
5
apps/server/drizzle/0033_drop_approval_columns.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- Drop orphaned approval columns left behind by 0030_remove_task_approval.
|
||||||
|
-- These columns were removed from schema.ts but left in the DB because
|
||||||
|
-- 0030 assumed SQLite couldn't DROP COLUMN. SQLite 3.35+ supports it.
|
||||||
|
ALTER TABLE initiatives DROP COLUMN merge_requires_approval;--> statement-breakpoint
|
||||||
|
ALTER TABLE tasks DROP COLUMN requires_approval;
|
||||||
1864
apps/server/drizzle/meta/0032_snapshot.json
Normal file
1864
apps/server/drizzle/meta/0032_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -218,6 +218,27 @@
|
|||||||
"when": 1772150400000,
|
"when": 1772150400000,
|
||||||
"tag": "0030_remove_task_approval",
|
"tag": "0030_remove_task_approval",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 31,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772236800000,
|
||||||
|
"tag": "0031_add_phase_merge_base",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 32,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772323200000,
|
||||||
|
"tag": "0032_add_comment_threading",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 33,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772409600000,
|
||||||
|
"tag": "0033_drop_approval_columns",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,7 @@ export type {
|
|||||||
AccountCredentialsValidatedEvent,
|
AccountCredentialsValidatedEvent,
|
||||||
InitiativePendingReviewEvent,
|
InitiativePendingReviewEvent,
|
||||||
InitiativeReviewApprovedEvent,
|
InitiativeReviewApprovedEvent,
|
||||||
|
InitiativeChangesRequestedEvent,
|
||||||
DomainEventMap,
|
DomainEventMap,
|
||||||
DomainEventType,
|
DomainEventType,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|||||||
@@ -591,6 +591,15 @@ export interface InitiativeReviewApprovedEvent extends DomainEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InitiativeChangesRequestedEvent extends DomainEvent {
|
||||||
|
type: 'initiative:changes_requested';
|
||||||
|
payload: {
|
||||||
|
initiativeId: string;
|
||||||
|
phaseId: string;
|
||||||
|
taskId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat Session Events
|
* Chat Session Events
|
||||||
*/
|
*/
|
||||||
@@ -668,7 +677,8 @@ export type DomainEventMap =
|
|||||||
| ChatMessageCreatedEvent
|
| ChatMessageCreatedEvent
|
||||||
| ChatSessionClosedEvent
|
| ChatSessionClosedEvent
|
||||||
| InitiativePendingReviewEvent
|
| InitiativePendingReviewEvent
|
||||||
| InitiativeReviewApprovedEvent;
|
| InitiativeReviewApprovedEvent
|
||||||
|
| InitiativeChangesRequestedEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event type literal union for type checking
|
* Event type literal union for type checking
|
||||||
|
|||||||
309
apps/server/execution/orchestrator.test.ts
Normal file
309
apps/server/execution/orchestrator.test.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
/**
|
||||||
|
* ExecutionOrchestrator Tests
|
||||||
|
*
|
||||||
|
* Tests phase completion transitions, especially when initiative has no branch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { ExecutionOrchestrator } from './orchestrator.js';
|
||||||
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
|
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||||
|
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||||
|
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||||
|
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||||
|
import type { DispatchManager, PhaseDispatchManager } from '../dispatch/types.js';
|
||||||
|
import type { ConflictResolutionService } from '../coordination/conflict-resolution-service.js';
|
||||||
|
import type { EventBus, TaskCompletedEvent, DomainEvent } from '../events/types.js';
|
||||||
|
|
||||||
|
function createMockEventBus(): EventBus & { handlers: Map<string, Function[]>; emitted: DomainEvent[] } {
|
||||||
|
const handlers = new Map<string, Function[]>();
|
||||||
|
const emitted: DomainEvent[] = [];
|
||||||
|
return {
|
||||||
|
handlers,
|
||||||
|
emitted,
|
||||||
|
emit: vi.fn((event: DomainEvent) => {
|
||||||
|
emitted.push(event);
|
||||||
|
const fns = handlers.get(event.type) ?? [];
|
||||||
|
for (const fn of fns) fn(event);
|
||||||
|
}),
|
||||||
|
on: vi.fn((type: string, handler: Function) => {
|
||||||
|
const fns = handlers.get(type) ?? [];
|
||||||
|
fns.push(handler);
|
||||||
|
handlers.set(type, fns);
|
||||||
|
}),
|
||||||
|
off: vi.fn(),
|
||||||
|
once: vi.fn(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMocks() {
|
||||||
|
const branchManager: BranchManager = {
|
||||||
|
ensureBranch: vi.fn(),
|
||||||
|
mergeBranch: vi.fn().mockResolvedValue({ success: true, message: 'merged' }),
|
||||||
|
diffBranches: vi.fn().mockResolvedValue(''),
|
||||||
|
deleteBranch: vi.fn(),
|
||||||
|
branchExists: vi.fn().mockResolvedValue(true),
|
||||||
|
remoteBranchExists: vi.fn().mockResolvedValue(false),
|
||||||
|
listCommits: vi.fn().mockResolvedValue([]),
|
||||||
|
diffCommit: vi.fn().mockResolvedValue(''),
|
||||||
|
getMergeBase: vi.fn().mockResolvedValue('abc123'),
|
||||||
|
pushBranch: vi.fn(),
|
||||||
|
checkMergeability: vi.fn().mockResolvedValue({ mergeable: true }),
|
||||||
|
fetchRemote: vi.fn(),
|
||||||
|
fastForwardBranch: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const phaseRepository = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||||
|
update: vi.fn().mockImplementation(async (id: string, data: any) => ({ id, ...data })),
|
||||||
|
create: vi.fn(),
|
||||||
|
} as unknown as PhaseRepository;
|
||||||
|
|
||||||
|
const taskRepository = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByPhaseId: vi.fn().mockResolvedValue([]),
|
||||||
|
findByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as TaskRepository;
|
||||||
|
|
||||||
|
const initiativeRepository = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByStatus: vi.fn().mockResolvedValue([]),
|
||||||
|
update: vi.fn(),
|
||||||
|
} as unknown as InitiativeRepository;
|
||||||
|
|
||||||
|
const projectRepository = {
|
||||||
|
findProjectsByInitiativeId: vi.fn().mockResolvedValue([]),
|
||||||
|
} as unknown as ProjectRepository;
|
||||||
|
|
||||||
|
const phaseDispatchManager: PhaseDispatchManager = {
|
||||||
|
queuePhase: vi.fn(),
|
||||||
|
getNextDispatchablePhase: vi.fn().mockResolvedValue(null),
|
||||||
|
dispatchNextPhase: vi.fn().mockResolvedValue({ success: false, phaseId: '', reason: 'none' }),
|
||||||
|
completePhase: vi.fn(),
|
||||||
|
blockPhase: vi.fn(),
|
||||||
|
getPhaseQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchManager = {
|
||||||
|
queue: vi.fn(),
|
||||||
|
getNextDispatchable: vi.fn().mockResolvedValue(null),
|
||||||
|
dispatchNext: vi.fn().mockResolvedValue({ success: false, taskId: '' }),
|
||||||
|
completeTask: vi.fn(),
|
||||||
|
blockTask: vi.fn(),
|
||||||
|
retryBlockedTask: vi.fn(),
|
||||||
|
getQueueState: vi.fn().mockResolvedValue({ queued: [], ready: [], blocked: [] }),
|
||||||
|
} as unknown as DispatchManager;
|
||||||
|
|
||||||
|
const conflictResolutionService: ConflictResolutionService = {
|
||||||
|
handleConflict: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventBus = createMockEventBus();
|
||||||
|
|
||||||
|
return {
|
||||||
|
branchManager,
|
||||||
|
phaseRepository,
|
||||||
|
taskRepository,
|
||||||
|
initiativeRepository,
|
||||||
|
projectRepository,
|
||||||
|
phaseDispatchManager,
|
||||||
|
dispatchManager,
|
||||||
|
conflictResolutionService,
|
||||||
|
eventBus,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrchestrator(mocks: ReturnType<typeof createMocks>) {
|
||||||
|
const orchestrator = new ExecutionOrchestrator(
|
||||||
|
mocks.branchManager,
|
||||||
|
mocks.phaseRepository,
|
||||||
|
mocks.taskRepository,
|
||||||
|
mocks.initiativeRepository,
|
||||||
|
mocks.projectRepository,
|
||||||
|
mocks.phaseDispatchManager,
|
||||||
|
mocks.dispatchManager,
|
||||||
|
mocks.conflictResolutionService,
|
||||||
|
mocks.eventBus,
|
||||||
|
'/tmp/test-workspace',
|
||||||
|
);
|
||||||
|
orchestrator.start();
|
||||||
|
return orchestrator;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitTaskCompleted(eventBus: ReturnType<typeof createMockEventBus>, taskId: string) {
|
||||||
|
const event: TaskCompletedEvent = {
|
||||||
|
type: 'task:completed',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: { taskId, agentId: 'agent-1', success: true, message: 'done' },
|
||||||
|
};
|
||||||
|
eventBus.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ExecutionOrchestrator', () => {
|
||||||
|
let mocks: ReturnType<typeof createMocks>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks = createMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phase completion when initiative has no branch', () => {
|
||||||
|
it('should transition phase to pending_review in review mode even without a branch', async () => {
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
phaseId: 'phase-1',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
category: 'execute',
|
||||||
|
status: 'completed',
|
||||||
|
};
|
||||||
|
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
|
||||||
|
const initiative = { id: 'init-1', branch: null, executionMode: 'review_per_phase' };
|
||||||
|
|
||||||
|
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
|
||||||
|
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
|
||||||
|
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||||
|
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
|
||||||
|
|
||||||
|
createOrchestrator(mocks);
|
||||||
|
|
||||||
|
emitTaskCompleted(mocks.eventBus, 'task-1');
|
||||||
|
|
||||||
|
// Allow async handler to complete
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete phase in yolo mode even without a branch', async () => {
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
phaseId: 'phase-1',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
category: 'execute',
|
||||||
|
status: 'completed',
|
||||||
|
};
|
||||||
|
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
|
||||||
|
const initiative = { id: 'init-1', branch: null, executionMode: 'yolo' };
|
||||||
|
|
||||||
|
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
|
||||||
|
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
|
||||||
|
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||||
|
vi.mocked(mocks.initiativeRepository.findByStatus).mockResolvedValue([]);
|
||||||
|
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
|
||||||
|
vi.mocked(mocks.phaseRepository.findByInitiativeId).mockResolvedValue([phase] as any);
|
||||||
|
|
||||||
|
createOrchestrator(mocks);
|
||||||
|
|
||||||
|
emitTaskCompleted(mocks.eventBus, 'task-1');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mocks.phaseDispatchManager.completePhase).toHaveBeenCalledWith('phase-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should NOT have attempted any branch merges
|
||||||
|
expect(mocks.branchManager.mergeBranch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phase completion when merge fails', () => {
|
||||||
|
it('should still check phase completion even if task merge throws', async () => {
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
phaseId: 'phase-1',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
category: 'execute',
|
||||||
|
status: 'completed',
|
||||||
|
};
|
||||||
|
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
|
||||||
|
const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' };
|
||||||
|
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
|
||||||
|
|
||||||
|
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
|
||||||
|
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
|
||||||
|
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||||
|
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
|
||||||
|
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
|
||||||
|
// Merge fails
|
||||||
|
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({
|
||||||
|
success: false,
|
||||||
|
message: 'conflict',
|
||||||
|
conflicts: ['file.ts'],
|
||||||
|
});
|
||||||
|
|
||||||
|
createOrchestrator(mocks);
|
||||||
|
|
||||||
|
emitTaskCompleted(mocks.eventBus, 'task-1');
|
||||||
|
|
||||||
|
// Phase should still transition despite merge failure
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('phase completion with branch (normal flow)', () => {
|
||||||
|
it('should merge task branch and transition phase when all tasks done', async () => {
|
||||||
|
const task = {
|
||||||
|
id: 'task-1',
|
||||||
|
phaseId: 'phase-1',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
category: 'execute',
|
||||||
|
status: 'completed',
|
||||||
|
};
|
||||||
|
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
|
||||||
|
const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' };
|
||||||
|
const project = { id: 'proj-1', name: 'test', url: 'https://example.com', defaultBranch: 'main' };
|
||||||
|
|
||||||
|
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task as any);
|
||||||
|
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
|
||||||
|
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||||
|
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task] as any);
|
||||||
|
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([project] as any);
|
||||||
|
vi.mocked(mocks.branchManager.branchExists).mockResolvedValue(true);
|
||||||
|
vi.mocked(mocks.branchManager.mergeBranch).mockResolvedValue({ success: true, message: 'ok' });
|
||||||
|
|
||||||
|
createOrchestrator(mocks);
|
||||||
|
|
||||||
|
emitTaskCompleted(mocks.eventBus, 'task-1');
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(mocks.phaseRepository.update).toHaveBeenCalledWith('phase-1', { status: 'pending_review' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not transition phase when some tasks are still pending', async () => {
|
||||||
|
const task1 = {
|
||||||
|
id: 'task-1',
|
||||||
|
phaseId: 'phase-1',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
category: 'execute',
|
||||||
|
status: 'completed',
|
||||||
|
};
|
||||||
|
const task2 = {
|
||||||
|
id: 'task-2',
|
||||||
|
phaseId: 'phase-1',
|
||||||
|
initiativeId: 'init-1',
|
||||||
|
category: 'execute',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
const phase = { id: 'phase-1', initiativeId: 'init-1', name: 'Phase 1', status: 'in_progress' };
|
||||||
|
const initiative = { id: 'init-1', branch: 'cw/test', executionMode: 'review_per_phase' };
|
||||||
|
|
||||||
|
vi.mocked(mocks.taskRepository.findById).mockResolvedValue(task1 as any);
|
||||||
|
vi.mocked(mocks.phaseRepository.findById).mockResolvedValue(phase as any);
|
||||||
|
vi.mocked(mocks.initiativeRepository.findById).mockResolvedValue(initiative as any);
|
||||||
|
vi.mocked(mocks.taskRepository.findByPhaseId).mockResolvedValue([task1, task2] as any);
|
||||||
|
vi.mocked(mocks.projectRepository.findProjectsByInitiativeId).mockResolvedValue([]);
|
||||||
|
|
||||||
|
createOrchestrator(mocks);
|
||||||
|
|
||||||
|
emitTaskCompleted(mocks.eventBus, 'task-1');
|
||||||
|
|
||||||
|
// Give the async handler time to run
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|
||||||
|
expect(mocks.phaseRepository.update).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.phaseDispatchManager.completePhase).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
* - Review per-phase: pause after each phase for diff review
|
* - Review per-phase: pause after each phase for diff review
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent } from '../events/index.js';
|
import type { EventBus, TaskCompletedEvent, PhasePendingReviewEvent, PhaseChangesRequestedEvent, PhaseMergedEvent, TaskMergedEvent, PhaseQueuedEvent, AgentStoppedEvent, InitiativePendingReviewEvent, InitiativeReviewApprovedEvent, InitiativeChangesRequestedEvent } from '../events/index.js';
|
||||||
import type { BranchManager } from '../git/branch-manager.js';
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||||
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
import type { TaskRepository } from '../db/repositories/task-repository.js';
|
||||||
@@ -66,6 +66,11 @@ export class ExecutionOrchestrator {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Recover in-memory dispatch queues from DB state (survives server restarts)
|
||||||
|
this.recoverDispatchQueues().catch((err) => {
|
||||||
|
log.error({ err: err instanceof Error ? err.message : String(err) }, 'dispatch queue recovery failed');
|
||||||
|
});
|
||||||
|
|
||||||
log.info('execution orchestrator started');
|
log.info('execution orchestrator started');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,27 +145,29 @@ export class ExecutionOrchestrator {
|
|||||||
if (!task?.phaseId || !task.initiativeId) return;
|
if (!task?.phaseId || !task.initiativeId) return;
|
||||||
|
|
||||||
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
const initiative = await this.initiativeRepository.findById(task.initiativeId);
|
||||||
if (!initiative?.branch) return;
|
|
||||||
|
|
||||||
const phase = await this.phaseRepository.findById(task.phaseId);
|
const phase = await this.phaseRepository.findById(task.phaseId);
|
||||||
if (!phase) return;
|
if (!phase) return;
|
||||||
|
|
||||||
// Skip merge/review tasks — they already work on the phase branch directly
|
// Merge task branch into phase branch (only when branches exist)
|
||||||
if (task.category === 'merge' || task.category === 'review') return;
|
if (initiative?.branch && task.category !== 'merge' && task.category !== 'review') {
|
||||||
|
try {
|
||||||
|
const initBranch = initiative.branch;
|
||||||
|
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||||
|
const tBranch = taskBranchName(initBranch, task.id);
|
||||||
|
|
||||||
const initBranch = initiative.branch;
|
// Serialize merges per phase
|
||||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve();
|
||||||
const tBranch = taskBranchName(initBranch, task.id);
|
const mergeOp = lock.then(async () => {
|
||||||
|
await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch);
|
||||||
|
});
|
||||||
|
this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {}));
|
||||||
|
await mergeOp;
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ taskId, err: err instanceof Error ? err.message : String(err) }, 'task merge failed, still checking phase completion');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Serialize merges per phase
|
// Check if all phase tasks are done — always, regardless of branch/merge status
|
||||||
const lock = this.phaseMergeLocks.get(task.phaseId) ?? Promise.resolve();
|
|
||||||
const mergeOp = lock.then(async () => {
|
|
||||||
await this.mergeTaskIntoPhase(taskId, task.phaseId!, tBranch, phBranch);
|
|
||||||
});
|
|
||||||
this.phaseMergeLocks.set(task.phaseId, mergeOp.catch(() => {}));
|
|
||||||
await mergeOp;
|
|
||||||
|
|
||||||
// Check if all phase tasks are done
|
|
||||||
const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId);
|
const phaseTasks = await this.taskRepository.findByPhaseId(task.phaseId);
|
||||||
const allDone = phaseTasks.every((t) => t.status === 'completed');
|
const allDone = phaseTasks.every((t) => t.status === 'completed');
|
||||||
if (allDone) {
|
if (allDone) {
|
||||||
@@ -228,12 +235,18 @@ export class ExecutionOrchestrator {
|
|||||||
if (!phase) return;
|
if (!phase) return;
|
||||||
|
|
||||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||||
if (!initiative?.branch) return;
|
if (!initiative) return;
|
||||||
|
|
||||||
if (initiative.executionMode === 'yolo') {
|
if (initiative.executionMode === 'yolo') {
|
||||||
await this.mergePhaseIntoInitiative(phaseId);
|
// Merge phase branch into initiative branch (only when branches exist)
|
||||||
|
if (initiative.branch) {
|
||||||
|
await this.mergePhaseIntoInitiative(phaseId);
|
||||||
|
}
|
||||||
await this.phaseDispatchManager.completePhase(phaseId);
|
await this.phaseDispatchManager.completePhase(phaseId);
|
||||||
|
|
||||||
|
// Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue)
|
||||||
|
await this.requeueApprovedPhases(phase.initiativeId);
|
||||||
|
|
||||||
// Check if this was the last phase — if so, trigger initiative review
|
// Check if this was the last phase — if so, trigger initiative review
|
||||||
const dispatched = await this.phaseDispatchManager.dispatchNextPhase();
|
const dispatched = await this.phaseDispatchManager.dispatchNextPhase();
|
||||||
if (!dispatched.success) {
|
if (!dispatched.success) {
|
||||||
@@ -270,6 +283,18 @@ export class ExecutionOrchestrator {
|
|||||||
|
|
||||||
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
||||||
|
|
||||||
|
// Store merge base before merging so we can reconstruct diffs for completed phases
|
||||||
|
for (const project of projects) {
|
||||||
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
|
try {
|
||||||
|
const mergeBase = await this.branchManager.getMergeBase(clonePath, initBranch, phBranch);
|
||||||
|
await this.phaseRepository.update(phaseId, { mergeBase });
|
||||||
|
break; // Only need one merge base (first project)
|
||||||
|
} catch {
|
||||||
|
// Phase branch may not exist in this project clone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch);
|
const result = await this.branchManager.mergeBranch(clonePath, phBranch, initBranch);
|
||||||
@@ -305,6 +330,9 @@ export class ExecutionOrchestrator {
|
|||||||
await this.mergePhaseIntoInitiative(phaseId);
|
await this.mergePhaseIntoInitiative(phaseId);
|
||||||
await this.phaseDispatchManager.completePhase(phaseId);
|
await this.phaseDispatchManager.completePhase(phaseId);
|
||||||
|
|
||||||
|
// Re-queue approved phases (self-healing: survives server restarts that wipe in-memory queue)
|
||||||
|
await this.requeueApprovedPhases(phase.initiativeId);
|
||||||
|
|
||||||
// Check if this was the last phase — if so, trigger initiative review
|
// Check if this was the last phase — if so, trigger initiative review
|
||||||
const dispatched = await this.phaseDispatchManager.dispatchNextPhase();
|
const dispatched = await this.phaseDispatchManager.dispatchNextPhase();
|
||||||
if (!dispatched.success) {
|
if (!dispatched.success) {
|
||||||
@@ -321,7 +349,14 @@ export class ExecutionOrchestrator {
|
|||||||
*/
|
*/
|
||||||
async requestChangesOnPhase(
|
async requestChangesOnPhase(
|
||||||
phaseId: string,
|
phaseId: string,
|
||||||
unresolvedComments: Array<{ filePath: string; lineNumber: number; body: string }>,
|
unresolvedThreads: Array<{
|
||||||
|
id: string;
|
||||||
|
filePath: string;
|
||||||
|
lineNumber: number;
|
||||||
|
body: string;
|
||||||
|
author: string;
|
||||||
|
replies: Array<{ id: string; body: string; author: string }>;
|
||||||
|
}>,
|
||||||
summary?: string,
|
summary?: string,
|
||||||
): Promise<{ taskId: string }> {
|
): Promise<{ taskId: string }> {
|
||||||
const phase = await this.phaseRepository.findById(phaseId);
|
const phase = await this.phaseRepository.findById(phaseId);
|
||||||
@@ -333,16 +368,25 @@ export class ExecutionOrchestrator {
|
|||||||
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
const initiative = await this.initiativeRepository.findById(phase.initiativeId);
|
||||||
if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`);
|
if (!initiative) throw new Error(`Initiative not found: ${phase.initiativeId}`);
|
||||||
|
|
||||||
// Build revision task description from comments + summary
|
// Guard: don't create duplicate review tasks
|
||||||
|
const existingTasks = await this.taskRepository.findByPhaseId(phaseId);
|
||||||
|
const activeReview = existingTasks.find(
|
||||||
|
(t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'),
|
||||||
|
);
|
||||||
|
if (activeReview) {
|
||||||
|
return { taskId: activeReview.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build revision task description from threaded comments + summary
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
if (summary) {
|
if (summary) {
|
||||||
lines.push(`## Summary\n\n${summary}\n`);
|
lines.push(`## Summary\n\n${summary}\n`);
|
||||||
}
|
}
|
||||||
if (unresolvedComments.length > 0) {
|
if (unresolvedThreads.length > 0) {
|
||||||
lines.push('## Review Comments\n');
|
lines.push('## Review Comments\n');
|
||||||
// Group comments by file
|
// Group comments by file
|
||||||
const byFile = new Map<string, typeof unresolvedComments>();
|
const byFile = new Map<string, typeof unresolvedThreads>();
|
||||||
for (const c of unresolvedComments) {
|
for (const c of unresolvedThreads) {
|
||||||
const arr = byFile.get(c.filePath) ?? [];
|
const arr = byFile.get(c.filePath) ?? [];
|
||||||
arr.push(c);
|
arr.push(c);
|
||||||
byFile.set(c.filePath, arr);
|
byFile.set(c.filePath, arr);
|
||||||
@@ -350,9 +394,13 @@ export class ExecutionOrchestrator {
|
|||||||
for (const [filePath, fileComments] of byFile) {
|
for (const [filePath, fileComments] of byFile) {
|
||||||
lines.push(`### ${filePath}\n`);
|
lines.push(`### ${filePath}\n`);
|
||||||
for (const c of fileComments) {
|
for (const c of fileComments) {
|
||||||
lines.push(`- **Line ${c.lineNumber}**: ${c.body}`);
|
lines.push(`#### Line ${c.lineNumber} [comment:${c.id}]`);
|
||||||
|
lines.push(`**${c.author}**: ${c.body}`);
|
||||||
|
for (const r of c.replies) {
|
||||||
|
lines.push(`> **${r.author}**: ${r.body}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
}
|
}
|
||||||
lines.push('');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,12 +430,12 @@ export class ExecutionOrchestrator {
|
|||||||
phaseId,
|
phaseId,
|
||||||
initiativeId: phase.initiativeId,
|
initiativeId: phase.initiativeId,
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
commentCount: unresolvedComments.length,
|
commentCount: unresolvedThreads.length,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
this.eventBus.emit(event);
|
this.eventBus.emit(event);
|
||||||
|
|
||||||
log.info({ phaseId, taskId: task.id, commentCount: unresolvedComments.length }, 'changes requested on phase');
|
log.info({ phaseId, taskId: task.id, commentCount: unresolvedThreads.length }, 'changes requested on phase');
|
||||||
|
|
||||||
// Kick off dispatch
|
// Kick off dispatch
|
||||||
this.scheduleDispatch();
|
this.scheduleDispatch();
|
||||||
@@ -395,6 +443,146 @@ export class ExecutionOrchestrator {
|
|||||||
return { taskId: task.id };
|
return { taskId: task.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request changes on an initiative that's pending review.
|
||||||
|
* Creates/reuses a "Finalization" phase and adds a review task to it.
|
||||||
|
*/
|
||||||
|
async requestChangesOnInitiative(
|
||||||
|
initiativeId: string,
|
||||||
|
summary: string,
|
||||||
|
): Promise<{ taskId: string }> {
|
||||||
|
const initiative = await this.initiativeRepository.findById(initiativeId);
|
||||||
|
if (!initiative) throw new Error(`Initiative not found: ${initiativeId}`);
|
||||||
|
if (initiative.status !== 'pending_review') {
|
||||||
|
throw new Error(`Initiative ${initiativeId} is not pending review (status: ${initiative.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find or create a "Finalization" phase
|
||||||
|
const phases = await this.phaseRepository.findByInitiativeId(initiativeId);
|
||||||
|
let finalizationPhase = phases.find((p) => p.name === 'Finalization');
|
||||||
|
|
||||||
|
if (!finalizationPhase) {
|
||||||
|
finalizationPhase = await this.phaseRepository.create({
|
||||||
|
initiativeId,
|
||||||
|
name: 'Finalization',
|
||||||
|
status: 'in_progress',
|
||||||
|
});
|
||||||
|
} else if (finalizationPhase.status === 'completed' || finalizationPhase.status === 'pending_review') {
|
||||||
|
await this.phaseRepository.update(finalizationPhase.id, { status: 'in_progress' as any });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: don't create duplicate review tasks
|
||||||
|
const existingTasks = await this.taskRepository.findByPhaseId(finalizationPhase.id);
|
||||||
|
const activeReview = existingTasks.find(
|
||||||
|
(t) => t.category === 'review' && (t.status === 'pending' || t.status === 'in_progress'),
|
||||||
|
);
|
||||||
|
if (activeReview) {
|
||||||
|
// Still reset initiative to active
|
||||||
|
await this.initiativeRepository.update(initiativeId, { status: 'active' as any });
|
||||||
|
this.scheduleDispatch();
|
||||||
|
return { taskId: activeReview.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create review task
|
||||||
|
const task = await this.taskRepository.create({
|
||||||
|
phaseId: finalizationPhase.id,
|
||||||
|
initiativeId,
|
||||||
|
name: `Address initiative review feedback`,
|
||||||
|
description: `## Summary\n\n${summary}`,
|
||||||
|
category: 'review',
|
||||||
|
priority: 'high',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset initiative status to active
|
||||||
|
await this.initiativeRepository.update(initiativeId, { status: 'active' as any });
|
||||||
|
|
||||||
|
// Queue task for dispatch
|
||||||
|
await this.dispatchManager.queue(task.id);
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
const event: InitiativeChangesRequestedEvent = {
|
||||||
|
type: 'initiative:changes_requested',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: {
|
||||||
|
initiativeId,
|
||||||
|
phaseId: finalizationPhase.id,
|
||||||
|
taskId: task.id,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.eventBus.emit(event);
|
||||||
|
|
||||||
|
log.info({ initiativeId, phaseId: finalizationPhase.id, taskId: task.id }, 'changes requested on initiative');
|
||||||
|
|
||||||
|
this.scheduleDispatch();
|
||||||
|
|
||||||
|
return { taskId: task.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-queue approved phases for an initiative into the in-memory dispatch queue.
|
||||||
|
* Self-healing: ensures phases aren't lost if the server restarted since the
|
||||||
|
* initial queueAllPhases() call.
|
||||||
|
*/
|
||||||
|
private async requeueApprovedPhases(initiativeId: string): Promise<void> {
|
||||||
|
const phases = await this.phaseRepository.findByInitiativeId(initiativeId);
|
||||||
|
for (const p of phases) {
|
||||||
|
if (p.status === 'approved') {
|
||||||
|
try {
|
||||||
|
await this.phaseDispatchManager.queuePhase(p.id);
|
||||||
|
log.info({ phaseId: p.id }, 're-queued approved phase');
|
||||||
|
} catch {
|
||||||
|
// Already queued or status changed — safe to ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover in-memory dispatch queues from DB state on server startup.
|
||||||
|
* Re-queues approved phases and pending tasks for in_progress phases.
|
||||||
|
*/
|
||||||
|
private async recoverDispatchQueues(): Promise<void> {
|
||||||
|
const initiatives = await this.initiativeRepository.findByStatus('active');
|
||||||
|
let phasesRecovered = 0;
|
||||||
|
let tasksRecovered = 0;
|
||||||
|
|
||||||
|
for (const initiative of initiatives) {
|
||||||
|
const phases = await this.phaseRepository.findByInitiativeId(initiative.id);
|
||||||
|
|
||||||
|
for (const phase of phases) {
|
||||||
|
// Re-queue approved phases into the phase dispatch queue
|
||||||
|
if (phase.status === 'approved') {
|
||||||
|
try {
|
||||||
|
await this.phaseDispatchManager.queuePhase(phase.id);
|
||||||
|
phasesRecovered++;
|
||||||
|
} catch {
|
||||||
|
// Already queued or status changed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-queue pending tasks for in_progress phases into the task dispatch queue
|
||||||
|
if (phase.status === 'in_progress') {
|
||||||
|
const tasks = await this.taskRepository.findByPhaseId(phase.id);
|
||||||
|
for (const task of tasks) {
|
||||||
|
if (task.status === 'pending') {
|
||||||
|
try {
|
||||||
|
await this.dispatchManager.queue(task.id);
|
||||||
|
tasksRecovered++;
|
||||||
|
} catch {
|
||||||
|
// Already queued or task issue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phasesRecovered > 0 || tasksRecovered > 0) {
|
||||||
|
log.info({ phasesRecovered, tasksRecovered }, 'recovered dispatch queues from DB state');
|
||||||
|
this.scheduleDispatch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if all phases for an initiative are completed.
|
* Check if all phases for an initiative are completed.
|
||||||
* If so, set initiative to pending_review and emit event.
|
* If so, set initiative to pending_review and emit event.
|
||||||
@@ -449,7 +637,16 @@ export class ExecutionOrchestrator {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch remote so local branches are up-to-date before merge/push
|
||||||
|
await this.branchManager.fetchRemote(clonePath);
|
||||||
|
|
||||||
if (strategy === 'merge_and_push') {
|
if (strategy === 'merge_and_push') {
|
||||||
|
// Fast-forward local defaultBranch to match origin before merging
|
||||||
|
try {
|
||||||
|
await this.branchManager.fastForwardBranch(clonePath, project.defaultBranch);
|
||||||
|
} catch (ffErr) {
|
||||||
|
log.warn({ project: project.name, err: (ffErr as Error).message }, 'fast-forward of default branch failed — attempting merge anyway');
|
||||||
|
}
|
||||||
const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch);
|
const result = await this.branchManager.mergeBranch(clonePath, initiative.branch, project.defaultBranch);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`);
|
throw new Error(`Failed to merge ${initiative.branch} into ${project.defaultBranch} for project ${project.name}: ${result.message}`);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* a worktree to be checked out.
|
* a worktree to be checked out.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { MergeResult, BranchCommit } from './types.js';
|
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
|
||||||
|
|
||||||
export interface BranchManager {
|
export interface BranchManager {
|
||||||
/**
|
/**
|
||||||
@@ -57,9 +57,35 @@ export interface BranchManager {
|
|||||||
*/
|
*/
|
||||||
diffCommit(repoPath: string, commitHash: string): Promise<string>;
|
diffCommit(repoPath: string, commitHash: string): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the merge base (common ancestor) of two branches.
|
||||||
|
* Returns the commit hash of the merge base.
|
||||||
|
*/
|
||||||
|
getMergeBase(repoPath: string, branch1: string, branch2: string): Promise<string>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Push a branch to a remote.
|
* Push a branch to a remote.
|
||||||
* Defaults to 'origin' if no remote specified.
|
* Defaults to 'origin' if no remote specified.
|
||||||
*/
|
*/
|
||||||
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
pushBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dry-run merge check — determines if sourceBranch can be cleanly merged
|
||||||
|
* into targetBranch without actually performing the merge.
|
||||||
|
* Uses `git merge-tree --write-tree` (git 2.38+).
|
||||||
|
*/
|
||||||
|
checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch all refs from a remote.
|
||||||
|
* Defaults to 'origin' if no remote specified.
|
||||||
|
*/
|
||||||
|
fetchRemote(repoPath: string, remote?: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast-forward a local branch to match its remote-tracking counterpart.
|
||||||
|
* No-op if already up to date. Throws if fast-forward is not possible
|
||||||
|
* (i.e. the branches have diverged).
|
||||||
|
*/
|
||||||
|
fastForwardBranch(repoPath: string, branch: string, remote?: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
export type { WorktreeManager } from './types.js';
|
export type { WorktreeManager } from './types.js';
|
||||||
|
|
||||||
// Domain types
|
// Domain types
|
||||||
export type { Worktree, WorktreeDiff, MergeResult } from './types.js';
|
export type { Worktree, WorktreeDiff, MergeResult, MergeabilityResult } from './types.js';
|
||||||
|
|
||||||
// Adapters
|
// Adapters
|
||||||
export { SimpleGitWorktreeManager } from './manager.js';
|
export { SimpleGitWorktreeManager } from './manager.js';
|
||||||
|
|||||||
@@ -61,16 +61,16 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
|||||||
const worktreePath = path.join(this.worktreesDir, id);
|
const worktreePath = path.join(this.worktreesDir, id);
|
||||||
log.info({ id, branch, baseBranch }, 'creating worktree');
|
log.info({ id, branch, baseBranch }, 'creating worktree');
|
||||||
|
|
||||||
// Create worktree with new branch
|
// Create worktree — reuse existing branch or create new one
|
||||||
// git worktree add -b <branch> <path> <base-branch>
|
const branchExists = await this.branchExists(branch);
|
||||||
await this.git.raw([
|
if (branchExists) {
|
||||||
'worktree',
|
// Branch exists from a previous run — reset it to baseBranch and check it out
|
||||||
'add',
|
await this.git.raw(['branch', '-f', branch, baseBranch]);
|
||||||
'-b',
|
await this.git.raw(['worktree', 'add', worktreePath, branch]);
|
||||||
branch,
|
} else {
|
||||||
worktreePath,
|
// git worktree add -b <branch> <path> <base-branch>
|
||||||
baseBranch,
|
await this.git.raw(['worktree', 'add', '-b', branch, worktreePath, baseBranch]);
|
||||||
]);
|
}
|
||||||
|
|
||||||
const worktree: Worktree = {
|
const worktree: Worktree = {
|
||||||
id,
|
id,
|
||||||
@@ -327,6 +327,18 @@ export class SimpleGitWorktreeManager implements WorktreeManager {
|
|||||||
return worktrees;
|
return worktrees;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a local branch exists in the repository.
|
||||||
|
*/
|
||||||
|
private async branchExists(branch: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.git.raw(['rev-parse', '--verify', `refs/heads/${branch}`]);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse the output of git diff --name-status.
|
* Parse the output of git diff --name-status.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { simpleGit } from 'simple-git';
|
import { simpleGit } from 'simple-git';
|
||||||
import type { BranchManager } from './branch-manager.js';
|
import type { BranchManager } from './branch-manager.js';
|
||||||
import type { MergeResult, BranchCommit } from './types.js';
|
import type { MergeResult, MergeabilityResult, BranchCommit } from './types.js';
|
||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
|
|
||||||
const log = createModuleLogger('branch-manager');
|
const log = createModuleLogger('branch-manager');
|
||||||
@@ -31,19 +31,27 @@ export class SimpleGitBranchManager implements BranchManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
|
async mergeBranch(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeResult> {
|
||||||
// Use an ephemeral worktree for merge safety
|
// Use an ephemeral worktree with a temp branch for merge safety.
|
||||||
|
// We can't check out targetBranch directly — it may already be checked out
|
||||||
|
// in the clone's main working tree or an agent worktree.
|
||||||
const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
|
const tmpPath = mkdtempSync(join(tmpdir(), 'cw-merge-'));
|
||||||
const repoGit = simpleGit(repoPath);
|
const repoGit = simpleGit(repoPath);
|
||||||
|
const tempBranch = `cw-merge-${Date.now()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create ephemeral worktree on target branch
|
// Create worktree with a temp branch starting at targetBranch's commit
|
||||||
await repoGit.raw(['worktree', 'add', tmpPath, targetBranch]);
|
await repoGit.raw(['worktree', 'add', '-b', tempBranch, tmpPath, targetBranch]);
|
||||||
|
|
||||||
const wtGit = simpleGit(tmpPath);
|
const wtGit = simpleGit(tmpPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await wtGit.merge([sourceBranch, '--no-edit']);
|
await wtGit.merge([sourceBranch, '--no-edit']);
|
||||||
|
|
||||||
|
// Update the real target branch ref to the merge result.
|
||||||
|
// update-ref bypasses the "branch is checked out" guard.
|
||||||
|
const mergeCommit = (await wtGit.revparse(['HEAD'])).trim();
|
||||||
|
await repoGit.raw(['update-ref', `refs/heads/${targetBranch}`, mergeCommit]);
|
||||||
|
|
||||||
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
|
log.info({ repoPath, sourceBranch, targetBranch }, 'merge completed cleanly');
|
||||||
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
|
return { success: true, message: `Merged ${sourceBranch} into ${targetBranch}` };
|
||||||
} catch (mergeErr) {
|
} catch (mergeErr) {
|
||||||
@@ -73,6 +81,10 @@ export class SimpleGitBranchManager implements BranchManager {
|
|||||||
try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
|
try { rmSync(tmpPath, { recursive: true, force: true }); } catch { /* ignore */ }
|
||||||
try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
|
try { await repoGit.raw(['worktree', 'prune']); } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
// Delete the temp branch
|
||||||
|
try {
|
||||||
|
await repoGit.raw(['branch', '-D', tempBranch]);
|
||||||
|
} catch { /* ignore — may already be cleaned up */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,9 +153,59 @@ export class SimpleGitBranchManager implements BranchManager {
|
|||||||
return git.diff([`${commitHash}~1`, commitHash]);
|
return git.diff([`${commitHash}~1`, commitHash]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getMergeBase(repoPath: string, branch1: string, branch2: string): Promise<string> {
|
||||||
|
const git = simpleGit(repoPath);
|
||||||
|
const result = await git.raw(['merge-base', branch1, branch2]);
|
||||||
|
return result.trim();
|
||||||
|
}
|
||||||
|
|
||||||
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
async pushBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||||
const git = simpleGit(repoPath);
|
const git = simpleGit(repoPath);
|
||||||
await git.push(remote, branch);
|
await git.push(remote, branch);
|
||||||
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
log.info({ repoPath, branch, remote }, 'branch pushed to remote');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async checkMergeability(repoPath: string, sourceBranch: string, targetBranch: string): Promise<MergeabilityResult> {
|
||||||
|
const git = simpleGit(repoPath);
|
||||||
|
|
||||||
|
// git merge-tree --write-tree outputs everything to stdout.
|
||||||
|
// simple-git's .raw() resolves with stdout even on exit code 1 (conflicts),
|
||||||
|
// so we parse the output text instead of relying on catch.
|
||||||
|
const output = await git.raw(['merge-tree', '--write-tree', targetBranch, sourceBranch]);
|
||||||
|
|
||||||
|
// Parse conflict file names from "CONFLICT (content): Merge conflict in <path>"
|
||||||
|
const conflictPattern = /CONFLICT \([^)]+\): (?:Merge conflict in|.* -> )(.+)/g;
|
||||||
|
const conflicts: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = conflictPattern.exec(output)) !== null) {
|
||||||
|
conflicts.push(match[1].trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
log.debug({ repoPath, sourceBranch, targetBranch, conflicts }, 'merge-tree check: conflicts');
|
||||||
|
return { mergeable: false, conflicts };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: check for any CONFLICT text we couldn't parse specifically
|
||||||
|
if (output.includes('CONFLICT')) {
|
||||||
|
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: unparsed conflicts');
|
||||||
|
return { mergeable: false, conflicts: ['(unable to parse conflict details)'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug({ repoPath, sourceBranch, targetBranch }, 'merge-tree check: clean');
|
||||||
|
return { mergeable: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchRemote(repoPath: string, remote = 'origin'): Promise<void> {
|
||||||
|
const git = simpleGit(repoPath);
|
||||||
|
await git.fetch(remote);
|
||||||
|
log.info({ repoPath, remote }, 'fetched remote');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fastForwardBranch(repoPath: string, branch: string, remote = 'origin'): Promise<void> {
|
||||||
|
const git = simpleGit(repoPath);
|
||||||
|
const remoteBranch = `${remote}/${branch}`;
|
||||||
|
await git.raw(['merge', '--ff-only', remoteBranch, branch]);
|
||||||
|
log.info({ repoPath, branch, remoteBranch }, 'fast-forwarded branch');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,19 @@ export interface MergeResult {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Mergeability Check
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a dry-run merge check.
|
||||||
|
* No side effects — only tells you whether the merge would succeed.
|
||||||
|
*/
|
||||||
|
export interface MergeabilityResult {
|
||||||
|
mergeable: boolean;
|
||||||
|
conflicts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Branch Commit Info
|
// Branch Commit Info
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -164,7 +164,8 @@ describe('generateGatewayCaddyfile', () => {
|
|||||||
|
|
||||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||||
expect(caddyfile).toContain('auto_https off');
|
expect(caddyfile).toContain('auto_https off');
|
||||||
expect(caddyfile).toContain('abc123.localhost:9100 {');
|
expect(caddyfile).toContain('abc123.localhost:80 {');
|
||||||
|
expect(caddyfile).toContain('handle /* {');
|
||||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
|
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-app:3000');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,13 +177,14 @@ describe('generateGatewayCaddyfile', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||||
|
expect(caddyfile).toContain('abc123.localhost:80 {');
|
||||||
expect(caddyfile).toContain('handle_path /api/*');
|
expect(caddyfile).toContain('handle_path /api/*');
|
||||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
|
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-backend:8080');
|
||||||
expect(caddyfile).toContain('handle {');
|
expect(caddyfile).toContain('handle /* {');
|
||||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000');
|
expect(caddyfile).toContain('reverse_proxy cw-preview-abc123-frontend:3000');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('generates multi-preview Caddyfile with separate subdomain blocks', () => {
|
it('generates separate subdomain blocks for each preview', () => {
|
||||||
const previews = new Map<string, GatewayRoute[]>();
|
const previews = new Map<string, GatewayRoute[]>();
|
||||||
previews.set('abc', [
|
previews.set('abc', [
|
||||||
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
|
{ containerName: 'cw-preview-abc-app', port: 3000, route: '/' },
|
||||||
@@ -192,8 +194,8 @@ describe('generateGatewayCaddyfile', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||||
expect(caddyfile).toContain('abc.localhost:9100 {');
|
expect(caddyfile).toContain('abc.localhost:80 {');
|
||||||
expect(caddyfile).toContain('xyz.localhost:9100 {');
|
expect(caddyfile).toContain('xyz.localhost:80 {');
|
||||||
expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000');
|
expect(caddyfile).toContain('reverse_proxy cw-preview-abc-app:3000');
|
||||||
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
|
expect(caddyfile).toContain('reverse_proxy cw-preview-xyz-app:5000');
|
||||||
});
|
});
|
||||||
@@ -209,10 +211,10 @@ describe('generateGatewayCaddyfile', () => {
|
|||||||
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
const caddyfile = generateGatewayCaddyfile(previews, 9100);
|
||||||
const apiAuthIdx = caddyfile.indexOf('/api/auth');
|
const apiAuthIdx = caddyfile.indexOf('/api/auth');
|
||||||
const apiIdx = caddyfile.indexOf('handle_path /api/*');
|
const apiIdx = caddyfile.indexOf('handle_path /api/*');
|
||||||
const handleIdx = caddyfile.indexOf('handle {');
|
const rootIdx = caddyfile.indexOf('handle /* {');
|
||||||
|
|
||||||
expect(apiAuthIdx).toBeLessThan(apiIdx);
|
expect(apiAuthIdx).toBeLessThan(apiIdx);
|
||||||
expect(apiIdx).toBeLessThan(handleIdx);
|
expect(apiIdx).toBeLessThan(rootIdx);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Gateway Manager
|
* Gateway Manager
|
||||||
*
|
*
|
||||||
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
|
* Manages a single shared Caddy reverse proxy (the "gateway") that routes
|
||||||
* subdomain requests to per-preview compose stacks on a shared Docker network.
|
* subdomain-based requests to per-preview compose stacks on a shared Docker network.
|
||||||
*
|
*
|
||||||
* Architecture:
|
* Architecture:
|
||||||
* .cw-previews/gateway/
|
* .cw-previews/gateway/
|
||||||
@@ -195,18 +195,20 @@ export class GatewayManager {
|
|||||||
/**
|
/**
|
||||||
* Generate a Caddyfile for the gateway from all active preview routes.
|
* Generate a Caddyfile for the gateway from all active preview routes.
|
||||||
*
|
*
|
||||||
* Each preview gets a subdomain block: `<previewId>.localhost:<port>`
|
* Uses subdomain-based routing: each preview gets its own `<previewId>.localhost:80` block.
|
||||||
|
* Chrome/Firefox resolve `*.localhost` to 127.0.0.1 natively — no DNS setup needed.
|
||||||
* Routes within a preview are sorted by specificity (longest path first).
|
* Routes within a preview are sorted by specificity (longest path first).
|
||||||
*/
|
*/
|
||||||
export function generateGatewayCaddyfile(
|
export function generateGatewayCaddyfile(
|
||||||
previews: Map<string, GatewayRoute[]>,
|
previews: Map<string, GatewayRoute[]>,
|
||||||
port: number,
|
_port: number,
|
||||||
): string {
|
): string {
|
||||||
|
// Caddy runs inside a container where Docker maps host:${port} → container:80.
|
||||||
|
// The Caddyfile must listen on the container-internal port (80), not the host port.
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
'{',
|
'{',
|
||||||
' auto_https off',
|
' auto_https off',
|
||||||
'}',
|
'}',
|
||||||
'',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [previewId, routes] of previews) {
|
for (const [previewId, routes] of previews) {
|
||||||
@@ -217,11 +219,12 @@ export function generateGatewayCaddyfile(
|
|||||||
return b.route.length - a.route.length;
|
return b.route.length - a.route.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
lines.push(`${previewId}.localhost:${port} {`);
|
lines.push('');
|
||||||
|
lines.push(`${previewId}.localhost:80 {`);
|
||||||
|
|
||||||
for (const route of sorted) {
|
for (const route of sorted) {
|
||||||
if (route.route === '/') {
|
if (route.route === '/') {
|
||||||
lines.push(` handle {`);
|
lines.push(` handle /* {`);
|
||||||
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
lines.push(` reverse_proxy ${route.containerName}:${route.port}`);
|
||||||
lines.push(` }`);
|
lines.push(` }`);
|
||||||
} else {
|
} else {
|
||||||
@@ -233,8 +236,9 @@ export function generateGatewayCaddyfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines.push('}');
|
lines.push('}');
|
||||||
lines.push('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Health Checker
|
* Health Checker
|
||||||
*
|
*
|
||||||
* Polls service healthcheck endpoints through the gateway's subdomain routing
|
* Polls service healthcheck endpoints through the gateway's subdomain-based routing
|
||||||
* to verify that preview services are ready.
|
* to verify that preview services are ready.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ vi.mock('node:fs/promises', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('nanoid', () => ({
|
vi.mock('nanoid', () => ({
|
||||||
nanoid: vi.fn(() => 'abc123test'),
|
customAlphabet: vi.fn(() => vi.fn(() => 'abc123test')),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { PreviewManager } from './manager.js';
|
import { PreviewManager } from './manager.js';
|
||||||
@@ -220,7 +220,7 @@ describe('PreviewManager', () => {
|
|||||||
expect(result.projectId).toBe('proj-1');
|
expect(result.projectId).toBe('proj-1');
|
||||||
expect(result.branch).toBe('feature-x');
|
expect(result.branch).toBe('feature-x');
|
||||||
expect(result.gatewayPort).toBe(9100);
|
expect(result.gatewayPort).toBe(9100);
|
||||||
expect(result.url).toBe('http://abc123test.localhost:9100');
|
expect(result.url).toBe('http://abc123test.localhost:9100/');
|
||||||
expect(result.mode).toBe('preview');
|
expect(result.mode).toBe('preview');
|
||||||
expect(result.status).toBe('running');
|
expect(result.status).toBe('running');
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ describe('PreviewManager', () => {
|
|||||||
expect(buildingEvent).toBeDefined();
|
expect(buildingEvent).toBeDefined();
|
||||||
expect(readyEvent).toBeDefined();
|
expect(readyEvent).toBeDefined();
|
||||||
expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
|
expect((readyEvent!.payload as Record<string, unknown>).url).toBe(
|
||||||
'http://abc123test.localhost:9100',
|
'http://abc123test.localhost:9100/',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -472,7 +472,7 @@ describe('PreviewManager', () => {
|
|||||||
expect(previews).toHaveLength(2);
|
expect(previews).toHaveLength(2);
|
||||||
expect(previews[0].id).toBe('aaa');
|
expect(previews[0].id).toBe('aaa');
|
||||||
expect(previews[0].gatewayPort).toBe(9100);
|
expect(previews[0].gatewayPort).toBe(9100);
|
||||||
expect(previews[0].url).toBe('http://aaa.localhost:9100');
|
expect(previews[0].url).toBe('http://aaa.localhost:9100/');
|
||||||
expect(previews[0].mode).toBe('preview');
|
expect(previews[0].mode).toBe('preview');
|
||||||
expect(previews[0].services).toHaveLength(1);
|
expect(previews[0].services).toHaveLength(1);
|
||||||
expect(previews[1].id).toBe('bbb');
|
expect(previews[1].id).toBe('bbb');
|
||||||
@@ -573,7 +573,7 @@ describe('PreviewManager', () => {
|
|||||||
expect(status!.status).toBe('running');
|
expect(status!.status).toBe('running');
|
||||||
expect(status!.id).toBe('abc');
|
expect(status!.id).toBe('abc');
|
||||||
expect(status!.gatewayPort).toBe(9100);
|
expect(status!.gatewayPort).toBe(9100);
|
||||||
expect(status!.url).toBe('http://abc.localhost:9100');
|
expect(status!.url).toBe('http://abc.localhost:9100/');
|
||||||
expect(status!.mode).toBe('preview');
|
expect(status!.mode).toBe('preview');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
import { mkdir, writeFile, rm } from 'node:fs/promises';
|
||||||
import { nanoid } from 'nanoid';
|
import { customAlphabet } from 'nanoid';
|
||||||
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||||
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
import type { PhaseRepository } from '../db/repositories/phase-repository.js';
|
||||||
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
import type { InitiativeRepository } from '../db/repositories/initiative-repository.js';
|
||||||
@@ -116,7 +116,8 @@ export class PreviewManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Generate ID and prepare deploy dir
|
// 4. Generate ID and prepare deploy dir
|
||||||
const id = nanoid(10);
|
const previewNanoid = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 10);
|
||||||
|
const id = previewNanoid();
|
||||||
const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`;
|
const projectName = `${COMPOSE_PROJECT_PREFIX}${id}`;
|
||||||
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
|
const deployDir = join(this.workspaceRoot, PREVIEWS_DIR, id);
|
||||||
await mkdir(deployDir, { recursive: true });
|
await mkdir(deployDir, { recursive: true });
|
||||||
@@ -238,7 +239,7 @@ export class PreviewManager {
|
|||||||
await this.runSeeds(projectName, config);
|
await this.runSeeds(projectName, config);
|
||||||
|
|
||||||
// 11. Success
|
// 11. Success
|
||||||
const url = `http://${id}.localhost:${gatewayPort}`;
|
const url = `http://${id}.localhost:${gatewayPort}/`;
|
||||||
log.info({ id, url }, 'preview deployment ready');
|
log.info({ id, url }, 'preview deployment ready');
|
||||||
|
|
||||||
this.eventBus.emit<PreviewReadyEvent>({
|
this.eventBus.emit<PreviewReadyEvent>({
|
||||||
@@ -604,7 +605,7 @@ export class PreviewManager {
|
|||||||
projectId,
|
projectId,
|
||||||
branch,
|
branch,
|
||||||
gatewayPort,
|
gatewayPort,
|
||||||
url: `http://${previewId}.localhost:${gatewayPort}`,
|
url: `http://${previewId}.localhost:${gatewayPort}/`,
|
||||||
mode,
|
mode,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
services: [],
|
services: [],
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ describe('Detail Workflow E2E', () => {
|
|||||||
harness.setArchitectDetailComplete('detailer', [
|
harness.setArchitectDetailComplete('detailer', [
|
||||||
{ number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] },
|
{ number: 1, name: 'Task 1', content: 'First task', type: 'auto', dependencies: [] },
|
||||||
{ number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] },
|
{ number: 2, name: 'Task 2', content: 'Second task', type: 'auto', dependencies: [1] },
|
||||||
{ number: 3, name: 'Verify', content: 'Verify all', type: 'checkpoint:human-verify', dependencies: [2] },
|
{ number: 3, name: 'Verify', content: 'Verify all', type: 'auto', dependencies: [2] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Resume with all answers
|
// Resume with all answers
|
||||||
@@ -261,7 +261,7 @@ describe('Detail Workflow E2E', () => {
|
|||||||
tasks: [
|
tasks: [
|
||||||
{ number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] },
|
{ number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] },
|
||||||
{ number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] },
|
{ number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] },
|
||||||
{ number: 3, name: 'Verify', description: 'Test flow', type: 'checkpoint:human-verify', dependencies: [2] },
|
{ number: 3, name: 'Verify', description: 'Test flow', type: 'auto', dependencies: [2] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -271,33 +271,31 @@ describe('Detail Workflow E2E', () => {
|
|||||||
expect(tasks[0].name).toBe('Schema');
|
expect(tasks[0].name).toBe('Schema');
|
||||||
expect(tasks[1].name).toBe('API');
|
expect(tasks[1].name).toBe('API');
|
||||||
expect(tasks[2].name).toBe('Verify');
|
expect(tasks[2].name).toBe('Verify');
|
||||||
expect(tasks[2].type).toBe('checkpoint:human-verify');
|
expect(tasks[2].type).toBe('auto');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle all task types', async () => {
|
it('should create tasks with auto type', async () => {
|
||||||
const initiative = await harness.createInitiative('Task Types Test');
|
const initiative = await harness.createInitiative('Task Types Test');
|
||||||
const phases = await harness.createPhasesFromPlan(initiative.id, [
|
const phases = await harness.createPhasesFromPlan(initiative.id, [
|
||||||
{ name: 'Phase 1' },
|
{ name: 'Phase 1' },
|
||||||
]);
|
]);
|
||||||
const detailTask = await harness.createDetailTask(phases[0].id, 'Mixed Tasks');
|
const detailTask = await harness.createDetailTask(phases[0].id, 'Mixed Tasks');
|
||||||
|
|
||||||
// Create tasks with all types
|
|
||||||
await harness.caller.createChildTasks({
|
await harness.caller.createChildTasks({
|
||||||
parentTaskId: detailTask.id,
|
parentTaskId: detailTask.id,
|
||||||
tasks: [
|
tasks: [
|
||||||
{ number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' },
|
{ number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' },
|
||||||
{ number: 2, name: 'Human Verify', description: 'Visual check', type: 'checkpoint:human-verify', dependencies: [1] },
|
{ number: 2, name: 'Second Task', description: 'More work', type: 'auto', dependencies: [1] },
|
||||||
{ number: 3, name: 'Decision', description: 'Choose approach', type: 'checkpoint:decision', dependencies: [2] },
|
{ number: 3, name: 'Third Task', description: 'Even more', type: 'auto', dependencies: [2] },
|
||||||
{ number: 4, name: 'Human Action', description: 'Manual step', type: 'checkpoint:human-action', dependencies: [3] },
|
{ number: 4, name: 'Final Task', description: 'Last step', type: 'auto', dependencies: [3] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = await harness.getChildTasks(detailTask.id);
|
const tasks = await harness.getChildTasks(detailTask.id);
|
||||||
expect(tasks).toHaveLength(4);
|
expect(tasks).toHaveLength(4);
|
||||||
expect(tasks[0].type).toBe('auto');
|
for (const task of tasks) {
|
||||||
expect(tasks[1].type).toBe('checkpoint:human-verify');
|
expect(task.type).toBe('auto');
|
||||||
expect(tasks[2].type).toBe('checkpoint:decision');
|
}
|
||||||
expect(tasks[3].type).toBe('checkpoint:human-action');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create task dependencies', async () => {
|
it('should create task dependencies', async () => {
|
||||||
@@ -346,7 +344,7 @@ describe('Detail Workflow E2E', () => {
|
|||||||
{ number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] },
|
{ number: 1, name: 'Create user schema', content: 'Define User model', type: 'auto', dependencies: [] },
|
||||||
{ number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] },
|
{ number: 2, name: 'Implement JWT', content: 'Token generation', type: 'auto', dependencies: [1] },
|
||||||
{ number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] },
|
{ number: 3, name: 'Protected routes', content: 'Middleware', type: 'auto', dependencies: [2] },
|
||||||
{ number: 4, name: 'Verify auth', content: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] },
|
{ number: 4, name: 'Verify auth', content: 'Test login flow', type: 'auto', dependencies: [3] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await harness.caller.spawnArchitectDetail({
|
await harness.caller.spawnArchitectDetail({
|
||||||
@@ -367,7 +365,7 @@ describe('Detail Workflow E2E', () => {
|
|||||||
{ number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] },
|
{ number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] },
|
||||||
{ number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] },
|
{ number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] },
|
||||||
{ number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] },
|
{ number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] },
|
||||||
{ number: 4, name: 'Verify auth', description: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] },
|
{ number: 4, name: 'Verify auth', description: 'Test login flow', type: 'auto', dependencies: [3] },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -375,7 +373,7 @@ describe('Detail Workflow E2E', () => {
|
|||||||
const tasks = await harness.getChildTasks(detailTask.id);
|
const tasks = await harness.getChildTasks(detailTask.id);
|
||||||
expect(tasks).toHaveLength(4);
|
expect(tasks).toHaveLength(4);
|
||||||
expect(tasks[0].name).toBe('Create user schema');
|
expect(tasks[0].name).toBe('Create user schema');
|
||||||
expect(tasks[3].type).toBe('checkpoint:human-verify');
|
expect(tasks[3].type).toBe('auto');
|
||||||
|
|
||||||
// Agent should be idle
|
// Agent should be idle
|
||||||
const finalAgent = await harness.caller.getAgent({ name: 'detailer' });
|
const finalAgent = await harness.caller.getAgent({ name: 'detailer' });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from './index.js';
|
} from './index.js';
|
||||||
import type { TRPCContext } from './context.js';
|
import type { TRPCContext } from './context.js';
|
||||||
import type { EventBus } from '../events/types.js';
|
import type { EventBus } from '../events/types.js';
|
||||||
|
import type { AccountRepository } from '../db/repositories/account-repository.js';
|
||||||
|
|
||||||
// Create caller factory for the app router
|
// Create caller factory for the app router
|
||||||
const createCaller = createCallerFactory(appRouter);
|
const createCaller = createCallerFactory(appRouter);
|
||||||
@@ -161,6 +162,79 @@ describe('tRPC Router', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addAccountByToken procedure', () => {
|
||||||
|
let mockRepo: AccountRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockRepo = {
|
||||||
|
findByEmail: vi.fn(),
|
||||||
|
updateAccountAuth: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
} as unknown as AccountRepository;
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a new account and returns { upserted: false, account }', async () => {
|
||||||
|
const stubAccount = { id: 'new-id', email: 'user@example.com', provider: 'claude' };
|
||||||
|
(mockRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
|
(mockRepo.create as ReturnType<typeof vi.fn>).mockResolvedValue(stubAccount);
|
||||||
|
|
||||||
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
||||||
|
const testCaller = createCaller(testCtx);
|
||||||
|
const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' });
|
||||||
|
|
||||||
|
expect(result).toEqual({ upserted: false, account: stubAccount });
|
||||||
|
expect(mockRepo.create).toHaveBeenCalledWith({
|
||||||
|
email: 'user@example.com',
|
||||||
|
provider: 'claude',
|
||||||
|
configJson: '{"hasCompletedOnboarding":true}',
|
||||||
|
credentials: '{"claudeAiOauth":{"accessToken":"my-token"}}',
|
||||||
|
});
|
||||||
|
expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing account and returns { upserted: true, account }', async () => {
|
||||||
|
const existingAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude' };
|
||||||
|
const updatedAccount = { id: 'existing-id', email: 'user@example.com', provider: 'claude', updated: true };
|
||||||
|
(mockRepo.findByEmail as ReturnType<typeof vi.fn>).mockResolvedValue(existingAccount);
|
||||||
|
(mockRepo.updateAccountAuth as ReturnType<typeof vi.fn>).mockResolvedValue(updatedAccount);
|
||||||
|
|
||||||
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
||||||
|
const testCaller = createCaller(testCtx);
|
||||||
|
const result = await testCaller.addAccountByToken({ email: 'user@example.com', token: 'my-token' });
|
||||||
|
|
||||||
|
expect(result).toEqual({ upserted: true, account: updatedAccount });
|
||||||
|
expect(mockRepo.updateAccountAuth).toHaveBeenCalledWith(
|
||||||
|
'existing-id',
|
||||||
|
'{"hasCompletedOnboarding":true}',
|
||||||
|
'{"claudeAiOauth":{"accessToken":"my-token"}}',
|
||||||
|
);
|
||||||
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when email is empty', async () => {
|
||||||
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
||||||
|
const testCaller = createCaller(testCtx);
|
||||||
|
|
||||||
|
await expect(testCaller.addAccountByToken({ email: '', provider: 'claude', token: 'tok' }))
|
||||||
|
.rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(mockRepo.findByEmail).not.toHaveBeenCalled();
|
||||||
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws BAD_REQUEST when token is empty', async () => {
|
||||||
|
const testCtx = createTestContext({ accountRepository: mockRepo });
|
||||||
|
const testCaller = createCaller(testCtx);
|
||||||
|
|
||||||
|
await expect(testCaller.addAccountByToken({ email: 'user@example.com', provider: 'claude', token: '' }))
|
||||||
|
.rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
||||||
|
expect(mockRepo.findByEmail).not.toHaveBeenCalled();
|
||||||
|
expect(mockRepo.create).not.toHaveBeenCalled();
|
||||||
|
expect(mockRepo.updateAccountAuth).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Zod schema validation', () => {
|
describe('Zod schema validation', () => {
|
||||||
it('healthResponseSchema should reject invalid status', () => {
|
it('healthResponseSchema should reject invalid status', () => {
|
||||||
const invalid = {
|
const invalid = {
|
||||||
|
|||||||
@@ -72,5 +72,29 @@ export function accountProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
.query(() => {
|
.query(() => {
|
||||||
return listProviderNames();
|
return listProviderNames();
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
addAccountByToken: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
email: z.string().min(1),
|
||||||
|
provider: z.string().default('claude'),
|
||||||
|
token: z.string().min(1),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const repo = requireAccountRepository(ctx);
|
||||||
|
const credentials = JSON.stringify({ claudeAiOauth: { accessToken: input.token } });
|
||||||
|
const configJson = JSON.stringify({ hasCompletedOnboarding: true });
|
||||||
|
const existing = await repo.findByEmail(input.email);
|
||||||
|
if (existing) {
|
||||||
|
const account = await repo.updateAccountAuth(existing.id, configJson, credentials);
|
||||||
|
return { upserted: true, account };
|
||||||
|
}
|
||||||
|
const account = await repo.create({
|
||||||
|
email: input.email,
|
||||||
|
provider: input.provider,
|
||||||
|
configJson,
|
||||||
|
credentials,
|
||||||
|
});
|
||||||
|
return { upserted: false, account };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,6 +184,27 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return candidates[0] ?? null;
|
return candidates[0] ?? null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getActiveConflictAgent: publicProcedure
|
||||||
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
|
||||||
|
const agentManager = requireAgentManager(ctx);
|
||||||
|
const allAgents = await agentManager.list();
|
||||||
|
const candidates = allAgents
|
||||||
|
.filter(
|
||||||
|
(a) =>
|
||||||
|
a.mode === 'execute' &&
|
||||||
|
a.initiativeId === input.initiativeId &&
|
||||||
|
a.name?.startsWith('conflict-') &&
|
||||||
|
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
||||||
|
!a.userDismissedAt,
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||||
|
);
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}),
|
||||||
|
|
||||||
getAgentOutput: publicProcedure
|
getAgentOutput: publicProcedure
|
||||||
.input(agentIdentifierSchema)
|
.input(agentIdentifierSchema)
|
||||||
.query(async ({ ctx, input }): Promise<string> => {
|
.query(async ({ ctx, input }): Promise<string> => {
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark reverted FIRST to avoid ghost state if entity deletion fails partway
|
||||||
|
await repo.markReverted(input.id);
|
||||||
|
|
||||||
// Apply reverts in reverse entry order
|
// Apply reverts in reverse entry order
|
||||||
const reversedEntries = [...cs.entries].reverse();
|
const reversedEntries = [...cs.entries].reverse();
|
||||||
for (const entry of reversedEntries) {
|
for (const entry of reversedEntries) {
|
||||||
@@ -159,8 +162,6 @@ export function changeSetProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await repo.markReverted(input.id);
|
|
||||||
|
|
||||||
ctx.eventBus.emit({
|
ctx.eventBus.emit({
|
||||||
type: 'changeset:reverted' as const,
|
type: 'changeset:reverted' as const,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
|
|||||||
@@ -35,5 +35,15 @@ export function dispatchProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
await dispatchManager.completeTask(input.taskId);
|
await dispatchManager.completeTask(input.taskId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
retryBlockedTask: publicProcedure
|
||||||
|
.input(z.object({ taskId: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const dispatchManager = requireDispatchManager(ctx);
|
||||||
|
await dispatchManager.retryBlockedTask(input.taskId);
|
||||||
|
// Kick dispatch loop to pick up the re-queued task
|
||||||
|
await dispatchManager.dispatchNext();
|
||||||
|
return { success: true };
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
|
import { requireAgentManager, requireInitiativeRepository, requireProjectRepository, requireTaskRepository, requireBranchManager, requireExecutionOrchestrator } from './_helpers.js';
|
||||||
import { deriveInitiativeActivity } from './initiative-activity.js';
|
import { deriveInitiativeActivity } from './initiative-activity.js';
|
||||||
import { buildRefinePrompt } from '../../agent/prompts/index.js';
|
import { buildRefinePrompt, buildConflictResolutionPrompt, buildConflictResolutionDescription } from '../../agent/prompts/index.js';
|
||||||
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
import type { PageForSerialization } from '../../agent/content-serializer.js';
|
||||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||||
|
|
||||||
@@ -335,5 +335,145 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
await orchestrator.approveInitiative(input.initiativeId, input.strategy);
|
await orchestrator.approveInitiative(input.initiativeId, input.strategy);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
requestInitiativeChanges: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
initiativeId: z.string().min(1),
|
||||||
|
summary: z.string().trim().min(1),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const orchestrator = requireExecutionOrchestrator(ctx);
|
||||||
|
const result = await orchestrator.requestChangesOnInitiative(
|
||||||
|
input.initiativeId,
|
||||||
|
input.summary,
|
||||||
|
);
|
||||||
|
return { success: true, taskId: result.taskId };
|
||||||
|
}),
|
||||||
|
|
||||||
|
checkInitiativeMergeability: publicProcedure
|
||||||
|
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||||
|
const projectRepo = requireProjectRepository(ctx);
|
||||||
|
const branchManager = requireBranchManager(ctx);
|
||||||
|
|
||||||
|
const initiative = await initiativeRepo.findById(input.initiativeId);
|
||||||
|
if (!initiative) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
|
||||||
|
}
|
||||||
|
if (!initiative.branch) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
|
||||||
|
const allConflicts: string[] = [];
|
||||||
|
let mergeable = true;
|
||||||
|
|
||||||
|
for (const project of projects) {
|
||||||
|
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||||
|
const result = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
|
||||||
|
if (!result.mergeable) {
|
||||||
|
mergeable = false;
|
||||||
|
if (result.conflicts) allConflicts.push(...result.conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergeable,
|
||||||
|
conflictFiles: allConflicts,
|
||||||
|
targetBranch: projects[0]?.defaultBranch ?? 'main',
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
spawnConflictResolutionAgent: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
initiativeId: z.string().min(1),
|
||||||
|
provider: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const agentManager = requireAgentManager(ctx);
|
||||||
|
const initiativeRepo = requireInitiativeRepository(ctx);
|
||||||
|
const projectRepo = requireProjectRepository(ctx);
|
||||||
|
const taskRepo = requireTaskRepository(ctx);
|
||||||
|
const branchManager = requireBranchManager(ctx);
|
||||||
|
|
||||||
|
const initiative = await initiativeRepo.findById(input.initiativeId);
|
||||||
|
if (!initiative) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Initiative '${input.initiativeId}' not found` });
|
||||||
|
}
|
||||||
|
if (!initiative.branch) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no branch configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await projectRepo.findProjectsByInitiativeId(input.initiativeId);
|
||||||
|
if (projects.length === 0) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Initiative has no linked projects' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-dismiss stale conflict agents
|
||||||
|
const allAgents = await agentManager.list();
|
||||||
|
const staleAgents = allAgents.filter(
|
||||||
|
(a) =>
|
||||||
|
a.mode === 'execute' &&
|
||||||
|
a.initiativeId === input.initiativeId &&
|
||||||
|
a.name?.startsWith('conflict-') &&
|
||||||
|
['crashed', 'idle'].includes(a.status) &&
|
||||||
|
!a.userDismissedAt,
|
||||||
|
);
|
||||||
|
for (const stale of staleAgents) {
|
||||||
|
await agentManager.dismiss(stale.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject if active conflict agent already running
|
||||||
|
const activeConflictAgents = allAgents.filter(
|
||||||
|
(a) =>
|
||||||
|
a.mode === 'execute' &&
|
||||||
|
a.initiativeId === input.initiativeId &&
|
||||||
|
a.name?.startsWith('conflict-') &&
|
||||||
|
['running', 'waiting_for_input'].includes(a.status),
|
||||||
|
);
|
||||||
|
if (activeConflictAgents.length > 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: 'A conflict resolution agent is already running for this initiative',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check mergeability to get current conflict list
|
||||||
|
const project = projects[0];
|
||||||
|
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||||
|
const mergeCheck = await branchManager.checkMergeability(clonePath, initiative.branch, project.defaultBranch);
|
||||||
|
if (mergeCheck.mergeable) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: 'No merge conflicts detected — merge is clean' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts = mergeCheck.conflicts ?? [];
|
||||||
|
const targetBranch = project.defaultBranch;
|
||||||
|
|
||||||
|
// Create task
|
||||||
|
const task = await taskRepo.create({
|
||||||
|
initiativeId: input.initiativeId,
|
||||||
|
name: `Resolve conflicts: ${initiative.name}`,
|
||||||
|
description: buildConflictResolutionDescription(initiative.branch, targetBranch, conflicts),
|
||||||
|
category: 'merge',
|
||||||
|
status: 'in_progress',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spawn agent on a unique temp branch based off the initiative branch.
|
||||||
|
// Using initiative.branch directly as branchName would cause SimpleGitWorktreeManager.create()
|
||||||
|
// to run `git branch -f <branch> <base>`, force-resetting the initiative branch.
|
||||||
|
const tempBranch = `${initiative.branch}-conflict-${Date.now()}`;
|
||||||
|
const prompt = buildConflictResolutionPrompt(initiative.branch, targetBranch, conflicts);
|
||||||
|
return agentManager.spawn({
|
||||||
|
name: `conflict-${Date.now()}`,
|
||||||
|
taskId: task.id,
|
||||||
|
prompt,
|
||||||
|
mode: 'execute',
|
||||||
|
provider: input.provider,
|
||||||
|
initiativeId: input.initiativeId,
|
||||||
|
baseBranch: initiative.branch,
|
||||||
|
branchName: tempBranch,
|
||||||
|
});
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
number: z.number().int().positive(),
|
number: z.number().int().positive(),
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).default('auto'),
|
type: z.enum(['auto']).default('auto'),
|
||||||
dependencies: z.array(z.number().int().positive()).optional(),
|
dependencies: z.array(z.number().int().positive()).optional(),
|
||||||
})),
|
})),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Phase } from '../../db/schema.js';
|
import type { Phase } from '../../db/schema.js';
|
||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository } from './_helpers.js';
|
import { requirePhaseRepository, requireTaskRepository, requireBranchManager, requireInitiativeRepository, requireProjectRepository, requireExecutionOrchestrator, requireReviewCommentRepository, requireChangeSetRepository } from './_helpers.js';
|
||||||
import { phaseBranchName } from '../../git/branch-naming.js';
|
import { phaseBranchName } from '../../git/branch-naming.js';
|
||||||
import { ensureProjectClone } from '../../git/project-clones.js';
|
import { ensureProjectClone } from '../../git/project-clones.js';
|
||||||
|
|
||||||
@@ -98,6 +98,29 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const repo = requirePhaseRepository(ctx);
|
const repo = requirePhaseRepository(ctx);
|
||||||
await repo.delete(input.id);
|
await repo.delete(input.id);
|
||||||
|
|
||||||
|
// Reconcile any applied changesets that created this phase.
|
||||||
|
// If all created phases in a changeset are now deleted, mark it reverted.
|
||||||
|
if (ctx.changeSetRepository) {
|
||||||
|
try {
|
||||||
|
const csRepo = requireChangeSetRepository(ctx);
|
||||||
|
const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('phase', input.id);
|
||||||
|
for (const cs of affectedChangeSets) {
|
||||||
|
const createdPhaseIds = cs.entries
|
||||||
|
.filter(e => e.entityType === 'phase' && e.action === 'create')
|
||||||
|
.map(e => e.entityId);
|
||||||
|
const survivingPhases = await Promise.all(
|
||||||
|
createdPhaseIds.map(id => repo.findById(id)),
|
||||||
|
);
|
||||||
|
if (survivingPhases.every(p => p === null)) {
|
||||||
|
await csRepo.markReverted(cs.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort reconciliation — don't fail the delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -196,8 +219,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
if (!phase) {
|
if (!phase) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
|
||||||
}
|
}
|
||||||
if (phase.status !== 'pending_review') {
|
if (phase.status !== 'pending_review' && phase.status !== 'completed') {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const initiative = await initiativeRepo.findById(phase.initiativeId);
|
const initiative = await initiativeRepo.findById(phase.initiativeId);
|
||||||
@@ -207,13 +230,15 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
|
|
||||||
const initBranch = initiative.branch;
|
const initBranch = initiative.branch;
|
||||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||||
|
// For completed phases, use stored merge base; for pending_review, use initiative branch
|
||||||
|
const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch;
|
||||||
|
|
||||||
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
|
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
|
||||||
let rawDiff = '';
|
let rawDiff = '';
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||||
const diff = await branchManager.diffBranches(clonePath, initBranch, phBranch);
|
const diff = await branchManager.diffBranches(clonePath, diffBase, phBranch);
|
||||||
if (diff) {
|
if (diff) {
|
||||||
rawDiff += diff + '\n';
|
rawDiff += diff + '\n';
|
||||||
}
|
}
|
||||||
@@ -247,8 +272,8 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
if (!phase) {
|
if (!phase) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Phase '${input.phaseId}' not found` });
|
||||||
}
|
}
|
||||||
if (phase.status !== 'pending_review') {
|
if (phase.status !== 'pending_review' && phase.status !== 'completed') {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not pending review (status: ${phase.status})` });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: `Phase is not reviewable (status: ${phase.status})` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const initiative = await initiativeRepo.findById(phase.initiativeId);
|
const initiative = await initiativeRepo.findById(phase.initiativeId);
|
||||||
@@ -258,13 +283,14 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
|
|
||||||
const initBranch = initiative.branch;
|
const initBranch = initiative.branch;
|
||||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||||
|
const diffBase = (phase.status === 'completed' && phase.mergeBase) ? phase.mergeBase : initBranch;
|
||||||
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
|
const projects = await projectRepo.findProjectsByInitiativeId(phase.initiativeId);
|
||||||
|
|
||||||
const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = [];
|
const allCommits: Array<{ hash: string; shortHash: string; message: string; author: string; date: string; filesChanged: number; insertions: number; deletions: number }> = [];
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
const clonePath = await ensureProjectClone(project, ctx.workspaceRoot!);
|
||||||
const commits = await branchManager.listCommits(clonePath, initBranch, phBranch);
|
const commits = await branchManager.listCommits(clonePath, diffBase, phBranch);
|
||||||
allCommits.push(...commits);
|
allCommits.push(...commits);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,6 +346,20 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return repo.create(input);
|
return repo.create(input);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updateReviewComment: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
body: z.string().trim().min(1),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const repo = requireReviewCommentRepository(ctx);
|
||||||
|
const comment = await repo.update(input.id, input.body);
|
||||||
|
if (!comment) {
|
||||||
|
throw new TRPCError({ code: 'NOT_FOUND', message: `Review comment '${input.id}' not found` });
|
||||||
|
}
|
||||||
|
return comment;
|
||||||
|
}),
|
||||||
|
|
||||||
resolveReviewComment: publicProcedure
|
resolveReviewComment: publicProcedure
|
||||||
.input(z.object({ id: z.string().min(1) }))
|
.input(z.object({ id: z.string().min(1) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -342,25 +382,54 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
return comment;
|
return comment;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
replyToReviewComment: publicProcedure
|
||||||
|
.input(z.object({
|
||||||
|
parentCommentId: z.string().min(1),
|
||||||
|
body: z.string().trim().min(1),
|
||||||
|
author: z.string().optional(),
|
||||||
|
}))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const repo = requireReviewCommentRepository(ctx);
|
||||||
|
return repo.createReply(input.parentCommentId, input.body, input.author);
|
||||||
|
}),
|
||||||
|
|
||||||
requestPhaseChanges: publicProcedure
|
requestPhaseChanges: publicProcedure
|
||||||
.input(z.object({
|
.input(z.object({
|
||||||
phaseId: z.string().min(1),
|
phaseId: z.string().min(1),
|
||||||
summary: z.string().optional(),
|
summary: z.string().trim().min(1).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const orchestrator = requireExecutionOrchestrator(ctx);
|
const orchestrator = requireExecutionOrchestrator(ctx);
|
||||||
const reviewCommentRepo = requireReviewCommentRepository(ctx);
|
const reviewCommentRepo = requireReviewCommentRepository(ctx);
|
||||||
|
|
||||||
const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId);
|
const allComments = await reviewCommentRepo.findByPhaseId(input.phaseId);
|
||||||
const unresolved = allComments
|
// Build threaded structure: unresolved root comments with their replies
|
||||||
.filter((c: { resolved: boolean }) => !c.resolved)
|
const rootComments = allComments.filter((c) => !c.parentCommentId);
|
||||||
.map((c: { filePath: string; lineNumber: number; body: string }) => ({
|
const repliesByParent = new Map<string, typeof allComments>();
|
||||||
|
for (const c of allComments) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = repliesByParent.get(c.parentCommentId) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
repliesByParent.set(c.parentCommentId, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolvedThreads = rootComments
|
||||||
|
.filter((c) => !c.resolved)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
filePath: c.filePath,
|
filePath: c.filePath,
|
||||||
lineNumber: c.lineNumber,
|
lineNumber: c.lineNumber,
|
||||||
body: c.body,
|
body: c.body,
|
||||||
|
author: c.author,
|
||||||
|
replies: (repliesByParent.get(c.id) ?? []).map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
body: r.body,
|
||||||
|
author: r.author,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (unresolved.length === 0 && !input.summary) {
|
if (unresolvedThreads.length === 0 && !input.summary) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
message: 'Add comments or a summary before requesting changes',
|
message: 'Add comments or a summary before requesting changes',
|
||||||
@@ -369,7 +438,7 @@ export function phaseProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
|
|
||||||
const result = await orchestrator.requestChangesOnPhase(
|
const result = await orchestrator.requestChangesOnPhase(
|
||||||
input.phaseId,
|
input.phaseId,
|
||||||
unresolved,
|
unresolvedThreads,
|
||||||
input.summary,
|
input.summary,
|
||||||
);
|
);
|
||||||
return { success: true, taskId: result.taskId };
|
return { success: true, taskId: result.taskId };
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
requireInitiativeRepository,
|
requireInitiativeRepository,
|
||||||
requirePhaseRepository,
|
requirePhaseRepository,
|
||||||
requireDispatchManager,
|
requireDispatchManager,
|
||||||
|
requireChangeSetRepository,
|
||||||
} from './_helpers.js';
|
} from './_helpers.js';
|
||||||
|
|
||||||
export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
||||||
@@ -49,6 +50,14 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
message: `Task '${input.id}' not found`,
|
message: `Task '${input.id}' not found`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Route through dispatchManager when completing — emits task:completed
|
||||||
|
// event so the orchestrator can check phase completion and merge branches
|
||||||
|
if (input.status === 'completed' && ctx.dispatchManager) {
|
||||||
|
await ctx.dispatchManager.completeTask(input.id);
|
||||||
|
return (await taskRepository.findById(input.id))!;
|
||||||
|
}
|
||||||
|
|
||||||
return taskRepository.update(input.id, { status: input.status });
|
return taskRepository.update(input.id, { status: input.status });
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -58,7 +67,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
|
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
|
||||||
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(),
|
type: z.enum(['auto']).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const taskRepository = requireTaskRepository(ctx);
|
const taskRepository = requireTaskRepository(ctx);
|
||||||
@@ -88,7 +97,7 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
|
category: z.enum(['execute', 'research', 'discuss', 'plan', 'detail', 'refine', 'verify', 'merge', 'review']).optional(),
|
||||||
type: z.enum(['auto', 'checkpoint:human-verify', 'checkpoint:decision', 'checkpoint:human-action']).optional(),
|
type: z.enum(['auto']).optional(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const taskRepository = requireTaskRepository(ctx);
|
const taskRepository = requireTaskRepository(ctx);
|
||||||
@@ -152,6 +161,29 @@ export function taskProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const taskRepository = requireTaskRepository(ctx);
|
const taskRepository = requireTaskRepository(ctx);
|
||||||
await taskRepository.delete(input.id);
|
await taskRepository.delete(input.id);
|
||||||
|
|
||||||
|
// Reconcile any applied changesets that created this task.
|
||||||
|
// If all created tasks in a changeset are now deleted, mark it reverted.
|
||||||
|
if (ctx.changeSetRepository) {
|
||||||
|
try {
|
||||||
|
const csRepo = requireChangeSetRepository(ctx);
|
||||||
|
const affectedChangeSets = await csRepo.findAppliedByCreatedEntity('task', input.id);
|
||||||
|
for (const cs of affectedChangeSets) {
|
||||||
|
const createdTaskIds = cs.entries
|
||||||
|
.filter(e => e.entityType === 'task' && e.action === 'create')
|
||||||
|
.map(e => e.entityId);
|
||||||
|
const survivingTasks = await Promise.all(
|
||||||
|
createdTaskIds.map(id => taskRepository.findById(id)),
|
||||||
|
);
|
||||||
|
if (survivingTasks.every(t => t === null)) {
|
||||||
|
await csRepo.markReverted(cs.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best-effort reconciliation — don't fail the delete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
|
|||||||
'agent:account_switched',
|
'agent:account_switched',
|
||||||
'agent:deleted',
|
'agent:deleted',
|
||||||
'agent:waiting',
|
'agent:waiting',
|
||||||
'agent:output',
|
|
||||||
'task:queued',
|
'task:queued',
|
||||||
'task:dispatched',
|
'task:dispatched',
|
||||||
'task:completed',
|
'task:completed',
|
||||||
@@ -84,7 +83,6 @@ export const AGENT_EVENT_TYPES: DomainEventType[] = [
|
|||||||
'agent:account_switched',
|
'agent:account_switched',
|
||||||
'agent:deleted',
|
'agent:deleted',
|
||||||
'agent:waiting',
|
'agent:waiting',
|
||||||
'agent:output',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface SerializedTask {
|
|||||||
parentTaskId: string | null;
|
parentTaskId: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
type: "auto" | "checkpoint:human-verify" | "checkpoint:decision" | "checkpoint:human-action";
|
type: "auto";
|
||||||
category: string;
|
category: string;
|
||||||
priority: "low" | "medium" | "high";
|
priority: "low" | "medium" | "high";
|
||||||
status: "pending" | "in_progress" | "completed" | "blocked";
|
status: "pending" | "in_progress" | "completed" | "blocked";
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export function PlanSection({
|
|||||||
(a) =>
|
(a) =>
|
||||||
a.mode === "plan" &&
|
a.mode === "plan" &&
|
||||||
a.initiativeId === initiativeId &&
|
a.initiativeId === initiativeId &&
|
||||||
|
!a.userDismissedAt &&
|
||||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||||
)
|
)
|
||||||
.sort(
|
.sort(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useMemo } from "react";
|
import { useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { motion, AnimatePresence } from "motion/react";
|
||||||
import { X, Trash2, MessageCircle } from "lucide-react";
|
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
|
||||||
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
|
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -20,6 +20,7 @@ interface TaskSlideOverProps {
|
|||||||
export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
||||||
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
|
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
|
||||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||||
|
const retryBlockedTaskMutation = trpc.retryBlockedTask.useMutation();
|
||||||
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
const deleteTaskMutation = trpc.deleteTask.useMutation();
|
||||||
const updateTaskMutation = trpc.updateTask.useMutation();
|
const updateTaskMutation = trpc.updateTask.useMutation();
|
||||||
|
|
||||||
@@ -229,17 +230,32 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
|
<div className="flex items-center gap-2 border-t border-border px-5 py-3">
|
||||||
<Button
|
{task.status === "blocked" ? (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
disabled={!canQueue}
|
size="sm"
|
||||||
onClick={() => {
|
className="gap-1.5"
|
||||||
queueTaskMutation.mutate({ taskId: task.id });
|
onClick={() => {
|
||||||
close();
|
retryBlockedTaskMutation.mutate({ taskId: task.id });
|
||||||
}}
|
close();
|
||||||
>
|
}}
|
||||||
Queue Task
|
>
|
||||||
</Button>
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!canQueue}
|
||||||
|
onClick={() => {
|
||||||
|
queueTaskMutation.mutate({ taskId: task.id });
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Queue Task
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -7,14 +7,15 @@ interface CommentFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
submitLabel?: string;
|
submitLabel?: string;
|
||||||
|
initialValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
|
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
|
||||||
function CommentForm(
|
function CommentForm(
|
||||||
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" },
|
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment", initialValue = "" },
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState(initialValue);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
|
|||||||
@@ -1,71 +1,214 @@
|
|||||||
import { Check, RotateCcw } from "lucide-react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Check, RotateCcw, Reply, Pencil } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CommentForm } from "./CommentForm";
|
||||||
import type { ReviewComment } from "./types";
|
import type { ReviewComment } from "./types";
|
||||||
|
|
||||||
interface CommentThreadProps {
|
interface CommentThreadProps {
|
||||||
comments: ReviewComment[];
|
comments: ReviewComment[];
|
||||||
onResolve: (commentId: string) => void;
|
onResolve: (commentId: string) => void;
|
||||||
onUnresolve: (commentId: string) => void;
|
onUnresolve: (commentId: string) => void;
|
||||||
|
onReply?: (parentCommentId: string, body: string) => void;
|
||||||
|
onEdit?: (commentId: string, body: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) {
|
export function CommentThread({ comments, onResolve, onUnresolve, onReply, onEdit }: CommentThreadProps) {
|
||||||
|
// Group: root comments (no parentCommentId) and their replies
|
||||||
|
const rootComments = comments.filter((c) => !c.parentCommentId);
|
||||||
|
const repliesByParent = new Map<string, ReviewComment[]>();
|
||||||
|
for (const c of comments) {
|
||||||
|
if (c.parentCommentId) {
|
||||||
|
const arr = repliesByParent.get(c.parentCommentId) ?? [];
|
||||||
|
arr.push(c);
|
||||||
|
repliesByParent.set(c.parentCommentId, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{comments.map((comment) => (
|
{rootComments.map((comment) => (
|
||||||
<div
|
<RootComment
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
className={`rounded border p-2.5 text-xs space-y-1.5 ${
|
comment={comment}
|
||||||
comment.resolved
|
replies={repliesByParent.get(comment.id) ?? []}
|
||||||
? "border-status-success-border bg-status-success-bg/50"
|
onResolve={onResolve}
|
||||||
: "border-border bg-card"
|
onUnresolve={onUnresolve}
|
||||||
}`}
|
onReply={onReply}
|
||||||
>
|
onEdit={onEdit}
|
||||||
<div className="flex items-center justify-between gap-2">
|
/>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="font-semibold text-foreground">{comment.author}</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{formatTime(comment.createdAt)}
|
|
||||||
</span>
|
|
||||||
{comment.resolved && (
|
|
||||||
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
|
||||||
<Check className="h-3 w-3" />
|
|
||||||
Resolved
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{comment.resolved ? (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-1.5 text-[10px]"
|
|
||||||
onClick={() => onUnresolve(comment.id)}
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3 w-3 mr-0.5" />
|
|
||||||
Reopen
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-1.5 text-[10px]"
|
|
||||||
onClick={() => onResolve(comment.id)}
|
|
||||||
>
|
|
||||||
<Check className="h-3 w-3 mr-0.5" />
|
|
||||||
Resolve
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
|
|
||||||
{comment.body}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RootComment({
|
||||||
|
comment,
|
||||||
|
replies,
|
||||||
|
onResolve,
|
||||||
|
onUnresolve,
|
||||||
|
onReply,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
comment: ReviewComment;
|
||||||
|
replies: ReviewComment[];
|
||||||
|
onResolve: (id: string) => void;
|
||||||
|
onUnresolve: (id: string) => void;
|
||||||
|
onReply?: (parentCommentId: string, body: string) => void;
|
||||||
|
onEdit?: (commentId: string, body: string) => void;
|
||||||
|
}) {
|
||||||
|
const [isReplying, setIsReplying] = useState(false);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const editRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReplying) replyRef.current?.focus();
|
||||||
|
}, [isReplying]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingId) editRef.current?.focus();
|
||||||
|
}, [editingId]);
|
||||||
|
|
||||||
|
const isEditingRoot = editingId === comment.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded border ${comment.resolved ? "border-status-success-border bg-status-success-bg/50" : "border-border bg-card"}`}>
|
||||||
|
{/* Root comment */}
|
||||||
|
<div className="p-2.5 text-xs space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-semibold text-foreground">{comment.author}</span>
|
||||||
|
<span className="text-muted-foreground">{formatTime(comment.createdAt)}</span>
|
||||||
|
{comment.resolved && (
|
||||||
|
<span className="flex items-center gap-0.5 text-status-success-fg text-[10px] font-medium">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Resolved
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
{onEdit && comment.author !== "agent" && !comment.resolved && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 text-[10px]"
|
||||||
|
onClick={() => setEditingId(isEditingRoot ? null : comment.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3 mr-0.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onReply && !comment.resolved && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-1.5 text-[10px]"
|
||||||
|
onClick={() => setIsReplying(!isReplying)}
|
||||||
|
>
|
||||||
|
<Reply className="h-3 w-3 mr-0.5" />
|
||||||
|
Reply
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{comment.resolved ? (
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onUnresolve(comment.id)}>
|
||||||
|
<RotateCcw className="h-3 w-3 mr-0.5" />
|
||||||
|
Reopen
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px]" onClick={() => onResolve(comment.id)}>
|
||||||
|
<Check className="h-3 w-3 mr-0.5" />
|
||||||
|
Resolve
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isEditingRoot ? (
|
||||||
|
<CommentForm
|
||||||
|
ref={editRef}
|
||||||
|
initialValue={comment.body}
|
||||||
|
onSubmit={(body) => {
|
||||||
|
onEdit!(comment.id, body);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
placeholder="Edit comment..."
|
||||||
|
submitLabel="Save"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{comment.body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
|
{replies.length > 0 && (
|
||||||
|
<div className="border-t border-border/50">
|
||||||
|
{replies.map((reply) => (
|
||||||
|
<div
|
||||||
|
key={reply.id}
|
||||||
|
className={`px-2.5 py-2 text-xs border-l-2 ml-3 space-y-1 ${
|
||||||
|
reply.author === "agent"
|
||||||
|
? "border-l-primary bg-primary/5"
|
||||||
|
: "border-l-muted-foreground/30"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`font-semibold ${reply.author === "agent" ? "text-primary" : "text-foreground"}`}>
|
||||||
|
{reply.author}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">{formatTime(reply.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
{onEdit && reply.author !== "agent" && !comment.resolved && editingId !== reply.id && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1 text-[10px]"
|
||||||
|
onClick={() => setEditingId(reply.id)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-2.5 w-2.5 mr-0.5" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingId === reply.id ? (
|
||||||
|
<CommentForm
|
||||||
|
ref={editRef}
|
||||||
|
initialValue={reply.body}
|
||||||
|
onSubmit={(body) => {
|
||||||
|
onEdit!(reply.id, body);
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setEditingId(null)}
|
||||||
|
placeholder="Edit reply..."
|
||||||
|
submitLabel="Save"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reply form */}
|
||||||
|
{isReplying && onReply && (
|
||||||
|
<div className="border-t border-border/50 p-2.5">
|
||||||
|
<CommentForm
|
||||||
|
ref={replyRef}
|
||||||
|
onSubmit={(body) => {
|
||||||
|
onReply(comment.id, body);
|
||||||
|
setIsReplying(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setIsReplying(false)}
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
submitLabel="Reply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||||
|
|||||||
182
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
182
apps/web/src/components/review/ConflictResolutionPanel.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Loader2, AlertCircle, GitMerge, CheckCircle2, ChevronDown, ChevronRight, Terminal } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { QuestionForm } from '@/components/QuestionForm';
|
||||||
|
import { useConflictAgent } from '@/hooks/useConflictAgent';
|
||||||
|
|
||||||
|
interface ConflictResolutionPanelProps {
|
||||||
|
initiativeId: string;
|
||||||
|
conflicts: string[];
|
||||||
|
onResolved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConflictResolutionPanel({ initiativeId, conflicts, onResolved }: ConflictResolutionPanelProps) {
|
||||||
|
const { state, agent, questions, spawn, resume, stop, dismiss } = useConflictAgent(initiativeId);
|
||||||
|
const [showManual, setShowManual] = useState(false);
|
||||||
|
|
||||||
|
if (state === 'none') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="h-4 w-4 text-status-error-fg mt-0.5 shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-1">
|
||||||
|
{conflicts.length} merge conflict{conflicts.length !== 1 ? 's' : ''} detected
|
||||||
|
</h3>
|
||||||
|
<ul className="text-xs text-muted-foreground font-mono space-y-0.5 mb-3">
|
||||||
|
{conflicts.map((file) => (
|
||||||
|
<li key={file}>{file}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => spawn.mutate({ initiativeId })}
|
||||||
|
disabled={spawn.isPending}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{spawn.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<GitMerge className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Resolve with Agent
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowManual(!showManual)}
|
||||||
|
className="h-7 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{showManual ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||||
|
Manual Resolution
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{spawn.error && (
|
||||||
|
<p className="mt-2 text-xs text-status-error-fg">{spawn.error.message}</p>
|
||||||
|
)}
|
||||||
|
{showManual && (
|
||||||
|
<div className="mt-3 rounded border border-border bg-card p-3">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
In your project clone, run:
|
||||||
|
</p>
|
||||||
|
<pre className="text-xs font-mono bg-terminal text-terminal-fg rounded p-2 overflow-x-auto">
|
||||||
|
{`git checkout <initiative-branch>
|
||||||
|
git merge <target-branch>
|
||||||
|
# Resolve conflicts in each file
|
||||||
|
git add <resolved-files>
|
||||||
|
git commit --no-edit`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'running') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-border bg-card px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||||
|
<span className="text-sm text-muted-foreground">Resolving merge conflicts...</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => stop.mutate()}
|
||||||
|
disabled={stop.isPending}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'waiting' && questions) {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Terminal className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">Agent needs input</h3>
|
||||||
|
</div>
|
||||||
|
<QuestionForm
|
||||||
|
questions={questions.questions}
|
||||||
|
onSubmit={(answers) => resume.mutate(answers)}
|
||||||
|
onCancel={() => {}}
|
||||||
|
onDismiss={() => stop.mutate()}
|
||||||
|
isSubmitting={resume.isPending}
|
||||||
|
isDismissing={stop.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'completed') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-status-success-border bg-status-success-bg/50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-3.5 w-3.5 text-status-success-fg" />
|
||||||
|
<span className="text-sm text-status-success-fg">Conflicts resolved</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
onResolved();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Re-check Mergeability
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === 'crashed') {
|
||||||
|
return (
|
||||||
|
<div className="mx-4 mt-3 rounded-lg border border-status-error-border bg-status-error-bg/50 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
|
||||||
|
<span className="text-sm text-status-error-fg">Conflict resolution agent crashed</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
}}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
spawn.mutate({ initiativeId });
|
||||||
|
}}
|
||||||
|
disabled={spawn.isPending}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ interface DiffViewerProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onResolveComment: (commentId: string) => void;
|
onResolveComment: (commentId: string) => void;
|
||||||
onUnresolveComment: (commentId: string) => void;
|
onUnresolveComment: (commentId: string) => void;
|
||||||
|
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||||
|
onEditComment?: (commentId: string, body: string) => void;
|
||||||
viewedFiles?: Set<string>;
|
viewedFiles?: Set<string>;
|
||||||
onToggleViewed?: (filePath: string) => void;
|
onToggleViewed?: (filePath: string) => void;
|
||||||
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
onRegisterRef?: (filePath: string, el: HTMLDivElement | null) => void;
|
||||||
@@ -23,6 +25,8 @@ export function DiffViewer({
|
|||||||
onAddComment,
|
onAddComment,
|
||||||
onResolveComment,
|
onResolveComment,
|
||||||
onUnresolveComment,
|
onUnresolveComment,
|
||||||
|
onReplyComment,
|
||||||
|
onEditComment,
|
||||||
viewedFiles,
|
viewedFiles,
|
||||||
onToggleViewed,
|
onToggleViewed,
|
||||||
onRegisterRef,
|
onRegisterRef,
|
||||||
@@ -37,6 +41,8 @@ export function DiffViewer({
|
|||||||
onAddComment={onAddComment}
|
onAddComment={onAddComment}
|
||||||
onResolveComment={onResolveComment}
|
onResolveComment={onResolveComment}
|
||||||
onUnresolveComment={onUnresolveComment}
|
onUnresolveComment={onUnresolveComment}
|
||||||
|
onReplyComment={onReplyComment}
|
||||||
|
onEditComment={onEditComment}
|
||||||
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
isViewed={viewedFiles?.has(file.newPath) ?? false}
|
||||||
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
onToggleViewed={() => onToggleViewed?.(file.newPath)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ interface FileCardProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onResolveComment: (commentId: string) => void;
|
onResolveComment: (commentId: string) => void;
|
||||||
onUnresolveComment: (commentId: string) => void;
|
onUnresolveComment: (commentId: string) => void;
|
||||||
|
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||||
|
onEditComment?: (commentId: string, body: string) => void;
|
||||||
isViewed?: boolean;
|
isViewed?: boolean;
|
||||||
onToggleViewed?: () => void;
|
onToggleViewed?: () => void;
|
||||||
}
|
}
|
||||||
@@ -62,6 +64,8 @@ export function FileCard({
|
|||||||
onAddComment,
|
onAddComment,
|
||||||
onResolveComment,
|
onResolveComment,
|
||||||
onUnresolveComment,
|
onUnresolveComment,
|
||||||
|
onReplyComment,
|
||||||
|
onEditComment,
|
||||||
isViewed = false,
|
isViewed = false,
|
||||||
onToggleViewed = () => {},
|
onToggleViewed = () => {},
|
||||||
}: FileCardProps) {
|
}: FileCardProps) {
|
||||||
@@ -77,10 +81,11 @@ export function FileCard({
|
|||||||
const tokenMap = useHighlightedFile(file.newPath, allLines);
|
const tokenMap = useHighlightedFile(file.newPath, allLines);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border overflow-hidden">
|
<div className="rounded-lg border border-border overflow-clip">
|
||||||
{/* File header — sticky so it stays visible when scrolling */}
|
{/* File header — sticky so it stays visible when scrolling */}
|
||||||
<button
|
<button
|
||||||
className={`sticky top-0 z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
|
className={`sticky z-10 flex w-full items-center gap-2 px-3 py-2 bg-muted hover:bg-muted/90 text-left text-sm font-mono transition-colors ${leftBorderClass[file.changeType]}`}
|
||||||
|
style={{ top: 'var(--review-header-h, 0px)' }}
|
||||||
onClick={() => setExpanded(!expanded)}
|
onClick={() => setExpanded(!expanded)}
|
||||||
>
|
>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
@@ -157,6 +162,8 @@ export function FileCard({
|
|||||||
onAddComment={onAddComment}
|
onAddComment={onAddComment}
|
||||||
onResolveComment={onResolveComment}
|
onResolveComment={onResolveComment}
|
||||||
onUnresolveComment={onUnresolveComment}
|
onUnresolveComment={onUnresolveComment}
|
||||||
|
onReplyComment={onReplyComment}
|
||||||
|
onEditComment={onEditComment}
|
||||||
tokenMap={tokenMap}
|
tokenMap={tokenMap}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface HunkRowsProps {
|
|||||||
) => void;
|
) => void;
|
||||||
onResolveComment: (commentId: string) => void;
|
onResolveComment: (commentId: string) => void;
|
||||||
onUnresolveComment: (commentId: string) => void;
|
onUnresolveComment: (commentId: string) => void;
|
||||||
|
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||||
|
onEditComment?: (commentId: string, body: string) => void;
|
||||||
tokenMap?: LineTokenMap | null;
|
tokenMap?: LineTokenMap | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ export function HunkRows({
|
|||||||
onAddComment,
|
onAddComment,
|
||||||
onResolveComment,
|
onResolveComment,
|
||||||
onUnresolveComment,
|
onUnresolveComment,
|
||||||
|
onReplyComment,
|
||||||
|
onEditComment,
|
||||||
tokenMap,
|
tokenMap,
|
||||||
}: HunkRowsProps) {
|
}: HunkRowsProps) {
|
||||||
const [commentingLine, setCommentingLine] = useState<{
|
const [commentingLine, setCommentingLine] = useState<{
|
||||||
@@ -98,6 +102,8 @@ export function HunkRows({
|
|||||||
onSubmitComment={handleSubmitComment}
|
onSubmitComment={handleSubmitComment}
|
||||||
onResolveComment={onResolveComment}
|
onResolveComment={onResolveComment}
|
||||||
onUnresolveComment={onUnresolveComment}
|
onUnresolveComment={onUnresolveComment}
|
||||||
|
onReplyComment={onReplyComment}
|
||||||
|
onEditComment={onEditComment}
|
||||||
tokens={
|
tokens={
|
||||||
line.newLineNumber !== null
|
line.newLineNumber !== null
|
||||||
? tokenMap?.get(line.newLineNumber) ?? undefined
|
? tokenMap?.get(line.newLineNumber) ?? undefined
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge } from "lucide-react";
|
import { Loader2, GitBranch, ArrowRight, FileCode, Plus, Minus, Upload, GitMerge, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
import { parseUnifiedDiff } from "./parse-diff";
|
import { parseUnifiedDiff } from "./parse-diff";
|
||||||
import { DiffViewer } from "./DiffViewer";
|
import { DiffViewer } from "./DiffViewer";
|
||||||
import { ReviewSidebar } from "./ReviewSidebar";
|
import { ReviewSidebar } from "./ReviewSidebar";
|
||||||
|
import { PreviewControls } from "./PreviewControls";
|
||||||
|
import { ConflictResolutionPanel } from "./ConflictResolutionPanel";
|
||||||
|
|
||||||
interface InitiativeReviewProps {
|
interface InitiativeReviewProps {
|
||||||
initiativeId: string;
|
initiativeId: string;
|
||||||
@@ -48,6 +51,61 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
{ enabled: !!selectedCommit },
|
{ enabled: !!selectedCommit },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mergeability check
|
||||||
|
const mergeabilityQuery = trpc.checkInitiativeMergeability.useQuery(
|
||||||
|
{ initiativeId },
|
||||||
|
{ refetchInterval: 30_000 },
|
||||||
|
);
|
||||||
|
const mergeability = mergeabilityQuery.data ?? null;
|
||||||
|
|
||||||
|
// Auto-refresh mergeability when a conflict agent completes
|
||||||
|
const conflictAgentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
|
||||||
|
const conflictAgentStatus = conflictAgentQuery.data?.status;
|
||||||
|
const prevConflictStatusRef = useRef(conflictAgentStatus);
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = prevConflictStatusRef.current;
|
||||||
|
prevConflictStatusRef.current = conflictAgentStatus;
|
||||||
|
// When agent transitions from running/waiting to idle (completed)
|
||||||
|
if (prev && ['running', 'waiting_for_input'].includes(prev) && conflictAgentStatus === 'idle') {
|
||||||
|
void mergeabilityQuery.refetch();
|
||||||
|
void diffQuery.refetch();
|
||||||
|
void commitsQuery.refetch();
|
||||||
|
}
|
||||||
|
}, [conflictAgentStatus, mergeabilityQuery, diffQuery, commitsQuery]);
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||||
|
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
|
||||||
|
|
||||||
|
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
||||||
|
const existingPreview = previewsQuery.data?.find(
|
||||||
|
(p) => p.initiativeId === initiativeId,
|
||||||
|
);
|
||||||
|
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||||
|
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||||
|
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
||||||
|
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
|
||||||
|
);
|
||||||
|
const preview = previewStatusQuery.data ?? existingPreview;
|
||||||
|
|
||||||
|
const startPreview = trpc.startPreview.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setActivePreviewId(data.id);
|
||||||
|
previewsQuery.refetch();
|
||||||
|
toast.success(`Preview running at ${data.url}`);
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopPreview = trpc.stopPreview.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
setActivePreviewId(null);
|
||||||
|
toast.success("Preview stopped");
|
||||||
|
previewsQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
const approveMutation = trpc.approveInitiativeReview.useMutation({
|
const approveMutation = trpc.approveInitiativeReview.useMutation({
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
const msg = variables.strategy === "merge_and_push"
|
const msg = variables.strategy === "merge_and_push"
|
||||||
@@ -87,6 +145,31 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
|
||||||
const targetBranch = diffQuery.data?.targetBranch ?? "";
|
const targetBranch = diffQuery.data?.targetBranch ?? "";
|
||||||
|
|
||||||
|
const previewState = firstProjectId && sourceBranch
|
||||||
|
? {
|
||||||
|
status: preview?.status === "running"
|
||||||
|
? ("running" as const)
|
||||||
|
: preview?.status === "failed"
|
||||||
|
? ("failed" as const)
|
||||||
|
: (startPreview.isPending || preview?.status === "building")
|
||||||
|
? ("building" as const)
|
||||||
|
: ("idle" as const),
|
||||||
|
url: preview?.url ?? undefined,
|
||||||
|
onStart: () =>
|
||||||
|
startPreview.mutate({
|
||||||
|
initiativeId,
|
||||||
|
projectId: firstProjectId,
|
||||||
|
branch: sourceBranch,
|
||||||
|
}),
|
||||||
|
onStop: () => {
|
||||||
|
const id = activePreviewId ?? existingPreview?.id;
|
||||||
|
if (id) stopPreview.mutate({ previewId: id });
|
||||||
|
},
|
||||||
|
isStarting: startPreview.isPending,
|
||||||
|
isStopping: stopPreview.isPending,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -125,10 +208,29 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
{totalDeletions}
|
{totalDeletions}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mergeability badge */}
|
||||||
|
{mergeabilityQuery.isLoading ? (
|
||||||
|
<Badge variant="secondary" className="text-[10px] h-5">
|
||||||
|
<Loader2 className="h-2.5 w-2.5 animate-spin mr-1" />
|
||||||
|
Checking...
|
||||||
|
</Badge>
|
||||||
|
) : mergeability?.mergeable ? (
|
||||||
|
<Badge variant="success" className="text-[10px] h-5">
|
||||||
|
<CheckCircle2 className="h-2.5 w-2.5 mr-1" />
|
||||||
|
Clean merge
|
||||||
|
</Badge>
|
||||||
|
) : mergeability && !mergeability.mergeable ? (
|
||||||
|
<Badge variant="error" className="text-[10px] h-5">
|
||||||
|
<AlertTriangle className="h-2.5 w-2.5 mr-1" />
|
||||||
|
{mergeability.conflictFiles.length} conflict{mergeability.conflictFiles.length !== 1 ? 's' : ''}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: action buttons */}
|
{/* Right: preview + action buttons */}
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{previewState && <PreviewControls preview={previewState} />}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -146,7 +248,8 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
|
onClick={() => approveMutation.mutate({ initiativeId, strategy: "merge_and_push" })}
|
||||||
disabled={approveMutation.isPending}
|
disabled={approveMutation.isPending || mergeability?.mergeable === false}
|
||||||
|
title={mergeability?.mergeable === false ? 'Resolve merge conflicts before merging' : undefined}
|
||||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||||
>
|
>
|
||||||
{approveMutation.isPending ? (
|
{approveMutation.isPending ? (
|
||||||
@@ -154,12 +257,25 @@ export function InitiativeReview({ initiativeId, onCompleted }: InitiativeReview
|
|||||||
) : (
|
) : (
|
||||||
<GitMerge className="h-3.5 w-3.5" />
|
<GitMerge className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
Merge & Push to Default
|
Merge & Push to {targetBranch || "default"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Conflict resolution panel */}
|
||||||
|
{mergeability && !mergeability.mergeable && (
|
||||||
|
<ConflictResolutionPanel
|
||||||
|
initiativeId={initiativeId}
|
||||||
|
conflicts={mergeability.conflictFiles}
|
||||||
|
onResolved={() => {
|
||||||
|
void mergeabilityQuery.refetch();
|
||||||
|
void diffQuery.refetch();
|
||||||
|
void commitsQuery.refetch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
||||||
<div className="border-r border-border">
|
<div className="border-r border-border">
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ interface LineWithCommentsProps {
|
|||||||
onSubmitComment: (body: string) => void;
|
onSubmitComment: (body: string) => void;
|
||||||
onResolveComment: (commentId: string) => void;
|
onResolveComment: (commentId: string) => void;
|
||||||
onUnresolveComment: (commentId: string) => void;
|
onUnresolveComment: (commentId: string) => void;
|
||||||
|
onReplyComment?: (parentCommentId: string, body: string) => void;
|
||||||
|
onEditComment?: (commentId: string, body: string) => void;
|
||||||
/** Syntax-highlighted tokens for this line (if available) */
|
/** Syntax-highlighted tokens for this line (if available) */
|
||||||
tokens?: TokenizedLine;
|
tokens?: TokenizedLine;
|
||||||
}
|
}
|
||||||
@@ -29,6 +31,8 @@ export function LineWithComments({
|
|||||||
onSubmitComment,
|
onSubmitComment,
|
||||||
onResolveComment,
|
onResolveComment,
|
||||||
onUnresolveComment,
|
onUnresolveComment,
|
||||||
|
onReplyComment,
|
||||||
|
onEditComment,
|
||||||
tokens,
|
tokens,
|
||||||
}: LineWithCommentsProps) {
|
}: LineWithCommentsProps) {
|
||||||
const formRef = useRef<HTMLTextAreaElement>(null);
|
const formRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -132,7 +136,7 @@ export function LineWithComments({
|
|||||||
|
|
||||||
{/* Existing comments on this line */}
|
{/* Existing comments on this line */}
|
||||||
{lineComments.length > 0 && (
|
{lineComments.length > 0 && (
|
||||||
<tr>
|
<tr data-comment-id={lineComments.find((c) => !c.parentCommentId)?.id}>
|
||||||
<td
|
<td
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
className="px-3 py-2 bg-muted/20 border-y border-border/50"
|
className="px-3 py-2 bg-muted/20 border-y border-border/50"
|
||||||
@@ -141,6 +145,8 @@ export function LineWithComments({
|
|||||||
comments={lineComments}
|
comments={lineComments}
|
||||||
onResolve={onResolveComment}
|
onResolve={onResolveComment}
|
||||||
onUnresolve={onUnresolveComment}
|
onUnresolve={onUnresolveComment}
|
||||||
|
onReply={onReplyComment}
|
||||||
|
onEdit={onEditComment}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
81
apps/web/src/components/review/PreviewControls.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Square,
|
||||||
|
CircleDot,
|
||||||
|
RotateCcw,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
export interface PreviewState {
|
||||||
|
status: "idle" | "building" | "running" | "failed";
|
||||||
|
url?: string;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
isStarting: boolean;
|
||||||
|
isStopping: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreviewControls({ preview }: { preview: PreviewState }) {
|
||||||
|
if (preview.status === "building" || preview.isStarting) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
<span>Building...</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.status === "running") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<a
|
||||||
|
href={preview.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
||||||
|
>
|
||||||
|
<CircleDot className="h-3 w-3" />
|
||||||
|
Preview
|
||||||
|
<ExternalLink className="h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={preview.onStop}
|
||||||
|
disabled={preview.isStopping}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<Square className="h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview.status === "failed") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={preview.onStart}
|
||||||
|
className="h-7 text-xs text-status-error-fg"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
Retry Preview
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={preview.onStart}
|
||||||
|
disabled={preview.isStarting}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,11 +6,7 @@ import {
|
|||||||
FileCode,
|
FileCode,
|
||||||
Plus,
|
Plus,
|
||||||
Minus,
|
Minus,
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Square,
|
|
||||||
CircleDot,
|
|
||||||
RotateCcw,
|
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Eye,
|
Eye,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -18,25 +14,21 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { PreviewControls } from "./PreviewControls";
|
||||||
|
import type { PreviewState } from "./PreviewControls";
|
||||||
import type { FileDiff, ReviewStatus } from "./types";
|
import type { FileDiff, ReviewStatus } from "./types";
|
||||||
|
|
||||||
interface PhaseOption {
|
interface PhaseOption {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
status: string;
|
||||||
|
|
||||||
interface PreviewState {
|
|
||||||
status: "idle" | "building" | "running" | "failed";
|
|
||||||
url?: string;
|
|
||||||
onStart: () => void;
|
|
||||||
onStop: () => void;
|
|
||||||
isStarting: boolean;
|
|
||||||
isStopping: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReviewHeaderProps {
|
interface ReviewHeaderProps {
|
||||||
|
ref?: React.Ref<HTMLDivElement>;
|
||||||
phases: PhaseOption[];
|
phases: PhaseOption[];
|
||||||
activePhaseId: string | null;
|
activePhaseId: string | null;
|
||||||
|
isReadOnly?: boolean;
|
||||||
onPhaseSelect: (id: string) => void;
|
onPhaseSelect: (id: string) => void;
|
||||||
phaseName: string;
|
phaseName: string;
|
||||||
sourceBranch: string;
|
sourceBranch: string;
|
||||||
@@ -53,8 +45,10 @@ interface ReviewHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ReviewHeader({
|
export function ReviewHeader({
|
||||||
|
ref,
|
||||||
phases,
|
phases,
|
||||||
activePhaseId,
|
activePhaseId,
|
||||||
|
isReadOnly,
|
||||||
onPhaseSelect,
|
onPhaseSelect,
|
||||||
phaseName,
|
phaseName,
|
||||||
sourceBranch,
|
sourceBranch,
|
||||||
@@ -72,28 +66,38 @@ export function ReviewHeader({
|
|||||||
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
|
||||||
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
|
||||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||||
|
const [showRequestConfirm, setShowRequestConfirm] = useState(false);
|
||||||
const confirmRef = useRef<HTMLDivElement>(null);
|
const confirmRef = useRef<HTMLDivElement>(null);
|
||||||
|
const requestConfirmRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Click-outside handler to dismiss confirmation
|
// Click-outside handler to dismiss confirmation dropdowns
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showConfirmation) return;
|
if (!showConfirmation && !showRequestConfirm) return;
|
||||||
function handleClickOutside(e: MouseEvent) {
|
function handleClickOutside(e: MouseEvent) {
|
||||||
if (
|
if (
|
||||||
|
showConfirmation &&
|
||||||
confirmRef.current &&
|
confirmRef.current &&
|
||||||
!confirmRef.current.contains(e.target as Node)
|
!confirmRef.current.contains(e.target as Node)
|
||||||
) {
|
) {
|
||||||
setShowConfirmation(false);
|
setShowConfirmation(false);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
showRequestConfirm &&
|
||||||
|
requestConfirmRef.current &&
|
||||||
|
!requestConfirmRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
setShowRequestConfirm(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
}, [showConfirmation]);
|
}, [showConfirmation, showRequestConfirm]);
|
||||||
|
|
||||||
const viewed = viewedCount ?? 0;
|
const viewed = viewedCount ?? 0;
|
||||||
const total = totalCount ?? 0;
|
const total = totalCount ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border bg-card/80 backdrop-blur-sm">
|
<div ref={ref} className="border-b border-border bg-card backdrop-blur-sm sticky top-0 z-20 rounded-t-lg shadow-[0_-50px_0_0_hsl(var(--background))]">
|
||||||
{/* Phase selector row */}
|
{/* Phase selector row */}
|
||||||
{phases.length > 1 && (
|
{phases.length > 1 && (
|
||||||
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
|
<div className="flex items-center gap-1 px-4 pt-3 pb-2 border-b border-border/50">
|
||||||
@@ -103,6 +107,12 @@ export function ReviewHeader({
|
|||||||
<div className="flex gap-1 overflow-x-auto">
|
<div className="flex gap-1 overflow-x-auto">
|
||||||
{phases.map((phase) => {
|
{phases.map((phase) => {
|
||||||
const isActive = phase.id === activePhaseId;
|
const isActive = phase.id === activePhaseId;
|
||||||
|
const isCompleted = phase.status === "completed";
|
||||||
|
const dotColor = isActive
|
||||||
|
? "bg-primary"
|
||||||
|
: isCompleted
|
||||||
|
? "bg-status-success-dot"
|
||||||
|
: "bg-status-warning-dot";
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={phase.id}
|
key={phase.id}
|
||||||
@@ -117,9 +127,7 @@ export function ReviewHeader({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`h-1.5 w-1.5 rounded-full shrink-0 ${
|
className={`h-1.5 w-1.5 rounded-full shrink-0 ${dotColor}`}
|
||||||
isActive ? "bg-primary" : "bg-status-warning-dot"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
{phase.name}
|
{phase.name}
|
||||||
</button>
|
</button>
|
||||||
@@ -182,102 +190,151 @@ export function ReviewHeader({
|
|||||||
{preview && <PreviewControls preview={preview} />}
|
{preview && <PreviewControls preview={preview} />}
|
||||||
|
|
||||||
{/* Review status / actions */}
|
{/* Review status / actions */}
|
||||||
{status === "pending" && (
|
{isReadOnly ? (
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={onRequestChanges}
|
|
||||||
disabled={isRequestingChanges}
|
|
||||||
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
|
|
||||||
>
|
|
||||||
{isRequestingChanges ? (
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
Request Changes
|
|
||||||
</Button>
|
|
||||||
<div className="relative" ref={confirmRef}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (unresolvedCount > 0) return;
|
|
||||||
setShowConfirmation(true);
|
|
||||||
}}
|
|
||||||
disabled={unresolvedCount > 0}
|
|
||||||
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
|
||||||
>
|
|
||||||
{unresolvedCount > 0 ? (
|
|
||||||
<>
|
|
||||||
<AlertCircle className="h-3.5 w-3.5" />
|
|
||||||
{unresolvedCount} unresolved
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<GitMerge className="h-3.5 w-3.5" />
|
|
||||||
Approve & Merge
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Merge confirmation dropdown */}
|
|
||||||
{showConfirmation && (
|
|
||||||
<div className="absolute right-0 top-full mt-1 z-20 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
|
||||||
<p className="text-sm font-semibold mb-3">
|
|
||||||
Ready to merge?
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5 mb-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<Check className="h-3.5 w-3.5 text-status-success-fg" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
0 unresolved comments
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-xs">
|
|
||||||
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{viewed}/{total} files viewed
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowConfirmation(false)}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowConfirmation(false);
|
|
||||||
onApprove();
|
|
||||||
}}
|
|
||||||
className="h-8 px-4 text-xs font-semibold shadow-sm"
|
|
||||||
>
|
|
||||||
<GitMerge className="h-3.5 w-3.5" />
|
|
||||||
Merge Now
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{status === "approved" && (
|
|
||||||
<Badge variant="success" size="xs">
|
<Badge variant="success" size="xs">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
Approved
|
Merged
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{status === "changes_requested" && (
|
|
||||||
<Badge variant="warning" size="xs">
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
Changes Requested
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{status === "pending" && (
|
||||||
|
<>
|
||||||
|
<div className="relative" ref={requestConfirmRef}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRequestConfirm(true)}
|
||||||
|
disabled={isRequestingChanges || unresolvedCount === 0}
|
||||||
|
className="h-8 text-xs px-3 border-status-error-border/50 text-status-error-fg hover:bg-status-error-bg/50 hover:border-status-error-border"
|
||||||
|
>
|
||||||
|
{isRequestingChanges ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{showRequestConfirm && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
||||||
|
<p className="text-sm font-semibold mb-3">
|
||||||
|
Request changes?
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 text-status-error-fg" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{unresolvedCount} unresolved {unresolvedCount === 1 ? "comment" : "comments"} will be sent
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowRequestConfirm(false)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowRequestConfirm(false);
|
||||||
|
onRequestChanges();
|
||||||
|
}}
|
||||||
|
className="h-8 px-4 text-xs font-semibold shadow-sm border-status-error-border text-status-error-fg hover:bg-status-error-bg"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
Request Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="relative" ref={confirmRef}>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (unresolvedCount > 0) return;
|
||||||
|
setShowConfirmation(true);
|
||||||
|
}}
|
||||||
|
disabled={unresolvedCount > 0}
|
||||||
|
className="h-9 px-5 text-sm font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
{unresolvedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
{unresolvedCount} unresolved
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<GitMerge className="h-3.5 w-3.5" />
|
||||||
|
Approve & Merge
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Merge confirmation dropdown */}
|
||||||
|
{showConfirmation && (
|
||||||
|
<div className="absolute right-0 top-full mt-1 z-30 w-64 rounded-lg border border-border bg-card shadow-lg p-4">
|
||||||
|
<p className="text-sm font-semibold mb-3">
|
||||||
|
Ready to merge?
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 mb-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Check className="h-3.5 w-3.5 text-status-success-fg" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
0 unresolved comments
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<Eye className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{viewed}/{total} files viewed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowConfirmation(false)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setShowConfirmation(false);
|
||||||
|
onApprove();
|
||||||
|
}}
|
||||||
|
className="h-8 px-4 text-xs font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
<GitMerge className="h-3.5 w-3.5" />
|
||||||
|
Merge Now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{status === "approved" && (
|
||||||
|
<Badge variant="success" size="xs">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
Approved
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{status === "changes_requested" && (
|
||||||
|
<Badge variant="warning" size="xs">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
Changes Requested
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,66 +342,3 @@ export function ReviewHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewControls({ preview }: { preview: PreviewState }) {
|
|
||||||
if (preview.status === "building" || preview.isStarting) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-status-active-fg">
|
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
|
||||||
<span>Building...</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preview.status === "running") {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<a
|
|
||||||
href={preview.url}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-1 text-xs text-status-success-fg hover:underline"
|
|
||||||
>
|
|
||||||
<CircleDot className="h-3 w-3" />
|
|
||||||
Preview
|
|
||||||
<ExternalLink className="h-2.5 w-2.5" />
|
|
||||||
</a>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={preview.onStop}
|
|
||||||
disabled={preview.isStopping}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Square className="h-2.5 w-2.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preview.status === "failed") {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={preview.onStart}
|
|
||||||
className="h-7 text-xs text-status-error-fg"
|
|
||||||
>
|
|
||||||
<RotateCcw className="h-3 w-3" />
|
|
||||||
Retry Preview
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={preview.onStart}
|
|
||||||
disabled={preview.isStarting}
|
|
||||||
className="h-7 text-xs"
|
|
||||||
>
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
Preview
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface ReviewSidebarProps {
|
|||||||
files: FileDiff[];
|
files: FileDiff[];
|
||||||
comments: ReviewComment[];
|
comments: ReviewComment[];
|
||||||
onFileClick: (filePath: string) => void;
|
onFileClick: (filePath: string) => void;
|
||||||
|
onCommentClick?: (commentId: string) => void;
|
||||||
selectedCommit: string | null;
|
selectedCommit: string | null;
|
||||||
activeFiles: FileDiff[];
|
activeFiles: FileDiff[];
|
||||||
commits: CommitInfo[];
|
commits: CommitInfo[];
|
||||||
@@ -29,6 +30,7 @@ export function ReviewSidebar({
|
|||||||
files,
|
files,
|
||||||
comments,
|
comments,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
|
onCommentClick,
|
||||||
selectedCommit,
|
selectedCommit,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
commits,
|
commits,
|
||||||
@@ -63,6 +65,7 @@ export function ReviewSidebar({
|
|||||||
files={files}
|
files={files}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
onFileClick={onFileClick}
|
onFileClick={onFileClick}
|
||||||
|
onCommentClick={onCommentClick}
|
||||||
selectedCommit={selectedCommit}
|
selectedCommit={selectedCommit}
|
||||||
activeFiles={activeFiles}
|
activeFiles={activeFiles}
|
||||||
viewedFiles={viewedFiles}
|
viewedFiles={viewedFiles}
|
||||||
@@ -172,6 +175,7 @@ function FilesView({
|
|||||||
files,
|
files,
|
||||||
comments,
|
comments,
|
||||||
onFileClick,
|
onFileClick,
|
||||||
|
onCommentClick,
|
||||||
selectedCommit,
|
selectedCommit,
|
||||||
activeFiles,
|
activeFiles,
|
||||||
viewedFiles,
|
viewedFiles,
|
||||||
@@ -179,12 +183,13 @@ function FilesView({
|
|||||||
files: FileDiff[];
|
files: FileDiff[];
|
||||||
comments: ReviewComment[];
|
comments: ReviewComment[];
|
||||||
onFileClick: (filePath: string) => void;
|
onFileClick: (filePath: string) => void;
|
||||||
|
onCommentClick?: (commentId: string) => void;
|
||||||
selectedCommit: string | null;
|
selectedCommit: string | null;
|
||||||
activeFiles: FileDiff[];
|
activeFiles: FileDiff[];
|
||||||
viewedFiles: Set<string>;
|
viewedFiles: Set<string>;
|
||||||
}) {
|
}) {
|
||||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||||
const resolvedCount = comments.filter((c) => c.resolved).length;
|
const resolvedCount = comments.filter((c) => c.resolved && !c.parentCommentId).length;
|
||||||
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
const activeFilePaths = new Set(activeFiles.map((f) => f.newPath));
|
||||||
|
|
||||||
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
|
const directoryGroups = useMemo(() => groupFilesByDirectory(files), [files]);
|
||||||
@@ -213,29 +218,66 @@ function FilesView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comment summary */}
|
{/* Discussions — individual threads */}
|
||||||
{comments.length > 0 && (
|
{comments.length > 0 && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
<h4 className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider flex items-center justify-between">
|
||||||
Discussions
|
<span>Discussions</span>
|
||||||
</h4>
|
<span className="flex items-center gap-2 font-normal normal-case">
|
||||||
<div className="flex items-center gap-3 text-xs">
|
{unresolvedCount > 0 && (
|
||||||
<span className="flex items-center gap-1 text-muted-foreground">
|
<span className="flex items-center gap-0.5 text-status-warning-fg">
|
||||||
<MessageSquare className="h-3 w-3" />
|
<Circle className="h-2.5 w-2.5" />
|
||||||
{comments.length}
|
{unresolvedCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{resolvedCount > 0 && (
|
||||||
|
<span className="flex items-center gap-0.5 text-status-success-fg">
|
||||||
|
<CheckCircle2 className="h-2.5 w-2.5" />
|
||||||
|
{resolvedCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{resolvedCount > 0 && (
|
</h4>
|
||||||
<span className="flex items-center gap-1 text-status-success-fg">
|
<div className="space-y-0.5">
|
||||||
<CheckCircle2 className="h-3 w-3" />
|
{comments
|
||||||
{resolvedCount}
|
.filter((c) => !c.parentCommentId)
|
||||||
</span>
|
.map((thread) => {
|
||||||
)}
|
const replyCount = comments.filter(
|
||||||
{unresolvedCount > 0 && (
|
(c) => c.parentCommentId === thread.id,
|
||||||
<span className="flex items-center gap-1 text-status-warning-fg">
|
).length;
|
||||||
<Circle className="h-3 w-3" />
|
return (
|
||||||
{unresolvedCount}
|
<button
|
||||||
</span>
|
key={thread.id}
|
||||||
)}
|
className={`
|
||||||
|
flex w-full flex-col gap-0.5 rounded px-2 py-1.5 text-left
|
||||||
|
transition-colors hover:bg-accent/50
|
||||||
|
${thread.resolved ? "opacity-50" : ""}
|
||||||
|
`}
|
||||||
|
onClick={() => onCommentClick ? onCommentClick(thread.id) : onFileClick(thread.filePath)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 w-full min-w-0">
|
||||||
|
{thread.resolved ? (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-status-success-fg shrink-0" />
|
||||||
|
) : (
|
||||||
|
<MessageSquare className="h-3 w-3 text-status-warning-fg shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-[10px] font-mono text-muted-foreground truncate">
|
||||||
|
{getFileName(thread.filePath)}:{thread.lineNumber}
|
||||||
|
</span>
|
||||||
|
{replyCount > 0 && (
|
||||||
|
<span className="text-[9px] text-muted-foreground/70 shrink-0 ml-auto">
|
||||||
|
{replyCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-foreground/80 truncate pl-[18px]">
|
||||||
|
{thread.body.length > 60
|
||||||
|
? thread.body.slice(0, 57) + "..."
|
||||||
|
: thread.body}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -263,7 +305,7 @@ function FilesView({
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{group.files.map((file) => {
|
{group.files.map((file) => {
|
||||||
const fileCommentCount = comments.filter(
|
const fileCommentCount = comments.filter(
|
||||||
(c) => c.filePath === file.newPath,
|
(c) => c.filePath === file.newPath && !c.parentCommentId,
|
||||||
).length;
|
).length;
|
||||||
const isInView = activeFilePaths.has(file.newPath);
|
const isInView = activeFilePaths.has(file.newPath);
|
||||||
const dimmed = selectedCommit && !isInView;
|
const dimmed = selectedCommit && !isInView;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { trpc } from "@/lib/trpc";
|
import { trpc } from "@/lib/trpc";
|
||||||
@@ -18,6 +18,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
|
||||||
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
const [viewedFiles, setViewedFiles] = useState<Set<string>>(new Set());
|
||||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||||
|
const headerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [headerHeight, setHeaderHeight] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = headerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
setHeaderHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.target.getBoundingClientRect().height);
|
||||||
|
});
|
||||||
|
ro.observe(el, { box: 'border-box' });
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleViewed = useCallback((filePath: string) => {
|
const toggleViewed = useCallback((filePath: string) => {
|
||||||
setViewedFiles(prev => {
|
setViewedFiles(prev => {
|
||||||
@@ -45,14 +57,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
|
|
||||||
// Fetch phases for this initiative
|
// Fetch phases for this initiative
|
||||||
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
|
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
|
||||||
const pendingReviewPhases = useMemo(
|
const reviewablePhases = useMemo(
|
||||||
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
|
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review" || p.status === "completed"),
|
||||||
[phasesQuery.data],
|
[phasesQuery.data],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Select first pending review phase
|
// Select first pending review phase, falling back to completed phases
|
||||||
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
|
||||||
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
|
const defaultPhaseId = reviewablePhases.find((p) => p.status === "pending_review")?.id ?? reviewablePhases[0]?.id ?? null;
|
||||||
|
const activePhaseId = selectedPhaseId ?? defaultPhaseId;
|
||||||
|
const activePhase = reviewablePhases.find((p) => p.id === activePhaseId);
|
||||||
|
const isActivePhaseCompleted = activePhase?.status === "completed";
|
||||||
|
|
||||||
// Fetch projects for this initiative (needed for preview)
|
// Fetch projects for this initiative (needed for preview)
|
||||||
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
|
||||||
@@ -78,18 +93,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Preview state
|
// Preview state
|
||||||
const previewsQuery = trpc.listPreviews.useQuery(
|
const previewsQuery = trpc.listPreviews.useQuery({ initiativeId });
|
||||||
{ initiativeId },
|
|
||||||
);
|
|
||||||
const existingPreview = previewsQuery.data?.find(
|
const existingPreview = previewsQuery.data?.find(
|
||||||
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
(p) => p.phaseId === activePhaseId || p.initiativeId === initiativeId,
|
||||||
);
|
);
|
||||||
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
|
||||||
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
|
||||||
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
|
||||||
{
|
{ enabled: !!(activePreviewId ?? existingPreview?.id) },
|
||||||
enabled: !!(activePreviewId ?? existingPreview?.id),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const preview = previewStatusQuery.data ?? existingPreview;
|
const preview = previewStatusQuery.data ?? existingPreview;
|
||||||
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
const sourceBranch = diffQuery.data?.sourceBranch ?? commitsQuery.data?.sourceBranch ?? "";
|
||||||
@@ -97,6 +108,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
const startPreview = trpc.startPreview.useMutation({
|
const startPreview = trpc.startPreview.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setActivePreviewId(data.id);
|
setActivePreviewId(data.id);
|
||||||
|
previewsQuery.refetch();
|
||||||
toast.success(`Preview running at ${data.url}`);
|
toast.success(`Preview running at ${data.url}`);
|
||||||
},
|
},
|
||||||
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
onError: (err) => toast.error(`Preview failed: ${err.message}`),
|
||||||
@@ -113,15 +125,13 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
|
|
||||||
const previewState = firstProjectId && sourceBranch
|
const previewState = firstProjectId && sourceBranch
|
||||||
? {
|
? {
|
||||||
status: startPreview.isPending
|
status: preview?.status === "running"
|
||||||
? ("building" as const)
|
? ("running" as const)
|
||||||
: preview?.status === "running"
|
: preview?.status === "failed"
|
||||||
? ("running" as const)
|
? ("failed" as const)
|
||||||
: preview?.status === "building"
|
: (startPreview.isPending || preview?.status === "building")
|
||||||
? ("building" as const)
|
? ("building" as const)
|
||||||
: preview?.status === "failed"
|
: ("idle" as const),
|
||||||
? ("failed" as const)
|
|
||||||
: ("idle" as const),
|
|
||||||
url: preview?.url ?? undefined,
|
url: preview?.url ?? undefined,
|
||||||
onStart: () =>
|
onStart: () =>
|
||||||
startPreview.mutate({
|
startPreview.mutate({
|
||||||
@@ -155,6 +165,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
author: c.author,
|
author: c.author,
|
||||||
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
|
createdAt: typeof c.createdAt === 'string' ? c.createdAt : String(c.createdAt),
|
||||||
resolved: c.resolved,
|
resolved: c.resolved,
|
||||||
|
parentCommentId: c.parentCommentId ?? null,
|
||||||
}));
|
}));
|
||||||
}, [commentsQuery.data]);
|
}, [commentsQuery.data]);
|
||||||
|
|
||||||
@@ -177,6 +188,20 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const replyToCommentMutation = trpc.replyToReviewComment.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to post reply: ${err.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const editCommentMutation = trpc.updateReviewComment.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
utils.listReviewComments.invalidate({ phaseId: activePhaseId! });
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(`Failed to update comment: ${err.message}`),
|
||||||
|
});
|
||||||
|
|
||||||
const approveMutation = trpc.approvePhaseReview.useMutation({
|
const approveMutation = trpc.approvePhaseReview.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setStatus("approved");
|
setStatus("approved");
|
||||||
@@ -223,6 +248,14 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
unresolveCommentMutation.mutate({ id: commentId });
|
unresolveCommentMutation.mutate({ id: commentId });
|
||||||
}, [unresolveCommentMutation]);
|
}, [unresolveCommentMutation]);
|
||||||
|
|
||||||
|
const handleReplyComment = useCallback((parentCommentId: string, body: string) => {
|
||||||
|
replyToCommentMutation.mutate({ parentCommentId, body });
|
||||||
|
}, [replyToCommentMutation]);
|
||||||
|
|
||||||
|
const handleEditComment = useCallback((commentId: string, body: string) => {
|
||||||
|
editCommentMutation.mutate({ id: commentId, body });
|
||||||
|
}, [editCommentMutation]);
|
||||||
|
|
||||||
const handleApprove = useCallback(() => {
|
const handleApprove = useCallback(() => {
|
||||||
if (!activePhaseId) return;
|
if (!activePhaseId) return;
|
||||||
approveMutation.mutate({ phaseId: activePhaseId });
|
approveMutation.mutate({ phaseId: activePhaseId });
|
||||||
@@ -239,9 +272,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
|
|
||||||
const handleRequestChanges = useCallback(() => {
|
const handleRequestChanges = useCallback(() => {
|
||||||
if (!activePhaseId) return;
|
if (!activePhaseId) return;
|
||||||
const summary = window.prompt("Optional: describe what needs to change (leave blank for comments only)");
|
requestChangesMutation.mutate({ phaseId: activePhaseId });
|
||||||
if (summary === null) return; // cancelled
|
|
||||||
requestChangesMutation.mutate({ phaseId: activePhaseId, summary: summary || undefined });
|
|
||||||
}, [activePhaseId, requestChangesMutation]);
|
}, [activePhaseId, requestChangesMutation]);
|
||||||
|
|
||||||
const handleFileClick = useCallback((filePath: string) => {
|
const handleFileClick = useCallback((filePath: string) => {
|
||||||
@@ -251,6 +282,16 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleCommentClick = useCallback((commentId: string) => {
|
||||||
|
const el = document.querySelector(`[data-comment-id="${commentId}"]`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "instant", block: "center" });
|
||||||
|
// Brief highlight flash
|
||||||
|
el.classList.add("ring-2", "ring-primary/50");
|
||||||
|
setTimeout(() => el.classList.remove("ring-2", "ring-primary/50"), 1500);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePhaseSelect = useCallback((id: string) => {
|
const handlePhaseSelect = useCallback((id: string) => {
|
||||||
setSelectedPhaseId(id);
|
setSelectedPhaseId(id);
|
||||||
setSelectedCommit(null);
|
setSelectedCommit(null);
|
||||||
@@ -258,7 +299,18 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
setViewedFiles(new Set());
|
setViewedFiles(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const unresolvedCount = comments.filter((c) => !c.resolved).length;
|
const unresolvedCount = comments.filter((c) => !c.resolved && !c.parentCommentId).length;
|
||||||
|
|
||||||
|
const activePhaseName =
|
||||||
|
diffQuery.data?.phaseName ??
|
||||||
|
reviewablePhases.find((p) => p.id === activePhaseId)?.name ??
|
||||||
|
"Phase";
|
||||||
|
|
||||||
|
// All files from the full branch diff (for sidebar file list)
|
||||||
|
const allFiles = useMemo(() => {
|
||||||
|
if (!diffQuery.data?.rawDiff) return [];
|
||||||
|
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
||||||
|
}, [diffQuery.data?.rawDiff]);
|
||||||
|
|
||||||
// Initiative-level review takes priority
|
// Initiative-level review takes priority
|
||||||
if (isInitiativePendingReview) {
|
if (isInitiativePendingReview) {
|
||||||
@@ -273,7 +325,7 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingReviewPhases.length === 0) {
|
if (reviewablePhases.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||||
<p>No phases pending review</p>
|
<p>No phases pending review</p>
|
||||||
@@ -281,23 +333,17 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activePhaseName =
|
|
||||||
diffQuery.data?.phaseName ??
|
|
||||||
pendingReviewPhases.find((p) => p.id === activePhaseId)?.name ??
|
|
||||||
"Phase";
|
|
||||||
|
|
||||||
// All files from the full branch diff (for sidebar file list)
|
|
||||||
const allFiles = useMemo(() => {
|
|
||||||
if (!diffQuery.data?.rawDiff) return [];
|
|
||||||
return parseUnifiedDiff(diffQuery.data.rawDiff);
|
|
||||||
}, [diffQuery.data?.rawDiff]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-border overflow-hidden bg-card">
|
<div
|
||||||
|
className="rounded-lg border border-border bg-card"
|
||||||
|
style={{ '--review-header-h': `${headerHeight}px` } as React.CSSProperties}
|
||||||
|
>
|
||||||
{/* Header: phase selector + toolbar */}
|
{/* Header: phase selector + toolbar */}
|
||||||
<ReviewHeader
|
<ReviewHeader
|
||||||
phases={pendingReviewPhases.map((p) => ({ id: p.id, name: p.name }))}
|
ref={headerRef}
|
||||||
|
phases={reviewablePhases.map((p) => ({ id: p.id, name: p.name, status: p.status }))}
|
||||||
activePhaseId={activePhaseId}
|
activePhaseId={activePhaseId}
|
||||||
|
isReadOnly={isActivePhaseCompleted}
|
||||||
onPhaseSelect={handlePhaseSelect}
|
onPhaseSelect={handlePhaseSelect}
|
||||||
phaseName={activePhaseName}
|
phaseName={activePhaseName}
|
||||||
sourceBranch={sourceBranch}
|
sourceBranch={sourceBranch}
|
||||||
@@ -314,14 +360,21 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main content area — sidebar always rendered to preserve state */}
|
{/* Main content area — sidebar always rendered to preserve state */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr]">
|
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] rounded-b-lg">
|
||||||
{/* Left: Sidebar — sticky so icon strip stays visible */}
|
{/* Left: Sidebar — sticky to viewport, scrolls independently */}
|
||||||
<div className="border-r border-border">
|
<div className="border-r border-border">
|
||||||
<div className="sticky top-0 h-[calc(100vh-12rem)]">
|
<div
|
||||||
|
className="sticky overflow-hidden"
|
||||||
|
style={{
|
||||||
|
top: `${headerHeight}px`,
|
||||||
|
maxHeight: `calc(100vh - ${headerHeight}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ReviewSidebar
|
<ReviewSidebar
|
||||||
files={allFiles}
|
files={allFiles}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
onFileClick={handleFileClick}
|
onFileClick={handleFileClick}
|
||||||
|
onCommentClick={handleCommentClick}
|
||||||
selectedCommit={selectedCommit}
|
selectedCommit={selectedCommit}
|
||||||
activeFiles={files}
|
activeFiles={files}
|
||||||
commits={commits}
|
commits={commits}
|
||||||
@@ -351,6 +404,8 @@ export function ReviewTab({ initiativeId }: ReviewTabProps) {
|
|||||||
onAddComment={handleAddComment}
|
onAddComment={handleAddComment}
|
||||||
onResolveComment={handleResolveComment}
|
onResolveComment={handleResolveComment}
|
||||||
onUnresolveComment={handleUnresolveComment}
|
onUnresolveComment={handleUnresolveComment}
|
||||||
|
onReplyComment={handleReplyComment}
|
||||||
|
onEditComment={handleEditComment}
|
||||||
viewedFiles={viewedFiles}
|
viewedFiles={viewedFiles}
|
||||||
onToggleViewed={toggleViewed}
|
onToggleViewed={toggleViewed}
|
||||||
onRegisterRef={registerFileRef}
|
onRegisterRef={registerFileRef}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export interface ReviewComment {
|
|||||||
author: string;
|
author: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
|
parentCommentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ReviewStatus = "pending" | "approved" | "changes_requested";
|
export type ReviewStatus = "pending" | "approved" | "changes_requested";
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ export { useAutoSave } from './useAutoSave.js';
|
|||||||
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
|
||||||
export { useLiveUpdates } from './useLiveUpdates.js';
|
export { useLiveUpdates } from './useLiveUpdates.js';
|
||||||
export { useRefineAgent } from './useRefineAgent.js';
|
export { useRefineAgent } from './useRefineAgent.js';
|
||||||
|
export { useConflictAgent } from './useConflictAgent.js';
|
||||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
RefineAgentState,
|
RefineAgentState,
|
||||||
SpawnRefineAgentOptions,
|
SpawnRefineAgentOptions,
|
||||||
UseRefineAgentResult,
|
UseRefineAgentResult,
|
||||||
} from './useRefineAgent.js';
|
} from './useRefineAgent.js';
|
||||||
|
|
||||||
|
export type {
|
||||||
|
ConflictAgentState,
|
||||||
|
UseConflictAgentResult,
|
||||||
|
} from './useConflictAgent.js';
|
||||||
214
apps/web/src/hooks/useConflictAgent.ts
Normal file
214
apps/web/src/hooks/useConflictAgent.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import { useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { trpc } from '@/lib/trpc';
|
||||||
|
import type { PendingQuestions } from '@codewalk-district/shared';
|
||||||
|
|
||||||
|
export type ConflictAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||||
|
|
||||||
|
type ConflictAgent = NonNullable<ReturnType<typeof trpc.getActiveConflictAgent.useQuery>['data']>;
|
||||||
|
|
||||||
|
export interface UseConflictAgentResult {
|
||||||
|
agent: ConflictAgent | null;
|
||||||
|
state: ConflictAgentState;
|
||||||
|
questions: PendingQuestions | null;
|
||||||
|
spawn: {
|
||||||
|
mutate: (options: { initiativeId: string; provider?: string }) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
resume: {
|
||||||
|
mutate: (answers: Record<string, string>) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
stop: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
dismiss: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useConflictAgent(initiativeId: string): UseConflictAgentResult {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
const agentQuery = trpc.getActiveConflictAgent.useQuery({ initiativeId });
|
||||||
|
const agent = agentQuery.data ?? null;
|
||||||
|
|
||||||
|
const state: ConflictAgentState = useMemo(() => {
|
||||||
|
if (!agent) return 'none';
|
||||||
|
switch (agent.status) {
|
||||||
|
case 'running':
|
||||||
|
return 'running';
|
||||||
|
case 'waiting_for_input':
|
||||||
|
return 'waiting';
|
||||||
|
case 'idle':
|
||||||
|
return 'completed';
|
||||||
|
case 'crashed':
|
||||||
|
return 'crashed';
|
||||||
|
default:
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}, [agent]);
|
||||||
|
|
||||||
|
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||||
|
{ id: agent?.id ?? '' },
|
||||||
|
{ enabled: state === 'waiting' && !!agent },
|
||||||
|
);
|
||||||
|
|
||||||
|
const spawnMutation = trpc.spawnConflictResolutionAgent.useMutation({
|
||||||
|
onMutate: async () => {
|
||||||
|
await utils.listAgents.cancel();
|
||||||
|
await utils.getActiveConflictAgent.cancel({ initiativeId });
|
||||||
|
|
||||||
|
const previousAgents = utils.listAgents.getData();
|
||||||
|
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
|
||||||
|
|
||||||
|
const tempAgent = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
name: `conflict-${Date.now()}`,
|
||||||
|
mode: 'execute' as const,
|
||||||
|
status: 'running' as const,
|
||||||
|
initiativeId,
|
||||||
|
taskId: null,
|
||||||
|
phaseId: null,
|
||||||
|
provider: 'claude',
|
||||||
|
accountId: null,
|
||||||
|
instruction: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
userDismissedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, tempAgent as any);
|
||||||
|
|
||||||
|
return { previousAgents, previousConflictAgent };
|
||||||
|
},
|
||||||
|
onError: (_err, _variables, context) => {
|
||||||
|
if (context?.previousAgents) {
|
||||||
|
utils.listAgents.setData(undefined, context.previousAgents);
|
||||||
|
}
|
||||||
|
if (context?.previousConflictAgent !== undefined) {
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeMutation = trpc.resumeAgent.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopMutation = trpc.stopAgent.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
void utils.listWaitingAgents.invalidate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||||
|
onMutate: async ({ id }) => {
|
||||||
|
await utils.listAgents.cancel();
|
||||||
|
await utils.getActiveConflictAgent.cancel({ initiativeId });
|
||||||
|
|
||||||
|
const previousAgents = utils.listAgents.getData();
|
||||||
|
const previousConflictAgent = utils.getActiveConflictAgent.getData({ initiativeId });
|
||||||
|
|
||||||
|
utils.listAgents.setData(undefined, (old = []) => old.filter(a => a.id !== id));
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, null);
|
||||||
|
|
||||||
|
return { previousAgents, previousConflictAgent };
|
||||||
|
},
|
||||||
|
onError: (_err, _variables, context) => {
|
||||||
|
if (context?.previousAgents) {
|
||||||
|
utils.listAgents.setData(undefined, context.previousAgents);
|
||||||
|
}
|
||||||
|
if (context?.previousConflictAgent !== undefined) {
|
||||||
|
utils.getActiveConflictAgent.setData({ initiativeId }, context.previousConflictAgent);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
void utils.listAgents.invalidate();
|
||||||
|
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const spawnMutateRef = useRef(spawnMutation.mutate);
|
||||||
|
spawnMutateRef.current = spawnMutation.mutate;
|
||||||
|
const agentRef = useRef(agent);
|
||||||
|
agentRef.current = agent;
|
||||||
|
const resumeMutateRef = useRef(resumeMutation.mutate);
|
||||||
|
resumeMutateRef.current = resumeMutation.mutate;
|
||||||
|
const stopMutateRef = useRef(stopMutation.mutate);
|
||||||
|
stopMutateRef.current = stopMutation.mutate;
|
||||||
|
const dismissMutateRef = useRef(dismissMutation.mutate);
|
||||||
|
dismissMutateRef.current = dismissMutation.mutate;
|
||||||
|
|
||||||
|
const spawnFn = useCallback(({ initiativeId, provider }: { initiativeId: string; provider?: string }) => {
|
||||||
|
spawnMutateRef.current({ initiativeId, provider });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const spawn = useMemo(() => ({
|
||||||
|
mutate: spawnFn,
|
||||||
|
isPending: spawnMutation.isPending,
|
||||||
|
error: spawnMutation.error,
|
||||||
|
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
|
||||||
|
|
||||||
|
const resumeFn = useCallback((answers: Record<string, string>) => {
|
||||||
|
const a = agentRef.current;
|
||||||
|
if (a) {
|
||||||
|
resumeMutateRef.current({ id: a.id, answers });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resume = useMemo(() => ({
|
||||||
|
mutate: resumeFn,
|
||||||
|
isPending: resumeMutation.isPending,
|
||||||
|
error: resumeMutation.error,
|
||||||
|
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
|
||||||
|
|
||||||
|
const stopFn = useCallback(() => {
|
||||||
|
const a = agentRef.current;
|
||||||
|
if (a) {
|
||||||
|
stopMutateRef.current({ id: a.id });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stop = useMemo(() => ({
|
||||||
|
mutate: stopFn,
|
||||||
|
isPending: stopMutation.isPending,
|
||||||
|
}), [stopFn, stopMutation.isPending]);
|
||||||
|
|
||||||
|
const dismiss = useCallback(() => {
|
||||||
|
const a = agentRef.current;
|
||||||
|
if (a) {
|
||||||
|
dismissMutateRef.current({ id: a.id });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
void utils.getActiveConflictAgent.invalidate({ initiativeId });
|
||||||
|
}, [utils, initiativeId]);
|
||||||
|
|
||||||
|
const isLoading = agentQuery.isLoading ||
|
||||||
|
(state === 'waiting' && questionsQuery.isLoading);
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent,
|
||||||
|
state,
|
||||||
|
questions: questionsQuery.data ?? null,
|
||||||
|
spawn,
|
||||||
|
resume,
|
||||||
|
stop,
|
||||||
|
dismiss,
|
||||||
|
isLoading,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
|||||||
|
|
||||||
// --- Phases ---
|
// --- Phases ---
|
||||||
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
|
||||||
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"],
|
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
|
||||||
updatePhase: ["listPhases", "getPhase"],
|
updatePhase: ["listPhases", "getPhase"],
|
||||||
approvePhase: ["listPhases", "listInitiativeTasks"],
|
approvePhase: ["listPhases", "listInitiativeTasks"],
|
||||||
queuePhase: ["listPhases"],
|
queuePhase: ["listPhases"],
|
||||||
@@ -65,6 +65,8 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
|
|||||||
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||||
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
|
||||||
|
|
||||||
|
deleteTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listChangeSets"],
|
||||||
|
|
||||||
// --- Change Sets ---
|
// --- Change Sets ---
|
||||||
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
|
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
|
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
|
||||||
| `credentials/` | `AccountCredentialManager` — credential injection per account |
|
| `credentials/` | `AccountCredentialManager` — credential injection per account |
|
||||||
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
|
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
|
||||||
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
|
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
|
||||||
|
|
||||||
## Key Flows
|
## Key Flows
|
||||||
|
|
||||||
@@ -236,7 +236,7 @@ All prompts follow a consistent tag ordering:
|
|||||||
|------|------|--------------------|
|
|------|------|--------------------|
|
||||||
| **execute** | `execute.ts` | `<task>`, `<execution_protocol>`, `<anti_patterns>`, `<scope_rules>` |
|
| **execute** | `execute.ts` | `<task>`, `<execution_protocol>`, `<anti_patterns>`, `<scope_rules>` |
|
||||||
| **plan** | `plan.ts` | `<phase_design>`, `<dependencies>`, `<file_ownership>`, `<specificity>`, `<existing_context>` |
|
| **plan** | `plan.ts` | `<phase_design>`, `<dependencies>`, `<file_ownership>`, `<specificity>`, `<existing_context>` |
|
||||||
| **detail** | `detail.ts` | `<task_body_requirements>`, `<file_ownership>`, `<task_sizing>`, `<checkpoint_tasks>`, `<existing_context>` |
|
| **detail** | `detail.ts` | `<task_body_requirements>`, `<file_ownership>`, `<task_sizing>`, `<existing_context>` |
|
||||||
| **discuss** | `discuss.ts` | `<analysis_method>`, `<question_quality>`, `<decision_quality>`, `<question_categories>`, `<rules>` |
|
| **discuss** | `discuss.ts` | `<analysis_method>`, `<question_quality>`, `<decision_quality>`, `<question_categories>`, `<rules>` |
|
||||||
| **refine** | `refine.ts` | `<improvement_priorities>`, `<rules>` |
|
| **refine** | `refine.ts` | `<improvement_priorities>`, `<rules>` |
|
||||||
| **chat** | `chat.ts` | `<chat_history>`, `<instruction>` — iterative refinement loop, uses action field (create/update/delete) in output files, signals "questions" after each change to stay alive |
|
| **chat** | `chat.ts` | `<chat_history>`, `<instruction>` — iterative refinement loop, uses action field (create/update/delete) in output files, signals "questions" after each change to stay alive |
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ All three commands output JSON for programmatic agent consumption.
|
|||||||
| `list` | Show accounts with exhaustion status |
|
| `list` | Show accounts with exhaustion status |
|
||||||
| `remove <id>` | Remove account |
|
| `remove <id>` | Remove account |
|
||||||
| `refresh` | Clear expired exhaustion markers |
|
| `refresh` | Clear expired exhaustion markers |
|
||||||
|
| `extract [--email <email>]` | Extract current Claude credentials as JSON (no server required) |
|
||||||
|
|
||||||
## Server Wiring
|
## Server Wiring
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for
|
|||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
- **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions)
|
- **Schema definition:** `apps/server/db/schema.ts` (drizzle-orm table definitions)
|
||||||
- **Migration output:** `apps/server/drizzle/` directory (SQL files + meta journal)
|
- **Migration output:** `apps/server/drizzle/` directory (SQL files + `meta/_journal.json` + `meta/NNNN_snapshot.json`)
|
||||||
- **Config:** `drizzle.config.ts`
|
- **Config:** `drizzle.config.ts`
|
||||||
- **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`)
|
- **Runtime migrator:** `apps/server/db/ensure-schema.ts` (calls `drizzle-orm/better-sqlite3/migrator`)
|
||||||
|
|
||||||
@@ -13,6 +13,8 @@ This project uses [drizzle-kit](https://orm.drizzle.team/kit-docs/overview) for
|
|||||||
|
|
||||||
On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly.
|
On every server startup, `ensureSchema(db)` runs all pending migrations from the `apps/server/drizzle/` folder. Drizzle tracks applied migrations in a `__drizzle_migrations` table so only new migrations are applied. This is safe to call repeatedly.
|
||||||
|
|
||||||
|
The migrator discovers migrations via `apps/server/drizzle/meta/_journal.json` — **not** by scanning the filesystem.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### Making schema changes
|
### Making schema changes
|
||||||
@@ -23,7 +25,17 @@ On every server startup, `ensureSchema(db)` runs all pending migrations from the
|
|||||||
npx drizzle-kit generate
|
npx drizzle-kit generate
|
||||||
```
|
```
|
||||||
3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql`
|
3. Review the generated SQL in `apps/server/drizzle/NNNN_*.sql`
|
||||||
4. Commit the migration file along with your schema change
|
4. Verify multi-statement migrations use `--> statement-breakpoint` between statements (required by better-sqlite3 which only allows one statement per `prepare()` call)
|
||||||
|
5. Commit the migration file, snapshot, and journal update together
|
||||||
|
|
||||||
|
### Important: statement breakpoints
|
||||||
|
|
||||||
|
better-sqlite3 rejects SQL with multiple statements in a single `prepare()` call. Drizzle-kit splits on `--> statement-breakpoint`. If you hand-write or edit a migration with multiple statements, append `--> statement-breakpoint` to the end of each statement line (before the next statement):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE foo ADD COLUMN bar TEXT;--> statement-breakpoint
|
||||||
|
CREATE INDEX foo_bar_idx ON foo(bar);
|
||||||
|
```
|
||||||
|
|
||||||
### Applying migrations
|
### Applying migrations
|
||||||
|
|
||||||
@@ -31,20 +43,15 @@ Migrations are applied automatically on server startup. No manual step needed.
|
|||||||
|
|
||||||
For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`.
|
For tests, the same `ensureSchema()` function is called on in-memory SQLite databases in `apps/server/db/repositories/drizzle/test-helpers.ts`.
|
||||||
|
|
||||||
### Checking migration status
|
## History
|
||||||
|
|
||||||
```bash
|
Migrations 0000–0007 were generated by `drizzle-kit generate`. Migrations 0008–0032 were hand-written (the snapshots fell out of sync). A schema-derived snapshot was restored at 0032, so `drizzle-kit generate` works normally from that point forward.
|
||||||
# See what drizzle-kit would generate (dry run)
|
|
||||||
npx drizzle-kit generate --dry-run
|
|
||||||
|
|
||||||
# Open drizzle studio to inspect the database
|
|
||||||
npx drizzle-kit studio
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Never hand-write migration SQL.** Always use `drizzle-kit generate` from the schema.
|
- **Use `drizzle-kit generate`** for new migrations. It reads schema.ts, diffs against the last snapshot, and generates both SQL + snapshot automatically.
|
||||||
- **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this.
|
- **Never use raw CREATE TABLE statements** for schema initialization. The migration system handles this.
|
||||||
- **Always commit migration files.** They are the source of truth for database evolution.
|
- **Always commit migration files.** They are the source of truth for database evolution.
|
||||||
- **Migration files are immutable.** Once committed, never edit them. Make a new migration instead.
|
- **Migration files are immutable.** Once committed, never edit them. Make a new migration instead.
|
||||||
- **Test with `npx vitest run`** after generating migrations to verify they work with in-memory databases.
|
- **Keep schema.ts in sync.** The schema file is the source of truth for TypeScript types; migrations are the source of truth for database DDL. Both must reflect the same structure.
|
||||||
|
- **Test with `npm test`** after generating migrations to verify they work with in-memory databases.
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
|
|||||||
| initiativeId | text FK → initiatives (cascade) | |
|
| initiativeId | text FK → initiatives (cascade) | |
|
||||||
| name | text NOT NULL | |
|
| name | text NOT NULL | |
|
||||||
| content | text nullable | Tiptap JSON |
|
| content | text nullable | Tiptap JSON |
|
||||||
| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' |
|
| status | text enum | 'pending' \| 'approved' \| 'in_progress' \| 'completed' \| 'blocked' \| 'pending_review' |
|
||||||
|
| mergeBase | text nullable | git merge-base hash stored before phase merge, enables diff reconstruction for completed phases |
|
||||||
| createdAt, updatedAt | integer/timestamp | |
|
| createdAt, updatedAt | integer/timestamp | |
|
||||||
|
|
||||||
### phase_dependencies
|
### phase_dependencies
|
||||||
@@ -44,7 +45,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
|
|||||||
| parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy |
|
| parentTaskId | text nullable self-ref FK (cascade) | decomposition hierarchy |
|
||||||
| name | text NOT NULL | |
|
| name | text NOT NULL | |
|
||||||
| description | text nullable | |
|
| description | text nullable | |
|
||||||
| type | text enum | 'auto' \| 'checkpoint:human-verify' \| 'checkpoint:decision' \| 'checkpoint:human-action' |
|
| type | text enum | 'auto' |
|
||||||
| category | text enum | 'execute' \| 'research' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' \| 'verify' \| 'merge' \| 'review' |
|
| category | text enum | 'execute' \| 'research' \| 'discuss' \| 'plan' \| 'detail' \| 'refine' \| 'verify' \| 'merge' \| 'review' |
|
||||||
| priority | text enum | 'low' \| 'medium' \| 'high' |
|
| priority | text enum | 'low' \| 'medium' \| 'high' |
|
||||||
| status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
|
| status | text enum | 'pending' \| 'in_progress' \| 'completed' \| 'blocked' |
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
|
- **Adapter**: `TypedEventBus` using Node.js `EventEmitter`
|
||||||
- All events implement `BaseEvent { type, timestamp, payload }`
|
- All events implement `BaseEvent { type, timestamp, payload }`
|
||||||
|
|
||||||
### Event Types (57)
|
### Event Types (58)
|
||||||
|
|
||||||
| Category | Events | Count |
|
| Category | Events | Count |
|
||||||
|----------|--------|-------|
|
|----------|--------|-------|
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
| **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 |
|
| **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 |
|
||||||
| **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` |
|
| **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` |
|
||||||
| **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events |
|
| **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events |
|
||||||
| **Initiative** | `initiative:pending_review`, `initiative:review_approved` | 2 | Initiative-level review gate events |
|
| **Initiative** | `initiative:pending_review`, `initiative:review_approved`, `initiative:changes_requested` | 3 | Initiative-level review gate events |
|
||||||
| **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` |
|
| **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` |
|
||||||
| **Log** | `log:entry` | 1 |
|
| **Log** | `log:entry` | 1 |
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@ PhaseChangesRequestedEvent { phaseId, initiativeId, taskId, commentCount }
|
|||||||
AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? }
|
AccountCredentialsRefreshedEvent { accountId, expiresAt, previousExpiresAt? }
|
||||||
InitiativePendingReviewEvent { initiativeId, branch }
|
InitiativePendingReviewEvent { initiativeId, branch }
|
||||||
InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | 'merge_and_push' }
|
InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' | 'merge_and_push' }
|
||||||
|
InitiativeChangesRequestedEvent { initiativeId, phaseId, taskId }
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task Dispatch
|
## Task Dispatch
|
||||||
@@ -64,10 +65,10 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
|
|||||||
2. **Dispatch** — `dispatchNext()` finds highest-priority task with all deps complete
|
2. **Dispatch** — `dispatchNext()` finds highest-priority task with all deps complete
|
||||||
3. **Context gathering** — Before spawn, `dispatchNext()` gathers initiative context (initiative, phase, tasks, pages) and passes as `inputContext` to the agent. Agents receive `.cw/input/task.md`, `initiative.md`, `phase.md`, `context/tasks/`, `context/phases/`, and `pages/`.
|
3. **Context gathering** — Before spawn, `dispatchNext()` gathers initiative context (initiative, phase, tasks, pages) and passes as `inputContext` to the agent. Agents receive `.cw/input/task.md`, `initiative.md`, `phase.md`, `context/tasks/`, `context/phases/`, and `pages/`.
|
||||||
4. **Priority order**: high > medium > low, then oldest first (FIFO within priority)
|
4. **Priority order**: high > medium > low, then oldest first (FIFO within priority)
|
||||||
5. **Checkpoint skip** — Tasks with type starting with `checkpoint:` skip auto-dispatch
|
5. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow
|
||||||
6. **Planning skip** — Planning-category tasks (research, discuss, plan, detail, refine) skip auto-dispatch — they use the architect flow
|
|
||||||
7. **Summary propagation** — `completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter.
|
7. **Summary propagation** — `completeTask()` reads the completing agent's `result.message` and stores it on the task's `summary` column. Dependent tasks see this summary in `context/tasks/<id>.md` frontmatter.
|
||||||
8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing.
|
8. **Spawn failure** — If `agentManager.spawn()` throws, the task is blocked via `blockTask()` with the error message. The dispatch cycle continues instead of crashing.
|
||||||
|
9. **Retry blocked** — `retryBlockedTask(taskId)` resets a blocked task to pending and re-queues it. Exposed via tRPC `retryBlockedTask` mutation. The UI shows a Retry button in the task slide-over when status is `blocked`.
|
||||||
|
|
||||||
### DispatchManager Methods
|
### DispatchManager Methods
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
|
|||||||
| `getNextDispatchable()` | Get next task without dispatching |
|
| `getNextDispatchable()` | Get next task without dispatching |
|
||||||
| `completeTask(taskId, agentId?)` | Complete task |
|
| `completeTask(taskId, agentId?)` | Complete task |
|
||||||
| `blockTask(taskId, reason)` | Block task with reason |
|
| `blockTask(taskId, reason)` | Block task with reason |
|
||||||
|
| `retryBlockedTask(taskId)` | Reset blocked task to pending and re-queue |
|
||||||
| `getQueueState()` | Return queued, ready, blocked tasks |
|
| `getQueueState()` | Return queued, ready, blocked tasks |
|
||||||
|
|
||||||
## Phase Dispatch
|
## Phase Dispatch
|
||||||
@@ -111,7 +113,7 @@ InitiativeReviewApprovedEvent { initiativeId, branch, strategy: 'push_branch' |
|
|||||||
|-------|--------|
|
|-------|--------|
|
||||||
| `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents |
|
| `phase:queued` | Dispatch ready phases → dispatch their tasks to idle agents |
|
||||||
| `agent:stopped` | Re-dispatch queued tasks (freed agent slot) |
|
| `agent:stopped` | Re-dispatch queued tasks (freed agent slot) |
|
||||||
| `task:completed` | Merge task branch, then dispatch next queued task |
|
| `task:completed` | Merge task branch (if branch exists), check phase completion, dispatch next queued task |
|
||||||
|
|
||||||
### Coalesced Scheduling
|
### Coalesced Scheduling
|
||||||
|
|
||||||
@@ -119,6 +121,8 @@ Multiple rapid events (e.g. several `phase:queued` from `queueAllPhases`) are co
|
|||||||
|
|
||||||
### Execution Mode Behavior
|
### Execution Mode Behavior
|
||||||
|
|
||||||
- **YOLO**: phase completes → auto-merge → auto-dispatch next phase → auto-dispatch tasks
|
- **YOLO**: phase completes → auto-merge (if branch exists, skipped otherwise) → auto-dispatch next phase → auto-dispatch tasks
|
||||||
- **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks
|
- **review_per_phase**: phase completes → set `pending_review` → STOP. User approves → `approveAndMergePhase()` → merge → dispatch next phase → dispatch tasks
|
||||||
- **request-changes**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`) → phase reset to `in_progress` → agent fixes → phase returns to `pending_review`
|
- **No branch**: Phase completion check runs regardless of branch existence. Merge steps are skipped; status transitions still fire. `updateTaskStatus` tRPC routes completions through `dispatchManager.completeTask()` to emit `task:completed`.
|
||||||
|
- **request-changes (phase)**: phase `pending_review` → user requests changes → creates revision task (category=`'review'`, dedup guard skips if active review exists) with threaded comments (`[comment:ID]` tags + reply threads) → phase reset to `in_progress` → agent reads comments, fixes code, writes `.cw/output/comment-responses.json` → OutputHandler creates reply comments and optionally resolves threads → phase returns to `pending_review`
|
||||||
|
- **request-changes (initiative)**: initiative `pending_review` → user requests changes → creates/reuses "Finalization" phase → creates review task → initiative reset to `active` → agent fixes → initiative returns to `pending_review`
|
||||||
|
|||||||
@@ -111,10 +111,12 @@ The initiative detail page has three tabs managed via local state (not URL param
|
|||||||
### Review Components (`src/components/review/`)
|
### Review Components (`src/components/review/`)
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview |
|
| `ReviewTab` | Review tab container — orchestrates header, diff, sidebar, and preview. Phase-level review has threaded inline comments (with reply support) + Request Changes; initiative-level review has Request Changes (summary prompt) + Push Branch / Merge & Push |
|
||||||
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
|
| `ReviewHeader` | Consolidated toolbar: phase selector pills, branch info, stats, preview controls, approve/reject actions |
|
||||||
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, comment counts, and commit navigation |
|
| `ReviewSidebar` | VSCode-style icon strip (Files/Commits views) with file list, root-only comment counts, and commit navigation |
|
||||||
| `DiffViewer` | Unified diff renderer with inline comments |
|
| `DiffViewer` | Unified diff renderer with threaded inline comments (root + reply threads) |
|
||||||
|
| `CommentThread` | Renders root comment with resolve/reopen + nested reply threads (agent replies styled with primary border). Inline reply form |
|
||||||
|
| `ConflictResolutionPanel` | Merge conflict detection + agent resolution in initiative review. Shows conflict files, spawns conflict agent, inline questions, re-check on completion |
|
||||||
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
| `PreviewPanel` | Docker preview status: building/running/failed with start/stop (legacy, now integrated into ReviewHeader) |
|
||||||
| `ProposalCard` | Individual proposal display |
|
| `ProposalCard` | Individual proposal display |
|
||||||
|
|
||||||
@@ -126,6 +128,7 @@ shadcn/ui components: badge (6 status variants + xs size), button, card, dialog,
|
|||||||
| Hook | Purpose |
|
| Hook | Purpose |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
|
| `useRefineAgent` | Manages refine agent lifecycle for initiative |
|
||||||
|
| `useConflictAgent` | Manages conflict resolution agent lifecycle for initiative review |
|
||||||
| `useDetailAgent` | Manages detail agent for phase planning |
|
| `useDetailAgent` | Manages detail agent for phase planning |
|
||||||
| `useAgentOutput` | Subscribes to live agent output stream |
|
| `useAgentOutput` | Subscribes to live agent output stream |
|
||||||
| `useChatSession` | Manages chat session for phase/task refinement |
|
| `useChatSession` | Manages chat session for phase/task refinement |
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
|
|||||||
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |
|
| `remoteBranchExists(repoPath, branch)` | Check remote tracking branches (`origin/<branch>`) |
|
||||||
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
|
| `listCommits(repoPath, base, head)` | List commits head has that base doesn't (with stats) |
|
||||||
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
|
| `diffCommit(repoPath, commitHash)` | Get unified diff for a single commit |
|
||||||
|
| `getMergeBase(repoPath, branch1, branch2)` | Get common ancestor commit hash |
|
||||||
|
| `pushBranch(repoPath, branch, remote?)` | Push branch to remote (default: 'origin') |
|
||||||
|
| `checkMergeability(repoPath, source, target)` | Dry-run merge check via `git merge-tree --write-tree` (git 2.38+). Returns `{ mergeable, conflicts? }` with no side effects |
|
||||||
|
|
||||||
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
|
`remoteBranchExists` is used by `registerProject` and `updateProject` to validate that a project's default branch actually exists in the cloned repository before saving.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| getAgentQuestions | query | Pending questions |
|
| getAgentQuestions | query | Pending questions |
|
||||||
| getAgentOutput | query | Full output from DB log chunks |
|
| getAgentOutput | query | Full output from DB log chunks |
|
||||||
| getActiveRefineAgent | query | Active refine agent for initiative |
|
| getActiveRefineAgent | query | Active refine agent for initiative |
|
||||||
|
| getActiveConflictAgent | query | Active conflict resolution agent for initiative (name starts with `conflict-`) |
|
||||||
| listWaitingAgents | query | Agents waiting for input |
|
| listWaitingAgents | query | Agents waiting for input |
|
||||||
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
|
| onAgentOutput | subscription | Live raw JSONL output stream via EventBus |
|
||||||
|
|
||||||
@@ -95,6 +96,9 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| getInitiativeReviewCommits | query | Commits on initiative branch not on default branch |
|
| getInitiativeReviewCommits | query | Commits on initiative branch not on default branch |
|
||||||
| getInitiativeCommitDiff | query | Single commit diff for initiative review |
|
| getInitiativeCommitDiff | query | Single commit diff for initiative review |
|
||||||
| approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` |
|
| approveInitiativeReview | mutation | Approve initiative review: `{initiativeId, strategy: 'push_branch' \| 'merge_and_push'}` |
|
||||||
|
| requestInitiativeChanges | mutation | Request changes on initiative: `{initiativeId, summary}` → creates review task in Finalization phase, resets initiative to active |
|
||||||
|
| checkInitiativeMergeability | query | Dry-run merge check: `{initiativeId}` → `{mergeable, conflictFiles[], targetBranch}` |
|
||||||
|
| spawnConflictResolutionAgent | mutation | Spawn agent to resolve merge conflicts: `{initiativeId, provider?}` → auto-dismisses stale conflict agents, creates merge task |
|
||||||
|
|
||||||
### Phases
|
### Phases
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
@@ -115,11 +119,12 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| getPhaseReviewCommits | query | List commits between initiative and phase branch |
|
| getPhaseReviewCommits | query | List commits between initiative and phase branch |
|
||||||
| getCommitDiff | query | Diff for a single commit (by hash) in a phase |
|
| getCommitDiff | query | Diff for a single commit (by hash) in a phase |
|
||||||
| approvePhaseReview | mutation | Approve and merge phase branch |
|
| approvePhaseReview | mutation | Approve and merge phase branch |
|
||||||
| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved comments, resets phase to in_progress |
|
| requestPhaseChanges | mutation | Request changes: creates revision task from unresolved threaded comments (with `[comment:ID]` tags and reply threads), resets phase to in_progress |
|
||||||
| listReviewComments | query | List review comments by phaseId |
|
| listReviewComments | query | List review comments by phaseId (flat list including replies, frontend groups by parentCommentId) |
|
||||||
| createReviewComment | mutation | Create inline review comment on diff |
|
| createReviewComment | mutation | Create inline review comment on diff |
|
||||||
| resolveReviewComment | mutation | Mark review comment as resolved |
|
| resolveReviewComment | mutation | Mark review comment as resolved |
|
||||||
| unresolveReviewComment | mutation | Mark review comment as unresolved |
|
| unresolveReviewComment | mutation | Mark review comment as unresolved |
|
||||||
|
| replyToReviewComment | mutation | Create a threaded reply to an existing review comment (copies parent's phaseId/filePath/lineNumber) |
|
||||||
|
|
||||||
### Phase Dispatch
|
### Phase Dispatch
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
@@ -190,6 +195,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| updateAccountAuth | mutation | Update credentials |
|
| updateAccountAuth | mutation | Update credentials |
|
||||||
| markAccountExhausted | mutation | Set exhaustion timer |
|
| markAccountExhausted | mutation | Set exhaustion timer |
|
||||||
| listProviderNames | query | Available provider names |
|
| listProviderNames | query | Available provider names |
|
||||||
|
| addAccountByToken | mutation | Upsert account by email + raw OAuth token |
|
||||||
|
|
||||||
### Proposals
|
### Proposals
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
@@ -204,13 +210,13 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| Procedure | Type | Events |
|
| Procedure | Type | Events |
|
||||||
|-----------|------|--------|
|
|-----------|------|--------|
|
||||||
| onEvent | subscription | All event types |
|
| onEvent | subscription | All event types |
|
||||||
| onAgentUpdate | subscription | agent:* events (8 types) |
|
| onAgentUpdate | subscription | agent:* events (7 types, excludes agent:output) |
|
||||||
| onTaskUpdate | subscription | task:* + phase:* events (8 types) |
|
| onTaskUpdate | subscription | task:* + phase:* events (8 types) |
|
||||||
| onPageUpdate | subscription | page:created/updated/deleted |
|
| onPageUpdate | subscription | page:created/updated/deleted |
|
||||||
| onPreviewUpdate | subscription | preview:building/ready/stopped/failed |
|
| onPreviewUpdate | subscription | preview:building/ready/stopped/failed |
|
||||||
| onConversationUpdate | subscription | conversation:created/answered |
|
| onConversationUpdate | subscription | conversation:created/answered |
|
||||||
|
|
||||||
Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat.
|
Subscriptions use `eventBusIterable()` — queue-based async generator, max 1000 events, 30s heartbeat. `agent:output` is excluded from all general subscriptions (it's high-frequency streaming data); use the dedicated `onAgentOutput` subscription instead.
|
||||||
|
|
||||||
## Coordination Module
|
## Coordination Module
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user