@mikode13/harness 9.3.0 → 10.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 +66 -8
- package/dist/agent/domain/agent.d.ts +11 -5
- package/dist/agent/domain/agent.js.map +1 -1
- package/dist/agent/domain/approval.d.ts +2 -1
- package/dist/agent/domain/approval.js +2 -1
- package/dist/agent/domain/approval.js.map +1 -1
- package/dist/agent/domain/question.d.ts +5 -0
- package/dist/agent/domain/question.js +11 -0
- package/dist/agent/domain/question.js.map +1 -1
- package/dist/agent/domain/runContext.d.ts +17 -0
- package/dist/agent/domain/runContext.js +9 -0
- package/dist/agent/domain/runContext.js.map +1 -1
- package/dist/agent/domain/transcript.d.ts +10 -0
- package/dist/agent/domain/transcript.js.map +1 -1
- package/dist/engines/domain/model/askUser.d.ts +0 -5
- package/dist/engines/domain/model/askUser.js +0 -9
- package/dist/engines/domain/model/askUser.js.map +1 -1
- package/dist/engines/domain/model/historyFollower.d.ts +1 -1
- package/dist/engines/domain/model/historyFollower.js +14 -3
- package/dist/engines/domain/model/historyFollower.js.map +1 -1
- package/dist/engines/domain/model/llmAgent.d.ts +19 -3
- package/dist/engines/domain/model/llmAgent.js +141 -51
- package/dist/engines/domain/model/llmAgent.js.map +1 -1
- package/dist/factory/infrastructure/agentLLMFactory.d.ts +6 -2
- package/dist/factory/infrastructure/agentLLMFactory.js +4 -4
- package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/recovery/domain/history.d.ts +3 -2
- package/dist/recovery/domain/history.js +6 -2
- package/dist/recovery/domain/history.js.map +1 -1
- package/dist/recovery/domain/recoveryStore.d.ts +47 -5
- package/dist/recovery/domain/recoveryStore.js.map +1 -1
- package/dist/recovery/domain/runChanges.js +11 -2
- package/dist/recovery/domain/runChanges.js.map +1 -1
- package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +41 -9
- package/dist/recovery/infrastructure/fileRecoveryStore.js +217 -65
- package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
- package/dist/tools/domain/preparedCall.d.ts +14 -0
- package/dist/tools/domain/preparedCall.js +12 -0
- package/dist/tools/domain/preparedCall.js.map +1 -1
- package/dist/tools/domain/readRegistry.d.ts +7 -0
- package/dist/tools/domain/readRegistry.js.map +1 -1
- package/dist/tools/infrastructure/agentAsTool.d.ts +61 -0
- package/dist/tools/infrastructure/agentAsTool.js +144 -0
- package/dist/tools/infrastructure/agentAsTool.js.map +1 -0
- package/dist/tools/infrastructure/commandCheckpoint.d.ts +3 -2
- package/dist/tools/infrastructure/commandCheckpoint.js +21 -9
- package/dist/tools/infrastructure/commandCheckpoint.js.map +1 -1
- package/dist/tools/infrastructure/commandTool.js +7 -3
- package/dist/tools/infrastructure/commandTool.js.map +1 -1
- package/dist/tools/infrastructure/exploreTool.js +9 -22
- package/dist/tools/infrastructure/exploreTool.js.map +1 -1
- package/dist/tools/infrastructure/fileEditor.d.ts +2 -0
- package/dist/tools/infrastructure/fileEditor.js +5 -1
- package/dist/tools/infrastructure/fileEditor.js.map +1 -1
- package/dist/tools/infrastructure/fileFormatter.d.ts +1 -0
- package/dist/tools/infrastructure/fileFormatter.js +5 -1
- package/dist/tools/infrastructure/fileFormatter.js.map +1 -1
- package/dist/tools/infrastructure/fileTools.d.ts +1 -1
- package/dist/tools/infrastructure/fileTools.js +3 -3
- package/dist/tools/infrastructure/fileTools.js.map +1 -1
- package/dist/tools/infrastructure/subagents.d.ts +19 -0
- package/dist/tools/infrastructure/subagents.js +29 -0
- package/dist/tools/infrastructure/subagents.js.map +1 -0
- package/dist/tools/infrastructure/workspaceWrites.d.ts +8 -5
- package/dist/tools/infrastructure/workspaceWrites.js +19 -7
- package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
- package/dist/tools/infrastructure/writeSession.d.ts +8 -0
- package/dist/tools/infrastructure/writeSession.js +37 -0
- package/dist/tools/infrastructure/writeSession.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import
|
|
1
|
+
import type { ILogger } from '#src/shared/domain/logger';
|
|
2
|
+
import { type JournalEntry, type MoveOptions, type Revision, type RecoveryStore, type RunJournal, type RunRecord, type UnrecordedChange } from '../domain/recoveryStore.ts';
|
|
2
3
|
/** Where an application keeps state that is neither configuration nor cache, per platform. */
|
|
3
4
|
export declare function defaultStateDirectory(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, home?: string): string;
|
|
4
5
|
/**
|
|
@@ -21,11 +22,13 @@ export declare function defaultStateDirectory(env?: NodeJS.ProcessEnv, platform?
|
|
|
21
22
|
* same instant could both believe they hold it.
|
|
22
23
|
*/
|
|
23
24
|
export declare class FileRecoveryStore implements RecoveryStore {
|
|
25
|
+
readonly workspaceId: string;
|
|
24
26
|
private readonly root;
|
|
25
27
|
private readonly directory;
|
|
26
28
|
private readonly keepRuns;
|
|
29
|
+
private readonly logger;
|
|
27
30
|
private constructor();
|
|
28
|
-
static open({ root, directory, keepRuns, }: {
|
|
31
|
+
static open({ root, directory, keepRuns, logger, }: {
|
|
29
32
|
root: string;
|
|
30
33
|
/** Defaults to the platform's state directory. */
|
|
31
34
|
directory?: string;
|
|
@@ -35,6 +38,8 @@ export declare class FileRecoveryStore implements RecoveryStore {
|
|
|
35
38
|
* no next run to be chained into, so pruning can leave two runs besides the new one.
|
|
36
39
|
*/
|
|
37
40
|
keepRuns?: number;
|
|
41
|
+
/** Where it reports a failure it absorbs. Defaults to standard error. */
|
|
42
|
+
logger?: ILogger;
|
|
38
43
|
}): Promise<FileRecoveryStore>;
|
|
39
44
|
startRun(): Promise<RunJournal>;
|
|
40
45
|
listRuns(): Promise<{
|
|
@@ -44,6 +49,7 @@ export declare class FileRecoveryStore implements RecoveryStore {
|
|
|
44
49
|
readRun(runId: string): Promise<{
|
|
45
50
|
record: RunRecord;
|
|
46
51
|
entries: JournalEntry[];
|
|
52
|
+
unrecorded: UnrecordedChange[];
|
|
47
53
|
}>;
|
|
48
54
|
private readRecord;
|
|
49
55
|
private runDirectory;
|
|
@@ -102,24 +108,50 @@ export declare class FileRecoveryStore implements RecoveryStore {
|
|
|
102
108
|
* saved are freed by the next prune.
|
|
103
109
|
*/
|
|
104
110
|
private discardRun;
|
|
105
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* Removes the stored contents no kept run refers to. An observation still open refers to its
|
|
113
|
+
* manifest and to every content in it, which settling it needs.
|
|
114
|
+
*/
|
|
106
115
|
private collectContent;
|
|
107
116
|
private appendRevision;
|
|
108
117
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
* neither stays `prepared`, because nothing tells whether the change reached it.
|
|
118
|
+
* Settles every other run left unfinished: one still recorded as `running` is `interrupted`,
|
|
119
|
+
* since this run holds the lock and its process is gone, and one that finished `unsettled`
|
|
120
|
+
* keeps its status.
|
|
113
121
|
*/
|
|
114
122
|
private settleInterrupted;
|
|
123
|
+
/**
|
|
124
|
+
* Settles a run that finished with an observation open, under its own lock, before it releases
|
|
125
|
+
* the workspace. If that fails, its record stays `unsettled`, and the next run or move settles
|
|
126
|
+
* it, so the failure is reported and not thrown.
|
|
127
|
+
*/
|
|
128
|
+
private settleAtFinish;
|
|
129
|
+
/**
|
|
130
|
+
* Settles what a run left unfinished. Each change it left `prepared` is settled from what its
|
|
131
|
+
* file holds now: its `after` means it was made, its `before` that it was not. A file that holds
|
|
132
|
+
* neither stays `prepared`, because nothing tells whether the change reached it. Each
|
|
133
|
+
* observation it left open is settled from its manifest. A run still `running` becomes
|
|
134
|
+
* `interrupted`; any other keeps its status. Whether the run is still in the history: one that
|
|
135
|
+
* changed nothing leaves it if it was still `running`, or if it is `finishing`, which unties
|
|
136
|
+
* its turns from it. One that finished is kept otherwise, since the turns of a conversation may
|
|
137
|
+
* name it.
|
|
138
|
+
*/
|
|
139
|
+
private settleRun;
|
|
115
140
|
/**
|
|
116
141
|
* The lines that record what a command an interrupted run was observing changed: every file of
|
|
117
142
|
* its manifest that holds something else now, as changed by that command, and then the end of
|
|
118
143
|
* the observation. A file recorded after the observation began is left alone: the run
|
|
119
|
-
* recorded it itself. One that is now a folder, a link or too large to record
|
|
144
|
+
* recorded it itself. One that is now a folder, a link or too large to record, or that was one
|
|
145
|
+
* and is no longer, or that cannot be read, is recorded as a change the history could not copy.
|
|
120
146
|
*/
|
|
121
147
|
private settleObservation;
|
|
122
|
-
|
|
148
|
+
private warnUnreadable;
|
|
149
|
+
/**
|
|
150
|
+
* What `path` holds now, with its content stored, or `undefined` for what no entry records.
|
|
151
|
+
* A file it cannot read is `unreadable`, and reported: it cannot be undone either, and settling
|
|
152
|
+
* never waits on it, or every later run would fail to start. A failure to store its content is
|
|
153
|
+
* thrown, so the observation stays open and settling it is tried again.
|
|
154
|
+
*/
|
|
123
155
|
private saveState;
|
|
124
156
|
private readEntries;
|
|
125
157
|
/**
|
|
@@ -2,6 +2,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
2
2
|
import { link, mkdir, open, readdir, readFile, realpath, rename, rm, stat, truncate, unlink, } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, isAbsolute, join } from 'node:path';
|
|
5
|
+
import { classifyHostFailure, describeFailure, treatErrors, } from '#src/shared/domain/providerFailure';
|
|
6
|
+
import { Logger } from '#src/shared/infrastructure/logger';
|
|
5
7
|
import { fileChanges, sameState } from "../domain/fileChanges.js";
|
|
6
8
|
import { historyStart, NothingToMoveError, WorkspaceBusyError, WorkspaceMovedError, } from "../domain/recoveryStore.js";
|
|
7
9
|
import { chainEntries, nextPruneStep } from "../domain/retention.js";
|
|
@@ -146,15 +148,19 @@ async function storeContent(contentDirectory, content) {
|
|
|
146
148
|
* same instant could both believe they hold it.
|
|
147
149
|
*/
|
|
148
150
|
export class FileRecoveryStore {
|
|
151
|
+
workspaceId;
|
|
149
152
|
root;
|
|
150
153
|
directory;
|
|
151
154
|
keepRuns;
|
|
152
|
-
|
|
155
|
+
logger;
|
|
156
|
+
constructor(root, directory, keepRuns, logger, workspaceId) {
|
|
153
157
|
this.root = root;
|
|
154
158
|
this.directory = directory;
|
|
155
159
|
this.keepRuns = keepRuns;
|
|
160
|
+
this.logger = logger;
|
|
161
|
+
this.workspaceId = workspaceId;
|
|
156
162
|
}
|
|
157
|
-
static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, }) {
|
|
163
|
+
static async open({ root, directory = defaultStateDirectory(), keepRuns = defaultKeepRuns, logger = new Logger(), }) {
|
|
158
164
|
if (!Number.isInteger(keepRuns) || keepRuns < 3) {
|
|
159
165
|
throw new RangeError(`keepRuns must be an integer of at least 3; got ${String(keepRuns)}`);
|
|
160
166
|
}
|
|
@@ -164,7 +170,8 @@ export class FileRecoveryStore {
|
|
|
164
170
|
await makeDirectory(join(workspace, 'runs'));
|
|
165
171
|
await makeDirectory(join(workspace, 'content'));
|
|
166
172
|
await writeDurably(join(workspace, 'workspace.json'), JSON.stringify({ root: realRoot }));
|
|
167
|
-
|
|
173
|
+
// Real, so a state directory reached through a link is the history it holds.
|
|
174
|
+
return new FileRecoveryStore(realRoot, workspace, keepRuns, logger, await realpath(workspace));
|
|
168
175
|
}
|
|
169
176
|
async startRun() {
|
|
170
177
|
const runId = newRunId();
|
|
@@ -201,6 +208,7 @@ export class FileRecoveryStore {
|
|
|
201
208
|
contentDirectory: join(this.directory, 'content'),
|
|
202
209
|
journal,
|
|
203
210
|
discard: () => this.discardRun(record),
|
|
211
|
+
settle: finished => this.settleAtFinish(finished),
|
|
204
212
|
release: () => this.unlock(runId),
|
|
205
213
|
});
|
|
206
214
|
}
|
|
@@ -236,8 +244,8 @@ export class FileRecoveryStore {
|
|
|
236
244
|
if (run.status !== 'interrupted')
|
|
237
245
|
continue;
|
|
238
246
|
// A journal it cannot read leaves the run listed: whether it changed anything is unknown.
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
247
|
+
const journal = await this.readJournal(run.runId, run.journal).catch(() => undefined);
|
|
248
|
+
if (journal && changedNothing(journal))
|
|
241
249
|
gone.add(run.runId);
|
|
242
250
|
}
|
|
243
251
|
const parents = new Map(runs.map(run => [run.runId, run.parentRunId]));
|
|
@@ -262,9 +270,11 @@ export class FileRecoveryStore {
|
|
|
262
270
|
await this.assertNotAbsorbed(runId);
|
|
263
271
|
throw new UnknownRunError(runId);
|
|
264
272
|
});
|
|
273
|
+
const { entries, unrecorded } = await this.readJournal(runId, record.journal);
|
|
265
274
|
return {
|
|
266
275
|
record: withLiveStatus(record, holder),
|
|
267
|
-
entries
|
|
276
|
+
entries,
|
|
277
|
+
unrecorded: unrecorded.map(({ path, by }) => ({ path, by })),
|
|
268
278
|
};
|
|
269
279
|
}
|
|
270
280
|
async readRecord(runId) {
|
|
@@ -433,16 +443,16 @@ export class FileRecoveryStore {
|
|
|
433
443
|
}
|
|
434
444
|
/** Takes one run's changes back to its parent, or forward again, file by file. */
|
|
435
445
|
async step(runId, direction, announce) {
|
|
436
|
-
const { entries } = await this.readRun(runId);
|
|
446
|
+
const { entries, unrecorded } = await this.readRun(runId);
|
|
437
447
|
const changes = fileChanges(entries, true);
|
|
438
|
-
|
|
448
|
+
// What the history could not copy is left as it is, in either direction.
|
|
449
|
+
const conflicting = new Set(unrecorded.map(change => change.path));
|
|
439
450
|
// Undone newest first, so a folder the run created is empty by the time its file goes.
|
|
440
451
|
for (const change of direction === 'undo' ? changes.reverse() : changes) {
|
|
441
|
-
if (!(await this.moveFile(change, direction, announce)))
|
|
442
|
-
|
|
443
|
-
}
|
|
452
|
+
if (!(await this.moveFile(change, direction, announce)))
|
|
453
|
+
conflicting.add(change.path);
|
|
444
454
|
}
|
|
445
|
-
return
|
|
455
|
+
return [...conflicting].map((path) => ({ path, runId }));
|
|
446
456
|
}
|
|
447
457
|
/**
|
|
448
458
|
* Writes the state the step leads to, only if the file holds the state it leaves. A file
|
|
@@ -536,12 +546,21 @@ export class FileRecoveryStore {
|
|
|
536
546
|
async chain(older, newer) {
|
|
537
547
|
const olderRecord = await this.readRecord(older);
|
|
538
548
|
const newerRecord = await this.readRecord(newer);
|
|
539
|
-
const
|
|
549
|
+
const olderJournal = await this.readJournal(older, olderRecord.journal);
|
|
550
|
+
const newerJournal = await this.readJournal(newer, newerRecord.journal);
|
|
551
|
+
const entries = chainEntries(olderJournal.entries, newerJournal.entries);
|
|
540
552
|
const journal = `journal-${randomBytes(4).toString('hex')}.jsonl`;
|
|
541
553
|
const lines = entries.flatMap(({ status, sequence, ...change }) => [
|
|
542
554
|
{ type: 'prepared', sequence, ...change },
|
|
543
555
|
...(status === 'prepared' ? [] : [{ type: status, sequence }]),
|
|
544
556
|
]);
|
|
557
|
+
// What either run could not copy stays uncopied: undoing the chain cannot take it back.
|
|
558
|
+
for (const [index, { path, by }] of [
|
|
559
|
+
...olderJournal.unrecorded,
|
|
560
|
+
...newerJournal.unrecorded,
|
|
561
|
+
].entries()) {
|
|
562
|
+
lines.push({ type: 'unrecorded', sequence: entries.length + index + 1, path, by });
|
|
563
|
+
}
|
|
545
564
|
await writeDurably(join(this.runDirectory(newer), journal), lines.map(line => `${JSON.stringify(line)}\n`).join(''));
|
|
546
565
|
const record = {
|
|
547
566
|
...newerRecord,
|
|
@@ -607,16 +626,36 @@ export class FileRecoveryStore {
|
|
|
607
626
|
await syncDirectory(this.runDirectory(record.runId));
|
|
608
627
|
await rm(this.runDirectory(record.runId), { recursive: true, force: true });
|
|
609
628
|
}
|
|
610
|
-
/**
|
|
629
|
+
/**
|
|
630
|
+
* Removes the stored contents no kept run refers to. An observation still open refers to its
|
|
631
|
+
* manifest and to every content in it, which settling it needs.
|
|
632
|
+
*/
|
|
611
633
|
async collectContent() {
|
|
612
634
|
const referenced = new Set();
|
|
613
635
|
for (const run of (await this.listRuns()).runs) {
|
|
614
|
-
|
|
636
|
+
const { entries, observations } = await this.readJournal(run.runId, run.journal);
|
|
637
|
+
for (const entry of entries) {
|
|
615
638
|
for (const state of [entry.before, entry.after]) {
|
|
616
639
|
if (state.exists)
|
|
617
640
|
referenced.add(state.hash);
|
|
618
641
|
}
|
|
619
642
|
}
|
|
643
|
+
for (const observation of observations) {
|
|
644
|
+
referenced.add(observation.manifest);
|
|
645
|
+
const stored = await this.readContent(observation.manifest).catch((error) => {
|
|
646
|
+
// Never stored whole: settling it will find nothing to compare.
|
|
647
|
+
if (isNotFound(error))
|
|
648
|
+
return undefined;
|
|
649
|
+
throw error;
|
|
650
|
+
});
|
|
651
|
+
if (!stored)
|
|
652
|
+
continue;
|
|
653
|
+
const manifest = JSON.parse(stored.toString('utf8'));
|
|
654
|
+
for (const state of Object.values(manifest.files)) {
|
|
655
|
+
if (state.exists)
|
|
656
|
+
referenced.add(state.hash);
|
|
657
|
+
}
|
|
658
|
+
}
|
|
620
659
|
}
|
|
621
660
|
const contentDirectory = join(this.directory, 'content');
|
|
622
661
|
for (const prefix of await readdir(contentDirectory)) {
|
|
@@ -632,10 +671,9 @@ export class FileRecoveryStore {
|
|
|
632
671
|
await syncDirectory(this.directory);
|
|
633
672
|
}
|
|
634
673
|
/**
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
638
|
-
* neither stays `prepared`, because nothing tells whether the change reached it.
|
|
674
|
+
* Settles every other run left unfinished: one still recorded as `running` is `interrupted`,
|
|
675
|
+
* since this run holds the lock and its process is gone, and one that finished `unsettled`
|
|
676
|
+
* keeps its status.
|
|
639
677
|
*/
|
|
640
678
|
async settleInterrupted(ownRunId) {
|
|
641
679
|
const ids = (await readdir(join(this.directory, 'runs'))).filter(id => runIdPattern.test(id) && id !== ownRunId);
|
|
@@ -645,43 +683,80 @@ export class FileRecoveryStore {
|
|
|
645
683
|
return undefined;
|
|
646
684
|
throw error;
|
|
647
685
|
});
|
|
648
|
-
if (record?.status
|
|
686
|
+
if (record?.status === 'running' || record?.unsettled)
|
|
687
|
+
await this.settleRun(record);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Settles a run that finished with an observation open, under its own lock, before it releases
|
|
692
|
+
* the workspace. If that fails, its record stays `unsettled`, and the next run or move settles
|
|
693
|
+
* it, so the failure is reported and not thrown.
|
|
694
|
+
*/
|
|
695
|
+
async settleAtFinish(record) {
|
|
696
|
+
try {
|
|
697
|
+
return await this.settleRun(record, { finishing: true });
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
treatErrors(() => {
|
|
701
|
+
this.logger.warn(`The history could not settle what run ${record.runId} was observing when it finished; the next run or move settles it: ${describeFailure(error)}`);
|
|
702
|
+
}, classifyHostFailure, 'Recovery store logger failed while reporting an unsettled run');
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Settles what a run left unfinished. Each change it left `prepared` is settled from what its
|
|
708
|
+
* file holds now: its `after` means it was made, its `before` that it was not. A file that holds
|
|
709
|
+
* neither stays `prepared`, because nothing tells whether the change reached it. Each
|
|
710
|
+
* observation it left open is settled from its manifest. A run still `running` becomes
|
|
711
|
+
* `interrupted`; any other keeps its status. Whether the run is still in the history: one that
|
|
712
|
+
* changed nothing leaves it if it was still `running`, or if it is `finishing`, which unties
|
|
713
|
+
* its turns from it. One that finished is kept otherwise, since the turns of a conversation may
|
|
714
|
+
* name it.
|
|
715
|
+
*/
|
|
716
|
+
async settleRun(record, { finishing = false } = {}) {
|
|
717
|
+
const { runId } = record;
|
|
718
|
+
const journal = await this.readJournal(runId, record.journal);
|
|
719
|
+
const lines = [];
|
|
720
|
+
for (const entry of journal.entries) {
|
|
721
|
+
if (entry.status !== 'prepared' || !isAbsolute(entry.path))
|
|
649
722
|
continue;
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
}
|
|
723
|
+
const state = await currentState(entry.path);
|
|
724
|
+
if (state && sameState(state, entry.after)) {
|
|
725
|
+
lines.push({ type: 'applied', sequence: entry.sequence });
|
|
662
726
|
}
|
|
663
|
-
|
|
664
|
-
|
|
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;
|
|
727
|
+
else if (state && sameState(state, entry.before)) {
|
|
728
|
+
lines.push({ type: 'abandoned', sequence: entry.sequence });
|
|
673
729
|
}
|
|
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
730
|
}
|
|
731
|
+
let sequence = journal.lastSequence;
|
|
732
|
+
for (const observation of journal.observations) {
|
|
733
|
+
const observed = await this.settleObservation(observation, journal, sequence);
|
|
734
|
+
sequence += observed.length;
|
|
735
|
+
lines.push(...observed);
|
|
736
|
+
}
|
|
737
|
+
await appendWhole(join(this.runDirectory(runId), record.journal ?? firstJournal), lines);
|
|
738
|
+
const leaves = finishing || record.status === 'running';
|
|
739
|
+
if (leaves && changedNothing(await this.readJournal(runId, record.journal))) {
|
|
740
|
+
await this.discardRun(record);
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
const settled = {
|
|
744
|
+
...record,
|
|
745
|
+
status: record.status === 'running' ? 'interrupted' : record.status,
|
|
746
|
+
};
|
|
747
|
+
delete settled.unsettled;
|
|
748
|
+
// Last, so a crash before it leaves a run that is settled again, and settling is idempotent.
|
|
749
|
+
await writeDurably(join(this.runDirectory(runId), 'run.json'), JSON.stringify(settled));
|
|
750
|
+
return true;
|
|
677
751
|
}
|
|
678
752
|
/**
|
|
679
753
|
* The lines that record what a command an interrupted run was observing changed: every file of
|
|
680
754
|
* its manifest that holds something else now, as changed by that command, and then the end of
|
|
681
755
|
* 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
|
|
756
|
+
* recorded it itself. One that is now a folder, a link or too large to record, or that was one
|
|
757
|
+
* and is no longer, or that cannot be read, is recorded as a change the history could not copy.
|
|
683
758
|
*/
|
|
684
|
-
async settleObservation(observation, entries, lastSequence) {
|
|
759
|
+
async settleObservation(observation, { entries, unrecorded: uncopied, }, lastSequence) {
|
|
685
760
|
const stored = await this.readContent(observation.manifest).catch((error) => {
|
|
686
761
|
if (isNotFound(error))
|
|
687
762
|
return undefined;
|
|
@@ -691,31 +766,74 @@ export class FileRecoveryStore {
|
|
|
691
766
|
if (!stored)
|
|
692
767
|
return [{ type: 'observed', sequence: observation.sequence }];
|
|
693
768
|
const manifest = JSON.parse(stored.toString('utf8'));
|
|
694
|
-
|
|
769
|
+
// What the run recorded itself after the observation began, its changes and those it could
|
|
770
|
+
// not copy, such as when a command could not be confirmed stopped.
|
|
771
|
+
const recorded = new Set([...entries, ...uncopied]
|
|
772
|
+
.filter(line => line.sequence > observation.sequence)
|
|
773
|
+
.map(line => line.path));
|
|
695
774
|
const lines = [];
|
|
696
775
|
let sequence = lastSequence;
|
|
776
|
+
const unrecorded = (path) => ({
|
|
777
|
+
type: 'unrecorded',
|
|
778
|
+
sequence: ++sequence,
|
|
779
|
+
path,
|
|
780
|
+
by: observation.by,
|
|
781
|
+
});
|
|
697
782
|
for (const [path, before] of Object.entries(manifest.files)) {
|
|
698
783
|
if (!isAbsolute(path) || recorded.has(path))
|
|
699
784
|
continue;
|
|
700
785
|
const after = await this.saveState(path);
|
|
701
|
-
if (
|
|
786
|
+
if (after === undefined || after === 'unreadable') {
|
|
787
|
+
lines.push(unrecorded(path));
|
|
788
|
+
continue;
|
|
789
|
+
}
|
|
790
|
+
if (sameState(after, before))
|
|
702
791
|
continue;
|
|
703
792
|
sequence++;
|
|
704
793
|
lines.push({ type: 'prepared', sequence, path, before, after, observedBy: observation.by }, { type: 'applied', sequence });
|
|
705
794
|
}
|
|
795
|
+
// What it held before is unknown, so only a path that is no longer uncopyable has changed.
|
|
796
|
+
for (const path of manifest.uncopyable ?? []) {
|
|
797
|
+
if (!isAbsolute(path) || recorded.has(path))
|
|
798
|
+
continue;
|
|
799
|
+
const now = await currentState(path).catch((error) => {
|
|
800
|
+
this.warnUnreadable(path, error);
|
|
801
|
+
return 'unreadable';
|
|
802
|
+
});
|
|
803
|
+
if (now !== undefined)
|
|
804
|
+
lines.push(unrecorded(path));
|
|
805
|
+
}
|
|
706
806
|
lines.push({ type: 'observed', sequence: observation.sequence });
|
|
707
807
|
return lines;
|
|
708
808
|
}
|
|
709
|
-
|
|
809
|
+
warnUnreadable(path, error) {
|
|
810
|
+
treatErrors(() => {
|
|
811
|
+
this.logger.warn(`The history could not read ${path} to settle what a command changed, and records it as a change undo cannot take back: ${describeFailure(error)}`);
|
|
812
|
+
}, classifyHostFailure, 'Recovery store logger failed while reporting an unreadable file');
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* What `path` holds now, with its content stored, or `undefined` for what no entry records.
|
|
816
|
+
* A file it cannot read is `unreadable`, and reported: it cannot be undone either, and settling
|
|
817
|
+
* never waits on it, or every later run would fail to start. A failure to store its content is
|
|
818
|
+
* thrown, so the observation stays open and settling it is tried again.
|
|
819
|
+
*/
|
|
710
820
|
async saveState(path) {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
if (
|
|
716
|
-
return
|
|
717
|
-
|
|
718
|
-
|
|
821
|
+
let state;
|
|
822
|
+
let content;
|
|
823
|
+
try {
|
|
824
|
+
state = await currentState(path);
|
|
825
|
+
if (!state?.exists)
|
|
826
|
+
return state;
|
|
827
|
+
content = await readFile(path).catch((error) => {
|
|
828
|
+
if (isNotFound(error))
|
|
829
|
+
return undefined;
|
|
830
|
+
throw error;
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
catch (error) {
|
|
834
|
+
this.warnUnreadable(path, error);
|
|
835
|
+
return 'unreadable';
|
|
836
|
+
}
|
|
719
837
|
if (!content)
|
|
720
838
|
return { exists: false };
|
|
721
839
|
const { hash } = await storeContent(join(this.directory, 'content'), content);
|
|
@@ -736,13 +854,15 @@ export class FileRecoveryStore {
|
|
|
736
854
|
}
|
|
737
855
|
catch (error) {
|
|
738
856
|
// A run that stopped before opening its journal never changed anything.
|
|
739
|
-
if (isNotFound(error))
|
|
740
|
-
return { entries: [], observations: [], lastSequence: 0 };
|
|
857
|
+
if (isNotFound(error)) {
|
|
858
|
+
return { entries: [], observations: [], unrecorded: [], lastSequence: 0 };
|
|
859
|
+
}
|
|
741
860
|
throw error;
|
|
742
861
|
}
|
|
743
862
|
const lines = text.split('\n');
|
|
744
863
|
const entries = new Map();
|
|
745
864
|
const observations = new Map();
|
|
865
|
+
const unrecorded = [];
|
|
746
866
|
let lastSequence = 0;
|
|
747
867
|
lines.forEach((text, index) => {
|
|
748
868
|
if (text === '')
|
|
@@ -783,6 +903,10 @@ export class FileRecoveryStore {
|
|
|
783
903
|
observations.delete(line.sequence);
|
|
784
904
|
return;
|
|
785
905
|
}
|
|
906
|
+
if (line.type === 'unrecorded') {
|
|
907
|
+
unrecorded.push({ sequence: line.sequence, path: line.path, by: line.by });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
786
910
|
const entry = entries.get(line.sequence);
|
|
787
911
|
if (entry)
|
|
788
912
|
entry.status = line.type;
|
|
@@ -790,6 +914,7 @@ export class FileRecoveryStore {
|
|
|
790
914
|
return {
|
|
791
915
|
entries: [...entries.values()],
|
|
792
916
|
observations: [...observations.values()],
|
|
917
|
+
unrecorded,
|
|
793
918
|
lastSequence,
|
|
794
919
|
};
|
|
795
920
|
}
|
|
@@ -839,9 +964,12 @@ export class FileRecoveryStore {
|
|
|
839
964
|
await unlink(path);
|
|
840
965
|
}
|
|
841
966
|
}
|
|
842
|
-
/**
|
|
843
|
-
|
|
844
|
-
|
|
967
|
+
/**
|
|
968
|
+
* No change it recorded may have reached a file: it recorded none, or abandoned each one, and
|
|
969
|
+
* none it could not copy.
|
|
970
|
+
*/
|
|
971
|
+
function changedNothing({ entries, unrecorded, }) {
|
|
972
|
+
return unrecorded.length === 0 && entries.every(entry => entry.status === 'abandoned');
|
|
845
973
|
}
|
|
846
974
|
/**
|
|
847
975
|
* A run recorded as `running` only runs while it holds the lock, its process lives, and, in this
|
|
@@ -932,6 +1060,7 @@ class FileRunJournal {
|
|
|
932
1060
|
contentDirectory;
|
|
933
1061
|
journal;
|
|
934
1062
|
discard;
|
|
1063
|
+
settle;
|
|
935
1064
|
release;
|
|
936
1065
|
sequence = 0;
|
|
937
1066
|
stored = 0;
|
|
@@ -945,19 +1074,26 @@ class FileRunJournal {
|
|
|
945
1074
|
// The changes recorded as prepared and not abandoned: those that may have reached a file. One
|
|
946
1075
|
// whose `prepared` line failed is not here, since nothing writes a file before that line.
|
|
947
1076
|
effects = new Set();
|
|
1077
|
+
// The observations begun and not yet observed: what they saw is not all recorded.
|
|
1078
|
+
observing = new Set();
|
|
1079
|
+
// A change it could not copy reached a file, and stays in the history.
|
|
1080
|
+
lost = false;
|
|
1081
|
+
// Settling what it left open as it finished found that it changed nothing.
|
|
1082
|
+
settledEmpty = false;
|
|
948
1083
|
// Appends run one after another, so the lines never interleave.
|
|
949
1084
|
pending = Promise.resolve();
|
|
950
1085
|
finished = false;
|
|
951
1086
|
// Set when an append fails: it may have left part of a line, and anything after it would be
|
|
952
1087
|
// joined to that part and unreadable.
|
|
953
1088
|
broken;
|
|
954
|
-
constructor({ record, runDirectory, contentDirectory, journal, discard, release, }) {
|
|
1089
|
+
constructor({ record, runDirectory, contentDirectory, journal, discard, settle, release, }) {
|
|
955
1090
|
this.runId = record.runId;
|
|
956
1091
|
this.record = record;
|
|
957
1092
|
this.runDirectory = runDirectory;
|
|
958
1093
|
this.contentDirectory = contentDirectory;
|
|
959
1094
|
this.journal = journal;
|
|
960
1095
|
this.discard = discard;
|
|
1096
|
+
this.settle = settle;
|
|
961
1097
|
this.release = release;
|
|
962
1098
|
}
|
|
963
1099
|
async saveContent(content) {
|
|
@@ -1000,6 +1136,7 @@ class FileRunJournal {
|
|
|
1000
1136
|
await this.append({ type: 'observing', sequence, manifest, by });
|
|
1001
1137
|
// Until it is observed, whatever ran may have changed a file.
|
|
1002
1138
|
this.effects.add(sequence);
|
|
1139
|
+
this.observing.add(sequence);
|
|
1003
1140
|
return sequence;
|
|
1004
1141
|
}
|
|
1005
1142
|
async observed(sequence, measure) {
|
|
@@ -1007,6 +1144,12 @@ class FileRunJournal {
|
|
|
1007
1144
|
await this.append({ type: 'observed', sequence, ...(measure ? { measure } : {}) });
|
|
1008
1145
|
// Its changes, if any, are entries of their own now.
|
|
1009
1146
|
this.effects.delete(sequence);
|
|
1147
|
+
this.observing.delete(sequence);
|
|
1148
|
+
}
|
|
1149
|
+
async unrecorded(path, by) {
|
|
1150
|
+
this.assertOpen();
|
|
1151
|
+
await this.append({ type: 'unrecorded', sequence: ++this.sequence, path, by });
|
|
1152
|
+
this.lost = true;
|
|
1010
1153
|
}
|
|
1011
1154
|
get storedBytes() {
|
|
1012
1155
|
return this.stored;
|
|
@@ -1031,7 +1174,7 @@ class FileRunJournal {
|
|
|
1031
1174
|
return this.state.hash;
|
|
1032
1175
|
}
|
|
1033
1176
|
get changed() {
|
|
1034
|
-
return this.effects.size > 0;
|
|
1177
|
+
return !this.settledEmpty && (this.lost || this.effects.size > 0);
|
|
1035
1178
|
}
|
|
1036
1179
|
async finish(status) {
|
|
1037
1180
|
this.assertOpen();
|
|
@@ -1044,8 +1187,17 @@ class FileRunJournal {
|
|
|
1044
1187
|
await this.discard();
|
|
1045
1188
|
return;
|
|
1046
1189
|
}
|
|
1047
|
-
const
|
|
1190
|
+
const unsettled = this.observing.size > 0;
|
|
1191
|
+
const record = {
|
|
1192
|
+
...this.record,
|
|
1193
|
+
status,
|
|
1194
|
+
finishedAt: new Date().toISOString(),
|
|
1195
|
+
...(unsettled ? { unsettled } : {}),
|
|
1196
|
+
};
|
|
1048
1197
|
await writeDurably(join(this.runDirectory, 'run.json'), JSON.stringify(record));
|
|
1198
|
+
// Recorded first as unsettled, so a failure or a crash here leaves it to the next run.
|
|
1199
|
+
if (unsettled)
|
|
1200
|
+
this.settledEmpty = !(await this.settle(record));
|
|
1049
1201
|
}
|
|
1050
1202
|
finally {
|
|
1051
1203
|
await this.release();
|