@mikode13/harness 4.0.1 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +278 -16
- package/dist/agent/domain/agent.d.ts +12 -0
- package/dist/agent/domain/agent.js.map +1 -1
- package/dist/agent/domain/approval.d.ts +9 -0
- package/dist/agent/domain/approval.js.map +1 -1
- package/dist/agent/domain/runContext.d.ts +31 -0
- package/dist/agent/domain/runContext.js +47 -0
- package/dist/agent/domain/runContext.js.map +1 -0
- package/dist/diff/domain/unifiedDiff.d.ts +19 -0
- package/dist/diff/domain/unifiedDiff.js +166 -0
- package/dist/diff/domain/unifiedDiff.js.map +1 -0
- package/dist/engines/domain/model/historyFollower.d.ts +41 -0
- package/dist/engines/domain/model/historyFollower.js +158 -0
- package/dist/engines/domain/model/historyFollower.js.map +1 -0
- package/dist/engines/domain/model/llmAgent.d.ts +42 -4
- package/dist/engines/domain/model/llmAgent.js +223 -23
- package/dist/engines/domain/model/llmAgent.js.map +1 -1
- package/dist/factory/infrastructure/agentFactory.d.ts +14 -0
- package/dist/factory/infrastructure/agentFactory.js +20 -2
- package/dist/factory/infrastructure/agentFactory.js.map +1 -1
- package/dist/factory/infrastructure/agentLLMFactory.d.ts +119 -14
- package/dist/factory/infrastructure/agentLLMFactory.js +284 -57
- package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/llm/domain/conversation.d.ts +44 -4
- package/dist/llm/domain/conversation.js +56 -7
- package/dist/llm/domain/conversation.js.map +1 -1
- package/dist/llm/domain/llm.d.ts +3 -1
- package/dist/llm/domain/llm.js.map +1 -1
- package/dist/llm/domain/tool.d.ts +13 -0
- package/dist/llm/domain/tool.js +12 -1
- package/dist/llm/domain/tool.js.map +1 -1
- package/dist/llm/infrastructure/claudeLLMClient.d.ts +3 -2
- package/dist/llm/infrastructure/claudeLLMClient.js +38 -10
- package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
- package/dist/llm/infrastructure/openAILLMClient.d.ts +2 -1
- package/dist/llm/infrastructure/openAILLMClient.js +66 -16
- package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
- package/dist/orchestration/domain/model/orchestratorAgent.d.ts +11 -2
- package/dist/orchestration/domain/model/orchestratorAgent.js +43 -4
- package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
- package/dist/recovery/domain/fileChanges.d.ts +31 -0
- package/dist/recovery/domain/fileChanges.js +48 -0
- package/dist/recovery/domain/fileChanges.js.map +1 -0
- package/dist/recovery/domain/history.d.ts +114 -0
- package/dist/recovery/domain/history.js +62 -0
- package/dist/recovery/domain/history.js.map +1 -0
- package/dist/recovery/domain/recoveryStore.d.ts +233 -0
- package/dist/recovery/domain/recoveryStore.js +32 -0
- package/dist/recovery/domain/recoveryStore.js.map +1 -0
- package/dist/recovery/domain/retention.d.ts +33 -0
- package/dist/recovery/domain/retention.js +90 -0
- package/dist/recovery/domain/retention.js.map +1 -0
- package/dist/recovery/domain/runChanges.d.ts +37 -0
- package/dist/recovery/domain/runChanges.js +109 -0
- package/dist/recovery/domain/runChanges.js.map +1 -0
- package/dist/recovery/domain/runTree.d.ts +32 -0
- package/dist/recovery/domain/runTree.js +90 -0
- package/dist/recovery/domain/runTree.js.map +1 -0
- package/dist/recovery/infrastructure/createHistory.d.ts +11 -0
- package/dist/recovery/infrastructure/createHistory.js +17 -0
- package/dist/recovery/infrastructure/createHistory.js.map +1 -0
- package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +133 -0
- package/dist/recovery/infrastructure/fileRecoveryStore.js +1043 -0
- package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -0
- package/dist/recovery/infrastructure/workspaceFiles.d.ts +30 -0
- package/dist/recovery/infrastructure/workspaceFiles.js +125 -0
- package/dist/recovery/infrastructure/workspaceFiles.js.map +1 -0
- package/dist/tools/domain/accessPolicy.d.ts +30 -0
- package/dist/tools/domain/accessPolicy.js +11 -0
- package/dist/tools/domain/accessPolicy.js.map +1 -0
- package/dist/tools/domain/commands.d.ts +93 -0
- package/dist/tools/domain/commands.js +191 -0
- package/dist/tools/domain/commands.js.map +1 -0
- package/dist/tools/domain/editResponse.d.ts +14 -0
- package/dist/tools/domain/editResponse.js +79 -0
- package/dist/tools/domain/editResponse.js.map +1 -0
- package/dist/tools/domain/fileEdits.d.ts +64 -0
- package/dist/tools/domain/fileEdits.js +21 -0
- package/dist/tools/domain/fileEdits.js.map +1 -0
- package/dist/tools/domain/lineLength.d.ts +7 -0
- package/dist/tools/domain/lineLength.js +26 -0
- package/dist/tools/domain/lineLength.js.map +1 -0
- package/dist/tools/domain/preparedCall.d.ts +31 -0
- package/dist/tools/domain/preparedCall.js +14 -0
- package/dist/tools/domain/preparedCall.js.map +1 -0
- package/dist/tools/domain/readRegistry.d.ts +46 -0
- package/dist/tools/domain/readRegistry.js +62 -0
- package/dist/tools/domain/readRegistry.js.map +1 -0
- package/dist/tools/domain/textLines.d.ts +26 -0
- package/dist/tools/domain/textLines.js +44 -0
- package/dist/tools/domain/textLines.js.map +1 -0
- package/dist/tools/domain/v4aPatch.d.ts +17 -0
- package/dist/tools/domain/v4aPatch.js +236 -0
- package/dist/tools/domain/v4aPatch.js.map +1 -0
- package/dist/tools/infrastructure/boundedWorkspace.d.ts +11 -2
- package/dist/tools/infrastructure/boundedWorkspace.js +9 -1
- package/dist/tools/infrastructure/boundedWorkspace.js.map +1 -1
- package/dist/tools/infrastructure/commandCheckpoint.d.ts +55 -0
- package/dist/tools/infrastructure/commandCheckpoint.js +178 -0
- package/dist/tools/infrastructure/commandCheckpoint.js.map +1 -0
- package/dist/tools/infrastructure/commandProcess.d.ts +47 -0
- package/dist/tools/infrastructure/commandProcess.js +217 -0
- package/dist/tools/infrastructure/commandProcess.js.map +1 -0
- package/dist/tools/infrastructure/commandTool.d.ts +36 -0
- package/dist/tools/infrastructure/commandTool.js +254 -0
- package/dist/tools/infrastructure/commandTool.js.map +1 -0
- package/dist/tools/infrastructure/createWorkspace.d.ts +10 -0
- package/dist/tools/infrastructure/createWorkspace.js +11 -3
- package/dist/tools/infrastructure/createWorkspace.js.map +1 -1
- package/dist/tools/infrastructure/defineTool.d.ts +14 -0
- package/dist/tools/infrastructure/defineTool.js +28 -5
- package/dist/tools/infrastructure/defineTool.js.map +1 -1
- package/dist/tools/infrastructure/editFormats.d.ts +22 -0
- package/dist/tools/infrastructure/editFormats.js +211 -0
- package/dist/tools/infrastructure/editFormats.js.map +1 -0
- package/dist/tools/infrastructure/fileEditor.d.ts +64 -0
- package/dist/tools/infrastructure/fileEditor.js +306 -0
- package/dist/tools/infrastructure/fileEditor.js.map +1 -0
- package/dist/tools/infrastructure/fileTools.d.ts +81 -0
- package/dist/tools/infrastructure/fileTools.js +152 -0
- package/dist/tools/infrastructure/fileTools.js.map +1 -0
- package/dist/tools/infrastructure/gitIgnoreRules.d.ts +15 -0
- package/dist/tools/infrastructure/gitIgnoreRules.js +74 -0
- package/dist/tools/infrastructure/gitIgnoreRules.js.map +1 -0
- package/dist/tools/infrastructure/gitWorkspace.d.ts +1 -1
- package/dist/tools/infrastructure/gitWorkspace.js +1 -1
- package/dist/tools/infrastructure/gitWorkspace.js.map +1 -1
- package/dist/tools/infrastructure/ignoreFileChanges.d.ts +14 -0
- package/dist/tools/infrastructure/ignoreFileChanges.js +62 -0
- package/dist/tools/infrastructure/ignoreFileChanges.js.map +1 -0
- package/dist/tools/infrastructure/policyWorkspace.d.ts +52 -0
- package/dist/tools/infrastructure/policyWorkspace.js +32 -0
- package/dist/tools/infrastructure/policyWorkspace.js.map +1 -0
- package/dist/tools/infrastructure/ripgrepWorkspace.d.ts +2 -1
- package/dist/tools/infrastructure/ripgrepWorkspace.js +1 -1
- package/dist/tools/infrastructure/ripgrepWorkspace.js.map +1 -1
- package/dist/tools/infrastructure/rootsAccessPolicy.d.ts +100 -0
- package/dist/tools/infrastructure/rootsAccessPolicy.js +301 -0
- package/dist/tools/infrastructure/rootsAccessPolicy.js.map +1 -0
- package/dist/tools/infrastructure/runProcess.d.ts +4 -1
- package/dist/tools/infrastructure/runProcess.js +4 -2
- package/dist/tools/infrastructure/runProcess.js.map +1 -1
- package/dist/tools/infrastructure/secretPaths.d.ts +8 -0
- package/dist/tools/infrastructure/secretPaths.js +76 -0
- package/dist/tools/infrastructure/secretPaths.js.map +1 -0
- package/dist/tools/infrastructure/showChangesTool.d.ts +12 -0
- package/dist/tools/infrastructure/showChangesTool.js +43 -0
- package/dist/tools/infrastructure/showChangesTool.js.map +1 -0
- package/dist/tools/infrastructure/trackedEdits.d.ts +33 -0
- package/dist/tools/infrastructure/trackedEdits.js +70 -0
- package/dist/tools/infrastructure/trackedEdits.js.map +1 -0
- package/dist/tools/infrastructure/trackedReadFile.d.ts +21 -0
- package/dist/tools/infrastructure/trackedReadFile.js +78 -0
- package/dist/tools/infrastructure/trackedReadFile.js.map +1 -0
- package/dist/tools/infrastructure/workspaceTools.d.ts +28 -0
- package/dist/tools/infrastructure/workspaceTools.js +53 -40
- package/dist/tools/infrastructure/workspaceTools.js.map +1 -1
- package/dist/tools/infrastructure/workspaceWrites.d.ts +23 -0
- package/dist/tools/infrastructure/workspaceWrites.js +42 -0
- package/dist/tools/infrastructure/workspaceWrites.js.map +1 -0
- package/dist/tools/infrastructure/writeSession.d.ts +35 -0
- package/dist/tools/infrastructure/writeSession.js +81 -0
- package/dist/tools/infrastructure/writeSession.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { asNative } from '#src/llm/domain/tool';
|
|
4
|
+
import { EditRefusedError } from "../domain/fileEdits.js";
|
|
5
|
+
import { addedLine, joinLines, splitLines, textsOf } from "../domain/textLines.js";
|
|
6
|
+
import { applyUpdate, createdContent } from "../domain/v4aPatch.js";
|
|
7
|
+
import { definePreparingTool } from "./defineTool.js";
|
|
8
|
+
import { prepareTrackedEdit } from "./trackedEdits.js";
|
|
9
|
+
import { readTracked } from "./trackedReadFile.js";
|
|
10
|
+
import { readRange } from "./workspaceTools.js";
|
|
11
|
+
// A folder the editor views is listed, as its reference implementation does, up to this many.
|
|
12
|
+
const maxListedFiles = 200;
|
|
13
|
+
/**
|
|
14
|
+
* A native tool: its provider gives the model the schema, so the one here only says what the
|
|
15
|
+
* calls hold, and each call is checked against `input` before anything is prepared.
|
|
16
|
+
*/
|
|
17
|
+
function nativeTool({ name, description, native, input, prepare, }) {
|
|
18
|
+
return asNative({
|
|
19
|
+
name,
|
|
20
|
+
description,
|
|
21
|
+
inputSchema: { type: 'object' },
|
|
22
|
+
prepare: (raw, signal, call) => {
|
|
23
|
+
const parsed = input.safeParse(raw);
|
|
24
|
+
if (!parsed.success)
|
|
25
|
+
return Promise.reject(new Error(z.prettifyError(parsed.error)));
|
|
26
|
+
return prepare(parsed.data, signal, call);
|
|
27
|
+
},
|
|
28
|
+
}, native);
|
|
29
|
+
}
|
|
30
|
+
const patchOperation = z.discriminatedUnion('type', [
|
|
31
|
+
z.object({ type: z.literal('create_file'), path: z.string(), diff: z.string() }),
|
|
32
|
+
z.object({ type: z.literal('update_file'), path: z.string(), diff: z.string() }),
|
|
33
|
+
z.object({ type: z.literal('delete_file'), path: z.string() }),
|
|
34
|
+
]);
|
|
35
|
+
/**
|
|
36
|
+
* OpenAI's native `apply_patch`: each call is one operation on one file, `create_file` and
|
|
37
|
+
* `update_file` with a V4A diff. It cannot read, so the agent also has `readFile`.
|
|
38
|
+
*/
|
|
39
|
+
export function createApplyPatchTool(writes) {
|
|
40
|
+
return nativeTool({
|
|
41
|
+
name: 'apply_patch',
|
|
42
|
+
description: 'Creates, changes or deletes a file with a V4A patch.',
|
|
43
|
+
native: 'applyPatch',
|
|
44
|
+
input: patchOperation,
|
|
45
|
+
prepare: (operation, signal, call) => {
|
|
46
|
+
const { path } = operation;
|
|
47
|
+
switch (operation.type) {
|
|
48
|
+
case 'create_file':
|
|
49
|
+
return prepareTrackedEdit(writes, { kind: 'create', path, content: Buffer.from(createdContent(operation.diff, path)) }, signal, call);
|
|
50
|
+
case 'update_file':
|
|
51
|
+
return prepareTrackedEdit(writes, {
|
|
52
|
+
kind: 'modify',
|
|
53
|
+
path,
|
|
54
|
+
change: current => Buffer.from(applyUpdate(current.toString('utf8'), operation.diff, path)),
|
|
55
|
+
}, signal, call);
|
|
56
|
+
case 'delete_file':
|
|
57
|
+
return prepareTrackedEdit(writes, { kind: 'delete', path }, signal, call);
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
const editorCommand = z.discriminatedUnion('command', [
|
|
63
|
+
z.object({
|
|
64
|
+
command: z.literal('view'),
|
|
65
|
+
path: z.string(),
|
|
66
|
+
view_range: z.tuple([z.number().int(), z.number().int()]).optional(),
|
|
67
|
+
}),
|
|
68
|
+
z.object({
|
|
69
|
+
command: z.literal('str_replace'),
|
|
70
|
+
path: z.string(),
|
|
71
|
+
old_str: z.string(),
|
|
72
|
+
new_str: z.string().optional(),
|
|
73
|
+
}),
|
|
74
|
+
z.object({ command: z.literal('create'), path: z.string(), file_text: z.string() }),
|
|
75
|
+
z.object({
|
|
76
|
+
command: z.literal('insert'),
|
|
77
|
+
path: z.string(),
|
|
78
|
+
insert_line: z.number().int(),
|
|
79
|
+
insert_text: z.string(),
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
/** How many times `text` appears in `source`, without overlaps. */
|
|
83
|
+
function occurrences(source, text) {
|
|
84
|
+
return source.split(text).length - 1;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Replaces the one place `oldText` appears. The model writes `\n`: when the text as sent is not
|
|
88
|
+
* in the file, it is tried with CRLF too, so a CRLF file can be edited, and written with CRLF.
|
|
89
|
+
*/
|
|
90
|
+
function replaceOnce(source, oldText, newText, path) {
|
|
91
|
+
if (oldText === '') {
|
|
92
|
+
throw new EditRefusedError(`old_str is empty; copy the text to replace from "${path}"`);
|
|
93
|
+
}
|
|
94
|
+
let [from, to] = [oldText, newText];
|
|
95
|
+
let count = occurrences(source, from);
|
|
96
|
+
if (count === 0 && !oldText.includes('\r\n') && oldText.includes('\n')) {
|
|
97
|
+
[from, to] = [oldText.replaceAll('\n', '\r\n'), newText.replaceAll('\n', '\r\n')];
|
|
98
|
+
count = occurrences(source, from);
|
|
99
|
+
}
|
|
100
|
+
if (count === 0) {
|
|
101
|
+
throw new EditRefusedError(`old_str is not in "${path}"; view the file and copy the text exactly, whitespace included`);
|
|
102
|
+
}
|
|
103
|
+
if (count > 1) {
|
|
104
|
+
throw new EditRefusedError(`old_str appears ${String(count)} times in "${path}"; include more of the lines around it so it appears once`);
|
|
105
|
+
}
|
|
106
|
+
const at = source.indexOf(from);
|
|
107
|
+
// New lines end like the line where the text sits, so a CRLF line gains no bare `\n`.
|
|
108
|
+
// When the text ends with its line's newline, that newline says how the line ends.
|
|
109
|
+
const ending = endingAt(source, from.endsWith('\n') ? at + from.length - 1 : at + from.length);
|
|
110
|
+
const written = to.replace(/(?<!\r)\n/g, ending);
|
|
111
|
+
return source.slice(0, at) + written + source.slice(at + from.length);
|
|
112
|
+
}
|
|
113
|
+
/** The ending of the line holding `index`, or the text's usual one for a last line without. */
|
|
114
|
+
function endingAt(source, index) {
|
|
115
|
+
const newline = source.indexOf('\n', index);
|
|
116
|
+
if (newline === -1)
|
|
117
|
+
return splitLines(source).eol;
|
|
118
|
+
return source[newline - 1] === '\r' ? '\r\n' : '\n';
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Puts `text` after line `after` of `source`, 0 being before the first. The lines inserted end
|
|
122
|
+
* like the line before them, and every other line is written back as it was.
|
|
123
|
+
*/
|
|
124
|
+
function insertAfter(source, after, text, path) {
|
|
125
|
+
const file = splitLines(source);
|
|
126
|
+
if (after < 0 || after > file.lines.length) {
|
|
127
|
+
throw new EditRefusedError(`insert_line must be between 0 and ${String(file.lines.length)}, the number of lines in "${path}"`);
|
|
128
|
+
}
|
|
129
|
+
const texts = textsOf(splitLines(text).lines);
|
|
130
|
+
const inserted = [];
|
|
131
|
+
for (const line of texts.length > 0 ? texts : ['']) {
|
|
132
|
+
inserted.push(addedLine(line, inserted.at(-1) ?? file.lines[after - 1], file.eol));
|
|
133
|
+
}
|
|
134
|
+
const lines = [...file.lines];
|
|
135
|
+
lines.splice(after, 0, ...inserted);
|
|
136
|
+
return joinLines({ ...file, lines, finalNewline: file.finalNewline || file.lines.length === 0 });
|
|
137
|
+
}
|
|
138
|
+
/** The files under a folder, as the editor shows a folder it views. */
|
|
139
|
+
async function listFolder(workspace, path, signal) {
|
|
140
|
+
const { files, total, truncated } = await workspace.listFiles({ path, limit: maxListedFiles }, signal);
|
|
141
|
+
if (files.length === 0)
|
|
142
|
+
return 'No files.';
|
|
143
|
+
const notice = truncated
|
|
144
|
+
? `\n[Showing ${String(files.length)} of ${String(total)} files. View a folder inside it to see the rest.]`
|
|
145
|
+
: '';
|
|
146
|
+
return files.join('\n') + notice;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Claude's native text editor, `str_replace_based_edit_tool`. `view` reads through the access
|
|
150
|
+
* policy and counts as a read, so it replaces `readFile`; on a folder it lists the files in it.
|
|
151
|
+
* `create` makes only a file that does not exist, and the editor has no command to delete:
|
|
152
|
+
* Claude gets `delete_file` for that.
|
|
153
|
+
*/
|
|
154
|
+
export function createTextEditorTool({ writes, policy, workspace, }) {
|
|
155
|
+
return nativeTool({
|
|
156
|
+
name: 'str_replace_based_edit_tool',
|
|
157
|
+
description: 'Views, creates and changes files.',
|
|
158
|
+
native: 'textEditor',
|
|
159
|
+
input: editorCommand,
|
|
160
|
+
prepare: async (command, signal, call) => {
|
|
161
|
+
const { path } = command;
|
|
162
|
+
switch (command.command) {
|
|
163
|
+
case 'view': {
|
|
164
|
+
const [start, end] = command.view_range ?? [1, -1];
|
|
165
|
+
if (start < 1) {
|
|
166
|
+
throw new Error(`view_range starts at line ${String(start)}; lines count from 1`);
|
|
167
|
+
}
|
|
168
|
+
if (end !== -1 && end < start) {
|
|
169
|
+
throw new Error(`view_range ends before it starts: [${String(start)}, ${String(end)}]`);
|
|
170
|
+
}
|
|
171
|
+
const { from, count } = readRange({
|
|
172
|
+
fromLine: start,
|
|
173
|
+
lineCount: end === -1 ? null : end - start + 1,
|
|
174
|
+
});
|
|
175
|
+
const target = await policy.check(path, 'read', signal);
|
|
176
|
+
const isFolder = await lstat(target.absolute).then(stats => stats.isDirectory(), () => false);
|
|
177
|
+
return {
|
|
178
|
+
risk: 'safe',
|
|
179
|
+
run: runSignal => isFolder
|
|
180
|
+
? listFolder(workspace, path, runSignal)
|
|
181
|
+
: readTracked({ target, path, from, count, reads: call.reads, signal: runSignal }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
case 'str_replace':
|
|
185
|
+
return prepareTrackedEdit(writes, {
|
|
186
|
+
kind: 'modify',
|
|
187
|
+
path,
|
|
188
|
+
change: current => Buffer.from(replaceOnce(current.toString('utf8'), command.old_str, command.new_str ?? '', path)),
|
|
189
|
+
}, signal, call);
|
|
190
|
+
case 'create':
|
|
191
|
+
return prepareTrackedEdit(writes, { kind: 'create', path, content: Buffer.from(command.file_text) }, signal, call);
|
|
192
|
+
case 'insert':
|
|
193
|
+
return prepareTrackedEdit(writes, {
|
|
194
|
+
kind: 'modify',
|
|
195
|
+
path,
|
|
196
|
+
change: current => Buffer.from(insertAfter(current.toString('utf8'), command.insert_line, command.insert_text, path)),
|
|
197
|
+
}, signal, call);
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/** The delete Claude's text editor has no command for. */
|
|
203
|
+
export function createDeleteFileTool(writes) {
|
|
204
|
+
return definePreparingTool({
|
|
205
|
+
name: 'delete_file',
|
|
206
|
+
description: 'Deletes a file. Read it first.',
|
|
207
|
+
input: z.object({ path: z.string().describe('The file, relative to the root.') }),
|
|
208
|
+
prepare: ({ path }, signal, call) => prepareTrackedEdit(writes, { kind: 'delete', path }, signal, call),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
//# sourceMappingURL=editFormats.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"editFormats.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/editFormats.ts"],"names":[],"mappings":"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","sourcesContent":["import { lstat } from 'node:fs/promises';\nimport { z } from 'zod';\nimport { asNative, type NativeTool } from '#src/llm/domain/tool';\nimport type { AccessPolicy } from '../domain/accessPolicy.ts';\nimport { EditRefusedError } from '../domain/fileEdits.ts';\nimport type { PreparedCall, PreparingTool } from '../domain/preparedCall.ts';\nimport type { ToolCallContext } from '../domain/readRegistry.ts';\nimport { addedLine, joinLines, type Line, splitLines, textsOf } from '../domain/textLines.ts';\nimport { applyUpdate, createdContent } from '../domain/v4aPatch.ts';\nimport type { Workspace } from '../domain/workspace.ts';\nimport { definePreparingTool } from './defineTool.ts';\nimport { prepareTrackedEdit } from './trackedEdits.ts';\nimport { readTracked } from './trackedReadFile.ts';\nimport { readRange } from './workspaceTools.ts';\nimport type { WorkspaceWrites } from './workspaceWrites.ts';\n\n// A folder the editor views is listed, as its reference implementation does, up to this many.\nconst maxListedFiles = 200;\n\n/**\n * A native tool: its provider gives the model the schema, so the one here only says what the\n * calls hold, and each call is checked against `input` before anything is prepared.\n */\nfunction nativeTool<Schema extends z.ZodType>({\n\tname,\n\tdescription,\n\tnative,\n\tinput,\n\tprepare,\n}: {\n\tname: string;\n\tdescription: string;\n\tnative: NativeTool;\n\tinput: Schema;\n\tprepare: (\n\t\tinput: z.infer<Schema>,\n\t\tsignal: AbortSignal,\n\t\tcall: ToolCallContext,\n\t) => Promise<PreparedCall>;\n}): PreparingTool {\n\treturn asNative(\n\t\t{\n\t\t\tname,\n\t\t\tdescription,\n\t\t\tinputSchema: { type: 'object' },\n\t\t\tprepare: (raw: unknown, signal: AbortSignal, call: ToolCallContext) => {\n\t\t\t\tconst parsed = input.safeParse(raw);\n\t\t\t\tif (!parsed.success) return Promise.reject(new Error(z.prettifyError(parsed.error)));\n\t\t\t\treturn prepare(parsed.data, signal, call);\n\t\t\t},\n\t\t},\n\t\tnative,\n\t);\n}\n\nconst patchOperation = z.discriminatedUnion('type', [\n\tz.object({ type: z.literal('create_file'), path: z.string(), diff: z.string() }),\n\tz.object({ type: z.literal('update_file'), path: z.string(), diff: z.string() }),\n\tz.object({ type: z.literal('delete_file'), path: z.string() }),\n]);\n\n/**\n * OpenAI's native `apply_patch`: each call is one operation on one file, `create_file` and\n * `update_file` with a V4A diff. It cannot read, so the agent also has `readFile`.\n */\nexport function createApplyPatchTool(writes: WorkspaceWrites): PreparingTool {\n\treturn nativeTool({\n\t\tname: 'apply_patch',\n\t\tdescription: 'Creates, changes or deletes a file with a V4A patch.',\n\t\tnative: 'applyPatch',\n\t\tinput: patchOperation,\n\t\tprepare: (operation, signal, call) => {\n\t\t\tconst { path } = operation;\n\t\t\tswitch (operation.type) {\n\t\t\t\tcase 'create_file':\n\t\t\t\t\treturn prepareTrackedEdit(\n\t\t\t\t\t\twrites,\n\t\t\t\t\t\t{ kind: 'create', path, content: Buffer.from(createdContent(operation.diff, path)) },\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\tcall,\n\t\t\t\t\t);\n\t\t\t\tcase 'update_file':\n\t\t\t\t\treturn prepareTrackedEdit(\n\t\t\t\t\t\twrites,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkind: 'modify',\n\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\tchange: current =>\n\t\t\t\t\t\t\t\tBuffer.from(applyUpdate(current.toString('utf8'), operation.diff, path)),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\tcall,\n\t\t\t\t\t);\n\t\t\t\tcase 'delete_file':\n\t\t\t\t\treturn prepareTrackedEdit(writes, { kind: 'delete', path }, signal, call);\n\t\t\t}\n\t\t},\n\t});\n}\n\nconst editorCommand = z.discriminatedUnion('command', [\n\tz.object({\n\t\tcommand: z.literal('view'),\n\t\tpath: z.string(),\n\t\tview_range: z.tuple([z.number().int(), z.number().int()]).optional(),\n\t}),\n\tz.object({\n\t\tcommand: z.literal('str_replace'),\n\t\tpath: z.string(),\n\t\told_str: z.string(),\n\t\tnew_str: z.string().optional(),\n\t}),\n\tz.object({ command: z.literal('create'), path: z.string(), file_text: z.string() }),\n\tz.object({\n\t\tcommand: z.literal('insert'),\n\t\tpath: z.string(),\n\t\tinsert_line: z.number().int(),\n\t\tinsert_text: z.string(),\n\t}),\n]);\n\n/** How many times `text` appears in `source`, without overlaps. */\nfunction occurrences(source: string, text: string): number {\n\treturn source.split(text).length - 1;\n}\n\n/**\n * Replaces the one place `oldText` appears. The model writes `\\n`: when the text as sent is not\n * in the file, it is tried with CRLF too, so a CRLF file can be edited, and written with CRLF.\n */\nfunction replaceOnce(source: string, oldText: string, newText: string, path: string): string {\n\tif (oldText === '') {\n\t\tthrow new EditRefusedError(`old_str is empty; copy the text to replace from \"${path}\"`);\n\t}\n\tlet [from, to] = [oldText, newText];\n\tlet count = occurrences(source, from);\n\tif (count === 0 && !oldText.includes('\\r\\n') && oldText.includes('\\n')) {\n\t\t[from, to] = [oldText.replaceAll('\\n', '\\r\\n'), newText.replaceAll('\\n', '\\r\\n')];\n\t\tcount = occurrences(source, from);\n\t}\n\tif (count === 0) {\n\t\tthrow new EditRefusedError(\n\t\t\t`old_str is not in \"${path}\"; view the file and copy the text exactly, whitespace included`,\n\t\t);\n\t}\n\tif (count > 1) {\n\t\tthrow new EditRefusedError(\n\t\t\t`old_str appears ${String(count)} times in \"${path}\"; include more of the lines around it so it appears once`,\n\t\t);\n\t}\n\tconst at = source.indexOf(from);\n\t// New lines end like the line where the text sits, so a CRLF line gains no bare `\\n`.\n\t// When the text ends with its line's newline, that newline says how the line ends.\n\tconst ending = endingAt(source, from.endsWith('\\n') ? at + from.length - 1 : at + from.length);\n\tconst written = to.replace(/(?<!\\r)\\n/g, ending);\n\treturn source.slice(0, at) + written + source.slice(at + from.length);\n}\n\n/** The ending of the line holding `index`, or the text's usual one for a last line without. */\nfunction endingAt(source: string, index: number): '\\n' | '\\r\\n' {\n\tconst newline = source.indexOf('\\n', index);\n\tif (newline === -1) return splitLines(source).eol;\n\treturn source[newline - 1] === '\\r' ? '\\r\\n' : '\\n';\n}\n\n/**\n * Puts `text` after line `after` of `source`, 0 being before the first. The lines inserted end\n * like the line before them, and every other line is written back as it was.\n */\nfunction insertAfter(source: string, after: number, text: string, path: string): string {\n\tconst file = splitLines(source);\n\tif (after < 0 || after > file.lines.length) {\n\t\tthrow new EditRefusedError(\n\t\t\t`insert_line must be between 0 and ${String(file.lines.length)}, the number of lines in \"${path}\"`,\n\t\t);\n\t}\n\tconst texts = textsOf(splitLines(text).lines);\n\tconst inserted: Line[] = [];\n\tfor (const line of texts.length > 0 ? texts : ['']) {\n\t\tinserted.push(addedLine(line, inserted.at(-1) ?? file.lines[after - 1], file.eol));\n\t}\n\tconst lines = [...file.lines];\n\tlines.splice(after, 0, ...inserted);\n\treturn joinLines({ ...file, lines, finalNewline: file.finalNewline || file.lines.length === 0 });\n}\n\n/** The files under a folder, as the editor shows a folder it views. */\nasync function listFolder(\n\tworkspace: Workspace,\n\tpath: string,\n\tsignal: AbortSignal,\n): Promise<string> {\n\tconst { files, total, truncated } = await workspace.listFiles(\n\t\t{ path, limit: maxListedFiles },\n\t\tsignal,\n\t);\n\tif (files.length === 0) return 'No files.';\n\tconst notice = truncated\n\t\t? `\\n[Showing ${String(files.length)} of ${String(total)} files. View a folder inside it to see the rest.]`\n\t\t: '';\n\treturn files.join('\\n') + notice;\n}\n\n/**\n * Claude's native text editor, `str_replace_based_edit_tool`. `view` reads through the access\n * policy and counts as a read, so it replaces `readFile`; on a folder it lists the files in it.\n * `create` makes only a file that does not exist, and the editor has no command to delete:\n * Claude gets `delete_file` for that.\n */\nexport function createTextEditorTool({\n\twrites,\n\tpolicy,\n\tworkspace,\n}: {\n\twrites: WorkspaceWrites;\n\tpolicy: AccessPolicy;\n\tworkspace: Workspace;\n}): PreparingTool {\n\treturn nativeTool({\n\t\tname: 'str_replace_based_edit_tool',\n\t\tdescription: 'Views, creates and changes files.',\n\t\tnative: 'textEditor',\n\t\tinput: editorCommand,\n\t\tprepare: async (command, signal, call) => {\n\t\t\tconst { path } = command;\n\t\t\tswitch (command.command) {\n\t\t\t\tcase 'view': {\n\t\t\t\t\tconst [start, end] = command.view_range ?? [1, -1];\n\t\t\t\t\tif (start < 1) {\n\t\t\t\t\t\tthrow new Error(`view_range starts at line ${String(start)}; lines count from 1`);\n\t\t\t\t\t}\n\t\t\t\t\tif (end !== -1 && end < start) {\n\t\t\t\t\t\tthrow new Error(`view_range ends before it starts: [${String(start)}, ${String(end)}]`);\n\t\t\t\t\t}\n\t\t\t\t\tconst { from, count } = readRange({\n\t\t\t\t\t\tfromLine: start,\n\t\t\t\t\t\tlineCount: end === -1 ? null : end - start + 1,\n\t\t\t\t\t});\n\t\t\t\t\tconst target = await policy.check(path, 'read', signal);\n\t\t\t\t\tconst isFolder = await lstat(target.absolute).then(\n\t\t\t\t\t\tstats => stats.isDirectory(),\n\t\t\t\t\t\t() => false,\n\t\t\t\t\t);\n\t\t\t\t\treturn {\n\t\t\t\t\t\trisk: 'safe',\n\t\t\t\t\t\trun: runSignal =>\n\t\t\t\t\t\t\tisFolder\n\t\t\t\t\t\t\t\t? listFolder(workspace, path, runSignal)\n\t\t\t\t\t\t\t\t: readTracked({ target, path, from, count, reads: call.reads, signal: runSignal }),\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\tcase 'str_replace':\n\t\t\t\t\treturn prepareTrackedEdit(\n\t\t\t\t\t\twrites,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkind: 'modify',\n\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\tchange: current =>\n\t\t\t\t\t\t\t\tBuffer.from(\n\t\t\t\t\t\t\t\t\treplaceOnce(\n\t\t\t\t\t\t\t\t\t\tcurrent.toString('utf8'),\n\t\t\t\t\t\t\t\t\t\tcommand.old_str,\n\t\t\t\t\t\t\t\t\t\tcommand.new_str ?? '',\n\t\t\t\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\tcall,\n\t\t\t\t\t);\n\t\t\t\tcase 'create':\n\t\t\t\t\treturn prepareTrackedEdit(\n\t\t\t\t\t\twrites,\n\t\t\t\t\t\t{ kind: 'create', path, content: Buffer.from(command.file_text) },\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\tcall,\n\t\t\t\t\t);\n\t\t\t\tcase 'insert':\n\t\t\t\t\treturn prepareTrackedEdit(\n\t\t\t\t\t\twrites,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tkind: 'modify',\n\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\tchange: current =>\n\t\t\t\t\t\t\t\tBuffer.from(\n\t\t\t\t\t\t\t\t\tinsertAfter(\n\t\t\t\t\t\t\t\t\t\tcurrent.toString('utf8'),\n\t\t\t\t\t\t\t\t\t\tcommand.insert_line,\n\t\t\t\t\t\t\t\t\t\tcommand.insert_text,\n\t\t\t\t\t\t\t\t\t\tpath,\n\t\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t\t\t),\n\t\t\t\t\t\t},\n\t\t\t\t\t\tsignal,\n\t\t\t\t\t\tcall,\n\t\t\t\t\t);\n\t\t\t}\n\t\t},\n\t});\n}\n\n/** The delete Claude's text editor has no command for. */\nexport function createDeleteFileTool(writes: WorkspaceWrites): PreparingTool {\n\treturn definePreparingTool({\n\t\tname: 'delete_file',\n\t\tdescription: 'Deletes a file. Read it first.',\n\t\tinput: z.object({ path: z.string().describe('The file, relative to the root.') }),\n\t\tprepare: ({ path }, signal, call) =>\n\t\t\tprepareTrackedEdit(writes, { kind: 'delete', path }, signal, call),\n\t});\n}\n"]}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { FileState } from '#src/recovery/domain/recoveryStore';
|
|
2
|
+
import type { ToolRisk } from '#src/agent/domain/approval';
|
|
3
|
+
import type { AccessPolicy, AllowedPath } from '../domain/accessPolicy.ts';
|
|
4
|
+
import { type FileChange, type PreparedEdit } from '../domain/fileEdits.ts';
|
|
5
|
+
import type { WriteSession } from './writeSession.ts';
|
|
6
|
+
/**
|
|
7
|
+
* The risk of a prepared change. A change git or the run's record can undo is `mutating`; one
|
|
8
|
+
* that widens `.gitignore` is `destructive`, so it waits for the user, because it would open
|
|
9
|
+
* paths that every tool keeps closed.
|
|
10
|
+
*/
|
|
11
|
+
export declare function editRisk(prepared: PreparedEdit): ToolRisk;
|
|
12
|
+
/**
|
|
13
|
+
* Makes the changes the edit tools ask for, under the access policy and the run's recovery
|
|
14
|
+
* journal. `prepare` checks a change and fixes what it will do; `apply` makes it, in this order:
|
|
15
|
+
*
|
|
16
|
+
* 1. Check the policy again, and that the file still holds what was prepared.
|
|
17
|
+
* 2. Store the content it replaces and the content it writes, and record the change as
|
|
18
|
+
* prepared. If either fails, nothing is touched.
|
|
19
|
+
* 3. Write: a new file appears whole and only if nothing took its place meanwhile; a replaced
|
|
20
|
+
* one is swapped for its new version in one rename; a deleted one is unlinked.
|
|
21
|
+
* 4. Record the change as applied, or as abandoned when the write failed and left the file as
|
|
22
|
+
* it was. A change the record cannot confirm stops the session and throws
|
|
23
|
+
* `EditUnconfirmedError`, never a success or a refusal.
|
|
24
|
+
*
|
|
25
|
+
* Every message names the file as the model wrote it, never by its place on the host.
|
|
26
|
+
*/
|
|
27
|
+
export declare class FileEditor {
|
|
28
|
+
private readonly policy;
|
|
29
|
+
private readonly session;
|
|
30
|
+
private readonly maxFileBytes;
|
|
31
|
+
constructor({ policy, session, maxFileBytes, }: {
|
|
32
|
+
policy: AccessPolicy;
|
|
33
|
+
session: WriteSession;
|
|
34
|
+
maxFileBytes?: number;
|
|
35
|
+
});
|
|
36
|
+
/**
|
|
37
|
+
* What `path` holds now, checked as a change to it would be: an edit format works its new
|
|
38
|
+
* content out from this, and an edit tool compares it with the version the model read.
|
|
39
|
+
*/
|
|
40
|
+
current(path: string, signal: AbortSignal): Promise<{
|
|
41
|
+
target: AllowedPath;
|
|
42
|
+
state: FileState;
|
|
43
|
+
content?: Buffer;
|
|
44
|
+
}>;
|
|
45
|
+
prepare(change: FileChange, signal: AbortSignal): Promise<PreparedEdit>;
|
|
46
|
+
apply(prepared: PreparedEdit, signal: AbortSignal): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Records a change that was made. If that fails, its prepared line still holds both states, so
|
|
49
|
+
* an undo can tell; but the record cannot be trusted to continue, and the caller must know.
|
|
50
|
+
*/
|
|
51
|
+
private markApplied;
|
|
52
|
+
/** What `path` holds now. Only a regular file within the size limit can be changed. */
|
|
53
|
+
private read;
|
|
54
|
+
/** Stores what the change replaces and what it writes, and returns the state it leaves. */
|
|
55
|
+
private record;
|
|
56
|
+
private journal;
|
|
57
|
+
private write;
|
|
58
|
+
/**
|
|
59
|
+
* A write threw. If the file still holds what it held, nothing happened and the change is
|
|
60
|
+
* abandoned; if it holds the new state, the change happened anyway. Anything else cannot be
|
|
61
|
+
* explained, so the session stops writing.
|
|
62
|
+
*/
|
|
63
|
+
private settleFailedWrite;
|
|
64
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmod, link, lstat, mkdir, mkdtemp, open, readFile, rename, rm, rmdir, stat, unlink, } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { EditRefusedError, EditUnconfirmedError, } from "../domain/fileEdits.js";
|
|
6
|
+
import { isIgnoreFile, widensIgnoreRules } from "./ignoreFileChanges.js";
|
|
7
|
+
// Larger than any source file a model should rewrite whole, and small enough to keep a copy.
|
|
8
|
+
const defaultMaxFileBytes = 5 * 1024 * 1024;
|
|
9
|
+
function hashOf(content) {
|
|
10
|
+
return createHash('sha256').update(content).digest('hex');
|
|
11
|
+
}
|
|
12
|
+
function code(error) {
|
|
13
|
+
return error.code ?? 'an unknown error';
|
|
14
|
+
}
|
|
15
|
+
function sameState(a, b) {
|
|
16
|
+
if (!a.exists || !b.exists)
|
|
17
|
+
return a.exists === b.exists;
|
|
18
|
+
return a.hash === b.hash && a.mode === b.mode;
|
|
19
|
+
}
|
|
20
|
+
async function syncDirectory(path) {
|
|
21
|
+
const directory = await open(path, 'r');
|
|
22
|
+
try {
|
|
23
|
+
await directory.sync();
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
await directory.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let newFileMode;
|
|
30
|
+
/**
|
|
31
|
+
* The mode a new file gets in this process: 0o666 less the umask. Measured once by creating
|
|
32
|
+
* one, because reading the umask directly briefly changes it for every thread.
|
|
33
|
+
*/
|
|
34
|
+
function getNewFileMode() {
|
|
35
|
+
newFileMode ??= (async () => {
|
|
36
|
+
const folder = await mkdtemp(join(tmpdir(), 'mikode-harness-mode-'));
|
|
37
|
+
try {
|
|
38
|
+
const probe = join(folder, 'probe');
|
|
39
|
+
await (await open(probe, 'wx', 0o666)).close();
|
|
40
|
+
return (await stat(probe)).mode & 0o7777;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await rm(folder, { recursive: true, force: true });
|
|
44
|
+
}
|
|
45
|
+
})();
|
|
46
|
+
return newFileMode;
|
|
47
|
+
}
|
|
48
|
+
/** The folders between the nearest existing one and `folder`, outermost first. */
|
|
49
|
+
async function missingFolders(folder) {
|
|
50
|
+
const missing = [];
|
|
51
|
+
for (let current = folder;; current = dirname(current)) {
|
|
52
|
+
const exists = await lstat(current).then(() => true, (error) => {
|
|
53
|
+
if (code(error) === 'ENOENT')
|
|
54
|
+
return false;
|
|
55
|
+
throw error;
|
|
56
|
+
});
|
|
57
|
+
if (exists || dirname(current) === current)
|
|
58
|
+
return missing;
|
|
59
|
+
missing.unshift(current);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The risk of a prepared change. A change git or the run's record can undo is `mutating`; one
|
|
64
|
+
* that widens `.gitignore` is `destructive`, so it waits for the user, because it would open
|
|
65
|
+
* paths that every tool keeps closed.
|
|
66
|
+
*/
|
|
67
|
+
export function editRisk(prepared) {
|
|
68
|
+
return prepared.widensIgnoreRules ? 'destructive' : 'mutating';
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Makes the changes the edit tools ask for, under the access policy and the run's recovery
|
|
72
|
+
* journal. `prepare` checks a change and fixes what it will do; `apply` makes it, in this order:
|
|
73
|
+
*
|
|
74
|
+
* 1. Check the policy again, and that the file still holds what was prepared.
|
|
75
|
+
* 2. Store the content it replaces and the content it writes, and record the change as
|
|
76
|
+
* prepared. If either fails, nothing is touched.
|
|
77
|
+
* 3. Write: a new file appears whole and only if nothing took its place meanwhile; a replaced
|
|
78
|
+
* one is swapped for its new version in one rename; a deleted one is unlinked.
|
|
79
|
+
* 4. Record the change as applied, or as abandoned when the write failed and left the file as
|
|
80
|
+
* it was. A change the record cannot confirm stops the session and throws
|
|
81
|
+
* `EditUnconfirmedError`, never a success or a refusal.
|
|
82
|
+
*
|
|
83
|
+
* Every message names the file as the model wrote it, never by its place on the host.
|
|
84
|
+
*/
|
|
85
|
+
export class FileEditor {
|
|
86
|
+
policy;
|
|
87
|
+
session;
|
|
88
|
+
maxFileBytes;
|
|
89
|
+
constructor({ policy, session, maxFileBytes = defaultMaxFileBytes, }) {
|
|
90
|
+
this.policy = policy;
|
|
91
|
+
this.session = session;
|
|
92
|
+
this.maxFileBytes = maxFileBytes;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* What `path` holds now, checked as a change to it would be: an edit format works its new
|
|
96
|
+
* content out from this, and an edit tool compares it with the version the model read.
|
|
97
|
+
*/
|
|
98
|
+
async current(path, signal) {
|
|
99
|
+
const target = await this.policy.check(path, 'write', signal);
|
|
100
|
+
return { target, ...(await this.read(target.absolute, path)) };
|
|
101
|
+
}
|
|
102
|
+
async prepare(change, signal) {
|
|
103
|
+
const { path } = change;
|
|
104
|
+
const target = await this.policy.check(path, 'write', signal);
|
|
105
|
+
const { state, content: previous } = await this.read(target.absolute, path);
|
|
106
|
+
if (change.kind === 'create') {
|
|
107
|
+
if (state.exists) {
|
|
108
|
+
throw new EditRefusedError(`"${path}" already exists; read it and change it instead`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (!state.exists)
|
|
113
|
+
throw new EditRefusedError(`"${path}" does not exist`);
|
|
114
|
+
if (change.expected !== undefined && change.expected !== state.hash) {
|
|
115
|
+
throw new EditRefusedError(`"${path}" changed since it was read; read it again`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const content = change.kind === 'delete' ? undefined : change.content;
|
|
119
|
+
if (content && content.length > this.maxFileBytes) {
|
|
120
|
+
throw new EditRefusedError(`The new content of "${path}" is larger than ${String(this.maxFileBytes / 1024 / 1024)} MB`);
|
|
121
|
+
}
|
|
122
|
+
if (content && state.exists && hashOf(content) === state.hash) {
|
|
123
|
+
throw new EditRefusedError(`"${path}" already holds that content; nothing to change`);
|
|
124
|
+
}
|
|
125
|
+
// Copies all the way down: what is approved must not change while the approval waits.
|
|
126
|
+
return Object.freeze({
|
|
127
|
+
kind: change.kind,
|
|
128
|
+
path,
|
|
129
|
+
target: Object.freeze({ ...target }),
|
|
130
|
+
before: Object.freeze({ ...state }),
|
|
131
|
+
content: content && Buffer.from(content),
|
|
132
|
+
contentHash: content && hashOf(content),
|
|
133
|
+
widensIgnoreRules: isIgnoreFile(target.absolute) && widensIgnoreRules(previous, content),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
async apply(prepared, signal) {
|
|
137
|
+
const { path } = prepared;
|
|
138
|
+
signal.throwIfAborted();
|
|
139
|
+
// A Buffer's bytes cannot be frozen: copied, then checked, so what is written is what was
|
|
140
|
+
// approved, whatever happens to `prepared.content` from here on.
|
|
141
|
+
const content = prepared.content && Buffer.from(prepared.content);
|
|
142
|
+
if ((content && hashOf(content)) !== prepared.contentHash) {
|
|
143
|
+
throw new EditRefusedError(`The content prepared for "${path}" changed after it was prepared; prepare the change again`);
|
|
144
|
+
}
|
|
145
|
+
const target = await this.policy.check(path, 'write', signal);
|
|
146
|
+
if (target.absolute !== prepared.target.absolute) {
|
|
147
|
+
throw new EditRefusedError(`"${path}" now leads somewhere else; prepare the change again`);
|
|
148
|
+
}
|
|
149
|
+
const current = await this.read(target.absolute, path);
|
|
150
|
+
if (!sameState(current.state, prepared.before)) {
|
|
151
|
+
throw new EditRefusedError(`"${path}" changed after this change was prepared; read it again`);
|
|
152
|
+
}
|
|
153
|
+
// Checked again before the first effect: starting the journal takes the workspace.
|
|
154
|
+
signal.throwIfAborted();
|
|
155
|
+
const journal = await this.session.journalForChange();
|
|
156
|
+
const after = await this.record(journal, path, current.content, content, prepared.before);
|
|
157
|
+
const createdFolders = prepared.kind === 'create' ? await missingFolders(dirname(target.absolute)) : [];
|
|
158
|
+
const sequence = await this.journal(path, () => journal.prepare({
|
|
159
|
+
path: target.absolute,
|
|
160
|
+
before: current.state,
|
|
161
|
+
after,
|
|
162
|
+
...(createdFolders.length > 0 ? { createdFolders } : {}),
|
|
163
|
+
}));
|
|
164
|
+
// The last moment to stop: past here the file changes.
|
|
165
|
+
if (signal.aborted) {
|
|
166
|
+
await journal.abandoned(sequence);
|
|
167
|
+
signal.throwIfAborted();
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
await this.write(prepared, content, target.absolute);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
await this.settleFailedWrite({
|
|
174
|
+
journal,
|
|
175
|
+
sequence,
|
|
176
|
+
kind: prepared.kind,
|
|
177
|
+
path,
|
|
178
|
+
target: target.absolute,
|
|
179
|
+
current,
|
|
180
|
+
after,
|
|
181
|
+
createdFolders,
|
|
182
|
+
error,
|
|
183
|
+
});
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
await this.markApplied(journal, sequence, path);
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Records a change that was made. If that fails, its prepared line still holds both states, so
|
|
190
|
+
* an undo can tell; but the record cannot be trusted to continue, and the caller must know.
|
|
191
|
+
*/
|
|
192
|
+
async markApplied(journal, sequence, path) {
|
|
193
|
+
try {
|
|
194
|
+
await journal.applied(sequence);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
this.session.stop('Writing stopped: the harness could not record a change it made');
|
|
198
|
+
throw new EditUnconfirmedError(`"${path}" was changed, but the harness could not record it; writing has stopped for this run`, { cause: error });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** What `path` holds now. Only a regular file within the size limit can be changed. */
|
|
202
|
+
async read(absolute, path) {
|
|
203
|
+
let stats;
|
|
204
|
+
try {
|
|
205
|
+
stats = await lstat(absolute);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (code(error) === 'ENOENT')
|
|
209
|
+
return { state: { exists: false } };
|
|
210
|
+
throw new EditRefusedError(`Could not read "${path}" (${code(error)})`, { cause: error });
|
|
211
|
+
}
|
|
212
|
+
if (!stats.isFile())
|
|
213
|
+
throw new EditRefusedError(`"${path}" is not a regular file`);
|
|
214
|
+
if (stats.size > this.maxFileBytes) {
|
|
215
|
+
throw new EditRefusedError(`"${path}" is larger than ${String(this.maxFileBytes / 1024 / 1024)} MB, too large to change`);
|
|
216
|
+
}
|
|
217
|
+
let content;
|
|
218
|
+
try {
|
|
219
|
+
content = await readFile(absolute);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
throw new EditRefusedError(`Could not read "${path}" (${code(error)})`, { cause: error });
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
state: { exists: true, hash: hashOf(content), mode: stats.mode & 0o7777 },
|
|
226
|
+
content,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
/** Stores what the change replaces and what it writes, and returns the state it leaves. */
|
|
230
|
+
async record(journal, path, previous, content, before) {
|
|
231
|
+
if (previous)
|
|
232
|
+
await this.journal(path, () => journal.saveContent(previous));
|
|
233
|
+
if (!content)
|
|
234
|
+
return { exists: false };
|
|
235
|
+
const hash = await this.journal(path, () => journal.saveContent(content));
|
|
236
|
+
return { exists: true, hash, mode: before.exists ? before.mode : await getNewFileMode() };
|
|
237
|
+
}
|
|
238
|
+
async journal(path, step) {
|
|
239
|
+
try {
|
|
240
|
+
return await step();
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw new EditRefusedError(`The harness could not record how to undo the change to "${path}", so it was not changed`, { cause: error });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async write({ kind, before }, content, absolute) {
|
|
247
|
+
if (kind === 'delete') {
|
|
248
|
+
await unlink(absolute);
|
|
249
|
+
await syncDirectory(dirname(absolute));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await mkdir(dirname(absolute), { recursive: true });
|
|
253
|
+
// Written beside the file, so the final step is one rename or link on the same disk.
|
|
254
|
+
const temporary = join(dirname(absolute), `.${basename(absolute)}.${randomBytes(6).toString('hex')}.mikode-tmp`);
|
|
255
|
+
try {
|
|
256
|
+
const handle = await open(temporary, 'wx');
|
|
257
|
+
try {
|
|
258
|
+
await handle.writeFile(content ?? Buffer.alloc(0));
|
|
259
|
+
await handle.sync();
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await handle.close();
|
|
263
|
+
}
|
|
264
|
+
if (before.exists)
|
|
265
|
+
await chmod(temporary, before.mode);
|
|
266
|
+
if (kind === 'create') {
|
|
267
|
+
// A link fails if something appeared at the path meanwhile; a rename would replace it.
|
|
268
|
+
await link(temporary, absolute);
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
await rename(temporary, absolute);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
await rm(temporary, { force: true });
|
|
276
|
+
}
|
|
277
|
+
await syncDirectory(dirname(absolute));
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* A write threw. If the file still holds what it held, nothing happened and the change is
|
|
281
|
+
* abandoned; if it holds the new state, the change happened anyway. Anything else cannot be
|
|
282
|
+
* explained, so the session stops writing.
|
|
283
|
+
*/
|
|
284
|
+
async settleFailedWrite({ journal, sequence, kind, path, target, current, after, createdFolders, error, }) {
|
|
285
|
+
const now = await this.read(target, path).catch(() => undefined);
|
|
286
|
+
if (now && sameState(now.state, after)) {
|
|
287
|
+
await this.markApplied(journal, sequence, path);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
// A link that found the path taken made no change: what is there now is someone else's.
|
|
291
|
+
const appeared = kind === 'create' && code(error) === 'EEXIST';
|
|
292
|
+
if (appeared || (now && sameState(now.state, current.state))) {
|
|
293
|
+
// Innermost first, and only while empty: anything else in them is not this run's.
|
|
294
|
+
for (const folder of [...createdFolders].reverse()) {
|
|
295
|
+
await rmdir(folder).catch(() => undefined);
|
|
296
|
+
}
|
|
297
|
+
await journal.abandoned(sequence).catch(() => undefined);
|
|
298
|
+
throw new EditRefusedError(appeared
|
|
299
|
+
? `"${path}" appeared while it was being created; read it before changing it`
|
|
300
|
+
: `Could not write "${path}" (${code(error)}); it was not changed`, { cause: error });
|
|
301
|
+
}
|
|
302
|
+
this.session.stop(`Writing stopped: the harness could not tell whether "${path}" was changed; ask the user to check it`);
|
|
303
|
+
throw new EditUnconfirmedError(`Could not tell whether "${path}" was changed (${code(error)}); writing has stopped for this run`, { cause: error });
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
//# sourceMappingURL=fileEditor.js.map
|