agent-hitch 0.2.0 → 0.2.2

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.
Files changed (265) hide show
  1. package/README.md +88 -250
  2. package/README.zh-CN.md +84 -230
  3. package/dist/bin/hitch.js +1 -1
  4. package/dist/bin/hitch.js.map +1 -1
  5. package/dist/scripts/check-architecture.js +159 -0
  6. package/dist/scripts/check-architecture.js.map +1 -0
  7. package/dist/scripts/check-release.js +1 -1
  8. package/dist/scripts/check-release.js.map +1 -1
  9. package/dist/src/adapters/catalog.js +55 -0
  10. package/dist/src/adapters/catalog.js.map +1 -0
  11. package/dist/src/adapters/contract.js +2 -0
  12. package/dist/src/adapters/contract.js.map +1 -0
  13. package/dist/src/adapters/discovery.js +22 -0
  14. package/dist/src/adapters/discovery.js.map +1 -0
  15. package/dist/src/adapters/index.js +3 -0
  16. package/dist/src/adapters/index.js.map +1 -0
  17. package/dist/src/adapters/providers/claude.js +69 -0
  18. package/dist/src/adapters/providers/claude.js.map +1 -0
  19. package/dist/src/adapters/providers/codex.js +74 -0
  20. package/dist/src/adapters/providers/codex.js.map +1 -0
  21. package/dist/src/adapters/providers/deepseek.js +51 -0
  22. package/dist/src/adapters/providers/deepseek.js.map +1 -0
  23. package/dist/src/adapters/providers/opencode.js +73 -0
  24. package/dist/src/adapters/providers/opencode.js.map +1 -0
  25. package/dist/src/adapters/providers/pi.js +82 -0
  26. package/dist/src/adapters/providers/pi.js.map +1 -0
  27. package/dist/src/adapters/providers/shared.js +82 -0
  28. package/dist/src/adapters/providers/shared.js.map +1 -0
  29. package/dist/src/artifacts/handoff.js +12 -0
  30. package/dist/src/artifacts/handoff.js.map +1 -0
  31. package/dist/src/artifacts/index.js +5 -0
  32. package/dist/src/artifacts/index.js.map +1 -0
  33. package/dist/src/artifacts/integrity.js +188 -0
  34. package/dist/src/artifacts/integrity.js.map +1 -0
  35. package/dist/src/artifacts/preparer.js +379 -0
  36. package/dist/src/artifacts/preparer.js.map +1 -0
  37. package/dist/src/artifacts/store.js +157 -0
  38. package/dist/src/artifacts/store.js.map +1 -0
  39. package/dist/src/artifacts/types.js +2 -0
  40. package/dist/src/artifacts/types.js.map +1 -0
  41. package/dist/src/backends/contract.js +2 -0
  42. package/dist/src/backends/contract.js.map +1 -0
  43. package/dist/src/{harbor-backend.js → backends/harbor/backend.js} +52 -14
  44. package/dist/src/backends/harbor/backend.js.map +1 -0
  45. package/dist/src/backends/harbor/index.js +4 -0
  46. package/dist/src/backends/harbor/index.js.map +1 -0
  47. package/dist/src/backends/harbor/local-git-transport.js +447 -0
  48. package/dist/src/backends/harbor/local-git-transport.js.map +1 -0
  49. package/dist/src/{eval-tools.js → backends/harbor/tools.js} +2 -6
  50. package/dist/src/backends/harbor/tools.js.map +1 -0
  51. package/dist/src/backends/index.js +4 -0
  52. package/dist/src/backends/index.js.map +1 -0
  53. package/dist/src/cli/arguments.js +123 -0
  54. package/dist/src/cli/arguments.js.map +1 -0
  55. package/dist/src/cli/commands/compare.js +55 -0
  56. package/dist/src/cli/commands/compare.js.map +1 -0
  57. package/dist/src/cli/commands/daemon.js +106 -0
  58. package/dist/src/cli/commands/daemon.js.map +1 -0
  59. package/dist/src/cli/commands/eval.js +126 -0
  60. package/dist/src/cli/commands/eval.js.map +1 -0
  61. package/dist/src/cli/commands/feedback.js +84 -0
  62. package/dist/src/cli/commands/feedback.js.map +1 -0
  63. package/dist/src/cli/commands/inspect.js +20 -0
  64. package/dist/src/cli/commands/inspect.js.map +1 -0
  65. package/dist/src/cli/commands/list.js +17 -0
  66. package/dist/src/cli/commands/list.js.map +1 -0
  67. package/dist/src/cli/commands/prepare.js +21 -0
  68. package/dist/src/cli/commands/prepare.js.map +1 -0
  69. package/dist/src/cli/commands/resolve.js +17 -0
  70. package/dist/src/cli/commands/resolve.js.map +1 -0
  71. package/dist/src/cli/commands/run.js +145 -0
  72. package/dist/src/cli/commands/run.js.map +1 -0
  73. package/dist/src/cli/commands/runs.js +45 -0
  74. package/dist/src/cli/commands/runs.js.map +1 -0
  75. package/dist/src/cli/commands/trajectory.js +31 -0
  76. package/dist/src/cli/commands/trajectory.js.map +1 -0
  77. package/dist/src/cli/commands/workspace.js +44 -0
  78. package/dist/src/cli/commands/workspace.js.map +1 -0
  79. package/dist/src/cli/index.js +2 -0
  80. package/dist/src/cli/index.js.map +1 -0
  81. package/dist/src/cli/main.js +48 -0
  82. package/dist/src/cli/main.js.map +1 -0
  83. package/dist/src/cli/output.js +96 -0
  84. package/dist/src/cli/output.js.map +1 -0
  85. package/dist/src/controller-runtime/index.js +3 -0
  86. package/dist/src/controller-runtime/index.js.map +1 -0
  87. package/dist/src/controller-runtime/store.js +2 -7
  88. package/dist/src/controller-runtime/store.js.map +1 -1
  89. package/dist/src/daemon/auth.js +108 -0
  90. package/dist/src/daemon/auth.js.map +1 -0
  91. package/dist/src/daemon/client.js +76 -0
  92. package/dist/src/daemon/client.js.map +1 -0
  93. package/dist/src/daemon/index.js +5 -0
  94. package/dist/src/daemon/index.js.map +1 -0
  95. package/dist/src/daemon/launcher.js +24 -0
  96. package/dist/src/daemon/launcher.js.map +1 -0
  97. package/dist/src/{scheduler.js → daemon/scheduler.js} +6 -7
  98. package/dist/src/daemon/scheduler.js.map +1 -0
  99. package/dist/src/{daemon.js → daemon/server.js} +10 -155
  100. package/dist/src/daemon/server.js.map +1 -0
  101. package/dist/src/domain/artifacts.js +2 -0
  102. package/dist/src/domain/artifacts.js.map +1 -0
  103. package/dist/src/domain/controller-runtime.js +2 -0
  104. package/dist/src/domain/controller-runtime.js.map +1 -0
  105. package/dist/src/domain/eval-records.js +2 -0
  106. package/dist/src/domain/eval-records.js.map +1 -0
  107. package/dist/src/domain/evals.js +2 -0
  108. package/dist/src/domain/evals.js.map +1 -0
  109. package/dist/src/domain/feedback.js +2 -0
  110. package/dist/src/domain/feedback.js.map +1 -0
  111. package/dist/src/domain/{types.js → ids.js} +1 -1
  112. package/dist/src/domain/ids.js.map +1 -0
  113. package/dist/src/domain/index.js +11 -0
  114. package/dist/src/domain/index.js.map +1 -0
  115. package/dist/src/domain/revisions.js +2 -0
  116. package/dist/src/domain/revisions.js.map +1 -0
  117. package/dist/src/domain/runs.js +3 -0
  118. package/dist/src/domain/runs.js.map +1 -0
  119. package/dist/src/domain/trajectories.js +2 -0
  120. package/dist/src/domain/trajectories.js.map +1 -0
  121. package/dist/src/domain/{validate.js → validation.js} +194 -5
  122. package/dist/src/domain/validation.js.map +1 -0
  123. package/dist/src/domain/workspaces.js +2 -0
  124. package/dist/src/domain/workspaces.js.map +1 -0
  125. package/dist/src/evals/events.js +67 -0
  126. package/dist/src/evals/events.js.map +1 -0
  127. package/dist/src/evals/index.js +5 -0
  128. package/dist/src/evals/index.js.map +1 -0
  129. package/dist/src/evals/records.js +55 -0
  130. package/dist/src/evals/records.js.map +1 -0
  131. package/dist/src/evals/request.js +109 -0
  132. package/dist/src/evals/request.js.map +1 -0
  133. package/dist/src/evals/service.js +280 -0
  134. package/dist/src/evals/service.js.map +1 -0
  135. package/dist/src/evals/trial-import.js +394 -0
  136. package/dist/src/evals/trial-import.js.map +1 -0
  137. package/dist/src/feedback/index.js +2 -0
  138. package/dist/src/feedback/index.js.map +1 -0
  139. package/dist/src/feedback/service.js +3 -4
  140. package/dist/src/feedback/service.js.map +1 -1
  141. package/dist/src/{config.js → foundation/config.js} +1 -0
  142. package/dist/src/foundation/config.js.map +1 -0
  143. package/dist/src/foundation/errors.js.map +1 -0
  144. package/dist/src/foundation/executable.js +135 -0
  145. package/dist/src/foundation/executable.js.map +1 -0
  146. package/dist/src/foundation/fs.js.map +1 -0
  147. package/dist/src/foundation/hash.js +24 -0
  148. package/dist/src/foundation/hash.js.map +1 -0
  149. package/dist/src/foundation/index.js +11 -0
  150. package/dist/src/foundation/index.js.map +1 -0
  151. package/dist/src/foundation/line-stream.js.map +1 -0
  152. package/dist/src/foundation/locks.js +114 -0
  153. package/dist/src/foundation/locks.js.map +1 -0
  154. package/dist/src/{package-root.js → foundation/package-root.js} +3 -2
  155. package/dist/src/foundation/package-root.js.map +1 -0
  156. package/dist/src/foundation/process.js.map +1 -0
  157. package/dist/src/revisions/index.js +4 -0
  158. package/dist/src/revisions/index.js.map +1 -0
  159. package/dist/src/{harness-reference.js → revisions/reference.js} +16 -2
  160. package/dist/src/revisions/reference.js.map +1 -0
  161. package/dist/src/revisions/resolver.js +25 -0
  162. package/dist/src/revisions/resolver.js.map +1 -0
  163. package/dist/src/revisions/sources/git.js +180 -0
  164. package/dist/src/revisions/sources/git.js.map +1 -0
  165. package/dist/src/revisions/sources/installed.js +45 -0
  166. package/dist/src/revisions/sources/installed.js.map +1 -0
  167. package/dist/src/revisions/sources/npm.js +97 -0
  168. package/dist/src/revisions/sources/npm.js.map +1 -0
  169. package/dist/src/runs/compare.js +132 -0
  170. package/dist/src/runs/compare.js.map +1 -0
  171. package/dist/src/{events.js → runs/events.js} +1 -2
  172. package/dist/src/runs/events.js.map +1 -0
  173. package/dist/src/{engine.js → runs/executor.js} +172 -151
  174. package/dist/src/runs/executor.js.map +1 -0
  175. package/dist/src/runs/finalizer.js +22 -0
  176. package/dist/src/runs/finalizer.js.map +1 -0
  177. package/dist/src/runs/identity.js +35 -0
  178. package/dist/src/runs/identity.js.map +1 -0
  179. package/dist/src/runs/index.js +11 -0
  180. package/dist/src/runs/index.js.map +1 -0
  181. package/dist/src/runs/manifest.js +70 -0
  182. package/dist/src/runs/manifest.js.map +1 -0
  183. package/dist/src/runs/outcome.js +49 -0
  184. package/dist/src/runs/outcome.js.map +1 -0
  185. package/dist/src/runs/query.js +104 -0
  186. package/dist/src/runs/query.js.map +1 -0
  187. package/dist/src/runs/queued.js +54 -0
  188. package/dist/src/runs/queued.js.map +1 -0
  189. package/dist/src/runs/records.js +288 -0
  190. package/dist/src/runs/records.js.map +1 -0
  191. package/dist/src/runs/request.js +169 -0
  192. package/dist/src/runs/request.js.map +1 -0
  193. package/dist/src/trajectories/format.js +1 -1
  194. package/dist/src/trajectories/format.js.map +1 -1
  195. package/dist/src/trajectories/index.js +7 -0
  196. package/dist/src/trajectories/index.js.map +1 -0
  197. package/dist/src/trajectories/projector.js +1 -1
  198. package/dist/src/trajectories/projector.js.map +1 -1
  199. package/dist/src/trajectories/provider-capture.js +133 -0
  200. package/dist/src/trajectories/provider-capture.js.map +1 -0
  201. package/dist/src/trajectories/providers/deepseek.js +179 -0
  202. package/dist/src/trajectories/providers/deepseek.js.map +1 -0
  203. package/dist/src/trajectories/store.js +148 -8
  204. package/dist/src/trajectories/store.js.map +1 -1
  205. package/dist/src/workspaces/copy.js +142 -0
  206. package/dist/src/workspaces/copy.js.map +1 -0
  207. package/dist/src/workspaces/digest.js +47 -0
  208. package/dist/src/workspaces/digest.js.map +1 -0
  209. package/dist/src/workspaces/git.js +164 -0
  210. package/dist/src/workspaces/git.js.map +1 -0
  211. package/dist/src/workspaces/index.js +6 -0
  212. package/dist/src/workspaces/index.js.map +1 -0
  213. package/dist/src/workspaces/lifecycle.js +313 -0
  214. package/dist/src/workspaces/lifecycle.js.map +1 -0
  215. package/dist/src/workspaces/planner.js +80 -0
  216. package/dist/src/workspaces/planner.js.map +1 -0
  217. package/dist/src/workspaces/store.js +25 -0
  218. package/dist/src/workspaces/store.js.map +1 -0
  219. package/dist/src/workspaces/types.js +4 -0
  220. package/dist/src/workspaces/types.js.map +1 -0
  221. package/dist/src/workspaces/utils.js +56 -0
  222. package/dist/src/workspaces/utils.js.map +1 -0
  223. package/docs/schemas/eval-request.schema.json +2 -0
  224. package/docs/schemas/eval-result.schema.json +50 -1
  225. package/docs/schemas/local-git-transport-manifest.schema.json +34 -0
  226. package/docs/schemas/result.schema.json +9 -0
  227. package/docs/schemas/run-context.schema.json +39 -0
  228. package/docs/schemas/run-manifest.schema.json +81 -0
  229. package/docs/schemas/run-request.schema.json +16 -1
  230. package/docs/schemas/trajectory-ref.schema.json +66 -23
  231. package/integrations/harbor/hitch_harbor_agent.py +482 -10
  232. package/package.json +21 -3
  233. package/dist/src/adapters.js +0 -461
  234. package/dist/src/adapters.js.map +0 -1
  235. package/dist/src/artifacts.js +0 -1081
  236. package/dist/src/artifacts.js.map +0 -1
  237. package/dist/src/cli.js +0 -699
  238. package/dist/src/cli.js.map +0 -1
  239. package/dist/src/config.js.map +0 -1
  240. package/dist/src/daemon.js.map +0 -1
  241. package/dist/src/domain/types.js.map +0 -1
  242. package/dist/src/domain/validate.js.map +0 -1
  243. package/dist/src/engine.js.map +0 -1
  244. package/dist/src/errors.js.map +0 -1
  245. package/dist/src/eval-tools.js.map +0 -1
  246. package/dist/src/evals.js +0 -313
  247. package/dist/src/evals.js.map +0 -1
  248. package/dist/src/events.js.map +0 -1
  249. package/dist/src/fs.js.map +0 -1
  250. package/dist/src/harbor-backend.js.map +0 -1
  251. package/dist/src/harness-reference.js.map +0 -1
  252. package/dist/src/line-stream.js.map +0 -1
  253. package/dist/src/locks.js +0 -38
  254. package/dist/src/locks.js.map +0 -1
  255. package/dist/src/package-root.js.map +0 -1
  256. package/dist/src/process.js.map +0 -1
  257. package/dist/src/registry.js +0 -81
  258. package/dist/src/registry.js.map +0 -1
  259. package/dist/src/scheduler.js.map +0 -1
  260. package/dist/src/workspaces.js +0 -858
  261. package/dist/src/workspaces.js.map +0 -1
  262. /package/dist/src/{errors.js → foundation/errors.js} +0 -0
  263. /package/dist/src/{fs.js → foundation/fs.js} +0 -0
  264. /package/dist/src/{line-stream.js → foundation/line-stream.js} +0 -0
  265. /package/dist/src/{process.js → foundation/process.js} +0 -0
