@northflare/runner 0.0.28 → 0.0.30
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 +206 -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 +183 -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 +597 -96
- package/dist/components/enhanced-repository-manager.js.map +1 -1
- package/dist/components/message-handler-sse.d.ts +18 -0
- package/dist/components/message-handler-sse.d.ts.map +1 -1
- package/dist/components/message-handler-sse.js +147 -1
- 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 +184 -9
- package/dist/components/northflare-agent-sdk-manager.js.map +1 -1
- package/dist/runner-sse.d.ts +4 -0
- package/dist/runner-sse.d.ts.map +1 -1
- package/dist/runner-sse.js +28 -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 +20 -19
|
@@ -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,50 @@ 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
|
+
void hasOrigin;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
await git.raw(["checkout", "--orphan", branchName]);
|
|
361
|
+
await git.raw(["commit", "--allow-empty", "-m", "Initial commit"]);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
199
364
|
/**
|
|
200
365
|
* Remove a task worktree
|
|
201
366
|
*/
|
|
202
367
|
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) {
|
|
368
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
369
|
+
if (!entry) {
|
|
215
370
|
console.log(`No worktree found for task ${taskId}`);
|
|
216
371
|
return;
|
|
217
372
|
}
|
|
373
|
+
const { repoKey, repo, taskState } = entry;
|
|
218
374
|
// Handle local workspaces differently
|
|
219
375
|
if (repoKey === "__local__") {
|
|
220
376
|
// Just remove from state, don't touch the filesystem
|
|
@@ -227,13 +383,24 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
227
383
|
console.log(`Removed local task handle for task ${taskId}`);
|
|
228
384
|
return;
|
|
229
385
|
}
|
|
230
|
-
const
|
|
231
|
-
const controlPath = path.join(this.repoBasePath, "repos", key, "control");
|
|
386
|
+
const controlPath = repo.controlPath;
|
|
232
387
|
try {
|
|
233
388
|
console.log(`Removing worktree for task ${taskId}...`);
|
|
234
|
-
// Remove the worktree
|
|
235
389
|
const git = simpleGit(controlPath);
|
|
236
|
-
|
|
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
|
+
}
|
|
237
404
|
// Delete the branch if not preserving
|
|
238
405
|
if (!options.preserveBranch) {
|
|
239
406
|
try {
|
|
@@ -243,10 +410,19 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
243
410
|
console.warn(`Failed to delete branch ${taskState.branch}:`, error);
|
|
244
411
|
}
|
|
245
412
|
}
|
|
246
|
-
//
|
|
413
|
+
// Update state
|
|
247
414
|
const repoState = this.repoStates.get(repoKey);
|
|
248
|
-
if (repoState?.repositories[
|
|
249
|
-
|
|
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
|
+
}
|
|
250
426
|
}
|
|
251
427
|
await this.persistState();
|
|
252
428
|
console.log(`Successfully removed worktree for task ${taskId}`);
|
|
@@ -303,9 +479,17 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
303
479
|
const commitHash = result.commit;
|
|
304
480
|
// Update task state
|
|
305
481
|
taskState.lastCommit = commitHash;
|
|
482
|
+
taskState.commitCount = (taskState.commitCount ?? 0) + 1;
|
|
306
483
|
taskState.updatedAt = new Date();
|
|
307
484
|
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
308
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
|
+
});
|
|
309
493
|
return commitHash;
|
|
310
494
|
}
|
|
311
495
|
/**
|
|
@@ -344,19 +528,44 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
344
528
|
}
|
|
345
529
|
const git = simpleGit(taskState.worktreePath);
|
|
346
530
|
try {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
}
|
|
351
549
|
// Update base branch in state
|
|
352
550
|
taskState.baseBranch = targetBranch;
|
|
353
551
|
taskState.updatedAt = new Date();
|
|
354
552
|
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
355
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
|
+
});
|
|
356
559
|
return { success: true };
|
|
357
560
|
}
|
|
358
561
|
catch (error) {
|
|
359
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
|
+
});
|
|
360
569
|
// Check for conflicts
|
|
361
570
|
const status = await git.status();
|
|
362
571
|
if (status.conflicted.length > 0) {
|
|
@@ -385,22 +594,83 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
385
594
|
* Merge a task branch into target branch
|
|
386
595
|
*/
|
|
387
596
|
async mergeTask(taskId, targetBranch, mode = "no-ff") {
|
|
388
|
-
const
|
|
389
|
-
if (!
|
|
597
|
+
const entry = this.findTaskStateEntry(taskId);
|
|
598
|
+
if (!entry) {
|
|
390
599
|
throw new Error(`No worktree found for task ${taskId}`);
|
|
391
600
|
}
|
|
601
|
+
const { repoKey, taskState } = entry;
|
|
392
602
|
// Skip Git operations for local workspaces
|
|
393
603
|
if (taskState.branch === "local") {
|
|
394
604
|
console.log(`Skipping merge operation for local workspace task ${taskId}`);
|
|
395
605
|
return { success: true, mergedCommit: "local-merge" }; // Return success for local workspaces
|
|
396
606
|
}
|
|
397
|
-
|
|
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);
|
|
398
641
|
try {
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
}
|
|
404
674
|
// Perform merge based on mode
|
|
405
675
|
let mergeArgs = [];
|
|
406
676
|
switch (mode) {
|
|
@@ -414,34 +684,52 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
414
684
|
mergeArgs = ["--squash"];
|
|
415
685
|
break;
|
|
416
686
|
}
|
|
417
|
-
const result = await git.merge([taskState.branch
|
|
687
|
+
const result = await git.merge([...mergeArgs, taskState.branch]);
|
|
418
688
|
// For squash merge, we need to commit
|
|
419
689
|
if (mode === "squash") {
|
|
420
|
-
const commitResult = await git.commit(`Merge task ${taskId} (${taskState.branch})`);
|
|
690
|
+
const commitResult = await git.commit(`Merge task ${taskState.taskId} (${taskState.branch})`);
|
|
421
691
|
result.result = commitResult.commit;
|
|
422
692
|
}
|
|
423
693
|
// Update task state
|
|
424
694
|
taskState.status = "merged";
|
|
425
695
|
taskState.updatedAt = new Date();
|
|
426
|
-
await this.updateTaskStateByTaskId(taskId, taskState);
|
|
427
|
-
console.log(`Successfully merged task ${taskId} into ${targetBranch}`);
|
|
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
|
+
});
|
|
428
704
|
return {
|
|
429
705
|
success: true,
|
|
430
706
|
mergedCommit: result.result,
|
|
431
707
|
};
|
|
432
708
|
}
|
|
433
709
|
catch (error) {
|
|
434
|
-
console.error(`Merge failed for task ${taskId}:`, 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
|
+
});
|
|
435
718
|
// Check for conflicts
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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)
|
|
445
733
|
}
|
|
446
734
|
return {
|
|
447
735
|
success: false,
|
|
@@ -449,23 +737,205 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
449
737
|
};
|
|
450
738
|
}
|
|
451
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
|
+
}
|
|
452
752
|
/**
|
|
453
|
-
*
|
|
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.
|
|
454
758
|
*/
|
|
455
|
-
async
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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;
|
|
460
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;
|
|
461
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;
|
|
462
844
|
}
|
|
463
|
-
|
|
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;
|
|
464
934
|
}
|
|
465
935
|
/**
|
|
466
936
|
* Update task state
|
|
467
937
|
*/
|
|
468
|
-
async updateTaskState(repoKey, state) {
|
|
938
|
+
async updateTaskState(repoKey, state, repoInfo) {
|
|
469
939
|
if (!this.repoStates.has(repoKey)) {
|
|
470
940
|
this.repoStates.set(repoKey, {
|
|
471
941
|
repositories: {},
|
|
@@ -482,8 +952,16 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
482
952
|
worktrees: {},
|
|
483
953
|
};
|
|
484
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
|
+
}
|
|
485
963
|
else {
|
|
486
|
-
const { owner, repo } = this.
|
|
964
|
+
const { owner, repo } = this.parseRepoKey(repoKey);
|
|
487
965
|
repoState.repositories[repoKey] = {
|
|
488
966
|
owner,
|
|
489
967
|
repo,
|
|
@@ -492,6 +970,10 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
492
970
|
};
|
|
493
971
|
}
|
|
494
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
|
+
}
|
|
495
977
|
repoState.repositories[repoKey].worktrees[state.taskId] = state;
|
|
496
978
|
await this.persistState();
|
|
497
979
|
}
|
|
@@ -518,6 +1000,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
518
1000
|
for (const [key, value] of this.repoStates) {
|
|
519
1001
|
stateData[key] = value;
|
|
520
1002
|
}
|
|
1003
|
+
await this.ensureDirectory(path.dirname(this.stateFilePath));
|
|
521
1004
|
await fs.writeFile(this.stateFilePath, JSON.stringify(stateData, null, 2));
|
|
522
1005
|
console.log("Persisted repository state");
|
|
523
1006
|
}
|
|
@@ -529,6 +1012,7 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
529
1012
|
* Restore state from disk
|
|
530
1013
|
*/
|
|
531
1014
|
async restoreState() {
|
|
1015
|
+
await this.migrateLegacyStateFileIfNeeded();
|
|
532
1016
|
try {
|
|
533
1017
|
const data = await fs.readFile(this.stateFilePath, "utf-8");
|
|
534
1018
|
const stateData = JSON.parse(data);
|
|
@@ -552,6 +1036,23 @@ export class EnhancedRepositoryManager extends RepositoryManager {
|
|
|
552
1036
|
// If file doesn't exist, that's okay - we'll start fresh
|
|
553
1037
|
}
|
|
554
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
|
+
}
|
|
555
1056
|
/**
|
|
556
1057
|
* Override parent's checkoutRepository to use worktrees for backward compatibility
|
|
557
1058
|
*/
|