@mikode13/harness 4.0.1 → 5.1.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 (170) hide show
  1. package/README.md +299 -17
  2. package/dist/agent/domain/agent.d.ts +32 -1
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/approval.d.ts +9 -0
  5. package/dist/agent/domain/approval.js.map +1 -1
  6. package/dist/agent/domain/runContext.d.ts +31 -0
  7. package/dist/agent/domain/runContext.js +47 -0
  8. package/dist/agent/domain/runContext.js.map +1 -0
  9. package/dist/diff/domain/unifiedDiff.d.ts +19 -0
  10. package/dist/diff/domain/unifiedDiff.js +166 -0
  11. package/dist/diff/domain/unifiedDiff.js.map +1 -0
  12. package/dist/engines/domain/model/historyFollower.d.ts +41 -0
  13. package/dist/engines/domain/model/historyFollower.js +158 -0
  14. package/dist/engines/domain/model/historyFollower.js.map +1 -0
  15. package/dist/engines/domain/model/llmAgent.d.ts +42 -4
  16. package/dist/engines/domain/model/llmAgent.js +287 -31
  17. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  18. package/dist/factory/infrastructure/agentFactory.d.ts +14 -0
  19. package/dist/factory/infrastructure/agentFactory.js +20 -2
  20. package/dist/factory/infrastructure/agentFactory.js.map +1 -1
  21. package/dist/factory/infrastructure/agentLLMFactory.d.ts +119 -14
  22. package/dist/factory/infrastructure/agentLLMFactory.js +284 -57
  23. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  24. package/dist/index.d.ts +13 -5
  25. package/dist/index.js +5 -2
  26. package/dist/index.js.map +1 -1
  27. package/dist/llm/domain/conversation.d.ts +44 -4
  28. package/dist/llm/domain/conversation.js +56 -7
  29. package/dist/llm/domain/conversation.js.map +1 -1
  30. package/dist/llm/domain/llm.d.ts +3 -1
  31. package/dist/llm/domain/llm.js.map +1 -1
  32. package/dist/llm/domain/tool.d.ts +13 -0
  33. package/dist/llm/domain/tool.js +12 -1
  34. package/dist/llm/domain/tool.js.map +1 -1
  35. package/dist/llm/infrastructure/claudeLLMClient.d.ts +3 -2
  36. package/dist/llm/infrastructure/claudeLLMClient.js +38 -10
  37. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  38. package/dist/llm/infrastructure/openAILLMClient.d.ts +2 -1
  39. package/dist/llm/infrastructure/openAILLMClient.js +67 -17
  40. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  41. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +17 -3
  42. package/dist/orchestration/domain/model/orchestratorAgent.js +65 -8
  43. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  44. package/dist/recovery/domain/fileChanges.d.ts +31 -0
  45. package/dist/recovery/domain/fileChanges.js +48 -0
  46. package/dist/recovery/domain/fileChanges.js.map +1 -0
  47. package/dist/recovery/domain/history.d.ts +114 -0
  48. package/dist/recovery/domain/history.js +62 -0
  49. package/dist/recovery/domain/history.js.map +1 -0
  50. package/dist/recovery/domain/recoveryStore.d.ts +233 -0
  51. package/dist/recovery/domain/recoveryStore.js +32 -0
  52. package/dist/recovery/domain/recoveryStore.js.map +1 -0
  53. package/dist/recovery/domain/retention.d.ts +33 -0
  54. package/dist/recovery/domain/retention.js +90 -0
  55. package/dist/recovery/domain/retention.js.map +1 -0
  56. package/dist/recovery/domain/runChanges.d.ts +37 -0
  57. package/dist/recovery/domain/runChanges.js +109 -0
  58. package/dist/recovery/domain/runChanges.js.map +1 -0
  59. package/dist/recovery/domain/runTree.d.ts +32 -0
  60. package/dist/recovery/domain/runTree.js +90 -0
  61. package/dist/recovery/domain/runTree.js.map +1 -0
  62. package/dist/recovery/infrastructure/createHistory.d.ts +11 -0
  63. package/dist/recovery/infrastructure/createHistory.js +17 -0
  64. package/dist/recovery/infrastructure/createHistory.js.map +1 -0
  65. package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +133 -0
  66. package/dist/recovery/infrastructure/fileRecoveryStore.js +1043 -0
  67. package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -0
  68. package/dist/recovery/infrastructure/workspaceFiles.d.ts +30 -0
  69. package/dist/recovery/infrastructure/workspaceFiles.js +125 -0
  70. package/dist/recovery/infrastructure/workspaceFiles.js.map +1 -0
  71. package/dist/tools/domain/accessPolicy.d.ts +30 -0
  72. package/dist/tools/domain/accessPolicy.js +11 -0
  73. package/dist/tools/domain/accessPolicy.js.map +1 -0
  74. package/dist/tools/domain/commands.d.ts +93 -0
  75. package/dist/tools/domain/commands.js +191 -0
  76. package/dist/tools/domain/commands.js.map +1 -0
  77. package/dist/tools/domain/editResponse.d.ts +14 -0
  78. package/dist/tools/domain/editResponse.js +79 -0
  79. package/dist/tools/domain/editResponse.js.map +1 -0
  80. package/dist/tools/domain/fileEdits.d.ts +64 -0
  81. package/dist/tools/domain/fileEdits.js +21 -0
  82. package/dist/tools/domain/fileEdits.js.map +1 -0
  83. package/dist/tools/domain/lineLength.d.ts +7 -0
  84. package/dist/tools/domain/lineLength.js +26 -0
  85. package/dist/tools/domain/lineLength.js.map +1 -0
  86. package/dist/tools/domain/preparedCall.d.ts +44 -0
  87. package/dist/tools/domain/preparedCall.js +26 -0
  88. package/dist/tools/domain/preparedCall.js.map +1 -0
  89. package/dist/tools/domain/readRegistry.d.ts +46 -0
  90. package/dist/tools/domain/readRegistry.js +62 -0
  91. package/dist/tools/domain/readRegistry.js.map +1 -0
  92. package/dist/tools/domain/textLines.d.ts +26 -0
  93. package/dist/tools/domain/textLines.js +44 -0
  94. package/dist/tools/domain/textLines.js.map +1 -0
  95. package/dist/tools/domain/tool.d.ts +14 -0
  96. package/dist/tools/domain/tool.js +7 -1
  97. package/dist/tools/domain/tool.js.map +1 -1
  98. package/dist/tools/domain/v4aPatch.d.ts +17 -0
  99. package/dist/tools/domain/v4aPatch.js +236 -0
  100. package/dist/tools/domain/v4aPatch.js.map +1 -0
  101. package/dist/tools/infrastructure/boundedWorkspace.d.ts +11 -2
  102. package/dist/tools/infrastructure/boundedWorkspace.js +9 -1
  103. package/dist/tools/infrastructure/boundedWorkspace.js.map +1 -1
  104. package/dist/tools/infrastructure/commandCheckpoint.d.ts +55 -0
  105. package/dist/tools/infrastructure/commandCheckpoint.js +178 -0
  106. package/dist/tools/infrastructure/commandCheckpoint.js.map +1 -0
  107. package/dist/tools/infrastructure/commandProcess.d.ts +47 -0
  108. package/dist/tools/infrastructure/commandProcess.js +217 -0
  109. package/dist/tools/infrastructure/commandProcess.js.map +1 -0
  110. package/dist/tools/infrastructure/commandTool.d.ts +36 -0
  111. package/dist/tools/infrastructure/commandTool.js +285 -0
  112. package/dist/tools/infrastructure/commandTool.js.map +1 -0
  113. package/dist/tools/infrastructure/createWorkspace.d.ts +10 -0
  114. package/dist/tools/infrastructure/createWorkspace.js +11 -3
  115. package/dist/tools/infrastructure/createWorkspace.js.map +1 -1
  116. package/dist/tools/infrastructure/defineTool.d.ts +18 -2
  117. package/dist/tools/infrastructure/defineTool.js +52 -6
  118. package/dist/tools/infrastructure/defineTool.js.map +1 -1
  119. package/dist/tools/infrastructure/editFormats.d.ts +22 -0
  120. package/dist/tools/infrastructure/editFormats.js +243 -0
  121. package/dist/tools/infrastructure/editFormats.js.map +1 -0
  122. package/dist/tools/infrastructure/fileEditor.d.ts +64 -0
  123. package/dist/tools/infrastructure/fileEditor.js +306 -0
  124. package/dist/tools/infrastructure/fileEditor.js.map +1 -0
  125. package/dist/tools/infrastructure/fileTools.d.ts +81 -0
  126. package/dist/tools/infrastructure/fileTools.js +152 -0
  127. package/dist/tools/infrastructure/fileTools.js.map +1 -0
  128. package/dist/tools/infrastructure/gitIgnoreRules.d.ts +15 -0
  129. package/dist/tools/infrastructure/gitIgnoreRules.js +74 -0
  130. package/dist/tools/infrastructure/gitIgnoreRules.js.map +1 -0
  131. package/dist/tools/infrastructure/gitWorkspace.d.ts +1 -1
  132. package/dist/tools/infrastructure/gitWorkspace.js +1 -1
  133. package/dist/tools/infrastructure/gitWorkspace.js.map +1 -1
  134. package/dist/tools/infrastructure/ignoreFileChanges.d.ts +14 -0
  135. package/dist/tools/infrastructure/ignoreFileChanges.js +62 -0
  136. package/dist/tools/infrastructure/ignoreFileChanges.js.map +1 -0
  137. package/dist/tools/infrastructure/policyWorkspace.d.ts +52 -0
  138. package/dist/tools/infrastructure/policyWorkspace.js +32 -0
  139. package/dist/tools/infrastructure/policyWorkspace.js.map +1 -0
  140. package/dist/tools/infrastructure/ripgrepWorkspace.d.ts +2 -1
  141. package/dist/tools/infrastructure/ripgrepWorkspace.js +1 -1
  142. package/dist/tools/infrastructure/ripgrepWorkspace.js.map +1 -1
  143. package/dist/tools/infrastructure/rootsAccessPolicy.d.ts +100 -0
  144. package/dist/tools/infrastructure/rootsAccessPolicy.js +301 -0
  145. package/dist/tools/infrastructure/rootsAccessPolicy.js.map +1 -0
  146. package/dist/tools/infrastructure/runProcess.d.ts +4 -1
  147. package/dist/tools/infrastructure/runProcess.js +4 -2
  148. package/dist/tools/infrastructure/runProcess.js.map +1 -1
  149. package/dist/tools/infrastructure/secretPaths.d.ts +8 -0
  150. package/dist/tools/infrastructure/secretPaths.js +76 -0
  151. package/dist/tools/infrastructure/secretPaths.js.map +1 -0
  152. package/dist/tools/infrastructure/showChangesTool.d.ts +12 -0
  153. package/dist/tools/infrastructure/showChangesTool.js +44 -0
  154. package/dist/tools/infrastructure/showChangesTool.js.map +1 -0
  155. package/dist/tools/infrastructure/trackedEdits.d.ts +33 -0
  156. package/dist/tools/infrastructure/trackedEdits.js +70 -0
  157. package/dist/tools/infrastructure/trackedEdits.js.map +1 -0
  158. package/dist/tools/infrastructure/trackedReadFile.d.ts +21 -0
  159. package/dist/tools/infrastructure/trackedReadFile.js +79 -0
  160. package/dist/tools/infrastructure/trackedReadFile.js.map +1 -0
  161. package/dist/tools/infrastructure/workspaceTools.d.ts +34 -0
  162. package/dist/tools/infrastructure/workspaceTools.js +69 -40
  163. package/dist/tools/infrastructure/workspaceTools.js.map +1 -1
  164. package/dist/tools/infrastructure/workspaceWrites.d.ts +23 -0
  165. package/dist/tools/infrastructure/workspaceWrites.js +42 -0
  166. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -0
  167. package/dist/tools/infrastructure/writeSession.d.ts +35 -0
  168. package/dist/tools/infrastructure/writeSession.js +81 -0
  169. package/dist/tools/infrastructure/writeSession.js.map +1 -0
  170. package/package.json +1 -1
