@northflare/runner 0.0.28 → 0.0.29
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/dist/components/claude-sdk-manager.d.ts +4 -0
- package/dist/components/claude-sdk-manager.d.ts.map +1 -1
- package/dist/components/claude-sdk-manager.js +196 -22
- package/dist/components/claude-sdk-manager.js.map +1 -1
- package/dist/components/codex-sdk-manager.d.ts +3 -0
- package/dist/components/codex-sdk-manager.d.ts.map +1 -1
- package/dist/components/codex-sdk-manager.js +173 -7
- package/dist/components/codex-sdk-manager.js.map +1 -1
- package/dist/components/enhanced-repository-manager.d.ts +39 -0
- package/dist/components/enhanced-repository-manager.d.ts.map +1 -1
- package/dist/components/enhanced-repository-manager.js +604 -96
- package/dist/components/enhanced-repository-manager.js.map +1 -1
- package/dist/components/message-handler-sse.d.ts +1 -0
- package/dist/components/message-handler-sse.d.ts.map +1 -1
- package/dist/components/message-handler-sse.js +21 -0
- package/dist/components/message-handler-sse.js.map +1 -1
- package/dist/components/northflare-agent-sdk-manager.d.ts +3 -0
- package/dist/components/northflare-agent-sdk-manager.d.ts.map +1 -1
- package/dist/components/northflare-agent-sdk-manager.js +174 -9
- package/dist/components/northflare-agent-sdk-manager.js.map +1 -1
- package/dist/runner-sse.d.ts +3 -0
- package/dist/runner-sse.d.ts.map +1 -1
- package/dist/runner-sse.js +20 -0
- package/dist/runner-sse.js.map +1 -1
- package/dist/types/claude.d.ts +3 -0
- package/dist/types/claude.d.ts.map +1 -1
- package/dist/types/runner-interface.d.ts +2 -0
- package/dist/types/runner-interface.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -19,17 +19,30 @@
|
|
|
19
19
|
import { RepositoryManager } from './repository-manager.js';
|
|
20
20
|
import path from "path";
|
|
21
21
|
import fs from "fs/promises";
|
|
22
|
+
import crypto from "crypto";
|
|
22
23
|
import { simpleGit } from "simple-git";
|
|
23
24
|
import { createScopedConsole } from '../utils/console.js';
|
|
25
|
+
import { createLogger } from '../utils/logger.js';
|
|
24
26
|
const console = createScopedConsole("repo");
|
|
27
|
+
const logger = createLogger("EnhancedRepositoryManager", "repo");
|
|
25
28
|
export class EnhancedRepositoryManager extends RepositoryManager {
|
|
26
29
|
repoStates;
|
|
27
30
|
stateFilePath;
|
|
31
|
+
legacyStateFilePath;
|
|
32
|
+
repoLocks;
|
|
33
|
+
taskWorktreesBasePath;
|
|
28
34
|
constructor(runner) {
|
|
29
35
|
super(runner);
|
|
30
36
|
this.repoStates = new Map();
|
|
31
|
-
|
|
32
|
-
|
|
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");
|
|
33
46
|
// Load persisted state on initialization
|
|
34
47
|
this.restoreState().catch((err) => {
|
|
35
48
|
console.error("Failed to restore repository state:", err);
|
|
@@ -42,9 +55,13 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
42
55
|
// Handle local repository URLs
|
|
43
56
|
if (repoUrl.startsWith("file://")) {
|
|
44
57
|
const localPath = repoUrl.replace("file://", "");
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
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 };
|
|
48
65
|
}
|
|
49
66
|
// Extract owner and repo from URL
|
|
50
67
|
const match = repoUrl.match(/github\.com[:/]([^/]+)\/([^/.]+)(\.git)?$/);
|
|
@@ -56,6 +73,41 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
56
73
|
const key = `${owner}__${repo}`;
|
|
57
74
|
return { key, owner, repo };
|
|
58
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
|
+
}
|
|
59
111
|
/**
|
|
60
112
|
* Ensure control repository exists
|
|
61
113
|
*/
|
|
@@ -64,7 +116,18 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
64
116
|
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
65
117
|
// Check if control repository already exists
|
|
66
118
|
try {
|
|
67
|
-
|
|
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
|
+
}
|
|
68
131
|
console.log(`Control repository already exists for ${key}`);
|
|
69
132
|
// Update remote URL if token changed
|
|
70
133
|
if (githubToken) {
|
|
@@ -83,9 +146,6 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
83
146
|
await this.ensureDirectory(path.dirname(controlPath));
|
|
84
147
|
const authUrl = this.getAuthenticatedUrl(repoUrl, githubToken);
|
|
85
148
|
await this.executeGit(["clone", "--bare", authUrl, controlPath]);
|
|
86
|
-
// Convert to regular repository for worktree support
|
|
87
|
-
const git = simpleGit(controlPath);
|
|
88
|
-
await git.raw(["config", "--bool", "core.bare", "false"]);
|
|
89
149
|
console.log(`Successfully created control repository for ${key}`);
|
|
90
150
|
return controlPath;
|
|
91
151
|
}
|
|
@@ -121,51 +181,121 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
121
181
|
* Create a task-specific worktree
|
|
122
182
|
*/
|
|
123
183
|
async createTaskWorktree(taskId, workspaceId, repoUrl, baseBranch = "main", githubToken) {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
+
}
|
|
129
198
|
try {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
199
|
+
await fs.access(worktreePath);
|
|
200
|
+
return {
|
|
201
|
+
taskId,
|
|
202
|
+
worktreePath,
|
|
203
|
+
branch: existing.taskState.branch,
|
|
204
|
+
baseBranch: existing.taskState.baseBranch,
|
|
205
|
+
};
|
|
134
206
|
}
|
|
135
|
-
catch
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
};
|
|
138
234
|
}
|
|
139
|
-
return {
|
|
140
|
-
taskId,
|
|
141
|
-
worktreePath: localPath,
|
|
142
|
-
branch: "local",
|
|
143
|
-
baseBranch: "local",
|
|
144
|
-
};
|
|
145
235
|
}
|
|
146
|
-
const
|
|
236
|
+
const isLocalRepo = repoUrl.startsWith("file://");
|
|
147
237
|
// Ensure control repository exists
|
|
148
|
-
|
|
149
|
-
|
|
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);
|
|
150
252
|
const taskWorktreePath = path.join(worktreesPath, `task_${taskId}`);
|
|
151
253
|
// Create unique branch name for the task
|
|
152
|
-
const
|
|
153
|
-
const shortTaskId = taskId.substring(0, 8);
|
|
154
|
-
const branchName = `task/${shortTaskId}-${timestamp}`;
|
|
254
|
+
const branchName = `task/${taskId}`;
|
|
155
255
|
try {
|
|
156
256
|
console.log(`Creating worktree for task ${taskId} on branch ${branchName}...`);
|
|
157
257
|
// Create worktree directory
|
|
158
258
|
await this.ensureDirectory(worktreesPath);
|
|
159
259
|
// Add worktree with new branch based on baseBranch
|
|
160
260
|
const git = simpleGit(controlPath);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
"
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
}
|
|
169
299
|
// Configure the worktree
|
|
170
300
|
const worktreeGit = simpleGit(taskWorktreePath);
|
|
171
301
|
await worktreeGit.addConfig("user.name", "Northflare");
|
|
@@ -173,6 +303,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
173
303
|
// Create and persist state
|
|
174
304
|
const state = {
|
|
175
305
|
taskId,
|
|
306
|
+
workspaceId,
|
|
176
307
|
branch: branchName,
|
|
177
308
|
baseBranch,
|
|
178
309
|
worktreePath: taskWorktreePath,
|
|
@@ -180,7 +311,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
180
311
|
createdAt: new Date(),
|
|
181
312
|
updatedAt: new Date(),
|
|
182
313
|
};
|
|
183
|
-
await this.updateTaskState(key, state);
|
|
314
|
+
await this.updateTaskState(key, state, { owner, repo, controlPath });
|
|
184
315
|
console.log(`Successfully created worktree for task ${taskId}`);
|
|
185
316
|
return {
|
|
186
317
|
taskId,
|
|
@@ -196,25 +327,53 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
196
327
|
throw error;
|
|
197
328
|
}
|
|
198
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
|
+
if (hasOrigin) {
|
|
358
|
+
await git.raw(["push", "origin", `${branchName}:${branchName}`]);
|
|
359
|
+
await git.fetch("origin", branchName);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
await git.raw(["checkout", "--orphan", branchName]);
|
|
364
|
+
await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
199
367
|
/**
|
|
200
368
|
* Remove a task worktree
|
|
201
369
|
*/
|
|
202
370
|
async removeTaskWorktree(taskId, options = {}) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
let taskState = null;
|
|
206
|
-
for (const [key, state] of this.repoStates) {
|
|
207
|
-
const repo = state.repositories[key];
|
|
208
|
-
if (repo?.worktrees[taskId]) {
|
|
209
|
-
repoKey = key;
|
|
210
|
-
taskState = repo.worktrees[taskId];
|
|
211
|
-
break;
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (!repoKey || !taskState) {
|
|
371
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
372
|
+
if (!entry) {
|
|
215
373
|
console.log(`No worktree found for task ${taskId}`);
|
|
216
374
|
return;
|
|
217
375
|
}
|
|
376
|
+
const { repoKey, repo, taskState } = entry;
|
|
218
377
|
// Handle local workspaces differently
|
|
219
378
|
if (repoKey === "__local__") {
|
|
220
379
|
// Just remove from state, don't touch the filesystem
|
|
@@ -227,13 +386,24 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
227
386
|
console.log(`Removed local task handle for task ${taskId}`);
|
|
228
387
|
return;
|
|
229
388
|
}
|
|
230
|
-
const
|
|
231
|
-
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
389
|
+
const controlPath = repo.controlPath;
|
|
232
390
|
try {
|
|
233
391
|
console.log(`Removing worktree for task ${taskId}...`);
|
|
234
|
-
// Remove the worktree
|
|
235
392
|
const git = simpleGit(controlPath);
|
|
236
|
-
|
|
393
|
+
// Remove the worktree (when present)
|
|
394
|
+
if (taskState.worktreePath) {
|
|
395
|
+
const worktreePath = taskState.worktreePath;
|
|
396
|
+
try {
|
|
397
|
+
await fs.access(worktreePath);
|
|
398
|
+
await git.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
// Worktree path already gone; continue so we can still delete the branch if needed
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
await this.cleanupDirectory(worktreePath);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
237
407
|
// Delete the branch if not preserving
|
|
238
408
|
if (!options.preserveBranch) {
|
|
239
409
|
try {
|
|
@@ -243,10 +413,19 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
243
413
|
console.warn(`Failed to delete branch ${taskState.branch}:`, error);
|
|
244
414
|
}
|
|
245
415
|
}
|
|
246
|
-
//
|
|
416
|
+
// Update state
|
|
247
417
|
const repoState = this.repoStates.get(repoKey);
|
|
248
|
-
if (repoState?.repositories[
|
|
249
|
-
|
|
418
|
+
if (repoState?.repositories[repoKey]?.worktrees) {
|
|
419
|
+
if (options.preserveBranch) {
|
|
420
|
+
repoState.repositories[repoKey].worktrees[taskId] = {
|
|
421
|
+
...taskState,
|
|
422
|
+
worktreePath: "",
|
|
423
|
+
updatedAt: new Date(),
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
delete repoState.repositories[repoKey].worktrees[taskId];
|
|
428
|
+
}
|
|
250
429
|
}
|
|
251
430
|
await this.persistState();
|
|
252
431
|
console.log(`Successfully removed worktree for task ${taskId}`);
|
|
@@ -303,9 +482,17 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
303
482
|
const commitHash = result.commit;
|
|
304
483
|
// Update task state
|
|
305
484
|
taskState.lastCommit = commitHash;
|
|
485
|
+
taskState.commitCount = (taskState.commitCount ?? 0) + 1;
|
|
306
486
|
taskState.updatedAt = new Date();
|
|
307
487
|
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
308
488
|
console.log(`Created commit ${commitHash} for task ${taskId}`);
|
|
489
|
+
logger.info(`Committed task ${taskId} (${taskState.branch}) -> ${commitHash}`, {
|
|
490
|
+
taskId,
|
|
491
|
+
branch: taskState.branch,
|
|
492
|
+
baseBranch: taskState.baseBranch,
|
|
493
|
+
commit: commitHash,
|
|
494
|
+
worktreePath: taskState.worktreePath,
|
|
495
|
+
});
|
|
309
496
|
return commitHash;
|
|
310
497
|
}
|
|
311
498
|
/**
|
|
@@ -344,19 +531,44 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
344
531
|
}
|
|
345
532
|
const git = simpleGit(taskState.worktreePath);
|
|
346
533
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
534
|
+
const remotes = await git.getRemotes(true);
|
|
535
|
+
const hasOrigin = remotes.some((r) => r.name === "origin");
|
|
536
|
+
logger.info(`Rebasing task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
|
|
537
|
+
taskId,
|
|
538
|
+
branch: taskState.branch,
|
|
539
|
+
targetBranch,
|
|
540
|
+
hasOrigin,
|
|
541
|
+
worktreePath: taskState.worktreePath,
|
|
542
|
+
});
|
|
543
|
+
if (hasOrigin) {
|
|
544
|
+
// Fetch latest changes
|
|
545
|
+
await git.fetch("origin", targetBranch);
|
|
546
|
+
// Perform rebase
|
|
547
|
+
await git.rebase([`origin/${targetBranch}`]);
|
|
548
|
+
}
|
|
549
|
+
else {
|
|
550
|
+
await git.rebase([targetBranch]);
|
|
551
|
+
}
|
|
351
552
|
// Update base branch in state
|
|
352
553
|
taskState.baseBranch = targetBranch;
|
|
353
554
|
taskState.updatedAt = new Date();
|
|
354
555
|
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
355
556
|
console.log(`Successfully rebased task ${taskId} onto ${targetBranch}`);
|
|
557
|
+
logger.info(`Rebased task ${taskId} (${taskState.branch}) onto ${targetBranch}`, {
|
|
558
|
+
taskId,
|
|
559
|
+
branch: taskState.branch,
|
|
560
|
+
targetBranch,
|
|
561
|
+
});
|
|
356
562
|
return { success: true };
|
|
357
563
|
}
|
|
358
564
|
catch (error) {
|
|
359
565
|
console.error(`Rebase failed for task ${taskId}:`, error);
|
|
566
|
+
logger.error(`Rebase failed for task ${taskId} (${taskState.branch})`, {
|
|
567
|
+
taskId,
|
|
568
|
+
branch: taskState.branch,
|
|
569
|
+
targetBranch,
|
|
570
|
+
error: error instanceof Error ? error.message : String(error),
|
|
571
|
+
});
|
|
360
572
|
// Check for conflicts
|
|
361
573
|
const status = await git.status();
|
|
362
574
|
if (status.conflicted.length > 0) {
|
|
@@ -385,22 +597,83 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
385
597
|
* Merge a task branch into target branch
|
|
386
598
|
*/
|
|
387
599
|
async mergeTask(taskId, targetBranch, mode = "no-ff") {
|
|
388
|
-
const
|
|
389
|
-
if (!
|
|
600
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
601
|
+
if (!entry) {
|
|
390
602
|
throw new Error(`No worktree found for task ${taskId}`);
|
|
391
603
|
}
|
|
604
|
+
const { repoKey, taskState } = entry;
|
|
392
605
|
// Skip Git operations for local workspaces
|
|
393
606
|
if (taskState.branch === "local") {
|
|
394
607
|
console.log(`Skipping merge operation for local workspace task ${taskId}`);
|
|
395
608
|
return { success: true, mergedCommit: "local-merge" }; // Return success for local workspaces
|
|
396
609
|
}
|
|
397
|
-
|
|
610
|
+
return await this.withRepoLock(repoKey, async () => {
|
|
611
|
+
return this.mergeTaskUnlocked(entry, targetBranch, mode);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
async mergeTaskUnlocked(entry, targetBranch, mode) {
|
|
615
|
+
const { repoKey, repo, taskState } = entry;
|
|
616
|
+
const isLocalRepo = repoKey.startsWith("local__");
|
|
617
|
+
const mergePath = await (async () => {
|
|
618
|
+
if (isLocalRepo)
|
|
619
|
+
return repo.controlPath;
|
|
620
|
+
if (!taskState.workspaceId) {
|
|
621
|
+
throw new Error(`Cannot merge task ${taskState.taskId}: missing workspaceId in task Git state`);
|
|
622
|
+
}
|
|
623
|
+
const worktreesPath = path.join(this.repoBasePath, "repos", repoKey, "worktrees");
|
|
624
|
+
const workspaceWorktreePath = path.join(worktreesPath, `workspace_${taskState.workspaceId}`);
|
|
625
|
+
const exists = await fs
|
|
626
|
+
.access(workspaceWorktreePath)
|
|
627
|
+
.then(() => true)
|
|
628
|
+
.catch(() => false);
|
|
629
|
+
if (exists)
|
|
630
|
+
return workspaceWorktreePath;
|
|
631
|
+
await this.ensureDirectory(worktreesPath);
|
|
632
|
+
const controlGit = simpleGit(repo.controlPath);
|
|
633
|
+
await controlGit.fetch("origin", targetBranch);
|
|
634
|
+
await controlGit.raw([
|
|
635
|
+
"worktree",
|
|
636
|
+
"add",
|
|
637
|
+
workspaceWorktreePath,
|
|
638
|
+
`origin/${targetBranch}`,
|
|
639
|
+
]);
|
|
640
|
+
console.log(`Created merge worktree for workspace ${taskState.workspaceId} at ${workspaceWorktreePath}`);
|
|
641
|
+
return workspaceWorktreePath;
|
|
642
|
+
})();
|
|
643
|
+
const git = simpleGit(mergePath);
|
|
398
644
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
645
|
+
const remotes = await git.getRemotes(true);
|
|
646
|
+
const hasOrigin = remotes.some((r) => r.name === "origin");
|
|
647
|
+
logger.info(`Merging task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
|
|
648
|
+
taskId: taskState.taskId,
|
|
649
|
+
taskBranch: taskState.branch,
|
|
650
|
+
targetBranch,
|
|
651
|
+
mergePath,
|
|
652
|
+
hasOrigin,
|
|
653
|
+
isLocalRepo,
|
|
654
|
+
mode,
|
|
655
|
+
});
|
|
656
|
+
if (hasOrigin) {
|
|
657
|
+
// Fetch latest changes and ensure a local targetBranch exists + is up-to-date
|
|
658
|
+
await git.fetch("origin", targetBranch);
|
|
659
|
+
const localBranches = await git.branchLocal();
|
|
660
|
+
const hasLocalTarget = localBranches.all.includes(targetBranch);
|
|
661
|
+
if (!hasLocalTarget) {
|
|
662
|
+
await git.raw(["checkout", "-B", targetBranch, `origin/${targetBranch}`]);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
await git.checkout(targetBranch);
|
|
666
|
+
try {
|
|
667
|
+
await git.merge(["--ff-only", `origin/${targetBranch}`]);
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
throw new Error(`Base branch ${targetBranch} has diverged from origin/${targetBranch}; cannot fast-forward`);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
await git.checkout(targetBranch);
|
|
676
|
+
}
|
|
404
677
|
// Perform merge based on mode
|
|
405
678
|
let mergeArgs = [];
|
|
406
679
|
switch (mode) {
|
|
@@ -414,34 +687,56 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
414
687
|
mergeArgs = ["--squash"];
|
|
415
688
|
break;
|
|
416
689
|
}
|
|
417
|
-
const result = await git.merge([taskState.branch
|
|
690
|
+
const result = await git.merge([...mergeArgs, taskState.branch]);
|
|
418
691
|
// For squash merge, we need to commit
|
|
419
692
|
if (mode === "squash") {
|
|
420
|
-
const commitResult = await git.commit(`Merge task ${taskId} (${taskState.branch})`);
|
|
693
|
+
const commitResult = await git.commit(`Merge task ${taskState.taskId} (${taskState.branch})`);
|
|
421
694
|
result.result = commitResult.commit;
|
|
422
695
|
}
|
|
696
|
+
if (hasOrigin) {
|
|
697
|
+
await git.push("origin", targetBranch);
|
|
698
|
+
}
|
|
423
699
|
// Update task state
|
|
424
700
|
taskState.status = "merged";
|
|
425
701
|
taskState.updatedAt = new Date();
|
|
426
|
-
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
427
|
-
console.log(`Successfully merged task ${taskId} into ${targetBranch}`);
|
|
702
|
+
await this.updateTaskStateByTaskId(taskState.taskId, taskState);
|
|
703
|
+
console.log(`Successfully merged task ${taskState.taskId} into ${targetBranch}`);
|
|
704
|
+
logger.info(`Merged task ${taskState.taskId} (${taskState.branch}) into ${targetBranch}`, {
|
|
705
|
+
taskId: taskState.taskId,
|
|
706
|
+
taskBranch: taskState.branch,
|
|
707
|
+
targetBranch,
|
|
708
|
+
mergedCommit: result.result,
|
|
709
|
+
pushed: hasOrigin,
|
|
710
|
+
});
|
|
428
711
|
return {
|
|
429
712
|
success: true,
|
|
430
713
|
mergedCommit: result.result,
|
|
431
714
|
};
|
|
432
715
|
}
|
|
433
716
|
catch (error) {
|
|
434
|
-
console.error(`Merge failed for task ${taskId}:`, error);
|
|
717
|
+
console.error(`Merge failed for task ${taskState.taskId}:`, error);
|
|
718
|
+
logger.error(`Merge failed for task ${taskState.taskId} (${taskState.branch})`, {
|
|
719
|
+
taskId: taskState.taskId,
|
|
720
|
+
taskBranch: taskState.branch,
|
|
721
|
+
targetBranch,
|
|
722
|
+
mergePath,
|
|
723
|
+
error: error instanceof Error ? error.message : String(error),
|
|
724
|
+
});
|
|
435
725
|
// Check for conflicts
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
726
|
+
try {
|
|
727
|
+
const status = await git.status();
|
|
728
|
+
if (status.conflicted.length > 0) {
|
|
729
|
+
taskState.status = "conflicted";
|
|
730
|
+
await this.updateTaskStateByTaskId(taskState.taskId, taskState);
|
|
731
|
+
return {
|
|
732
|
+
success: false,
|
|
733
|
+
conflicts: status.conflicted,
|
|
734
|
+
error: "Merge resulted in conflicts",
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// Ignore status errors (e.g. bare repositories)
|
|
445
740
|
}
|
|
446
741
|
return {
|
|
447
742
|
success: false,
|
|
@@ -449,23 +744,205 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
449
744
|
};
|
|
450
745
|
}
|
|
451
746
|
}
|
|
747
|
+
async getTaskRepoInfo(taskId) {
|
|
748
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
749
|
+
if (!entry)
|
|
750
|
+
return null;
|
|
751
|
+
return {
|
|
752
|
+
repoKey: entry.repoKey,
|
|
753
|
+
controlPath: entry.repo.controlPath,
|
|
754
|
+
worktreePath: entry.taskState.worktreePath,
|
|
755
|
+
branch: entry.taskState.branch,
|
|
756
|
+
baseBranch: entry.taskState.baseBranch,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
452
759
|
/**
|
|
453
|
-
*
|
|
760
|
+
* Fast-forward (or hard reset) the shared workspace worktree to a branch tip after a task merge.
|
|
761
|
+
* This keeps the "workspace" checkout in sync with concurrent task integrations.
|
|
762
|
+
*
|
|
763
|
+
* We only update existing workspace worktrees and only when they're clean to avoid
|
|
764
|
+
* clobbering any active non-concurrent task that might be using the workspace checkout.
|
|
454
765
|
*/
|
|
455
|
-
async
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
766
|
+
async syncWorkspaceWorktree(workspaceId, repoUrl, branch) {
|
|
767
|
+
if (!workspaceId || !repoUrl)
|
|
768
|
+
return;
|
|
769
|
+
// Local repos merge directly into the primary working tree (controlPath), nothing to sync.
|
|
770
|
+
if (repoUrl.startsWith("file://"))
|
|
771
|
+
return;
|
|
772
|
+
const { key } = this.getRepoKey(repoUrl);
|
|
773
|
+
const workspaceWorktreePath = path.join(this.repoBasePath, "repos", key, "worktrees", `workspace_${workspaceId}`);
|
|
774
|
+
const exists = await fs
|
|
775
|
+
.access(workspaceWorktreePath)
|
|
776
|
+
.then(() => true)
|
|
777
|
+
.catch(() => false);
|
|
778
|
+
await this.withRepoLock(key, async () => {
|
|
779
|
+
if (!exists) {
|
|
780
|
+
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
781
|
+
const controlExists = await fs
|
|
782
|
+
.access(controlPath)
|
|
783
|
+
.then(() => true)
|
|
784
|
+
.catch(() => false);
|
|
785
|
+
if (!controlExists) {
|
|
786
|
+
console.warn(`Skipping workspace worktree sync for ${workspaceId}: control repo missing`, { controlPath, repoUrl });
|
|
787
|
+
return;
|
|
460
788
|
}
|
|
789
|
+
await this.ensureDirectory(path.dirname(workspaceWorktreePath));
|
|
790
|
+
const controlGit = simpleGit(controlPath);
|
|
791
|
+
const remotes = await controlGit.getRemotes(true);
|
|
792
|
+
const hasOrigin = remotes.some((remote) => remote.name === "origin");
|
|
793
|
+
const checkoutRef = hasOrigin ? `origin/${branch}` : branch;
|
|
794
|
+
await controlGit.raw(["worktree", "add", workspaceWorktreePath, checkoutRef]);
|
|
795
|
+
console.log(`Created workspace worktree for ${workspaceId} at ${workspaceWorktreePath}`);
|
|
796
|
+
}
|
|
797
|
+
const git = simpleGit(workspaceWorktreePath);
|
|
798
|
+
const status = await git.status();
|
|
799
|
+
if (status.conflicted.length > 0 || !status.isClean()) {
|
|
800
|
+
console.warn(`Skipping workspace worktree sync for ${workspaceId}: worktree is not clean`, {
|
|
801
|
+
path: workspaceWorktreePath,
|
|
802
|
+
branch,
|
|
803
|
+
conflicted: status.conflicted,
|
|
804
|
+
fileCount: status.files.length,
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const remotes = await git.getRemotes(true);
|
|
809
|
+
const hasOrigin = remotes.some((remote) => remote.name === "origin");
|
|
810
|
+
if (hasOrigin) {
|
|
811
|
+
await git.fetch("origin", branch);
|
|
812
|
+
await git.reset(["--hard", `origin/${branch}`]);
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
await git.reset(["--hard", branch]);
|
|
461
816
|
}
|
|
817
|
+
await git.clean("f", ["-d"]);
|
|
818
|
+
console.log(`Synced workspace worktree for ${workspaceId} to ${branch} at ${workspaceWorktreePath}`);
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
async isTaskBranchMerged(taskId, targetBranch) {
|
|
822
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
823
|
+
if (!entry)
|
|
824
|
+
return false;
|
|
825
|
+
const { repo, taskState } = entry;
|
|
826
|
+
const git = simpleGit(repo.controlPath);
|
|
827
|
+
try {
|
|
828
|
+
// Ensure targetBranch exists locally (avoid erroring for repos that only have origin/<branch>)
|
|
829
|
+
const showRef = await git.raw([
|
|
830
|
+
"show-ref",
|
|
831
|
+
"--verify",
|
|
832
|
+
`refs/heads/${targetBranch}`,
|
|
833
|
+
]);
|
|
834
|
+
if (!showRef?.trim())
|
|
835
|
+
return false;
|
|
462
836
|
}
|
|
463
|
-
|
|
837
|
+
catch {
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
try {
|
|
841
|
+
await git.raw([
|
|
842
|
+
"merge-base",
|
|
843
|
+
"--is-ancestor",
|
|
844
|
+
taskState.branch,
|
|
845
|
+
targetBranch,
|
|
846
|
+
]);
|
|
847
|
+
return true;
|
|
848
|
+
}
|
|
849
|
+
catch {
|
|
850
|
+
return false;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
async pushBranch(taskId, branch) {
|
|
854
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
855
|
+
if (!entry)
|
|
856
|
+
return;
|
|
857
|
+
const git = simpleGit(entry.repo.controlPath);
|
|
858
|
+
const remotes = await git.getRemotes(true);
|
|
859
|
+
const hasOrigin = remotes.some((r) => r.name === "origin");
|
|
860
|
+
if (!hasOrigin)
|
|
861
|
+
return;
|
|
862
|
+
await git.push("origin", branch);
|
|
863
|
+
}
|
|
864
|
+
async integrateTask(taskId, targetBranch, mode = "no-ff") {
|
|
865
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
866
|
+
if (!entry) {
|
|
867
|
+
return {
|
|
868
|
+
success: false,
|
|
869
|
+
phase: "merge",
|
|
870
|
+
error: `No worktree found for task ${taskId}`,
|
|
871
|
+
conflictWorkdir: "",
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
const { repoKey, repo, taskState } = entry;
|
|
875
|
+
const isLocalRepo = repoKey.startsWith("local__");
|
|
876
|
+
const mergeWorkdir = isLocalRepo
|
|
877
|
+
? repo.controlPath
|
|
878
|
+
: taskState.workspaceId
|
|
879
|
+
? path.join(this.repoBasePath, "repos", repoKey, "worktrees", `workspace_${taskState.workspaceId}`)
|
|
880
|
+
: taskState.worktreePath;
|
|
881
|
+
logger.info(`Integrating task ${taskId} (${taskState.branch}) into ${targetBranch}`, {
|
|
882
|
+
taskId,
|
|
883
|
+
taskBranch: taskState.branch,
|
|
884
|
+
targetBranch,
|
|
885
|
+
mode,
|
|
886
|
+
isLocalRepo,
|
|
887
|
+
controlPath: repo.controlPath,
|
|
888
|
+
worktreePath: taskState.worktreePath,
|
|
889
|
+
});
|
|
890
|
+
return await this.withRepoLock(repoKey, async () => {
|
|
891
|
+
const rebaseResult = await this.rebaseTask(taskId, targetBranch);
|
|
892
|
+
if (!rebaseResult.success) {
|
|
893
|
+
logger.warn(`Task ${taskId} rebase failed`, {
|
|
894
|
+
taskId,
|
|
895
|
+
phase: "rebase",
|
|
896
|
+
targetBranch,
|
|
897
|
+
conflicts: rebaseResult.conflicts ?? [],
|
|
898
|
+
error: rebaseResult.error,
|
|
899
|
+
});
|
|
900
|
+
return {
|
|
901
|
+
success: false,
|
|
902
|
+
phase: "rebase",
|
|
903
|
+
conflicts: rebaseResult.conflicts ?? [],
|
|
904
|
+
error: rebaseResult.error,
|
|
905
|
+
conflictWorkdir: taskState.worktreePath,
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
const mergeResult = await this.mergeTaskUnlocked(entry, targetBranch, mode);
|
|
909
|
+
if (!mergeResult.success) {
|
|
910
|
+
logger.warn(`Task ${taskId} merge failed`, {
|
|
911
|
+
taskId,
|
|
912
|
+
phase: "merge",
|
|
913
|
+
targetBranch,
|
|
914
|
+
conflicts: mergeResult.conflicts ?? [],
|
|
915
|
+
error: mergeResult.error,
|
|
916
|
+
});
|
|
917
|
+
return {
|
|
918
|
+
success: false,
|
|
919
|
+
phase: "merge",
|
|
920
|
+
conflicts: mergeResult.conflicts ?? [],
|
|
921
|
+
error: mergeResult.error,
|
|
922
|
+
conflictWorkdir: mergeWorkdir,
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
logger.info(`Integrated task ${taskId} into ${targetBranch}`, {
|
|
926
|
+
taskId,
|
|
927
|
+
targetBranch,
|
|
928
|
+
mergedCommit: mergeResult.mergedCommit,
|
|
929
|
+
});
|
|
930
|
+
return {
|
|
931
|
+
success: true,
|
|
932
|
+
mergedCommit: mergeResult.mergedCommit,
|
|
933
|
+
};
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
/**
|
|
937
|
+
* Get task Git state
|
|
938
|
+
*/
|
|
939
|
+
async getTaskState(taskId) {
|
|
940
|
+
return this.findTaskStateEntry(taskId)?.taskState ?? null;
|
|
464
941
|
}
|
|
465
942
|
/**
|
|
466
943
|
* Update task state
|
|
467
944
|
*/
|
|
468
|
-
async updateTaskState(repoKey, state) {
|
|
945
|
+
async updateTaskState(repoKey, state, repoInfo) {
|
|
469
946
|
if (!this.repoStates.has(repoKey)) {
|
|
470
947
|
this.repoStates.set(repoKey, {
|
|
471
948
|
repositories: {},
|
|
@@ -482,8 +959,16 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
482
959
|
worktrees: {},
|
|
483
960
|
};
|
|
484
961
|
}
|
|
962
|
+
else if (repoInfo) {
|
|
963
|
+
repoState.repositories[repoKey] = {
|
|
964
|
+
owner: repoInfo.owner,
|
|
965
|
+
repo: repoInfo.repo,
|
|
966
|
+
controlPath: repoInfo.controlPath,
|
|
967
|
+
worktrees: {},
|
|
968
|
+
};
|
|
969
|
+
}
|
|
485
970
|
else {
|
|
486
|
-
const { owner, repo } = this.
|
|
971
|
+
const { owner, repo } = this.parseRepoKey(repoKey);
|
|
487
972
|
repoState.repositories[repoKey] = {
|
|
488
973
|
owner,
|
|
489
974
|
repo,
|
|
@@ -492,6 +977,10 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
492
977
|
};
|
|
493
978
|
}
|
|
494
979
|
}
|
|
980
|
+
else if (repoInfo?.controlPath) {
|
|
981
|
+
// Ensure we persist the latest controlPath (important for local repos)
|
|
982
|
+
repoState.repositories[repoKey].controlPath = repoInfo.controlPath;
|
|
983
|
+
}
|
|
495
984
|
repoState.repositories[repoKey].worktrees[state.taskId] = state;
|
|
496
985
|
await this.persistState();
|
|
497
986
|
}
|
|
@@ -518,6 +1007,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
518
1007
|
for (const [key, value] of this.repoStates) {
|
|
519
1008
|
stateData[key] = value;
|
|
520
1009
|
}
|
|
1010
|
+
await this.ensureDirectory(path.dirname(this.stateFilePath));
|
|
521
1011
|
await fs.writeFile(this.stateFilePath, JSON.stringify(stateData, null, 2));
|
|
522
1012
|
console.log("Persisted repository state");
|
|
523
1013
|
}
|
|
@@ -529,6 +1019,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
529
1019
|
* Restore state from disk
|
|
530
1020
|
*/
|
|
531
1021
|
async restoreState() {
|
|
1022
|
+
await this.migrateLegacyStateFileIfNeeded();
|
|
532
1023
|
try {
|
|
533
1024
|
const data = await fs.readFile(this.stateFilePath, "utf-8");
|
|
534
1025
|
const stateData = JSON.parse(data);
|
|
@@ -552,6 +1043,23 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
552
1043
|
// If file doesn't exist, that's okay - we'll start fresh
|
|
553
1044
|
}
|
|
554
1045
|
}
|
|
1046
|
+
async migrateLegacyStateFileIfNeeded() {
|
|
1047
|
+
const hasNewStateFile = await fs
|
|
1048
|
+
.access(this.stateFilePath)
|
|
1049
|
+
.then(() => true)
|
|
1050
|
+
.catch(() => false);
|
|
1051
|
+
if (hasNewStateFile)
|
|
1052
|
+
return;
|
|
1053
|
+
const hasLegacyStateFile = await fs
|
|
1054
|
+
.access(this.legacyStateFilePath)
|
|
1055
|
+
.then(() => true)
|
|
1056
|
+
.catch(() => false);
|
|
1057
|
+
if (!hasLegacyStateFile)
|
|
1058
|
+
return;
|
|
1059
|
+
await this.ensureDirectory(path.dirname(this.stateFilePath));
|
|
1060
|
+
await fs.rename(this.legacyStateFilePath, this.stateFilePath);
|
|
1061
|
+
console.log(`Moved legacy repository state file to runner data directory: ${this.stateFilePath}`);
|
|
1062
|
+
}
|
|
555
1063
|
/**
|
|
556
1064
|
* Override parent's checkoutRepository to use worktrees for backward compatibility
|
|
557
1065
|
*/
|