dev-orchestrator-connector 0.1.0 → 0.1.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.
@@ -0,0 +1,182 @@
1
+ import { mkdtemp, readFile, writeFile, rm, realpath, lstat, access, mkdir } from "node:fs/promises";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { command, cleanGitEnv } from "./process.js";
5
+ import { withGitHubCredential, validateGitHubCredential } from "./git-credential.js";
6
+ import { validBranch, validRepository } from "./workspace.js";
7
+ const validSHA = (sha) => /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(sha);
8
+ export class Publisher {
9
+ context;
10
+ runCommand;
11
+ apiFetch;
12
+ remoteURL;
13
+ constructor(context, dependencies = {}) {
14
+ if (!validRepository(context.repository) || !validBranch(context.branch) || !validBranch(context.baseBranch) || context.branch === context.baseBranch || !context.branch.startsWith("task/") || !validSHA(context.baseline))
15
+ throw new Error("Invalid publication context.");
16
+ this.context = context;
17
+ this.runCommand = dependencies.runCommand ?? command;
18
+ this.apiFetch = dependencies.apiFetch ?? fetch;
19
+ this.remoteURL = dependencies.remoteURL ?? (repository => `https://github.com/${repository}.git`);
20
+ }
21
+ async status(signal) {
22
+ return this.trusted(async (git, sha) => {
23
+ const uncommitted = await git(["diff", "--no-ext-diff", "--no-textconv", "--quiet", sha, "--"], true);
24
+ const untracked = await git(["ls-files", "--others", "--exclude-standard"]);
25
+ if (uncommitted.code !== 0 || untracked.output)
26
+ throw new Error("Uncommitted changes remain. Codex must commit the task before publication.");
27
+ const diff = await git(["diff", "--no-ext-diff", "--no-textconv", "--quiet", this.context.baseline, sha, "--"], true);
28
+ return { changed: diff.code === 1, commit: sha };
29
+ }, signal);
30
+ }
31
+ async publish(title, body, credential, signal) {
32
+ if (!title.trim() || title.length > 240 || body.length > 64_000)
33
+ throw new Error("Invalid PR metadata.");
34
+ validateGitHubCredential(credential);
35
+ const status = await this.status(signal);
36
+ if (!status.changed)
37
+ throw new Error("No committed changes to publish.");
38
+ return this.trusted(async (git, sha, temp, objects) => {
39
+ await withGitHubCredential(temp, credential, env => git(["push", "--no-verify", this.remoteURL(this.context.repository), `${sha}:refs/heads/${this.context.branch}`], false, { ...env, GIT_OBJECT_DIRECTORY: objects }));
40
+ let pr = await this.findOpenPullRequest(credential, signal);
41
+ if (!pr) {
42
+ const [owner] = this.context.repository.split("/");
43
+ const created = await this.githubRequest("POST", `/repos/${this.context.repository}/pulls`, credential, {
44
+ title,
45
+ head: `${owner}:${this.context.branch}`,
46
+ base: this.context.baseBranch,
47
+ body,
48
+ draft: false
49
+ }, signal);
50
+ if (created.status === 422)
51
+ pr = await this.findOpenPullRequest(credential, signal);
52
+ else if (created.ok)
53
+ pr = created.body;
54
+ else
55
+ throw new Error("GitHub could not create a pull request for this branch.");
56
+ }
57
+ if (!this.matchesPullRequest(pr, sha))
58
+ throw new Error("GitHub pull request does not match the task commit.");
59
+ return { repository: this.context.repository, branch: this.context.branch, base_branch: this.context.baseBranch, commit: sha, pr_url: pr.html_url };
60
+ }, signal);
61
+ }
62
+ async findOpenPullRequest(credential, signal) {
63
+ const [owner] = this.context.repository.split("/");
64
+ const query = new URLSearchParams({ state: "open", head: `${owner}:${this.context.branch}`, per_page: "100" });
65
+ const response = await this.githubRequest("GET", `/repos/${this.context.repository}/pulls?${query}`, credential, undefined, signal);
66
+ if (!response.ok || !Array.isArray(response.body))
67
+ throw new Error("GitHub pull request lookup failed.");
68
+ const sameBranch = response.body.filter((pr) => pr?.head?.ref === this.context.branch && pr?.head?.repo?.full_name === this.context.repository);
69
+ const exact = sameBranch.find((pr) => pr?.base?.ref === this.context.baseBranch && pr?.base?.repo?.full_name === this.context.repository);
70
+ if (sameBranch.length && !exact)
71
+ throw new Error("An open pull request exists for this task branch with a different base repository or branch.");
72
+ return exact;
73
+ }
74
+ matchesPullRequest(pr, sha) {
75
+ const escaped = this.context.repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
+ return pr?.state === "open" && pr?.head?.sha === sha && pr?.head?.ref === this.context.branch &&
77
+ pr?.head?.repo?.full_name === this.context.repository && pr?.base?.ref === this.context.baseBranch &&
78
+ pr?.base?.repo?.full_name === this.context.repository &&
79
+ new RegExp(`^https://github\\.com/${escaped}/pull/[0-9]+$`).test(pr?.html_url ?? "");
80
+ }
81
+ async githubRequest(method, path, credential, body, signal) {
82
+ const timeout = AbortSignal.timeout(15_000);
83
+ const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
84
+ let response;
85
+ try {
86
+ response = await this.apiFetch("https://api.github.com" + path, {
87
+ method, redirect: "error", signal: requestSignal,
88
+ headers: { authorization: `Bearer ${credential.token}`, accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "content-type": "application/json", "user-agent": "dev-orchestrator" },
89
+ ...(body ? { body: JSON.stringify(body) } : {})
90
+ });
91
+ }
92
+ catch {
93
+ throw new Error("GitHub pull request service is temporarily unavailable.");
94
+ }
95
+ let payload;
96
+ try {
97
+ payload = await response.json();
98
+ }
99
+ catch {
100
+ throw new Error("GitHub returned an invalid pull request response.");
101
+ }
102
+ return { ok: response.ok, status: response.status, body: payload };
103
+ }
104
+ async trusted(fn, signal) {
105
+ const workspace = await realpath(this.context.workspace);
106
+ const pointer = join(workspace, ".git");
107
+ const stat = await lstat(pointer);
108
+ if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile()))
109
+ throw new Error("Publication needs valid Git metadata.");
110
+ let gitDir;
111
+ let commonDir;
112
+ if (stat.isDirectory()) {
113
+ gitDir = workspace + sep + ".git";
114
+ commonDir = gitDir;
115
+ }
116
+ else {
117
+ const content = await readFile(pointer, "utf8");
118
+ const match = content.match(/^gitdir: (.+)\r?\n?$/);
119
+ if (!match)
120
+ throw new Error("Publication needs valid Git worktree metadata.");
121
+ gitDir = await realpath(resolve(workspace, match[1]));
122
+ commonDir = await realpath(join(gitDir, "..", ".."));
123
+ const worktrees = await realpath(join(commonDir, "worktrees"));
124
+ if (!gitDir.startsWith(worktrees + sep) || gitDir.slice(worktrees.length + 1).includes(sep))
125
+ throw new Error("Publication worktree metadata escaped its repository.");
126
+ }
127
+ const objects = await realpath(join(commonDir, "objects"));
128
+ if (!objects.startsWith(commonDir + sep))
129
+ throw new Error("Publication needs an internal Git object directory.");
130
+ for (const forbidden of ["info/alternates", "info/http-alternates"]) {
131
+ try {
132
+ await access(join(objects, forbidden));
133
+ throw new Error("External Git object stores are not supported.");
134
+ }
135
+ catch (error) {
136
+ if (error.code !== "ENOENT")
137
+ throw error;
138
+ }
139
+ }
140
+ const head = await readFile(join(gitDir, "HEAD"), "utf8");
141
+ if (head.trim() !== `ref: refs/heads/${this.context.branch}`)
142
+ throw new Error("Task branch changed unexpectedly.");
143
+ const ref = join(commonDir, "refs", "heads", this.context.branch);
144
+ const resolved = await realpath(ref);
145
+ if (!resolved.startsWith(commonDir + sep))
146
+ throw new Error("Unsafe Git reference.");
147
+ const sha = (await readFile(ref, "utf8")).trim();
148
+ if (!validSHA(sha))
149
+ throw new Error("Invalid task commit.");
150
+ const temporary = await mkdtemp(join(tmpdir(), "dev-orchestrator-publish-"));
151
+ try {
152
+ const bare = join(temporary, "repo.git");
153
+ await this.runCommand("git", ["init", "--bare", "--template=", bare], { signal });
154
+ await mkdir(join(bare, "info"), { mode: 0o700 });
155
+ const excludes = join(commonDir, "info", "exclude");
156
+ const excludesInfo = await lstat(excludes).catch(error => { if (error.code === "ENOENT")
157
+ return undefined; throw error; });
158
+ if (excludesInfo) {
159
+ if (!excludesInfo.isFile() || excludesInfo.isSymbolicLink() || excludesInfo.size > 1_048_576)
160
+ throw new Error("Unsafe Git exclude metadata.");
161
+ await writeFile(join(bare, "info", "exclude"), await readFile(excludes, "utf8"), { mode: 0o600 });
162
+ }
163
+ try {
164
+ const shallow = await readFile(join(commonDir, "shallow"), "utf8");
165
+ if (!shallow.trim().split("\n").every(validSHA))
166
+ throw new Error("Invalid shallow boundary.");
167
+ await writeFile(join(bare, "shallow"), shallow, { mode: 0o600 });
168
+ }
169
+ catch (error) {
170
+ if (error.code !== "ENOENT")
171
+ throw error;
172
+ }
173
+ const env = { ...cleanGitEnv(), GIT_OBJECT_DIRECTORY: objects };
174
+ const git = (args, allowExitOne = false, commandEnv = env) => this.runCommand("git", ["--git-dir", bare, "--work-tree", workspace, "-c", "core.bare=false", "-c", "core.hooksPath=/dev/null", ...args], { cwd: temporary, env: commandEnv, signal, allowExitOne });
175
+ await git(["read-tree", sha]);
176
+ return await fn(git, sha, temporary, objects);
177
+ }
178
+ finally {
179
+ await rm(temporary, { recursive: true, force: true });
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,310 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, unlink, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { command } from "./process.js";
5
+ import { withGitHubCredential } from "./git-credential.js";
6
+ import { validBranch, validRepository } from "./workspace.js";
7
+ const validId = (value) => typeof value === "string" && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(value);
8
+ const validSHA = (value) => typeof value === "string" && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
9
+ async function ensureDirectory(path) {
10
+ await mkdir(path, { recursive: true, mode: 0o700 });
11
+ const info = await lstat(path);
12
+ const canonical = await realpath(path);
13
+ if (!info.isDirectory() || info.isSymbolicLink() || canonical !== resolve(path))
14
+ throw new Error("Managed repository storage must be a canonical directory without symlinks.");
15
+ await chmod(path, 0o700);
16
+ return canonical;
17
+ }
18
+ async function acquireLock(path) {
19
+ const deadline = Date.now() + 30_000;
20
+ while (Date.now() < deadline) {
21
+ try {
22
+ const file = await open(path, "wx", 0o600);
23
+ await file.writeFile(String(process.pid));
24
+ await file.sync();
25
+ await file.close();
26
+ return async () => { await unlink(path).catch(error => { if (error.code !== "ENOENT")
27
+ throw error; }); };
28
+ }
29
+ catch (error) {
30
+ const code = error.code;
31
+ if (code !== "EEXIST")
32
+ throw error;
33
+ const info = await lstat(path).catch(() => undefined);
34
+ if (info?.isSymbolicLink() || (info && !info.isFile()))
35
+ throw new Error("Unsafe repository cache lock.");
36
+ const value = info ? await readFile(path, "utf8").catch(() => "") : "";
37
+ const pid = Number(value.trim());
38
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ }
42
+ catch (e) {
43
+ if (e.code === "ESRCH") {
44
+ await unlink(path).catch(() => { });
45
+ continue;
46
+ }
47
+ }
48
+ }
49
+ await new Promise(resolve => setTimeout(resolve, 100));
50
+ }
51
+ }
52
+ throw new Error("Repository cache is busy. Retry after the active repository operation finishes.");
53
+ }
54
+ async function worktreeGitDirectory(workspace, cache) {
55
+ const pointer = join(workspace, ".git");
56
+ const info = await lstat(pointer);
57
+ if (!info.isFile() || info.isSymbolicLink())
58
+ throw new Error("Task worktree Git pointer is invalid.");
59
+ const contents = await readFile(pointer, "utf8");
60
+ const match = contents.match(/^gitdir: (.+)\r?\n?$/);
61
+ if (!match)
62
+ throw new Error("Task worktree Git pointer is invalid.");
63
+ const gitdir = await realpath(resolve(workspace, match[1]));
64
+ const worktrees = await realpath(join(cache, "worktrees"));
65
+ if (!gitdir.startsWith(worktrees + sep) || relative(worktrees, gitdir).split(sep).length !== 1)
66
+ throw new Error("Task worktree metadata escaped its managed repository.");
67
+ const gitInfo = await lstat(gitdir);
68
+ if (!gitInfo.isDirectory() || gitInfo.isSymbolicLink())
69
+ throw new Error("Task worktree metadata is invalid.");
70
+ return gitdir;
71
+ }
72
+ async function readMarker(workspace, cache) {
73
+ const gitdir = await worktreeGitDirectory(workspace, cache);
74
+ const path = join(gitdir, "orchestrator.json");
75
+ const info = await lstat(path);
76
+ if (!info.isFile() || info.isSymbolicLink() || (info.mode & 0o077) !== 0)
77
+ throw new Error("Task worktree marker is unsafe.");
78
+ const marker = JSON.parse(await readFile(path, "utf8"));
79
+ if (!validId(marker.run) || !validId(marker.project) || !Number.isInteger(marker.lifecycle) || marker.lifecycle < 1 || !validRepository(marker.repository) || !validBranch(marker.base_branch) || !validBranch(marker.branch) || !validSHA(marker.baseline))
80
+ throw new Error("Task worktree marker is invalid.");
81
+ return marker;
82
+ }
83
+ async function workspaceCache(workspace) {
84
+ const pointer = await readFile(join(workspace, ".git"), "utf8");
85
+ const match = pointer.match(/^gitdir: (.+)\r?\n?$/);
86
+ if (!match)
87
+ throw new Error("Task worktree Git pointer is invalid.");
88
+ const gitdir = await realpath(resolve(workspace, match[1]));
89
+ return realpath(join(gitdir, "..", ".."));
90
+ }
91
+ export class RepositoryWorkspace {
92
+ remoteURL;
93
+ runCommand;
94
+ constructor(options = {}) {
95
+ this.remoteURL = options.remoteURL ?? (repository => `https://github.com/${repository}.git`);
96
+ this.runCommand = options.runCommand ?? command;
97
+ }
98
+ async prepare(root, lease, credential) {
99
+ const projectId = lease.project_id;
100
+ const repository = lease.snapshot?.repository;
101
+ const baseBranch = lease.snapshot?.base_branch;
102
+ if (!isAbsolute(root) || !validId(lease.id) || !validId(projectId) || !Number.isInteger(lease.lifecycle) || lease.lifecycle < 1 || !validRepository(repository) || !validBranch(baseBranch))
103
+ throw new Error("Task repository snapshot is invalid.");
104
+ const dataRoot = await ensureDirectory(root);
105
+ const repositories = await ensureDirectory(join(dataRoot, "repositories"));
106
+ const workspaces = await ensureDirectory(join(dataRoot, "workspaces"));
107
+ const cacheKey = createHash("sha256").update(repository).digest("hex").slice(0, 16);
108
+ const cache = join(repositories, `${projectId}-${cacheKey}`);
109
+ const workspace = join(workspaces, `${lease.id}-${lease.lifecycle}`);
110
+ const branch = `task/${lease.id}`;
111
+ const remote = this.remoteURL(repository);
112
+ const release = await acquireLock(cache + ".lock");
113
+ try {
114
+ return await withGitHubCredential(dataRoot, credential, async (env) => {
115
+ const git = (args, cwd) => this.runCommand("git", args, { cwd, env, timeout: 120_000 });
116
+ let cacheInfo = await lstat(cache).catch(error => { if (error.code === "ENOENT")
117
+ return undefined; throw error; });
118
+ if (cacheInfo) {
119
+ if (!cacheInfo.isDirectory() || cacheInfo.isSymbolicLink() || await realpath(cache) !== cache)
120
+ throw new Error("Managed repository cache is unsafe.");
121
+ if ((await git(["--git-dir", cache, "rev-parse", "--is-bare-repository"])).output !== "true")
122
+ throw new Error("Managed repository cache is invalid.");
123
+ const existing = (await git(["--git-dir", cache, "remote", "get-url", "origin"])).output;
124
+ if (existing !== remote)
125
+ throw new Error("Managed repository cache does not match the selected repository.");
126
+ }
127
+ else {
128
+ await git(["-c", "core.hooksPath=/dev/null", "clone", "--bare", "--template=", "--", remote, cache]);
129
+ cacheInfo = await lstat(cache);
130
+ if (!cacheInfo.isDirectory() || cacheInfo.isSymbolicLink())
131
+ throw new Error("Repository checkout did not create a safe cache.");
132
+ await chmod(cache, 0o700);
133
+ }
134
+ await git(["--git-dir", cache, "-c", "core.hooksPath=/dev/null", "fetch", "--prune", "origin", `+refs/heads/${baseBranch}:refs/remotes/origin/${baseBranch}`]);
135
+ const baseline = (await git(["--git-dir", cache, "rev-parse", `refs/remotes/origin/${baseBranch}^{commit}`])).output;
136
+ if (!validSHA(baseline))
137
+ throw new Error("Selected base branch does not resolve to a commit.");
138
+ const existingWorkspace = await lstat(workspace).catch(error => { if (error.code === "ENOENT")
139
+ return undefined; throw error; });
140
+ if (existingWorkspace) {
141
+ if (!existingWorkspace.isDirectory() || existingWorkspace.isSymbolicLink() || await realpath(workspace) !== workspace)
142
+ throw new Error("Existing task worktree is unsafe.");
143
+ const marker = await readMarker(workspace, cache);
144
+ if (marker.run !== lease.id || marker.lifecycle !== lease.lifecycle || marker.project !== projectId || marker.repository !== repository || marker.base_branch !== baseBranch || marker.branch !== branch || !validSHA(marker.baseline))
145
+ throw new Error("Existing task worktree identity does not match this lease.");
146
+ return { path: workspace, branch, baseline: marker.baseline };
147
+ }
148
+ await git(["--git-dir", cache, "worktree", "add", "--force", "--detach", workspace, baseline]);
149
+ await git(["-C", workspace, "-c", "core.hooksPath=/dev/null", "switch", "-C", branch, "--ignore-other-worktrees", baseline]);
150
+ await git(["-C", workspace, "config", "user.name", credential.author_name]);
151
+ await git(["-C", workspace, "config", "user.email", credential.author_email]);
152
+ await git(["-C", workspace, "config", "core.hooksPath", "/dev/null"]);
153
+ const gitdir = await worktreeGitDirectory(workspace, cache);
154
+ await writeFile(join(gitdir, "orchestrator.json"), JSON.stringify({ run: lease.id, lifecycle: lease.lifecycle, project: projectId, repository, base_branch: baseBranch, baseline, branch }), { mode: 0o600, flag: "wx" });
155
+ return { path: workspace, branch, baseline };
156
+ });
157
+ }
158
+ catch (error) {
159
+ throw error instanceof Error && /Task repository|Managed repository|Selected base|Existing task|Task worktree/.test(error.message)
160
+ ? error
161
+ : new Error("Repository setup failed. Check GitHub App access, repository selection, base branch, and local storage.", { cause: error });
162
+ }
163
+ finally {
164
+ await release();
165
+ }
166
+ }
167
+ async writeProjectInstructions(workspace, instructions) {
168
+ if (typeof instructions !== "string" || Buffer.byteLength(instructions, "utf8") > 32_000)
169
+ throw new Error("Project instructions snapshot is invalid.");
170
+ const canonical = await realpath(workspace);
171
+ if (!isAbsolute(workspace) || canonical !== resolve(workspace))
172
+ throw new Error("Task worktree path must be canonical.");
173
+ const pointer = await readFile(join(canonical, ".git"), "utf8");
174
+ const match = pointer.match(/^gitdir: (.+)\r?\n?$/);
175
+ if (!match)
176
+ throw new Error("Task worktree Git pointer is invalid.");
177
+ const gitdir = await realpath(resolve(canonical, match[1]));
178
+ const cache = dirname(dirname(gitdir));
179
+ const marker = await readMarker(canonical, cache);
180
+ const target = join(canonical, `.dev-orchestrator-instructions-${marker.run}-${marker.lifecycle}.md`);
181
+ const release = await acquireLock(cache + ".lock");
182
+ try {
183
+ const existing = await lstat(target).catch(error => { if (error.code === "ENOENT")
184
+ return undefined; throw error; });
185
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
186
+ throw new Error("Project instructions target is unsafe.");
187
+ if (instructions === "") {
188
+ if (existing)
189
+ await unlink(target);
190
+ return undefined;
191
+ }
192
+ if (existing) {
193
+ if (await readFile(target, "utf8") !== instructions)
194
+ throw new Error("Project instructions target already exists with different content.");
195
+ return target;
196
+ }
197
+ const excludeDirPath = join(cache, "info");
198
+ await mkdir(excludeDirPath, { recursive: true, mode: 0o700 });
199
+ const excludeDir = await realpath(excludeDirPath);
200
+ if (!excludeDir.startsWith(cache + sep))
201
+ throw new Error("Worktree ignore metadata escaped its Git directory.");
202
+ const excludeDirInfo = await lstat(excludeDirPath);
203
+ if (!excludeDirInfo.isDirectory() || excludeDirInfo.isSymbolicLink())
204
+ throw new Error("Worktree ignore metadata is unsafe.");
205
+ const excludePath = join(excludeDir, "exclude");
206
+ await writeFile(excludePath, "", { encoding: "utf8", mode: 0o600, flag: "wx" }).catch(error => {
207
+ if (error.code !== "EEXIST")
208
+ throw error;
209
+ });
210
+ const excludeInfo = await lstat(excludePath);
211
+ if (!excludeInfo.isFile() || excludeInfo.isSymbolicLink())
212
+ throw new Error("Worktree ignore metadata is unsafe.");
213
+ const exclude = await readFile(excludePath, "utf8");
214
+ const rule = `/${basename(target)}`;
215
+ if (!exclude.split(/\r?\n/).includes(rule)) {
216
+ const excludeTemp = join(excludeDir, `.exclude-${randomUUID()}`);
217
+ try {
218
+ await writeFile(excludeTemp, exclude + (exclude.endsWith("\n") || exclude === "" ? "" : "\n") + rule + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
219
+ await rename(excludeTemp, excludePath);
220
+ }
221
+ catch (error) {
222
+ await unlink(excludeTemp).catch(() => { });
223
+ throw error;
224
+ }
225
+ }
226
+ const temporary = join(canonical, `.instructions-${randomUUID()}`);
227
+ try {
228
+ await writeFile(temporary, instructions, { encoding: "utf8", mode: 0o600, flag: "wx" });
229
+ await chmod(temporary, 0o600);
230
+ await rename(temporary, target);
231
+ return target;
232
+ }
233
+ catch (error) {
234
+ await unlink(temporary).catch(() => { });
235
+ throw error;
236
+ }
237
+ }
238
+ finally {
239
+ await release();
240
+ }
241
+ }
242
+ async markTerminal(workspace, outcome) {
243
+ const canonical = await realpath(workspace);
244
+ if (!isAbsolute(workspace) || canonical !== resolve(workspace))
245
+ throw new Error("Task worktree path must be canonical.");
246
+ const cache = await workspaceCache(canonical);
247
+ const gitdir = await worktreeGitDirectory(canonical, cache);
248
+ await readMarker(canonical, cache);
249
+ const terminalPath = join(gitdir, "terminal.json");
250
+ const existing = await lstat(terminalPath).catch(error => { if (error.code === "ENOENT")
251
+ return undefined; throw error; });
252
+ if (existing) {
253
+ if (!existing.isFile() || existing.isSymbolicLink() || (existing.mode & 0o077) !== 0)
254
+ throw new Error("Task terminal marker is unsafe.");
255
+ const previous = JSON.parse(await readFile(terminalPath, "utf8"));
256
+ if (previous.outcome !== outcome)
257
+ throw new Error("Task terminal outcome changed unexpectedly.");
258
+ return;
259
+ }
260
+ await writeFile(terminalPath, JSON.stringify({ outcome, recorded_at: Date.now() }), { mode: 0o600, flag: "wx" });
261
+ }
262
+ async cleanupExpired(root, protectedWorkspace, now = Date.now()) {
263
+ const canonicalRoot = await ensureDirectory(root);
264
+ const workspacesRoot = join(canonicalRoot, "workspaces");
265
+ const entries = await readdir(workspacesRoot, { withFileTypes: true }).catch(error => {
266
+ if (error.code === "ENOENT")
267
+ return [];
268
+ throw error;
269
+ });
270
+ let removed = 0;
271
+ for (const entry of entries) {
272
+ if (!entry.isDirectory() || entry.isSymbolicLink())
273
+ continue;
274
+ const workspace = join(workspacesRoot, entry.name);
275
+ if (workspace === protectedWorkspace)
276
+ continue;
277
+ try {
278
+ if (await realpath(workspace) !== workspace)
279
+ continue;
280
+ const cache = await workspaceCache(workspace);
281
+ const marker = await readMarker(workspace, cache);
282
+ if (entry.name !== `${marker.run}-${marker.lifecycle}`)
283
+ continue;
284
+ const terminalPath = join(await worktreeGitDirectory(workspace, cache), "terminal.json");
285
+ const terminalInfo = await lstat(terminalPath);
286
+ if (!terminalInfo.isFile() || terminalInfo.isSymbolicLink() || (terminalInfo.mode & 0o077) !== 0)
287
+ continue;
288
+ const terminal = JSON.parse(await readFile(terminalPath, "utf8"));
289
+ if (!["success", "failure"].includes(String(terminal.outcome)) || typeof terminal.recorded_at !== "number")
290
+ continue;
291
+ const retention = terminal.outcome === "success" ? 24 * 60 * 60_000 : 7 * 24 * 60 * 60_000;
292
+ if (now - terminal.recorded_at < retention)
293
+ continue;
294
+ if (await realpath(cache) !== cache)
295
+ continue;
296
+ const release = await acquireLock(cache + ".lock");
297
+ try {
298
+ await this.runCommand("git", ["--git-dir", cache, "-c", "core.hooksPath=/dev/null", "worktree", "remove", "--force", workspace], { env: { PATH: process.env.PATH, HOME: process.env.HOME, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, timeout: 30_000 });
299
+ await rm(workspace, { recursive: true, force: true });
300
+ removed++;
301
+ }
302
+ finally {
303
+ await release();
304
+ }
305
+ }
306
+ catch { /* Unknown or unsafe worktrees are retained for manual recovery. */ }
307
+ }
308
+ return removed;
309
+ }
310
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};