harness-dispatch 0.4.0 → 0.6.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.
Files changed (185) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/README.md +232 -87
  3. package/config.default.yaml +416 -397
  4. package/dist/auth.d.ts.map +1 -1
  5. package/dist/auth.js +2 -2
  6. package/dist/auth.js.map +1 -1
  7. package/dist/billing.d.ts +35 -4
  8. package/dist/billing.d.ts.map +1 -1
  9. package/dist/billing.js +48 -17
  10. package/dist/billing.js.map +1 -1
  11. package/dist/bin.d.ts.map +1 -1
  12. package/dist/bin.js +147 -91
  13. package/dist/bin.js.map +1 -1
  14. package/dist/breaker-store.d.ts +60 -9
  15. package/dist/breaker-store.d.ts.map +1 -1
  16. package/dist/breaker-store.js +246 -50
  17. package/dist/breaker-store.js.map +1 -1
  18. package/dist/circuit-breaker.d.ts +35 -0
  19. package/dist/circuit-breaker.d.ts.map +1 -1
  20. package/dist/circuit-breaker.js +68 -11
  21. package/dist/circuit-breaker.js.map +1 -1
  22. package/dist/config/coercions.d.ts +31 -0
  23. package/dist/config/coercions.d.ts.map +1 -0
  24. package/dist/config/coercions.js +169 -0
  25. package/dist/config/coercions.js.map +1 -0
  26. package/dist/config/env-interpolation.d.ts +36 -0
  27. package/dist/config/env-interpolation.d.ts.map +1 -0
  28. package/dist/config/env-interpolation.js +73 -0
  29. package/dist/config/env-interpolation.js.map +1 -0
  30. package/dist/config/protocol.d.ts +51 -0
  31. package/dist/config/protocol.d.ts.map +1 -0
  32. package/dist/config/protocol.js +300 -0
  33. package/dist/config/protocol.js.map +1 -0
  34. package/dist/config/route-fields.d.ts +72 -0
  35. package/dist/config/route-fields.d.ts.map +1 -0
  36. package/dist/config/route-fields.js +128 -0
  37. package/dist/config/route-fields.js.map +1 -0
  38. package/dist/config/validation.d.ts +72 -0
  39. package/dist/config/validation.d.ts.map +1 -0
  40. package/dist/config/validation.js +189 -0
  41. package/dist/config/validation.js.map +1 -0
  42. package/dist/config.d.ts +19 -2
  43. package/dist/config.d.ts.map +1 -1
  44. package/dist/config.js +214 -635
  45. package/dist/config.js.map +1 -1
  46. package/dist/configure-yaml.d.ts +23 -0
  47. package/dist/configure-yaml.d.ts.map +1 -0
  48. package/dist/configure-yaml.js +193 -0
  49. package/dist/configure-yaml.js.map +1 -0
  50. package/dist/dispatch-log.d.ts.map +1 -1
  51. package/dist/dispatch-log.js +2 -3
  52. package/dist/dispatch-log.js.map +1 -1
  53. package/dist/dispatchers/base.d.ts +3 -0
  54. package/dist/dispatchers/base.d.ts.map +1 -1
  55. package/dist/dispatchers/base.js.map +1 -1
  56. package/dist/dispatchers/generic-cli.d.ts +15 -1
  57. package/dist/dispatchers/generic-cli.d.ts.map +1 -1
  58. package/dist/dispatchers/generic-cli.js +69 -5
  59. package/dist/dispatchers/generic-cli.js.map +1 -1
  60. package/dist/dispatchers/openai-compatible.d.ts.map +1 -1
  61. package/dist/dispatchers/openai-compatible.js +70 -2
  62. package/dist/dispatchers/openai-compatible.js.map +1 -1
  63. package/dist/dispatchers/shared/kill-tree.d.ts +17 -4
  64. package/dist/dispatchers/shared/kill-tree.d.ts.map +1 -1
  65. package/dist/dispatchers/shared/kill-tree.js +28 -4
  66. package/dist/dispatchers/shared/kill-tree.js.map +1 -1
  67. package/dist/dispatchers/shared/rate-limit-headers.d.ts +17 -0
  68. package/dist/dispatchers/shared/rate-limit-headers.d.ts.map +1 -1
  69. package/dist/dispatchers/shared/rate-limit-headers.js +27 -0
  70. package/dist/dispatchers/shared/rate-limit-headers.js.map +1 -1
  71. package/dist/dispatchers/shared/stream-subprocess.d.ts +11 -0
  72. package/dist/dispatchers/shared/stream-subprocess.d.ts.map +1 -1
  73. package/dist/dispatchers/shared/stream-subprocess.js +62 -35
  74. package/dist/dispatchers/shared/stream-subprocess.js.map +1 -1
  75. package/dist/dispatchers/shared/subprocess.d.ts.map +1 -1
  76. package/dist/dispatchers/shared/subprocess.js +3 -0
  77. package/dist/dispatchers/shared/subprocess.js.map +1 -1
  78. package/dist/dispatchers/shared/which-available.d.ts.map +1 -1
  79. package/dist/dispatchers/shared/which-available.js +16 -2
  80. package/dist/dispatchers/shared/which-available.js.map +1 -1
  81. package/dist/file-lock.d.ts +17 -0
  82. package/dist/file-lock.d.ts.map +1 -0
  83. package/dist/file-lock.js +143 -0
  84. package/dist/file-lock.js.map +1 -0
  85. package/dist/http/parse.d.ts +64 -0
  86. package/dist/http/parse.d.ts.map +1 -0
  87. package/dist/http/parse.js +230 -0
  88. package/dist/http/parse.js.map +1 -0
  89. package/dist/http/server.d.ts +2 -0
  90. package/dist/http/server.d.ts.map +1 -1
  91. package/dist/http/server.js +249 -286
  92. package/dist/http/server.js.map +1 -1
  93. package/dist/index.d.ts +1 -1
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +3 -2
  96. package/dist/index.js.map +1 -1
  97. package/dist/job-runner.js +23 -5
  98. package/dist/job-runner.js.map +1 -1
  99. package/dist/jobs/context.d.ts +17 -0
  100. package/dist/jobs/context.d.ts.map +1 -0
  101. package/dist/jobs/context.js +84 -0
  102. package/dist/jobs/context.js.map +1 -0
  103. package/dist/jobs/store.d.ts +80 -0
  104. package/dist/jobs/store.d.ts.map +1 -0
  105. package/dist/jobs/store.js +279 -0
  106. package/dist/jobs/store.js.map +1 -0
  107. package/dist/jobs/types.d.ts +135 -0
  108. package/dist/jobs/types.d.ts.map +1 -0
  109. package/dist/jobs/types.js +11 -0
  110. package/dist/jobs/types.js.map +1 -0
  111. package/dist/jobs.d.ts +165 -85
  112. package/dist/jobs.d.ts.map +1 -1
  113. package/dist/jobs.js +824 -208
  114. package/dist/jobs.js.map +1 -1
  115. package/dist/leaderboard.d.ts +24 -1
  116. package/dist/leaderboard.d.ts.map +1 -1
  117. package/dist/leaderboard.js +94 -2
  118. package/dist/leaderboard.js.map +1 -1
  119. package/dist/mcp/config-hot-reload.d.ts.map +1 -1
  120. package/dist/mcp/config-hot-reload.js +1 -1
  121. package/dist/mcp/config-hot-reload.js.map +1 -1
  122. package/dist/mcp/dispatcher-factory.d.ts +16 -1
  123. package/dist/mcp/dispatcher-factory.d.ts.map +1 -1
  124. package/dist/mcp/dispatcher-factory.js +41 -3
  125. package/dist/mcp/dispatcher-factory.js.map +1 -1
  126. package/dist/mcp/tool-schemas.d.ts +194 -0
  127. package/dist/mcp/tool-schemas.d.ts.map +1 -0
  128. package/dist/mcp/tool-schemas.js +311 -0
  129. package/dist/mcp/tool-schemas.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +26 -51
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +122 -151
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/observability/index.d.ts +16 -3
  135. package/dist/observability/index.d.ts.map +1 -1
  136. package/dist/observability/index.js +23 -7
  137. package/dist/observability/index.js.map +1 -1
  138. package/dist/observability/spans.d.ts.map +1 -1
  139. package/dist/observability/spans.js +4 -1
  140. package/dist/observability/spans.js.map +1 -1
  141. package/dist/quota.d.ts +106 -22
  142. package/dist/quota.d.ts.map +1 -1
  143. package/dist/quota.js +198 -58
  144. package/dist/quota.js.map +1 -1
  145. package/dist/route-policy.d.ts.map +1 -1
  146. package/dist/route-policy.js +13 -3
  147. package/dist/route-policy.js.map +1 -1
  148. package/dist/router.d.ts +29 -1
  149. package/dist/router.d.ts.map +1 -1
  150. package/dist/router.js +102 -40
  151. package/dist/router.js.map +1 -1
  152. package/dist/safety.d.ts.map +1 -1
  153. package/dist/safety.js +11 -2
  154. package/dist/safety.js.map +1 -1
  155. package/dist/state-dir.d.ts +17 -0
  156. package/dist/state-dir.d.ts.map +1 -0
  157. package/dist/state-dir.js +21 -0
  158. package/dist/state-dir.js.map +1 -0
  159. package/dist/status.d.ts +49 -0
  160. package/dist/status.d.ts.map +1 -1
  161. package/dist/status.js +67 -4
  162. package/dist/status.js.map +1 -1
  163. package/dist/types.d.ts +74 -1
  164. package/dist/types.d.ts.map +1 -1
  165. package/dist/working-dir.d.ts +13 -0
  166. package/dist/working-dir.d.ts.map +1 -1
  167. package/dist/working-dir.js +27 -0
  168. package/dist/working-dir.js.map +1 -1
  169. package/dist/workspace-lock.d.ts +40 -0
  170. package/dist/workspace-lock.d.ts.map +1 -0
  171. package/dist/workspace-lock.js +258 -0
  172. package/dist/workspace-lock.js.map +1 -0
  173. package/dist/workspace-resolve.d.ts +98 -0
  174. package/dist/workspace-resolve.d.ts.map +1 -0
  175. package/dist/workspace-resolve.js +382 -0
  176. package/dist/workspace-resolve.js.map +1 -0
  177. package/dist/workspaces.d.ts +16 -0
  178. package/dist/workspaces.d.ts.map +1 -1
  179. package/dist/workspaces.js +166 -22
  180. package/dist/workspaces.js.map +1 -1
  181. package/package.json +99 -96
  182. package/dist/dashboard/live.d.ts +0 -49
  183. package/dist/dashboard/live.d.ts.map +0 -1
  184. package/dist/dashboard/live.js +0 -149
  185. package/dist/dashboard/live.js.map +0 -1
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Mutual exclusion over a working directory, across processes.
3
+ *
4
+ * `workspace_policy: shared_locked` promises that two dispatches never edit
5
+ * one workspace at the same time. Until now that promise was implemented as a
6
+ * `Map` in module scope — real within a process, and every job used to get its
7
+ * OWN process, so concurrent jobs sharing a directory were free to clobber
8
+ * each other the entire time. The guarantee read correctly and did nothing.
9
+ * Pooling supervisors made it bind for jobs sharing a supervisor, which
10
+ * narrowed the hole to four ways instead of N but did not close it.
11
+ *
12
+ * Two layers, because they solve different halves:
13
+ *
14
+ * 1. An in-process promise chain. Fast, fair (FIFO by arrival), and it keeps
15
+ * same-process waiters off the filesystem entirely.
16
+ * 2. A lock FILE, for everything the first layer cannot see. Created with
17
+ * `wx`, which fails atomically if it already exists on both Windows and
18
+ * POSIX.
19
+ *
20
+ * Lock files live in the state directory, keyed by a hash of the resolved
21
+ * path, rather than inside the workspace: a dispatcher should not drop
22
+ * bookkeeping files into a user's repository, where they would show up in
23
+ * `git status` and in the workspace diff the caller is handed back.
24
+ *
25
+ * A holder that dies must not wedge the directory forever, so the lock carries
26
+ * a heartbeat refreshed while held, and a lock whose heartbeat has gone stale
27
+ * is stolen. That is the same rule, and the same threshold, the job orphan
28
+ * check already uses — one staleness concept in the system, not two.
29
+ */
30
+ import { createHash } from "node:crypto";
31
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
32
+ import path from "node:path";
33
+ import { stateRoot } from "./state-dir.js";
34
+ /** Matches ORPHAN_THRESHOLD_MS in jobs.ts: one notion of "the holder is gone". */
35
+ export const LOCK_STALE_MS = 90_000;
36
+ /** How often a held lock refreshes its heartbeat. Comfortably inside the stale window. */
37
+ const HEARTBEAT_MS = 15_000;
38
+ /** Gap between attempts when another process holds the lock. */
39
+ const RETRY_MS = 100;
40
+ /**
41
+ * How long an EXISTING-but-unreadable lock file is given before it is treated
42
+ * as stealable. An unreadable record used to be stolen on sight, which made a
43
+ * torn read of a mid-rewrite heartbeat sufficient to take a LIVE holder's lock
44
+ * — two dispatches then edited the same workspace, the exact outcome
45
+ * `shared_locked` exists to prevent. Heartbeats are written atomically now, so
46
+ * within one build a torn read cannot happen; the grace keeps the steal honest
47
+ * against writers from older builds (in-place heartbeat rewrites during a
48
+ * rolling upgrade) and external interference.
49
+ */
50
+ const UNREADABLE_GRACE_MS = 1_000;
51
+ const inProcessLocks = new Map();
52
+ function lockKey(workingDir) {
53
+ const resolved = path.resolve(workingDir || process.cwd());
54
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
55
+ }
56
+ function lockFileFor(key) {
57
+ const dir = stateRoot();
58
+ const digest = createHash("sha256").update(key).digest("hex").slice(0, 16);
59
+ return path.join(dir, "workspace-locks", `${digest}.json`);
60
+ }
61
+ function readRecord(file) {
62
+ try {
63
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
64
+ if (typeof parsed?.beatMs !== "number")
65
+ return undefined;
66
+ return { pid: Number(parsed.pid) || 0, key: String(parsed.key ?? ""), beatMs: parsed.beatMs };
67
+ }
68
+ catch {
69
+ // Unreadable or half-written: treat as absent, and let the staleness
70
+ // check below decide. A corrupt lock must not wedge the directory.
71
+ return undefined;
72
+ }
73
+ }
74
+ /** True if the recorded holder is definitely gone. */
75
+ function isDead(record) {
76
+ if (Date.now() - record.beatMs > LOCK_STALE_MS)
77
+ return true;
78
+ if (record.pid > 0) {
79
+ try {
80
+ // Signal 0 tests for existence without touching the process.
81
+ process.kill(record.pid, 0);
82
+ }
83
+ catch {
84
+ return true;
85
+ }
86
+ }
87
+ return false;
88
+ }
89
+ function tryCreate(file, key) {
90
+ try {
91
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
92
+ writeFileSync(file, JSON.stringify({ pid: process.pid, key, beatMs: Date.now() }), {
93
+ encoding: "utf8",
94
+ mode: 0o600,
95
+ flag: "wx",
96
+ });
97
+ return true;
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ /**
104
+ * Take a stale lock out of the way — by RENAME, not delete.
105
+ *
106
+ * Delete-then-recreate let two waiters both decide the same record was stale:
107
+ * the slower one's delete then removed the FASTER one's freshly created lock,
108
+ * and both ended up holding the directory. Rename is atomic and names a
109
+ * specific victim — whoever loses the rename gets ENOENT and simply goes
110
+ * round the acquire loop again. Returns true if this process performed the
111
+ * steal (the caller still has to win the `wx` create; a third waiter may get
112
+ * there first, which is an honest race, not a double hold).
113
+ */
114
+ function stealLock(file) {
115
+ const tomb = `${file}.stolen-${process.pid}-${Date.now().toString(36)}`;
116
+ try {
117
+ renameSync(file, tomb);
118
+ }
119
+ catch {
120
+ return false; // Another waiter stole it, or the holder released it first.
121
+ }
122
+ try {
123
+ rmSync(tomb, { force: true });
124
+ }
125
+ catch {
126
+ // A leftover tombstone is inert: nothing reads `*.stolen-*` names.
127
+ }
128
+ return true;
129
+ }
130
+ async function acquireFileLock(key, timeoutMs) {
131
+ const file = lockFileFor(key);
132
+ const deadline = Date.now() + timeoutMs;
133
+ let unreadableSince;
134
+ for (;;) {
135
+ if (tryCreate(file, key))
136
+ break;
137
+ const record = readRecord(file);
138
+ if (record === undefined) {
139
+ // Exists but unreadable (or vanished between the two calls). Give it a
140
+ // grace window rather than stealing on sight — see UNREADABLE_GRACE_MS.
141
+ unreadableSince ??= Date.now();
142
+ if (Date.now() - unreadableSince >= UNREADABLE_GRACE_MS) {
143
+ unreadableSince = undefined;
144
+ stealLock(file);
145
+ continue;
146
+ }
147
+ }
148
+ else {
149
+ unreadableSince = undefined;
150
+ if (isDead(record)) {
151
+ stealLock(file);
152
+ continue;
153
+ }
154
+ }
155
+ if (Date.now() >= deadline) {
156
+ // Proceeding unlocked would silently break the guarantee the caller
157
+ // asked for, so this is an error rather than a warning.
158
+ throw new Error(`workspace lock timed out after ${Math.round(timeoutMs / 1000)}s waiting for ` +
159
+ `pid ${record?.pid ?? "unknown"} to release ${key}. Another dispatch is still ` +
160
+ `using this workspace; use workspace_policy: copy to run them concurrently.`);
161
+ }
162
+ await new Promise((r) => setTimeout(r, RETRY_MS));
163
+ }
164
+ const beat = setInterval(() => {
165
+ // Refresh ATOMICALLY (tmp + rename), never by rewriting in place: an
166
+ // in-place rewrite truncates first, and a waiter reading in that window
167
+ // saw an empty record — grounds, under the old rules, to steal a lock
168
+ // whose holder was alive and mid-write.
169
+ //
170
+ // Ownership is checked first: if the record is no longer ours, this
171
+ // process froze past the stale window and was legitimately stolen.
172
+ // Overwriting would clobber the thief's record — and worse, make our own
173
+ // release() believe it still held the lock and delete it under the thief.
174
+ try {
175
+ const current = readRecord(file);
176
+ if (current !== undefined && current.pid !== process.pid) {
177
+ clearInterval(beat);
178
+ return;
179
+ }
180
+ if (current === undefined) {
181
+ // Missing or unreadable while we believe we hold it: a steal may be
182
+ // mid-flight. Recreating it could re-take a lock someone else now
183
+ // owns, so stop refreshing and let release()'s ownership check
184
+ // decide what to delete.
185
+ clearInterval(beat);
186
+ return;
187
+ }
188
+ const tmp = `${file}.${process.pid}.beat`;
189
+ writeFileSync(tmp, JSON.stringify({ pid: process.pid, key, beatMs: Date.now() }), {
190
+ encoding: "utf8",
191
+ mode: 0o600,
192
+ });
193
+ renameSync(tmp, file);
194
+ }
195
+ catch {
196
+ // A failed refresh only risks the lock being stolen as stale, which is
197
+ // the correct outcome if this process really is in trouble. The next
198
+ // beat retries; a leftover .beat tmp file is inert.
199
+ }
200
+ }, HEARTBEAT_MS);
201
+ beat.unref?.();
202
+ let released = false;
203
+ return () => {
204
+ if (released)
205
+ return;
206
+ released = true;
207
+ clearInterval(beat);
208
+ try {
209
+ // Only remove it if we still hold it — a stolen lock now belongs to
210
+ // someone else and deleting it would hand the directory to a third
211
+ // party while the thief is mid-write.
212
+ const current = readRecord(file);
213
+ if (current === undefined || current.pid === process.pid) {
214
+ rmSync(file, { force: true });
215
+ }
216
+ }
217
+ catch {
218
+ // Left behind; it ages out via the staleness rule.
219
+ }
220
+ };
221
+ }
222
+ /**
223
+ * Take the lock for `workingDir`. Resolves once held; call the returned
224
+ * function to release.
225
+ *
226
+ * @param timeoutMs how long to wait for another process. Defaults to an hour,
227
+ * matching the job runtime ceiling — a legitimate holder can hold it that long.
228
+ */
229
+ export async function acquireWorkspaceLock(workingDir, timeoutMs = 60 * 60 * 1000) {
230
+ const key = lockKey(workingDir);
231
+ // Layer 1: queue behind same-process waiters first, so a burst inside one
232
+ // supervisor costs one filesystem acquisition rather than N spinning ones.
233
+ const previous = inProcessLocks.get(key) ?? Promise.resolve();
234
+ let releaseLocal;
235
+ const current = previous.catch(() => undefined).then(() => new Promise((resolve) => {
236
+ releaseLocal = resolve;
237
+ }));
238
+ inProcessLocks.set(key, current);
239
+ await previous.catch(() => undefined);
240
+ // Layer 2: the cross-process lock.
241
+ let releaseFile;
242
+ try {
243
+ releaseFile = await acquireFileLock(key, timeoutMs);
244
+ }
245
+ catch (err) {
246
+ releaseLocal();
247
+ if (inProcessLocks.get(key) === current)
248
+ inProcessLocks.delete(key);
249
+ throw err;
250
+ }
251
+ return () => {
252
+ releaseFile();
253
+ releaseLocal();
254
+ if (inProcessLocks.get(key) === current)
255
+ inProcessLocks.delete(key);
256
+ };
257
+ }
258
+ //# sourceMappingURL=workspace-lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-lock.js","sourceRoot":"","sources":["../src/workspace-lock.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What happens to isolated work AFTER the agent finishes.
3
+ *
4
+ * `copy` and `git_worktree` gave you a workspace path, a changed-file list and
5
+ * a `cleanupHint` — a string telling you to run `git worktree remove`
6
+ * yourself. Isolation worked and nothing was ever applied automatically, but
7
+ * there was no way to see the actual change, no way to keep it, and no way to
8
+ * clean up but by hand. Isolated dispatches were effectively write-only: you
9
+ * could look at a summary and then abandon the result.
10
+ *
11
+ * This module is the missing half — inspect, then keep or throw away.
12
+ *
13
+ * THE DANGEROUS PART IS `apply`, and it is dangerous in one specific way:
14
+ * applying a patch into a directory that has moved on since the agent started
15
+ * can conflict with, or silently overwrite, work the user did in the meantime.
16
+ * So apply refuses when the target has uncommitted changes unless the caller
17
+ * explicitly overrides, and it always writes the patch to disk first so there
18
+ * is a manual path (`git apply`) even when the automatic one declines.
19
+ */
20
+ import type { WorkspaceRun } from "./types.js";
21
+ /** Patches are read into memory and returned over MCP, so they are bounded. */
22
+ export declare const MAX_PATCH_BYTES: number;
23
+ /** How much of a patch is returned inline before it is truncated for display. */
24
+ export declare const MAX_PATCH_CHARS = 60000;
25
+ export declare function isResolvable(run: WorkspaceRun | undefined): run is WorkspaceRun;
26
+ /**
27
+ * Produce a unified patch of what the agent changed, relative to the project
28
+ * root, so it applies with `git apply -p1` from `originalWorkingDir`.
29
+ *
30
+ * The two policies need different bases and getting this wrong is destructive:
31
+ *
32
+ * git_worktree — diff against the COMMIT the worktree was created from.
33
+ * A worktree starts at HEAD, not at the caller's working tree, so diffing
34
+ * it against the original directory of a dirty project would report the
35
+ * user's own uncommitted work as deletions.
36
+ * copy — diff against the original directory, which is exactly what the
37
+ * copy was made from, so the difference is the agent's work and nothing
38
+ * else.
39
+ */
40
+ export declare function buildWorkspacePatch(run: WorkspaceRun): Promise<string>;
41
+ /**
42
+ * Rewrite `git diff --no-index` headers so the patch is repo-relative.
43
+ *
44
+ * --no-index emits the absolute paths it was given (`--- a/C:/long/path/...`),
45
+ * which applies nowhere but this machine. Rewriting them to a/<rel> and
46
+ * b/<rel> makes the patch portable and applicable with -p1, which is what
47
+ * every other git patch in the world expects.
48
+ */
49
+ export declare function normaliseNoIndexPaths(patch: string, origRoot: string, isoRoot: string): string;
50
+ export interface PatchResult {
51
+ jobId: string;
52
+ policy: WorkspaceRun["policy"];
53
+ /** Where the full patch was written, always, so there is a manual path. */
54
+ patchPath: string;
55
+ bytes: number;
56
+ /** Possibly truncated for display; patchPath always holds the whole thing. */
57
+ patch: string;
58
+ truncated: boolean;
59
+ changedFiles?: WorkspaceRun["changedFiles"];
60
+ }
61
+ /** Build the patch, cache it next to the job, and return it (bounded). */
62
+ export declare function workspaceDiff(jobId: string, jobDir: string, run: WorkspaceRun): Promise<PatchResult>;
63
+ export interface ApplyResult {
64
+ jobId: string;
65
+ applied: boolean;
66
+ patchPath: string;
67
+ message: string;
68
+ changedFiles?: WorkspaceRun["changedFiles"];
69
+ }
70
+ /**
71
+ * Apply the agent's work into the real project.
72
+ *
73
+ * Refuses by default when the target has uncommitted changes: the patch was
74
+ * built against a specific base, and applying it over work done since can
75
+ * conflict or clobber. `force` overrides, and the patch file is written
76
+ * either way so `git apply` by hand is always available.
77
+ */
78
+ export declare function applyWorkspace(jobId: string, jobDir: string, run: WorkspaceRun, opts?: {
79
+ force?: boolean;
80
+ }): Promise<ApplyResult>;
81
+ export interface DiscardResult {
82
+ jobId: string;
83
+ discarded: boolean;
84
+ message: string;
85
+ }
86
+ /**
87
+ * Throw the isolated workspace away.
88
+ *
89
+ * A git worktree is not just a directory: `rm -rf` on it leaves the parent
90
+ * repository's worktree metadata behind, and git then complains about a
91
+ * missing worktree until someone runs `git worktree prune`. So worktrees are
92
+ * removed through git, with a filesystem sweep afterwards for the wrapper
93
+ * directory git does not own.
94
+ */
95
+ export declare function discardWorkspace(jobId: string, run: WorkspaceRun): Promise<DiscardResult>;
96
+ /** Read a previously written patch, if one was cached. */
97
+ export declare function cachedPatch(jobDir: string): Promise<string | undefined>;
98
+ //# sourceMappingURL=workspace-resolve.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-resolve.d.ts","sourceRoot":"","sources":["../src/workspace-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAQH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAK/C,+EAA+E;AAC/E,eAAO,MAAM,eAAe,QAAkB,CAAC;AAE/C,iFAAiF;AACjF,eAAO,MAAM,eAAe,QAAS,CAAC;AA4BtC,wBAAgB,YAAY,CAAC,GAAG,EAAE,YAAY,GAAG,SAAS,GAAG,GAAG,IAAI,YAAY,CAE/E;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,mBAAmB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CA0C5E;AAmFD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAwB9F;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,YAAY,CAAC,QAAQ,CAAC,CAAC;IAC/B,2EAA2E;IAC3E,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,YAAY,CAAC,EAAE,YAAY,CAAC,cAAc,CAAC,CAAC;CAC7C;AAED,0EAA0E;AAC1E,wBAAsB,aAAa,CACjC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,YAAY,GAChB,OAAO,CAAC,WAAW,CAAC,CAiBtB;AA4BD,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,YAAY,CAAC,cAAc,CAAC,CAAC;CAC7C;AAED;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,GAAG,EAAE,YAAY,EACjB,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAO,GAC7B,OAAO,CAAC,WAAW,CAAC,CAiEtB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,YAAY,GAChB,OAAO,CAAC,aAAa,CAAC,CAyBxB;AAED,0DAA0D;AAC1D,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAM7E"}