@northflare/runner 0.0.30 → 0.0.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/northflare-runner +1 -1
- package/dist/chunk-3QTLJ4CG.js +33622 -0
- package/dist/chunk-3QTLJ4CG.js.map +1 -0
- package/dist/chunk-7D4SUZUM.js +38 -0
- package/dist/chunk-7D4SUZUM.js.map +1 -0
- package/dist/dist-W7DZRE4U.js +365 -0
- package/dist/dist-W7DZRE4U.js.map +1 -0
- package/dist/index.d.ts +764 -5
- package/dist/index.js +9872 -202
- package/dist/index.js.map +1 -1
- package/dist/sdk-query-TRMSGGID-EIENWDKW.js +14 -0
- package/dist/sdk-query-TRMSGGID-EIENWDKW.js.map +1 -0
- package/package.json +17 -17
- package/tsup.config.ts +5 -2
- package/dist/components/claude-sdk-manager.d.ts +0 -60
- package/dist/components/claude-sdk-manager.d.ts.map +0 -1
- package/dist/components/claude-sdk-manager.js +0 -1378
- package/dist/components/claude-sdk-manager.js.map +0 -1
- package/dist/components/codex-sdk-manager.d.ts +0 -94
- package/dist/components/codex-sdk-manager.d.ts.map +0 -1
- package/dist/components/codex-sdk-manager.js +0 -1450
- package/dist/components/codex-sdk-manager.js.map +0 -1
- package/dist/components/enhanced-repository-manager.d.ts +0 -173
- package/dist/components/enhanced-repository-manager.d.ts.map +0 -1
- package/dist/components/enhanced-repository-manager.js +0 -1097
- package/dist/components/enhanced-repository-manager.js.map +0 -1
- package/dist/components/message-handler-sse.d.ts +0 -77
- package/dist/components/message-handler-sse.d.ts.map +0 -1
- package/dist/components/message-handler-sse.js +0 -1224
- package/dist/components/message-handler-sse.js.map +0 -1
- package/dist/components/northflare-agent-sdk-manager.d.ts +0 -58
- package/dist/components/northflare-agent-sdk-manager.d.ts.map +0 -1
- package/dist/components/northflare-agent-sdk-manager.js +0 -2032
- package/dist/components/northflare-agent-sdk-manager.js.map +0 -1
- package/dist/components/repository-manager.d.ts +0 -51
- package/dist/components/repository-manager.d.ts.map +0 -1
- package/dist/components/repository-manager.js +0 -256
- package/dist/components/repository-manager.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/runner-sse.d.ts +0 -102
- package/dist/runner-sse.d.ts.map +0 -1
- package/dist/runner-sse.js +0 -877
- package/dist/runner-sse.js.map +0 -1
- package/dist/services/RunnerAPIClient.d.ts +0 -61
- package/dist/services/RunnerAPIClient.d.ts.map +0 -1
- package/dist/services/RunnerAPIClient.js +0 -187
- package/dist/services/RunnerAPIClient.js.map +0 -1
- package/dist/services/SSEClient.d.ts +0 -62
- package/dist/services/SSEClient.d.ts.map +0 -1
- package/dist/services/SSEClient.js +0 -225
- package/dist/services/SSEClient.js.map +0 -1
- package/dist/types/claude.d.ts +0 -80
- package/dist/types/claude.d.ts.map +0 -1
- package/dist/types/claude.js +0 -5
- package/dist/types/claude.js.map +0 -1
- package/dist/types/index.d.ts +0 -52
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js +0 -7
- package/dist/types/index.js.map +0 -1
- package/dist/types/messages.d.ts +0 -33
- package/dist/types/messages.d.ts.map +0 -1
- package/dist/types/messages.js +0 -5
- package/dist/types/messages.js.map +0 -1
- package/dist/types/runner-interface.d.ts +0 -38
- package/dist/types/runner-interface.d.ts.map +0 -1
- package/dist/types/runner-interface.js +0 -5
- package/dist/types/runner-interface.js.map +0 -1
- package/dist/utils/StateManager.d.ts +0 -61
- package/dist/utils/StateManager.d.ts.map +0 -1
- package/dist/utils/StateManager.js +0 -170
- package/dist/utils/StateManager.js.map +0 -1
- package/dist/utils/config.d.ts +0 -48
- package/dist/utils/config.d.ts.map +0 -1
- package/dist/utils/config.js +0 -378
- package/dist/utils/config.js.map +0 -1
- package/dist/utils/console.d.ts +0 -8
- package/dist/utils/console.d.ts.map +0 -1
- package/dist/utils/console.js +0 -31
- package/dist/utils/console.js.map +0 -1
- package/dist/utils/debug.d.ts +0 -12
- package/dist/utils/debug.d.ts.map +0 -1
- package/dist/utils/debug.js +0 -94
- package/dist/utils/debug.js.map +0 -1
- package/dist/utils/expand-env.d.ts +0 -2
- package/dist/utils/expand-env.d.ts.map +0 -1
- package/dist/utils/expand-env.js +0 -17
- package/dist/utils/expand-env.js.map +0 -1
- package/dist/utils/inactivity-timeout.d.ts +0 -19
- package/dist/utils/inactivity-timeout.d.ts.map +0 -1
- package/dist/utils/inactivity-timeout.js +0 -72
- package/dist/utils/inactivity-timeout.js.map +0 -1
- package/dist/utils/logger.d.ts +0 -10
- package/dist/utils/logger.d.ts.map +0 -1
- package/dist/utils/logger.js +0 -129
- package/dist/utils/logger.js.map +0 -1
- package/dist/utils/message-log.d.ts +0 -23
- package/dist/utils/message-log.d.ts.map +0 -1
- package/dist/utils/message-log.js +0 -69
- package/dist/utils/message-log.js.map +0 -1
- package/dist/utils/model.d.ts +0 -8
- package/dist/utils/model.d.ts.map +0 -1
- package/dist/utils/model.js +0 -37
- package/dist/utils/model.js.map +0 -1
- package/dist/utils/status-line.d.ts +0 -34
- package/dist/utils/status-line.d.ts.map +0 -1
- package/dist/utils/status-line.js +0 -131
- package/dist/utils/status-line.js.map +0 -1
- package/dist/utils/tool-response-sanitizer.d.ts +0 -9
- package/dist/utils/tool-response-sanitizer.d.ts.map +0 -1
- package/dist/utils/tool-response-sanitizer.js +0 -118
- package/dist/utils/tool-response-sanitizer.js.map +0 -1
- package/dist/utils/update-coordinator.d.ts +0 -53
- package/dist/utils/update-coordinator.d.ts.map +0 -1
- package/dist/utils/update-coordinator.js +0 -159
- package/dist/utils/update-coordinator.js.map +0 -1
- package/dist/utils/version.d.ts +0 -10
- package/dist/utils/version.d.ts.map +0 -1
- package/dist/utils/version.js +0 -33
- package/dist/utils/version.js.map +0 -1
|
@@ -1,1097 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* EnhancedRepositoryManager - Advanced Git repository management with worktree support
|
|
3
|
-
*
|
|
4
|
-
* This enhanced version builds upon the original RepositoryManager to add:
|
|
5
|
-
* - Task-level Git isolation through worktrees
|
|
6
|
-
* - Git operations (stage, commit, push, merge, rebase)
|
|
7
|
-
* - State persistence and recovery
|
|
8
|
-
* - Backward compatibility with workspace-based operations
|
|
9
|
-
*
|
|
10
|
-
* Directory structure:
|
|
11
|
-
* /workspace/repos/<owner>__<repo>/
|
|
12
|
-
* control/ # Primary clone (.git directory owner)
|
|
13
|
-
* .git/
|
|
14
|
-
* worktrees/
|
|
15
|
-
* workspace_<workspaceId>/ # Backward compatibility - workspace default branch
|
|
16
|
-
* task_<taskId>/ # New - isolated task worktrees
|
|
17
|
-
* state.json # Persistent state mapping
|
|
18
|
-
*/
|
|
19
|
-
import { RepositoryManager } from './repository-manager.js';
|
|
20
|
-
import path from "path";
|
|
21
|
-
import fs from "fs/promises";
|
|
22
|
-
import crypto from "crypto";
|
|
23
|
-
import { simpleGit } from "simple-git";
|
|
24
|
-
import { createScopedConsole } from '../utils/console.js';
|
|
25
|
-
import { createLogger } from '../utils/logger.js';
|
|
26
|
-
const console = createScopedConsole("repo");
|
|
27
|
-
const logger = createLogger("EnhancedRepositoryManager", "repo");
|
|
28
|
-
export class EnhancedRepositoryManager extends RepositoryManager {
|
|
29
|
-
repoStates;
|
|
30
|
-
stateFilePath;
|
|
31
|
-
legacyStateFilePath;
|
|
32
|
-
repoLocks;
|
|
33
|
-
taskWorktreesBasePath;
|
|
34
|
-
constructor(runner) {
|
|
35
|
-
super(runner);
|
|
36
|
-
this.repoStates = new Map();
|
|
37
|
-
this.repoLocks = new Map();
|
|
38
|
-
const dataDir = runner.config_.dataDir;
|
|
39
|
-
const runnerId = runner.getRunnerId();
|
|
40
|
-
const stateFileName = runnerId
|
|
41
|
-
? `repository-state-${runnerId}.json`
|
|
42
|
-
: "repository-state.json";
|
|
43
|
-
this.stateFilePath = path.join(dataDir, "git", stateFileName);
|
|
44
|
-
this.legacyStateFilePath = path.join(this.repoBasePath, "repository-state.json");
|
|
45
|
-
this.taskWorktreesBasePath = path.join(dataDir, "git", "worktrees");
|
|
46
|
-
// Load persisted state on initialization
|
|
47
|
-
this.restoreState().catch((err) => {
|
|
48
|
-
console.error("Failed to restore repository state:", err);
|
|
49
|
-
});
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* Get repository key from owner and repo name
|
|
53
|
-
*/
|
|
54
|
-
getRepoKey(repoUrl) {
|
|
55
|
-
// Handle local repository URLs
|
|
56
|
-
if (repoUrl.startsWith("file://")) {
|
|
57
|
-
const localPath = repoUrl.replace("file://", "");
|
|
58
|
-
const repoName = path.basename(localPath) || "local";
|
|
59
|
-
const digest = crypto
|
|
60
|
-
.createHash("sha1")
|
|
61
|
-
.update(localPath)
|
|
62
|
-
.digest("hex")
|
|
63
|
-
.slice(0, 12);
|
|
64
|
-
return { key: `local__${repoName}__${digest}`, owner: "local", repo: repoName };
|
|
65
|
-
}
|
|
66
|
-
// Extract owner and repo from URL
|
|
67
|
-
const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/);
|
|
68
|
-
if (!match) {
|
|
69
|
-
throw new Error(`Invalid GitHub repository URL: ${repoUrl}`);
|
|
70
|
-
}
|
|
71
|
-
const owner = match[1] || "";
|
|
72
|
-
const repo = match[2] || "";
|
|
73
|
-
const key = `${owner}__${repo}`;
|
|
74
|
-
return { key, owner, repo };
|
|
75
|
-
}
|
|
76
|
-
parseRepoKey(repoKey) {
|
|
77
|
-
const parts = repoKey.split("__");
|
|
78
|
-
const owner = parts[0] || repoKey;
|
|
79
|
-
const repo = parts[1] || repoKey;
|
|
80
|
-
return { owner, repo };
|
|
81
|
-
}
|
|
82
|
-
findTaskStateEntry(taskId) {
|
|
83
|
-
for (const [repoKey, state] of this.repoStates) {
|
|
84
|
-
const repo = state.repositories[repoKey];
|
|
85
|
-
const taskState = repo?.worktrees?.[taskId];
|
|
86
|
-
if (repo && taskState) {
|
|
87
|
-
return { repoKey, repo, taskState };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
return null;
|
|
91
|
-
}
|
|
92
|
-
async withRepoLock(repoKey, fn) {
|
|
93
|
-
const previous = this.repoLocks.get(repoKey) ?? Promise.resolve();
|
|
94
|
-
let release;
|
|
95
|
-
const current = new Promise((resolve) => {
|
|
96
|
-
release = resolve;
|
|
97
|
-
});
|
|
98
|
-
const chained = previous.then(() => current);
|
|
99
|
-
this.repoLocks.set(repoKey, chained);
|
|
100
|
-
await previous;
|
|
101
|
-
try {
|
|
102
|
-
return await fn();
|
|
103
|
-
}
|
|
104
|
-
finally {
|
|
105
|
-
release();
|
|
106
|
-
if (this.repoLocks.get(repoKey) === chained) {
|
|
107
|
-
this.repoLocks.delete(repoKey);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Ensure control repository exists
|
|
113
|
-
*/
|
|
114
|
-
async ensureControlRepository(repoUrl, githubToken) {
|
|
115
|
-
const { key, owner, repo } = this.getRepoKey(repoUrl);
|
|
116
|
-
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
117
|
-
// Check if control repository already exists
|
|
118
|
-
try {
|
|
119
|
-
// Support both bare repos (config/objects at root) and legacy non-bare repos (.git/)
|
|
120
|
-
const hasGitDir = await fs
|
|
121
|
-
.access(path.join(controlPath, ".git"))
|
|
122
|
-
.then(() => true)
|
|
123
|
-
.catch(() => false);
|
|
124
|
-
const hasBareLayout = await fs
|
|
125
|
-
.access(path.join(controlPath, "config"))
|
|
126
|
-
.then(() => fs.access(path.join(controlPath, "objects")).then(() => true))
|
|
127
|
-
.catch(() => false);
|
|
128
|
-
if (!hasGitDir && !hasBareLayout) {
|
|
129
|
-
throw new Error("missing");
|
|
130
|
-
}
|
|
131
|
-
console.log(`Control repository already exists for ${key}`);
|
|
132
|
-
// Update remote URL if token changed
|
|
133
|
-
if (githubToken) {
|
|
134
|
-
const git = simpleGit(controlPath);
|
|
135
|
-
const authUrl = this.getAuthenticatedUrl(repoUrl, githubToken);
|
|
136
|
-
await git.remote(["set-url", "origin", authUrl]);
|
|
137
|
-
}
|
|
138
|
-
// Fetch latest changes
|
|
139
|
-
const git = simpleGit(controlPath);
|
|
140
|
-
await git.fetch("origin");
|
|
141
|
-
return controlPath;
|
|
142
|
-
}
|
|
143
|
-
catch {
|
|
144
|
-
// Control repository doesn't exist, create it
|
|
145
|
-
console.log(`Creating control repository for ${key}...`);
|
|
146
|
-
await this.ensureDirectory(path.dirname(controlPath));
|
|
147
|
-
const authUrl = this.getAuthenticatedUrl(repoUrl, githubToken);
|
|
148
|
-
await this.executeGit(["clone", "--bare", authUrl, controlPath]);
|
|
149
|
-
console.log(`Successfully created control repository for ${key}`);
|
|
150
|
-
return controlPath;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
/**
|
|
154
|
-
* Create a task-specific worktree for local workspaces
|
|
155
|
-
*/
|
|
156
|
-
async createLocalTaskHandle(taskId, localPath) {
|
|
157
|
-
// For local workspaces, we don't create worktrees or branches
|
|
158
|
-
// Just return a handle pointing to the local path
|
|
159
|
-
const handle = {
|
|
160
|
-
taskId,
|
|
161
|
-
worktreePath: localPath,
|
|
162
|
-
branch: "local", // Placeholder branch name for local workspaces
|
|
163
|
-
baseBranch: "local",
|
|
164
|
-
};
|
|
165
|
-
// Create and persist state for tracking
|
|
166
|
-
const state = {
|
|
167
|
-
taskId,
|
|
168
|
-
branch: "local",
|
|
169
|
-
baseBranch: "local",
|
|
170
|
-
worktreePath: localPath,
|
|
171
|
-
status: "active",
|
|
172
|
-
createdAt: new Date(),
|
|
173
|
-
updatedAt: new Date(),
|
|
174
|
-
};
|
|
175
|
-
// Store state using a special key for local workspaces
|
|
176
|
-
await this.updateTaskState("__local__", state);
|
|
177
|
-
console.log(`Created local task handle for task ${taskId} at ${localPath}`);
|
|
178
|
-
return handle;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Create a task-specific worktree
|
|
182
|
-
*/
|
|
183
|
-
async createTaskWorktree(taskId, workspaceId, repoUrl, baseBranch = "main", githubToken) {
|
|
184
|
-
const { key, owner, repo } = this.getRepoKey(repoUrl);
|
|
185
|
-
// Reuse an existing worktree/branch for this task when possible
|
|
186
|
-
const existing = this.findTaskStateEntry(taskId);
|
|
187
|
-
if (existing && existing.repoKey !== "__local__" && existing.taskState.branch !== "local") {
|
|
188
|
-
const expectedWorktreePath = path.join(this.taskWorktreesBasePath, existing.repoKey, `task_${taskId}`);
|
|
189
|
-
const worktreePath = existing.taskState.worktreePath || expectedWorktreePath;
|
|
190
|
-
const shouldPersistMetadata = existing.taskState.workspaceId !== workspaceId ||
|
|
191
|
-
existing.taskState.baseBranch !== baseBranch;
|
|
192
|
-
if (shouldPersistMetadata) {
|
|
193
|
-
existing.taskState.workspaceId = workspaceId;
|
|
194
|
-
existing.taskState.baseBranch = baseBranch;
|
|
195
|
-
existing.taskState.updatedAt = new Date();
|
|
196
|
-
await this.updateTaskStateByTaskId(taskId, existing.taskState);
|
|
197
|
-
}
|
|
198
|
-
try {
|
|
199
|
-
await fs.access(worktreePath);
|
|
200
|
-
return {
|
|
201
|
-
taskId,
|
|
202
|
-
worktreePath,
|
|
203
|
-
branch: existing.taskState.branch,
|
|
204
|
-
baseBranch: existing.taskState.baseBranch,
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
// Recreate missing worktree from the existing branch
|
|
209
|
-
const git = simpleGit(existing.repo.controlPath);
|
|
210
|
-
await this.ensureDirectory(path.dirname(worktreePath));
|
|
211
|
-
try {
|
|
212
|
-
await git.raw(["worktree", "add", worktreePath, existing.taskState.branch]);
|
|
213
|
-
}
|
|
214
|
-
catch (error) {
|
|
215
|
-
console.error(`Failed to recreate worktree for task ${taskId}:`, error);
|
|
216
|
-
await this.cleanupDirectory(worktreePath);
|
|
217
|
-
throw error;
|
|
218
|
-
}
|
|
219
|
-
const worktreeGit = simpleGit(worktreePath);
|
|
220
|
-
await worktreeGit.addConfig("user.name", "Northflare");
|
|
221
|
-
await worktreeGit.addConfig("user.email", "runner@northflare.ai");
|
|
222
|
-
existing.taskState.worktreePath = worktreePath;
|
|
223
|
-
existing.taskState.workspaceId = workspaceId;
|
|
224
|
-
existing.taskState.baseBranch = baseBranch;
|
|
225
|
-
existing.taskState.status = "active";
|
|
226
|
-
existing.taskState.updatedAt = new Date();
|
|
227
|
-
await this.updateTaskState(existing.repoKey, existing.taskState);
|
|
228
|
-
return {
|
|
229
|
-
taskId,
|
|
230
|
-
worktreePath,
|
|
231
|
-
branch: existing.taskState.branch,
|
|
232
|
-
baseBranch: existing.taskState.baseBranch,
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
const isLocalRepo = repoUrl.startsWith("file://");
|
|
237
|
-
// Ensure control repository exists
|
|
238
|
-
let controlPath;
|
|
239
|
-
if (isLocalRepo) {
|
|
240
|
-
controlPath = repoUrl.replace("file://", "");
|
|
241
|
-
// Verify local path exists and has a Git directory
|
|
242
|
-
const stats = await fs.stat(controlPath);
|
|
243
|
-
if (!stats.isDirectory()) {
|
|
244
|
-
throw new Error(`Local repository path is not a directory: ${controlPath}`);
|
|
245
|
-
}
|
|
246
|
-
await fs.access(path.join(controlPath, ".git"));
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
controlPath = await this.ensureControlRepository(repoUrl, githubToken);
|
|
250
|
-
}
|
|
251
|
-
const worktreesPath = path.join(this.taskWorktreesBasePath, key);
|
|
252
|
-
const taskWorktreePath = path.join(worktreesPath, `task_${taskId}`);
|
|
253
|
-
// Create unique branch name for the task
|
|
254
|
-
const branchName = `task/${taskId}`;
|
|
255
|
-
try {
|
|
256
|
-
console.log(`Creating worktree for task ${taskId} on branch ${branchName}...`);
|
|
257
|
-
// Create worktree directory
|
|
258
|
-
await this.ensureDirectory(worktreesPath);
|
|
259
|
-
// Add worktree with new branch based on baseBranch
|
|
260
|
-
const git = simpleGit(controlPath);
|
|
261
|
-
const baseRef = isLocalRepo ? baseBranch : `origin/${baseBranch}`;
|
|
262
|
-
let baseRefIsValid = await git
|
|
263
|
-
.raw(["rev-parse", "--verify", `${baseRef}^{commit}`])
|
|
264
|
-
.then(() => true)
|
|
265
|
-
.catch(() => false);
|
|
266
|
-
if (!baseRefIsValid) {
|
|
267
|
-
const hasAnyCommit = await git
|
|
268
|
-
.raw(["rev-parse", "--verify", "HEAD^{commit}"])
|
|
269
|
-
.then(() => true)
|
|
270
|
-
.catch(() => false);
|
|
271
|
-
if (!hasAnyCommit) {
|
|
272
|
-
await this.ensureInitialCommitOnBranch(controlPath, baseBranch);
|
|
273
|
-
baseRefIsValid = await git
|
|
274
|
-
.raw(["rev-parse", "--verify", `${baseRef}^{commit}`])
|
|
275
|
-
.then(() => true)
|
|
276
|
-
.catch(() => false);
|
|
277
|
-
}
|
|
278
|
-
if (!baseRefIsValid) {
|
|
279
|
-
throw new Error(`Cannot create a task worktree for ${taskId}: base branch '${baseBranch}' is not a valid Git reference in ${controlPath}. Set Workspace → Settings → Git branch to an existing branch name and retry.`);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
const branchExists = await git
|
|
283
|
-
.raw(["show-ref", "--verify", `refs/heads/${branchName}`])
|
|
284
|
-
.then(() => true)
|
|
285
|
-
.catch(() => false);
|
|
286
|
-
if (branchExists) {
|
|
287
|
-
await git.raw(["worktree", "add", taskWorktreePath, branchName]);
|
|
288
|
-
}
|
|
289
|
-
else {
|
|
290
|
-
await git.raw([
|
|
291
|
-
"worktree",
|
|
292
|
-
"add",
|
|
293
|
-
"-b",
|
|
294
|
-
branchName,
|
|
295
|
-
taskWorktreePath,
|
|
296
|
-
baseRef,
|
|
297
|
-
]);
|
|
298
|
-
}
|
|
299
|
-
// Configure the worktree
|
|
300
|
-
const worktreeGit = simpleGit(taskWorktreePath);
|
|
301
|
-
await worktreeGit.addConfig("user.name", "Northflare");
|
|
302
|
-
await worktreeGit.addConfig("user.email", "runner@northflare.ai");
|
|
303
|
-
// Create and persist state
|
|
304
|
-
const state = {
|
|
305
|
-
taskId,
|
|
306
|
-
workspaceId,
|
|
307
|
-
branch: branchName,
|
|
308
|
-
baseBranch,
|
|
309
|
-
worktreePath: taskWorktreePath,
|
|
310
|
-
status: "active",
|
|
311
|
-
createdAt: new Date(),
|
|
312
|
-
updatedAt: new Date(),
|
|
313
|
-
};
|
|
314
|
-
await this.updateTaskState(key, state, { owner, repo, controlPath });
|
|
315
|
-
console.log(`Successfully created worktree for task ${taskId}`);
|
|
316
|
-
return {
|
|
317
|
-
taskId,
|
|
318
|
-
worktreePath: taskWorktreePath,
|
|
319
|
-
branch: branchName,
|
|
320
|
-
baseBranch,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
catch (error) {
|
|
324
|
-
console.error(`Failed to create worktree for task ${taskId}:`, error);
|
|
325
|
-
// Cleanup on failure
|
|
326
|
-
await this.cleanupDirectory(taskWorktreePath);
|
|
327
|
-
throw error;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
async ensureInitialCommitOnBranch(repoPath, branchName) {
|
|
331
|
-
const git = simpleGit(repoPath);
|
|
332
|
-
await git.addConfig("user.name", "Northflare");
|
|
333
|
-
await git.addConfig("user.email", "runner@northflare.ai");
|
|
334
|
-
const hasAnyCommit = await git
|
|
335
|
-
.raw(["rev-parse", "--verify", "HEAD^{commit}"])
|
|
336
|
-
.then(() => true)
|
|
337
|
-
.catch(() => false);
|
|
338
|
-
if (hasAnyCommit)
|
|
339
|
-
return;
|
|
340
|
-
console.log(`Repository at ${repoPath} has no commits; creating an empty initial commit on '${branchName}'...`);
|
|
341
|
-
const isBareRepo = await git
|
|
342
|
-
.raw(["rev-parse", "--is-bare-repository"])
|
|
343
|
-
.then((output) => output.trim() === "true")
|
|
344
|
-
.catch(() => false);
|
|
345
|
-
if (isBareRepo) {
|
|
346
|
-
const emptyTreeHash = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
347
|
-
const commitHash = (await git.raw([
|
|
348
|
-
"commit-tree",
|
|
349
|
-
emptyTreeHash,
|
|
350
|
-
"-m",
|
|
351
|
-
"Initial commit",
|
|
352
|
-
])).trim();
|
|
353
|
-
await git.raw(["update-ref", `refs/heads/${branchName}`, commitHash]);
|
|
354
|
-
await git.raw(["symbolic-ref", "HEAD", `refs/heads/${branchName}`]);
|
|
355
|
-
const remotes = await git.getRemotes(true);
|
|
356
|
-
const hasOrigin = remotes.some((remote) => remote.name === "origin");
|
|
357
|
-
void hasOrigin;
|
|
358
|
-
}
|
|
359
|
-
else {
|
|
360
|
-
await git.raw(["checkout", "--orphan", branchName]);
|
|
361
|
-
await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]);
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
/**
|
|
365
|
-
* Remove a task worktree
|
|
366
|
-
*/
|
|
367
|
-
async removeTaskWorktree(taskId, options = {}) {
|
|
368
|
-
const entry = this.findTaskStateEntry(taskId);
|
|
369
|
-
if (!entry) {
|
|
370
|
-
console.log(`No worktree found for task ${taskId}`);
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
const { repoKey, repo, taskState } = entry;
|
|
374
|
-
// Handle local workspaces differently
|
|
375
|
-
if (repoKey === "__local__") {
|
|
376
|
-
// Just remove from state, don't touch the filesystem
|
|
377
|
-
const repoState = this.repoStates.get(repoKey);
|
|
378
|
-
const repo = repoState?.repositories?.[repoKey];
|
|
379
|
-
if (repo?.worktrees?.[taskId]) {
|
|
380
|
-
delete repo.worktrees[taskId];
|
|
381
|
-
}
|
|
382
|
-
await this.persistState();
|
|
383
|
-
console.log(`Removed local task handle for task ${taskId}`);
|
|
384
|
-
return;
|
|
385
|
-
}
|
|
386
|
-
const controlPath = repo.controlPath;
|
|
387
|
-
try {
|
|
388
|
-
console.log(`Removing worktree for task ${taskId}...`);
|
|
389
|
-
const git = simpleGit(controlPath);
|
|
390
|
-
// Remove the worktree (when present)
|
|
391
|
-
if (taskState.worktreePath) {
|
|
392
|
-
const worktreePath = taskState.worktreePath;
|
|
393
|
-
try {
|
|
394
|
-
await fs.access(worktreePath);
|
|
395
|
-
await git.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
396
|
-
}
|
|
397
|
-
catch {
|
|
398
|
-
// Worktree path already gone; continue so we can still delete the branch if needed
|
|
399
|
-
}
|
|
400
|
-
finally {
|
|
401
|
-
await this.cleanupDirectory(worktreePath);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
// Delete the branch if not preserving
|
|
405
|
-
if (!options.preserveBranch) {
|
|
406
|
-
try {
|
|
407
|
-
await git.raw(["branch", "-D", taskState.branch]);
|
|
408
|
-
}
|
|
409
|
-
catch (error) {
|
|
410
|
-
console.warn(`Failed to delete branch ${taskState.branch}:`, error);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
// Update state
|
|
414
|
-
const repoState = this.repoStates.get(repoKey);
|
|
415
|
-
if (repoState?.repositories[repoKey]?.worktrees) {
|
|
416
|
-
if (options.preserveBranch) {
|
|
417
|
-
repoState.repositories[repoKey].worktrees[taskId] = {
|
|
418
|
-
...taskState,
|
|
419
|
-
worktreePath: "",
|
|
420
|
-
updatedAt: new Date(),
|
|
421
|
-
};
|
|
422
|
-
}
|
|
423
|
-
else {
|
|
424
|
-
delete repoState.repositories[repoKey].worktrees[taskId];
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
await this.persistState();
|
|
428
|
-
console.log(`Successfully removed worktree for task ${taskId}`);
|
|
429
|
-
}
|
|
430
|
-
catch (error) {
|
|
431
|
-
console.error(`Failed to remove worktree for task ${taskId}:`, error);
|
|
432
|
-
if (options.force) {
|
|
433
|
-
// Force cleanup
|
|
434
|
-
await this.cleanupDirectory(taskState.worktreePath);
|
|
435
|
-
}
|
|
436
|
-
else {
|
|
437
|
-
throw error;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* Stage all changes in a task worktree
|
|
443
|
-
*/
|
|
444
|
-
async stageAll(taskId) {
|
|
445
|
-
const taskState = await this.getTaskState(taskId);
|
|
446
|
-
if (!taskState) {
|
|
447
|
-
throw new Error(`No worktree found for task ${taskId}`);
|
|
448
|
-
}
|
|
449
|
-
// Skip Git operations for local workspaces
|
|
450
|
-
if (taskState.branch === "local") {
|
|
451
|
-
console.log(`Skipping stage operation for local workspace task ${taskId}`);
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
const git = simpleGit(taskState.worktreePath);
|
|
455
|
-
await git.add(".");
|
|
456
|
-
console.log(`Staged all changes for task ${taskId}`);
|
|
457
|
-
}
|
|
458
|
-
/**
|
|
459
|
-
* Commit changes in a task worktree
|
|
460
|
-
*/
|
|
461
|
-
async commit(taskId, message, author) {
|
|
462
|
-
const taskState = await this.getTaskState(taskId);
|
|
463
|
-
if (!taskState) {
|
|
464
|
-
throw new Error(`No worktree found for task ${taskId}`);
|
|
465
|
-
}
|
|
466
|
-
// Skip Git operations for local workspaces
|
|
467
|
-
if (taskState.branch === "local") {
|
|
468
|
-
console.log(`Skipping commit operation for local workspace task ${taskId}`);
|
|
469
|
-
return "local-commit"; // Return a placeholder commit hash
|
|
470
|
-
}
|
|
471
|
-
const git = simpleGit(taskState.worktreePath);
|
|
472
|
-
// Set author if provided
|
|
473
|
-
if (author) {
|
|
474
|
-
await git.addConfig("user.name", author.name);
|
|
475
|
-
await git.addConfig("user.email", author.email);
|
|
476
|
-
}
|
|
477
|
-
// Commit changes
|
|
478
|
-
const result = await git.commit(message);
|
|
479
|
-
const commitHash = result.commit;
|
|
480
|
-
// Update task state
|
|
481
|
-
taskState.lastCommit = commitHash;
|
|
482
|
-
taskState.commitCount = (taskState.commitCount ?? 0) + 1;
|
|
483
|
-
taskState.updatedAt = new Date();
|
|
484
|
-
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
485
|
-
console.log(`Created commit ${commitHash} for task ${taskId}`);
|
|
486
|
-
logger.info(`Committed task ${taskId} (${taskState.branch}) -> ${commitHash}`, {
|
|
487
|
-
taskId,
|
|
488
|
-
branch: taskState.branch,
|
|
489
|
-
baseBranch: taskState.baseBranch,
|
|
490
|
-
commit: commitHash,
|
|
491
|
-
worktreePath: taskState.worktreePath,
|
|
492
|
-
});
|
|
493
|
-
return commitHash;
|
|
494
|
-
}
|
|
495
|
-
/**
|
|
496
|
-
* Create a new branch for a task
|
|
497
|
-
*/
|
|
498
|
-
async createBranch(taskId, branchName) {
|
|
499
|
-
const taskState = await this.getTaskState(taskId);
|
|
500
|
-
if (!taskState) {
|
|
501
|
-
throw new Error(`No worktree found for task ${taskId}`);
|
|
502
|
-
}
|
|
503
|
-
// Skip Git operations for local workspaces
|
|
504
|
-
if (taskState.branch === "local") {
|
|
505
|
-
console.log(`Skipping branch creation for local workspace task ${taskId}`);
|
|
506
|
-
return;
|
|
507
|
-
}
|
|
508
|
-
const git = simpleGit(taskState.worktreePath);
|
|
509
|
-
await git.checkoutBranch(branchName, taskState.branch);
|
|
510
|
-
// Update task state
|
|
511
|
-
taskState.branch = branchName;
|
|
512
|
-
taskState.updatedAt = new Date();
|
|
513
|
-
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
514
|
-
console.log(`Created branch ${branchName} for task ${taskId}`);
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Rebase a task branch onto target branch
|
|
518
|
-
*/
|
|
519
|
-
async rebaseTask(taskId, targetBranch) {
|
|
520
|
-
const taskState = await this.getTaskState(taskId);
|
|
521
|
-
if (!taskState) {
|
|
522
|
-
throw new Error(`No worktree found for task ${taskId}`);
|
|
523
|
-
}
|
|
524
|
-
// Skip Git operations for local workspaces
|
|
525
|
-
if (taskState.branch === "local") {
|
|
526
|
-
console.log(`Skipping rebase operation for local workspace task ${taskId}`);
|
|
527
|
-
return { success: true }; // Return success for local workspaces
|
|
528
|
-
}
|
|
529
|
-
const git = simpleGit(taskState.worktreePath);
|
|
530
|
-
try {
|
|
531
|
-
const remotes = await git.getRemotes(true);
|
|
532
|
-
const hasOrigin = remotes.some((r) => r.name === "origin");
|
|
533
|
-
logger.info(`Rebasing task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
|
|
534
|
-
taskId,
|
|
535
|
-
branch: taskState.branch,
|
|
536
|
-
targetBranch,
|
|
537
|
-
hasOrigin,
|
|
538
|
-
worktreePath: taskState.worktreePath,
|
|
539
|
-
});
|
|
540
|
-
if (hasOrigin) {
|
|
541
|
-
// Fetch latest changes
|
|
542
|
-
await git.fetch("origin", targetBranch);
|
|
543
|
-
// Perform rebase
|
|
544
|
-
await git.rebase([`origin/${targetBranch}`]);
|
|
545
|
-
}
|
|
546
|
-
else {
|
|
547
|
-
await git.rebase([targetBranch]);
|
|
548
|
-
}
|
|
549
|
-
// Update base branch in state
|
|
550
|
-
taskState.baseBranch = targetBranch;
|
|
551
|
-
taskState.updatedAt = new Date();
|
|
552
|
-
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
553
|
-
console.log(`Successfully rebased task ${taskId} onto ${targetBranch}`);
|
|
554
|
-
logger.info(`Rebased task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
|
|
555
|
-
taskId,
|
|
556
|
-
branch: taskState.branch,
|
|
557
|
-
targetBranch,
|
|
558
|
-
});
|
|
559
|
-
return { success: true };
|
|
560
|
-
}
|
|
561
|
-
catch (error) {
|
|
562
|
-
console.error(`Rebase failed for task ${taskId}:`, error);
|
|
563
|
-
logger.error(`Rebase failed for task ${taskId} (${taskState.branch})`, {
|
|
564
|
-
taskId,
|
|
565
|
-
branch: taskState.branch,
|
|
566
|
-
targetBranch,
|
|
567
|
-
error: error instanceof Error ? error.message : String(error),
|
|
568
|
-
});
|
|
569
|
-
// Check for conflicts
|
|
570
|
-
const status = await git.status();
|
|
571
|
-
if (status.conflicted.length > 0) {
|
|
572
|
-
taskState.status = "conflicted";
|
|
573
|
-
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
574
|
-
return {
|
|
575
|
-
success: false,
|
|
576
|
-
conflicts: status.conflicted,
|
|
577
|
-
error: "Rebase resulted in conflicts",
|
|
578
|
-
};
|
|
579
|
-
}
|
|
580
|
-
// Abort rebase on error
|
|
581
|
-
try {
|
|
582
|
-
await git.rebase(["--abort"]);
|
|
583
|
-
}
|
|
584
|
-
catch {
|
|
585
|
-
// Ignore abort errors
|
|
586
|
-
}
|
|
587
|
-
return {
|
|
588
|
-
success: false,
|
|
589
|
-
error: error instanceof Error ? error.message : String(error),
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
/**
|
|
594
|
-
* Merge a task branch into target branch
|
|
595
|
-
*/
|
|
596
|
-
async mergeTask(taskId, targetBranch, mode = "no-ff") {
|
|
597
|
-
const entry = this.findTaskStateEntry(taskId);
|
|
598
|
-
if (!entry) {
|
|
599
|
-
throw new Error(`No worktree found for task ${taskId}`);
|
|
600
|
-
}
|
|
601
|
-
const { repoKey, taskState } = entry;
|
|
602
|
-
// Skip Git operations for local workspaces
|
|
603
|
-
if (taskState.branch === "local") {
|
|
604
|
-
console.log(`Skipping merge operation for local workspace task ${taskId}`);
|
|
605
|
-
return { success: true, mergedCommit: "local-merge" }; // Return success for local workspaces
|
|
606
|
-
}
|
|
607
|
-
return await this.withRepoLock(repoKey, async () => {
|
|
608
|
-
return this.mergeTaskUnlocked(entry, targetBranch, mode);
|
|
609
|
-
});
|
|
610
|
-
}
|
|
611
|
-
async mergeTaskUnlocked(entry, targetBranch, mode) {
|
|
612
|
-
const { repoKey, repo, taskState } = entry;
|
|
613
|
-
const isLocalRepo = repoKey.startsWith("local__");
|
|
614
|
-
const mergePath = await (async () => {
|
|
615
|
-
if (isLocalRepo)
|
|
616
|
-
return repo.controlPath;
|
|
617
|
-
if (!taskState.workspaceId) {
|
|
618
|
-
throw new Error(`Cannot merge task ${taskState.taskId}: missing workspaceId in task Git state`);
|
|
619
|
-
}
|
|
620
|
-
const worktreesPath = path.join(this.repoBasePath, "repos", repoKey, "worktrees");
|
|
621
|
-
const workspaceWorktreePath = path.join(worktreesPath, `workspace_${taskState.workspaceId}`);
|
|
622
|
-
const exists = await fs
|
|
623
|
-
.access(workspaceWorktreePath)
|
|
624
|
-
.then(() => true)
|
|
625
|
-
.catch(() => false);
|
|
626
|
-
if (exists)
|
|
627
|
-
return workspaceWorktreePath;
|
|
628
|
-
await this.ensureDirectory(worktreesPath);
|
|
629
|
-
const controlGit = simpleGit(repo.controlPath);
|
|
630
|
-
await controlGit.fetch("origin", targetBranch);
|
|
631
|
-
await controlGit.raw([
|
|
632
|
-
"worktree",
|
|
633
|
-
"add",
|
|
634
|
-
workspaceWorktreePath,
|
|
635
|
-
`origin/${targetBranch}`,
|
|
636
|
-
]);
|
|
637
|
-
console.log(`Created merge worktree for workspace ${taskState.workspaceId} at ${workspaceWorktreePath}`);
|
|
638
|
-
return workspaceWorktreePath;
|
|
639
|
-
})();
|
|
640
|
-
const git = simpleGit(mergePath);
|
|
641
|
-
try {
|
|
642
|
-
const remotes = await git.getRemotes(true);
|
|
643
|
-
const hasOrigin = remotes.some((r) => r.name === "origin");
|
|
644
|
-
logger.info(`Merging task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
|
|
645
|
-
taskId: taskState.taskId,
|
|
646
|
-
taskBranch: taskState.branch,
|
|
647
|
-
targetBranch,
|
|
648
|
-
mergePath,
|
|
649
|
-
hasOrigin,
|
|
650
|
-
isLocalRepo,
|
|
651
|
-
mode,
|
|
652
|
-
});
|
|
653
|
-
if (hasOrigin) {
|
|
654
|
-
// Fetch latest changes and ensure a local targetBranch exists + is up-to-date
|
|
655
|
-
await git.fetch("origin", targetBranch);
|
|
656
|
-
const localBranches = await git.branchLocal();
|
|
657
|
-
const hasLocalTarget = localBranches.all.includes(targetBranch);
|
|
658
|
-
if (!hasLocalTarget) {
|
|
659
|
-
await git.raw(["checkout", "-B", targetBranch, `origin/${targetBranch}`]);
|
|
660
|
-
}
|
|
661
|
-
else {
|
|
662
|
-
await git.checkout(targetBranch);
|
|
663
|
-
try {
|
|
664
|
-
await git.merge(["--ff-only", `origin/${targetBranch}`]);
|
|
665
|
-
}
|
|
666
|
-
catch (error) {
|
|
667
|
-
throw new Error(`Base branch ${targetBranch} has diverged from origin/${targetBranch}; cannot fast-forward`);
|
|
668
|
-
}
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
else {
|
|
672
|
-
await git.checkout(targetBranch);
|
|
673
|
-
}
|
|
674
|
-
// Perform merge based on mode
|
|
675
|
-
let mergeArgs = [];
|
|
676
|
-
switch (mode) {
|
|
677
|
-
case "ff-only":
|
|
678
|
-
mergeArgs = ["--ff-only"];
|
|
679
|
-
break;
|
|
680
|
-
case "no-ff":
|
|
681
|
-
mergeArgs = ["--no-ff"];
|
|
682
|
-
break;
|
|
683
|
-
case "squash":
|
|
684
|
-
mergeArgs = ["--squash"];
|
|
685
|
-
break;
|
|
686
|
-
}
|
|
687
|
-
const result = await git.merge([...mergeArgs, taskState.branch]);
|
|
688
|
-
// For squash merge, we need to commit
|
|
689
|
-
if (mode === "squash") {
|
|
690
|
-
const commitResult = await git.commit(`Merge task ${taskState.taskId} (${taskState.branch})`);
|
|
691
|
-
result.result = commitResult.commit;
|
|
692
|
-
}
|
|
693
|
-
// Update task state
|
|
694
|
-
taskState.status = "merged";
|
|
695
|
-
taskState.updatedAt = new Date();
|
|
696
|
-
await this.updateTaskStateByTaskId(taskState.taskId, taskState);
|
|
697
|
-
console.log(`Successfully merged task ${taskState.taskId} into ${targetBranch}`);
|
|
698
|
-
logger.info(`Merged task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
|
|
699
|
-
taskId: taskState.taskId,
|
|
700
|
-
taskBranch: taskState.branch,
|
|
701
|
-
targetBranch,
|
|
702
|
-
mergedCommit: result.result,
|
|
703
|
-
});
|
|
704
|
-
return {
|
|
705
|
-
success: true,
|
|
706
|
-
mergedCommit: result.result,
|
|
707
|
-
};
|
|
708
|
-
}
|
|
709
|
-
catch (error) {
|
|
710
|
-
console.error(`Merge failed for task ${taskState.taskId}:`, error);
|
|
711
|
-
logger.error(`Merge failed for task ${taskState.taskId} (${taskState.branch})`, {
|
|
712
|
-
taskId: taskState.taskId,
|
|
713
|
-
taskBranch: taskState.branch,
|
|
714
|
-
targetBranch,
|
|
715
|
-
mergePath,
|
|
716
|
-
error: error instanceof Error ? error.message : String(error),
|
|
717
|
-
});
|
|
718
|
-
// Check for conflicts
|
|
719
|
-
try {
|
|
720
|
-
const status = await git.status();
|
|
721
|
-
if (status.conflicted.length > 0) {
|
|
722
|
-
taskState.status = "conflicted";
|
|
723
|
-
await this.updateTaskStateByTaskId(taskState.taskId, taskState);
|
|
724
|
-
return {
|
|
725
|
-
success: false,
|
|
726
|
-
conflicts: status.conflicted,
|
|
727
|
-
error: "Merge resulted in conflicts",
|
|
728
|
-
};
|
|
729
|
-
}
|
|
730
|
-
}
|
|
731
|
-
catch {
|
|
732
|
-
// Ignore status errors (e.g. bare repositories)
|
|
733
|
-
}
|
|
734
|
-
return {
|
|
735
|
-
success: false,
|
|
736
|
-
error: error instanceof Error ? error.message : String(error),
|
|
737
|
-
};
|
|
738
|
-
}
|
|
739
|
-
}
|
|
740
|
-
async getTaskRepoInfo(taskId) {
|
|
741
|
-
const entry = this.findTaskStateEntry(taskId);
|
|
742
|
-
if (!entry)
|
|
743
|
-
return null;
|
|
744
|
-
return {
|
|
745
|
-
repoKey: entry.repoKey,
|
|
746
|
-
controlPath: entry.repo.controlPath,
|
|
747
|
-
worktreePath: entry.taskState.worktreePath,
|
|
748
|
-
branch: entry.taskState.branch,
|
|
749
|
-
baseBranch: entry.taskState.baseBranch,
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* Fast-forward (or hard reset) the shared workspace worktree to a branch tip after a task merge.
|
|
754
|
-
* This keeps the "workspace" checkout in sync with concurrent task integrations.
|
|
755
|
-
*
|
|
756
|
-
* We only update existing workspace worktrees and only when they're clean to avoid
|
|
757
|
-
* clobbering any active non-concurrent task that might be using the workspace checkout.
|
|
758
|
-
*/
|
|
759
|
-
async syncWorkspaceWorktree(workspaceId, repoUrl, branch) {
|
|
760
|
-
if (!workspaceId || !repoUrl)
|
|
761
|
-
return;
|
|
762
|
-
// Local repos merge directly into the primary working tree (controlPath), nothing to sync.
|
|
763
|
-
if (repoUrl.startsWith("file://"))
|
|
764
|
-
return;
|
|
765
|
-
const { key } = this.getRepoKey(repoUrl);
|
|
766
|
-
const workspaceWorktreePath = path.join(this.repoBasePath, "repos", key, "worktrees", `workspace_${workspaceId}`);
|
|
767
|
-
const exists = await fs
|
|
768
|
-
.access(workspaceWorktreePath)
|
|
769
|
-
.then(() => true)
|
|
770
|
-
.catch(() => false);
|
|
771
|
-
await this.withRepoLock(key, async () => {
|
|
772
|
-
if (!exists) {
|
|
773
|
-
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
774
|
-
const controlExists = await fs
|
|
775
|
-
.access(controlPath)
|
|
776
|
-
.then(() => true)
|
|
777
|
-
.catch(() => false);
|
|
778
|
-
if (!controlExists) {
|
|
779
|
-
console.warn(`Skipping workspace worktree sync for ${workspaceId}: control repo missing`, { controlPath, repoUrl });
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
await this.ensureDirectory(path.dirname(workspaceWorktreePath));
|
|
783
|
-
const controlGit = simpleGit(controlPath);
|
|
784
|
-
const remotes = await controlGit.getRemotes(true);
|
|
785
|
-
const hasOrigin = remotes.some((remote) => remote.name === "origin");
|
|
786
|
-
const checkoutRef = hasOrigin ? `origin/${branch}` : branch;
|
|
787
|
-
await controlGit.raw(["worktree", "add", workspaceWorktreePath, checkoutRef]);
|
|
788
|
-
console.log(`Created workspace worktree for ${workspaceId} at ${workspaceWorktreePath}`);
|
|
789
|
-
}
|
|
790
|
-
const git = simpleGit(workspaceWorktreePath);
|
|
791
|
-
const status = await git.status();
|
|
792
|
-
if (status.conflicted.length > 0 || !status.isClean()) {
|
|
793
|
-
console.warn(`Skipping workspace worktree sync for ${workspaceId}: worktree is not clean`, {
|
|
794
|
-
path: workspaceWorktreePath,
|
|
795
|
-
branch,
|
|
796
|
-
conflicted: status.conflicted,
|
|
797
|
-
fileCount: status.files.length,
|
|
798
|
-
});
|
|
799
|
-
return;
|
|
800
|
-
}
|
|
801
|
-
const remotes = await git.getRemotes(true);
|
|
802
|
-
const hasOrigin = remotes.some((remote) => remote.name === "origin");
|
|
803
|
-
if (hasOrigin) {
|
|
804
|
-
await git.fetch("origin", branch);
|
|
805
|
-
await git.reset(["--hard", `origin/${branch}`]);
|
|
806
|
-
}
|
|
807
|
-
else {
|
|
808
|
-
await git.reset(["--hard", branch]);
|
|
809
|
-
}
|
|
810
|
-
await git.clean("f", ["-d"]);
|
|
811
|
-
console.log(`Synced workspace worktree for ${workspaceId} to ${branch} at ${workspaceWorktreePath}`);
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
async isTaskBranchMerged(taskId, targetBranch) {
|
|
815
|
-
const entry = this.findTaskStateEntry(taskId);
|
|
816
|
-
if (!entry)
|
|
817
|
-
return false;
|
|
818
|
-
const { repo, taskState } = entry;
|
|
819
|
-
const git = simpleGit(repo.controlPath);
|
|
820
|
-
try {
|
|
821
|
-
// Ensure targetBranch exists locally (avoid erroring for repos that only have origin/<branch>)
|
|
822
|
-
const showRef = await git.raw([
|
|
823
|
-
"show-ref",
|
|
824
|
-
"--verify",
|
|
825
|
-
`refs/heads/${targetBranch}`,
|
|
826
|
-
]);
|
|
827
|
-
if (!showRef?.trim())
|
|
828
|
-
return false;
|
|
829
|
-
}
|
|
830
|
-
catch {
|
|
831
|
-
return false;
|
|
832
|
-
}
|
|
833
|
-
try {
|
|
834
|
-
await git.raw([
|
|
835
|
-
"merge-base",
|
|
836
|
-
"--is-ancestor",
|
|
837
|
-
taskState.branch,
|
|
838
|
-
targetBranch,
|
|
839
|
-
]);
|
|
840
|
-
return true;
|
|
841
|
-
}
|
|
842
|
-
catch {
|
|
843
|
-
return false;
|
|
844
|
-
}
|
|
845
|
-
}
|
|
846
|
-
async pushBranch(taskId, branch) {
|
|
847
|
-
const entry = this.findTaskStateEntry(taskId);
|
|
848
|
-
if (!entry)
|
|
849
|
-
return;
|
|
850
|
-
const git = simpleGit(entry.repo.controlPath);
|
|
851
|
-
const remotes = await git.getRemotes(true);
|
|
852
|
-
const hasOrigin = remotes.some((r) => r.name === "origin");
|
|
853
|
-
if (!hasOrigin)
|
|
854
|
-
return;
|
|
855
|
-
await git.push("origin", branch);
|
|
856
|
-
}
|
|
857
|
-
async integrateTask(taskId, targetBranch, mode = "no-ff") {
|
|
858
|
-
const entry = this.findTaskStateEntry(taskId);
|
|
859
|
-
if (!entry) {
|
|
860
|
-
return {
|
|
861
|
-
success: false,
|
|
862
|
-
phase: "merge",
|
|
863
|
-
error: `No worktree found for task ${taskId}`,
|
|
864
|
-
conflictWorkdir: "",
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
const { repoKey, repo, taskState } = entry;
|
|
868
|
-
const isLocalRepo = repoKey.startsWith("local__");
|
|
869
|
-
const mergeWorkdir = isLocalRepo
|
|
870
|
-
? repo.controlPath
|
|
871
|
-
: taskState.workspaceId
|
|
872
|
-
? path.join(this.repoBasePath, "repos", repoKey, "worktrees", `workspace_${taskState.workspaceId}`)
|
|
873
|
-
: taskState.worktreePath;
|
|
874
|
-
logger.info(`Integrating task ${taskId} (${taskState.branch}) into ${targetBranch}`, {
|
|
875
|
-
taskId,
|
|
876
|
-
taskBranch: taskState.branch,
|
|
877
|
-
targetBranch,
|
|
878
|
-
mode,
|
|
879
|
-
isLocalRepo,
|
|
880
|
-
controlPath: repo.controlPath,
|
|
881
|
-
worktreePath: taskState.worktreePath,
|
|
882
|
-
});
|
|
883
|
-
return await this.withRepoLock(repoKey, async () => {
|
|
884
|
-
const rebaseResult = await this.rebaseTask(taskId, targetBranch);
|
|
885
|
-
if (!rebaseResult.success) {
|
|
886
|
-
logger.warn(`Task ${taskId} rebase failed`, {
|
|
887
|
-
taskId,
|
|
888
|
-
phase: "rebase",
|
|
889
|
-
targetBranch,
|
|
890
|
-
conflicts: rebaseResult.conflicts ?? [],
|
|
891
|
-
error: rebaseResult.error,
|
|
892
|
-
});
|
|
893
|
-
return {
|
|
894
|
-
success: false,
|
|
895
|
-
phase: "rebase",
|
|
896
|
-
conflicts: rebaseResult.conflicts ?? [],
|
|
897
|
-
error: rebaseResult.error,
|
|
898
|
-
conflictWorkdir: taskState.worktreePath,
|
|
899
|
-
};
|
|
900
|
-
}
|
|
901
|
-
const mergeResult = await this.mergeTaskUnlocked(entry, targetBranch, mode);
|
|
902
|
-
if (!mergeResult.success) {
|
|
903
|
-
logger.warn(`Task ${taskId} merge failed`, {
|
|
904
|
-
taskId,
|
|
905
|
-
phase: "merge",
|
|
906
|
-
targetBranch,
|
|
907
|
-
conflicts: mergeResult.conflicts ?? [],
|
|
908
|
-
error: mergeResult.error,
|
|
909
|
-
});
|
|
910
|
-
return {
|
|
911
|
-
success: false,
|
|
912
|
-
phase: "merge",
|
|
913
|
-
conflicts: mergeResult.conflicts ?? [],
|
|
914
|
-
error: mergeResult.error,
|
|
915
|
-
conflictWorkdir: mergeWorkdir,
|
|
916
|
-
};
|
|
917
|
-
}
|
|
918
|
-
logger.info(`Integrated task ${taskId} into ${targetBranch}`, {
|
|
919
|
-
taskId,
|
|
920
|
-
targetBranch,
|
|
921
|
-
mergedCommit: mergeResult.mergedCommit,
|
|
922
|
-
});
|
|
923
|
-
return {
|
|
924
|
-
success: true,
|
|
925
|
-
mergedCommit: mergeResult.mergedCommit,
|
|
926
|
-
};
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* Get task Git state
|
|
931
|
-
*/
|
|
932
|
-
async getTaskState(taskId) {
|
|
933
|
-
return this.findTaskStateEntry(taskId)?.taskState ?? null;
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Update task state
|
|
937
|
-
*/
|
|
938
|
-
async updateTaskState(repoKey, state, repoInfo) {
|
|
939
|
-
if (!this.repoStates.has(repoKey)) {
|
|
940
|
-
this.repoStates.set(repoKey, {
|
|
941
|
-
repositories: {},
|
|
942
|
-
});
|
|
943
|
-
}
|
|
944
|
-
const repoState = this.repoStates.get(repoKey);
|
|
945
|
-
if (!repoState.repositories[repoKey]) {
|
|
946
|
-
// Handle special case for local workspaces
|
|
947
|
-
if (repoKey === "__local__") {
|
|
948
|
-
repoState.repositories[repoKey] = {
|
|
949
|
-
owner: "local",
|
|
950
|
-
repo: "local",
|
|
951
|
-
controlPath: "local",
|
|
952
|
-
worktrees: {},
|
|
953
|
-
};
|
|
954
|
-
}
|
|
955
|
-
else if (repoInfo) {
|
|
956
|
-
repoState.repositories[repoKey] = {
|
|
957
|
-
owner: repoInfo.owner,
|
|
958
|
-
repo: repoInfo.repo,
|
|
959
|
-
controlPath: repoInfo.controlPath,
|
|
960
|
-
worktrees: {},
|
|
961
|
-
};
|
|
962
|
-
}
|
|
963
|
-
else {
|
|
964
|
-
const { owner, repo } = this.parseRepoKey(repoKey);
|
|
965
|
-
repoState.repositories[repoKey] = {
|
|
966
|
-
owner,
|
|
967
|
-
repo,
|
|
968
|
-
controlPath: path.join(this.repoBasePath, "repos", repoKey, "control"),
|
|
969
|
-
worktrees: {},
|
|
970
|
-
};
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
else if (repoInfo?.controlPath) {
|
|
974
|
-
// Ensure we persist the latest controlPath (important for local repos)
|
|
975
|
-
repoState.repositories[repoKey].controlPath = repoInfo.controlPath;
|
|
976
|
-
}
|
|
977
|
-
repoState.repositories[repoKey].worktrees[state.taskId] = state;
|
|
978
|
-
await this.persistState();
|
|
979
|
-
}
|
|
980
|
-
/**
|
|
981
|
-
* Update task state by task ID
|
|
982
|
-
*/
|
|
983
|
-
async updateTaskStateByTaskId(taskId, state) {
|
|
984
|
-
for (const [repoKey, repoState] of this.repoStates) {
|
|
985
|
-
for (const repo of Object.values(repoState.repositories)) {
|
|
986
|
-
if (repo.worktrees[taskId]) {
|
|
987
|
-
repo.worktrees[taskId] = state;
|
|
988
|
-
await this.persistState();
|
|
989
|
-
return;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
/**
|
|
995
|
-
* Persist state to disk
|
|
996
|
-
*/
|
|
997
|
-
async persistState() {
|
|
998
|
-
try {
|
|
999
|
-
const stateData = {};
|
|
1000
|
-
for (const [key, value] of this.repoStates) {
|
|
1001
|
-
stateData[key] = value;
|
|
1002
|
-
}
|
|
1003
|
-
await this.ensureDirectory(path.dirname(this.stateFilePath));
|
|
1004
|
-
await fs.writeFile(this.stateFilePath, JSON.stringify(stateData, null, 2));
|
|
1005
|
-
console.log("Persisted repository state");
|
|
1006
|
-
}
|
|
1007
|
-
catch (error) {
|
|
1008
|
-
console.error("Failed to persist repository state:", error);
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
/**
|
|
1012
|
-
* Restore state from disk
|
|
1013
|
-
*/
|
|
1014
|
-
async restoreState() {
|
|
1015
|
-
await this.migrateLegacyStateFileIfNeeded();
|
|
1016
|
-
try {
|
|
1017
|
-
const data = await fs.readFile(this.stateFilePath, "utf-8");
|
|
1018
|
-
const stateData = JSON.parse(data);
|
|
1019
|
-
this.repoStates.clear();
|
|
1020
|
-
for (const [key, value] of Object.entries(stateData)) {
|
|
1021
|
-
// Convert date strings back to Date objects
|
|
1022
|
-
for (const repo of Object.values(value.repositories)) {
|
|
1023
|
-
for (const worktree of Object.values(repo.worktrees)) {
|
|
1024
|
-
worktree.createdAt = new Date(worktree.createdAt);
|
|
1025
|
-
worktree.updatedAt = new Date(worktree.updatedAt);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
this.repoStates.set(key, value);
|
|
1029
|
-
}
|
|
1030
|
-
console.log("Restored repository state");
|
|
1031
|
-
}
|
|
1032
|
-
catch (error) {
|
|
1033
|
-
if (error.code !== "ENOENT") {
|
|
1034
|
-
console.error("Failed to restore repository state:", error);
|
|
1035
|
-
}
|
|
1036
|
-
// If file doesn't exist, that's okay - we'll start fresh
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
async migrateLegacyStateFileIfNeeded() {
|
|
1040
|
-
const hasNewStateFile = await fs
|
|
1041
|
-
.access(this.stateFilePath)
|
|
1042
|
-
.then(() => true)
|
|
1043
|
-
.catch(() => false);
|
|
1044
|
-
if (hasNewStateFile)
|
|
1045
|
-
return;
|
|
1046
|
-
const hasLegacyStateFile = await fs
|
|
1047
|
-
.access(this.legacyStateFilePath)
|
|
1048
|
-
.then(() => true)
|
|
1049
|
-
.catch(() => false);
|
|
1050
|
-
if (!hasLegacyStateFile)
|
|
1051
|
-
return;
|
|
1052
|
-
await this.ensureDirectory(path.dirname(this.stateFilePath));
|
|
1053
|
-
await fs.rename(this.legacyStateFilePath, this.stateFilePath);
|
|
1054
|
-
console.log(`Moved legacy repository state file to runner data directory: ${this.stateFilePath}`);
|
|
1055
|
-
}
|
|
1056
|
-
/**
|
|
1057
|
-
* Override parent's checkoutRepository to use worktrees for backward compatibility
|
|
1058
|
-
*/
|
|
1059
|
-
async checkoutRepository(workspaceId, repoUrl, branch, githubToken) {
|
|
1060
|
-
// If it's a local repository URL, delegate to parent's checkoutLocalRepository
|
|
1061
|
-
if (repoUrl.startsWith("file://")) {
|
|
1062
|
-
const localPath = repoUrl.replace("file://", "");
|
|
1063
|
-
return super.checkoutLocalRepository(workspaceId, localPath);
|
|
1064
|
-
}
|
|
1065
|
-
const { key } = this.getRepoKey(repoUrl);
|
|
1066
|
-
// Ensure control repository exists
|
|
1067
|
-
await this.ensureControlRepository(repoUrl, githubToken);
|
|
1068
|
-
// Create or update workspace worktree
|
|
1069
|
-
const worktreesPath = path.join(this.repoBasePath, "repos", key, "worktrees");
|
|
1070
|
-
const workspaceWorktreePath = path.join(worktreesPath, `workspace_${workspaceId}`);
|
|
1071
|
-
try {
|
|
1072
|
-
// Check if workspace worktree already exists
|
|
1073
|
-
await fs.access(workspaceWorktreePath);
|
|
1074
|
-
// Update existing worktree
|
|
1075
|
-
const git = simpleGit(workspaceWorktreePath);
|
|
1076
|
-
await git.fetch("origin");
|
|
1077
|
-
await git.reset(["--hard", `origin/${branch}`]);
|
|
1078
|
-
await git.clean("f", ["-d"]);
|
|
1079
|
-
console.log(`Updated workspace worktree for ${workspaceId}`);
|
|
1080
|
-
}
|
|
1081
|
-
catch {
|
|
1082
|
-
// Create new workspace worktree
|
|
1083
|
-
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
1084
|
-
const git = simpleGit(controlPath);
|
|
1085
|
-
await this.ensureDirectory(worktreesPath);
|
|
1086
|
-
await git.raw([
|
|
1087
|
-
"worktree",
|
|
1088
|
-
"add",
|
|
1089
|
-
workspaceWorktreePath,
|
|
1090
|
-
`origin/${branch}`,
|
|
1091
|
-
]);
|
|
1092
|
-
console.log(`Created workspace worktree for ${workspaceId}`);
|
|
1093
|
-
}
|
|
1094
|
-
return workspaceWorktreePath;
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
//# sourceMappingURL=enhanced-repository-manager.js.map
|