@@ -1,858 +0,0 @@
1
- import { createHash, randomBytes } from "node:crypto";
2
- import { createReadStream, createWriteStream } from "node:fs";
3
- import { chmod, lstat, mkdir, open, readFile, readdir, readlink, realpath, rm, stat, symlink, utimes, } from "node:fs/promises";
4
- import path from "node:path";
5
- import { spawn } from "node:child_process";
6
- import { pipeline } from "node:stream/promises";
7
- import { statePaths, SCHEMA_VERSION } from "./config.js";
8
- import { HitchError, invalidInput } from "./errors.js";
9
- import { atomicWriteJSON, ensureDir, readJSON, removeIfExists } from "./fs.js";
10
- import { delay, terminateProcess } from "./process.js";
11
- import { reclaimStaleLock } from "./locks.js";
12
- export const WORKSPACE_MODES = new Set(["shared", "worktree", "copy"]);
13
- const COMMAND_TIMEOUT_MS = 120_000;
14
- const ACTIVE_WORKSPACE_STATES = new Set(["planned", "provisioning", "ready", "running"]);
15
- export async function planWorkspace({ runId, sourceCwd, mode = "shared", root, recordPath }) {
16
- assertRunId(runId);
17
- if (!WORKSPACE_MODES.has(mode)) {
18
- throw invalidInput(`workspace_mode must be one of: ${[...WORKSPACE_MODES].join(", ")}`);
19
- }
20
- let sourceRealPath;
21
- let sourceInfo;
22
- try {
23
- sourceRealPath = await realpath(sourceCwd);
24
- sourceInfo = await stat(sourceRealPath);
25
- }
26
- catch (error) {
27
- throw invalidInput(`workspace does not exist: ${sourceCwd}`, { cause: error });
28
- }
29
- if (!sourceInfo.isDirectory())
30
- throw invalidInput(`workspace is not a directory: ${sourceCwd}`);
31
- const paths = statePaths(root);
32
- const managedDirectory = path.join(await canonicalizePotentialPath(paths.workspaces), runId);
33
- let git = null;
34
- let sourceBase = sourceRealPath;
35
- let sourceSubdirectory = ".";
36
- if (mode !== "shared") {
37
- git = await inspectGitWorkspace(sourceRealPath, { required: mode === "worktree" });
38
- if (git) {
39
- sourceBase = git.repository_root;
40
- sourceSubdirectory = path.relative(sourceBase, sourceRealPath) || ".";
41
- }
42
- const stateRoot = await canonicalizePotentialPath(paths.root);
43
- if (isWithin(sourceBase, stateRoot)) {
44
- throw new HitchError("the Hitch state root is inside the source workspace; use a --root outside the source repository", { code: "workspace_root_overlap", exitCode: 2 });
45
- }
46
- assertNoWorkspaceOverlap(sourceBase, managedDirectory);
47
- }
48
- if (mode === "worktree") {
49
- if (git?.status) {
50
- throw new HitchError("worktree isolation requires a clean Git workspace; use workspace_mode=copy to preserve local changes", { code: "workspace_dirty", exitCode: 2 });
51
- }
52
- }
53
- const executionRoot = mode === "shared" ? sourceRealPath : path.join(managedDirectory, "root");
54
- const executionWorkspace = sourceSubdirectory === "."
55
- ? executionRoot
56
- : path.join(executionRoot, sourceSubdirectory);
57
- const snapshot = mode === "worktree"
58
- ? { consistency: "git_commit", ...(git?.head ? { commit: git.head } : {}), source_subdirectory: sourceSubdirectory }
59
- : mode === "copy"
60
- ? {
61
- consistency: "best_effort",
62
- ...(git?.head ? { commit: git.head } : {}),
63
- source_subdirectory: sourceSubdirectory,
64
- }
65
- : { consistency: "none", source_subdirectory: "." };
66
- const plan = {
67
- schema_version: SCHEMA_VERSION,
68
- run_id: runId,
69
- state_root: path.resolve(root),
70
- mode: mode,
71
- status: "planned",
72
- source_workspace: path.resolve(sourceCwd),
73
- source_realpath: sourceRealPath,
74
- source_base: sourceBase,
75
- source_subdirectory: sourceSubdirectory,
76
- execution_root: executionRoot,
77
- execution_workspace: executionWorkspace,
78
- managed_directory: mode === "shared" ? null : managedDirectory,
79
- snapshot,
80
- git,
81
- retained: false,
82
- planned_at: new Date().toISOString(),
83
- };
84
- if (recordPath)
85
- await atomicWriteJSON(recordPath, plan);
86
- return plan;
87
- }
88
- export async function prepareWorkspace(plan, { recordPath, signal } = {}) {
89
- if (plan.mode === "shared") {
90
- const ready = {
91
- ...plan,
92
- status: "ready",
93
- prepared_at: new Date().toISOString(),
94
- };
95
- if (recordPath)
96
- await atomicWriteJSON(recordPath, ready);
97
- return ready;
98
- }
99
- const provisioning = { ...plan, status: "provisioning", provisioning_at: new Date().toISOString() };
100
- if (recordPath)
101
- await atomicWriteJSON(recordPath, provisioning);
102
- let ownsManagedDirectory = false;
103
- try {
104
- throwIfAborted(signal);
105
- await assertManagedPlan(plan);
106
- await mkdir(path.dirname(plan.managed_directory), { recursive: true, mode: 0o700 });
107
- await chmod(path.dirname(plan.managed_directory), 0o700);
108
- if (await pathExists(plan.managed_directory)) {
109
- throw new HitchError(`managed workspace already exists for ${plan.run_id}`, {
110
- code: "workspace_already_exists",
111
- exitCode: 11,
112
- });
113
- }
114
- await mkdir(plan.managed_directory, { mode: 0o700 });
115
- ownsManagedDirectory = true;
116
- const preparation = plan.mode === "worktree"
117
- ? await prepareWorktree(plan, { signal })
118
- : await prepareCopy(plan, { signal });
119
- await chmod(plan.managed_directory, 0o700);
120
- await chmod(plan.execution_root, 0o700);
121
- const baselineDigest = preparation?.baseline_digest || await workspaceDigest(plan.execution_root, {
122
- signal,
123
- excludedTopLevel: gitMetadataExclusion(plan),
124
- });
125
- const baselineGitMetadataDigest = plan.mode === "copy" && plan.git
126
- ? await isolatedGitMetadataDigest(plan.execution_root, { signal })
127
- : null;
128
- const preparedAt = new Date().toISOString();
129
- const ready = {
130
- ...provisioning,
131
- status: "ready",
132
- baseline_digest: baselineDigest,
133
- baseline_git_metadata_digest: baselineGitMetadataDigest,
134
- snapshot: { ...plan.snapshot, content_digest: baselineDigest, captured_at: preparedAt },
135
- retained: true,
136
- prepared_at: preparedAt,
137
- };
138
- if (recordPath)
139
- await atomicWriteJSON(recordPath, ready);
140
- return ready;
141
- }
142
- catch (error) {
143
- if (ownsManagedDirectory)
144
- await cleanupProvisioning(plan).catch(() => { });
145
- const errno = error;
146
- const workspaceError = errno?.code === "EEXIST"
147
- ? new HitchError(`managed workspace already exists for ${plan.run_id}`, {
148
- code: "workspace_already_exists",
149
- exitCode: 11,
150
- cause: error,
151
- })
152
- : error;
153
- const hitchWorkspaceError = workspaceError;
154
- const failed = {
155
- ...provisioning,
156
- status: workspaceError instanceof Error && workspaceError.name === "HitchError" && hitchWorkspaceError.code === "cancelled" ? "cancelled" : "failed",
157
- retained: false,
158
- error: { code: hitchWorkspaceError.code || "workspace_provision_failed", message: workspaceError?.message || String(workspaceError) },
159
- completed_at: new Date().toISOString(),
160
- };
161
- if (recordPath)
162
- await atomicWriteJSON(recordPath, failed);
163
- if (workspaceError instanceof HitchError)
164
- throw workspaceError;
165
- throw new HitchError(`failed to provision isolated workspace: ${workspaceError.message}`, {
166
- code: "workspace_provision_failed",
167
- exitCode: 5,
168
- cause: workspaceError,
169
- });
170
- }
171
- }
172
- export async function markWorkspaceRunning(workspace, { recordPath } = {}) {
173
- const running = { ...workspace, status: "running", started_at: new Date().toISOString() };
174
- if (recordPath)
175
- await atomicWriteJSON(recordPath, running);
176
- return running;
177
- }
178
- export async function finalizeWorkspace(workspace, { recordPath } = {}) {
179
- if (!workspace)
180
- return null;
181
- if (workspace.mode === "shared") {
182
- const released = {
183
- ...workspace,
184
- status: "released",
185
- retained: false,
186
- changed: null,
187
- finalized_at: new Date().toISOString(),
188
- };
189
- if (recordPath)
190
- await atomicWriteJSON(recordPath, released);
191
- return released;
192
- }
193
- const finalDigest = await workspaceDigest(workspace.execution_root, {
194
- excludedTopLevel: gitMetadataExclusion(workspace),
195
- });
196
- let headAfter = null;
197
- let gitStatus = null;
198
- let finalGitMetadataDigest = null;
199
- if (workspace.git) {
200
- headAfter = await tryGitOutput(["-C", workspace.execution_root, "rev-parse", "--verify", "HEAD^{commit}"]);
201
- gitStatus = await tryGitOutput(["-C", workspace.execution_root, "status", "--porcelain=v1", "--untracked-files=all"]);
202
- if (workspace.mode === "copy")
203
- finalGitMetadataDigest = await isolatedGitMetadataDigest(workspace.execution_root);
204
- }
205
- const gitStateChanged = workspace.git
206
- ? gitStatus === null
207
- || (workspace.git.head ? !headAfter || headAfter !== workspace.git.head : Boolean(headAfter))
208
- || (workspace.mode === "copy" && finalGitMetadataDigest !== workspace.baseline_git_metadata_digest)
209
- : false;
210
- const retained = {
211
- ...workspace,
212
- status: "retained",
213
- retained: true,
214
- changed: finalDigest !== workspace.baseline_digest || gitStateChanged,
215
- final_digest: finalDigest,
216
- ...(finalGitMetadataDigest ? { final_git_metadata_digest: finalGitMetadataDigest } : {}),
217
- ...(headAfter ? { head_after: headAfter } : {}),
218
- ...(gitStatus !== null ? { git_dirty: Boolean(gitStatus) } : {}),
219
- finalized_at: new Date().toISOString(),
220
- };
221
- if (recordPath)
222
- await atomicWriteJSON(recordPath, retained);
223
- return retained;
224
- }
225
- export async function markWorkspaceFinalizationFailed(workspace, { recordPath, warning } = {}) {
226
- if (!workspace)
227
- return null;
228
- const terminal = {
229
- ...workspace,
230
- status: workspace.mode === "shared" ? "released" : "orphaned",
231
- retained: workspace.mode !== "shared",
232
- changed: null,
233
- warning: warning || {
234
- code: "workspace_finalization_failed",
235
- message: "workspace finalization failed",
236
- },
237
- finalized_at: new Date().toISOString(),
238
- };
239
- if (recordPath)
240
- await atomicWriteJSON(recordPath, terminal);
241
- return terminal;
242
- }
243
- export async function cancelPlannedWorkspace({ root, runId }) {
244
- return abandonPlannedWorkspace({ root, runId, status: "cancelled" });
245
- }
246
- export async function abandonPlannedWorkspace({ root, runId, status = "unused" }) {
247
- const recordPath = workspaceRecordPath(root, runId);
248
- const workspace = await readJSON(recordPath, null);
249
- if (!workspace || workspace.status !== "planned")
250
- return workspace;
251
- const completed = { ...workspace, status, completed_at: new Date().toISOString() };
252
- await atomicWriteJSON(recordPath, completed);
253
- return completed;
254
- }
255
- export async function recoverInterruptedWorkspace({ root, runId }) {
256
- const recordPath = workspaceRecordPath(root, runId);
257
- const workspace = await readJSON(recordPath, null);
258
- if (!workspace)
259
- return null;
260
- if (workspace.status === "provisioning") {
261
- await cleanupProvisioning(workspace).catch(() => { });
262
- const failed = {
263
- ...workspace,
264
- status: "failed",
265
- retained: false,
266
- error: { code: "daemon_restarted", message: "daemon stopped while provisioning the workspace" },
267
- completed_at: new Date().toISOString(),
268
- };
269
- await atomicWriteJSON(recordPath, failed);
270
- return failed;
271
- }
272
- if (["ready", "running"].includes(workspace.status)) {
273
- const orphaned = {
274
- ...workspace,
275
- status: workspace.mode === "shared" ? "released" : "orphaned",
276
- retained: workspace.mode !== "shared",
277
- completed_at: new Date().toISOString(),
278
- };
279
- await atomicWriteJSON(recordPath, orphaned);
280
- return orphaned;
281
- }
282
- if (workspace.status === "planned")
283
- return cancelPlannedWorkspace({ root, runId });
284
- return workspace;
285
- }
286
- export async function inspectWorkspace({ root, runId }) {
287
- assertRunId(runId);
288
- return readJSON(workspaceRecordPath(root, runId), null);
289
- }
290
- export async function removeWorkspace({ root, runId, force = false }) {
291
- assertRunId(runId);
292
- const recordPath = workspaceRecordPath(root, runId);
293
- const workspace = await readJSON(recordPath, null);
294
- if (!workspace) {
295
- throw new HitchError(`workspace record not found: ${runId}`, { code: "workspace_not_found", exitCode: 3 });
296
- }
297
- if (workspace.mode === "shared") {
298
- throw invalidInput(`run ${runId} used a shared workspace that Hitch does not own`);
299
- }
300
- if (ACTIVE_WORKSPACE_STATES.has(workspace.status)) {
301
- throw new HitchError(`workspace is still in use by ${runId}`, { code: "workspace_in_use", exitCode: 11 });
302
- }
303
- if (!force && workspace.changed !== false) {
304
- throw new HitchError("workspace may contain changes; pass --force to remove it", {
305
- code: "workspace_has_changes",
306
- exitCode: 11,
307
- });
308
- }
309
- const expectedDirectory = path.join(await canonicalizePotentialPath(statePaths(root).workspaces), runId);
310
- const expectedExecutionRoot = path.join(expectedDirectory, "root");
311
- const recordedDirectory = workspace.managed_directory
312
- ? await canonicalizePotentialPath(workspace.managed_directory)
313
- : null;
314
- const recordedExecutionRoot = workspace.execution_root
315
- ? await canonicalizePotentialPath(workspace.execution_root)
316
- : null;
317
- if (!recordedDirectory
318
- || !recordedExecutionRoot
319
- || path.resolve(recordedDirectory) !== path.resolve(expectedDirectory)
320
- || path.resolve(recordedExecutionRoot) !== path.resolve(expectedExecutionRoot)) {
321
- throw new HitchError("workspace record points outside the managed workspace root", {
322
- code: "workspace_cleanup_rejected",
323
- exitCode: 11,
324
- });
325
- }
326
- if (workspace.mode === "worktree" && workspace.git?.common_dir) {
327
- const git = workspace.git;
328
- await withWorkspaceLock(root, git.common_dir, async () => {
329
- if (await pathExists(git.common_dir) && await registeredWorktree(git.common_dir, workspace.execution_root)) {
330
- await runGit(["--git-dir", git.common_dir, "worktree", "remove", "--force", workspace.execution_root], {
331
- failureCode: "workspace_cleanup_failed",
332
- failureExitCode: 12,
333
- });
334
- }
335
- });
336
- }
337
- await rm(expectedDirectory, { recursive: true, force: true });
338
- const removed = {
339
- ...workspace,
340
- status: "removed",
341
- retained: false,
342
- removed_at: new Date().toISOString(),
343
- };
344
- await atomicWriteJSON(recordPath, removed);
345
- return removed;
346
- }
347
- export function workspaceRecordPath(root, runId) {
348
- return path.join(statePaths(root).runs, runId, "workspace.json");
349
- }
350
- export function workspaceManifestFields(workspace) {
351
- if (!workspace)
352
- return {};
353
- return {
354
- workspace_mode: workspace.mode,
355
- source_workspace: workspace.source_workspace,
356
- execution_workspace: workspace.execution_workspace,
357
- workspace_snapshot: workspace.snapshot,
358
- workspace_retained: Boolean(workspace.retained),
359
- ...(workspace.changed !== undefined ? { workspace_changed: workspace.changed } : {}),
360
- };
361
- }
362
- async function prepareWorktree(plan, { signal }) {
363
- await withWorkspaceLock(plan.state_root, plan.git?.common_dir, async () => {
364
- await runGit([
365
- "-C",
366
- plan.git?.repository_root,
367
- "worktree",
368
- "add",
369
- "--detach",
370
- plan.execution_root,
371
- plan.git?.head,
372
- ], {
373
- signal,
374
- env: { ...process.env, GIT_LFS_SKIP_SMUDGE: "1" },
375
- failureCode: "workspace_provision_failed",
376
- failureExitCode: 5,
377
- });
378
- }, { signal });
379
- if (!await pathExists(plan.execution_workspace)) {
380
- throw new HitchError(`workspace subdirectory does not exist at commit ${plan.git?.head}: ${plan.source_subdirectory}`, {
381
- code: "workspace_subdirectory_missing",
382
- exitCode: 2,
383
- });
384
- }
385
- return undefined;
386
- }
387
- async function prepareCopy(plan, { signal }) {
388
- const excluded = plan.git?.head ? new Set([".git"]) : new Set();
389
- await assertNoLinkedGitDirectories(plan.source_base, { signal, excludeRootGit: Boolean(plan.git) });
390
- const git = plan.git;
391
- if (git?.head) {
392
- const currentHead = await runGit(["-C", git.repository_root, "rev-parse", "--verify", "HEAD^{commit}"], {
393
- signal,
394
- failureCode: "workspace_changed_during_snapshot",
395
- failureExitCode: 5,
396
- }).then((result) => result.stdout.trim());
397
- if (currentHead !== git.head) {
398
- throw new HitchError("source workspace HEAD changed while the run was queued", {
399
- code: "workspace_changed_during_snapshot",
400
- exitCode: 5,
401
- });
402
- }
403
- }
404
- const beforeDigest = await workspaceDigest(plan.source_base, { signal, excludedTopLevel: excluded });
405
- if (git?.head) {
406
- await runGit(["clone", "--no-local", "--no-checkout", git.repository_root, plan.execution_root], {
407
- signal,
408
- env: { ...process.env, GIT_LFS_SKIP_SMUDGE: "1" },
409
- failureCode: "workspace_provision_failed",
410
- failureExitCode: 5,
411
- });
412
- await runGit(["-C", plan.execution_root, "checkout", "--detach", "--force", git.head], {
413
- signal,
414
- env: { ...process.env, GIT_LFS_SKIP_SMUDGE: "1" },
415
- failureCode: "workspace_provision_failed",
416
- failureExitCode: 5,
417
- });
418
- for (const entry of await readdir(plan.execution_root)) {
419
- if (entry !== ".git")
420
- await rm(path.join(plan.execution_root, entry), { recursive: true, force: true });
421
- }
422
- await copyDirectoryContents(plan.source_base, plan.execution_root, { signal, excludedTopLevel: excluded });
423
- }
424
- else {
425
- await mkdir(plan.execution_root, { recursive: true });
426
- await copyDirectoryContents(plan.source_base, plan.execution_root, { signal });
427
- }
428
- await assertNoLinkedGitDirectories(plan.execution_root, { signal, excludeRootGit: Boolean(git) });
429
- const [afterDigest, copiedDigest, afterHead] = await Promise.all([
430
- workspaceDigest(plan.source_base, { signal, excludedTopLevel: excluded }),
431
- workspaceDigest(plan.execution_root, { signal, excludedTopLevel: excluded }),
432
- git?.head
433
- ? runGit(["-C", git.repository_root, "rev-parse", "--verify", "HEAD^{commit}"], {
434
- signal,
435
- failureCode: "workspace_changed_during_snapshot",
436
- failureExitCode: 5,
437
- }).then((result) => result.stdout.trim())
438
- : null,
439
- ]);
440
- if (beforeDigest !== afterDigest || afterDigest !== copiedDigest || (git?.head && afterHead !== git.head)) {
441
- throw new HitchError("source workspace changed while Hitch was copying it", {
442
- code: "workspace_changed_during_snapshot",
443
- exitCode: 5,
444
- });
445
- }
446
- if (!await pathExists(plan.execution_workspace)) {
447
- throw new HitchError(`copied workspace subdirectory is missing: ${plan.source_subdirectory}`, {
448
- code: "workspace_subdirectory_missing",
449
- exitCode: 2,
450
- });
451
- }
452
- // Unborn repositories include .git in the source consistency check but
453
- // exclude it from the work-tree baseline, so that case needs one fresh hash.
454
- return { baseline_digest: plan.git && !plan.git.head ? "" : copiedDigest };
455
- }
456
- async function inspectGitWorkspace(directory, { required }) {
457
- const rootOutput = await tryGitOutput(["-C", directory, "rev-parse", "--show-toplevel"]);
458
- if (rootOutput === null) {
459
- if (required) {
460
- throw new HitchError("worktree isolation requires a Git workspace", {
461
- code: "workspace_not_git",
462
- exitCode: 2,
463
- });
464
- }
465
- return null;
466
- }
467
- const repositoryRoot = await realpath(rootOutput);
468
- const [head, commonDirOutput, statusOutput] = await Promise.all([
469
- tryGitOutput(["-C", repositoryRoot, "rev-parse", "--verify", "HEAD^{commit}"]),
470
- runGit(["-C", repositoryRoot, "rev-parse", "--git-common-dir"], {
471
- failureCode: "workspace_git_invalid",
472
- failureExitCode: 2,
473
- }).then((result) => result.stdout.trim()),
474
- runGit(["-C", repositoryRoot, "status", "--porcelain=v1", "--untracked-files=all"], {
475
- failureCode: "workspace_git_invalid",
476
- failureExitCode: 2,
477
- }).then((result) => result.stdout.trim()),
478
- ]);
479
- if (required && !head) {
480
- throw new HitchError("worktree isolation requires a Git workspace with at least one commit", {
481
- code: "workspace_git_no_commit",
482
- exitCode: 2,
483
- });
484
- }
485
- const commonDir = await realpath(path.resolve(repositoryRoot, commonDirOutput));
486
- return {
487
- repository_root: repositoryRoot,
488
- common_dir: commonDir,
489
- head,
490
- status: statusOutput,
491
- };
492
- }
493
- async function isolatedGitMetadataDigest(directory, { signal } = {}) {
494
- const [head, statusOutput, refs, config] = await Promise.all([
495
- runGit(["-C", directory, "rev-parse", "--verify", "HEAD^{commit}"], {
496
- signal,
497
- failureCode: "workspace_git_invalid",
498
- failureExitCode: 12,
499
- }).then((result) => result.stdout, () => ""),
500
- runGit(["-C", directory, "status", "--porcelain=v1", "--untracked-files=all"], {
501
- signal,
502
- failureCode: "workspace_git_invalid",
503
- failureExitCode: 12,
504
- }).then((result) => result.stdout),
505
- runGit(["-C", directory, "for-each-ref", "--format=%(refname)%00%(objectname)"], {
506
- signal,
507
- failureCode: "workspace_git_invalid",
508
- failureExitCode: 12,
509
- }).then((result) => result.stdout),
510
- runGit(["-C", directory, "config", "--local", "--null", "--list"], {
511
- signal,
512
- failureCode: "workspace_git_invalid",
513
- failureExitCode: 12,
514
- }).then((result) => result.stdout),
515
- ]);
516
- return `sha256:${createHash("sha256").update(JSON.stringify({ head, status: statusOutput, refs, config })).digest("hex")}`;
517
- }
518
- async function cleanupProvisioning(workspace) {
519
- await assertManagedPlan(workspace);
520
- const git = workspace.git;
521
- if (workspace.mode === "worktree"
522
- && git?.common_dir
523
- && await pathExists(git.common_dir)
524
- && await registeredWorktree(git.common_dir, workspace.execution_root)) {
525
- await withWorkspaceLock(workspace.state_root, git.common_dir, () => runGit([
526
- "--git-dir",
527
- git.common_dir,
528
- "worktree",
529
- "remove",
530
- "--force",
531
- workspace.execution_root,
532
- ], {
533
- failureCode: "workspace_cleanup_failed",
534
- failureExitCode: 12,
535
- })).catch(() => { });
536
- }
537
- if (workspace.managed_directory)
538
- await rm(workspace.managed_directory, { recursive: true, force: true });
539
- }
540
- async function registeredWorktree(commonDirectory, candidate) {
541
- const result = await runGit(["--git-dir", commonDirectory, "worktree", "list", "--porcelain"], {
542
- failureCode: "workspace_cleanup_failed",
543
- failureExitCode: 12,
544
- });
545
- const candidatePath = await canonicalizePotentialPath(candidate);
546
- for (const line of result.stdout.split(/\r?\n/)) {
547
- if (!line.startsWith("worktree "))
548
- continue;
549
- const registeredPath = await canonicalizePotentialPath(line.slice("worktree ".length));
550
- if (path.resolve(registeredPath) === path.resolve(candidatePath))
551
- return true;
552
- }
553
- return false;
554
- }
555
- async function copyDirectoryContents(source, destination, { signal, excludedTopLevel = new Set() } = {}) {
556
- await ensureDir(destination);
557
- const entries = await readdir(source, { withFileTypes: true });
558
- for (const entry of entries) {
559
- throwIfAborted(signal);
560
- if (excludedTopLevel.has(entry.name))
561
- continue;
562
- await copyEntry(path.join(source, entry.name), path.join(destination, entry.name), { signal });
563
- }
564
- }
565
- async function assertNoLinkedGitDirectories(directory, { signal, excludeRootGit }) {
566
- async function visit(current, depth) {
567
- throwIfAborted(signal);
568
- for (const name of await readdir(current)) {
569
- if (depth === 0 && excludeRootGit && name === ".git")
570
- continue;
571
- const child = path.join(current, name);
572
- const info = await lstat(child);
573
- if (name === ".git" && !info.isDirectory()) {
574
- throw new HitchError(`copy isolation does not yet support linked nested Git workspaces: ${child}`, {
575
- code: "workspace_nested_git_unsupported",
576
- exitCode: 10,
577
- });
578
- }
579
- if (info.isDirectory())
580
- await visit(child, depth + 1);
581
- }
582
- }
583
- await visit(directory, 0);
584
- }
585
- async function copyEntry(source, destination, { signal }) {
586
- throwIfAborted(signal);
587
- const info = await lstat(source);
588
- if (info.isDirectory()) {
589
- await mkdir(destination, { recursive: true, mode: info.mode });
590
- for (const entry of await readdir(source)) {
591
- await copyEntry(path.join(source, entry), path.join(destination, entry), { signal });
592
- }
593
- await chmod(destination, info.mode);
594
- await utimes(destination, info.atime, info.mtime);
595
- return;
596
- }
597
- if (info.isFile()) {
598
- try {
599
- await pipeline(createReadStream(source), createWriteStream(destination, { mode: info.mode }), { signal });
600
- }
601
- catch (error) {
602
- if (signal?.aborted)
603
- throw cancelledError();
604
- throw error;
605
- }
606
- await chmod(destination, info.mode);
607
- await utimes(destination, info.atime, info.mtime);
608
- return;
609
- }
610
- if (info.isSymbolicLink()) {
611
- await symlink(await readlink(source), destination);
612
- return;
613
- }
614
- throw new HitchError(`unsupported special file in workspace: ${source}`, {
615
- code: "workspace_special_file",
616
- exitCode: 5,
617
- });
618
- }
619
- async function workspaceDigest(directory, { signal, excludedTopLevel = new Set() } = {}) {
620
- const hash = createHash("sha256");
621
- await digestDirectory(hash, directory, "", { signal, excludedTopLevel, topLevel: true });
622
- return `sha256:${hash.digest("hex")}`;
623
- }
624
- async function digestDirectory(hash, directory, relative, { signal, excludedTopLevel, topLevel }) {
625
- throwIfAborted(signal);
626
- const entries = await readdir(directory);
627
- entries.sort();
628
- for (const name of entries) {
629
- if (topLevel && excludedTopLevel.has(name))
630
- continue;
631
- throwIfAborted(signal);
632
- const absolute = path.join(directory, name);
633
- const childRelative = relative ? path.join(relative, name) : name;
634
- const info = await lstat(absolute);
635
- if (info.isDirectory()) {
636
- hash.update(`d\0${childRelative}\0${info.mode & 0o7777}\0`);
637
- await digestDirectory(hash, absolute, childRelative, { signal, excludedTopLevel, topLevel: false });
638
- }
639
- else if (info.isFile()) {
640
- hash.update(`f\0${childRelative}\0${info.mode & 0o7777}\0${info.size}\0`);
641
- const stream = createReadStream(absolute);
642
- for await (const chunk of stream) {
643
- throwIfAborted(signal);
644
- hash.update(chunk);
645
- }
646
- hash.update("\0");
647
- }
648
- else if (info.isSymbolicLink()) {
649
- hash.update(`l\0${childRelative}\0${await readlink(absolute)}\0`);
650
- }
651
- else {
652
- throw new HitchError(`unsupported special file in workspace: ${absolute}`, {
653
- code: "workspace_special_file",
654
- exitCode: 5,
655
- });
656
- }
657
- }
658
- }
659
- async function withWorkspaceLock(root, identity, operation, { signal } = {}) {
660
- const directory = statePaths(root).workspaceLocks;
661
- await ensureDir(directory);
662
- const key = createHash("sha256").update(identity).digest("hex");
663
- const file = path.join(directory, `${key}.lock`);
664
- const owner = randomBytes(12).toString("hex");
665
- let handle;
666
- for (let attempt = 0; attempt < 3_000; attempt += 1) {
667
- throwIfAborted(signal);
668
- try {
669
- handle = await open(file, "wx", 0o600);
670
- try {
671
- await handle.writeFile(`${JSON.stringify({ owner, pid: process.pid, created_at: new Date().toISOString() })}\n`);
672
- }
673
- catch (error) {
674
- await handle.close().catch(() => { });
675
- handle = undefined;
676
- await removeIfExists(file);
677
- throw error;
678
- }
679
- break;
680
- }
681
- catch (error) {
682
- if (error?.code !== "EEXIST")
683
- throw error;
684
- if (await staleLock(file)) {
685
- if (!await reclaimStaleLock(file, staleLock))
686
- await delay(100);
687
- continue;
688
- }
689
- await delay(100);
690
- }
691
- }
692
- if (!handle) {
693
- throw new HitchError("timed out waiting for workspace lock", {
694
- code: "workspace_locked",
695
- exitCode: 5,
696
- });
697
- }
698
- try {
699
- return await operation();
700
- }
701
- finally {
702
- await handle.close().catch(() => { });
703
- const current = await readJSON(file, null).catch(() => null);
704
- if (current?.owner === owner)
705
- await removeIfExists(file);
706
- }
707
- }
708
- async function staleLock(file) {
709
- let lock;
710
- try {
711
- lock = JSON.parse(await readFile(file, "utf8"));
712
- }
713
- catch {
714
- try {
715
- return Date.now() - (await stat(file)).mtimeMs > 2_000;
716
- }
717
- catch {
718
- return true;
719
- }
720
- }
721
- if (!Number.isInteger(lock?.pid))
722
- return true;
723
- try {
724
- process.kill(lock.pid, 0);
725
- return false;
726
- }
727
- catch (error) {
728
- return error?.code === "ESRCH";
729
- }
730
- }
731
- async function runGit(args, options = {}) {
732
- return runCommand(process.env.HITCH_GIT_PATH?.trim() || "git", args, options);
733
- }
734
- async function tryGitOutput(args) {
735
- try {
736
- return (await runGit(args)).stdout.trim();
737
- }
738
- catch {
739
- return null;
740
- }
741
- }
742
- async function runCommand(executable, args, { env = process.env, signal, failureCode = "workspace_provision_failed", failureExitCode = 5, timeoutMs = COMMAND_TIMEOUT_MS, } = {}) {
743
- throwIfAborted(signal);
744
- return new Promise((resolve, reject) => {
745
- const child = spawn(executable, args, {
746
- env,
747
- stdio: ["ignore", "pipe", "pipe"],
748
- windowsHide: true,
749
- detached: process.platform !== "win32",
750
- });
751
- let stdout = "";
752
- let stderr = "";
753
- let settled = false;
754
- let aborted = false;
755
- const append = (current, chunk) => `${current}${chunk}`.slice(-1_000_000);
756
- child.stdout.on("data", (chunk) => { stdout = append(stdout, chunk); });
757
- child.stderr.on("data", (chunk) => { stderr = append(stderr, chunk); });
758
- const finish = (callback) => {
759
- if (settled)
760
- return;
761
- settled = true;
762
- clearTimeout(timer);
763
- signal?.removeEventListener("abort", abortHandler);
764
- callback();
765
- };
766
- const abortHandler = () => {
767
- aborted = true;
768
- terminateProcess(child).catch(() => { });
769
- };
770
- const timer = setTimeout(() => terminateProcess(child).catch(() => { }), timeoutMs);
771
- timer.unref?.();
772
- signal?.addEventListener("abort", abortHandler, { once: true });
773
- child.once("error", (error) => finish(() => reject(new HitchError(`failed to start ${path.basename(executable)}: ${error.message}`, { code: failureCode, exitCode: failureExitCode, cause: error }))));
774
- child.once("close", (code, processSignal) => {
775
- if (aborted)
776
- return finish(() => reject(cancelledError()));
777
- if (code === 0)
778
- return finish(() => resolve({ stdout, stderr }));
779
- const detail = stderr.trim() || stdout.trim();
780
- finish(() => reject(new HitchError(`${path.basename(executable)} exited with code ${code ?? "null"}${processSignal ? ` (${processSignal})` : ""}${detail ? `: ${detail}` : ""}`, { code: failureCode, exitCode: failureExitCode })));
781
- });
782
- });
783
- }
784
- function assertNoWorkspaceOverlap(source, managedDirectory) {
785
- const sourcePath = path.resolve(source);
786
- const managedPath = path.resolve(managedDirectory);
787
- if (isWithin(sourcePath, managedPath) || isWithin(managedPath, sourcePath)) {
788
- throw new HitchError("managed workspace storage overlaps the source workspace; use a Hitch --root outside the source repository", { code: "workspace_root_overlap", exitCode: 2 });
789
- }
790
- }
791
- async function assertManagedPlan(workspace) {
792
- assertRunId(workspace.run_id);
793
- if (!workspace.state_root) {
794
- throw new HitchError("workspace plan is missing its state root", {
795
- code: "workspace_cleanup_rejected",
796
- exitCode: 11,
797
- });
798
- }
799
- const expectedDirectory = path.join(await canonicalizePotentialPath(statePaths(workspace.state_root).workspaces), workspace.run_id);
800
- const expectedRoot = path.join(expectedDirectory, "root");
801
- if (path.resolve(workspace.managed_directory || "") !== path.resolve(expectedDirectory)
802
- || path.resolve(workspace.execution_root || "") !== path.resolve(expectedRoot)
803
- || !isWithin(expectedRoot, path.resolve(workspace.execution_workspace || ""))) {
804
- throw new HitchError("workspace plan points outside the managed workspace root", {
805
- code: "workspace_cleanup_rejected",
806
- exitCode: 11,
807
- });
808
- }
809
- }
810
- function isWithin(parent, candidate) {
811
- const relative = path.relative(parent, candidate);
812
- return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== "..");
813
- }
814
- async function pathExists(file) {
815
- try {
816
- await lstat(file);
817
- return true;
818
- }
819
- catch (error) {
820
- if (error?.code === "ENOENT")
821
- return false;
822
- throw error;
823
- }
824
- }
825
- async function canonicalizePotentialPath(file) {
826
- const missing = [];
827
- let current = path.resolve(file);
828
- for (;;) {
829
- try {
830
- const resolved = await realpath(current);
831
- return path.join(resolved, ...missing.reverse());
832
- }
833
- catch (error) {
834
- if (error?.code !== "ENOENT")
835
- throw error;
836
- const parent = path.dirname(current);
837
- if (parent === current)
838
- throw error;
839
- missing.push(path.basename(current));
840
- current = parent;
841
- }
842
- }
843
- }
844
- function assertRunId(runId) {
845
- if (!/^run_[a-f0-9]{32}$/.test(runId || ""))
846
- throw invalidInput(`invalid run ID: ${runId || ""}`);
847
- }
848
- function gitMetadataExclusion(workspace) {
849
- return workspace.git ? new Set([".git"]) : new Set();
850
- }
851
- function throwIfAborted(signal) {
852
- if (signal?.aborted)
853
- throw cancelledError();
854
- }
855
- function cancelledError() {
856
- return new HitchError("workspace preparation cancelled", { code: "cancelled", exitCode: 9 });
857
- }
858
- //# sourceMappingURL=workspaces.js.map