@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.
Files changed (35) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/modules/database/migrate.d.ts +87 -1
  6. package/dist/modules/database/migrate.d.ts.map +1 -1
  7. package/dist/modules/database/migrate.js +166 -48
  8. package/dist/modules/database/migrate.js.map +1 -1
  9. package/dist/modules/kb-fs/locking-filesystem.d.ts +17 -0
  10. package/dist/modules/kb-fs/locking-filesystem.d.ts.map +1 -1
  11. package/dist/modules/kb-fs/locking-filesystem.js +29 -0
  12. package/dist/modules/kb-fs/locking-filesystem.js.map +1 -1
  13. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  14. package/dist/modules/tool-manuals/tool-manuals.service.js +30 -0
  15. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  16. package/dist/modules/workspace/startup/kb-startup-runner.d.ts +70 -0
  17. package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
  18. package/dist/modules/workspace/startup/kb-startup-runner.js +213 -20
  19. package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
  20. package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
  21. package/dist/modules/workspace/workspace.tools.js +58 -21
  22. package/dist/modules/workspace/workspace.tools.js.map +1 -1
  23. package/kb-template/AGENTS.md +40 -0
  24. package/package.json +3 -3
  25. package/src/index.ts +7 -0
  26. package/src/modules/database/__tests__/pii-backfill.pg.test.ts +223 -4
  27. package/src/modules/database/migrate.ts +238 -52
  28. package/src/modules/kb-fs/__tests__/locking-filesystem.test.ts +93 -0
  29. package/src/modules/kb-fs/locking-filesystem.ts +37 -0
  30. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +151 -0
  31. package/src/modules/tool-manuals/tool-manuals.service.ts +37 -0
  32. package/src/modules/workspace/__tests__/workspace.tools.test.ts +112 -1
  33. package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +231 -1
  34. package/src/modules/workspace/startup/kb-startup-runner.ts +216 -19
  35. 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
- try {
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
- if (!hasGit) {
748
- await fs.mkdir(workspaceDir, { recursive: true });
749
- await fs.rm(repoDir, { recursive: true, force: true });
750
- await git(this.opts.gitRunner, workspaceDir, ['clone', '-b', branch, this.opts.kbRepoUrl(), repoDir]);
751
- // Said as soon as the clone is there, before its configuration: if a
752
- // command below fails, the clone stays on disk, and the retry finds it
753
- // and never comes back through here.
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.onCloneCreated?.(workspaceIdForBranch(branch));
903
+ await this.opts.onCloneDiscarded?.(id);
904
+ return;
756
905
  } catch (err) {
757
- startupLog.warn(`could not announce the fresh working copy for "${branch}":`, {
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
- * Returns the bytes it read (so a caller that needs the content next —
357
- * `edit_file` — does not read the file a second time), or undefined when it
358
- * had no reason to read or nothing existed.
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<Buffer | undefined> {
364
+ ): Promise<void> {
365
365
  const reader = readers.readerFor(path);
366
- if (reader.editRefusalForExisting === undefined) return undefined;
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 undefined; // nothing there yet
375
+ if (isAbsence(err)) return; // nothing there yet
376
376
  throw err;
377
377
  }
378
- const refusal = reader.editRefusalForExisting(existing, path);
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
- // The overwrite gate already read the file when its reader asked the
2371
- // binary question — reuse those bytes instead of reading twice.
2372
- const content = await orNotFound(path, async () => {
2373
- const existing = await assertNotBinaryOverwrite(readers, path, fs);
2374
- return asText(existing ?? (await fs.readFile(path)));
2375
- });
2376
- const count = oldStr ? content.split(oldStr).length - 1 : 0;
2377
- if (count === 0) throw new ToolError('old_string not found in the file.', 400);
2378
- if (count > 1 && a.replace_all !== true) {
2379
- throw new ToolError(`old_string appears ${count} times — add more context to make it unique, or set replace_all.`, 400);
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
- const updated = a.replace_all === true ? content.split(oldStr).join(newStr) : content.replace(oldStr, newStr);
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