feat: Add remote sync for project clones
Fetch remote changes before agents start working so they build on up-to-date code. Adds ProjectSyncManager with git fetch + ff-only merge of defaultBranch, integrated into phase dispatch to sync before branch creation. - Schema: lastFetchedAt column on projects table (migration 0029) - Events: project:synced, project:sync_failed - Phase dispatch: sync all linked projects before creating branches - tRPC: syncProject, syncAllProjects, getProjectSyncStatus - CLI: cw project sync [name] --all, cw project status [name] - Frontend: sync button + ahead/behind badge on projects settings
This commit is contained in:
@@ -132,7 +132,7 @@ describe('ProcessManager', () => {
|
|||||||
|
|
||||||
// Mock project repository
|
// Mock project repository
|
||||||
vi.mocked(mockProjectRepository.findProjectsByInitiativeId).mockResolvedValue([
|
vi.mocked(mockProjectRepository.findProjectsByInitiativeId).mockResolvedValue([
|
||||||
{ id: '1', name: 'project1', url: 'https://github.com/user/project1.git', defaultBranch: 'main', createdAt: new Date(), updatedAt: new Date() }
|
{ id: '1', name: 'project1', url: 'https://github.com/user/project1.git', defaultBranch: 'main', lastFetchedAt: null, createdAt: new Date(), updatedAt: new Date() }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Mock existsSync to return true for worktree paths
|
// Mock existsSync to return true for worktree paths
|
||||||
|
|||||||
@@ -1080,6 +1080,76 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): Com
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// cw project sync [name] --all
|
||||||
|
projectCommand
|
||||||
|
.command('sync [name]')
|
||||||
|
.description('Sync project clone(s) from remote')
|
||||||
|
.option('--all', 'Sync all projects')
|
||||||
|
.action(async (name: string | undefined, options: { all?: boolean }) => {
|
||||||
|
try {
|
||||||
|
const client = createDefaultTrpcClient();
|
||||||
|
if (options.all) {
|
||||||
|
const results = await client.syncAllProjects.mutate();
|
||||||
|
for (const r of results) {
|
||||||
|
const status = r.success ? 'ok' : `FAILED: ${r.error}`;
|
||||||
|
console.log(`${r.projectName}: ${status}`);
|
||||||
|
}
|
||||||
|
} else if (name) {
|
||||||
|
const projects = await client.listProjects.query();
|
||||||
|
const project = projects.find((p) => p.name === name || p.id === name);
|
||||||
|
if (!project) {
|
||||||
|
console.error(`Project not found: ${name}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const result = await client.syncProject.mutate({ id: project.id });
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`Synced ${result.projectName}: fetched=${result.fetched}, fast-forwarded=${result.fastForwarded}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Sync failed: ${result.error}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Specify a project name or use --all');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync:', (error as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// cw project status [name]
|
||||||
|
projectCommand
|
||||||
|
.command('status [name]')
|
||||||
|
.description('Show sync status for a project')
|
||||||
|
.action(async (name: string | undefined) => {
|
||||||
|
try {
|
||||||
|
const client = createDefaultTrpcClient();
|
||||||
|
const projects = await client.listProjects.query();
|
||||||
|
const targets = name
|
||||||
|
? projects.filter((p) => p.name === name || p.id === name)
|
||||||
|
: projects;
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
console.log(name ? `Project not found: ${name}` : 'No projects registered');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const project of targets) {
|
||||||
|
const status = await client.getProjectSyncStatus.query({ id: project.id });
|
||||||
|
const fetchedStr = status.lastFetchedAt
|
||||||
|
? new Date(status.lastFetchedAt).toLocaleString()
|
||||||
|
: 'never';
|
||||||
|
console.log(`${project.name}:`);
|
||||||
|
console.log(` Last fetched: ${fetchedStr}`);
|
||||||
|
console.log(` Ahead: ${status.ahead} Behind: ${status.behind}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get status:', (error as Error).message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Account command group
|
// Account command group
|
||||||
const accountCommand = program
|
const accountCommand = program
|
||||||
.command('account')
|
.command('account')
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { DefaultPhaseDispatchManager } from './dispatch/phase-manager.js';
|
|||||||
import type { DispatchManager, PhaseDispatchManager } from './dispatch/types.js';
|
import type { DispatchManager, PhaseDispatchManager } from './dispatch/types.js';
|
||||||
import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js';
|
import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js';
|
||||||
import type { BranchManager } from './git/branch-manager.js';
|
import type { BranchManager } from './git/branch-manager.js';
|
||||||
|
import { ProjectSyncManager } from './git/remote-sync.js';
|
||||||
import { ExecutionOrchestrator } from './execution/orchestrator.js';
|
import { ExecutionOrchestrator } from './execution/orchestrator.js';
|
||||||
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
|
import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js';
|
||||||
import { PreviewManager } from './preview/index.js';
|
import { PreviewManager } from './preview/index.js';
|
||||||
@@ -118,6 +119,7 @@ export interface Container extends Repositories {
|
|||||||
dispatchManager: DispatchManager;
|
dispatchManager: DispatchManager;
|
||||||
phaseDispatchManager: PhaseDispatchManager;
|
phaseDispatchManager: PhaseDispatchManager;
|
||||||
branchManager: BranchManager;
|
branchManager: BranchManager;
|
||||||
|
projectSyncManager: ProjectSyncManager;
|
||||||
executionOrchestrator: ExecutionOrchestrator;
|
executionOrchestrator: ExecutionOrchestrator;
|
||||||
previewManager: PreviewManager;
|
previewManager: PreviewManager;
|
||||||
|
|
||||||
@@ -192,6 +194,14 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
const branchManager = new SimpleGitBranchManager();
|
const branchManager = new SimpleGitBranchManager();
|
||||||
log.info('branch manager created');
|
log.info('branch manager created');
|
||||||
|
|
||||||
|
// Project sync manager
|
||||||
|
const projectSyncManager = new ProjectSyncManager(
|
||||||
|
repos.projectRepository,
|
||||||
|
workspaceRoot,
|
||||||
|
eventBus,
|
||||||
|
);
|
||||||
|
log.info('project sync manager created');
|
||||||
|
|
||||||
// Dispatch managers
|
// Dispatch managers
|
||||||
const dispatchManager = new DefaultDispatchManager(
|
const dispatchManager = new DefaultDispatchManager(
|
||||||
repos.taskRepository,
|
repos.taskRepository,
|
||||||
@@ -212,6 +222,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
repos.projectRepository,
|
repos.projectRepository,
|
||||||
branchManager,
|
branchManager,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
|
projectSyncManager,
|
||||||
);
|
);
|
||||||
log.info('dispatch managers created');
|
log.info('dispatch managers created');
|
||||||
|
|
||||||
@@ -258,6 +269,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
dispatchManager,
|
dispatchManager,
|
||||||
phaseDispatchManager,
|
phaseDispatchManager,
|
||||||
branchManager,
|
branchManager,
|
||||||
|
projectSyncManager,
|
||||||
executionOrchestrator,
|
executionOrchestrator,
|
||||||
previewManager,
|
previewManager,
|
||||||
...repos,
|
...repos,
|
||||||
@@ -269,6 +281,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
|
|||||||
dispatchManager,
|
dispatchManager,
|
||||||
phaseDispatchManager,
|
phaseDispatchManager,
|
||||||
branchManager,
|
branchManager,
|
||||||
|
projectSyncManager,
|
||||||
executionOrchestrator,
|
executionOrchestrator,
|
||||||
previewManager,
|
previewManager,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
|
|||||||
@@ -453,6 +453,7 @@ export const projects = sqliteTable('projects', {
|
|||||||
name: text('name').notNull().unique(),
|
name: text('name').notNull().unique(),
|
||||||
url: text('url').notNull().unique(),
|
url: text('url').notNull().unique(),
|
||||||
defaultBranch: text('default_branch').notNull().default('main'),
|
defaultBranch: text('default_branch').notNull().default('main'),
|
||||||
|
lastFetchedAt: integer('last_fetched_at', { mode: 'timestamp' }),
|
||||||
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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { BranchManager } from '../git/branch-manager.js';
|
|||||||
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
|
||||||
import { phaseBranchName, isPlanningCategory } from '../git/branch-naming.js';
|
import { phaseBranchName, isPlanningCategory } from '../git/branch-naming.js';
|
||||||
import { ensureProjectClone } from '../git/project-clones.js';
|
import { ensureProjectClone } from '../git/project-clones.js';
|
||||||
|
import type { ProjectSyncManager } from '../git/remote-sync.js';
|
||||||
import { createModuleLogger } from '../logger/index.js';
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
|
|
||||||
const log = createModuleLogger('phase-dispatch');
|
const log = createModuleLogger('phase-dispatch');
|
||||||
@@ -64,6 +65,7 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
|||||||
private projectRepository?: ProjectRepository,
|
private projectRepository?: ProjectRepository,
|
||||||
private branchManager?: BranchManager,
|
private branchManager?: BranchManager,
|
||||||
private workspaceRoot?: string,
|
private workspaceRoot?: string,
|
||||||
|
private projectSyncManager?: ProjectSyncManager,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +178,24 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
|
|||||||
const initBranch = initiative.branch;
|
const initBranch = initiative.branch;
|
||||||
const phBranch = phaseBranchName(initBranch, phase.name);
|
const phBranch = phaseBranchName(initBranch, phase.name);
|
||||||
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
|
||||||
|
|
||||||
|
// 1. Sync project clones (git fetch + ff-only defaultBranch) before branching
|
||||||
|
if (this.projectSyncManager) {
|
||||||
|
for (const project of projects) {
|
||||||
|
try {
|
||||||
|
const result = await this.projectSyncManager.syncProject(project.id);
|
||||||
|
if (result.success) {
|
||||||
|
log.info({ project: project.name, fetched: result.fetched, ff: result.fastForwarded }, 'synced before phase dispatch');
|
||||||
|
} else {
|
||||||
|
log.warn({ project: project.name, err: result.error }, 'sync failed before phase dispatch (continuing)');
|
||||||
|
}
|
||||||
|
} catch (syncErr) {
|
||||||
|
log.warn({ project: project.name, err: syncErr instanceof Error ? syncErr.message : String(syncErr) }, 'sync error (continuing)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create initiative and phase branches from (now up-to-date) defaultBranch
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch);
|
await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch);
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ CREATE TABLE IF NOT EXISTS review_comments (
|
|||||||
resolved INTEGER NOT NULL DEFAULT 0,
|
resolved INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
);
|
);--> statement-breakpoint
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS review_comments_phase_id_idx ON review_comments (phase_id);
|
CREATE INDEX IF NOT EXISTS review_comments_phase_id_idx ON review_comments (phase_id);
|
||||||
|
|||||||
1
apps/server/drizzle/0029_add_project_last_fetched_at.sql
Normal file
1
apps/server/drizzle/0029_add_project_last_fetched_at.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE projects ADD COLUMN last_fetched_at INTEGER;
|
||||||
@@ -197,6 +197,20 @@
|
|||||||
"when": 1771891200000,
|
"when": 1771891200000,
|
||||||
"tag": "0027_add_chat_sessions",
|
"tag": "0027_add_chat_sessions",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 28,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1771977600000,
|
||||||
|
"tag": "0028_add_review_comments",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 29,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772064000000,
|
||||||
|
"tag": "0029_add_project_last_fetched_at",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -553,6 +553,29 @@ export interface ConversationAnsweredEvent extends DomainEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project Sync Events
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProjectSyncedEvent extends DomainEvent {
|
||||||
|
type: 'project:synced';
|
||||||
|
payload: {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
fetched: boolean;
|
||||||
|
fastForwarded: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSyncFailedEvent extends DomainEvent {
|
||||||
|
type: 'project:sync_failed';
|
||||||
|
payload: {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat Session Events
|
* Chat Session Events
|
||||||
*/
|
*/
|
||||||
@@ -626,6 +649,8 @@ export type DomainEventMap =
|
|||||||
| PreviewFailedEvent
|
| PreviewFailedEvent
|
||||||
| ConversationCreatedEvent
|
| ConversationCreatedEvent
|
||||||
| ConversationAnsweredEvent
|
| ConversationAnsweredEvent
|
||||||
|
| ProjectSyncedEvent
|
||||||
|
| ProjectSyncFailedEvent
|
||||||
| ChatMessageCreatedEvent
|
| ChatMessageCreatedEvent
|
||||||
| ChatSessionClosedEvent;
|
| ChatSessionClosedEvent;
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,7 @@ export { SimpleGitWorktreeManager } from './manager.js';
|
|||||||
// Utilities
|
// Utilities
|
||||||
export { cloneProject } from './clone.js';
|
export { cloneProject } from './clone.js';
|
||||||
export { ensureProjectClone, getProjectCloneDir } from './project-clones.js';
|
export { ensureProjectClone, getProjectCloneDir } from './project-clones.js';
|
||||||
|
|
||||||
|
// Remote sync
|
||||||
|
export { ProjectSyncManager } from './remote-sync.js';
|
||||||
|
export type { SyncResult, SyncStatus, BranchDivergence } from './remote-sync.js';
|
||||||
|
|||||||
223
apps/server/git/remote-sync.ts
Normal file
223
apps/server/git/remote-sync.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
/**
|
||||||
|
* Project Sync Manager
|
||||||
|
*
|
||||||
|
* Fetches remote changes for project clones and optionally fast-forwards
|
||||||
|
* the local defaultBranch. Safe to run with active worktrees — only
|
||||||
|
* updates remote-tracking refs and the base branch (never checked out
|
||||||
|
* directly by any worktree).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { simpleGit } from 'simple-git';
|
||||||
|
import type { ProjectRepository } from '../db/repositories/project-repository.js';
|
||||||
|
import type { EventBus, ProjectSyncedEvent, ProjectSyncFailedEvent } from '../events/types.js';
|
||||||
|
import { ensureProjectClone } from './project-clones.js';
|
||||||
|
import { createModuleLogger } from '../logger/index.js';
|
||||||
|
|
||||||
|
const log = createModuleLogger('remote-sync');
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export interface SyncResult {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
success: boolean;
|
||||||
|
fetched: boolean;
|
||||||
|
fastForwarded: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncStatus {
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
lastFetchedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BranchDivergence {
|
||||||
|
ahead: number;
|
||||||
|
behind: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// ProjectSyncManager
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export class ProjectSyncManager {
|
||||||
|
constructor(
|
||||||
|
private projectRepository: ProjectRepository,
|
||||||
|
private workspaceRoot: string,
|
||||||
|
private eventBus?: EventBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync a single project: git fetch + ff-only merge of defaultBranch.
|
||||||
|
*/
|
||||||
|
async syncProject(projectId: string): Promise<SyncResult> {
|
||||||
|
const project = await this.projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return {
|
||||||
|
projectId,
|
||||||
|
projectName: 'unknown',
|
||||||
|
success: false,
|
||||||
|
fetched: false,
|
||||||
|
fastForwarded: false,
|
||||||
|
error: `Project not found: ${projectId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: SyncResult = {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
success: false,
|
||||||
|
fetched: false,
|
||||||
|
fastForwarded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
|
const git = simpleGit(clonePath);
|
||||||
|
|
||||||
|
// 1. git fetch origin
|
||||||
|
await git.fetch('origin');
|
||||||
|
result.fetched = true;
|
||||||
|
log.info({ project: project.name }, 'fetched remote');
|
||||||
|
|
||||||
|
// 2. Fast-forward defaultBranch to origin/<defaultBranch>
|
||||||
|
const remoteBranch = `origin/${project.defaultBranch}`;
|
||||||
|
try {
|
||||||
|
await git.raw(['merge', '--ff-only', remoteBranch, project.defaultBranch]);
|
||||||
|
result.fastForwarded = true;
|
||||||
|
log.info({ project: project.name, branch: project.defaultBranch }, 'fast-forwarded default branch');
|
||||||
|
} catch (ffErr) {
|
||||||
|
// ff-only failed — local branch has diverged or doesn't exist yet.
|
||||||
|
// This is non-fatal; fetch alone still updated remote-tracking refs.
|
||||||
|
log.warn(
|
||||||
|
{ project: project.name, err: (ffErr as Error).message },
|
||||||
|
'fast-forward failed (non-fatal)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update lastFetchedAt
|
||||||
|
await this.projectRepository.update(project.id, { lastFetchedAt: new Date() });
|
||||||
|
|
||||||
|
result.success = true;
|
||||||
|
|
||||||
|
// 4. Emit event
|
||||||
|
if (this.eventBus) {
|
||||||
|
const event: ProjectSyncedEvent = {
|
||||||
|
type: 'project:synced',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
fetched: result.fetched,
|
||||||
|
fastForwarded: result.fastForwarded,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.eventBus.emit(event);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
result.error = (err as Error).message;
|
||||||
|
log.error({ project: project.name, err: result.error }, 'sync failed');
|
||||||
|
|
||||||
|
if (this.eventBus) {
|
||||||
|
const event: ProjectSyncFailedEvent = {
|
||||||
|
type: 'project:sync_failed',
|
||||||
|
timestamp: new Date(),
|
||||||
|
payload: {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
error: result.error,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.eventBus.emit(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync all registered projects.
|
||||||
|
*/
|
||||||
|
async syncAllProjects(): Promise<SyncResult[]> {
|
||||||
|
const projects = await this.projectRepository.findAll();
|
||||||
|
const results: SyncResult[] = [];
|
||||||
|
for (const project of projects) {
|
||||||
|
results.push(await this.syncProject(project.id));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sync status for a project: ahead/behind vs origin and lastFetchedAt.
|
||||||
|
*/
|
||||||
|
async getSyncStatus(projectId: string): Promise<SyncStatus> {
|
||||||
|
const project = await this.projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return { ahead: 0, behind: 0, lastFetchedAt: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
|
const git = simpleGit(clonePath);
|
||||||
|
const { ahead, behind } = await this.revListCount(
|
||||||
|
git,
|
||||||
|
`origin/${project.defaultBranch}`,
|
||||||
|
project.defaultBranch,
|
||||||
|
);
|
||||||
|
return { ahead, behind, lastFetchedAt: project.lastFetchedAt };
|
||||||
|
} catch {
|
||||||
|
return { ahead: 0, behind: 0, lastFetchedAt: project.lastFetchedAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get divergence between defaultBranch and an initiative branch.
|
||||||
|
*/
|
||||||
|
async getInitiativeDivergence(
|
||||||
|
projectId: string,
|
||||||
|
initiativeBranch: string,
|
||||||
|
): Promise<BranchDivergence> {
|
||||||
|
const project = await this.projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return { ahead: 0, behind: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
|
||||||
|
const git = simpleGit(clonePath);
|
||||||
|
return this.revListCount(git, project.defaultBranch, initiativeBranch);
|
||||||
|
} catch {
|
||||||
|
return { ahead: 0, behind: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Private
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count commits ahead/behind between two refs using rev-list --left-right --count.
|
||||||
|
*/
|
||||||
|
private async revListCount(
|
||||||
|
git: ReturnType<typeof simpleGit>,
|
||||||
|
left: string,
|
||||||
|
right: string,
|
||||||
|
): Promise<{ ahead: number; behind: number }> {
|
||||||
|
try {
|
||||||
|
const raw = await git.raw([
|
||||||
|
'rev-list',
|
||||||
|
'--left-right',
|
||||||
|
'--count',
|
||||||
|
`${left}...${right}`,
|
||||||
|
]);
|
||||||
|
const [leftCount, rightCount] = raw.trim().split(/\s+/).map(Number);
|
||||||
|
// left-right: leftCount = commits in left not in right (behind), rightCount = commits in right not in left (ahead)
|
||||||
|
return { ahead: rightCount ?? 0, behind: leftCount ?? 0 };
|
||||||
|
} catch {
|
||||||
|
return { ahead: 0, behind: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ import type { CoordinationManager } from '../coordination/types.js';
|
|||||||
import type { BranchManager } from '../git/branch-manager.js';
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
||||||
import type { PreviewManager } from '../preview/index.js';
|
import type { PreviewManager } from '../preview/index.js';
|
||||||
|
import type { ProjectSyncManager } from '../git/remote-sync.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for creating the tRPC request handler.
|
* Options for creating the tRPC request handler.
|
||||||
@@ -79,6 +80,8 @@ export interface TrpcAdapterOptions {
|
|||||||
chatSessionRepository?: ChatSessionRepository;
|
chatSessionRepository?: ChatSessionRepository;
|
||||||
/** Review comment repository for inline review comments on phase diffs */
|
/** Review comment repository for inline review comments on phase diffs */
|
||||||
reviewCommentRepository?: ReviewCommentRepository;
|
reviewCommentRepository?: ReviewCommentRepository;
|
||||||
|
/** Project sync manager for remote fetch/sync operations */
|
||||||
|
projectSyncManager?: ProjectSyncManager;
|
||||||
/** Absolute path to the workspace root (.cwrc directory) */
|
/** Absolute path to the workspace root (.cwrc directory) */
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
@@ -162,6 +165,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) {
|
|||||||
conversationRepository: options.conversationRepository,
|
conversationRepository: options.conversationRepository,
|
||||||
chatSessionRepository: options.chatSessionRepository,
|
chatSessionRepository: options.chatSessionRepository,
|
||||||
reviewCommentRepository: options.reviewCommentRepository,
|
reviewCommentRepository: options.reviewCommentRepository,
|
||||||
|
projectSyncManager: options.projectSyncManager,
|
||||||
workspaceRoot: options.workspaceRoot,
|
workspaceRoot: options.workspaceRoot,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import type { CoordinationManager } from '../coordination/types.js';
|
|||||||
import type { BranchManager } from '../git/branch-manager.js';
|
import type { BranchManager } from '../git/branch-manager.js';
|
||||||
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
import type { ExecutionOrchestrator } from '../execution/orchestrator.js';
|
||||||
import type { PreviewManager } from '../preview/index.js';
|
import type { PreviewManager } from '../preview/index.js';
|
||||||
|
import type { ProjectSyncManager } from '../git/remote-sync.js';
|
||||||
|
|
||||||
// Re-export for convenience
|
// Re-export for convenience
|
||||||
export type { EventBus, DomainEvent };
|
export type { EventBus, DomainEvent };
|
||||||
@@ -79,6 +80,8 @@ export interface TRPCContext {
|
|||||||
chatSessionRepository?: ChatSessionRepository;
|
chatSessionRepository?: ChatSessionRepository;
|
||||||
/** Review comment repository for inline review comments on phase diffs */
|
/** Review comment repository for inline review comments on phase diffs */
|
||||||
reviewCommentRepository?: ReviewCommentRepository;
|
reviewCommentRepository?: ReviewCommentRepository;
|
||||||
|
/** Project sync manager for remote fetch/sync operations */
|
||||||
|
projectSyncManager?: ProjectSyncManager;
|
||||||
/** Absolute path to the workspace root (.cwrc directory) */
|
/** Absolute path to the workspace root (.cwrc directory) */
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,7 @@ export interface CreateContextOptions {
|
|||||||
conversationRepository?: ConversationRepository;
|
conversationRepository?: ConversationRepository;
|
||||||
chatSessionRepository?: ChatSessionRepository;
|
chatSessionRepository?: ChatSessionRepository;
|
||||||
reviewCommentRepository?: ReviewCommentRepository;
|
reviewCommentRepository?: ReviewCommentRepository;
|
||||||
|
projectSyncManager?: ProjectSyncManager;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +148,7 @@ export function createContext(options: CreateContextOptions): TRPCContext {
|
|||||||
conversationRepository: options.conversationRepository,
|
conversationRepository: options.conversationRepository,
|
||||||
chatSessionRepository: options.chatSessionRepository,
|
chatSessionRepository: options.chatSessionRepository,
|
||||||
reviewCommentRepository: options.reviewCommentRepository,
|
reviewCommentRepository: options.reviewCommentRepository,
|
||||||
|
projectSyncManager: options.projectSyncManager,
|
||||||
workspaceRoot: options.workspaceRoot,
|
workspaceRoot: options.workspaceRoot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { CoordinationManager } from '../../coordination/types.js';
|
|||||||
import type { BranchManager } from '../../git/branch-manager.js';
|
import type { BranchManager } from '../../git/branch-manager.js';
|
||||||
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
|
import type { ExecutionOrchestrator } from '../../execution/orchestrator.js';
|
||||||
import type { PreviewManager } from '../../preview/index.js';
|
import type { PreviewManager } from '../../preview/index.js';
|
||||||
|
import type { ProjectSyncManager } from '../../git/remote-sync.js';
|
||||||
|
|
||||||
export function requireAgentManager(ctx: TRPCContext) {
|
export function requireAgentManager(ctx: TRPCContext) {
|
||||||
if (!ctx.agentManager) {
|
if (!ctx.agentManager) {
|
||||||
@@ -214,3 +215,13 @@ export function requireReviewCommentRepository(ctx: TRPCContext): ReviewCommentR
|
|||||||
}
|
}
|
||||||
return ctx.reviewCommentRepository;
|
return ctx.reviewCommentRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function requireProjectSyncManager(ctx: TRPCContext): ProjectSyncManager {
|
||||||
|
if (!ctx.projectSyncManager) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
|
message: 'Project sync manager not available',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ctx.projectSyncManager;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { z } from 'zod';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { rm } from 'node:fs/promises';
|
import { rm } from 'node:fs/promises';
|
||||||
import type { ProcedureBuilder } from '../trpc.js';
|
import type { ProcedureBuilder } from '../trpc.js';
|
||||||
import { requireProjectRepository } from './_helpers.js';
|
import { requireProjectRepository, requireProjectSyncManager } from './_helpers.js';
|
||||||
import { cloneProject } from '../../git/clone.js';
|
import { cloneProject } from '../../git/clone.js';
|
||||||
import { getProjectCloneDir } from '../../git/project-clones.js';
|
import { getProjectCloneDir } from '../../git/project-clones.js';
|
||||||
|
|
||||||
@@ -153,5 +153,25 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) {
|
|||||||
await repo.setInitiativeProjects(input.initiativeId, input.projectIds);
|
await repo.setInitiativeProjects(input.initiativeId, input.projectIds);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
syncProject: publicProcedure
|
||||||
|
.input(z.object({ id: z.string().min(1) }))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const syncManager = requireProjectSyncManager(ctx);
|
||||||
|
return syncManager.syncProject(input.id);
|
||||||
|
}),
|
||||||
|
|
||||||
|
syncAllProjects: publicProcedure
|
||||||
|
.mutation(async ({ ctx }) => {
|
||||||
|
const syncManager = requireProjectSyncManager(ctx);
|
||||||
|
return syncManager.syncAllProjects();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getProjectSyncStatus: publicProcedure
|
||||||
|
.input(z.object({ id: z.string().min(1) }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const syncManager = requireProjectSyncManager(ctx);
|
||||||
|
return syncManager.getSyncStatus(input.id);
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react'
|
||||||
import { trpc } from '@/lib/trpc'
|
import { trpc } from '@/lib/trpc'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -92,11 +92,24 @@ function ProjectsSettingsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(date: Date | string | null): string {
|
||||||
|
if (!date) return 'never synced'
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
const seconds = Math.floor((Date.now() - d.getTime()) / 1000)
|
||||||
|
if (seconds < 60) return 'just now'
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
function ProjectCard({
|
function ProjectCard({
|
||||||
project,
|
project,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
project: { id: string; name: string; url: string; defaultBranch: string }
|
project: { id: string; name: string; url: string; defaultBranch: string; lastFetchedAt: Date | null }
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false)
|
const [editing, setEditing] = useState(false)
|
||||||
@@ -115,6 +128,26 @@ function ProjectCard({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const syncMutation = trpc.syncProject.useMutation({
|
||||||
|
onSuccess: (result) => {
|
||||||
|
void utils.listProjects.invalidate()
|
||||||
|
void utils.getProjectSyncStatus.invalidate({ id: project.id })
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(`Synced ${result.projectName}`)
|
||||||
|
} else {
|
||||||
|
toast.error(`Sync failed: ${result.error}`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error(`Sync failed: ${err.message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncStatusQuery = trpc.getProjectSyncStatus.useQuery(
|
||||||
|
{ id: project.id },
|
||||||
|
{ refetchInterval: 60_000 },
|
||||||
|
)
|
||||||
|
|
||||||
function saveEdit() {
|
function saveEdit() {
|
||||||
const trimmed = editValue.trim()
|
const trimmed = editValue.trim()
|
||||||
if (!trimmed || trimmed === project.defaultBranch) {
|
if (!trimmed || trimmed === project.defaultBranch) {
|
||||||
@@ -125,6 +158,8 @@ function ProjectCard({
|
|||||||
updateMutation.mutate({ id: project.id, defaultBranch: trimmed })
|
updateMutation.mutate({ id: project.id, defaultBranch: trimmed })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const syncStatus = syncStatusQuery.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-center gap-4 py-4">
|
||||||
@@ -164,7 +199,27 @@ function ProjectCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>Synced: {formatRelativeTime(project.lastFetchedAt)}</span>
|
||||||
|
{syncStatus && (syncStatus.ahead > 0 || syncStatus.behind > 0) && (
|
||||||
|
<span className="font-mono">
|
||||||
|
{syncStatus.ahead > 0 && <span className="text-status-success-fg">+{syncStatus.ahead}</span>}
|
||||||
|
{syncStatus.ahead > 0 && syncStatus.behind > 0 && ' / '}
|
||||||
|
{syncStatus.behind > 0 && <span className="text-status-warning-fg">-{syncStatus.behind}</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="shrink-0 text-muted-foreground"
|
||||||
|
onClick={() => syncMutation.mutate({ id: project.id })}
|
||||||
|
disabled={syncMutation.isPending}
|
||||||
|
title="Sync from remote"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
|
|||||||
| name | text NOT NULL UNIQUE | |
|
| name | text NOT NULL UNIQUE | |
|
||||||
| url | text NOT NULL UNIQUE | git repo URL |
|
| url | text NOT NULL UNIQUE | git repo URL |
|
||||||
| defaultBranch | text NOT NULL | default 'main' |
|
| defaultBranch | text NOT NULL | default 'main' |
|
||||||
|
| lastFetchedAt | integer/timestamp | nullable, updated by ProjectSyncManager |
|
||||||
| createdAt, updatedAt | integer/timestamp | |
|
| createdAt, updatedAt | integer/timestamp | |
|
||||||
|
|
||||||
### initiative_projects (junction)
|
### initiative_projects (junction)
|
||||||
|
|||||||
@@ -27,6 +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 |
|
||||||
|
| **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` |
|
||||||
| **Log** | `log:entry` | 1 |
|
| **Log** | `log:entry` | 1 |
|
||||||
|
|
||||||
### Key Event Payloads
|
### Key Event Payloads
|
||||||
|
|||||||
@@ -53,8 +53,27 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
|
|||||||
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not
|
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not
|
||||||
- `getProjectCloneDir(name, id)` — Canonical path: `repos/<sanitized-name>-<id>/`
|
- `getProjectCloneDir(name, id)` — Canonical path: `repos/<sanitized-name>-<id>/`
|
||||||
|
|
||||||
|
### ProjectSyncManager (`apps/server/git/remote-sync.ts`)
|
||||||
|
|
||||||
|
Fetches remote changes for project clones and fast-forwards the local `defaultBranch`. Safe to run with active worktrees — only updates remote-tracking refs and the base branch (never checked out directly by any worktree).
|
||||||
|
|
||||||
|
**Constructor**: `ProjectSyncManager(projectRepository, workspaceRoot, eventBus?)`
|
||||||
|
|
||||||
|
| Method | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `syncProject(projectId)` | `git fetch origin` + `git merge --ff-only origin/<defaultBranch>`, updates `lastFetchedAt` |
|
||||||
|
| `syncAllProjects()` | Sync all registered projects sequentially |
|
||||||
|
| `getSyncStatus(projectId)` | Returns `{ ahead, behind, lastFetchedAt }` via `rev-list --left-right --count` |
|
||||||
|
| `getInitiativeDivergence(projectId, branch)` | Ahead/behind between `defaultBranch` and an initiative branch |
|
||||||
|
|
||||||
|
**Sync flow during phase dispatch**: `dispatchNextPhase()` syncs all linked project clones *before* creating initiative/phase branches, so branches fork from up-to-date remote state. Sync is best-effort — failures are logged but don't block dispatch.
|
||||||
|
|
||||||
|
**CLI**: `cw project sync [name] --all`, `cw project status [name]`
|
||||||
|
|
||||||
|
**tRPC**: `syncProject`, `syncAllProjects`, `getProjectSyncStatus`
|
||||||
|
|
||||||
### Events Emitted
|
### Events Emitted
|
||||||
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`
|
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`, `project:synced`, `project:sync_failed`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
|||||||
| deleteProject | mutation | Delete clone and record |
|
| deleteProject | mutation | Delete clone and record |
|
||||||
| getInitiativeProjects | query | Projects for initiative |
|
| getInitiativeProjects | query | Projects for initiative |
|
||||||
| updateInitiativeProjects | mutation | Sync junction table |
|
| updateInitiativeProjects | mutation | Sync junction table |
|
||||||
|
| syncProject | mutation | `git fetch` + ff-only merge of defaultBranch, updates `lastFetchedAt` |
|
||||||
|
| syncAllProjects | mutation | Sync all registered projects |
|
||||||
|
| getProjectSyncStatus | query | Returns `{ ahead, behind, lastFetchedAt }` for a project |
|
||||||
|
|
||||||
### Pages
|
### Pages
|
||||||
| Procedure | Type | Description |
|
| Procedure | Type | Description |
|
||||||
|
|||||||
Reference in New Issue
Block a user