@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.
Files changed (167) hide show
  1. package/README.md +278 -16
  2. package/dist/agent/domain/agent.d.ts +12 -0
  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 +223 -23
  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 +11 -3
  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 +66 -16
  40. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  41. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +11 -2
  42. package/dist/orchestration/domain/model/orchestratorAgent.js +43 -4
  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 +31 -0
  87. package/dist/tools/domain/preparedCall.js +14 -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/v4aPatch.d.ts +17 -0
  96. package/dist/tools/domain/v4aPatch.js +236 -0
  97. package/dist/tools/domain/v4aPatch.js.map +1 -0
  98. package/dist/tools/infrastructure/boundedWorkspace.d.ts +11 -2
  99. package/dist/tools/infrastructure/boundedWorkspace.js +9 -1
  100. package/dist/tools/infrastructure/boundedWorkspace.js.map +1 -1
  101. package/dist/tools/infrastructure/commandCheckpoint.d.ts +55 -0
  102. package/dist/tools/infrastructure/commandCheckpoint.js +178 -0
  103. package/dist/tools/infrastructure/commandCheckpoint.js.map +1 -0
  104. package/dist/tools/infrastructure/commandProcess.d.ts +47 -0
  105. package/dist/tools/infrastructure/commandProcess.js +217 -0
  106. package/dist/tools/infrastructure/commandProcess.js.map +1 -0
  107. package/dist/tools/infrastructure/commandTool.d.ts +36 -0
  108. package/dist/tools/infrastructure/commandTool.js +254 -0
  109. package/dist/tools/infrastructure/commandTool.js.map +1 -0
  110. package/dist/tools/infrastructure/createWorkspace.d.ts +10 -0
  111. package/dist/tools/infrastructure/createWorkspace.js +11 -3
  112. package/dist/tools/infrastructure/createWorkspace.js.map +1 -1
  113. package/dist/tools/infrastructure/defineTool.d.ts +14 -0
  114. package/dist/tools/infrastructure/defineTool.js +28 -5
  115. package/dist/tools/infrastructure/defineTool.js.map +1 -1
  116. package/dist/tools/infrastructure/editFormats.d.ts +22 -0
  117. package/dist/tools/infrastructure/editFormats.js +211 -0
  118. package/dist/tools/infrastructure/editFormats.js.map +1 -0
  119. package/dist/tools/infrastructure/fileEditor.d.ts +64 -0
  120. package/dist/tools/infrastructure/fileEditor.js +306 -0
  121. package/dist/tools/infrastructure/fileEditor.js.map +1 -0
  122. package/dist/tools/infrastructure/fileTools.d.ts +81 -0
  123. package/dist/tools/infrastructure/fileTools.js +152 -0
  124. package/dist/tools/infrastructure/fileTools.js.map +1 -0
  125. package/dist/tools/infrastructure/gitIgnoreRules.d.ts +15 -0
  126. package/dist/tools/infrastructure/gitIgnoreRules.js +74 -0
  127. package/dist/tools/infrastructure/gitIgnoreRules.js.map +1 -0
  128. package/dist/tools/infrastructure/gitWorkspace.d.ts +1 -1
  129. package/dist/tools/infrastructure/gitWorkspace.js +1 -1
  130. package/dist/tools/infrastructure/gitWorkspace.js.map +1 -1
  131. package/dist/tools/infrastructure/ignoreFileChanges.d.ts +14 -0
  132. package/dist/tools/infrastructure/ignoreFileChanges.js +62 -0
  133. package/dist/tools/infrastructure/ignoreFileChanges.js.map +1 -0
  134. package/dist/tools/infrastructure/policyWorkspace.d.ts +52 -0
  135. package/dist/tools/infrastructure/policyWorkspace.js +32 -0
  136. package/dist/tools/infrastructure/policyWorkspace.js.map +1 -0
  137. package/dist/tools/infrastructure/ripgrepWorkspace.d.ts +2 -1
  138. package/dist/tools/infrastructure/ripgrepWorkspace.js +1 -1
  139. package/dist/tools/infrastructure/ripgrepWorkspace.js.map +1 -1
  140. package/dist/tools/infrastructure/rootsAccessPolicy.d.ts +100 -0
  141. package/dist/tools/infrastructure/rootsAccessPolicy.js +301 -0
  142. package/dist/tools/infrastructure/rootsAccessPolicy.js.map +1 -0
  143. package/dist/tools/infrastructure/runProcess.d.ts +4 -1
  144. package/dist/tools/infrastructure/runProcess.js +4 -2
  145. package/dist/tools/infrastructure/runProcess.js.map +1 -1
  146. package/dist/tools/infrastructure/secretPaths.d.ts +8 -0
  147. package/dist/tools/infrastructure/secretPaths.js +76 -0
  148. package/dist/tools/infrastructure/secretPaths.js.map +1 -0
  149. package/dist/tools/infrastructure/showChangesTool.d.ts +12 -0
  150. package/dist/tools/infrastructure/showChangesTool.js +43 -0
  151. package/dist/tools/infrastructure/showChangesTool.js.map +1 -0
  152. package/dist/tools/infrastructure/trackedEdits.d.ts +33 -0
  153. package/dist/tools/infrastructure/trackedEdits.js +70 -0
  154. package/dist/tools/infrastructure/trackedEdits.js.map +1 -0
  155. package/dist/tools/infrastructure/trackedReadFile.d.ts +21 -0
  156. package/dist/tools/infrastructure/trackedReadFile.js +78 -0
  157. package/dist/tools/infrastructure/trackedReadFile.js.map +1 -0
  158. package/dist/tools/infrastructure/workspaceTools.d.ts +28 -0
  159. package/dist/tools/infrastructure/workspaceTools.js +53 -40
  160. package/dist/tools/infrastructure/workspaceTools.js.map +1 -1
  161. package/dist/tools/infrastructure/workspaceWrites.d.ts +23 -0
  162. package/dist/tools/infrastructure/workspaceWrites.js +42 -0
  163. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -0
  164. package/dist/tools/infrastructure/writeSession.d.ts +35 -0
  165. package/dist/tools/infrastructure/writeSession.js +81 -0
  166. package/dist/tools/infrastructure/writeSession.js.map +1 -0
  167. package/package.json +1 -1
