dev-flow-deepseek 0.8.8 → 0.9.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/README.md +71 -20
- package/lib/authorization.mjs +15 -1
- package/lib/index.mjs +10 -5
- package/lib/provisioning-receipt.mjs +163 -0
- package/lib/tool-names.mjs +2 -0
- package/lib/workspace-coordinator.mjs +625 -0
- package/lib/workspace-tool.mjs +118 -0
- package/package.json +4 -1
- package/runtime/darwin-arm64/dev-flow +0 -0
- package/runtime/win32-x64/dev-flow.exe +0 -0
- package/skills/dev-flow/SKILL.md +292 -113
- package/skills/dev-flow/references/method-profiles.md +22 -18
- package/skills/dev-flow/references/node-payloads.md +31 -18
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { access, lstat, realpath, stat } from "node:fs/promises";
|
|
4
|
+
import { constants as fsConstants } from "node:fs";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
import { currentDirectUserText } from "./authorization.mjs";
|
|
8
|
+
import {
|
|
9
|
+
readProvisioningReceipt,
|
|
10
|
+
validateProvisioningReceipt,
|
|
11
|
+
writeProvisioningReceipt,
|
|
12
|
+
} from "./provisioning-receipt.mjs";
|
|
13
|
+
|
|
14
|
+
export const WORKSPACE_COORDINATOR_TOOL = "workspace_coordinator";
|
|
15
|
+
const MAX_COMMAND_OUTPUT = 1024 * 1024;
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
17
|
+
|
|
18
|
+
export function workspaceConfirmationText(repositories) {
|
|
19
|
+
const rows = validateRepositoryRequests(repositories).map((repository) =>
|
|
20
|
+
`repository=${repository.repository_key};remote=${repository.remote_name};base=${repository.base_branch};target=${repository.target_branch}`,
|
|
21
|
+
);
|
|
22
|
+
return ["/dev-flow confirm-worktree", ...rows].join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function workspaceResumeText(launchID) {
|
|
26
|
+
assertLaunchID(launchID);
|
|
27
|
+
return `/dev-flow resume-worktree launch=${launchID}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function workspaceCleanupText(operation, { launchID, repositoryKey, taskID, revision }) {
|
|
31
|
+
if (!new Set(["prepare_cleanup", "cleanup_worktree", "cleanup_branch"]).has(operation)) throw new Error("cleanup operation is invalid");
|
|
32
|
+
assertLaunchID(launchID);
|
|
33
|
+
if (typeof repositoryKey !== "string" || !/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(repositoryKey)) throw new Error("cleanup repository key is invalid");
|
|
34
|
+
if (typeof taskID !== "string" || taskID.trim() === "" || /\s/u.test(taskID)) throw new Error("cleanup Task identity is invalid");
|
|
35
|
+
if (!Number.isInteger(revision) || revision < 1) throw new Error("cleanup Task revision is invalid");
|
|
36
|
+
return `/dev-flow ${operation.replace("_", "-")} launch=${launchID} repository=${repositoryKey} task=${taskID} revision=${revision}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function authorizeWorkspaceExecution(execution) {
|
|
40
|
+
if (execution?.name !== WORKSPACE_COORDINATOR_TOOL) return;
|
|
41
|
+
const operation = execution.arguments?.operation;
|
|
42
|
+
const text = currentDirectUserText(execution);
|
|
43
|
+
if (operation === "provision") {
|
|
44
|
+
const expected = workspaceConfirmationText(execution.arguments?.repositories);
|
|
45
|
+
if (!text.includes(expected)) {
|
|
46
|
+
throw new Error(`DEV_FLOW_WORKTREE_CONFIRMATION_REQUIRED: send this exact confirmation in the current direct user turn:\n${expected}`);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (operation === "consume") {
|
|
51
|
+
const expected = workspaceResumeText(execution.arguments?.launch_id);
|
|
52
|
+
if (!text.includes(expected)) {
|
|
53
|
+
throw new Error(`DEV_FLOW_WORKTREE_RELAUNCH_REQUIRED: the current direct user turn must include ${expected}`);
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (operation === "prepare_cleanup" || operation === "cleanup_worktree" || operation === "cleanup_branch") {
|
|
58
|
+
const expected = workspaceCleanupText(operation, {
|
|
59
|
+
launchID: execution.arguments?.launch_id,
|
|
60
|
+
repositoryKey: execution.arguments?.repository_key,
|
|
61
|
+
taskID: execution.arguments?.task_id,
|
|
62
|
+
revision: execution.arguments?.revision,
|
|
63
|
+
});
|
|
64
|
+
if (!text.includes(expected)) throw new Error(`DEV_FLOW_WORKSPACE_CLEANUP_CONFIRMATION_REQUIRED: the current direct user turn must include ${expected}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new Error("DEV_FLOW_WORKSPACE_OPERATION_INVALID: operation is not supported");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createWorkspaceCoordinator({
|
|
71
|
+
dataDirectory,
|
|
72
|
+
workspaceRoot = process.cwd(),
|
|
73
|
+
command = runClosedCommand,
|
|
74
|
+
now = () => new Date(),
|
|
75
|
+
launchID = randomUUID,
|
|
76
|
+
dshExecutable = "dsh",
|
|
77
|
+
readTask,
|
|
78
|
+
} = {}) {
|
|
79
|
+
if (typeof dataDirectory !== "string" || !isAbsolute(dataDirectory)) throw new Error("workspace coordinator data directory is required");
|
|
80
|
+
if (typeof workspaceRoot !== "string" || !isAbsolute(workspaceRoot)) throw new Error("workspace coordinator root is required");
|
|
81
|
+
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
async provision({ request, profile, repositories, signal } = {}) {
|
|
84
|
+
if (typeof request !== "string" || request.trim() === "" || request !== request.trim()) throw new Error("workspace request is invalid");
|
|
85
|
+
assertProfile(profile);
|
|
86
|
+
const requested = validateRepositoryRequests(repositories);
|
|
87
|
+
const canonicalWorkspaceRoot = await canonicalDirectory(workspaceRoot, "Workspace Root");
|
|
88
|
+
const id = launchID();
|
|
89
|
+
assertLaunchID(id);
|
|
90
|
+
const requestDigest = sha256(request);
|
|
91
|
+
const launchRoot = resolve(dirname(canonicalWorkspaceRoot), ".dev-flow-worktrees", id);
|
|
92
|
+
if (inside(canonicalWorkspaceRoot, launchRoot)) throw new Error("worktree launch root must be outside the current Workspace Root");
|
|
93
|
+
await assertNoSymlinkComponents(dirname(canonicalWorkspaceRoot), launchRoot);
|
|
94
|
+
|
|
95
|
+
const observed = [];
|
|
96
|
+
for (const repository of requested) {
|
|
97
|
+
const source = await observeSourceRepository(repository.source_repository_path, { command, signal });
|
|
98
|
+
if (!inside(canonicalWorkspaceRoot, source.root)) throw new Error(`repository ${repository.repository_key} is outside the current Workspace Root`);
|
|
99
|
+
await validateBranchSelection(source.root, repository, { command, signal });
|
|
100
|
+
const worktreePath = resolve(launchRoot, repository.repository_key);
|
|
101
|
+
await assertPathAbsent(worktreePath, `worktree path for ${repository.repository_key}`);
|
|
102
|
+
observed.push({ ...repository, source, worktreePath });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const timestamp = now().toISOString();
|
|
106
|
+
let receipt = validateProvisioningReceipt({
|
|
107
|
+
launch_id: id,
|
|
108
|
+
host: "deepseek",
|
|
109
|
+
request_digest: requestDigest,
|
|
110
|
+
profile,
|
|
111
|
+
workspace_root: observed.length === 1 ? observed[0].worktreePath : launchRoot,
|
|
112
|
+
operation_status: "confirmed",
|
|
113
|
+
repositories: observed.map((repository) => ({
|
|
114
|
+
source_repository_identity: repository.source.identity,
|
|
115
|
+
repository_key: repository.repository_key,
|
|
116
|
+
remote_name: repository.remote_name,
|
|
117
|
+
base_branch: repository.base_branch,
|
|
118
|
+
target_branch: repository.target_branch,
|
|
119
|
+
fetched_commit: null,
|
|
120
|
+
worktree_path: repository.worktreePath,
|
|
121
|
+
operation_status: "confirmed",
|
|
122
|
+
created_at: timestamp,
|
|
123
|
+
})),
|
|
124
|
+
created_at: timestamp,
|
|
125
|
+
updated_at: timestamp,
|
|
126
|
+
});
|
|
127
|
+
receipt = await writeProvisioningReceipt(dataDirectory, receipt);
|
|
128
|
+
|
|
129
|
+
const provisioned = [];
|
|
130
|
+
try {
|
|
131
|
+
receipt = await setLaunchStatus(receipt, "fetching", now, (repository) => ({ ...repository, operation_status: "fetching" }));
|
|
132
|
+
await writeProvisioningReceipt(dataDirectory, receipt);
|
|
133
|
+
for (const repository of receipt.repositories) {
|
|
134
|
+
const source = observed.find((entry) => entry.repository_key === repository.repository_key).source.root;
|
|
135
|
+
await git(source, [
|
|
136
|
+
"fetch", "--no-tags", repository.remote_name,
|
|
137
|
+
`refs/heads/${repository.base_branch}:refs/remotes/${repository.remote_name}/${repository.base_branch}`,
|
|
138
|
+
], { command, signal, mutating: true });
|
|
139
|
+
const fetchedCommit = (await git(source, [
|
|
140
|
+
"rev-parse", "--verify", `refs/remotes/${repository.remote_name}/${repository.base_branch}^{commit}`,
|
|
141
|
+
], { command, signal })).stdout.trim();
|
|
142
|
+
if (!/^[0-9a-f]{40,64}$/u.test(fetchedCommit)) throw new Error(`fetched commit for ${repository.repository_key} is invalid`);
|
|
143
|
+
receipt = await updateRepositoryStatus(receipt, repository.repository_key, "fetched", now, { fetched_commit: fetchedCommit });
|
|
144
|
+
await writeProvisioningReceipt(dataDirectory, receipt);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
receipt = await setLaunchStatus(receipt, "provisioning", now, (repository) => ({ ...repository, operation_status: "provisioning" }));
|
|
148
|
+
await writeProvisioningReceipt(dataDirectory, receipt);
|
|
149
|
+
for (const repository of receipt.repositories) {
|
|
150
|
+
const source = observed.find((entry) => entry.repository_key === repository.repository_key).source.root;
|
|
151
|
+
await ensureTargetStillAvailable(source, repository, { command, signal });
|
|
152
|
+
await git(source, [
|
|
153
|
+
"worktree", "add", "-b", repository.target_branch, repository.worktree_path, repository.fetched_commit,
|
|
154
|
+
], { command, signal, mutating: true });
|
|
155
|
+
await verifyProvisionedRepository(repository, { command, signal, sourceRepositoryPath: source });
|
|
156
|
+
provisioned.push(repository.repository_key);
|
|
157
|
+
receipt = await updateRepositoryStatus(receipt, repository.repository_key, "provisioned", now);
|
|
158
|
+
await writeProvisioningReceipt(dataDirectory, receipt);
|
|
159
|
+
}
|
|
160
|
+
receipt = await setLaunchStatus(receipt, "provisioned", now, (repository) => ({ ...repository, operation_status: "provisioned" }));
|
|
161
|
+
await writeProvisioningReceipt(dataDirectory, receipt);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const uncertain = error?.operationUncertain === true;
|
|
164
|
+
if (!uncertain) await compensateProvisioned(receipt, provisioned, observed, { command, signal }).catch(() => {});
|
|
165
|
+
receipt = await setLaunchStatus(receipt, uncertain ? "uncertain" : "failed", now, (repository) =>
|
|
166
|
+
provisioned.includes(repository.repository_key)
|
|
167
|
+
? repository
|
|
168
|
+
: { ...repository, operation_status: uncertain ? "uncertain" : "failed" },
|
|
169
|
+
);
|
|
170
|
+
await writeProvisioningReceipt(dataDirectory, receipt).catch(() => {});
|
|
171
|
+
const failure = new Error(uncertain
|
|
172
|
+
? "worktree provisioning result is uncertain; inspect the retained receipt and filesystem before retrying"
|
|
173
|
+
: "worktree provisioning failed; no Core Task was created");
|
|
174
|
+
failure.code = uncertain ? "WORKTREE_PROVISIONING_UNCERTAIN" : "WORKTREE_PROVISIONING_FAILED";
|
|
175
|
+
throw failure;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const prompt = `${workspaceResumeText(receipt.launch_id)}\nContinue the confirmed request exactly as assessed:\n${request}`;
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
status: "relaunch_required",
|
|
181
|
+
launch_id: receipt.launch_id,
|
|
182
|
+
request_digest: receipt.request_digest,
|
|
183
|
+
workspace_root: receipt.workspace_root,
|
|
184
|
+
source_dirty_paths: Object.fromEntries(observed.map((repository) => [repository.repository_key, repository.source.dirtyPaths])),
|
|
185
|
+
source_dirty_paths_truncated: Object.fromEntries(observed.map((repository) => [repository.repository_key, repository.source.dirtyPathsTruncated])),
|
|
186
|
+
relaunch: Object.freeze({ command: dshExecutable, arguments: Object.freeze(["--profile", profile, prompt]), cwd: receipt.workspace_root }),
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
|
|
190
|
+
async consume({ launchID: id, signal } = {}) {
|
|
191
|
+
assertLaunchID(id);
|
|
192
|
+
let receipt = await readProvisioningReceipt(dataDirectory, id);
|
|
193
|
+
if (receipt === null) throw new Error("provisioning receipt was not found");
|
|
194
|
+
if (!new Set(["provisioned", "consumed"]).has(receipt.operation_status)) {
|
|
195
|
+
throw new Error(`provisioning receipt is ${receipt.operation_status}; it cannot open a Core Task`);
|
|
196
|
+
}
|
|
197
|
+
const canonicalWorkspaceRoot = await canonicalDirectory(workspaceRoot, "Workspace Root");
|
|
198
|
+
if (canonicalWorkspaceRoot !== resolve(receipt.workspace_root)) {
|
|
199
|
+
throw new Error("DSH must be relaunched from the receipt workspace root");
|
|
200
|
+
}
|
|
201
|
+
for (const repository of receipt.repositories) {
|
|
202
|
+
if (!inside(canonicalWorkspaceRoot, repository.worktree_path)) throw new Error(`repository ${repository.repository_key} is outside the relaunched Workspace Root`);
|
|
203
|
+
await verifyProvisionedRepository(repository, { command, signal });
|
|
204
|
+
}
|
|
205
|
+
if (receipt.operation_status !== "consumed") {
|
|
206
|
+
receipt = await setLaunchStatus(receipt, "consumed", now, (repository) => ({ ...repository, operation_status: "consumed" }));
|
|
207
|
+
await writeProvisioningReceipt(dataDirectory, receipt);
|
|
208
|
+
}
|
|
209
|
+
const repositoriesOutput = receipt.repositories.map((repository) => ({
|
|
210
|
+
key: repository.repository_key,
|
|
211
|
+
repository_path: repository.worktree_path,
|
|
212
|
+
workspace_origin: {
|
|
213
|
+
mode: "dedicated_worktree",
|
|
214
|
+
remote_name: repository.remote_name,
|
|
215
|
+
base_branch: repository.base_branch,
|
|
216
|
+
base_commit: repository.fetched_commit,
|
|
217
|
+
task_branch: repository.target_branch,
|
|
218
|
+
provisioning_receipt_id: receipt.launch_id,
|
|
219
|
+
},
|
|
220
|
+
}));
|
|
221
|
+
const [primary, ...additional] = repositoriesOutput;
|
|
222
|
+
return Object.freeze({
|
|
223
|
+
status: "consumed",
|
|
224
|
+
launch_id: receipt.launch_id,
|
|
225
|
+
request_digest: receipt.request_digest,
|
|
226
|
+
workspace_root: receipt.workspace_root,
|
|
227
|
+
open_task: Object.freeze({
|
|
228
|
+
repository_path: primary.repository_path,
|
|
229
|
+
primary_repository_key: primary.key,
|
|
230
|
+
workspace_origin: primary.workspace_origin,
|
|
231
|
+
additional_repositories: Object.freeze(additional.map((repository) => Object.freeze({
|
|
232
|
+
key: repository.key,
|
|
233
|
+
repository_path: repository.repository_path,
|
|
234
|
+
workspace_origin: repository.workspace_origin,
|
|
235
|
+
}))),
|
|
236
|
+
}),
|
|
237
|
+
});
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async prepareCleanup({ launchID: id, repositoryKey, taskID, revision, sourceRepositoryPath, signal, execution } = {}) {
|
|
241
|
+
const state = await cleanupState({ dataDirectory, workspaceRoot, launchID: id, repositoryKey, taskID, revision, signal, execution, readTask, command });
|
|
242
|
+
if (typeof sourceRepositoryPath !== "string" || !isAbsolute(sourceRepositoryPath)) throw new Error("cleanup relaunch source repository path is required");
|
|
243
|
+
const source = await observeSourceRepository(sourceRepositoryPath, { command, signal });
|
|
244
|
+
if (source.identity !== state.repository.source_repository_identity) throw new Error("cleanup relaunch source does not match the receipt repository group");
|
|
245
|
+
if (source.root === state.repository.worktree_path) throw new Error("cleanup relaunch must use a source checkout outside the Task worktree");
|
|
246
|
+
const prompt = [
|
|
247
|
+
`/dev-flow resume-cleanup launch=${id} repository=${repositoryKey} task=${taskID} revision=${revision}`,
|
|
248
|
+
"The receipt-owned source checkout is now the fixed DSH Workspace Root.",
|
|
249
|
+
`Ask the developer to send exactly: ${workspaceCleanupText("cleanup_worktree", { launchID: id, repositoryKey, taskID, revision })}`,
|
|
250
|
+
"Do not delete the worktree or branch in this relaunch turn.",
|
|
251
|
+
].join("\n");
|
|
252
|
+
return Object.freeze({ status: "cleanup_relaunch_required", changed: false, launch_id: id, repository_key: repositoryKey, relaunch: Object.freeze({ command: dshExecutable, arguments: Object.freeze(["--profile", state.receipt.profile, prompt]), cwd: source.root }) });
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async cleanupWorktree({ launchID: id, repositoryKey, taskID, revision, signal, execution } = {}) {
|
|
256
|
+
const state = await cleanupState({ dataDirectory, workspaceRoot, launchID: id, repositoryKey, taskID, revision, signal, execution, readTask, command });
|
|
257
|
+
if (state.repository.operation_status === "worktree_removed" || state.repository.operation_status === "branch_removed") {
|
|
258
|
+
return Object.freeze({ status: state.repository.operation_status, changed: false, launch_id: id, repository_key: repositoryKey });
|
|
259
|
+
}
|
|
260
|
+
if (state.repository.operation_status !== "consumed") throw new Error("receipt repository is not ready for terminal cleanup");
|
|
261
|
+
const inspected = await inspectTerminalWorktree(state.repository, state.taskRepository, { command, signal });
|
|
262
|
+
await git(dirname(state.repository.worktree_path), ["--git-dir", inspected.commonDir, "worktree", "remove", state.repository.worktree_path], { command, signal, mutating: true });
|
|
263
|
+
const updated = await updateCleanupStatus(state.receipt, repositoryKey, "worktree_removed", now);
|
|
264
|
+
await writeProvisioningReceipt(dataDirectory, updated);
|
|
265
|
+
return Object.freeze({ status: "worktree_removed", changed: true, launch_id: id, repository_key: repositoryKey, branch_retained: true });
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
async cleanupBranch({ launchID: id, repositoryKey, taskID, revision, sourceRepositoryPath, signal, execution } = {}) {
|
|
269
|
+
const state = await cleanupState({ dataDirectory, workspaceRoot, launchID: id, repositoryKey, taskID, revision, signal, execution, readTask, command });
|
|
270
|
+
if (state.repository.operation_status === "branch_removed") {
|
|
271
|
+
return Object.freeze({ status: "branch_removed", changed: false, launch_id: id, repository_key: repositoryKey });
|
|
272
|
+
}
|
|
273
|
+
if (state.repository.operation_status !== "worktree_removed") throw new Error("worktree cleanup requires its own earlier authorization");
|
|
274
|
+
if (typeof sourceRepositoryPath !== "string" || !isAbsolute(sourceRepositoryPath)) throw new Error("branch cleanup source repository path is required");
|
|
275
|
+
const source = await observeSourceRepository(sourceRepositoryPath, { command, signal });
|
|
276
|
+
const canonicalWorkspaceRoot = await canonicalDirectory(workspaceRoot, "Workspace Root");
|
|
277
|
+
if (!inside(canonicalWorkspaceRoot, source.root) || source.identity !== state.repository.source_repository_identity) {
|
|
278
|
+
throw new Error("branch cleanup source does not match the receipt repository group");
|
|
279
|
+
}
|
|
280
|
+
const branchHead = (await git(source.root, ["rev-parse", "--verify", `refs/heads/${state.repository.target_branch}^{commit}`], { command, signal })).stdout.trim();
|
|
281
|
+
if (branchHead !== state.taskRepository.current_head) throw new Error("task branch HEAD differs from the terminal Core observation");
|
|
282
|
+
await assertRemoteHead(source.root, state.repository, branchHead, { command, signal });
|
|
283
|
+
const worktrees = (await git(source.root, ["worktree", "list", "--porcelain"], { command, signal })).stdout;
|
|
284
|
+
if (worktrees.split(/\r?\n/u).some((line) => line === `branch refs/heads/${state.repository.target_branch}`)) throw new Error("task branch is still checked out");
|
|
285
|
+
await git(source.root, ["branch", "-d", state.repository.target_branch], { command, signal, mutating: true });
|
|
286
|
+
const updated = await updateCleanupStatus(state.receipt, repositoryKey, "branch_removed", now);
|
|
287
|
+
await writeProvisioningReceipt(dataDirectory, updated);
|
|
288
|
+
return Object.freeze({ status: "branch_removed", changed: true, launch_id: id, repository_key: repositoryKey });
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function observeSourceRepository(path, { command, signal }) {
|
|
294
|
+
if (typeof path !== "string" || !isAbsolute(path)) throw new Error("source repository path must be absolute");
|
|
295
|
+
const root = await canonicalDirectory(path, "source repository");
|
|
296
|
+
const top = resolve((await git(root, ["rev-parse", "--show-toplevel"], { command, signal })).stdout.trim());
|
|
297
|
+
if (top !== root) throw new Error("source repository path must name its canonical worktree root");
|
|
298
|
+
const commonDir = resolve(root, (await git(root, ["rev-parse", "--git-common-dir"], { command, signal })).stdout.trim());
|
|
299
|
+
const gitDir = resolve(root, (await git(root, ["rev-parse", "--absolute-git-dir"], { command, signal })).stdout.trim());
|
|
300
|
+
const dirty = (await git(root, ["status", "--porcelain=v2", "-z", "--untracked-files=all", "--ignore-submodules=none"], { command, signal })).stdout;
|
|
301
|
+
const dirtyPaths = parseDirtyPaths(dirty);
|
|
302
|
+
return Object.freeze({
|
|
303
|
+
root,
|
|
304
|
+
commonDir,
|
|
305
|
+
gitDir,
|
|
306
|
+
identity: sourceRepositoryIdentity(commonDir),
|
|
307
|
+
dirtyPaths: Object.freeze(dirtyPaths.slice(0, 64)),
|
|
308
|
+
dirtyPathsTruncated: dirtyPaths.length > 64,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function validateBranchSelection(root, repository, { command, signal }) {
|
|
313
|
+
assertRemoteName(repository.remote_name);
|
|
314
|
+
for (const branch of [repository.base_branch, repository.target_branch]) {
|
|
315
|
+
await git(root, ["check-ref-format", "--branch", branch], { command, signal });
|
|
316
|
+
}
|
|
317
|
+
await git(root, ["remote", "get-url", repository.remote_name], { command, signal });
|
|
318
|
+
await ensureTargetStillAvailable(root, repository, { command, signal });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function ensureTargetStillAvailable(sourceRepositoryPath, repository, { command, signal }) {
|
|
322
|
+
const local = await git(sourceRepositoryPath, ["show-ref", "--verify", "--quiet", `refs/heads/${repository.target_branch}`], {
|
|
323
|
+
command, signal, allowExitCodes: [0, 1],
|
|
324
|
+
});
|
|
325
|
+
if (local.code === 0) throw new Error(`target branch ${repository.target_branch} already exists locally`);
|
|
326
|
+
const remote = await git(sourceRepositoryPath, ["ls-remote", "--exit-code", "--heads", repository.remote_name, `refs/heads/${repository.target_branch}`], {
|
|
327
|
+
command, signal, allowExitCodes: [0, 2],
|
|
328
|
+
});
|
|
329
|
+
if (remote.code === 0) throw new Error(`target branch ${repository.target_branch} already exists on ${repository.remote_name}`);
|
|
330
|
+
const worktrees = (await git(sourceRepositoryPath, ["worktree", "list", "--porcelain"], { command, signal })).stdout;
|
|
331
|
+
if (worktrees.split(/\r?\n/u).some((line) => line === `branch refs/heads/${repository.target_branch}`)) {
|
|
332
|
+
throw new Error(`target branch ${repository.target_branch} is already checked out`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function verifyProvisionedRepository(repository, { command, signal, sourceRepositoryPath = null }) {
|
|
337
|
+
const root = await canonicalDirectory(repository.worktree_path, `worktree ${repository.repository_key}`);
|
|
338
|
+
const targetCommon = resolve(root, (await git(root, ["rev-parse", "--git-common-dir"], { command, signal })).stdout.trim());
|
|
339
|
+
if (sourceRepositoryIdentity(targetCommon) !== repository.source_repository_identity) throw new Error("Task worktree does not belong to the source repository group");
|
|
340
|
+
const targetGit = resolve(root, (await git(root, ["rev-parse", "--absolute-git-dir"], { command, signal })).stdout.trim());
|
|
341
|
+
if (sourceRepositoryPath !== null) {
|
|
342
|
+
if (root === resolve(sourceRepositoryPath)) throw new Error("Task worktree must differ from the source checkout");
|
|
343
|
+
const sourceGit = resolve(sourceRepositoryPath, (await git(sourceRepositoryPath, ["rev-parse", "--absolute-git-dir"], { command, signal })).stdout.trim());
|
|
344
|
+
if (sourceGit === targetGit) throw new Error("Task worktree Git directory is not a new instance");
|
|
345
|
+
}
|
|
346
|
+
const head = (await git(root, ["rev-parse", "HEAD"], { command, signal })).stdout.trim();
|
|
347
|
+
const branch = (await git(root, ["branch", "--show-current"], { command, signal })).stdout.trim();
|
|
348
|
+
const status = (await git(root, ["status", "--porcelain=v2", "--untracked-files=all", "--ignore-submodules=none"], { command, signal })).stdout;
|
|
349
|
+
if (head !== repository.fetched_commit || branch !== repository.target_branch || status !== "") {
|
|
350
|
+
throw new Error(`Task worktree ${repository.repository_key} failed branch, HEAD, or clean-state verification`);
|
|
351
|
+
}
|
|
352
|
+
await access(root, fsConstants.R_OK | fsConstants.W_OK);
|
|
353
|
+
return Object.freeze({ root, head, branch, commonDir: targetCommon, gitDir: targetGit });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function compensateProvisioned(receipt, keys, observed, { command, signal }) {
|
|
357
|
+
for (const key of [...keys].reverse()) {
|
|
358
|
+
const repository = receipt.repositories.find((entry) => entry.repository_key === key);
|
|
359
|
+
const source = observed.find((entry) => entry.repository_key === key)?.source.root;
|
|
360
|
+
if (!repository || !source) continue;
|
|
361
|
+
try {
|
|
362
|
+
await verifyProvisionedRepository(repository, { command, signal, sourceRepositoryPath: source });
|
|
363
|
+
await git(source, ["worktree", "remove", repository.worktree_path], { command, signal, mutating: true });
|
|
364
|
+
} catch {
|
|
365
|
+
// Preserve resources whenever cleanup safety cannot be proven.
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function cleanupState({ dataDirectory, launchID, repositoryKey, taskID, revision, signal, execution, readTask, command }) {
|
|
371
|
+
assertLaunchID(launchID);
|
|
372
|
+
if (typeof readTask !== "function") throw new Error("terminal Core Task reader is unavailable");
|
|
373
|
+
const receipt = await readProvisioningReceipt(dataDirectory, launchID);
|
|
374
|
+
if (receipt === null) throw new Error("provisioning receipt was not found");
|
|
375
|
+
if (!new Set(["consumed", "cleaned"]).has(receipt.operation_status)) throw new Error("provisioning receipt is not eligible for terminal cleanup");
|
|
376
|
+
const repository = receipt.repositories.find((entry) => entry.repository_key === repositoryKey);
|
|
377
|
+
if (!repository) throw new Error("cleanup repository is not owned by the receipt");
|
|
378
|
+
const task = await readTask({ taskID, signal, execution });
|
|
379
|
+
if (task === null || typeof task !== "object" || !new Set(["DONE", "CANCELLED"]).has(task.current_cursor)) {
|
|
380
|
+
throw new Error("Core Task is not terminal");
|
|
381
|
+
}
|
|
382
|
+
if (task.task_id !== taskID || task.revision !== revision) throw new Error("terminal Core Task identity or revision changed");
|
|
383
|
+
const primaryKey = task.primary_repository_key ?? "primary";
|
|
384
|
+
const taskRepository = repositoryKey === primaryKey
|
|
385
|
+
? { origin: task.workspace_origin, binding: task.repository }
|
|
386
|
+
: (task.additional_repositories ?? []).filter((entry) => entry.key === repositoryKey)
|
|
387
|
+
.map((entry) => ({ origin: entry.workspace_origin, binding: entry.repository }))[0];
|
|
388
|
+
if (!taskRepository || taskRepository.origin?.provisioning_receipt_id !== launchID ||
|
|
389
|
+
taskRepository.origin?.canonical_worktree_root !== repository.worktree_path ||
|
|
390
|
+
taskRepository.origin?.task_branch !== repository.target_branch ||
|
|
391
|
+
taskRepository.binding?.current_head === undefined) {
|
|
392
|
+
throw new Error("terminal Core Task does not match the receipt workspace");
|
|
393
|
+
}
|
|
394
|
+
return { receipt, repository, taskRepository: { ...taskRepository.binding, origin: taskRepository.origin }, command };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function inspectTerminalWorktree(repository, taskRepository, { command, signal }) {
|
|
398
|
+
const root = await canonicalDirectory(repository.worktree_path, `worktree ${repository.repository_key}`);
|
|
399
|
+
const commonDir = resolve(root, (await git(root, ["rev-parse", "--git-common-dir"], { command, signal })).stdout.trim());
|
|
400
|
+
if (sourceRepositoryIdentity(commonDir) !== repository.source_repository_identity) throw new Error("worktree no longer belongs to the receipt repository group");
|
|
401
|
+
const head = (await git(root, ["rev-parse", "HEAD"], { command, signal })).stdout.trim();
|
|
402
|
+
const branch = (await git(root, ["branch", "--show-current"], { command, signal })).stdout.trim();
|
|
403
|
+
const status = (await git(root, ["status", "--porcelain=v2", "--untracked-files=all", "--ignore-submodules=none"], { command, signal })).stdout;
|
|
404
|
+
if (head !== taskRepository.current_head || branch !== repository.target_branch) throw new Error("worktree branch or HEAD differs from the terminal Core observation");
|
|
405
|
+
if (status !== "") throw new Error("dirty terminal worktree is retained");
|
|
406
|
+
await assertRemoteHead(root, repository, head, { command, signal });
|
|
407
|
+
return { root, commonDir, head };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function assertRemoteHead(root, repository, head, { command, signal }) {
|
|
411
|
+
const remote = await git(root, ["ls-remote", "--exit-code", "--heads", repository.remote_name, `refs/heads/${repository.target_branch}`], {
|
|
412
|
+
command, signal, allowExitCodes: [0, 2],
|
|
413
|
+
});
|
|
414
|
+
if (remote.code !== 0) throw new Error("unpushed task branch is retained");
|
|
415
|
+
const rows = remote.stdout.trim().split(/\r?\n/u).filter(Boolean);
|
|
416
|
+
if (rows.length !== 1 || rows[0].split(/\s+/u)[0] !== head) throw new Error("remote task branch differs from the terminal HEAD");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function updateCleanupStatus(receipt, repositoryKey, status, now) {
|
|
420
|
+
const repositories = receipt.repositories.map((repository) => repository.repository_key === repositoryKey
|
|
421
|
+
? { ...repository, operation_status: status }
|
|
422
|
+
: repository);
|
|
423
|
+
return validateProvisioningReceipt({
|
|
424
|
+
...receipt,
|
|
425
|
+
operation_status: repositories.every((repository) => repository.operation_status === "branch_removed") ? "cleaned" : "consumed",
|
|
426
|
+
repositories,
|
|
427
|
+
updated_at: now().toISOString(),
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function updateRepositoryStatus(receipt, key, status, now, patch = {}) {
|
|
432
|
+
return validateProvisioningReceipt({
|
|
433
|
+
...receipt,
|
|
434
|
+
operation_status: status === "provisioned" && receipt.repositories.every((entry) => entry.repository_key === key || entry.operation_status === "provisioned") ? "provisioned" : receipt.operation_status,
|
|
435
|
+
repositories: receipt.repositories.map((repository) => repository.repository_key === key
|
|
436
|
+
? { ...repository, ...patch, operation_status: status }
|
|
437
|
+
: repository),
|
|
438
|
+
updated_at: now().toISOString(),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function setLaunchStatus(receipt, status, now, mapRepository) {
|
|
443
|
+
return validateProvisioningReceipt({
|
|
444
|
+
...receipt,
|
|
445
|
+
operation_status: status,
|
|
446
|
+
repositories: receipt.repositories.map(mapRepository),
|
|
447
|
+
updated_at: now().toISOString(),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function validateRepositoryRequests(value) {
|
|
452
|
+
if (!Array.isArray(value) || value.length < 1 || value.length > 8) throw new Error("one to eight confirmed repositories are required");
|
|
453
|
+
const keys = new Set();
|
|
454
|
+
return value.map((entry) => {
|
|
455
|
+
const expected = ["repository_key", "source_repository_path", "remote_name", "base_branch", "target_branch"];
|
|
456
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry) || JSON.stringify(Object.keys(entry).sort()) !== JSON.stringify(expected.sort())) {
|
|
457
|
+
throw new Error("workspace repository fields are invalid");
|
|
458
|
+
}
|
|
459
|
+
if (typeof entry.repository_key !== "string" || !/^[a-z0-9][a-z0-9._-]{0,127}$/u.test(entry.repository_key) || keys.has(entry.repository_key)) {
|
|
460
|
+
throw new Error("workspace repository key is invalid or duplicated");
|
|
461
|
+
}
|
|
462
|
+
if (typeof entry.source_repository_path !== "string" || !isAbsolute(entry.source_repository_path) || entry.source_repository_path.includes("\0")) {
|
|
463
|
+
throw new Error(`repository ${entry.repository_key} source path is invalid`);
|
|
464
|
+
}
|
|
465
|
+
assertRemoteName(entry.remote_name);
|
|
466
|
+
for (const field of ["base_branch", "target_branch"]) {
|
|
467
|
+
if (typeof entry[field] !== "string" || entry[field] === "" || entry[field].length > 255 || /[\0\r\n;=]/u.test(entry[field])) {
|
|
468
|
+
throw new Error(`repository ${entry.repository_key} ${field} is invalid`);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
keys.add(entry.repository_key);
|
|
472
|
+
return Object.freeze({ ...entry });
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function assertRemoteName(value) {
|
|
477
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value)) throw new Error("remote name is invalid");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function assertProfile(value) {
|
|
481
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(value)) throw new Error("DSH Profile is invalid");
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function assertLaunchID(value) {
|
|
485
|
+
if (typeof value !== "string" || !/^[0-9a-f-]{36}$/u.test(value)) throw new Error("provisioning launch identity is invalid");
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
async function canonicalDirectory(path, label) {
|
|
489
|
+
const canonical = await realpath(path).catch((error) => { throw new Error(`${label} is unavailable`, { cause: error }); });
|
|
490
|
+
if (canonical !== resolve(path) || !(await stat(canonical)).isDirectory()) throw new Error(`${label} must be a canonical non-symlink directory`);
|
|
491
|
+
return canonical;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
async function assertPathAbsent(path, label) {
|
|
495
|
+
try {
|
|
496
|
+
await lstat(path);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
if (error?.code === "ENOENT") return;
|
|
499
|
+
throw error;
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`${label} already exists`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function assertNoSymlinkComponents(root, candidate) {
|
|
505
|
+
const canonicalRoot = resolve(root);
|
|
506
|
+
const offset = relative(canonicalRoot, resolve(candidate));
|
|
507
|
+
if (offset === ".." || offset.startsWith(`..${sep}`) || isAbsolute(offset)) throw new Error("worktree launch path escapes its parent");
|
|
508
|
+
let current = canonicalRoot;
|
|
509
|
+
for (const part of offset.split(sep).filter(Boolean)) {
|
|
510
|
+
current = join(current, part);
|
|
511
|
+
try {
|
|
512
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error(`worktree launch path contains a symbolic link: ${current}`);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
if (error?.code === "ENOENT") return;
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function inside(root, candidate) {
|
|
521
|
+
const offset = relative(resolve(root), resolve(candidate));
|
|
522
|
+
return offset === "" || !(offset === ".." || offset.startsWith(`..${sep}`) || isAbsolute(offset));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function parseDirtyPaths(status) {
|
|
526
|
+
const paths = [];
|
|
527
|
+
const records = status.split("\0");
|
|
528
|
+
for (let index = 0; index < records.length; index += 1) {
|
|
529
|
+
const record = records[index];
|
|
530
|
+
if (!record) continue;
|
|
531
|
+
if (record.startsWith("? ") || record.startsWith("! ")) {
|
|
532
|
+
paths.push(record.slice(2));
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (record.startsWith("1 ") || record.startsWith("u ")) {
|
|
536
|
+
paths.push(record.split(" ").slice(record.startsWith("1 ") ? 8 : 10).join(" "));
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
if (record.startsWith("2 ")) {
|
|
540
|
+
paths.push(record.split(" ").slice(9).join(" "));
|
|
541
|
+
if (records[index + 1]) paths.push(records[index + 1]);
|
|
542
|
+
index += 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return [...new Set(paths)].sort();
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function sha256(value) {
|
|
549
|
+
return createHash("sha256").update(value).digest("hex");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function sourceRepositoryIdentity(commonDirectory) {
|
|
553
|
+
return sha256(`dev-flow/source-repository\0${commonDirectory}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function git(cwd, arguments_, options) {
|
|
557
|
+
return await options.command("git", arguments_, {
|
|
558
|
+
cwd,
|
|
559
|
+
signal: options.signal,
|
|
560
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
561
|
+
maxOutputBytes: MAX_COMMAND_OUTPUT,
|
|
562
|
+
allowExitCodes: options.allowExitCodes ?? [0],
|
|
563
|
+
mutating: options.mutating === true,
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function runClosedCommand(executable, arguments_, {
|
|
568
|
+
cwd,
|
|
569
|
+
signal,
|
|
570
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
571
|
+
maxOutputBytes = MAX_COMMAND_OUTPUT,
|
|
572
|
+
allowExitCodes = [0],
|
|
573
|
+
mutating = false,
|
|
574
|
+
} = {}) {
|
|
575
|
+
if (typeof executable !== "string" || executable === "" || executable.includes("\0") || !Array.isArray(arguments_) || arguments_.some((value) => typeof value !== "string" || value.includes("\0"))) {
|
|
576
|
+
throw new Error("command arguments must be closed strings");
|
|
577
|
+
}
|
|
578
|
+
if (signal?.aborted) throw signal.reason ?? new Error("command aborted");
|
|
579
|
+
return await new Promise((resolvePromise, reject) => {
|
|
580
|
+
const child = spawn(executable, arguments_, {
|
|
581
|
+
cwd,
|
|
582
|
+
env: process.env,
|
|
583
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
584
|
+
shell: false,
|
|
585
|
+
windowsHide: true,
|
|
586
|
+
});
|
|
587
|
+
const stdout = [];
|
|
588
|
+
const stderr = [];
|
|
589
|
+
let bytes = 0;
|
|
590
|
+
let timedOut = false;
|
|
591
|
+
const collect = (target) => (chunk) => {
|
|
592
|
+
bytes += chunk.length;
|
|
593
|
+
if (bytes > maxOutputBytes) {
|
|
594
|
+
timedOut = true;
|
|
595
|
+
child.kill("SIGKILL");
|
|
596
|
+
} else target.push(Buffer.from(chunk));
|
|
597
|
+
};
|
|
598
|
+
child.stdout.on("data", collect(stdout));
|
|
599
|
+
child.stderr.on("data", collect(stderr));
|
|
600
|
+
const abort = () => child.kill("SIGKILL");
|
|
601
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
602
|
+
const timer = setTimeout(() => {
|
|
603
|
+
timedOut = true;
|
|
604
|
+
child.kill("SIGKILL");
|
|
605
|
+
}, timeoutMs);
|
|
606
|
+
child.once("error", (error) => {
|
|
607
|
+
clearTimeout(timer);
|
|
608
|
+
signal?.removeEventListener("abort", abort);
|
|
609
|
+
reject(error);
|
|
610
|
+
});
|
|
611
|
+
child.once("exit", (code, exitSignal) => {
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
signal?.removeEventListener("abort", abort);
|
|
614
|
+
const result = { code: code ?? -1, signal: exitSignal, stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8") };
|
|
615
|
+
if (!timedOut && !signal?.aborted && exitSignal === null && allowExitCodes.includes(result.code)) {
|
|
616
|
+
resolvePromise(result);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const error = new Error(`${executable} command failed`);
|
|
620
|
+
error.exitCode = result.code;
|
|
621
|
+
error.operationUncertain = mutating && (timedOut || signal?.aborted || exitSignal !== null);
|
|
622
|
+
reject(error);
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
}
|