@anhlp/alp-runtime 0.0.0-beta.1
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/LICENSE +202 -0
- package/NOTICE +7 -0
- package/README.md +7 -0
- package/dist/acp-transport.d.ts +114 -0
- package/dist/acp-transport.js +478 -0
- package/dist/antigravity-transport.d.ts +80 -0
- package/dist/antigravity-transport.js +609 -0
- package/dist/board-domain.d.ts +56 -0
- package/dist/board-domain.js +157 -0
- package/dist/board.d.ts +21 -0
- package/dist/board.js +69 -0
- package/dist/catalog.d.ts +31 -0
- package/dist/catalog.js +38 -0
- package/dist/claude-transport.d.ts +157 -0
- package/dist/claude-transport.js +671 -0
- package/dist/contracts.d.ts +163 -0
- package/dist/contracts.js +5 -0
- package/dist/events.d.ts +1 -0
- package/dist/events.js +0 -0
- package/dist/github.d.ts +10 -0
- package/dist/github.js +39 -0
- package/dist/handoff.d.ts +36 -0
- package/dist/handoff.js +73 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +64 -0
- package/dist/language.d.ts +41 -0
- package/dist/language.js +68 -0
- package/dist/live.d.ts +48 -0
- package/dist/live.js +45 -0
- package/dist/mailbox.d.ts +24 -0
- package/dist/mailbox.js +36 -0
- package/dist/mcp-tools.d.ts +115 -0
- package/dist/mcp-tools.js +71 -0
- package/dist/permission-path.d.ts +2 -0
- package/dist/permission-path.js +44 -0
- package/dist/process-info.d.ts +14 -0
- package/dist/process-info.js +32 -0
- package/dist/providers.d.ts +28 -0
- package/dist/providers.js +71 -0
- package/dist/recall.d.ts +42 -0
- package/dist/recall.js +60 -0
- package/dist/resolve.d.ts +121 -0
- package/dist/resolve.js +238 -0
- package/dist/runtime-context.d.ts +43 -0
- package/dist/runtime-context.js +61 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +2979 -0
- package/dist/session-config.d.ts +1973 -0
- package/dist/session-config.js +205 -0
- package/dist/session-state.d.ts +140 -0
- package/dist/session-state.js +0 -0
- package/dist/tool-results.d.ts +12 -0
- package/dist/tool-results.js +25 -0
- package/dist/tools.d.ts +674 -0
- package/dist/tools.js +375 -0
- package/dist/transport.d.ts +47 -0
- package/dist/transport.js +169 -0
- package/dist/usage.d.ts +15 -0
- package/dist/usage.js +35 -0
- package/dist/workspace.d.ts +101 -0
- package/dist/workspace.js +259 -0
- package/dist/worktree-domain.d.ts +88 -0
- package/dist/worktree-domain.js +196 -0
- package/package.json +256 -0
- package/src/acp-transport.ts +473 -0
- package/src/antigravity-transport.ts +658 -0
- package/src/board-domain.ts +185 -0
- package/src/board.ts +80 -0
- package/src/catalog.ts +32 -0
- package/src/claude-transport.ts +771 -0
- package/src/contracts.ts +166 -0
- package/src/events.ts +1 -0
- package/src/github.ts +37 -0
- package/src/handoff.ts +86 -0
- package/src/index.ts +26 -0
- package/src/language.ts +102 -0
- package/src/live.ts +86 -0
- package/src/mailbox.ts +46 -0
- package/src/mcp-tools.ts +73 -0
- package/src/permission-path.ts +37 -0
- package/src/process-info.ts +40 -0
- package/src/providers.ts +84 -0
- package/src/recall.ts +85 -0
- package/src/resolve.ts +230 -0
- package/src/runtime-context.ts +49 -0
- package/src/runtime.ts +3579 -0
- package/src/session-config.ts +301 -0
- package/src/session-state.ts +141 -0
- package/src/tool-results.ts +26 -0
- package/src/tools.ts +379 -0
- package/src/transport.ts +116 -0
- package/src/usage.ts +42 -0
- package/src/workspace.ts +318 -0
- package/src/worktree-domain.ts +216 -0
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { copyFile, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Phase C isolation (plans/reference/ALPD.md §16): a writing assignment may run in its
|
|
9
|
+
* own git worktree, so writers do not share a checkout. Its work is committed to a
|
|
10
|
+
* branch when it ends, and the requester merges the change into its checkout or
|
|
11
|
+
* discards it. Branches outlive their worktrees, so work is never deleted silently.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type Worktree = {
|
|
15
|
+
/** The requester's checkout, which receives the change on merge. */
|
|
16
|
+
checkout: string;
|
|
17
|
+
/** The worktree's top level, and the assignment's working directory inside it. */
|
|
18
|
+
path: string;
|
|
19
|
+
workdir: string;
|
|
20
|
+
branch: string;
|
|
21
|
+
/** The requester's state when the assignment started: HEAD plus uncommitted tracked changes. */
|
|
22
|
+
base: string;
|
|
23
|
+
/** checkoutFingerprint of the requester's checkout then, to tell whether it changed since. */
|
|
24
|
+
fingerprint?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type WorktreeChange = { commit: string; files: string[]; stat: string };
|
|
28
|
+
|
|
29
|
+
type GitResult = { code: number; stdout: string; stderr: string };
|
|
30
|
+
|
|
31
|
+
/** Commits made for ALP never run the project's hooks and need no user identity. */
|
|
32
|
+
const IDENTITY = ['-c', 'user.name=ALP', '-c', 'user.email=alp@localhost', '-c', 'commit.gpgsign=false'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Variables git sets for its hooks and aliases, which would point ALP's git
|
|
36
|
+
* commands at another repository, index or configuration (ALPD §32).
|
|
37
|
+
*/
|
|
38
|
+
const GIT_CONTEXT = [
|
|
39
|
+
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES',
|
|
40
|
+
'GIT_PREFIX', 'GIT_NAMESPACE', 'GIT_IMPLICIT_WORK_TREE', 'GIT_GRAFT_FILE', 'GIT_REPLACE_REF_BASE', 'GIT_NO_REPLACE_OBJECTS',
|
|
41
|
+
'GIT_SHALLOW_FILE', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** The environment ALP runs git in: the caller's, without git's own context, and never prompting. */
|
|
45
|
+
export function gitEnvironment(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
46
|
+
const clean: NodeJS.ProcessEnv = { ...env, GIT_TERMINAL_PROMPT: '0' };
|
|
47
|
+
for (const key of Object.keys(clean)) {
|
|
48
|
+
if (GIT_CONTEXT.includes(key) || /^GIT_CONFIG_(KEY|VALUE)_\d+$/.test(key)) delete clean[key];
|
|
49
|
+
}
|
|
50
|
+
return clean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function git(cwd: string, args: string[], input?: string): Promise<GitResult> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const child = spawn('git', args, { cwd, stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true, env: gitEnvironment() });
|
|
56
|
+
let stdout = '';
|
|
57
|
+
let stderr = '';
|
|
58
|
+
child.stdout!.on('data', chunk => { stdout += chunk; });
|
|
59
|
+
child.stderr!.on('data', chunk => { stderr += chunk; });
|
|
60
|
+
child.once('error', reject);
|
|
61
|
+
child.once('close', code => resolve({ code: code ?? 1, stdout, stderr }));
|
|
62
|
+
if (child.stdin) {
|
|
63
|
+
// git may exit before reading its input (EPIPE); its exit code reports the failure.
|
|
64
|
+
child.stdin.on('error', () => {});
|
|
65
|
+
child.stdin.end(input);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function checked(cwd: string, args: string[], input?: string) {
|
|
71
|
+
const result = await git(cwd, args, input);
|
|
72
|
+
if (result.code !== 0) throw new Error(`git ${args[0]} failed: ${(result.stderr || result.stdout).trim()}`);
|
|
73
|
+
return result.stdout.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A file's content at a revision, or undefined where it does not exist. */
|
|
77
|
+
function blob(cwd: string, revision: string, file: string): Promise<Buffer | undefined> {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const child = spawn('git', ['cat-file', 'blob', `${revision}:${file}`], { cwd, stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true, env: gitEnvironment() });
|
|
80
|
+
const chunks: Buffer[] = [];
|
|
81
|
+
child.stdout.on('data', chunk => chunks.push(chunk));
|
|
82
|
+
child.once('error', reject);
|
|
83
|
+
child.once('close', code => resolve(code === 0 ? Buffer.concat(chunks) : undefined));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const binary = (content: Buffer) => content.subarray(0, 8000).includes(0);
|
|
88
|
+
const same = (a?: Buffer, b?: Buffer) => a === b || (!!a && !!b && a.equals(b));
|
|
89
|
+
|
|
90
|
+
/** Three-way merges one text file; returns the result and whether it has conflict markers. */
|
|
91
|
+
async function mergeText(ours: Buffer, base: Buffer, theirs: Buffer) {
|
|
92
|
+
const directory = await mkdtemp(path.join(os.tmpdir(), 'alp-merge-'));
|
|
93
|
+
try {
|
|
94
|
+
const [o, b, t] = ['ours', 'base', 'theirs'].map(name => path.join(directory, name));
|
|
95
|
+
await Promise.all([writeFile(o, ours), writeFile(b, base), writeFile(t, theirs)]);
|
|
96
|
+
const result = await git(directory, ['merge-file', '-p', '-L', 'yours', '-L', 'base', '-L', 'assignment', o, b, t]);
|
|
97
|
+
if (result.code < 0 || result.code > 127) throw new Error(`git merge-file failed: ${result.stderr.trim()}`);
|
|
98
|
+
return { content: result.stdout, conflict: result.code > 0 };
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(directory, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The top level of the git checkout containing `directory`, or undefined outside git. */
|
|
105
|
+
export async function checkoutOf(directory: string) {
|
|
106
|
+
const result = await git(directory, ['rev-parse', '--show-toplevel']).catch(() => undefined);
|
|
107
|
+
return result?.code === 0 ? realpath(result.stdout.trim()) : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The key that writers of one checkout share: its top level, or the directory itself outside git. */
|
|
111
|
+
export async function checkoutKey(directory: string) {
|
|
112
|
+
return (await checkoutOf(directory)) ?? realpath(directory).catch(() => path.resolve(directory));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Whether the checkout containing `directory` has a local branch of that name. */
|
|
116
|
+
export async function branchExists(directory: string, branch: string) {
|
|
117
|
+
const result = await git(directory, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`]).catch(() => undefined);
|
|
118
|
+
return result?.code === 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Creates a worktree for `id` from the current state of the checkout containing `workdir`. */
|
|
122
|
+
/**
|
|
123
|
+
* Creates a worktree for `id` from the current state of the checkout containing `workdir`,
|
|
124
|
+
* or, with `from`, from an earlier assignment's committed change, keeping that assignment's
|
|
125
|
+
* base so a merge applies both changes together.
|
|
126
|
+
*/
|
|
127
|
+
export async function createWorktree(workdir: string, root: string, id: string, from?: Pick<Worktree, 'base' | 'fingerprint'> & { commit: string }): Promise<Worktree> {
|
|
128
|
+
const checkout = await checkoutOf(workdir);
|
|
129
|
+
if (!checkout) throw new Error('Worktree isolation needs a git repository');
|
|
130
|
+
const head = await git(checkout, ['rev-parse', '--verify', 'HEAD']);
|
|
131
|
+
if (head.code !== 0) throw new Error('Worktree isolation needs at least one commit');
|
|
132
|
+
const fingerprint = from ? from.fingerprint : await checkoutFingerprint(checkout);
|
|
133
|
+
// `stash create` snapshots uncommitted tracked changes without touching the checkout.
|
|
134
|
+
const base = from ? from.base : (await checked(checkout, ['stash', 'create'])) || head.stdout.trim();
|
|
135
|
+
const target = path.join(root, id);
|
|
136
|
+
const branch = `alp/${id}`;
|
|
137
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
138
|
+
await checked(checkout, ['worktree', 'add', '--quiet', '-b', branch, target, from ? from.commit : base]);
|
|
139
|
+
const relative = path.relative(checkout, await realpath(workdir));
|
|
140
|
+
return { checkout, path: target, workdir: path.join(target, relative), branch, base, ...(fingerprint ? { fingerprint } : {}) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Checks out an assignment's existing branch again at its worktree's place, after
|
|
145
|
+
* reclaimWorktrees committed and removed it, so the assignment continues there.
|
|
146
|
+
*/
|
|
147
|
+
export async function reattachWorktree(worktree: Worktree): Promise<Worktree> {
|
|
148
|
+
if (!await branchExists(worktree.checkout, worktree.branch)) throw new Error(`Branch ${worktree.branch} of the worktree no longer exists`);
|
|
149
|
+
if (await stat(worktree.path).catch(() => undefined)) return worktree;
|
|
150
|
+
await mkdir(path.dirname(worktree.path), { recursive: true, mode: 0o700 });
|
|
151
|
+
await git(worktree.checkout, ['worktree', 'prune']);
|
|
152
|
+
await checked(worktree.checkout, ['worktree', 'add', '--quiet', worktree.path, worktree.branch]);
|
|
153
|
+
return worktree;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A digest of a checkout's state: HEAD, its uncommitted changes, and the names of untracked files. */
|
|
157
|
+
export async function checkoutFingerprint(checkout: string) {
|
|
158
|
+
const head = await git(checkout, ['rev-parse', '--verify', 'HEAD']);
|
|
159
|
+
if (head.code !== 0) return undefined;
|
|
160
|
+
const diff = await git(checkout, ['diff', '--binary', 'HEAD']);
|
|
161
|
+
const untracked = await git(checkout, ['ls-files', '--others', '--exclude-standard', '-z']);
|
|
162
|
+
return createHash('sha256').update(head.stdout).update('\0').update(diff.stdout).update('\0').update(untracked.stdout).digest('hex');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Links the project's node_modules into the same place in a worktree that has none,
|
|
167
|
+
* so its build and tests run; returns what undoes it. Commits never see the link,
|
|
168
|
+
* because ALP removes it before the worktree is committed again.
|
|
169
|
+
*/
|
|
170
|
+
export async function linkModules(projectDir: string, worktreeDir: string) {
|
|
171
|
+
const source = path.join(projectDir, 'node_modules');
|
|
172
|
+
const target = path.join(worktreeDir, 'node_modules');
|
|
173
|
+
if (!(await stat(source).catch(() => undefined))?.isDirectory() || await lstat(target).catch(() => undefined)) return async () => {};
|
|
174
|
+
await symlink(source, target, 'dir');
|
|
175
|
+
return async () => { await unlink(target).catch(() => {}); };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Commits whatever the assignment left in its worktree and describes the change from its base. */
|
|
179
|
+
export async function commitWorktree(worktree: Worktree, message: string): Promise<WorktreeChange> {
|
|
180
|
+
await checked(worktree.path, ['add', '-A']);
|
|
181
|
+
const staged = await git(worktree.path, ['diff', '--cached', '--quiet']);
|
|
182
|
+
if (staged.code === 1) await checked(worktree.path, [...IDENTITY, 'commit', '--quiet', '--no-verify', '-m', message]);
|
|
183
|
+
const commit = await checked(worktree.path, ['rev-parse', 'HEAD']);
|
|
184
|
+
const names = await checked(worktree.path, ['diff', '--name-only', worktree.base, commit]);
|
|
185
|
+
const stat = await checked(worktree.path, ['diff', '--shortstat', worktree.base, commit]);
|
|
186
|
+
return { commit, files: names ? names.split('\n') : [], stat };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Applies the change to the requester's working tree without committing or touching
|
|
191
|
+
* its index. A patch that applies cleanly is applied as is; otherwise each file is
|
|
192
|
+
* merged three ways against the requester's current file, leaving conflict markers.
|
|
193
|
+
* Where a file was deleted on one side or is binary, the requester's version is kept
|
|
194
|
+
* and reported as a conflict.
|
|
195
|
+
*/
|
|
196
|
+
export async function mergeWorktree(worktree: Worktree, change: WorktreeChange) {
|
|
197
|
+
if (!change.files.length) return { status: 'empty' as const, files: [] };
|
|
198
|
+
const patch = await checked(worktree.checkout, ['diff', '--binary', worktree.base, change.commit]);
|
|
199
|
+
if ((await git(worktree.checkout, ['apply', '--whitespace=nowarn'], `${patch}\n`)).code === 0) return { status: 'applied' as const, files: change.files };
|
|
200
|
+
const conflicts: string[] = [];
|
|
201
|
+
for (const file of change.files) {
|
|
202
|
+
const target = path.join(worktree.checkout, file);
|
|
203
|
+
const ours = await readFile(target).catch(() => undefined);
|
|
204
|
+
const base = await blob(worktree.checkout, worktree.base, file);
|
|
205
|
+
const theirs = await blob(worktree.checkout, change.commit, file);
|
|
206
|
+
if (same(ours, theirs)) continue;
|
|
207
|
+
if (same(ours, base)) {
|
|
208
|
+
if (theirs) {
|
|
209
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
210
|
+
await writeFile(target, theirs);
|
|
211
|
+
} else {
|
|
212
|
+
await rm(target, { force: true });
|
|
213
|
+
}
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!ours || !theirs || binary(ours) || binary(theirs) || (base && binary(base))) {
|
|
217
|
+
conflicts.push(file);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const merged = await mergeText(ours, base ?? Buffer.alloc(0), theirs);
|
|
221
|
+
await writeFile(target, merged.content);
|
|
222
|
+
if (merged.conflict) conflicts.push(file);
|
|
223
|
+
}
|
|
224
|
+
return conflicts.length ? { status: 'conflicts' as const, files: change.files, conflicts } : { status: 'applied' as const, files: change.files };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Removes the worktree directory; the branch stays unless `deleteBranch`. */
|
|
228
|
+
export async function removeWorktree(worktree: Worktree, { deleteBranch = false } = {}) {
|
|
229
|
+
await git(worktree.checkout, ['worktree', 'remove', '--force', worktree.path]);
|
|
230
|
+
await rm(worktree.path, { recursive: true, force: true });
|
|
231
|
+
await git(worktree.checkout, ['worktree', 'prune']);
|
|
232
|
+
if (deleteBranch) await git(worktree.checkout, ['branch', '-D', worktree.branch]);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* After a crash: commits work left in worktrees under `root` to their branches and
|
|
237
|
+
* removes the directories. Returns the branches that were kept.
|
|
238
|
+
*/
|
|
239
|
+
export async function reclaimWorktrees(root: string) {
|
|
240
|
+
const kept: string[] = [];
|
|
241
|
+
const entries = await readdir(root).catch(() => [] as string[]);
|
|
242
|
+
for (const name of entries) {
|
|
243
|
+
const target = path.join(root, name);
|
|
244
|
+
if (!(await stat(target).catch(() => undefined))?.isDirectory()) continue;
|
|
245
|
+
const common = await git(target, ['rev-parse', '--path-format=absolute', '--git-common-dir']).catch(() => undefined);
|
|
246
|
+
if (common?.code !== 0) {
|
|
247
|
+
await rm(target, { recursive: true, force: true });
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
const checkout = path.dirname(common.stdout.trim());
|
|
251
|
+
const branch = (await git(target, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
|
252
|
+
await git(target, ['add', '-A']);
|
|
253
|
+
if ((await git(target, ['diff', '--cached', '--quiet'])).code === 1) {
|
|
254
|
+
await git(target, [...IDENTITY, 'commit', '--quiet', '--no-verify', '-m', 'alp: work left by an interrupted assignment']);
|
|
255
|
+
}
|
|
256
|
+
await removeWorktree({ checkout, path: target, workdir: target, branch, base: '' });
|
|
257
|
+
kept.push(branch);
|
|
258
|
+
}
|
|
259
|
+
return kept;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A disposable copy of a checkout for an assignment that may build and test but
|
|
264
|
+
* must not touch the requester's tree (ALPD §26): a detached worktree at the
|
|
265
|
+
* requester's HEAD with its uncommitted changes applied as uncommitted changes, so
|
|
266
|
+
* `git diff` and `git status` there show what they show the requester, plus its
|
|
267
|
+
* untracked files.
|
|
268
|
+
* A top-level node_modules is linked, not copied. Nothing in it is ever merged.
|
|
269
|
+
*/
|
|
270
|
+
export type Copy = { checkout: string; path: string; workdir: string };
|
|
271
|
+
|
|
272
|
+
export async function createCopy(workdir: string, root: string, id: string): Promise<Copy> {
|
|
273
|
+
const checkout = await checkoutOf(workdir);
|
|
274
|
+
if (!checkout) throw new Error('A review copy needs a git repository');
|
|
275
|
+
const head = await git(checkout, ['rev-parse', '--verify', 'HEAD']);
|
|
276
|
+
if (head.code !== 0) throw new Error('A review copy needs at least one commit');
|
|
277
|
+
const target = path.join(root, id);
|
|
278
|
+
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
279
|
+
await checked(checkout, ['worktree', 'add', '--quiet', '--detach', target, head.stdout.trim()]);
|
|
280
|
+
try {
|
|
281
|
+
const patch = (await git(checkout, ['diff', '--binary', 'HEAD'])).stdout;
|
|
282
|
+
if (patch.trim()) await checked(target, ['apply', '--whitespace=nowarn', '--binary'], patch);
|
|
283
|
+
const untracked = (await checked(checkout, ['ls-files', '--others', '--exclude-standard', '-z'])).split('\0').filter(Boolean);
|
|
284
|
+
for (const file of untracked) {
|
|
285
|
+
const from = path.join(checkout, file);
|
|
286
|
+
if (!(await lstat(from).catch(() => undefined))?.isFile()) continue;
|
|
287
|
+
await mkdir(path.dirname(path.join(target, file)), { recursive: true });
|
|
288
|
+
await copyFile(from, path.join(target, file));
|
|
289
|
+
}
|
|
290
|
+
const modules = path.join(checkout, 'node_modules');
|
|
291
|
+
if ((await stat(modules).catch(() => undefined))?.isDirectory()) await symlink(modules, path.join(target, 'node_modules'), 'dir');
|
|
292
|
+
} catch (error) {
|
|
293
|
+
await removeCopy({ checkout, path: target, workdir: target });
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
const relative = path.relative(checkout, await realpath(workdir));
|
|
297
|
+
return { checkout, path: target, workdir: path.join(target, relative) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export async function removeCopy(copy: Copy) {
|
|
301
|
+
await git(copy.checkout, ['worktree', 'remove', '--force', copy.path]).catch(() => undefined);
|
|
302
|
+
await rm(copy.path, { recursive: true, force: true });
|
|
303
|
+
await git(copy.checkout, ['worktree', 'prune']).catch(() => undefined);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** After a crash: removes copies left under `root`; they hold nothing to keep. */
|
|
307
|
+
export async function reclaimCopies(root: string) {
|
|
308
|
+
let removed = 0;
|
|
309
|
+
for (const name of await readdir(root).catch(() => [] as string[])) {
|
|
310
|
+
const target = path.join(root, name);
|
|
311
|
+
const common = await git(target, ['rev-parse', '--path-format=absolute', '--git-common-dir']).catch(() => undefined);
|
|
312
|
+
if (common?.code === 0) await removeCopy({ checkout: path.dirname(common.stdout.trim()), path: target, workdir: target });
|
|
313
|
+
else await rm(target, { recursive: true, force: true });
|
|
314
|
+
removed++;
|
|
315
|
+
}
|
|
316
|
+
return removed;
|
|
317
|
+
}
|
|
318
|
+
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describeVerification, runVerify, verifyConfig, type Verification, type VerifyConfig } from '@anhlp/alp-core/verify';
|
|
4
|
+
import { recordVerification, loadTasks } from '@anhlp/alp-core/tasks';
|
|
5
|
+
import { checkoutFingerprint, checkoutKey, commitWorktree, linkModules, mergeWorktree, removeWorktree, type Worktree, type WorktreeChange } from './workspace.js';
|
|
6
|
+
import { writes } from './catalog.js';
|
|
7
|
+
import { nativeEnvironment } from './providers.js';
|
|
8
|
+
import { mainOf } from './tools.js';
|
|
9
|
+
import { errorData, plainObject, toolResult } from './tool-results.js';
|
|
10
|
+
import type { RuntimeOptions } from './contracts.js';
|
|
11
|
+
import type { Session, Assignment } from './session-state.js';
|
|
12
|
+
|
|
13
|
+
type WorktreeContext = {
|
|
14
|
+
options: RuntimeOptions;
|
|
15
|
+
leases: Map<string, { assignment: string; agent: string }>;
|
|
16
|
+
rootOf(sessionId: string): string;
|
|
17
|
+
lineage(sessionId: string): string[];
|
|
18
|
+
runLog(rootId: string, entry: Record<string, unknown>): void;
|
|
19
|
+
runHooks(sessionId: string, session: Session, event: string, details?: Record<string, unknown>, taskId?: string): Promise<string | undefined>;
|
|
20
|
+
touchTask(rootId: string, id: string): void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Checkout mutation and verification retain one shared queue per checkout. */
|
|
24
|
+
export function createWorktreeDomain({ options, leases, rootOf, lineage, runLog, runHooks, touchTask }: WorktreeContext) {
|
|
25
|
+
const merging = new Map<string, Promise<unknown>>();
|
|
26
|
+
|
|
27
|
+
/** Commits a finished isolated assignment's work and keeps it for the requester to merge. */
|
|
28
|
+
async function settleWorktree(parentId: string, parent: Session, assignment: Assignment) {
|
|
29
|
+
const worktree = assignment.worktree!;
|
|
30
|
+
try {
|
|
31
|
+
const change = await commitWorktree(worktree, `alp: ${assignment.agent} assignment ${assignment.id}`);
|
|
32
|
+
if (!change.files.length) {
|
|
33
|
+
await removeWorktree(worktree, { deleteBranch: true });
|
|
34
|
+
return { branch: worktree.branch, files: [], note: 'No changes; the worktree was removed' };
|
|
35
|
+
}
|
|
36
|
+
const described = { branch: worktree.branch, base: worktree.base, commit: change.commit, files: change.files, stat: change.stat };
|
|
37
|
+
if (parent.closed) {
|
|
38
|
+
await removeWorktree(worktree);
|
|
39
|
+
runLog(rootOf(parentId), { event: 'worktree.kept', assignmentId: assignment.id, branch: worktree.branch });
|
|
40
|
+
return { ...described, note: 'The requester closed; the change stays on the branch' };
|
|
41
|
+
}
|
|
42
|
+
parent.worktrees.set(assignment.id, { agent: assignment.agent, worktree, change, ...(assignment.taskId ? { taskId: assignment.taskId } : {}) });
|
|
43
|
+
return { ...described, next: 'Apply it with alp_merge, or drop it with alp_discard' };
|
|
44
|
+
} catch (error) {
|
|
45
|
+
await removeWorktree(worktree).catch(() => {});
|
|
46
|
+
return { branch: worktree.branch, error: errorData(error).message };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function worktreeTool(sessionId: string, session: Session, args: unknown, action: 'merge' | 'discard') {
|
|
51
|
+
if (!plainObject(args, ['assignmentId', 'skipVerify']) || typeof args.assignmentId !== 'string' ||
|
|
52
|
+
(args.skipVerify !== undefined && (action !== 'merge' || typeof args.skipVerify !== 'string' || !args.skipVerify.trim()))) {
|
|
53
|
+
return toolResult(false, { error: action === 'merge' ? 'assignmentId is required; skipVerify, when given, says why to merge without verification' : 'assignmentId is required' });
|
|
54
|
+
}
|
|
55
|
+
const pending = session.worktrees.get(args.assignmentId);
|
|
56
|
+
if (!pending) {
|
|
57
|
+
return toolResult(false, { error: session.assignments.has(args.assignmentId) ? 'The assignment is still running; wait for its result' : 'No unmerged worktree change with that assignment id' });
|
|
58
|
+
}
|
|
59
|
+
const { worktree, change } = pending;
|
|
60
|
+
if (action === 'discard') {
|
|
61
|
+
session.worktrees.delete(args.assignmentId);
|
|
62
|
+
await removeWorktree(worktree, { deleteBranch: true });
|
|
63
|
+
runLog(rootOf(sessionId), { event: 'worktree.discarded', assignmentId: args.assignmentId, branch: worktree.branch });
|
|
64
|
+
return toolResult(true, { discarded: args.assignmentId, branch: worktree.branch });
|
|
65
|
+
}
|
|
66
|
+
if (!writes(session.mapping.mode)) return toolResult(false, { error: 'Merging needs workspace-write or full-access' });
|
|
67
|
+
if ([...session.assignments.values()].some(assignment => assignment.mode !== 'read-only' && assignment.isolation === 'shared')) {
|
|
68
|
+
return toolResult(false, { error: 'A writer assignment is working in this checkout; merge after its result' });
|
|
69
|
+
}
|
|
70
|
+
// Claimed before any await, so a repeated call cannot merge it twice.
|
|
71
|
+
session.worktrees.delete(args.assignmentId);
|
|
72
|
+
const checkout = await checkoutKey(session.mapping.workdir);
|
|
73
|
+
const holder = leases.get(checkout);
|
|
74
|
+
if (holder && !lineage(sessionId).includes(holder.assignment)) {
|
|
75
|
+
session.worktrees.set(args.assignmentId, pending);
|
|
76
|
+
return toolResult(false, { error: `${holder.agent} in another session is writing this checkout; merge after it finishes` });
|
|
77
|
+
}
|
|
78
|
+
const project = session.mapping.agent.projectRoot;
|
|
79
|
+
const rootId = rootOf(sessionId);
|
|
80
|
+
let config: VerifyConfig | undefined;
|
|
81
|
+
try { config = await verifyConfig(project); }
|
|
82
|
+
catch (error) {
|
|
83
|
+
session.worktrees.set(args.assignmentId, pending);
|
|
84
|
+
return toolResult(false, { error: errorData(error).message });
|
|
85
|
+
}
|
|
86
|
+
let verified: Verification | undefined;
|
|
87
|
+
if (config && args.skipVerify === undefined) {
|
|
88
|
+
const verification = verified = await verifyIn(project, worktreeDirectory(worktree, project), config);
|
|
89
|
+
noteVerification(rootId, project, pending.taskId, args.assignmentId, 'worktree', verification);
|
|
90
|
+
if (!verification.passed) {
|
|
91
|
+
session.worktrees.set(args.assignmentId, { ...pending, verification });
|
|
92
|
+
return toolResult(false, {
|
|
93
|
+
assignmentId: args.assignmentId,
|
|
94
|
+
error: `${describeVerification(verification)} in the assignment's worktree; nothing was applied`,
|
|
95
|
+
verification: verificationResult(verification),
|
|
96
|
+
branch: worktree.branch,
|
|
97
|
+
next: verification.skipped
|
|
98
|
+
? 'The check could not run, which says nothing about the change; call alp_merge again, or alp_merge it with skipVerify saying why'
|
|
99
|
+
: `Delegate again with continueFrom "${args.assignmentId}"${pending.taskId ? ` and taskId ${pending.taskId}` : ''} to fix it in a worktree that starts from this change; or alp_discard it; or alp_merge it with skipVerify saying why`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
} else if (config && pending.taskId) {
|
|
103
|
+
await recordVerification(project, pending.taskId, { passed: false, skipped: args.skipVerify }, session.mapping.agent.name).catch(() => {});
|
|
104
|
+
runLog(rootId, { event: 'verify', assignmentId: args.assignmentId, where: 'worktree', skipped: args.skipVerify });
|
|
105
|
+
}
|
|
106
|
+
// A blocking merge hook can refuse the change before anything is applied.
|
|
107
|
+
const refused = await runHooks(sessionId, session, 'merge', { assignment: args.assignmentId, branch: worktree.branch }, pending.taskId);
|
|
108
|
+
if (refused) {
|
|
109
|
+
session.worktrees.set(args.assignmentId, pending);
|
|
110
|
+
return toolResult(false, { assignmentId: args.assignmentId, error: `A merge hook refused it; nothing was applied:\n${refused}`, branch: worktree.branch, next: `Fix what it reports in a new assignment with continueFrom "${args.assignmentId}", or alp_discard it` });
|
|
111
|
+
}
|
|
112
|
+
let merged: Awaited<ReturnType<typeof mergeWorktree>>;
|
|
113
|
+
let moved = false;
|
|
114
|
+
try {
|
|
115
|
+
({ merged, moved } = await inCheckout(checkout, async () => {
|
|
116
|
+
const before = await checkoutFingerprint(worktree.checkout).catch(() => undefined);
|
|
117
|
+
return { merged: await mergeWorktree(worktree, change), moved: !worktree.fingerprint || before !== worktree.fingerprint };
|
|
118
|
+
}));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
session.worktrees.set(args.assignmentId, pending);
|
|
121
|
+
return toolResult(false, { error: errorData(error).message, branch: worktree.branch, next: 'Merge the branch yourself, or alp_discard it' });
|
|
122
|
+
}
|
|
123
|
+
// A conflicted change keeps its branch for reference.
|
|
124
|
+
await removeWorktree(worktree, { deleteBranch: merged.status !== 'conflicts' });
|
|
125
|
+
runLog(rootId, { event: 'worktree.merged', assignmentId: args.assignmentId, branch: worktree.branch, ...merged });
|
|
126
|
+
// The checkout changed since the assignment started, so the worktree's check says little about the result: check the checkout too.
|
|
127
|
+
let checked: Verification | undefined;
|
|
128
|
+
if (config && args.skipVerify === undefined && merged.status === 'applied' && moved) {
|
|
129
|
+
checked = await inCheckout(checkout, () => verifyIn(project, session.mapping.workdir, config!));
|
|
130
|
+
noteVerification(rootId, project, pending.taskId, args.assignmentId, 'checkout', checked);
|
|
131
|
+
}
|
|
132
|
+
return toolResult(true, {
|
|
133
|
+
assignmentId: args.assignmentId,
|
|
134
|
+
...merged,
|
|
135
|
+
...(merged.status === 'conflicts' ? { branch: worktree.branch } : {}),
|
|
136
|
+
...(verified ? { verification: verificationResult(checked ?? verified), ...(checked ? { verifiedIn: 'your checkout, which changed since the assignment started' } : {}) } : {}),
|
|
137
|
+
...(args.skipVerify !== undefined && config ? { verification: { skipped: args.skipVerify } } : {}),
|
|
138
|
+
next: merged.status === 'conflicts' ? `Resolve the conflict markers in the listed files, then verify${config ? ' with alp_verify' : ''}`
|
|
139
|
+
: checked && !checked.passed ? `The change passed in its worktree but ${describeVerification(checked)} in your checkout, which changed meanwhile; fix it, then run alp_verify`
|
|
140
|
+
: 'Review the applied change; it is not committed',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Work on a checkout, one at a time: merges and the verify runs that check them. */
|
|
145
|
+
function inCheckout<T>(checkout: string, work: () => Promise<T>): Promise<T> {
|
|
146
|
+
const previous = merging.get(checkout) ?? Promise.resolve();
|
|
147
|
+
const attempt = previous.catch(() => {}).then(work);
|
|
148
|
+
merging.set(checkout, attempt);
|
|
149
|
+
void attempt.finally(() => { if (merging.get(checkout) === attempt) merging.delete(checkout); }).catch(() => {});
|
|
150
|
+
return attempt;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The directory in a worktree that corresponds to the project root. */
|
|
154
|
+
function worktreeDirectory(worktree: Worktree, project: string) {
|
|
155
|
+
let real = project;
|
|
156
|
+
try { real = realpathSync(project); } catch {}
|
|
157
|
+
return path.join(worktree.path, path.relative(worktree.checkout, real));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Runs the project's verify commands in `directory`; a worktree borrows the project's node_modules for the run. */
|
|
161
|
+
async function verifyIn(project: string, directory: string, config: VerifyConfig) {
|
|
162
|
+
const same = path.resolve(directory) === path.resolve(project);
|
|
163
|
+
const unlinkModules = same ? async () => {} : await linkModules(project, directory).catch(() => async () => {});
|
|
164
|
+
try {
|
|
165
|
+
return await runVerify(directory, config, { env: nativeEnvironment(options) });
|
|
166
|
+
} finally {
|
|
167
|
+
await unlinkModules();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** What an agent sees of a verification: each step's exit code and time, and the end of a failed step's output. */
|
|
172
|
+
function verificationResult(verification: Verification | { passed: boolean; commands: Array<Record<string, any>>; cwd?: string; skipped?: string }) {
|
|
173
|
+
const failed = verification.commands.find(command => command.exitCode !== 0);
|
|
174
|
+
return {
|
|
175
|
+
passed: verification.passed,
|
|
176
|
+
...(verification.skipped ? { skipped: verification.skipped } : {}),
|
|
177
|
+
steps: verification.commands.map(({ step, command, exitCode, ms, timedOut, idle }) => ({ step, command, exitCode, ms, ...(timedOut ? { timedOut } : {}), ...(idle ? { idle } : {}) })),
|
|
178
|
+
...(failed?.output ? { output: failed.output } : {}),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Logs a verification, and records it on the task it counts for. */
|
|
183
|
+
function noteVerification(rootId: string, project: string, taskId: string | undefined, assignmentId: string | undefined, where: 'worktree' | 'checkout', verification: Verification) {
|
|
184
|
+
runLog(rootId, { event: 'verify', ...(assignmentId ? { assignmentId } : {}), ...(taskId ? { taskId } : {}), where, passed: verification.passed, detail: describeVerification(verification) });
|
|
185
|
+
if (taskId) void recordVerification(project, taskId, { ...verification, where }, 'alpd').then(() => touchTask(rootId, taskId), () => {});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The start of the brief of an assignment that continues an earlier worktree change. */
|
|
189
|
+
function continuedBrief(from: string, continued: { agent: string; change: WorktreeChange; verification?: Verification }) {
|
|
190
|
+
const failed = continued.verification?.commands.find(command => command.exitCode !== 0);
|
|
191
|
+
return `Your worktree starts from the change of ${continued.agent}'s assignment ${from}, committed on your branch (${continued.change.files.length} files: ${continued.change.files.slice(0, 20).join(', ')}). Build on it rather than starting over.` +
|
|
192
|
+
(continued.verification && !continued.verification.passed
|
|
193
|
+
? ` The project's checks failed on it: ${describeVerification(continued.verification)}, running \`${failed?.command}\`. The end of its output:\n${failed?.output.slice(-1500) ?? ''}`
|
|
194
|
+
: '');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function verifyTool(sessionId: string, session: Session, args: unknown) {
|
|
198
|
+
if (!plainObject(args, ['taskId']) || (args.taskId !== undefined && typeof args.taskId !== 'string')) return toolResult(false, { error: 'alp_verify takes only taskId' });
|
|
199
|
+
if (args.taskId !== undefined && (session.parent || session.mapping.agent.name !== mainOf(session.mapping))) return toolResult(false, { error: 'Only main records a verification on a task; call alp_verify without taskId' });
|
|
200
|
+
const project = session.mapping.agent.projectRoot;
|
|
201
|
+
let config: VerifyConfig | undefined;
|
|
202
|
+
try { config = await verifyConfig(project); } catch (error) { return toolResult(false, { error: errorData(error).message }); }
|
|
203
|
+
if (!config) return toolResult(false, { error: 'This project has no verify commands (verify in .alp/settings.json); run its checks yourself' });
|
|
204
|
+
if (args.taskId !== undefined) {
|
|
205
|
+
const task = (await loadTasks(project)).tasks.find(entry => entry.id === args.taskId);
|
|
206
|
+
if (!task) return toolResult(false, { error: `No task ${args.taskId}` });
|
|
207
|
+
if (task.status === 'closed') return toolResult(false, { error: `${args.taskId} is closed` });
|
|
208
|
+
}
|
|
209
|
+
const checkout = await checkoutKey(session.mapping.workdir);
|
|
210
|
+
const verification = await inCheckout(checkout, () => verifyIn(project, session.mapping.workdir, config!));
|
|
211
|
+
noteVerification(rootOf(sessionId), project, args.taskId, undefined, 'checkout', verification);
|
|
212
|
+
return toolResult(true, { ...verificationResult(verification), summary: describeVerification(verification), ...(args.taskId ? { recordedOn: args.taskId } : {}) });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { settleWorktree, worktreeTool, inCheckout, verifyIn, verificationResult, noteVerification, continuedBrief, verifyTool };
|
|
216
|
+
}
|