@@ -0,0 +1,1043 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { link, mkdir, open, readdir, readFile, realpath, rename, rm, stat, truncate, unlink, } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, isAbsolute, join } from 'node:path';
5
+ import { fileChanges, sameState } from "../domain/fileChanges.js";
6
+ import { historyStart, NothingToMoveError, WorkspaceBusyError, WorkspaceMovedError, } from "../domain/recoveryStore.js";
7
+ import { chainEntries, nextPruneStep } from "../domain/retention.js";
8
+ import { HistoryExpiredError, redoTarget, route, UnknownRunError } from "../domain/runTree.js";
9
+ import { currentState, hasRealParents, removeEmptyFolders, removeFile, removeTemporary, temporaryPath, writeFileState, } from "./workspaceFiles.js";
10
+ const appName = 'mikode-harness';
11
+ // The store holds source code the agent touched: only its owner may read it.
12
+ const privateDirectory = 0o700;
13
+ const privateFile = 0o600;
14
+ const firstJournal = 'journal.jsonl';
15
+ // A run is roughly one prompt: enough to go back through a working session, not a project's life.
16
+ const defaultKeepRuns = 25;
17
+ const runIdPattern = /^\d{8}T\d{9}Z-[0-9a-f]{6}$/;
18
+ const hashPattern = /^[0-9a-f]{64}$/;
19
+ /** Where an application keeps state that is neither configuration nor cache, per platform. */
20
+ export function defaultStateDirectory(env = process.env, platform = process.platform, home = homedir()) {
21
+ // The XDG spec says to ignore a relative path, which here could land inside the repository.
22
+ const xdg = env.XDG_STATE_HOME;
23
+ if (xdg && isAbsolute(xdg))
24
+ return join(xdg, appName);
25
+ if (platform === 'darwin')
26
+ return join(home, 'Library', 'Application Support', appName);
27
+ return join(home, '.local', 'state', appName);
28
+ }
29
+ /** Sorts by start time as text, so the latest run is the last one listed. */
30
+ function newRunId() {
31
+ return `${new Date().toISOString().replace(/[-:.]/g, '')}-${randomBytes(3).toString('hex')}`;
32
+ }
33
+ function hashOf(content) {
34
+ return createHash('sha256').update(content).digest('hex');
35
+ }
36
+ function isNotFound(error) {
37
+ return error.code === 'ENOENT';
38
+ }
39
+ /**
40
+ * The runs and moves this process holds a workspace lock for and has not ended. A lock naming
41
+ * this process and none of these was left by one whose release failed: it is as stale as a dead
42
+ * process's, which a process that lives on would otherwise keep until it exits.
43
+ */
44
+ const heldHere = new Set();
45
+ /** Whether the holder of a lock is still at work. */
46
+ function isLiveHolder(holder) {
47
+ // A holder that cannot be named cannot be shown to be gone.
48
+ if (holder.pid === undefined)
49
+ return true;
50
+ if (holder.pid === process.pid)
51
+ return heldHere.has(holder.runId);
52
+ return isAlive(holder.pid);
53
+ }
54
+ /** Whether a process still exists. One we may not signal exists too. */
55
+ function isAlive(pid) {
56
+ try {
57
+ process.kill(pid, 0);
58
+ return true;
59
+ }
60
+ catch (error) {
61
+ return error.code === 'EPERM';
62
+ }
63
+ }
64
+ async function syncDirectory(path) {
65
+ const directory = await open(path, 'r');
66
+ try {
67
+ await directory.sync();
68
+ }
69
+ finally {
70
+ await directory.close();
71
+ }
72
+ }
73
+ /**
74
+ * Creates a private folder and any missing parents. A new folder only survives a crash once
75
+ * its parent's entry for it is synced too, so each parent that gained one is.
76
+ */
77
+ async function makeDirectory(path) {
78
+ const first = await mkdir(path, { recursive: true, mode: privateDirectory });
79
+ if (first === undefined)
80
+ return;
81
+ for (let created = path;; created = dirname(created)) {
82
+ await syncDirectory(dirname(created));
83
+ if (created === first)
84
+ return;
85
+ }
86
+ }
87
+ /**
88
+ * Puts `content` at `path` all at once: a crash leaves either the old file or the new one, never
89
+ * half of the new one. It returns only once the new file is on disk. With `exclusive`, it fails
90
+ * with `EEXIST` instead of replacing a file already there.
91
+ */
92
+ async function writeDurably(path, content, { exclusive = false } = {}) {
93
+ const temporary = `${path}.${randomBytes(6).toString('hex')}.tmp`;
94
+ try {
95
+ const handle = await open(temporary, 'wx', privateFile);
96
+ try {
97
+ await handle.writeFile(content);
98
+ await handle.sync();
99
+ }
100
+ finally {
101
+ await handle.close();
102
+ }
103
+ // A link fails if the path is taken; a rename would replace it.
104
+ await (exclusive ? link : rename)(temporary, path);
105
+ }
106
+ finally {
107
+ await rm(temporary, { force: true });
108
+ }
109
+ await syncDirectory(dirname(path));
110
+ }
111
+ /**
112
+ * Stores `content` under its hash, unless it is stored already, and returns the hash and whether
113
+ * it was new. It returns only once the content is on disk.
114
+ */
115
+ async function storeContent(contentDirectory, content) {
116
+ const hash = hashOf(content);
117
+ const path = contentPath(contentDirectory, hash);
118
+ const stored = await stat(path).then(() => true, (error) => {
119
+ if (isNotFound(error))
120
+ return false;
121
+ throw error;
122
+ });
123
+ if (!stored) {
124
+ await makeDirectory(dirname(path));
125
+ await writeDurably(path, content);
126
+ }
127
+ return { hash, written: !stored };
128
+ }
129
+ /**
130
+ * The recovery store on disk, in a private directory per workspace under the platform's state
131
+ * directory:
132
+ *
133
+ * ```text
134
+ * <directory>/workspaces/<hash of the root>/
135
+ * workspace.json the root it belongs to
136
+ * lock the run writing now, if any
137
+ * head.json the run the workspace is at, and a move in progress
138
+ * revisions.jsonl every move of the workspace through its history
139
+ * content/<ab>/<hash> file contents, named by their SHA-256
140
+ * runs/<runId>/run.json the run's record
141
+ * runs/<runId>/journal.jsonl its changes, one line per step, appended and synced
142
+ * ```
143
+ *
144
+ * The lock only keeps a second run of this harness from writing at the same time. It does not
145
+ * stop an editor or another program, and two processes taking over a dead run's lock in the
146
+ * same instant could both believe they hold it.
147
+ */
148
+ export class FileRecoveryStore {
149
+ root;
150
+ directory;
151
+ keepRuns;
152
+ constructor(root, directory, keepRuns) {
153
+ this.root = root;
154
+ this.directory = directory;
155
+ this.keepRuns = keepRuns;
156
+ }
157
+ static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, }) {
158
+ if (!Number.isInteger(keepRuns) || keepRuns < 3) {
159
+ throw new RangeError(`keepRuns must be an integer of at least 3; got ${String(keepRuns)}`);
160
+ }
161
+ // Two spellings of one folder, such as a symlinked path, are one workspace.
162
+ const realRoot = await realpath(root);
163
+ const workspace = join(directory, 'workspaces', createHash('sha256').update(realRoot).digest('hex').slice(0, 16));
164
+ await makeDirectory(join(workspace, 'runs'));
165
+ await makeDirectory(join(workspace, 'content'));
166
+ await writeDurably(join(workspace, 'workspace.json'), JSON.stringify({ root: realRoot }));
167
+ return new FileRecoveryStore(realRoot, workspace, keepRuns);
168
+ }
169
+ async startRun() {
170
+ const runId = newRunId();
171
+ try {
172
+ // Inside, because taking the lock can fail after publishing it. Unlocking removes only a
173
+ // lock naming this run, never another run's.
174
+ await this.lock(runId);
175
+ await this.settleInterrupted(runId);
176
+ // A run starts where the user last sent the workspace.
177
+ await this.finishPendingMove();
178
+ // Before the new run exists, so it is never pruned, and after the move, so no run on its
179
+ // route is.
180
+ await this.prune();
181
+ const head = await this.readHead();
182
+ const parentRunId = head.runId;
183
+ const runDirectory = join(this.directory, 'runs', runId);
184
+ await makeDirectory(runDirectory);
185
+ const record = {
186
+ runId,
187
+ root: this.root,
188
+ ...(parentRunId ? { parentRunId } : {}),
189
+ startedAt: new Date().toISOString(),
190
+ status: 'running',
191
+ pid: process.pid,
192
+ };
193
+ await writeDurably(join(runDirectory, 'run.json'), JSON.stringify(record));
194
+ // After the record, so the head never names a run that does not exist.
195
+ await this.writeHead({ runId, revision: head.revision });
196
+ const journal = await open(join(runDirectory, firstJournal), 'a', privateFile);
197
+ await syncDirectory(runDirectory);
198
+ return new FileRunJournal({
199
+ record,
200
+ runDirectory,
201
+ contentDirectory: join(this.directory, 'content'),
202
+ journal,
203
+ discard: () => this.discardRun(record),
204
+ release: () => this.unlock(runId),
205
+ });
206
+ }
207
+ catch (error) {
208
+ await this.unlock(runId);
209
+ throw error;
210
+ }
211
+ }
212
+ async listRuns() {
213
+ const ids = (await readdir(join(this.directory, 'runs'))).filter(id => runIdPattern.test(id));
214
+ const holder = await readLock(join(this.directory, 'lock'));
215
+ const runs = [];
216
+ for (const runId of ids.sort()) {
217
+ const record = await this.readRecord(runId).catch((error) => {
218
+ // A crash between creating a run's folder and writing its record leaves the folder
219
+ // empty: that run never changed anything.
220
+ if (isNotFound(error))
221
+ return undefined;
222
+ throw error;
223
+ });
224
+ if (record)
225
+ runs.push(withLiveStatus(record, holder));
226
+ }
227
+ // A run retention removed or chained into a later one, whose folder a crash left behind, is
228
+ // not in the history.
229
+ const gone = new Set([
230
+ ...runs.flatMap(run => run.absorbed ?? []),
231
+ ...(await this.listPruned()),
232
+ ]);
233
+ // A dead run that changed nothing is not in the history either, though its removal did not
234
+ // finish. No run follows it: the next run or move removes it before starting.
235
+ for (const run of runs) {
236
+ if (run.status !== 'interrupted')
237
+ continue;
238
+ // A journal it cannot read leaves the run listed: whether it changed anything is unknown.
239
+ const entries = await this.readEntries(run.runId, run.journal).catch(() => undefined);
240
+ if (entries && changedNothing(entries))
241
+ gone.add(run.runId);
242
+ }
243
+ const parents = new Map(runs.map(run => [run.runId, run.parentRunId]));
244
+ let { runId: head } = await this.readHead();
245
+ while (head !== undefined && gone.has(head))
246
+ head = parents.get(head);
247
+ return { ...(head ? { head } : {}), runs: runs.filter(run => !gone.has(run.runId)) };
248
+ }
249
+ async readRun(runId) {
250
+ // An id from outside is checked before it names a folder.
251
+ if (!runIdPattern.test(runId))
252
+ throw new UnknownRunError(runId);
253
+ // The lock first: a run that finishes between the two reads then shows as finished, not as
254
+ // a `running` record nobody holds.
255
+ const holder = await readLock(join(this.directory, 'lock'));
256
+ // Before the record: a crash can leave the folder of a run retention already took.
257
+ await this.assertNotAbsorbed(runId);
258
+ const record = await this.readRecord(runId).catch(async (error) => {
259
+ if (!isNotFound(error))
260
+ throw error;
261
+ // Retention may have taken it since the check above.
262
+ await this.assertNotAbsorbed(runId);
263
+ throw new UnknownRunError(runId);
264
+ });
265
+ return {
266
+ record: withLiveStatus(record, holder),
267
+ entries: await this.readEntries(runId, record.journal),
268
+ };
269
+ }
270
+ async readRecord(runId) {
271
+ return JSON.parse(await readFile(join(this.runDirectory(runId), 'run.json'), 'utf8'));
272
+ }
273
+ runDirectory(runId) {
274
+ // The id names a folder, so it must never be a path of its own.
275
+ if (!runIdPattern.test(runId))
276
+ throw new Error(`Not a run id: ${runId}`);
277
+ return join(this.directory, 'runs', runId);
278
+ }
279
+ async readHead() {
280
+ try {
281
+ const head = JSON.parse(await readFile(join(this.directory, 'head.json'), 'utf8'));
282
+ return { ...head, revision: head.revision ?? 0 };
283
+ }
284
+ catch (error) {
285
+ if (isNotFound(error))
286
+ return { revision: 0 };
287
+ throw error;
288
+ }
289
+ }
290
+ async writeHead(head) {
291
+ await writeDurably(join(this.directory, 'head.json'), JSON.stringify(head));
292
+ }
293
+ goTo(target, options = {}) {
294
+ return this.move(() => (target === historyStart ? undefined : target), options);
295
+ }
296
+ undo(options = {}) {
297
+ return this.move(({ runs, head }) => {
298
+ if (head === undefined)
299
+ throw new NothingToMoveError('undo');
300
+ return runs.find(run => run.runId === head)?.parentRunId;
301
+ }, options);
302
+ }
303
+ redo(options = {}) {
304
+ return this.move(({ runs, head, revisions }) => {
305
+ const target = redoTarget(runs, revisions, head);
306
+ if (target === undefined)
307
+ throw new NothingToMoveError('redo');
308
+ return target;
309
+ }, options);
310
+ }
311
+ async listRevisions() {
312
+ let text;
313
+ try {
314
+ text = await readFile(join(this.directory, 'revisions.jsonl'), 'utf8');
315
+ }
316
+ catch (error) {
317
+ if (isNotFound(error))
318
+ return [];
319
+ throw error;
320
+ }
321
+ const lines = text.split('\n');
322
+ return lines.flatMap((line, index) => {
323
+ if (line === '')
324
+ return [];
325
+ try {
326
+ return [JSON.parse(line)];
327
+ }
328
+ catch (error) {
329
+ // A crash in the middle of an append leaves half a line, always the last one. Its
330
+ // move is still pending, and finishing it records it again.
331
+ if (index === lines.length - 1)
332
+ return [];
333
+ throw new Error(`The history of this workspace is corrupt at line ${String(index + 1)}`, {
334
+ cause: error,
335
+ });
336
+ }
337
+ });
338
+ }
339
+ /** Holds the workspace like a run does, then moves it to the run `resolve` picks. */
340
+ async move(resolve, { reason, from }) {
341
+ const moveId = `move-${newRunId()}`;
342
+ try {
343
+ await this.lock(moveId);
344
+ await this.settleInterrupted(moveId);
345
+ await this.finishPendingMove();
346
+ const head = await this.readHead();
347
+ // Only now, under the lock and once nothing is left unsettled, is the head the one the
348
+ // move would start from.
349
+ const at = head.runId ?? historyStart;
350
+ if (from !== undefined && from !== at)
351
+ throw new WorkspaceMovedError(from, at);
352
+ const { runs } = await this.listRuns();
353
+ const to = resolve({ runs, head: head.runId, revisions: await this.listRevisions() });
354
+ if (to !== undefined && !runs.some(run => run.runId === to)) {
355
+ await this.assertNotAbsorbed(to);
356
+ }
357
+ return await this.takeSteps(head, runs, {
358
+ revision: head.revision + 1,
359
+ ...(head.runId ? { from: head.runId } : {}),
360
+ ...(to ? { to } : {}),
361
+ ...(reason === undefined ? {} : { reason }),
362
+ conflicts: [],
363
+ });
364
+ }
365
+ finally {
366
+ await this.unlock(moveId);
367
+ }
368
+ }
369
+ /** Finishes a move whose process stopped halfway, from the run its last step reached. */
370
+ async finishPendingMove() {
371
+ const head = await this.readHead();
372
+ const { pending } = head;
373
+ if (!pending)
374
+ return;
375
+ // It was recorded, and only clearing it from the head was missing.
376
+ if ((await this.listRevisions()).some(revision => revision.revision === pending.revision)) {
377
+ await this.writeHead({
378
+ ...(pending.to ? { runId: pending.to } : {}),
379
+ revision: pending.revision,
380
+ });
381
+ return;
382
+ }
383
+ await this.takeSteps(head, (await this.listRuns()).runs, pending);
384
+ }
385
+ /**
386
+ * Undoes, then redoes, one run at a time from where the head is to `pending.to`. After each
387
+ * step the head names the run reached, with the move still pending, so a crash loses at
388
+ * most the step in progress, and that step is safe to take again.
389
+ */
390
+ async takeSteps(head, runs, pending) {
391
+ const { undo, redo } = route(runs, head.runId, pending.to);
392
+ const parents = new Map(runs.map(run => [run.runId, run.parentRunId]));
393
+ let reached = head.runId;
394
+ const save = () => this.writeHead({ ...(reached ? { runId: reached } : {}), revision: head.revision, pending });
395
+ // What a write the crash interrupted left beside its file.
396
+ if (pending.temporary) {
397
+ await removeTemporary(pending.temporary);
398
+ delete pending.temporary;
399
+ }
400
+ await save();
401
+ const announce = async (temporary) => {
402
+ if (temporary === undefined)
403
+ delete pending.temporary;
404
+ else
405
+ pending.temporary = temporary;
406
+ await save();
407
+ };
408
+ for (const runId of undo) {
409
+ pending.conflicts.push(...(await this.step(runId, 'undo', announce)));
410
+ reached = parents.get(runId);
411
+ await save();
412
+ }
413
+ for (const runId of redo) {
414
+ pending.conflicts.push(...(await this.step(runId, 'redo', announce)));
415
+ reached = runId;
416
+ await save();
417
+ }
418
+ // Every write that started has finished, so no temporary is left to name.
419
+ delete pending.temporary;
420
+ const { conflicts, ...rest } = pending;
421
+ const revision = {
422
+ ...rest,
423
+ at: new Date().toISOString(),
424
+ complete: conflicts.length === 0,
425
+ conflicts,
426
+ };
427
+ await this.appendRevision(revision);
428
+ await this.writeHead({
429
+ ...(pending.to ? { runId: pending.to } : {}),
430
+ revision: pending.revision,
431
+ });
432
+ return revision;
433
+ }
434
+ /** Takes one run's changes back to its parent, or forward again, file by file. */
435
+ async step(runId, direction, announce) {
436
+ const { entries } = await this.readRun(runId);
437
+ const changes = fileChanges(entries, true);
438
+ const conflicts = [];
439
+ // Undone newest first, so a folder the run created is empty by the time its file goes.
440
+ for (const change of direction === 'undo' ? changes.reverse() : changes) {
441
+ if (!(await this.moveFile(change, direction, announce))) {
442
+ conflicts.push({ path: change.path, runId });
443
+ }
444
+ }
445
+ return conflicts;
446
+ }
447
+ /**
448
+ * Writes the state the step leads to, only if the file holds the state it leaves. A file
449
+ * already there is left alone, so taking a step again is safe. Returns whether the file
450
+ * ends where the step leads.
451
+ */
452
+ async moveFile(change, direction, announce) {
453
+ const [source, destination] = direction === 'undo' ? [change.after, change.before] : [change.before, change.after];
454
+ // First, so a file reached through a folder that became a link is never taken for the
455
+ // destination, and nothing is read or removed through it.
456
+ if (!(await hasRealParents(change.path)))
457
+ return false;
458
+ const current = await currentState(change.path);
459
+ if (current && sameState(current, destination)) {
460
+ if (direction === 'undo' && !destination.exists) {
461
+ await removeEmptyFolders(change.createdFolders);
462
+ }
463
+ return true;
464
+ }
465
+ if (!current || !sameState(current, source))
466
+ return false;
467
+ // Someone wrote to the file between two of the run's changes, and going back to before
468
+ // the first would discard that.
469
+ if (direction === 'undo' && !change.continuous)
470
+ return false;
471
+ if (destination.exists) {
472
+ const temporary = temporaryPath(change.path);
473
+ await announce(temporary);
474
+ await writeFileState(change.path, await this.readContent(destination.hash), destination.mode, temporary);
475
+ await announce(undefined);
476
+ }
477
+ else {
478
+ await removeFile(change.path);
479
+ if (direction === 'undo')
480
+ await removeEmptyFolders(change.createdFolders);
481
+ }
482
+ return true;
483
+ }
484
+ /** Throws `HistoryExpiredError` if retention removed `runId` or chained it into a later run. */
485
+ async assertNotAbsorbed(runId) {
486
+ const { runs } = await this.listRuns();
487
+ const keptIn = runs.find(run => run.absorbed?.includes(runId));
488
+ if (keptIn)
489
+ throw new HistoryExpiredError(runId, keptIn.runId);
490
+ if ((await this.listPruned()).includes(runId))
491
+ throw new HistoryExpiredError(runId);
492
+ }
493
+ async listPruned() {
494
+ try {
495
+ const text = await readFile(join(this.directory, 'pruned.jsonl'), 'utf8');
496
+ return text.split('\n').flatMap(line => {
497
+ try {
498
+ return [JSON.parse(line)];
499
+ }
500
+ catch {
501
+ // Half a line a crash left, whose removal never happened.
502
+ return [];
503
+ }
504
+ });
505
+ }
506
+ catch (error) {
507
+ if (isNotFound(error))
508
+ return [];
509
+ throw error;
510
+ }
511
+ }
512
+ /**
513
+ * Brings the history down to `keepRuns - 1`, so the run about to start makes `keepRuns`, then
514
+ * frees the contents no run kept refers to.
515
+ */
516
+ async prune() {
517
+ await this.finishChains();
518
+ const revisions = await this.listRevisions();
519
+ for (;;) {
520
+ const { head, runs } = await this.listRuns();
521
+ const step = nextPruneStep(runs, revisions, head, this.keepRuns - 1);
522
+ if (!step)
523
+ break;
524
+ if (step.kind === 'remove')
525
+ await this.removeRun(step.runId);
526
+ else
527
+ await this.chain(step.runId, step.into);
528
+ }
529
+ await this.collectContent();
530
+ }
531
+ /**
532
+ * Chains the changes of `older` into `newer`, its child: `newer` then hangs from `older`'s
533
+ * parent and goes back to where `older` started. The new record is the commit point. Before
534
+ * it, the history is as it was; after it, `finishChains` removes what is left of `older`.
535
+ */
536
+ async chain(older, newer) {
537
+ const olderRecord = await this.readRecord(older);
538
+ const newerRecord = await this.readRecord(newer);
539
+ const entries = chainEntries(await this.readEntries(older, olderRecord.journal), await this.readEntries(newer, newerRecord.journal));
540
+ const journal = `journal-${randomBytes(4).toString('hex')}.jsonl`;
541
+ const lines = entries.flatMap(({ status, sequence, ...change }) => [
542
+ { type: 'prepared', sequence, ...change },
543
+ ...(status === 'prepared' ? [] : [{ type: status, sequence }]),
544
+ ]);
545
+ await writeDurably(join(this.runDirectory(newer), journal), lines.map(line => `${JSON.stringify(line)}\n`).join(''));
546
+ const record = {
547
+ ...newerRecord,
548
+ absorbed: [...(olderRecord.absorbed ?? []), older, ...(newerRecord.absorbed ?? [])],
549
+ journal,
550
+ };
551
+ // It now hangs where `older` did: from its parent, or from the start.
552
+ if (olderRecord.parentRunId)
553
+ record.parentRunId = olderRecord.parentRunId;
554
+ else
555
+ delete record.parentRunId;
556
+ await writeDurably(join(this.runDirectory(newer), 'run.json'), JSON.stringify(record));
557
+ await this.finishChains();
558
+ }
559
+ /**
560
+ * Removes what a chain or a removal a crash interrupted left behind: the folders of runs
561
+ * pruned or absorbed by a kept run, and journals a run no longer reads.
562
+ */
563
+ async finishChains() {
564
+ for (const runId of await this.listPruned()) {
565
+ await rm(this.runDirectory(runId), { recursive: true, force: true });
566
+ }
567
+ const { runs } = await this.listRuns();
568
+ for (const run of runs) {
569
+ for (const absorbed of run.absorbed ?? []) {
570
+ await rm(this.runDirectory(absorbed), { recursive: true, force: true });
571
+ }
572
+ const current = run.journal ?? firstJournal;
573
+ for (const name of await readdir(this.runDirectory(run.runId))) {
574
+ if (name.startsWith('journal') && name.endsWith('.jsonl') && name !== current) {
575
+ await rm(join(this.runDirectory(run.runId), name), { force: true });
576
+ }
577
+ }
578
+ }
579
+ }
580
+ /**
581
+ * Removes a run whole, recording its id first so a move to it reports an expired history.
582
+ * Its record goes next, so a crash halfway leaves no run behind.
583
+ */
584
+ async removeRun(runId) {
585
+ await appendWhole(join(this.directory, 'pruned.jsonl'), [runId]);
586
+ // The log may be new: its entry in the folder must last before the run it names goes.
587
+ await syncDirectory(this.directory);
588
+ await rm(join(this.runDirectory(runId), 'run.json'), { force: true });
589
+ await syncDirectory(this.runDirectory(runId));
590
+ await rm(this.runDirectory(runId), { recursive: true, force: true });
591
+ }
592
+ /**
593
+ * Takes a run that changed nothing out of the history, under the lock: the workspace goes back
594
+ * to its parent first, so the head never names a run that is gone. Until the run is removed,
595
+ * `listRuns` already hides it, and the next run or move finishes removing it. The contents it
596
+ * saved are freed by the next prune.
597
+ */
598
+ async discardRun(record) {
599
+ const { runId: headRunId, ...head } = await this.readHead();
600
+ if (headRunId === record.runId) {
601
+ await this.writeHead({
602
+ ...head,
603
+ ...(record.parentRunId ? { runId: record.parentRunId } : {}),
604
+ });
605
+ }
606
+ await rm(join(this.runDirectory(record.runId), 'run.json'), { force: true });
607
+ await syncDirectory(this.runDirectory(record.runId));
608
+ await rm(this.runDirectory(record.runId), { recursive: true, force: true });
609
+ }
610
+ /** Removes the stored contents no kept run refers to. */
611
+ async collectContent() {
612
+ const referenced = new Set();
613
+ for (const run of (await this.listRuns()).runs) {
614
+ for (const entry of await this.readEntries(run.runId, run.journal)) {
615
+ for (const state of [entry.before, entry.after]) {
616
+ if (state.exists)
617
+ referenced.add(state.hash);
618
+ }
619
+ }
620
+ }
621
+ const contentDirectory = join(this.directory, 'content');
622
+ for (const prefix of await readdir(contentDirectory)) {
623
+ for (const hash of await readdir(join(contentDirectory, prefix))) {
624
+ if (hashPattern.test(hash) && !referenced.has(hash)) {
625
+ await rm(join(contentDirectory, prefix, hash), { force: true });
626
+ }
627
+ }
628
+ }
629
+ }
630
+ async appendRevision(revision) {
631
+ await appendWhole(join(this.directory, 'revisions.jsonl'), [revision]);
632
+ await syncDirectory(this.directory);
633
+ }
634
+ /**
635
+ * Marks every other run still recorded as `running` as `interrupted`: this run holds the lock,
636
+ * so their processes are gone. Each change one left `prepared` is settled from what its file
637
+ * holds now: its `after` means it was made, its `before` that it was not. A file that holds
638
+ * neither stays `prepared`, because nothing tells whether the change reached it.
639
+ */
640
+ async settleInterrupted(ownRunId) {
641
+ const ids = (await readdir(join(this.directory, 'runs'))).filter(id => runIdPattern.test(id) && id !== ownRunId);
642
+ for (const runId of ids) {
643
+ const record = await this.readRecord(runId).catch((error) => {
644
+ if (isNotFound(error))
645
+ return undefined;
646
+ throw error;
647
+ });
648
+ if (record?.status !== 'running')
649
+ continue;
650
+ const journal = await this.readJournal(runId, record.journal);
651
+ const settled = [];
652
+ for (const entry of journal.entries) {
653
+ if (entry.status !== 'prepared' || !isAbsolute(entry.path))
654
+ continue;
655
+ const state = await currentState(entry.path);
656
+ if (state && sameState(state, entry.after)) {
657
+ settled.push({ type: 'applied', sequence: entry.sequence });
658
+ }
659
+ else if (state && sameState(state, entry.before)) {
660
+ settled.push({ type: 'abandoned', sequence: entry.sequence });
661
+ }
662
+ }
663
+ let sequence = journal.lastSequence;
664
+ for (const observation of journal.observations) {
665
+ const lines = await this.settleObservation(observation, journal.entries, sequence);
666
+ sequence += lines.length;
667
+ settled.push(...lines);
668
+ }
669
+ await appendWhole(join(this.runDirectory(runId), record.journal ?? firstJournal), settled);
670
+ if (changedNothing(await this.readEntries(runId, record.journal))) {
671
+ await this.discardRun(record);
672
+ continue;
673
+ }
674
+ // Last, so a crash before it leaves a run that is settled again, and settling is idempotent.
675
+ await writeDurably(join(this.runDirectory(runId), 'run.json'), JSON.stringify({ ...record, status: 'interrupted' }));
676
+ }
677
+ }
678
+ /**
679
+ * The lines that record what a command an interrupted run was observing changed: every file of
680
+ * its manifest that holds something else now, as changed by that command, and then the end of
681
+ * the observation. A file recorded after the observation began is left alone: the run
682
+ * recorded it itself. One that is now a folder, a link or too large to record is left out.
683
+ */
684
+ async settleObservation(observation, entries, lastSequence) {
685
+ const stored = await this.readContent(observation.manifest).catch((error) => {
686
+ if (isNotFound(error))
687
+ return undefined;
688
+ throw error;
689
+ });
690
+ // Its manifest was never stored whole: the command it was saved for never started.
691
+ if (!stored)
692
+ return [{ type: 'observed', sequence: observation.sequence }];
693
+ const manifest = JSON.parse(stored.toString('utf8'));
694
+ const recorded = new Set(entries.filter(entry => entry.sequence > observation.sequence).map(entry => entry.path));
695
+ const lines = [];
696
+ let sequence = lastSequence;
697
+ for (const [path, before] of Object.entries(manifest.files)) {
698
+ if (!isAbsolute(path) || recorded.has(path))
699
+ continue;
700
+ const after = await this.saveState(path);
701
+ if (!after || sameState(after, before))
702
+ continue;
703
+ sequence++;
704
+ lines.push({ type: 'prepared', sequence, path, before, after, observedBy: observation.by }, { type: 'applied', sequence });
705
+ }
706
+ lines.push({ type: 'observed', sequence: observation.sequence });
707
+ return lines;
708
+ }
709
+ /** What `path` holds now, with its content stored, or `undefined` for what no entry records. */
710
+ async saveState(path) {
711
+ const state = await currentState(path);
712
+ if (!state?.exists)
713
+ return state;
714
+ const content = await readFile(path).catch((error) => {
715
+ if (isNotFound(error))
716
+ return undefined;
717
+ throw error;
718
+ });
719
+ if (!content)
720
+ return { exists: false };
721
+ const { hash } = await storeContent(join(this.directory, 'content'), content);
722
+ // Read again: the file may have changed between hashing it and storing it.
723
+ return hash === state.hash ? state : this.saveState(path);
724
+ }
725
+ async readEntries(runId, journal = firstJournal) {
726
+ return (await this.readJournal(runId, journal)).entries;
727
+ }
728
+ /**
729
+ * A run's changes, the observations it began and never finished, and the highest sequence
730
+ * number its journal uses, which the next line it gets must exceed.
731
+ */
732
+ async readJournal(runId, journal = firstJournal) {
733
+ let text;
734
+ try {
735
+ text = await readFile(join(this.runDirectory(runId), journal), 'utf8');
736
+ }
737
+ catch (error) {
738
+ // A run that stopped before opening its journal never changed anything.
739
+ if (isNotFound(error))
740
+ return { entries: [], observations: [], lastSequence: 0 };
741
+ throw error;
742
+ }
743
+ const lines = text.split('\n');
744
+ const entries = new Map();
745
+ const observations = new Map();
746
+ let lastSequence = 0;
747
+ lines.forEach((text, index) => {
748
+ if (text === '')
749
+ return;
750
+ let line;
751
+ try {
752
+ line = JSON.parse(text);
753
+ }
754
+ catch (error) {
755
+ // A crash in the middle of an append leaves half a line, always the last one. Its
756
+ // step never returned, so no change was made after it.
757
+ if (index === lines.length - 1)
758
+ return;
759
+ throw new Error(`The journal of run ${runId} is corrupt at line ${String(index + 1)}`, {
760
+ cause: error,
761
+ });
762
+ }
763
+ lastSequence = Math.max(lastSequence, line.sequence);
764
+ if (line.type === 'prepared') {
765
+ const { sequence, path, before, after, createdFolders, observedBy } = line;
766
+ entries.set(sequence, {
767
+ sequence,
768
+ path,
769
+ before,
770
+ after,
771
+ ...(createdFolders ? { createdFolders } : {}),
772
+ ...(observedBy === undefined ? {} : { observedBy }),
773
+ status: 'prepared',
774
+ });
775
+ return;
776
+ }
777
+ if (line.type === 'observing') {
778
+ const { sequence, manifest, by } = line;
779
+ observations.set(sequence, { sequence, manifest, by });
780
+ return;
781
+ }
782
+ if (line.type === 'observed') {
783
+ observations.delete(line.sequence);
784
+ return;
785
+ }
786
+ const entry = entries.get(line.sequence);
787
+ if (entry)
788
+ entry.status = line.type;
789
+ });
790
+ return {
791
+ entries: [...entries.values()],
792
+ observations: [...observations.values()],
793
+ lastSequence,
794
+ };
795
+ }
796
+ async readContent(hash) {
797
+ if (!hashPattern.test(hash))
798
+ throw new Error(`Not a content hash: ${hash}`);
799
+ return readFile(contentPath(join(this.directory, 'content'), hash));
800
+ }
801
+ async lock(runId) {
802
+ const path = join(this.directory, 'lock');
803
+ // Twice at most: once as it is, and once more after clearing a dead run's lock.
804
+ for (let attempt = 0; attempt < 2; attempt++) {
805
+ try {
806
+ // Written whole before it appears, so a lock that exists always names its holder.
807
+ await writeDurably(path, JSON.stringify({ runId, pid: process.pid }), { exclusive: true });
808
+ heldHere.add(runId);
809
+ return;
810
+ }
811
+ catch (error) {
812
+ if (error.code !== 'EEXIST')
813
+ throw error;
814
+ }
815
+ const holder = await readLock(path);
816
+ // Released meanwhile: try again.
817
+ if (!holder)
818
+ continue;
819
+ if (isLiveHolder(holder))
820
+ throw new WorkspaceBusyError(holder.runId);
821
+ // Its process died, or its run ended, without releasing it. Read again just before
822
+ // removing, so a lock another run has just taken is left alone.
823
+ const again = await readLock(path);
824
+ if (again !== undefined && again.pid === holder.pid && again.runId === holder.runId) {
825
+ await unlink(path).catch((error) => {
826
+ if (!isNotFound(error))
827
+ throw error;
828
+ });
829
+ }
830
+ }
831
+ throw new WorkspaceBusyError((await readLock(path))?.runId ?? 'unknown');
832
+ }
833
+ async unlock(runId) {
834
+ // First: the run has ended even if removing its lock fails, so that lock is stale.
835
+ heldHere.delete(runId);
836
+ const path = join(this.directory, 'lock');
837
+ // Only its own lock: one cleared as stale and taken by another run is not this run's.
838
+ if ((await readLock(path))?.runId === runId)
839
+ await unlink(path);
840
+ }
841
+ }
842
+ /** No change it recorded may have reached a file: it recorded none, or abandoned each one. */
843
+ function changedNothing(entries) {
844
+ return entries.every(entry => entry.status === 'abandoned');
845
+ }
846
+ /**
847
+ * A run recorded as `running` only runs while it holds the lock, its process lives, and, in this
848
+ * process, it has not ended. Any other is reported as `interrupted`, before the next run settles
849
+ * it.
850
+ */
851
+ function withLiveStatus(record, holder) {
852
+ if (record.status !== 'running')
853
+ return record;
854
+ const live = holder?.runId === record.runId && holder.pid !== undefined && isLiveHolder(holder);
855
+ return live ? record : { ...record, status: 'interrupted' };
856
+ }
857
+ /**
858
+ * Appends `lines`, as JSON, to a file of one line each, created private if missing. A crash may
859
+ * have left its last line without a newline. A whole line, which the readers already count, is
860
+ * kept and ended. Half a line would join the first new line, so it is cut off: whatever was
861
+ * writing it never returned.
862
+ */
863
+ async function appendWhole(path, lines) {
864
+ if (lines.length === 0)
865
+ return;
866
+ let text = '';
867
+ try {
868
+ text = await readFile(path, 'utf8');
869
+ }
870
+ catch (error) {
871
+ if (!isNotFound(error))
872
+ throw error;
873
+ }
874
+ const last = text.slice(text.lastIndexOf('\n') + 1);
875
+ let separator = '';
876
+ if (last !== '') {
877
+ if (isWholeLine(last))
878
+ separator = '\n';
879
+ else
880
+ await truncate(path, Buffer.byteLength(text) - Buffer.byteLength(last));
881
+ }
882
+ const journal = await open(path, 'a', privateFile);
883
+ try {
884
+ await journal.appendFile(separator + lines.map(line => `${JSON.stringify(line)}\n`).join(''));
885
+ await journal.sync();
886
+ }
887
+ finally {
888
+ await journal.close();
889
+ }
890
+ }
891
+ function isWholeLine(text) {
892
+ try {
893
+ JSON.parse(text);
894
+ return true;
895
+ }
896
+ catch {
897
+ return false;
898
+ }
899
+ }
900
+ /**
901
+ * Who holds the lock, or `undefined` when nobody does. A lock this store did not write whole
902
+ * has no `pid`, so it is never taken for a dead run's.
903
+ */
904
+ async function readLock(path) {
905
+ let text;
906
+ try {
907
+ text = await readFile(path, 'utf8');
908
+ }
909
+ catch (error) {
910
+ if (isNotFound(error))
911
+ return undefined;
912
+ throw error;
913
+ }
914
+ try {
915
+ const { runId, pid } = JSON.parse(text);
916
+ if (typeof runId === 'string' && Number.isInteger(pid))
917
+ return { runId, pid: pid };
918
+ }
919
+ catch {
920
+ // Not JSON: named below as unknown.
921
+ }
922
+ return { runId: 'unknown' };
923
+ }
924
+ function contentPath(contentDirectory, hash) {
925
+ // Two levels, so no folder holds every file a workspace ever stored.
926
+ return join(contentDirectory, hash.slice(0, 2), hash);
927
+ }
928
+ class FileRunJournal {
929
+ runId;
930
+ record;
931
+ runDirectory;
932
+ contentDirectory;
933
+ journal;
934
+ discard;
935
+ release;
936
+ sequence = 0;
937
+ stored = 0;
938
+ // The changes recorded as prepared and not abandoned: those that may have reached a file. One
939
+ // whose `prepared` line failed is not here, since nothing writes a file before that line.
940
+ effects = new Set();
941
+ // Appends run one after another, so the lines never interleave.
942
+ pending = Promise.resolve();
943
+ finished = false;
944
+ // Set when an append fails: it may have left part of a line, and anything after it would be
945
+ // joined to that part and unreadable.
946
+ broken;
947
+ constructor({ record, runDirectory, contentDirectory, journal, discard, release, }) {
948
+ this.runId = record.runId;
949
+ this.record = record;
950
+ this.runDirectory = runDirectory;
951
+ this.contentDirectory = contentDirectory;
952
+ this.journal = journal;
953
+ this.discard = discard;
954
+ this.release = release;
955
+ }
956
+ async saveContent(content) {
957
+ this.assertOpen();
958
+ const { hash, written } = await storeContent(this.contentDirectory, content);
959
+ if (written)
960
+ this.stored += content.length;
961
+ return hash;
962
+ }
963
+ async prepare(change) {
964
+ this.assertOpen();
965
+ const sequence = ++this.sequence;
966
+ await this.append({ type: 'prepared', sequence, ...change });
967
+ this.effects.add(sequence);
968
+ return sequence;
969
+ }
970
+ applied(sequence) {
971
+ this.assertOpen();
972
+ return this.append({ type: 'applied', sequence });
973
+ }
974
+ async abandoned(sequence) {
975
+ this.assertOpen();
976
+ await this.append({ type: 'abandoned', sequence });
977
+ // Only once recorded: a change whose abandonment was not may still count as made.
978
+ this.effects.delete(sequence);
979
+ }
980
+ async observe(manifest, by) {
981
+ this.assertOpen();
982
+ const sequence = ++this.sequence;
983
+ await this.append({ type: 'observing', sequence, manifest, by });
984
+ // Until it is observed, whatever ran may have changed a file.
985
+ this.effects.add(sequence);
986
+ return sequence;
987
+ }
988
+ async observed(sequence, measure) {
989
+ this.assertOpen();
990
+ await this.append({ type: 'observed', sequence, ...(measure ? { measure } : {}) });
991
+ // Its changes, if any, are entries of their own now.
992
+ this.effects.delete(sequence);
993
+ }
994
+ get storedBytes() {
995
+ return this.stored;
996
+ }
997
+ get changed() {
998
+ return this.effects.size > 0;
999
+ }
1000
+ async finish(status) {
1001
+ this.assertOpen();
1002
+ this.finished = true;
1003
+ try {
1004
+ await this.pending;
1005
+ await this.journal.close();
1006
+ // Its record is left `running`: if removing it fails, readers already take it as gone.
1007
+ if (!this.changed) {
1008
+ await this.discard();
1009
+ return;
1010
+ }
1011
+ const record = { ...this.record, status, finishedAt: new Date().toISOString() };
1012
+ await writeDurably(join(this.runDirectory, 'run.json'), JSON.stringify(record));
1013
+ }
1014
+ finally {
1015
+ await this.release();
1016
+ }
1017
+ }
1018
+ append(line) {
1019
+ const write = this.pending.then(async () => {
1020
+ if (this.broken !== undefined) {
1021
+ throw new Error(`The journal of run ${this.runId} stopped after a failed write`, {
1022
+ cause: this.broken,
1023
+ });
1024
+ }
1025
+ try {
1026
+ await this.journal.appendFile(`${JSON.stringify(line)}\n`);
1027
+ await this.journal.sync();
1028
+ }
1029
+ catch (error) {
1030
+ this.broken = error ?? new Error('Unknown write failure');
1031
+ throw error;
1032
+ }
1033
+ });
1034
+ // The chain goes on, so each later append reports the broken journal to its own caller.
1035
+ this.pending = write.catch(() => undefined);
1036
+ return write;
1037
+ }
1038
+ assertOpen() {
1039
+ if (this.finished)
1040
+ throw new Error(`Run ${this.runId} has already finished`);
1041
+ }
1042
+ }
1043
+ //# sourceMappingURL=fileRecoveryStore.js.map