@fixy/core 0.0.0
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/__tests__/diff-parser.test.d.ts +2 -0
- package/dist/__tests__/diff-parser.test.d.ts.map +1 -0
- package/dist/__tests__/diff-parser.test.js +89 -0
- package/dist/__tests__/diff-parser.test.js.map +1 -0
- package/dist/__tests__/fixy-commands.test.d.ts +2 -0
- package/dist/__tests__/fixy-commands.test.d.ts.map +1 -0
- package/dist/__tests__/fixy-commands.test.js +176 -0
- package/dist/__tests__/fixy-commands.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +66 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/router.test.d.ts +2 -0
- package/dist/__tests__/router.test.d.ts.map +1 -0
- package/dist/__tests__/router.test.js +77 -0
- package/dist/__tests__/router.test.js.map +1 -0
- package/dist/__tests__/smoke.test.d.ts +2 -0
- package/dist/__tests__/smoke.test.d.ts.map +1 -0
- package/dist/__tests__/smoke.test.js +7 -0
- package/dist/__tests__/smoke.test.js.map +1 -0
- package/dist/__tests__/store.test.d.ts +2 -0
- package/dist/__tests__/store.test.d.ts.map +1 -0
- package/dist/__tests__/store.test.js +121 -0
- package/dist/__tests__/store.test.js.map +1 -0
- package/dist/__tests__/turn.test.d.ts +2 -0
- package/dist/__tests__/turn.test.d.ts.map +1 -0
- package/dist/__tests__/turn.test.js +194 -0
- package/dist/__tests__/turn.test.js.map +1 -0
- package/dist/__tests__/worktree.test.d.ts +2 -0
- package/dist/__tests__/worktree.test.d.ts.map +1 -0
- package/dist/__tests__/worktree.test.js +119 -0
- package/dist/__tests__/worktree.test.js.map +1 -0
- package/dist/adapter.d.ts +75 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/adapter.js +3 -0
- package/dist/adapter.js.map +1 -0
- package/dist/diff-parser.d.ts +3 -0
- package/dist/diff-parser.d.ts.map +1 -0
- package/dist/diff-parser.js +38 -0
- package/dist/diff-parser.js.map +1 -0
- package/dist/fixy-commands.d.ts +25 -0
- package/dist/fixy-commands.d.ts.map +1 -0
- package/dist/fixy-commands.js +154 -0
- package/dist/fixy-commands.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +36 -0
- package/dist/paths.js.map +1 -0
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +32 -0
- package/dist/registry.js.map +1 -0
- package/dist/router.d.ts +21 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +36 -0
- package/dist/router.js.map +1 -0
- package/dist/store.d.ts +36 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +127 -0
- package/dist/store.js.map +1 -0
- package/dist/thread.d.ts +47 -0
- package/dist/thread.d.ts.map +1 -0
- package/dist/thread.js +3 -0
- package/dist/thread.js.map +1 -0
- package/dist/turn.d.ts +20 -0
- package/dist/turn.d.ts.map +1 -0
- package/dist/turn.js +130 -0
- package/dist/turn.js.map +1 -0
- package/dist/worktree.d.ts +36 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +91 -0
- package/dist/worktree.js.map +1 -0
- package/package.json +21 -0
- package/src/__tests__/diff-parser.test.ts +99 -0
- package/src/__tests__/fixy-commands.test.ts +231 -0
- package/src/__tests__/registry.test.ts +79 -0
- package/src/__tests__/router.test.ts +91 -0
- package/src/__tests__/smoke.test.ts +7 -0
- package/src/__tests__/store.test.ts +151 -0
- package/src/__tests__/turn.test.ts +266 -0
- package/src/__tests__/worktree.test.ts +155 -0
- package/src/adapter.ts +84 -0
- package/src/diff-parser.ts +46 -0
- package/src/fixy-commands.ts +201 -0
- package/src/index.ts +40 -0
- package/src/paths.ts +43 -0
- package/src/registry.ts +40 -0
- package/src/router.ts +49 -0
- package/src/store.ts +164 -0
- package/src/thread.ts +50 -0
- package/src/turn.ts +165 -0
- package/src/worktree.ts +119 -0
- package/tsconfig.json +9 -0
package/src/store.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// packages/core/src/store.ts
|
|
2
|
+
|
|
3
|
+
import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
import type { FixyMessage, FixyThread } from './thread.js';
|
|
8
|
+
import {
|
|
9
|
+
computeProjectId,
|
|
10
|
+
getFixyHome,
|
|
11
|
+
getProjectFile,
|
|
12
|
+
getThreadFile,
|
|
13
|
+
getThreadsDir,
|
|
14
|
+
} from './paths.js';
|
|
15
|
+
|
|
16
|
+
export class LocalThreadStore {
|
|
17
|
+
/**
|
|
18
|
+
* Ensures the top-level `projects/` and `worktrees/` directories exist
|
|
19
|
+
* under the Fixy home directory.
|
|
20
|
+
*/
|
|
21
|
+
async init(): Promise<void> {
|
|
22
|
+
const home = getFixyHome();
|
|
23
|
+
await Promise.all([
|
|
24
|
+
mkdir(join(home, 'projects'), { recursive: true }),
|
|
25
|
+
mkdir(join(home, 'worktrees'), { recursive: true }),
|
|
26
|
+
]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Creates a new thread for the given project root, persisting it to disk.
|
|
31
|
+
* Writes project.json if it does not already exist.
|
|
32
|
+
*/
|
|
33
|
+
async createThread(projectRoot: string): Promise<FixyThread> {
|
|
34
|
+
const id = randomUUID();
|
|
35
|
+
const projectId = computeProjectId(projectRoot);
|
|
36
|
+
const now = new Date().toISOString();
|
|
37
|
+
|
|
38
|
+
// Ensure project and threads directories exist.
|
|
39
|
+
await mkdir(getThreadsDir(projectRoot), { recursive: true });
|
|
40
|
+
|
|
41
|
+
// Write project.json only if it doesn't already exist (flag 'wx').
|
|
42
|
+
const projectFile = getProjectFile(projectRoot);
|
|
43
|
+
try {
|
|
44
|
+
await writeFile(
|
|
45
|
+
projectFile,
|
|
46
|
+
JSON.stringify({ projectId, projectRoot, createdAt: now }, null, 2),
|
|
47
|
+
{ flag: 'wx' },
|
|
48
|
+
);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
// EEXIST means the file already exists — that's fine.
|
|
51
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const thread: FixyThread = {
|
|
57
|
+
id,
|
|
58
|
+
projectId,
|
|
59
|
+
projectRoot,
|
|
60
|
+
createdAt: now,
|
|
61
|
+
updatedAt: now,
|
|
62
|
+
title: null,
|
|
63
|
+
status: 'active',
|
|
64
|
+
workerModel: 'claude',
|
|
65
|
+
agentSessions: {},
|
|
66
|
+
worktrees: {},
|
|
67
|
+
messages: [],
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
await this._writeAtomic(getThreadFile(projectRoot, id), thread);
|
|
71
|
+
|
|
72
|
+
return thread;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Appends a message to an existing thread and persists the update.
|
|
77
|
+
* Throws if the thread does not exist.
|
|
78
|
+
*/
|
|
79
|
+
async appendMessage(
|
|
80
|
+
threadId: string,
|
|
81
|
+
projectRoot: string,
|
|
82
|
+
message: FixyMessage,
|
|
83
|
+
): Promise<FixyThread> {
|
|
84
|
+
const thread = await this.getThread(threadId, projectRoot);
|
|
85
|
+
|
|
86
|
+
thread.messages.push(message);
|
|
87
|
+
thread.updatedAt = new Date().toISOString();
|
|
88
|
+
|
|
89
|
+
await this._writeAtomic(getThreadFile(projectRoot, threadId), thread);
|
|
90
|
+
|
|
91
|
+
return thread;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reads and returns a single thread by id.
|
|
96
|
+
* Throws if the thread file does not exist.
|
|
97
|
+
*/
|
|
98
|
+
async getThread(threadId: string, projectRoot: string): Promise<FixyThread> {
|
|
99
|
+
const threadPath = getThreadFile(projectRoot, threadId);
|
|
100
|
+
try {
|
|
101
|
+
const raw = await readFile(threadPath, 'utf8');
|
|
102
|
+
return JSON.parse(raw) as FixyThread;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
105
|
+
throw new Error(`Thread not found: ${threadId}`, { cause: err });
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns all threads for the given project root.
|
|
113
|
+
* Returns an empty array if the threads directory does not exist yet.
|
|
114
|
+
*/
|
|
115
|
+
async listThreads(projectRoot: string): Promise<FixyThread[]> {
|
|
116
|
+
const threadsDir = getThreadsDir(projectRoot);
|
|
117
|
+
let entries: string[];
|
|
118
|
+
try {
|
|
119
|
+
entries = await readdir(threadsDir);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
throw err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const jsonFiles = entries.filter((f) => f.endsWith('.json') && !f.endsWith('.tmp'));
|
|
128
|
+
|
|
129
|
+
const threads = await Promise.all(
|
|
130
|
+
jsonFiles.map(async (file) => {
|
|
131
|
+
const raw = await readFile(join(threadsDir, file), 'utf8');
|
|
132
|
+
return JSON.parse(raw) as FixyThread;
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
return threads;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sets the thread status to "archived" and persists the change.
|
|
141
|
+
* Throws if the thread does not exist.
|
|
142
|
+
*/
|
|
143
|
+
async archiveThread(threadId: string, projectRoot: string): Promise<FixyThread> {
|
|
144
|
+
const thread = await this.getThread(threadId, projectRoot);
|
|
145
|
+
|
|
146
|
+
thread.status = 'archived';
|
|
147
|
+
thread.updatedAt = new Date().toISOString();
|
|
148
|
+
|
|
149
|
+
await this._writeAtomic(getThreadFile(projectRoot, threadId), thread);
|
|
150
|
+
|
|
151
|
+
return thread;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Private helpers
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
/** Atomically writes an object as formatted JSON: write to *.tmp, then rename. */
|
|
159
|
+
private async _writeAtomic(destPath: string, data: unknown): Promise<void> {
|
|
160
|
+
const tmpPath = `${destPath}.tmp`;
|
|
161
|
+
await writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
|
162
|
+
await rename(tmpPath, destPath);
|
|
163
|
+
}
|
|
164
|
+
}
|
package/src/thread.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// packages/core/src/thread.ts
|
|
2
|
+
|
|
3
|
+
import type { FixySession } from './adapter.js';
|
|
4
|
+
|
|
5
|
+
export type FixyRole = 'user' | 'agent' | 'system';
|
|
6
|
+
|
|
7
|
+
export interface FixyThread {
|
|
8
|
+
id: string; // uuid v7
|
|
9
|
+
projectId: string; // sha1 of projectRoot
|
|
10
|
+
projectRoot: string; // absolute path
|
|
11
|
+
createdAt: string; // ISO 8601
|
|
12
|
+
updatedAt: string; // ISO 8601
|
|
13
|
+
title: string | null; // user-editable, null until first message
|
|
14
|
+
status: 'active' | 'archived';
|
|
15
|
+
workerModel: string; // current @fixy worker adapter id, e.g. "claude"
|
|
16
|
+
agentSessions: Record<string, FixySession | null>; // per-adapter resume state
|
|
17
|
+
worktrees: Record<string, string>; // agentId → absolute worktree path
|
|
18
|
+
messages: FixyMessage[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface FixyMessage {
|
|
22
|
+
id: string; // uuid v7, monotonic by createdAt
|
|
23
|
+
createdAt: string; // ISO 8601
|
|
24
|
+
role: FixyRole;
|
|
25
|
+
/** For role=agent, the adapter id that produced this message. */
|
|
26
|
+
agentId: string | null;
|
|
27
|
+
/** The raw user input or agent summary. Streamed chunks are concatenated here once settled. */
|
|
28
|
+
content: string;
|
|
29
|
+
/** For role=agent, the runId of the FixyAdapter.execute() call. */
|
|
30
|
+
runId: string | null;
|
|
31
|
+
/** For role=user, the list of adapter ids the router dispatched this message to. */
|
|
32
|
+
dispatchedTo: string[];
|
|
33
|
+
/** Patches captured from the agent's worktree after the turn, if any. */
|
|
34
|
+
patches: FixyPatch[];
|
|
35
|
+
/** Non-fatal warnings surfaced to the user. */
|
|
36
|
+
warnings: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FixyPatch {
|
|
40
|
+
/** Absolute path of the file inside the adapter's worktree. */
|
|
41
|
+
filePath: string;
|
|
42
|
+
/** Path relative to the thread's project root. */
|
|
43
|
+
relativePath: string;
|
|
44
|
+
/** Unified diff produced by `git diff --no-color` inside the worktree. */
|
|
45
|
+
diff: string;
|
|
46
|
+
/** Bytes added / removed, populated by the worktree manager. */
|
|
47
|
+
stats: { additions: number; deletions: number };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type { FixySession } from './adapter.js';
|
package/src/turn.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// packages/core/src/turn.ts
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import type { FixyExecutionContext } from './adapter.js';
|
|
6
|
+
import type { FixyMessage, FixyThread } from './thread.js';
|
|
7
|
+
import type { AdapterRegistry } from './registry.js';
|
|
8
|
+
import { Router } from './router.js';
|
|
9
|
+
import type { LocalThreadStore } from './store.js';
|
|
10
|
+
import { FixyCommandRunner } from './fixy-commands.js';
|
|
11
|
+
import { WorktreeManager } from './worktree.js';
|
|
12
|
+
|
|
13
|
+
export interface TurnParams {
|
|
14
|
+
thread: FixyThread;
|
|
15
|
+
input: string;
|
|
16
|
+
registry: AdapterRegistry;
|
|
17
|
+
store: LocalThreadStore;
|
|
18
|
+
onLog: (stream: 'stdout' | 'stderr', chunk: string) => void;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
worktreeManager?: WorktreeManager;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class TurnController {
|
|
24
|
+
async runTurn(params: TurnParams): Promise<void> {
|
|
25
|
+
const { thread, input, store } = params;
|
|
26
|
+
|
|
27
|
+
const router = new Router(params.registry);
|
|
28
|
+
const parsed = router.parse(input);
|
|
29
|
+
|
|
30
|
+
let dispatchedTo: string[];
|
|
31
|
+
if (parsed.kind === 'mention') {
|
|
32
|
+
dispatchedTo = parsed.agentIds;
|
|
33
|
+
} else if (parsed.kind === 'bare') {
|
|
34
|
+
const lastAgent = this._findLastAgentId(thread);
|
|
35
|
+
dispatchedTo = [lastAgent ?? thread.workerModel];
|
|
36
|
+
} else {
|
|
37
|
+
dispatchedTo = [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const userMsg: FixyMessage = {
|
|
41
|
+
id: randomUUID(),
|
|
42
|
+
createdAt: new Date().toISOString(),
|
|
43
|
+
role: 'user',
|
|
44
|
+
agentId: null,
|
|
45
|
+
content: input,
|
|
46
|
+
runId: null,
|
|
47
|
+
dispatchedTo,
|
|
48
|
+
patches: [],
|
|
49
|
+
warnings: [],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
await store.appendMessage(thread.id, thread.projectRoot, userMsg);
|
|
53
|
+
|
|
54
|
+
switch (parsed.kind) {
|
|
55
|
+
case 'mention': {
|
|
56
|
+
if (parsed.agentIds.length > 3) {
|
|
57
|
+
await this._appendSystemMessage('maximum 3 adapters per turn', params);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const agentId of parsed.agentIds) {
|
|
61
|
+
await this._dispatchToAdapter(agentId, parsed.body, params);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
case 'fixy': {
|
|
67
|
+
const runner = new FixyCommandRunner();
|
|
68
|
+
await runner.run({
|
|
69
|
+
thread: params.thread,
|
|
70
|
+
rest: parsed.rest,
|
|
71
|
+
store: params.store,
|
|
72
|
+
registry: params.registry,
|
|
73
|
+
worktreeManager: params.worktreeManager ?? new WorktreeManager(),
|
|
74
|
+
onLog: params.onLog,
|
|
75
|
+
signal: params.signal,
|
|
76
|
+
});
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
case 'bare': {
|
|
81
|
+
const lastAgent = this._findLastAgentId(thread);
|
|
82
|
+
const resolvedAgentId = lastAgent ?? thread.workerModel;
|
|
83
|
+
await this._dispatchToAdapter(resolvedAgentId, parsed.body, params);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case 'error': {
|
|
88
|
+
await this._appendSystemMessage(parsed.reason, params);
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private _findLastAgentId(thread: FixyThread): string | null {
|
|
95
|
+
for (let i = thread.messages.length - 1; i >= 0; i--) {
|
|
96
|
+
const msg = thread.messages[i];
|
|
97
|
+
if (msg.role === 'agent' && msg.agentId !== null) {
|
|
98
|
+
return msg.agentId;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async _dispatchToAdapter(
|
|
105
|
+
agentId: string,
|
|
106
|
+
body: string,
|
|
107
|
+
params: TurnParams,
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const freshThread = await params.store.getThread(params.thread.id, params.thread.projectRoot);
|
|
110
|
+
|
|
111
|
+
const adapter = params.registry.require(agentId);
|
|
112
|
+
const runId = randomUUID();
|
|
113
|
+
|
|
114
|
+
const ctx: FixyExecutionContext = {
|
|
115
|
+
runId,
|
|
116
|
+
agent: { id: adapter.id, name: adapter.name },
|
|
117
|
+
threadContext: {
|
|
118
|
+
threadId: freshThread.id,
|
|
119
|
+
projectRoot: freshThread.projectRoot,
|
|
120
|
+
worktreePath: freshThread.projectRoot,
|
|
121
|
+
repoRef: null,
|
|
122
|
+
},
|
|
123
|
+
messages: freshThread.messages,
|
|
124
|
+
prompt: body,
|
|
125
|
+
session: freshThread.agentSessions[agentId] ?? null,
|
|
126
|
+
onLog: params.onLog,
|
|
127
|
+
onMeta: () => {},
|
|
128
|
+
onSpawn: () => {},
|
|
129
|
+
signal: params.signal,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const result = await adapter.execute(ctx);
|
|
133
|
+
|
|
134
|
+
const agentMsg: FixyMessage = {
|
|
135
|
+
id: randomUUID(),
|
|
136
|
+
createdAt: new Date().toISOString(),
|
|
137
|
+
role: 'agent',
|
|
138
|
+
agentId: adapter.id,
|
|
139
|
+
content: result.summary,
|
|
140
|
+
runId,
|
|
141
|
+
dispatchedTo: [],
|
|
142
|
+
patches: result.patches,
|
|
143
|
+
warnings: result.warnings,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
await params.store.appendMessage(params.thread.id, params.thread.projectRoot, agentMsg);
|
|
147
|
+
|
|
148
|
+
params.thread.agentSessions[agentId] = result.session;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async _appendSystemMessage(content: string, params: TurnParams): Promise<void> {
|
|
152
|
+
const msg: FixyMessage = {
|
|
153
|
+
id: randomUUID(),
|
|
154
|
+
createdAt: new Date().toISOString(),
|
|
155
|
+
role: 'system',
|
|
156
|
+
agentId: null,
|
|
157
|
+
content,
|
|
158
|
+
runId: null,
|
|
159
|
+
dispatchedTo: [],
|
|
160
|
+
patches: [],
|
|
161
|
+
warnings: [],
|
|
162
|
+
};
|
|
163
|
+
await params.store.appendMessage(params.thread.id, params.thread.projectRoot, msg);
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// packages/core/src/worktree.ts
|
|
2
|
+
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { access, mkdir, readdir } from 'node:fs/promises';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
import { parseUnifiedDiff } from './diff-parser.js';
|
|
9
|
+
import { getWorktreesDir } from './paths.js';
|
|
10
|
+
import type { FixyPatch } from './thread.js';
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
export interface WorktreeHandle {
|
|
15
|
+
/** Absolute path to the worktree directory. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Branch name, e.g. "fixy/<threadId>-<agentId>" */
|
|
18
|
+
branch: string;
|
|
19
|
+
agentId: string;
|
|
20
|
+
threadId: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class WorktreeManager {
|
|
24
|
+
/**
|
|
25
|
+
* Idempotent — returns an existing worktree handle or creates a new one.
|
|
26
|
+
*/
|
|
27
|
+
async ensure(projectRoot: string, threadId: string, agentId: string): Promise<WorktreeHandle> {
|
|
28
|
+
const worktreePath = join(getWorktreesDir(threadId), agentId);
|
|
29
|
+
const branch = `fixy/${threadId}-${agentId}`;
|
|
30
|
+
|
|
31
|
+
const exists = await this.#pathExists(worktreePath);
|
|
32
|
+
|
|
33
|
+
if (!exists) {
|
|
34
|
+
// Ensure parent directories exist before running git worktree add.
|
|
35
|
+
// git worktree add creates the leaf directory itself — only create the parent.
|
|
36
|
+
await mkdir(dirname(worktreePath), { recursive: true });
|
|
37
|
+
|
|
38
|
+
await execFileAsync('git', ['worktree', 'add', worktreePath, '-b', branch], {
|
|
39
|
+
cwd: projectRoot,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { path: worktreePath, branch, agentId, threadId };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Runs `git diff --no-color` in the worktree and parses the output into
|
|
48
|
+
* FixyPatch objects. Returns an empty array when there are no changes.
|
|
49
|
+
*/
|
|
50
|
+
async collectPatches(handle: WorktreeHandle): Promise<FixyPatch[]> {
|
|
51
|
+
const { stdout } = await execFileAsync('git', ['diff', '--no-color'], { cwd: handle.path });
|
|
52
|
+
|
|
53
|
+
if (!stdout || stdout.trim().length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return parseUnifiedDiff(stdout, handle.path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Removes the worktree and its branch, then re-provisions a fresh one.
|
|
62
|
+
*/
|
|
63
|
+
async reset(handle: WorktreeHandle, projectRoot: string): Promise<void> {
|
|
64
|
+
await this.#removeWorktree(handle, projectRoot);
|
|
65
|
+
await this.ensure(projectRoot, handle.threadId, handle.agentId);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Permanently removes the worktree and its tracking branch.
|
|
70
|
+
* Does NOT re-provision.
|
|
71
|
+
*/
|
|
72
|
+
async remove(handle: WorktreeHandle, projectRoot: string): Promise<void> {
|
|
73
|
+
await this.#removeWorktree(handle, projectRoot);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Lists all WorktreeHandles for the given threadId by reading the worktrees
|
|
78
|
+
* directory on disk. Returns an empty array when no worktrees exist yet.
|
|
79
|
+
*/
|
|
80
|
+
async list(threadId: string): Promise<WorktreeHandle[]> {
|
|
81
|
+
const dir = getWorktreesDir(threadId);
|
|
82
|
+
|
|
83
|
+
const dirExists = await this.#pathExists(dir);
|
|
84
|
+
if (!dirExists) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
89
|
+
return entries
|
|
90
|
+
.filter((e) => e.isDirectory())
|
|
91
|
+
.map((e) => ({
|
|
92
|
+
path: join(dir, e.name),
|
|
93
|
+
branch: `fixy/${threadId}-${e.name}`,
|
|
94
|
+
agentId: e.name,
|
|
95
|
+
threadId,
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Private helpers
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async #removeWorktree(handle: WorktreeHandle, projectRoot: string): Promise<void> {
|
|
104
|
+
await execFileAsync('git', ['worktree', 'remove', '--force', handle.path], {
|
|
105
|
+
cwd: projectRoot,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await execFileAsync('git', ['branch', '-D', handle.branch], { cwd: projectRoot });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async #pathExists(p: string): Promise<boolean> {
|
|
112
|
+
try {
|
|
113
|
+
await access(p);
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|