@@ -0,0 +1,55 @@
1
+ import type { FileState, RunJournal } from '#src/recovery/domain/recoveryStore';
2
+ /**
3
+ * Every file a command's checkpoint covers now, by its real path, absolute: what `.gitignore`
4
+ * does not exclude in the `write` roots, less what the access policy closes to writing by name.
5
+ */
6
+ export type CoveredFiles = (signal: AbortSignal) => Promise<string[]>;
7
+ /** A file a command changed, as the checkpoint found it. */
8
+ export interface ObservedChange {
9
+ path: string;
10
+ before: FileState;
11
+ after: FileState;
12
+ }
13
+ /**
14
+ * The files a command may change, captured right before it runs and compared right after, so
15
+ * what it changed is in the run's history like any tool's change, and can be undone.
16
+ *
17
+ * `begin` stores the content of every covered file and a manifest of them, then records the
18
+ * observation in the journal; if any of that fails, the command must not run. `end` lists the
19
+ * covered files again, and records each one that differs from the manifest as a change made by
20
+ * the command. This first version reads and hashes everything both times, trusting neither size
21
+ * nor modification time, and keeps what that cost with the observation.
22
+ */
23
+ export declare class CommandCheckpoint {
24
+ private readonly journal;
25
+ private readonly covered;
26
+ private readonly by;
27
+ private readonly manifest;
28
+ /** Covered files it could not copy before, which it leaves out after too. */
29
+ private readonly uncopyable;
30
+ private readonly sequence;
31
+ private readonly measure;
32
+ private constructor();
33
+ /** Captures every covered file. Cancelling it, or any failure, leaves the command unstarted. */
34
+ static begin({ journal, covered, by, signal, }: {
35
+ journal: RunJournal;
36
+ covered: CoveredFiles;
37
+ /** The command, as the history will name it. */
38
+ by: string;
39
+ signal: AbortSignal;
40
+ }): Promise<CommandCheckpoint>;
41
+ /**
42
+ * Compares the covered files with the capture and records every change found. It is never
43
+ * cancelled: once the command has run, what it changed must reach the history. Unless
44
+ * `complete`, the observation stays open, because the command may still be changing files.
45
+ *
46
+ * @returns the changes, and the paths it could not record because they are now something the
47
+ * history cannot copy, such as a file the command replaced with a folder, or made too large.
48
+ */
49
+ end({ complete }: {
50
+ complete: boolean;
51
+ }): Promise<{
52
+ changes: ObservedChange[];
53
+ unrecorded: string[];
54
+ }>;
55
+ }
@@ -0,0 +1,178 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile } from 'node:fs/promises';
3
+ import { sameState } from '#src/recovery/domain/fileChanges';
4
+ // The same bound the history uses for a file it records: a larger one is not copied.
5
+ const maxCoveredBytes = 64 * 1024 * 1024;
6
+ const captureConcurrency = 16;
7
+ function code(error) {
8
+ return error.code;
9
+ }
10
+ /**
11
+ * The files a command may change, captured right before it runs and compared right after, so
12
+ * what it changed is in the run's history like any tool's change, and can be undone.
13
+ *
14
+ * `begin` stores the content of every covered file and a manifest of them, then records the
15
+ * observation in the journal; if any of that fails, the command must not run. `end` lists the
16
+ * covered files again, and records each one that differs from the manifest as a change made by
17
+ * the command. This first version reads and hashes everything both times, trusting neither size
18
+ * nor modification time, and keeps what that cost with the observation.
19
+ */
20
+ export class CommandCheckpoint {
21
+ journal;
22
+ covered;
23
+ by;
24
+ manifest;
25
+ /** Covered files it could not copy before, which it leaves out after too. */
26
+ uncopyable;
27
+ sequence;
28
+ measure;
29
+ constructor(fields) {
30
+ this.journal = fields.journal;
31
+ this.covered = fields.covered;
32
+ this.by = fields.by;
33
+ this.manifest = fields.manifest;
34
+ this.uncopyable = fields.uncopyable;
35
+ this.sequence = fields.sequence;
36
+ this.measure = fields.measure;
37
+ }
38
+ /** Captures every covered file. Cancelling it, or any failure, leaves the command unstarted. */
39
+ static async begin({ journal, covered, by, signal, }) {
40
+ const started = performance.now();
41
+ const storedBefore = journal.storedBytes;
42
+ const paths = await covered(signal);
43
+ const states = [];
44
+ const uncopyable = new Set();
45
+ let bytesRead = 0;
46
+ // Several at once: each stored content waits for its own sync, which dominates the time.
47
+ await eachAtOnce(paths, captureConcurrency, async (path, index) => {
48
+ signal.throwIfAborted();
49
+ const read = await readState(path);
50
+ // A folder, a link or a file too large: no entry records it, so it is not covered.
51
+ if (!read) {
52
+ uncopyable.add(path);
53
+ return;
54
+ }
55
+ if (read.content) {
56
+ bytesRead += read.content.length;
57
+ const hash = await journal.saveContent(read.content);
58
+ states[index] = { exists: true, hash, mode: read.mode };
59
+ }
60
+ });
61
+ const files = {};
62
+ paths.forEach((path, index) => {
63
+ const state = states[index];
64
+ if (state)
65
+ files[path] = state;
66
+ });
67
+ const manifest = { files };
68
+ const hash = await journal.saveContent(Buffer.from(JSON.stringify(manifest)));
69
+ signal.throwIfAborted();
70
+ const sequence = await journal.observe(hash, by);
71
+ return new CommandCheckpoint({
72
+ journal,
73
+ covered,
74
+ by,
75
+ manifest,
76
+ uncopyable,
77
+ sequence,
78
+ measure: { bytesRead, durationMs: performance.now() - started, storedBefore },
79
+ });
80
+ }
81
+ /**
82
+ * Compares the covered files with the capture and records every change found. It is never
83
+ * cancelled: once the command has run, what it changed must reach the history. Unless
84
+ * `complete`, the observation stays open, because the command may still be changing files.
85
+ *
86
+ * @returns the changes, and the paths it could not record because they are now something the
87
+ * history cannot copy, such as a file the command replaced with a folder, or made too large.
88
+ */
89
+ async end({ complete }) {
90
+ const started = performance.now();
91
+ const signal = new AbortController().signal;
92
+ const paths = new Set([...Object.keys(this.manifest.files), ...(await this.covered(signal))]);
93
+ const changes = [];
94
+ const unrecorded = [];
95
+ let bytesRead = this.measure.bytesRead;
96
+ for (const path of paths) {
97
+ const before = this.manifest.files[path] ?? { exists: false };
98
+ const read = await readState(path);
99
+ if (!read) {
100
+ // One it could not copy before either, such as a file over the size limit, is not
101
+ // covered at all; one it could is now something no entry records.
102
+ if (!this.uncopyable.has(path))
103
+ unrecorded.push(path);
104
+ continue;
105
+ }
106
+ let after = { exists: false };
107
+ if (read.content) {
108
+ bytesRead += read.content.length;
109
+ const hash = createHash('sha256').update(read.content).digest('hex');
110
+ after = { exists: true, hash, mode: read.mode };
111
+ }
112
+ if (sameState(before, after))
113
+ continue;
114
+ // Stored only now that it changed, so an unchanged file costs no write.
115
+ if (read.content)
116
+ await this.journal.saveContent(read.content);
117
+ const sequence = await this.journal.prepare({ path, before, after, observedBy: this.by });
118
+ await this.journal.applied(sequence);
119
+ changes.push({ path, before, after });
120
+ }
121
+ // Left open when something the command started may still be running: the record then says
122
+ // that what it changed may not all be here.
123
+ if (!complete)
124
+ return { changes, unrecorded };
125
+ await this.journal.observed(this.sequence, {
126
+ files: paths.size,
127
+ bytesRead,
128
+ bytesStored: this.journal.storedBytes - this.measure.storedBefore,
129
+ durationMs: Math.round(this.measure.durationMs + performance.now() - started),
130
+ });
131
+ return { changes, unrecorded };
132
+ }
133
+ }
134
+ /**
135
+ * What `path` holds: nothing, or a regular file's content and mode. `undefined` for anything an
136
+ * entry cannot record: a folder, a link, or a file too large to copy.
137
+ */
138
+ async function readState(path) {
139
+ let stats;
140
+ try {
141
+ stats = await lstat(path);
142
+ }
143
+ catch (error) {
144
+ if (code(error) === 'ENOENT')
145
+ return { mode: 0 };
146
+ throw error;
147
+ }
148
+ if (!stats.isFile() || stats.size > maxCoveredBytes)
149
+ return undefined;
150
+ try {
151
+ return { content: await readFile(path), mode: stats.mode & 0o7777 };
152
+ }
153
+ catch (error) {
154
+ // Deleted between the two calls.
155
+ if (code(error) === 'ENOENT')
156
+ return { mode: 0 };
157
+ throw error;
158
+ }
159
+ }
160
+ /** Runs `task` on every item, at most `limit` at a time, and fails with the first failure. */
161
+ async function eachAtOnce(items, limit, task) {
162
+ let next = 0;
163
+ let failed = false;
164
+ const worker = async () => {
165
+ while (!failed && next < items.length) {
166
+ const index = next++;
167
+ try {
168
+ await task(items[index], index);
169
+ }
170
+ catch (error) {
171
+ failed = true;
172
+ throw error;
173
+ }
174
+ }
175
+ };
176
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
177
+ }
178
+ //# sourceMappingURL=commandCheckpoint.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commandCheckpoint.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/commandCheckpoint.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,kCAAkC,CAAC;AAG7D,qFAAqF;AACrF,MAAM,eAAe,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAC;AACzC,MAAM,kBAAkB,GAAG,EAAE,CAAC;AAe9B,SAAS,IAAI,CAAC,KAAc;IAC3B,OAAQ,KAA+B,CAAC,IAAI,CAAC;AAC9C,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,OAAO,iBAAiB;IACZ,OAAO,CAAa;IACpB,OAAO,CAAe;IACtB,EAAE,CAAS;IACX,QAAQ,CAAW;IACpC,6EAA6E;IAC5D,UAAU,CAAsB;IAChC,QAAQ,CAAS;IACjB,OAAO,CAAkE;IAE1F,YAAoB,MAQnB;QACA,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;QAC9B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;QAC9B,IAAI,CAAC,EAAE,GAAG,MAAM,CAAC,EAAE,CAAC;QACpB,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;QAChC,IAAI,CAAC,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC;QACpC,IAAI,CAAC,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC;QAChC,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IAC/B,CAAC;IAED,gGAAgG;IAChG,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,EAClB,OAAO,EACP,OAAO,EACP,EAAE,EACF,MAAM,GAON;QACA,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;QAClC,MAAM,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;QACzC,MAAM,KAAK,GAAG,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;QACpC,MAAM,MAAM,GAA8B,EAAE,CAAC;QAC7C,MAAM,UAAU,GAAG,IAAI,GAAG,EAAU,CAAC;QACrC,IAAI,SAAS,GAAG,CAAC,CAAC;QAClB,yFAAyF;QACzF,MAAM,UAAU,CAAC,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE;YACjE,MAAM,CAAC,cAAc,EAAE,CAAC;YACxB,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;YACnC,mFAAmF;YACnF,IAAI,CAAC,IAAI,EAAE,CAAC;gBACX,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;gBACrB,OAAO;YACR,CAAC;YACD,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;gBAClB,SAAS,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;gBACjC,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;gBACrD,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;YACzD,CAAC;QACF,CAAC,CAAC,CAAC;QACH,MAAM,KAAK,GAAsB,EAAE,CAAC;QACpC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;YAC7B,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;YAC5B,IAAI,KAAK;gBAAE,KAAK,CAAC,IAAI,CAAC,GAAG,KAAK,CAAC;QAChC,CAAC,CAAC,CAAC;QACH,MAAM,QAAQ,GAAa,EAAE,KAAK,EAAE,CAAC;QACrC,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC;QAC9E,MAAM,CAAC,cAAc,EAAE,CAAC;QACxB,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;QACjD,OAAO,IAAI,iBAAiB,CAAC;YAC5B,OAAO;YACP,OAAO;YACP,EAAE;YACF,QAAQ;YACR,UAAU;YACV,QAAQ;YACR,OAAO,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,EAAE,YAAY,EAAE;SAC7E,CAAC,CAAC;IACJ,CAAC;IAED;;;;;;;OAOG;IACH,KAAK,CAAC,GAAG,CAAC,EAAE,QAAQ,EAAyB;QAI5C,MAAM,OAAO,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC;QAClC,MAAM,MAAM,GAAG,IAAI,eAAe,EAAE,CAAC,MAAM,CAAC;QAC5C,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,MAAM,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9F,MAAM,OAAO,GAAqB,EAAE,CAAC;QACrC,MAAM,UAAU,GAAa,EAAE,CAAC;QAChC,IAAI,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC;QAEvC,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YAC1B,MAAM,MAAM,GAAc,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;YACzE,MAAM,IAAI,GAAG,MAAM,SAAS,CAAC,IAAI,CAAC,CAAC;YACnC,IAAI,CAAC,IAAI,EAAE,CAAC;gBACX,kFAAkF;gBAClF,kEAAkE;gBAClE,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,CAAC;oBAAE,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACtD,SAAS;YACV,CAAC;YACD,IAAI,KAAK,GAAc,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC;YACzC,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;gBAClB,SAAS,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC;gBACjC,MAAM,IAAI,GAAG,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;gBACrE,KAAK,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,CAAC;YACjD,CAAC;YACD,IAAI,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC;gBAAE,SAAS;YACvC,wEAAwE;YACxE,IAAI,IAAI,CAAC,OAAO;gBAAE,MAAM,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;YAC/D,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,UAAU,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CAAC;YAC1F,MAAM,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;YACrC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;QACvC,CAAC;QAED,0FAA0F;QAC1F,4CAA4C;QAC5C,IAAI,CAAC,QAAQ;YAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC;QAC9C,MAAM,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,EAAE;YAC1C,KAAK,EAAE,KAAK,CAAC,IAAI;YACjB,SAAS;YACT,WAAW,EAAE,IAAI,CAAC,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY;YACjE,UAAU,EAAE,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,UAAU,GAAG,WAAW,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;SAC7E,CAAC,CAAC;QACH,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,CAAC;IAChC,CAAC;CACD;AAED;;;GAGG;AACH,KAAK,UAAU,SAAS,CAAC,IAAY;IACpC,IAAI,KAAK,CAAC;IACV,IAAI,CAAC;QACJ,KAAK,GAAG,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC;IAC3B,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,QAAQ;YAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,MAAM,KAAK,CAAC;IACb,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,KAAK,CAAC,IAAI,GAAG,eAAe;QAAE,OAAO,SAAS,CAAC;IACtE,IAAI,CAAC;QACJ,OAAO,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,CAAC,IAAI,GAAG,MAAM,EAAE,CAAC;IACrE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,iCAAiC;QACjC,IAAI,IAAI,CAAC,KAAK,CAAC,KAAK,QAAQ;YAAE,OAAO,EAAE,IAAI,EAAE,CAAC,EAAE,CAAC;QACjD,MAAM,KAAK,CAAC;IACb,CAAC;AACF,CAAC;AAED,8FAA8F;AAC9F,KAAK,UAAU,UAAU,CACxB,KAAsB,EACtB,KAAa,EACb,IAAkD;IAElD,IAAI,IAAI,GAAG,CAAC,CAAC;IACb,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,MAAM,MAAM,GAAG,KAAK,IAAI,EAAE;QACzB,OAAO,CAAC,MAAM,IAAI,IAAI,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;YACvC,MAAM,KAAK,GAAG,IAAI,EAAE,CAAC;YACrB,IAAI,CAAC;gBACJ,MAAM,IAAI,CAAC,KAAK,CAAC,KAAK,CAAS,EAAE,KAAK,CAAC,CAAC;YACzC,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBAChB,MAAM,GAAG,IAAI,CAAC;gBACd,MAAM,KAAK,CAAC;YACb,CAAC;QACF,CAAC;IACF,CAAC,CAAC;IACF,MAAM,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;AAClF,CAAC","sourcesContent":["import { createHash } from 'node:crypto';\nimport { lstat, readFile } from 'node:fs/promises';\nimport { sameState } from '#src/recovery/domain/fileChanges';\nimport type { FileState, Manifest, RunJournal } from '#src/recovery/domain/recoveryStore';\n\n// The same bound the history uses for a file it records: a larger one is not copied.\nconst maxCoveredBytes = 64 * 1024 * 1024;\nconst captureConcurrency = 16;\n\n/**\n * Every file a command's checkpoint covers now, by its real path, absolute: what `.gitignore`\n * does not exclude in the `write` roots, less what the access policy closes to writing by name.\n */\nexport type CoveredFiles = (signal: AbortSignal) => Promise<string[]>;\n\n/** A file a command changed, as the checkpoint found it. */\nexport interface ObservedChange {\n\tpath: string;\n\tbefore: FileState;\n\tafter: FileState;\n}\n\nfunction code(error: unknown): string | undefined {\n\treturn (error as NodeJS.ErrnoException).code;\n}\n\n/**\n * The files a command may change, captured right before it runs and compared right after, so\n * what it changed is in the run's history like any tool's change, and can be undone.\n *\n * `begin` stores the content of every covered file and a manifest of them, then records the\n * observation in the journal; if any of that fails, the command must not run. `end` lists the\n * covered files again, and records each one that differs from the manifest as a change made by\n * the command. This first version reads and hashes everything both times, trusting neither size\n * nor modification time, and keeps what that cost with the observation.\n */\nexport class CommandCheckpoint {\n\tprivate readonly journal: RunJournal;\n\tprivate readonly covered: CoveredFiles;\n\tprivate readonly by: string;\n\tprivate readonly manifest: Manifest;\n\t/** Covered files it could not copy before, which it leaves out after too. */\n\tprivate readonly uncopyable: ReadonlySet<string>;\n\tprivate readonly sequence: number;\n\tprivate readonly measure: { bytesRead: number; durationMs: number; storedBefore: number };\n\n\tprivate constructor(fields: {\n\t\tjournal: RunJournal;\n\t\tcovered: CoveredFiles;\n\t\tby: string;\n\t\tmanifest: Manifest;\n\t\tuncopyable: ReadonlySet<string>;\n\t\tsequence: number;\n\t\tmeasure: { bytesRead: number; durationMs: number; storedBefore: number };\n\t}) {\n\t\tthis.journal = fields.journal;\n\t\tthis.covered = fields.covered;\n\t\tthis.by = fields.by;\n\t\tthis.manifest = fields.manifest;\n\t\tthis.uncopyable = fields.uncopyable;\n\t\tthis.sequence = fields.sequence;\n\t\tthis.measure = fields.measure;\n\t}\n\n\t/** Captures every covered file. Cancelling it, or any failure, leaves the command unstarted. */\n\tstatic async begin({\n\t\tjournal,\n\t\tcovered,\n\t\tby,\n\t\tsignal,\n\t}: {\n\t\tjournal: RunJournal;\n\t\tcovered: CoveredFiles;\n\t\t/** The command, as the history will name it. */\n\t\tby: string;\n\t\tsignal: AbortSignal;\n\t}): Promise<CommandCheckpoint> {\n\t\tconst started = performance.now();\n\t\tconst storedBefore = journal.storedBytes;\n\t\tconst paths = await covered(signal);\n\t\tconst states: (FileState | undefined)[] = [];\n\t\tconst uncopyable = new Set<string>();\n\t\tlet bytesRead = 0;\n\t\t// Several at once: each stored content waits for its own sync, which dominates the time.\n\t\tawait eachAtOnce(paths, captureConcurrency, async (path, index) => {\n\t\t\tsignal.throwIfAborted();\n\t\t\tconst read = await readState(path);\n\t\t\t// A folder, a link or a file too large: no entry records it, so it is not covered.\n\t\t\tif (!read) {\n\t\t\t\tuncopyable.add(path);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (read.content) {\n\t\t\t\tbytesRead += read.content.length;\n\t\t\t\tconst hash = await journal.saveContent(read.content);\n\t\t\t\tstates[index] = { exists: true, hash, mode: read.mode };\n\t\t\t}\n\t\t});\n\t\tconst files: Manifest['files'] = {};\n\t\tpaths.forEach((path, index) => {\n\t\t\tconst state = states[index];\n\t\t\tif (state) files[path] = state;\n\t\t});\n\t\tconst manifest: Manifest = { files };\n\t\tconst hash = await journal.saveContent(Buffer.from(JSON.stringify(manifest)));\n\t\tsignal.throwIfAborted();\n\t\tconst sequence = await journal.observe(hash, by);\n\t\treturn new CommandCheckpoint({\n\t\t\tjournal,\n\t\t\tcovered,\n\t\t\tby,\n\t\t\tmanifest,\n\t\t\tuncopyable,\n\t\t\tsequence,\n\t\t\tmeasure: { bytesRead, durationMs: performance.now() - started, storedBefore },\n\t\t});\n\t}\n\n\t/**\n\t * Compares the covered files with the capture and records every change found. It is never\n\t * cancelled: once the command has run, what it changed must reach the history. Unless\n\t * `complete`, the observation stays open, because the command may still be changing files.\n\t *\n\t * @returns the changes, and the paths it could not record because they are now something the\n\t * history cannot copy, such as a file the command replaced with a folder, or made too large.\n\t */\n\tasync end({ complete }: { complete: boolean }): Promise<{\n\t\tchanges: ObservedChange[];\n\t\tunrecorded: string[];\n\t}> {\n\t\tconst started = performance.now();\n\t\tconst signal = new AbortController().signal;\n\t\tconst paths = new Set([...Object.keys(this.manifest.files), ...(await this.covered(signal))]);\n\t\tconst changes: ObservedChange[] = [];\n\t\tconst unrecorded: string[] = [];\n\t\tlet bytesRead = this.measure.bytesRead;\n\n\t\tfor (const path of paths) {\n\t\t\tconst before: FileState = this.manifest.files[path] ?? { exists: false };\n\t\t\tconst read = await readState(path);\n\t\t\tif (!read) {\n\t\t\t\t// One it could not copy before either, such as a file over the size limit, is not\n\t\t\t\t// covered at all; one it could is now something no entry records.\n\t\t\t\tif (!this.uncopyable.has(path)) unrecorded.push(path);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tlet after: FileState = { exists: false };\n\t\t\tif (read.content) {\n\t\t\t\tbytesRead += read.content.length;\n\t\t\t\tconst hash = createHash('sha256').update(read.content).digest('hex');\n\t\t\t\tafter = { exists: true, hash, mode: read.mode };\n\t\t\t}\n\t\t\tif (sameState(before, after)) continue;\n\t\t\t// Stored only now that it changed, so an unchanged file costs no write.\n\t\t\tif (read.content) await this.journal.saveContent(read.content);\n\t\t\tconst sequence = await this.journal.prepare({ path, before, after, observedBy: this.by });\n\t\t\tawait this.journal.applied(sequence);\n\t\t\tchanges.push({ path, before, after });\n\t\t}\n\n\t\t// Left open when something the command started may still be running: the record then says\n\t\t// that what it changed may not all be here.\n\t\tif (!complete) return { changes, unrecorded };\n\t\tawait this.journal.observed(this.sequence, {\n\t\t\tfiles: paths.size,\n\t\t\tbytesRead,\n\t\t\tbytesStored: this.journal.storedBytes - this.measure.storedBefore,\n\t\t\tdurationMs: Math.round(this.measure.durationMs + performance.now() - started),\n\t\t});\n\t\treturn { changes, unrecorded };\n\t}\n}\n\n/**\n * What `path` holds: nothing, or a regular file's content and mode. `undefined` for anything an\n * entry cannot record: a folder, a link, or a file too large to copy.\n */\nasync function readState(path: string): Promise<{ content?: Buffer; mode: number } | undefined> {\n\tlet stats;\n\ttry {\n\t\tstats = await lstat(path);\n\t} catch (error) {\n\t\tif (code(error) === 'ENOENT') return { mode: 0 };\n\t\tthrow error;\n\t}\n\tif (!stats.isFile() || stats.size > maxCoveredBytes) return undefined;\n\ttry {\n\t\treturn { content: await readFile(path), mode: stats.mode & 0o7777 };\n\t} catch (error) {\n\t\t// Deleted between the two calls.\n\t\tif (code(error) === 'ENOENT') return { mode: 0 };\n\t\tthrow error;\n\t}\n}\n\n/** Runs `task` on every item, at most `limit` at a time, and fails with the first failure. */\nasync function eachAtOnce<Item>(\n\titems: readonly Item[],\n\tlimit: number,\n\ttask: (item: Item, index: number) => Promise<void>,\n): Promise<void> {\n\tlet next = 0;\n\tlet failed = false;\n\tconst worker = async () => {\n\t\twhile (!failed && next < items.length) {\n\t\t\tconst index = next++;\n\t\t\ttry {\n\t\t\t\tawait task(items[index] as Item, index);\n\t\t\t} catch (error) {\n\t\t\t\tfailed = true;\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t}\n\t};\n\tawait Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));\n}\n"]}
@@ -0,0 +1,47 @@
1
+ /**
2
+ * How a command ended:
3
+ * - `exited`: it ended by itself, with `exitCode`, or killed by `signal` from outside.
4
+ * - `timedOut`: it ran past its time and was stopped.
5
+ * - `cancelled`: the run was cancelled while it ran, and it was stopped.
6
+ */
7
+ export interface CommandProcessResult {
8
+ outcome: 'exited' | 'timedOut' | 'cancelled';
9
+ exitCode: number | null;
10
+ signal: NodeJS.Signals | null;
11
+ /** The end of what it printed, standard output and error together, as they arrived. */
12
+ output: string;
13
+ /** Everything it printed, of which `output` is the end. */
14
+ printedBytes: number;
15
+ durationMs: number;
16
+ /**
17
+ * Every process of its group is gone. When false, something it started may still be
18
+ * changing files, so nothing observed after it can be taken as its final effect.
19
+ */
20
+ stopped: boolean;
21
+ }
22
+ /**
23
+ * Runs a configured command in a process group of its own, never through a shell, and returns
24
+ * once that group is gone. A timeout or a cancellation stops the whole group: first asked with
25
+ * `SIGTERM`, then killed. A command that ends by itself has whatever it left running in its
26
+ * group stopped too, so nothing it started outlives it. A process that leaves the group, as a
27
+ * daemon does, is out of reach; the docs say so.
28
+ *
29
+ * Only for POSIX: Windows has no process groups to stop.
30
+ *
31
+ * @throws {Error} when the program cannot be started, such as when it is not found; nothing ran.
32
+ */
33
+ export declare function runCommandProcess(executable: string, args: string[], { cwd, env, timeoutMs, outputBytes, signal, gracePeriodMs, killWaitMs, recheckMs, }: {
34
+ cwd: string;
35
+ env: NodeJS.ProcessEnv;
36
+ timeoutMs: number;
37
+ outputBytes: number;
38
+ signal: AbortSignal;
39
+ /** How long the group gets to stop on `SIGTERM` before it is killed. Defaults to 5 s. */
40
+ gracePeriodMs?: number;
41
+ /** How long a killed group gets to disappear before it counts as unconfirmed. 5 s. */
42
+ killWaitMs?: number;
43
+ /** How often an unconfirmed group is asked about again. Defaults to 1 s. */
44
+ recheckMs?: number;
45
+ }): Promise<CommandProcessResult>;
46
+ /** The groups of the commands still running, for tests. */
47
+ export declare function liveGroupIds(): number[];
@@ -0,0 +1,217 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ // How long a group asked to stop gets before it is killed, and how long a kill gets to take.
4
+ const defaultGracePeriodMs = 5_000;
5
+ const defaultKillWaitMs = 5_000;
6
+ const pollMs = 25;
7
+ // Output still in the pipes once every process is gone arrives at once; a process that left
8
+ // the group and holds a pipe open must not hold up the result.
9
+ const drainMs = 1_000;
10
+ /**
11
+ * Runs a configured command in a process group of its own, never through a shell, and returns
12
+ * once that group is gone. A timeout or a cancellation stops the whole group: first asked with
13
+ * `SIGTERM`, then killed. A command that ends by itself has whatever it left running in its
14
+ * group stopped too, so nothing it started outlives it. A process that leaves the group, as a
15
+ * daemon does, is out of reach; the docs say so.
16
+ *
17
+ * Only for POSIX: Windows has no process groups to stop.
18
+ *
19
+ * @throws {Error} when the program cannot be started, such as when it is not found; nothing ran.
20
+ */
21
+ export function runCommandProcess(executable, args, { cwd, env, timeoutMs, outputBytes, signal, gracePeriodMs = defaultGracePeriodMs, killWaitMs = defaultKillWaitMs, recheckMs = defaultRecheckMs, }) {
22
+ // A rejection, not a throw: a caller awaiting the result must see it as one.
23
+ if (signal.aborted)
24
+ return Promise.reject(signal.reason);
25
+ const started = performance.now();
26
+ return new Promise((resolve, reject) => {
27
+ // `detached` makes it the leader of a new process group, whose id is its pid.
28
+ const child = spawn(executable, args, {
29
+ cwd,
30
+ env,
31
+ detached: true,
32
+ stdio: ['ignore', 'pipe', 'pipe'],
33
+ });
34
+ if (child.pid !== undefined)
35
+ watchForExit(child.pid);
36
+ const tail = new OutputTail(outputBytes);
37
+ let outcome = 'exited';
38
+ let stopping;
39
+ let exit;
40
+ const streamsClosed = Promise.all([child.stdout, child.stderr].map(stream => new Promise(done => stream.once('close', () => {
41
+ done();
42
+ }))));
43
+ child.stdout.on('data', (chunk) => {
44
+ tail.push(chunk);
45
+ });
46
+ child.stderr.on('data', (chunk) => {
47
+ tail.push(chunk);
48
+ });
49
+ const stop = (why) => {
50
+ // The first reason stands: a timeout during a cancellation's grace period is still a
51
+ // cancellation, and must end the run as one.
52
+ if (exit || stopping || child.pid === undefined)
53
+ return;
54
+ outcome = why;
55
+ detach();
56
+ stopping = stopGroup(child.pid, { gracePeriodMs, killWaitMs });
57
+ // A leader that outlives even the kill never exits, so the result cannot wait for it.
58
+ void stopping.then(gone => {
59
+ if (!gone)
60
+ void finish(false);
61
+ });
62
+ };
63
+ const timer = setTimeout(() => {
64
+ stop('timedOut');
65
+ }, timeoutMs);
66
+ const onAbort = () => {
67
+ stop('cancelled');
68
+ };
69
+ signal.addEventListener('abort', onAbort, { once: true });
70
+ const detach = () => {
71
+ clearTimeout(timer);
72
+ signal.removeEventListener('abort', onAbort);
73
+ };
74
+ let finished = false;
75
+ const finish = async (stopped) => {
76
+ if (finished)
77
+ return;
78
+ finished = true;
79
+ detach();
80
+ if (child.pid !== undefined) {
81
+ if (stopped)
82
+ liveGroups.delete(child.pid);
83
+ else
84
+ forgetWhenGone(child.pid, recheckMs);
85
+ }
86
+ await Promise.race([streamsClosed, delay(drainMs)]);
87
+ child.stdout.destroy();
88
+ child.stderr.destroy();
89
+ resolve({
90
+ outcome,
91
+ exitCode: exit?.code ?? null,
92
+ signal: exit?.signal ?? null,
93
+ output: tail.text(),
94
+ printedBytes: tail.total,
95
+ durationMs: Math.round(performance.now() - started),
96
+ stopped,
97
+ });
98
+ };
99
+ child.once('error', error => {
100
+ // Only a program that never started: nothing here signals the child through Node.
101
+ if (child.pid !== undefined)
102
+ return;
103
+ detach();
104
+ reject(error);
105
+ });
106
+ child.once('exit', (code, exitSignal) => {
107
+ exit = { code, signal: exitSignal };
108
+ detach();
109
+ const pid = child.pid;
110
+ // What it left in its group is stopped as well; an empty group is gone at once.
111
+ void (stopping ??
112
+ (pid === undefined ? Promise.resolve(true) : stopGroup(pid, { gracePeriodMs, killWaitMs }))).then(finish);
113
+ });
114
+ });
115
+ }
116
+ /**
117
+ * The groups of the commands still running. If this process exits while one runs, as on an
118
+ * uncaught exception, each is killed on the way out: nothing would be left to stop it or
119
+ * record what it changed. A process killed by a signal gets no such chance, and its commands
120
+ * then run on until they end.
121
+ */
122
+ const liveGroups = new Set();
123
+ let exitHookInstalled = false;
124
+ const defaultRecheckMs = 1_000;
125
+ /** The groups of the commands still running, for tests. */
126
+ export function liveGroupIds() {
127
+ return [...liveGroups];
128
+ }
129
+ /**
130
+ * Keeps an unconfirmed group in `liveGroups` until the system says it is gone, then forgets
131
+ * it: once the group is empty its id can be given to another, which the exit hook must never
132
+ * kill. It asks every second, so a reuse in between is the only gap; nothing short of a pidfd
133
+ * closes it.
134
+ */
135
+ function forgetWhenGone(pgid, intervalMs) {
136
+ const timer = setInterval(() => {
137
+ if (signalGroup(pgid, 0))
138
+ return;
139
+ liveGroups.delete(pgid);
140
+ clearInterval(timer);
141
+ }, intervalMs);
142
+ // It must not keep this process alive on its own.
143
+ timer.unref();
144
+ }
145
+ function watchForExit(pgid) {
146
+ liveGroups.add(pgid);
147
+ if (exitHookInstalled)
148
+ return;
149
+ exitHookInstalled = true;
150
+ process.once('exit', () => {
151
+ for (const group of liveGroups)
152
+ signalGroup(group, 'SIGKILL');
153
+ });
154
+ }
155
+ /**
156
+ * Stops every process in the group `pgid` and waits until none is left. Whether it is gone is
157
+ * asked of the system, never assumed from a signal being sent.
158
+ *
159
+ * @returns whether the group is confirmed gone.
160
+ */
161
+ async function stopGroup(pgid, { gracePeriodMs, killWaitMs }) {
162
+ if (!signalGroup(pgid, 'SIGTERM'))
163
+ return true;
164
+ if (await goneWithin(pgid, gracePeriodMs))
165
+ return true;
166
+ if (!signalGroup(pgid, 'SIGKILL'))
167
+ return true;
168
+ return goneWithin(pgid, killWaitMs);
169
+ }
170
+ /** Sends `signal` to the group; false when the group no longer exists. */
171
+ function signalGroup(pgid, signal) {
172
+ try {
173
+ process.kill(-pgid, signal);
174
+ return true;
175
+ }
176
+ catch (error) {
177
+ if (error.code === 'ESRCH')
178
+ return false;
179
+ // EPERM: a process of the group exists, but this one may not signal it.
180
+ return true;
181
+ }
182
+ }
183
+ async function goneWithin(pgid, ms) {
184
+ const deadline = performance.now() + ms;
185
+ while (performance.now() < deadline) {
186
+ if (!signalGroup(pgid, 0))
187
+ return true;
188
+ await delay(pollMs);
189
+ }
190
+ return !signalGroup(pgid, 0);
191
+ }
192
+ /** Keeps only the last `limit` bytes of what is pushed, and counts all of it. */
193
+ class OutputTail {
194
+ limit;
195
+ chunks = [];
196
+ kept = 0;
197
+ total = 0;
198
+ constructor(limit) {
199
+ this.limit = limit;
200
+ }
201
+ push(chunk) {
202
+ this.total += chunk.length;
203
+ this.chunks.push(chunk);
204
+ this.kept += chunk.length;
205
+ while (this.chunks.length > 1 && this.kept - (this.chunks[0]?.length ?? 0) >= this.limit) {
206
+ this.kept -= this.chunks.shift()?.length ?? 0;
207
+ }
208
+ }
209
+ text() {
210
+ const all = Buffer.concat(this.chunks);
211
+ const cut = all.length > this.limit;
212
+ const text = (cut ? all.subarray(all.length - this.limit) : all).toString('utf8');
213
+ // A cut can land inside a character, which decodes as replacement characters.
214
+ return cut ? text.replace(/^�+/u, '') : text;
215
+ }
216
+ }
217
+ //# sourceMappingURL=commandProcess.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"commandProcess.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/commandProcess.ts"],"names":[],"mappings":"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","sourcesContent":["import { spawn } from 'node:child_process';\nimport { setTimeout as delay } from 'node:timers/promises';\n\n// How long a group asked to stop gets before it is killed, and how long a kill gets to take.\nconst defaultGracePeriodMs = 5_000;\nconst defaultKillWaitMs = 5_000;\nconst pollMs = 25;\n// Output still in the pipes once every process is gone arrives at once; a process that left\n// the group and holds a pipe open must not hold up the result.\nconst drainMs = 1_000;\n\n/**\n * How a command ended:\n * - `exited`: it ended by itself, with `exitCode`, or killed by `signal` from outside.\n * - `timedOut`: it ran past its time and was stopped.\n * - `cancelled`: the run was cancelled while it ran, and it was stopped.\n */\nexport interface CommandProcessResult {\n\toutcome: 'exited' | 'timedOut' | 'cancelled';\n\texitCode: number | null;\n\tsignal: NodeJS.Signals | null;\n\t/** The end of what it printed, standard output and error together, as they arrived. */\n\toutput: string;\n\t/** Everything it printed, of which `output` is the end. */\n\tprintedBytes: number;\n\tdurationMs: number;\n\t/**\n\t * Every process of its group is gone. When false, something it started may still be\n\t * changing files, so nothing observed after it can be taken as its final effect.\n\t */\n\tstopped: boolean;\n}\n\n/**\n * Runs a configured command in a process group of its own, never through a shell, and returns\n * once that group is gone. A timeout or a cancellation stops the whole group: first asked with\n * `SIGTERM`, then killed. A command that ends by itself has whatever it left running in its\n * group stopped too, so nothing it started outlives it. A process that leaves the group, as a\n * daemon does, is out of reach; the docs say so.\n *\n * Only for POSIX: Windows has no process groups to stop.\n *\n * @throws {Error} when the program cannot be started, such as when it is not found; nothing ran.\n */\nexport function runCommandProcess(\n\texecutable: string,\n\targs: string[],\n\t{\n\t\tcwd,\n\t\tenv,\n\t\ttimeoutMs,\n\t\toutputBytes,\n\t\tsignal,\n\t\tgracePeriodMs = defaultGracePeriodMs,\n\t\tkillWaitMs = defaultKillWaitMs,\n\t\trecheckMs = defaultRecheckMs,\n\t}: {\n\t\tcwd: string;\n\t\tenv: NodeJS.ProcessEnv;\n\t\ttimeoutMs: number;\n\t\toutputBytes: number;\n\t\tsignal: AbortSignal;\n\t\t/** How long the group gets to stop on `SIGTERM` before it is killed. Defaults to 5 s. */\n\t\tgracePeriodMs?: number;\n\t\t/** How long a killed group gets to disappear before it counts as unconfirmed. 5 s. */\n\t\tkillWaitMs?: number;\n\t\t/** How often an unconfirmed group is asked about again. Defaults to 1 s. */\n\t\trecheckMs?: number;\n\t},\n): Promise<CommandProcessResult> {\n\t// A rejection, not a throw: a caller awaiting the result must see it as one.\n\tif (signal.aborted) return Promise.reject(signal.reason as Error);\n\tconst started = performance.now();\n\n\treturn new Promise((resolve, reject) => {\n\t\t// `detached` makes it the leader of a new process group, whose id is its pid.\n\t\tconst child = spawn(executable, args, {\n\t\t\tcwd,\n\t\t\tenv,\n\t\t\tdetached: true,\n\t\t\tstdio: ['ignore', 'pipe', 'pipe'],\n\t\t});\n\t\tif (child.pid !== undefined) watchForExit(child.pid);\n\t\tconst tail = new OutputTail(outputBytes);\n\t\tlet outcome: CommandProcessResult['outcome'] = 'exited';\n\t\tlet stopping: Promise<boolean> | undefined;\n\t\tlet exit: { code: number | null; signal: NodeJS.Signals | null } | undefined;\n\n\t\tconst streamsClosed = Promise.all(\n\t\t\t[child.stdout, child.stderr].map(\n\t\t\t\tstream =>\n\t\t\t\t\tnew Promise<void>(done =>\n\t\t\t\t\t\tstream.once('close', () => {\n\t\t\t\t\t\t\tdone();\n\t\t\t\t\t\t}),\n\t\t\t\t\t),\n\t\t\t),\n\t\t);\n\t\tchild.stdout.on('data', (chunk: Buffer) => {\n\t\t\ttail.push(chunk);\n\t\t});\n\t\tchild.stderr.on('data', (chunk: Buffer) => {\n\t\t\ttail.push(chunk);\n\t\t});\n\n\t\tconst stop = (why: 'timedOut' | 'cancelled'): void => {\n\t\t\t// The first reason stands: a timeout during a cancellation's grace period is still a\n\t\t\t// cancellation, and must end the run as one.\n\t\t\tif (exit || stopping || child.pid === undefined) return;\n\t\t\toutcome = why;\n\t\t\tdetach();\n\t\t\tstopping = stopGroup(child.pid, { gracePeriodMs, killWaitMs });\n\t\t\t// A leader that outlives even the kill never exits, so the result cannot wait for it.\n\t\t\tvoid stopping.then(gone => {\n\t\t\t\tif (!gone) void finish(false);\n\t\t\t});\n\t\t};\n\t\tconst timer = setTimeout(() => {\n\t\t\tstop('timedOut');\n\t\t}, timeoutMs);\n\t\tconst onAbort = () => {\n\t\t\tstop('cancelled');\n\t\t};\n\t\tsignal.addEventListener('abort', onAbort, { once: true });\n\t\tconst detach = () => {\n\t\t\tclearTimeout(timer);\n\t\t\tsignal.removeEventListener('abort', onAbort);\n\t\t};\n\n\t\tlet finished = false;\n\t\tconst finish = async (stopped: boolean) => {\n\t\t\tif (finished) return;\n\t\t\tfinished = true;\n\t\t\tdetach();\n\t\t\tif (child.pid !== undefined) {\n\t\t\t\tif (stopped) liveGroups.delete(child.pid);\n\t\t\t\telse forgetWhenGone(child.pid, recheckMs);\n\t\t\t}\n\t\t\tawait Promise.race([streamsClosed, delay(drainMs)]);\n\t\t\tchild.stdout.destroy();\n\t\t\tchild.stderr.destroy();\n\t\t\tresolve({\n\t\t\t\toutcome,\n\t\t\t\texitCode: exit?.code ?? null,\n\t\t\t\tsignal: exit?.signal ?? null,\n\t\t\t\toutput: tail.text(),\n\t\t\t\tprintedBytes: tail.total,\n\t\t\t\tdurationMs: Math.round(performance.now() - started),\n\t\t\t\tstopped,\n\t\t\t});\n\t\t};\n\n\t\tchild.once('error', error => {\n\t\t\t// Only a program that never started: nothing here signals the child through Node.\n\t\t\tif (child.pid !== undefined) return;\n\t\t\tdetach();\n\t\t\treject(error);\n\t\t});\n\t\tchild.once('exit', (code, exitSignal) => {\n\t\t\texit = { code, signal: exitSignal };\n\t\t\tdetach();\n\t\t\tconst pid = child.pid;\n\t\t\t// What it left in its group is stopped as well; an empty group is gone at once.\n\t\t\tvoid (\n\t\t\t\tstopping ??\n\t\t\t\t(pid === undefined ? Promise.resolve(true) : stopGroup(pid, { gracePeriodMs, killWaitMs }))\n\t\t\t).then(finish);\n\t\t});\n\t});\n}\n\n/**\n * The groups of the commands still running. If this process exits while one runs, as on an\n * uncaught exception, each is killed on the way out: nothing would be left to stop it or\n * record what it changed. A process killed by a signal gets no such chance, and its commands\n * then run on until they end.\n */\nconst liveGroups = new Set<number>();\nlet exitHookInstalled = false;\n\nconst defaultRecheckMs = 1_000;\n\n/** The groups of the commands still running, for tests. */\nexport function liveGroupIds(): number[] {\n\treturn [...liveGroups];\n}\n\n/**\n * Keeps an unconfirmed group in `liveGroups` until the system says it is gone, then forgets\n * it: once the group is empty its id can be given to another, which the exit hook must never\n * kill. It asks every second, so a reuse in between is the only gap; nothing short of a pidfd\n * closes it.\n */\nfunction forgetWhenGone(pgid: number, intervalMs: number): void {\n\tconst timer = setInterval(() => {\n\t\tif (signalGroup(pgid, 0)) return;\n\t\tliveGroups.delete(pgid);\n\t\tclearInterval(timer);\n\t}, intervalMs);\n\t// It must not keep this process alive on its own.\n\ttimer.unref();\n}\n\nfunction watchForExit(pgid: number): void {\n\tliveGroups.add(pgid);\n\tif (exitHookInstalled) return;\n\texitHookInstalled = true;\n\tprocess.once('exit', () => {\n\t\tfor (const group of liveGroups) signalGroup(group, 'SIGKILL');\n\t});\n}\n\n/**\n * Stops every process in the group `pgid` and waits until none is left. Whether it is gone is\n * asked of the system, never assumed from a signal being sent.\n *\n * @returns whether the group is confirmed gone.\n */\nasync function stopGroup(\n\tpgid: number,\n\t{ gracePeriodMs, killWaitMs }: { gracePeriodMs: number; killWaitMs: number },\n): Promise<boolean> {\n\tif (!signalGroup(pgid, 'SIGTERM')) return true;\n\tif (await goneWithin(pgid, gracePeriodMs)) return true;\n\tif (!signalGroup(pgid, 'SIGKILL')) return true;\n\treturn goneWithin(pgid, killWaitMs);\n}\n\n/** Sends `signal` to the group; false when the group no longer exists. */\nfunction signalGroup(pgid: number, signal: NodeJS.Signals | 0): boolean {\n\ttry {\n\t\tprocess.kill(-pgid, signal);\n\t\treturn true;\n\t} catch (error) {\n\t\tif ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;\n\t\t// EPERM: a process of the group exists, but this one may not signal it.\n\t\treturn true;\n\t}\n}\n\nasync function goneWithin(pgid: number, ms: number): Promise<boolean> {\n\tconst deadline = performance.now() + ms;\n\twhile (performance.now() < deadline) {\n\t\tif (!signalGroup(pgid, 0)) return true;\n\t\tawait delay(pollMs);\n\t}\n\treturn !signalGroup(pgid, 0);\n}\n\n/** Keeps only the last `limit` bytes of what is pushed, and counts all of it. */\nclass OutputTail {\n\tprivate readonly limit: number;\n\tprivate chunks: Buffer[] = [];\n\tprivate kept = 0;\n\ttotal = 0;\n\n\tconstructor(limit: number) {\n\t\tthis.limit = limit;\n\t}\n\n\tpush(chunk: Buffer): void {\n\t\tthis.total += chunk.length;\n\t\tthis.chunks.push(chunk);\n\t\tthis.kept += chunk.length;\n\t\twhile (this.chunks.length > 1 && this.kept - (this.chunks[0]?.length ?? 0) >= this.limit) {\n\t\t\tthis.kept -= this.chunks.shift()?.length ?? 0;\n\t\t}\n\t}\n\n\ttext(): string {\n\t\tconst all = Buffer.concat(this.chunks);\n\t\tconst cut = all.length > this.limit;\n\t\tconst text = (cut ? all.subarray(all.length - this.limit) : all).toString('utf8');\n\t\t// A cut can land inside a character, which decodes as replacement characters.\n\t\treturn cut ? text.replace(/^�+/u, '') : text;\n\t}\n}\n"]}
@@ -0,0 +1,36 @@
1
+ import type { AccessPolicy } from '../domain/accessPolicy.ts';
2
+ import { type CommandDefinition, type Commands } from '../domain/commands.ts';
3
+ import { type PreparingTool } from '../domain/preparedCall.ts';
4
+ import { type CoveredFiles } from './commandCheckpoint.ts';
5
+ import { runCommandProcess } from './commandProcess.ts';
6
+ import type { WorkspaceWrites } from './workspaceWrites.ts';
7
+ /**
8
+ * `runCommand`, which runs one of the commands the consumer configured, by name, with the
9
+ * parameters each one declares. Every call asks the approver, showing the program exactly as
10
+ * it will run; `autoApprove` skips that.
11
+ *
12
+ * Around each command, the covered files are captured before and compared after, so what it
13
+ * changed is in the run's history and can be undone: see `CommandCheckpoint`. If the capture
14
+ * fails, the command does not run. A timeout or a cancellation stops its whole process group,
15
+ * and is never reported as a completed command. If the group cannot be confirmed stopped, what
16
+ * changed so far is recorded, the observation stays open, and writing stops for the run.
17
+ *
18
+ * This is not a sandbox: a command runs the repository's code, with everything this user can
19
+ * reach. See "Configured commands are trusted, not sandboxed" in decisions.md.
20
+ *
21
+ * @throws {InvalidAgentConfigError} for an invalid configuration, a working directory outside
22
+ * every `write` root, or on Windows, where a process group cannot be stopped.
23
+ */
24
+ export declare function createCommandTool({ commands, policy, writes, covered, base, platform, runProcess, }: {
25
+ commands: Commands;
26
+ policy: AccessPolicy;
27
+ writes: WorkspaceWrites;
28
+ covered: CoveredFiles;
29
+ /** The real path of the first root, where a relative `cwd` starts. */
30
+ base: string;
31
+ platform?: NodeJS.Platform;
32
+ /** Runs the program. Replaced in tests, to reach what a real process rarely does. */
33
+ runProcess?: typeof runCommandProcess;
34
+ }): Promise<PreparingTool>;
35
+ /** A small environment of its own: the inherited few, then what the consumer set. */
36
+ export declare function environmentFor(definition: CommandDefinition, env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;