@bevel-software/platform-core-backend 0.24.0 → 0.25.2
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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/modules/database/migrate.d.ts +87 -1
- package/dist/modules/database/migrate.d.ts.map +1 -1
- package/dist/modules/database/migrate.js +166 -48
- package/dist/modules/database/migrate.js.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
- package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/kb-fs/locking-filesystem.js +29 -0
- package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +58 -21
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/kb-template/AGENTS.md +40 -0
- package/package.json +3 -3
- package/src/index.ts +7 -0
- package/src/modules/database/__tests__/pii-backfill.pg.test.ts +223 -4
- package/src/modules/database/migrate.ts +238 -52
- package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
- package/src/modules/kb-fs/locking-filesystem.ts +37 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +112 -1
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
- package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
- package/src/modules/workspace/workspace.tools.ts +59 -21
|
@@ -122,6 +122,46 @@ export interface KbStartupRunnerOptions {
|
|
|
122
122
|
* Never throws into the phase, for the same reason as above.
|
|
123
123
|
*/
|
|
124
124
|
onCloneCreated?: (workspaceId: string) => void;
|
|
125
|
+
/**
|
|
126
|
+
* How long nothing may have been written to an unfinished clone before it
|
|
127
|
+
* is taken for abandoned and set aside. A clone that is still running looks
|
|
128
|
+
* the same on disk, so this is what tells the two apart. Defaults to a
|
|
129
|
+
* minute; a suite passes a short one.
|
|
130
|
+
*/
|
|
131
|
+
unfinishedCloneQuietMs?: number;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const UNFINISHED_CLONE_QUIET_MS = 60_000;
|
|
135
|
+
/** The floor `kb-git.ts` gives a startup git command, a clone included. */
|
|
136
|
+
const LONGEST_CLONE_MS = 600_000;
|
|
137
|
+
/**
|
|
138
|
+
* How many times one working copy is read before the start gives up on it.
|
|
139
|
+
* A pass ends by acting or by finding the path changed; a path that changes
|
|
140
|
+
* on every one of these is being worked on by something that will not stop.
|
|
141
|
+
*/
|
|
142
|
+
const PASSES_OVER_ONE_WORKING_COPY = 4;
|
|
143
|
+
|
|
144
|
+
/** Whether `git clone` failed because its destination already held something, having written nothing. */
|
|
145
|
+
function refusedAnOccupiedFolder(err: unknown): boolean {
|
|
146
|
+
return err instanceof Error && /already exists and is not an empty directory/.test(err.message);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* When anything under `dir` was last written, as a time in milliseconds. Now,
|
|
151
|
+
* for a folder that cannot be read: "could not tell" counts as still in use.
|
|
152
|
+
*/
|
|
153
|
+
async function newestChangeUnder(dir: string): Promise<number> {
|
|
154
|
+
let newest = 0;
|
|
155
|
+
try {
|
|
156
|
+
newest = (await fs.stat(dir)).mtimeMs;
|
|
157
|
+
for (const entry of await fs.readdir(dir, { recursive: true, withFileTypes: true })) {
|
|
158
|
+
const stat = await fs.stat(path.join(entry.parentPath, entry.name)).catch(() => null);
|
|
159
|
+
if (stat && stat.mtimeMs > newest) newest = stat.mtimeMs;
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
return Date.now();
|
|
163
|
+
}
|
|
164
|
+
return newest;
|
|
125
165
|
}
|
|
126
166
|
|
|
127
167
|
/**
|
|
@@ -678,13 +718,7 @@ export class KbStartupRunner {
|
|
|
678
718
|
// Setting it aside already removed it from the workspaces root, so the
|
|
679
719
|
// cached handle the workspace service holds for it now points at
|
|
680
720
|
// nothing.
|
|
681
|
-
|
|
682
|
-
await this.opts.onCloneDiscarded?.(entry.name);
|
|
683
|
-
} catch (err) {
|
|
684
|
-
startupLog.warn(`could not finish setting the working copy "${entry.name}" aside:`, {
|
|
685
|
-
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
686
|
-
});
|
|
687
|
-
}
|
|
721
|
+
await this.announceDiscarded(entry.name);
|
|
688
722
|
}
|
|
689
723
|
}
|
|
690
724
|
|
|
@@ -741,27 +775,168 @@ export class KbStartupRunner {
|
|
|
741
775
|
* commit recovery owns that work, not this phase.
|
|
742
776
|
*/
|
|
743
777
|
private async ensureClone(branch: string): Promise<string> {
|
|
778
|
+
try {
|
|
779
|
+
return await this.cloneOrUpdate(branch);
|
|
780
|
+
} catch (err) {
|
|
781
|
+
// One branch's working copy is what failed, and every boot visits all
|
|
782
|
+
// of them: said here, so the log names the one to look at. The kind of
|
|
783
|
+
// failure git's words were read as is kept, since the setup screen and
|
|
784
|
+
// the degraded start decide by it.
|
|
785
|
+
const message = `the working copy of branch "${branch}" could not be prepared: ${err instanceof Error ? err.message : String(err)}`;
|
|
786
|
+
throw new ClassifiedFailure(message, failureOf(err), { cause: err });
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/**
|
|
791
|
+
* Decided from what is at the path NOW, and decided again after anything
|
|
792
|
+
* that waited. This phase is not the only thing that touches a working
|
|
793
|
+
* copy: on a running server a branch being opened clones into the same
|
|
794
|
+
* path, and on a redeploy two processes share the volume for a few seconds.
|
|
795
|
+
* So nothing here acts on an answer it read before a wait. Each pass reads
|
|
796
|
+
* the path once and does one thing about it; a pass that found the path
|
|
797
|
+
* changed under it does nothing and lets the next one read it again.
|
|
798
|
+
*/
|
|
799
|
+
private async cloneOrUpdate(branch: string): Promise<string> {
|
|
744
800
|
const workspaceDir = path.join(this.opts.workspacesRoot, workspaceIdForBranch(branch));
|
|
745
801
|
const repoDir = path.join(workspaceDir, this.opts.kbDirName);
|
|
802
|
+
for (let pass = 1; ; pass++) {
|
|
803
|
+
const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
|
|
804
|
+
if (hasGit && (await this.hasCommit(repoDir))) return this.updateClone(branch, repoDir);
|
|
805
|
+
if (pass > PASSES_OVER_ONE_WORKING_COPY) {
|
|
806
|
+
throw new Error('it kept changing while this start was preparing it; something else is working on it');
|
|
807
|
+
}
|
|
808
|
+
if (hasGit) {
|
|
809
|
+
await this.setAsideUnfinishedClone(workspaceIdForBranch(branch), repoDir);
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
try {
|
|
813
|
+
return await this.cloneFresh(branch, workspaceDir, repoDir);
|
|
814
|
+
} catch (err) {
|
|
815
|
+
// Git refuses to clone into a folder that already holds something,
|
|
816
|
+
// and writes nothing when it does. If that is what happened, somebody
|
|
817
|
+
// else put a clone here between the look above and this one: theirs
|
|
818
|
+
// is read on the next pass. Any other failure is this clone's own.
|
|
819
|
+
if (!refusedAnOccupiedFolder(err)) throw err;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* A `.git` WITH NO COMMIT IS NOT A CLONE. It is what a clone leaves when it
|
|
826
|
+
* is cut short (the process stopped, the disk filled), and it is found
|
|
827
|
+
* again on every start: nothing can fast-forward a branch that has no
|
|
828
|
+
* commit, so the boot failed on it for good, and one such folder kept the
|
|
829
|
+
* whole deployment from starting.
|
|
830
|
+
*
|
|
831
|
+
* It holds no commit, so no committed work of anyone's. It may still hold
|
|
832
|
+
* files somebody put there, so it is MOVED, never deleted, to where every
|
|
833
|
+
* other set-aside working copy goes. The caller clones again.
|
|
834
|
+
*
|
|
835
|
+
* It is also exactly what a clone that is STILL RUNNING looks like: git
|
|
836
|
+
* makes `.git` first and a commit appears only at the end. So it is left
|
|
837
|
+
* alone until nothing has been written to it for a while, and read once
|
|
838
|
+
* more after every wait. Returns having moved it, or having found nothing
|
|
839
|
+
* of the kind left to move.
|
|
840
|
+
*/
|
|
841
|
+
private async setAsideUnfinishedClone(id: string, repoDir: string): Promise<void> {
|
|
842
|
+
if (!(await this.waitUntilNothingWritesTo(repoDir))) return;
|
|
843
|
+
await this.opts.beforeCloneSetAside?.(id);
|
|
844
|
+
// The hook above may have waited. What is here now decides.
|
|
845
|
+
if (!(await this.isUnfinishedClone(repoDir))) return;
|
|
846
|
+
const kept = path.join(setAsideRootFor(this.opts.workspacesRoot, this.opts.setAsideRoot), setAsideStamp(), id);
|
|
847
|
+
try {
|
|
848
|
+
await setAsideClone(repoDir, kept);
|
|
849
|
+
} catch (err) {
|
|
850
|
+
// Taken away by somebody else in the instant since it was read: there
|
|
851
|
+
// is nothing left to move, which is not a reason to stop the start.
|
|
852
|
+
// Only a move this call made is logged and announced.
|
|
853
|
+
if (await fs.access(repoDir).then(() => true, () => false)) throw err;
|
|
854
|
+
await fs.rmdir(path.dirname(kept)).catch(() => undefined);
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
startupLog.warn(
|
|
858
|
+
`working copy "${id}" has a git folder and no commit: a clone that was never finished. ` +
|
|
859
|
+
`Set aside at ${kept}; nothing was deleted, and it is cloned again now.`,
|
|
860
|
+
);
|
|
861
|
+
await this.announceDiscarded(id);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private async isUnfinishedClone(repoDir: string): Promise<boolean> {
|
|
746
865
|
const hasGit = await fs.access(path.join(repoDir, '.git')).then(() => true, () => false);
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
866
|
+
return hasGit && !(await this.hasCommit(repoDir));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Wait until nothing under the unfinished clone's `.git` has been written
|
|
871
|
+
* for {@link KbStartupRunnerOptions.unfinishedCloneQuietMs}. True when it is
|
|
872
|
+
* then still an unfinished clone; false when it finished or went away
|
|
873
|
+
* meanwhile, so there is nothing to set aside.
|
|
874
|
+
*
|
|
875
|
+
* A running clone that has written nothing for that long (a remote still
|
|
876
|
+
* counting objects on a very large repository) is not told apart from an
|
|
877
|
+
* abandoned one. Bounded by the longest a clone may run plus the quiet
|
|
878
|
+
* time: past that, whatever is writing is not a clone.
|
|
879
|
+
*/
|
|
880
|
+
private async waitUntilNothingWritesTo(repoDir: string): Promise<boolean> {
|
|
881
|
+
const quietMs = this.opts.unfinishedCloneQuietMs ?? UNFINISHED_CLONE_QUIET_MS;
|
|
882
|
+
const giveUpAt = Date.now() + Math.max(this.opts.gitRunner.defaultTimeoutMs, LONGEST_CLONE_MS) + quietMs;
|
|
883
|
+
for (;;) {
|
|
884
|
+
if (!(await this.isUnfinishedClone(repoDir))) return false;
|
|
885
|
+
const lastWrite = await newestChangeUnder(path.join(repoDir, '.git'));
|
|
886
|
+
const quietFor = Date.now() - lastWrite;
|
|
887
|
+
if (quietFor >= quietMs) return true;
|
|
888
|
+
if (Date.now() > giveUpAt) throw new Error('an unfinished clone at its path is still being written to');
|
|
889
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(Math.max(quietMs - quietFor, 25), 1_000)));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/**
|
|
894
|
+
* Tell the rest of the process a working copy was set aside. The listener
|
|
895
|
+
* releases, a second time, the work queued against the copy that went;
|
|
896
|
+
* skipped, those rows would be committed into the clone that replaces it.
|
|
897
|
+
* So it is tried twice before the failure is only logged. Never throws
|
|
898
|
+
* into the phase: a listener that fails must not stop a boot.
|
|
899
|
+
*/
|
|
900
|
+
private async announceDiscarded(id: string): Promise<void> {
|
|
901
|
+
for (let attempt = 1; ; attempt++) {
|
|
754
902
|
try {
|
|
755
|
-
this.opts.
|
|
903
|
+
await this.opts.onCloneDiscarded?.(id);
|
|
904
|
+
return;
|
|
756
905
|
} catch (err) {
|
|
757
|
-
|
|
906
|
+
if (attempt < 2) continue;
|
|
907
|
+
startupLog.warn(`could not finish setting the working copy "${id}" aside:`, {
|
|
758
908
|
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
759
909
|
});
|
|
910
|
+
return;
|
|
760
911
|
}
|
|
761
|
-
await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
|
|
762
|
-
await stampIdentity(this.opts.gitRunner, repoDir);
|
|
763
|
-
return repoDir;
|
|
764
912
|
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
private async cloneFresh(branch: string, workspaceDir: string, repoDir: string): Promise<string> {
|
|
916
|
+
await fs.mkdir(workspaceDir, { recursive: true });
|
|
917
|
+
// Only what cannot be a clone is cleared out of the way: a folder that
|
|
918
|
+
// gained a `.git` since the caller looked is somebody's clone, and git
|
|
919
|
+
// refuses it below rather than this removing it.
|
|
920
|
+
if (!(await fs.access(path.join(repoDir, '.git')).then(() => true, () => false))) {
|
|
921
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
922
|
+
}
|
|
923
|
+
await git(this.opts.gitRunner, workspaceDir, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
|
|
924
|
+
// Said as soon as the clone is there, before its configuration: if a
|
|
925
|
+
// command below fails, the clone stays on disk, and the retry finds it
|
|
926
|
+
// and never comes back through here.
|
|
927
|
+
try {
|
|
928
|
+
this.opts.onCloneCreated?.(workspaceIdForBranch(branch));
|
|
929
|
+
} catch (err) {
|
|
930
|
+
startupLog.warn(`could not announce the fresh working copy for "${branch}":`, {
|
|
931
|
+
detail: this.redact(err instanceof Error ? err.message : String(err)),
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
await git(this.opts.gitRunner, repoDir, ['config', 'core.longpaths', 'true']);
|
|
935
|
+
await stampIdentity(this.opts.gitRunner, repoDir);
|
|
936
|
+
return repoDir;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private async updateClone(branch: string, repoDir: string): Promise<string> {
|
|
765
940
|
await git(this.opts.gitRunner, repoDir, ['fetch', 'origin', branch]);
|
|
766
941
|
const local = (await git(this.opts.gitRunner, repoDir, ['rev-parse', 'HEAD'])).trim();
|
|
767
942
|
const remote = (await git(this.opts.gitRunner, repoDir, ['rev-parse', `origin/${branch}`])).trim();
|
|
@@ -775,6 +950,28 @@ export class KbStartupRunner {
|
|
|
775
950
|
return repoDir;
|
|
776
951
|
}
|
|
777
952
|
|
|
953
|
+
/**
|
|
954
|
+
* Whether the repository at `repoDir` has a commit checked out. False for a
|
|
955
|
+
* clone that was cut short.
|
|
956
|
+
*
|
|
957
|
+
* False ONLY when git itself answered that there is none: with `--quiet
|
|
958
|
+
* --verify` that is exit status 1 and nothing else. A git that timed out,
|
|
959
|
+
* could not be started, or could not read the repository has not said the
|
|
960
|
+
* working copy is empty, and "could not tell" is no reason to move
|
|
961
|
+
* someone's work: that failure is thrown, and stops the start with the
|
|
962
|
+
* branch named.
|
|
963
|
+
*/
|
|
964
|
+
private async hasCommit(repoDir: string): Promise<boolean> {
|
|
965
|
+
try {
|
|
966
|
+
await git(this.opts.gitRunner, repoDir, ['rev-parse', '--quiet', '--verify', 'HEAD^{commit}']);
|
|
967
|
+
return true;
|
|
968
|
+
} catch (err) {
|
|
969
|
+
const ran = err instanceof Error ? err.cause : undefined;
|
|
970
|
+
if (ran instanceof GitRunError && !ran.timedOut && ran.exitCode === 1) return false;
|
|
971
|
+
throw err;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
778
975
|
/** One commit per dirty branch; push; the replica carve-out on rejection. */
|
|
779
976
|
private async finalize(h: BranchHandle): Promise<void> {
|
|
780
977
|
if (!h.dirty) return;
|
|
@@ -353,17 +353,17 @@ function assertNotDocumentEdit(readers: FileReaderRegistry, path: string): void
|
|
|
353
353
|
*
|
|
354
354
|
* Costs one read of the existing file, and only for readers that ask the
|
|
355
355
|
* question. A path with nothing at it is a CREATE: there is nothing to destroy.
|
|
356
|
-
*
|
|
357
|
-
* `edit_file`
|
|
358
|
-
*
|
|
356
|
+
* For the tools that REPLACE a file without needing what it held (`write_file`,
|
|
357
|
+
* `write_files`); `edit_file` holds the bytes already and asks
|
|
358
|
+
* `assertBytesTextEditable` of each reading it takes.
|
|
359
359
|
*/
|
|
360
360
|
async function assertNotBinaryOverwrite(
|
|
361
361
|
readers: FileReaderRegistry,
|
|
362
362
|
path: string,
|
|
363
363
|
fs: { readFile(p: string): Promise<string | Buffer> },
|
|
364
|
-
): Promise<
|
|
364
|
+
): Promise<void> {
|
|
365
365
|
const reader = readers.readerFor(path);
|
|
366
|
-
if (reader.editRefusalForExisting === undefined) return
|
|
366
|
+
if (reader.editRefusalForExisting === undefined) return;
|
|
367
367
|
let existing: Buffer;
|
|
368
368
|
try {
|
|
369
369
|
existing = asBytes(await fs.readFile(path));
|
|
@@ -372,12 +372,21 @@ async function assertNotBinaryOverwrite(
|
|
|
372
372
|
// FileNotFoundError carry the disk's absence codes). Any other failure —
|
|
373
373
|
// permissions, I/O — means the existing content could not be inspected:
|
|
374
374
|
// propagate it rather than let the write destroy bytes the gate never saw.
|
|
375
|
-
if (isAbsence(err)) return
|
|
375
|
+
if (isAbsence(err)) return; // nothing there yet
|
|
376
376
|
throw err;
|
|
377
377
|
}
|
|
378
|
-
|
|
378
|
+
assertBytesTextEditable(readers, path, existing);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* The same refusal, over bytes the caller already holds. Split out so a tool
|
|
383
|
+
* that reads the file more than once — `edit_file`, before the lock and again
|
|
384
|
+
* under it — judges EVERY reading with the one rule, and the bytes it replaces
|
|
385
|
+
* are always bytes this gate has seen.
|
|
386
|
+
*/
|
|
387
|
+
function assertBytesTextEditable(readers: FileReaderRegistry, path: string, existing: Buffer): void {
|
|
388
|
+
const refusal = readers.readerFor(path).editRefusalForExisting?.(existing, path) ?? null;
|
|
379
389
|
if (refusal !== null) throw binaryNotWritable('binary', refusal);
|
|
380
|
-
return existing;
|
|
381
390
|
}
|
|
382
391
|
|
|
383
392
|
/** What a write is ALLOWED to do at a path. `create` is the default everywhere. */
|
|
@@ -2367,20 +2376,49 @@ export function registerWorkspaceTools(
|
|
|
2367
2376
|
const path = a.path as string;
|
|
2368
2377
|
const oldStr = a.old_string as string;
|
|
2369
2378
|
const newStr = a.new_string as string;
|
|
2370
|
-
//
|
|
2371
|
-
//
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2379
|
+
// Everything the tool decides about ONE reading of the file: may these
|
|
2380
|
+
// bytes be edited as text at all, is `old_string` there, is it unique,
|
|
2381
|
+
// and what the file becomes. One function, because the file is read
|
|
2382
|
+
// twice — before the lock and under it — and a reading that skipped any
|
|
2383
|
+
// of these questions would let bytes land that were never judged.
|
|
2384
|
+
// `split`/`join`, not `String.replace`, which reads `$&`, `$'`, `` $` ``
|
|
2385
|
+
// and `$$` in `new_string` as patterns and writes something the caller
|
|
2386
|
+
// never sent.
|
|
2387
|
+
const edit = (existing: Buffer): { updated: string; replaced: number } => {
|
|
2388
|
+
assertBytesTextEditable(readers, path, existing);
|
|
2389
|
+
const text = asText(existing);
|
|
2390
|
+
const pieces = oldStr ? text.split(oldStr) : [text];
|
|
2391
|
+
const count = pieces.length - 1;
|
|
2392
|
+
if (count === 0) throw new ToolError('old_string not found in the file.', 400);
|
|
2393
|
+
if (count > 1 && a.replace_all !== true) {
|
|
2394
|
+
throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
|
|
2395
|
+
}
|
|
2396
|
+
return { updated: pieces.join(newStr), replaced: count };
|
|
2397
|
+
};
|
|
2398
|
+
// A first verdict before any lock is taken, so an ordinary refusal costs
|
|
2399
|
+
// no lock cycle. It is a verdict about a file anyone may still change.
|
|
2400
|
+
let result = edit(await orNotFound(path, async () => asBytes(await fs.readFile(path))));
|
|
2401
|
+
// The one the answer carries is taken again with the path's lock HELD,
|
|
2402
|
+
// over the bytes read there (`write: true` guarantees the locking
|
|
2403
|
+
// filesystem): read, verdict and write are one step nobody can get
|
|
2404
|
+
// between. Taken before the lock only, two callers replacing the same
|
|
2405
|
+
// text — two runners claiming a work item by filling its empty owner
|
|
2406
|
+
// field — were BOTH told their edit landed, and the second silently
|
|
2407
|
+
// overwrote the first. A filesystem without the method has no lock to
|
|
2408
|
+
// read under, so the first verdict stands.
|
|
2409
|
+
const locking = fs as unknown as {
|
|
2410
|
+
rewriteFile?(path: string, rewrite: (current: Buffer | null) => string): Promise<void>;
|
|
2411
|
+
};
|
|
2412
|
+
if (typeof locking.rewriteFile === 'function') {
|
|
2413
|
+
await locking.rewriteFile(path, (current) => {
|
|
2414
|
+
if (current === null) throw notFound(path);
|
|
2415
|
+
result = edit(current);
|
|
2416
|
+
return result.updated;
|
|
2417
|
+
});
|
|
2418
|
+
} else {
|
|
2419
|
+
await fs.writeFile(path, result.updated);
|
|
2380
2420
|
}
|
|
2381
|
-
|
|
2382
|
-
await fs.writeFile(path, updated);
|
|
2383
|
-
return { path, replaced: a.replace_all === true ? count : 1, ...(await saveWarnings(ctx, path, updated)) };
|
|
2421
|
+
return { path, replaced: result.replaced, ...(await saveWarnings(ctx, path, result.updated)) };
|
|
2384
2422
|
},
|
|
2385
2423
|
});
|
|
2386
2424
|
|