From 85206a5ea60f152b4df05491b73ec42a4a7c6ebd Mon Sep 17 00:00:00 2001 From: Yishay Polatov Date: Tue, 18 Aug 2026 02:18:32 +0300 Subject: [PATCH 1/7] feat(overleaf): add manual bidirectional sync --- .../workflows/scient-upstream-provenance.yml | 4 + .../server/src/scient/overleaf/ManagedTree.ts | 309 +++ .../overleaf/OverleafGitExecutor.test.ts | 62 + .../scient/overleaf/OverleafGitExecutor.ts | 456 +++ .../src/scient/overleaf/OverleafMirror.ts | 934 +++++++ .../src/scient/overleaf/OverleafStateStore.ts | 956 +++++++ .../scient/overleaf/OverleafSyncService.ts | 2437 +++++++++++++++++ .../overleaf/WorkspaceProjector.test.ts | 166 ++ .../src/scient/overleaf/WorkspaceProjector.ts | 323 +++ apps/server/src/scient/overleaf/http.ts | 153 ++ apps/server/src/scient/overleaf/model.ts | 83 + .../server/src/scient/overleaf/policy.test.ts | 107 + apps/server/src/scient/overleaf/urls.test.ts | 74 + apps/server/src/scient/overleaf/urls.ts | 92 + apps/server/src/server.ts | 32 + .../settings/ProjectSettingsPanel.tsx | 7 + .../src/scient/latex/ScientLatexSurface.tsx | 28 +- .../overleaf/OverleafLatexToolbarControl.tsx | 179 ++ .../overleaf/OverleafProjectSettings.tsx | 1741 ++++++++++++ .../src/scient/overleaf/OverleafTextDiff.tsx | 47 + apps/web/src/scient/overleaf/client.ts | 137 + docs/README.md | 2 + docs/internals/scient-overleaf.md | 86 + docs/user/overleaf-sync.md | 77 + package.json | 1 + packages/client-runtime/package.json | 4 + .../src/state/scientOverleafHttp.ts | 270 ++ packages/contracts/src/environmentHttp.ts | 177 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/scientOverleaf.ts | 344 +++ scient-overleaf-seams.json | 65 + scripts/verify-scient-overleaf-seams.mjs | 143 + 32 files changed, 9496 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/scient/overleaf/ManagedTree.ts create mode 100644 apps/server/src/scient/overleaf/OverleafGitExecutor.test.ts create mode 100644 apps/server/src/scient/overleaf/OverleafGitExecutor.ts create mode 100644 apps/server/src/scient/overleaf/OverleafMirror.ts create mode 100644 apps/server/src/scient/overleaf/OverleafStateStore.ts create mode 100644 apps/server/src/scient/overleaf/OverleafSyncService.ts create mode 100644 apps/server/src/scient/overleaf/WorkspaceProjector.test.ts create mode 100644 apps/server/src/scient/overleaf/WorkspaceProjector.ts create mode 100644 apps/server/src/scient/overleaf/http.ts create mode 100644 apps/server/src/scient/overleaf/model.ts create mode 100644 apps/server/src/scient/overleaf/policy.test.ts create mode 100644 apps/server/src/scient/overleaf/urls.test.ts create mode 100644 apps/server/src/scient/overleaf/urls.ts create mode 100644 apps/web/src/scient/overleaf/OverleafLatexToolbarControl.tsx create mode 100644 apps/web/src/scient/overleaf/OverleafProjectSettings.tsx create mode 100644 apps/web/src/scient/overleaf/OverleafTextDiff.tsx create mode 100644 apps/web/src/scient/overleaf/client.ts create mode 100644 docs/internals/scient-overleaf.md create mode 100644 docs/user/overleaf-sync.md create mode 100644 packages/client-runtime/src/state/scientOverleafHttp.ts create mode 100644 packages/contracts/src/scientOverleaf.ts create mode 100644 scient-overleaf-seams.json create mode 100644 scripts/verify-scient-overleaf-seams.mjs diff --git a/.github/workflows/scient-upstream-provenance.yml b/.github/workflows/scient-upstream-provenance.yml index e64a8c6d31..9b2ea2bf07 100644 --- a/.github/workflows/scient-upstream-provenance.yml +++ b/.github/workflows/scient-upstream-provenance.yml @@ -43,3 +43,7 @@ jobs: --base "$VERIFY_BASE" \ --head "$VERIFY_HEAD" \ --upstream-ref refs/remotes/upstream-verification/main + node scripts/verify-scient-overleaf-seams.mjs \ + --base "$VERIFY_BASE" \ + --head "$VERIFY_HEAD" \ + --upstream-ref refs/remotes/upstream-verification/main diff --git a/apps/server/src/scient/overleaf/ManagedTree.ts b/apps/server/src/scient/overleaf/ManagedTree.ts new file mode 100644 index 0000000000..a7c798b70a --- /dev/null +++ b/apps/server/src/scient/overleaf/ManagedTree.ts @@ -0,0 +1,309 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { ScientOverleafWarning } from "@t3tools/contracts"; +import { ScientOverleafOperationError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import type { TreeFile, TreeManifest } from "./model.ts"; +import { OverleafGitExecutor } from "./OverleafGitExecutor.ts"; +import { OverleafStateStore } from "./OverleafStateStore.ts"; + +const EDITABLE_TEXT_BYTES = 2 * 1024 * 1024; +const EDITABLE_MATERIAL_BYTES = 7 * 1024 * 1024; +const LARGE_FILE_BYTES = 50 * 1024 * 1024; +const PROJECT_BYTES = 100 * 1024 * 1024; +const PROJECT_FILES = 2_000; +const EDITABLE_EXTENSIONS = new Set([".tex", ".bib", ".cls", ".sty", ".bst", ".txt", ".md"]); +const MATERIAL_EXTENSIONS = new Set([...EDITABLE_EXTENSIONS, ".svg", ".eps", ".pdf"]); + +function unsafe(message: string): ScientOverleafOperationError { + return new ScientOverleafOperationError({ code: "unsafe_tree", message, retryable: false }); +} +const isOverleafOperationError = Schema.is(ScientOverleafOperationError); +const hasControlCharacter = (value: string) => + [...value].some((character) => character.charCodeAt(0) <= 0x1f); + +export function normalizeManagedPath(input: string): string { + const normalized = input.replaceAll("\\", "/").replace(/^\.\//u, ""); + const parts = normalized.split("/"); + if ( + normalized.length === 0 || + normalized.length > 4_096 || + normalized.startsWith("/") || + /^[A-Za-z]:/u.test(normalized) || + parts.some((part) => part.length === 0 || part === "." || part === ".." || part === ".git") + ) { + throw unsafe("The project contains a path the app cannot synchronize safely."); + } + for (const part of parts) { + if (/^[ .]|[ .]$/u.test(part) || /[<>:"|?*]/u.test(part) || hasControlCharacter(part)) { + throw unsafe("The project contains a path that is invalid on a supported platform."); + } + const stem = part.split(".")[0]!.toUpperCase(); + if (/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/u.test(stem)) { + throw unsafe("The project contains a Windows-reserved path."); + } + } + return normalized; +} + +async function hashFile(filePath: string): Promise<{ hash: string; size: number }> { + const handle = await NodeFSP.open(filePath, "r"); + try { + const hash = NodeCrypto.createHash("sha256"); + let size = 0; + const buffer = Buffer.allocUnsafe(256 * 1024); + for (;;) { + const result = await handle.read(buffer, 0, buffer.length, null); + if (result.bytesRead === 0) break; + hash.update(buffer.subarray(0, result.bytesRead)); + size += result.bytesRead; + } + return { hash: hash.digest("hex"), size }; + } finally { + await handle.close(); + } +} + +async function inspectStructure(root: string): Promise { + const visit = async (directory: string): Promise => { + for (const entry of await NodeFSP.readdir(directory, { withFileTypes: true })) { + const absolute = NodePath.join(directory, entry.name); + const info = await NodeFSP.lstat(absolute); + if (info.isSymbolicLink()) + throw unsafe("Symlinks and junctions are not supported by Overleaf sync."); + if (entry.name === ".git") { + if (directory !== root) + throw unsafe("Embedded Git repositories are not supported by Overleaf sync."); + continue; + } + if (info.isDirectory()) await visit(absolute); + } + }; + await visit(root); +} + +export function advisoryWarnings(manifest: TreeManifest): ReadonlyArray { + const warnings: ScientOverleafWarning[] = []; + if (manifest.files.length > PROJECT_FILES) + warnings.push({ + kind: "file_count", + message: `This candidate contains ${manifest.files.length.toLocaleString()} files.`, + paths: [], + blocking: true, + suppressible: false, + }); + const largeFiles = manifest.files + .filter((file) => file.size > LARGE_FILE_BYTES) + .map((file) => file.path); + if (largeFiles.length > 0) + warnings.push({ + kind: "large_file", + message: "Some files exceed 50 MB.", + paths: largeFiles, + blocking: true, + suppressible: false, + }); + const largeText = manifest.files + .filter( + (file) => + EDITABLE_EXTENSIONS.has(NodePath.extname(file.path).toLowerCase()) && + file.size > EDITABLE_TEXT_BYTES, + ) + .map((file) => file.path); + if (largeText.length > 0) + warnings.push({ + kind: "large_editable_text", + message: "Some editable text files exceed 2 MB.", + paths: largeText, + blocking: true, + suppressible: false, + }); + const largeMaterial = manifest.files + .filter( + (file) => + MATERIAL_EXTENSIONS.has(NodePath.extname(file.path).toLowerCase()) && + file.size > EDITABLE_MATERIAL_BYTES, + ) + .map((file) => file.path); + if (largeMaterial.length > 0) + warnings.push({ + kind: "large_editable_material", + message: "Some editable project material exceeds 7 MB.", + paths: largeMaterial, + blocking: true, + suppressible: false, + }); + if (manifest.totalBytes > PROJECT_BYTES) + warnings.push({ + kind: "project_size", + message: "This project exceeds the recommended 100 MB Git-integration size.", + paths: [], + blocking: true, + suppressible: false, + }); + return warnings; +} + +export class ManagedTree extends Context.Service< + ManagedTree, + { + readonly scan: (input: { + readonly operationId: string; + readonly root: string; + readonly trackedPaths?: ReadonlyArray; + readonly excludedPaths?: ReadonlyArray; + }) => Effect.Effect; + readonly hashFile: ( + path: string, + ) => Effect.Effect<{ hash: string; size: number }, ScientOverleafOperationError>; + } +>()("t3/scient/overleaf/ManagedTree") {} + +export const make = Effect.fn("ManagedTree.make")(function* () { + const git = yield* OverleafGitExecutor; + const state = yield* OverleafStateStore; + const hashFileEffect = (filePath: string) => + Effect.tryPromise({ + try: () => hashFile(filePath), + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to read a project file.", + retryable: true, + }), + }); + + const scan: ManagedTree["Service"]["scan"] = Effect.fnUntraced(function* (input) { + const absoluteRoot = NodePath.resolve(input.root); + const rootInfo = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(absoluteRoot), + catch: () => unsafe("The selected project folder is unavailable."), + }); + if (!rootInfo.isDirectory() || rootInfo.isSymbolicLink()) + return yield* unsafe("The selected project folder must be a real directory."); + yield* Effect.tryPromise({ + try: () => inspectStructure(absoluteRoot), + catch: (cause) => + isOverleafOperationError(cause) + ? cause + : unsafe("The project tree could not be inspected safely."), + }); + const ignoreRepository = NodePath.join( + state.operationDirectory(input.operationId), + "ignore-index.git", + ); + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(NodePath.dirname(ignoreRepository), { recursive: true }), + catch: () => unsafe("Unable to prepare project scanning."), + }); + yield* git + .execute({ + operationId: input.operationId, + cwd: absoluteRoot, + args: ["init", "--bare", ignoreRepository], + }) + .pipe( + Effect.mapError( + () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to prepare Git ignore evaluation.", + retryable: true, + }), + ), + ); + const listed = yield* git + .execute({ + operationId: input.operationId, + cwd: absoluteRoot, + args: [ + `--git-dir=${ignoreRepository}`, + `--work-tree=${absoluteRoot}`, + "ls-files", + "--others", + "--exclude-standard", + "-z", + ], + maxOutputBytes: 32 * 1024 * 1024, + }) + .pipe( + Effect.mapError( + () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to evaluate the project .gitignore.", + retryable: true, + }), + ), + ); + const included = new Set(listed.stdout.split("\0").filter(Boolean).map(normalizeManagedPath)); + for (const tracked of input.trackedPaths ?? []) included.add(normalizeManagedPath(tracked)); + for (const excluded of input.excludedPaths ?? []) + included.delete(normalizeManagedPath(excluded)); + + for (const attributesPath of [...included].filter( + (relative) => NodePath.posix.basename(relative) === ".gitattributes", + )) { + const attributes = yield* Effect.tryPromise({ + try: async () => { + const absolute = NodePath.join(absoluteRoot, ...attributesPath.split("/")); + const info = await NodeFSP.lstat(absolute); + if (!info.isFile() || info.isSymbolicLink() || info.size > 8 * 1024 * 1024) + throw unsafe("A .gitattributes file cannot be inspected safely."); + return await NodeFSP.readFile(absolute, "utf8"); + }, + catch: (cause) => + isOverleafOperationError(cause) + ? cause + : unsafe("A .gitattributes file cannot be inspected safely."), + }); + if (/(?:^|\s)filter=lfs(?:\s|$)/mu.test(attributes)) + return yield* new ScientOverleafOperationError({ + code: "unsupported_tree", + message: "Git LFS paths are not supported by Overleaf sync.", + retryable: false, + }); + } + + const caseFolded = new Map(); + const files: TreeFile[] = []; + for (const relative of [...included].toSorted()) { + const folded = relative.toLocaleLowerCase("en-US"); + const previous = caseFolded.get(folded); + if (previous !== undefined && previous !== relative) + return yield* unsafe("The project contains paths that collide by letter case."); + caseFolded.set(folded, relative); + const absolute = NodePath.resolve(absoluteRoot, ...relative.split("/")); + if (absolute !== absoluteRoot && !absolute.startsWith(`${absoluteRoot}${NodePath.sep}`)) + return yield* unsafe("A project path escapes the selected folder."); + const info = yield* Effect.tryPromise({ + try: async () => { + try { + return await NodeFSP.lstat(absolute); + } catch { + return null; + } + }, + catch: () => unsafe("A project file could not be inspected."), + }); + if (info === null) continue; + if (info.isSymbolicLink()) + return yield* unsafe("Symlinks and junctions are not supported by Overleaf sync."); + if (!info.isFile()) continue; + const hashed = yield* hashFileEffect(absolute); + files.push({ path: relative, hash: hashed.hash, size: hashed.size, executable: false }); + } + return { files, totalBytes: files.reduce((total, file) => total + file.size, 0) }; + }); + + return ManagedTree.of({ scan, hashFile: hashFileEffect }); +}); + +export const layer = Layer.effect(ManagedTree, make()); diff --git a/apps/server/src/scient/overleaf/OverleafGitExecutor.test.ts b/apps/server/src/scient/overleaf/OverleafGitExecutor.test.ts new file mode 100644 index 0000000000..816e136c45 --- /dev/null +++ b/apps/server/src/scient/overleaf/OverleafGitExecutor.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildOverleafGitEnvironment, + posixAskpassScript, + windowsAskpassLauncher, + windowsAskpassPowerShellScript, +} from "./OverleafGitExecutor.ts"; + +describe("Overleaf Git credential boundary", () => { + it("constructs a closed environment without ambient Git, SSH, proxy, trace, or Node values", () => { + const env = buildOverleafGitEnvironment({ + home: "C:/state/runtime/op/home", + temp: "C:/state/runtime/op/tmp", + childPath: "C:/Program Files/Git/cmd;C:/Windows/System32", + hooks: "C:/state/runtime/op/hooks", + globalConfig: "C:/state/runtime/op/gitconfig", + globalExcludes: "C:/state/runtime/op/excludes", + askpass: "C:/state/runtime/op/askpass.cmd", + tokenPath: "C:/state/runtime/op/token with spaces", + identity: { name: "Human Author", email: "human@example.com" }, + windows: { + systemRoot: "C:/Windows", + systemDrive: "C:", + comspec: "C:/Windows/System32/cmd.exe", + pathext: ".COM;.EXE;.BAT;.CMD", + appData: "C:/state/runtime/op/home/AppData/Roaming", + localAppData: "C:/state/runtime/op/home/AppData/Local", + }, + }); + + expect(env).toMatchObject({ + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS_REQUIRE: "force", + GIT_CONFIG_NOSYSTEM: "1", + GIT_AUTHOR_NAME: "Human Author", + GIT_COMMITTER_EMAIL: "human@example.com", + SystemRoot: "C:/Windows", + TEMP: "C:/state/runtime/op/tmp", + }); + expect( + Object.keys(env).some((key) => /^(?:SSH_|https?_proxy|GIT_TRACE|NODE_OPTIONS)/iu.test(key)), + ).toBe(false); + expect(Object.values(env).join("\n")).not.toContain("AI"); + expect(Object.values(env).join("\n")).not.toContain("LLM"); + }); + + it("uses newline-free askpass output and a packaged-app-independent Windows runtime", () => { + const script = windowsAskpassPowerShellScript(); + const launcher = windowsAskpassLauncher( + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", + "C:\\state with spaces\\askpass.ps1", + ); + expect(script).toContain("File]::ReadAllText"); + expect(script).toContain("Console]::Out.Write"); + expect(script).not.toMatch(/WriteLine|\btype\b/iu); + expect(launcher).toContain('powershell.exe"'); + expect(launcher).not.toContain("node"); + expect(launcher).not.toContain("ELECTRON_RUN_AS_NODE"); + expect(posixAskpassScript()).toContain("printf '%s'"); + }); +}); diff --git a/apps/server/src/scient/overleaf/OverleafGitExecutor.ts b/apps/server/src/scient/overleaf/OverleafGitExecutor.ts new file mode 100644 index 0000000000..c53d3763c7 --- /dev/null +++ b/apps/server/src/scient/overleaf/OverleafGitExecutor.ts @@ -0,0 +1,456 @@ +// @effect-diagnostics nodeBuiltinImport:off +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as NodeFSP from "node:fs/promises"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { OverleafStateStore } from "./OverleafStateStore.ts"; + +const DEFAULT_TIMEOUT_MS = 120_000; +const DEFAULT_MAX_OUTPUT_BYTES = 4 * 1024 * 1024; + +export class OverleafGitError extends Schema.TaggedErrorClass()( + "OverleafGitError", + { + reason: Schema.Literals([ + "git-not-found", + "spawn-failed", + "output-failed", + "output-overflow", + "timeout", + "non-zero-exit", + "runtime-failed", + ]), + exitCode: Schema.optionalKey(Schema.Number), + retryable: Schema.Boolean, + }, +) { + override get message(): string { + return `The isolated Overleaf Git operation failed (${this.reason}).`; + } +} +const isOverleafGitError = Schema.is(OverleafGitError); + +export interface OverleafGitExecuteInput { + readonly operationId: string; + readonly cwd: string; + readonly args: ReadonlyArray; + readonly token?: Uint8Array; + readonly identity?: { readonly name: string; readonly email: string }; + readonly timeoutMs?: number; + readonly maxOutputBytes?: number; + readonly allowNonZeroExit?: boolean; + /** Stream stdout to this owned path instead of retaining it in memory. */ + readonly stdoutFile?: string; +} + +export interface OverleafGitExecuteResult { + readonly exitCode: number; + readonly stdout: string; + readonly stdoutBytes: Uint8Array; + readonly stderr: string; +} + +export function windowsAskpassPowerShellScript(): string { + return "param([string]$Prompt)\nif ($Prompt -match 'Username') { [Console]::Out.Write('git') } else { [Console]::Out.Write([IO.File]::ReadAllText($env:SCIENT_OVERLEAF_TOKEN_FILE)) }\n"; +} + +export function windowsAskpassLauncher(powershell: string, scriptPath: string): string { + return `@echo off\r\n"${powershell}" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${scriptPath}" %*\r\n`; +} + +export function posixAskpassScript(): string { + return '#!/bin/sh\ncase "$1" in *Username*) printf \'%s\' git ;; *) exec /bin/cat "$SCIENT_OVERLEAF_TOKEN_FILE" ;; esac\n'; +} + +export function buildOverleafGitEnvironment(input: { + readonly home: string; + readonly temp: string; + readonly childPath: string; + readonly hooks: string; + readonly globalConfig: string; + readonly globalExcludes: string; + readonly askpass?: string; + readonly tokenPath?: string; + readonly identity?: { readonly name: string; readonly email: string }; + readonly windows?: { + readonly systemRoot: string; + readonly systemDrive: string; + readonly comspec: string; + readonly pathext: string; + readonly appData: string; + readonly localAppData: string; + }; +}): Record { + const config = [ + ["credential.helper", ""], + ["core.hooksPath", input.hooks], + ["commit.gpgSign", "false"], + ["tag.gpgSign", "false"], + ["protocol.allow", "never"], + ["protocol.https.allow", "always"], + ["protocol.file.allow", "never"], + ["protocol.ext.allow", "never"], + ["http.followRedirects", "false"], + ["core.fileMode", "false"], + ["core.autocrlf", "false"], + ["core.excludesFile", input.globalExcludes], + ] as const; + return { + HOME: input.home, + XDG_CONFIG_HOME: `${input.home}/.config`, + PATH: input.childPath, + TMPDIR: input.temp, + TEMP: input.temp, + TMP: input.temp, + LC_ALL: "C", + LANG: "C", + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS_REQUIRE: "force", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: input.globalConfig, + GIT_EDITOR: input.windows ? "cmd /c exit 0" : "true", + GIT_SEQUENCE_EDITOR: input.windows ? "cmd /c exit 0" : "true", + GIT_CONFIG_COUNT: String(config.length), + ...Object.fromEntries( + config.flatMap(([key, value], index) => [ + [`GIT_CONFIG_KEY_${index}`, key], + [`GIT_CONFIG_VALUE_${index}`, value], + ]), + ), + ...(input.askpass === undefined || input.tokenPath === undefined + ? {} + : { GIT_ASKPASS: input.askpass, SCIENT_OVERLEAF_TOKEN_FILE: input.tokenPath }), + ...(input.identity === undefined + ? {} + : { + GIT_AUTHOR_NAME: input.identity.name, + GIT_AUTHOR_EMAIL: input.identity.email, + GIT_COMMITTER_NAME: input.identity.name, + GIT_COMMITTER_EMAIL: input.identity.email, + }), + ...(input.windows === undefined + ? {} + : { + SystemRoot: input.windows.systemRoot, + SystemDrive: input.windows.systemDrive, + COMSPEC: input.windows.comspec, + PATHEXT: input.windows.pathext, + APPDATA: input.windows.appData, + LOCALAPPDATA: input.windows.localAppData, + }), + }; +} + +async function firstExisting(fs: FileSystem.FileSystem, candidates: ReadonlyArray) { + for (const candidate of candidates) { + if (await Effect.runPromise(fs.exists(candidate).pipe(Effect.orElseSucceed(() => false)))) { + return candidate; + } + } + return null; +} + +const collectOutput = Effect.fnUntraced(function* ( + stream: Stream.Stream, + maxOutputBytes: number, +) { + const decoder = new TextDecoder(); + let bytes = 0; + let text = ""; + const chunks: Uint8Array[] = []; + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* new OverleafGitError({ + reason: "output-overflow", + retryable: false, + }); + } + chunks.push(chunk.slice()); + text += decoder.decode(chunk, { stream: true }); + }), + ).pipe( + Effect.mapError((cause) => + isOverleafGitError(cause) + ? cause + : new OverleafGitError({ reason: "output-failed", retryable: true }), + ), + ); + text += decoder.decode(); + const binary = new Uint8Array(bytes); + let offset = 0; + for (const chunk of chunks) { + binary.set(chunk, offset); + offset += chunk.byteLength; + } + return { text, bytes: binary }; +}); + +const writeOutput = Effect.fnUntraced(function* ( + stream: Stream.Stream, + filePath: string, + maxOutputBytes: number, +) { + const handle = yield* Effect.tryPromise({ + try: () => NodeFSP.open(filePath, "wx"), + catch: () => new OverleafGitError({ reason: "output-failed", retryable: true }), + }); + let bytes = 0; + yield* Stream.runForEach(stream, (chunk) => + Effect.gen(function* () { + bytes += chunk.byteLength; + if (bytes > maxOutputBytes) { + return yield* new OverleafGitError({ reason: "output-overflow", retryable: false }); + } + yield* Effect.tryPromise({ + try: () => handle.writeFile(chunk), + catch: () => new OverleafGitError({ reason: "output-failed", retryable: true }), + }); + }), + ).pipe( + Effect.ensuring( + Effect.tryPromise({ + try: async () => { + await handle.sync(); + await handle.close(); + }, + catch: () => new OverleafGitError({ reason: "output-failed", retryable: true }), + }).pipe(Effect.ignore), + ), + Effect.mapError((cause) => + isOverleafGitError(cause) + ? cause + : new OverleafGitError({ reason: "output-failed", retryable: true }), + ), + ); + return { text: "", bytes: new Uint8Array(0) }; +}); + +function pathParts(value: string | undefined, delimiter: string): ReadonlyArray { + return (value ?? "") + .split(delimiter) + .map((part) => part.trim().replace(/^"|"$/gu, "")) + .filter(Boolean); +} + +export class OverleafGitExecutor extends Context.Service< + OverleafGitExecutor, + { + readonly gitExecutable: string; + readonly execute: ( + input: OverleafGitExecuteInput, + ) => Effect.Effect; + } +>()("t3/scient/overleaf/OverleafGitExecutor") {} + +export const make = Effect.fn("OverleafGitExecutor.make")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const state = yield* OverleafStateStore; + const windows = platform === "win32"; + const delimiter = windows ? ";" : ":"; + const extensions = windows ? pathParts(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD", ";") : [""]; + const candidates = pathParts(process.env.PATH, delimiter).flatMap((directory) => + extensions.map((extension) => path.join(directory, `git${extension.toLowerCase()}`)), + ); + if (windows) { + const systemDrive = process.env.SystemDrive ?? "C:"; + candidates.push( + path.join(systemDrive, "Program Files", "Git", "cmd", "git.exe"), + path.join(systemDrive, "Program Files", "Git", "bin", "git.exe"), + ); + } else { + candidates.push("/usr/bin/git", "/usr/local/bin/git", "/opt/homebrew/bin/git"); + } + const gitExecutable = yield* Effect.tryPromise({ + try: () => firstExisting(fs, candidates), + catch: () => new OverleafGitError({ reason: "git-not-found", retryable: false }), + }).pipe( + Effect.flatMap((candidate) => + candidate === null + ? Effect.fail(new OverleafGitError({ reason: "git-not-found", retryable: false })) + : Effect.succeed(path.resolve(candidate)), + ), + ); + + const gitDirectory = path.dirname(gitExecutable); + const gitRoot = + windows && ["cmd", "bin"].includes(path.basename(gitDirectory).toLowerCase()) + ? path.dirname(gitDirectory) + : gitDirectory; + + const execute: OverleafGitExecutor["Service"]["execute"] = Effect.fnUntraced(function* (input) { + const commandId = yield* state.newId.pipe( + Effect.mapError(() => new OverleafGitError({ reason: "runtime-failed", retryable: true })), + ); + const commandDirectory = path.join(state.runtimeRoot, input.operationId, commandId); + const home = path.join(commandDirectory, "home"); + const temp = path.join(commandDirectory, "tmp"); + const hooks = path.join(commandDirectory, "hooks-disabled"); + const globalConfig = path.join(commandDirectory, "gitconfig"); + const tokenPath = path.join(commandDirectory, "token"); + const powershell = windows + ? path.join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ) + : null; + const askpass = windows + ? path.join(commandDirectory, "askpass.cmd") + : path.join(commandDirectory, "askpass.sh"); + + const cleanup = fs + .remove(commandDirectory, { recursive: true, force: true }) + .pipe( + Effect.andThen( + fs.remove(path.dirname(commandDirectory), { force: false }).pipe(Effect.ignore), + ), + Effect.ignore, + ); + return yield* Effect.scoped( + Effect.gen(function* () { + yield* fs.makeDirectory(commandDirectory, { recursive: true }); + yield* Effect.addFinalizer(() => cleanup); + yield* fs.makeDirectory(home, { recursive: true }); + yield* fs.makeDirectory(temp, { recursive: true }); + yield* fs.makeDirectory(hooks, { recursive: true }); + yield* fs.writeFileString(globalConfig, ""); + if (input.token !== undefined) { + yield* fs.writeFile(tokenPath, input.token); + yield* fs.chmod(tokenPath, 0o600); + if (windows) { + const scriptPath = path.join(commandDirectory, "askpass.ps1"); + yield* fs.writeFileString(scriptPath, windowsAskpassPowerShellScript()); + yield* fs.writeFileString(askpass, windowsAskpassLauncher(powershell!, scriptPath)); + } else { + yield* fs.writeFileString(askpass, posixAskpassScript()); + yield* fs.chmod(askpass, 0o700); + } + } + const windowsSystemRoot = process.env.SystemRoot ?? "C:\\Windows"; + const childPath = windows + ? [ + gitDirectory, + path.join(gitRoot, "mingw64", "bin"), + path.join(gitRoot, "mingw64", "libexec", "git-core"), + path.join(gitRoot, "libexec", "git-core"), + path.join(windowsSystemRoot, "System32"), + ].join(";") + : [gitDirectory, "/usr/bin", "/bin"].join(":"); + const globalExcludes = path.join(commandDirectory, "global-excludes"); + yield* fs.writeFileString(globalExcludes, ""); + const windowsSystemDrive = process.env.SystemDrive ?? "C:"; + const env = buildOverleafGitEnvironment({ + home, + temp, + childPath, + hooks, + globalConfig, + globalExcludes, + ...(input.token === undefined ? {} : { askpass, tokenPath }), + ...(input.identity === undefined ? {} : { identity: input.identity }), + ...(windows + ? { + windows: { + systemRoot: windowsSystemRoot, + systemDrive: windowsSystemDrive, + comspec: + process.env.COMSPEC ?? path.join(windowsSystemRoot, "System32", "cmd.exe"), + pathext: process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD", + appData: path.join(home, "AppData", "Roaming"), + localAppData: path.join(home, "AppData", "Local"), + }, + } + : {}), + }); + + const child = yield* spawner + .spawn( + ChildProcess.make(gitExecutable, input.args, { + cwd: input.cwd, + env, + extendEnv: false, + shell: false, + detached: !windows, + killSignal: "SIGTERM", + forceKillAfter: "5 seconds", + }), + ) + .pipe( + Effect.mapError( + () => new OverleafGitError({ reason: "spawn-failed", retryable: true }), + ), + ); + yield* Effect.addFinalizer(() => + child + .kill({ killSignal: "SIGTERM", forceKillAfter: "5 seconds" }) + .pipe(Effect.andThen(child.exitCode), Effect.ignore), + ); + const result = Effect.all( + [ + input.stdoutFile === undefined + ? collectOutput(child.stdout, input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES) + : writeOutput( + child.stdout, + input.stdoutFile, + input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES, + ), + collectOutput(child.stderr, input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + () => new OverleafGitError({ reason: "output-failed", retryable: true }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + const timed = yield* result.pipe( + Effect.timeoutOption(input.timeoutMs ?? DEFAULT_TIMEOUT_MS), + ); + if (Option.isNone(timed)) { + return yield* new OverleafGitError({ reason: "timeout", retryable: true }); + } + const [stdout, stderr, exitCode] = timed.value; + if (exitCode !== 0 && !input.allowNonZeroExit) { + return yield* new OverleafGitError({ + reason: "non-zero-exit", + exitCode, + retryable: true, + }); + } + return { + stdout: stdout.text, + stdoutBytes: stdout.bytes, + stderr: stderr.text, + exitCode, + }; + }).pipe( + Effect.mapError((cause) => + isOverleafGitError(cause) + ? cause + : new OverleafGitError({ reason: "runtime-failed", retryable: true }), + ), + ), + ); + }); + + return OverleafGitExecutor.of({ gitExecutable, execute }); +}); + +export const layer = Layer.effect(OverleafGitExecutor, make()); diff --git a/apps/server/src/scient/overleaf/OverleafMirror.ts b/apps/server/src/scient/overleaf/OverleafMirror.ts new file mode 100644 index 0000000000..53cd7c4a5b --- /dev/null +++ b/apps/server/src/scient/overleaf/OverleafMirror.ts @@ -0,0 +1,934 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { + ScientOverleafChange, + ScientOverleafConflict, + ScientOverleafConflictDetail, + ScientOverleafReview, + ScientOverleafWarning, +} from "@t3tools/contracts"; +import { ScientOverleafOperationError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { advisoryWarnings, ManagedTree, normalizeManagedPath } from "./ManagedTree.ts"; +import type { + PersistedOverleafAccount, + PersistedOverleafConnection, + TreeManifest, +} from "./model.ts"; +import { OverleafGitExecutor, type OverleafGitExecuteResult } from "./OverleafGitExecutor.ts"; +import { OverleafStateStore } from "./OverleafStateStore.ts"; + +const isOverleafOperationError = Schema.is(ScientOverleafOperationError); + +interface GitContext { + readonly operationId: string; + readonly cwd: string; + readonly token?: Uint8Array; + readonly account?: PersistedOverleafAccount; +} + +function operationError( + code: + | "git_access_unavailable" + | "network_failed" + | "dns_failed" + | "tls_failed" + | "rate_limited" + | "filesystem_failed" + | "conflict" + | "push_race" + | "push_outcome_unknown" + | "unsafe_tree" + | "unsupported_tree", + message: string, + retryable: boolean, +) { + return new ScientOverleafOperationError({ code, message, retryable }); +} + +function classifyRemoteFailure(stderr: string): ScientOverleafOperationError { + if ( + /could not resolve host|name or service not known|temporary failure in name resolution/iu.test( + stderr, + ) + ) { + return operationError("dns_failed", "The Overleaf host could not be resolved.", true); + } + if (/certificate|ssl|tls|schannel|secure channel/iu.test(stderr)) { + return operationError( + "tls_failed", + "A secure TLS connection to Overleaf could not be established.", + true, + ); + } + if (/\b429\b|rate.?limit|too many requests/iu.test(stderr)) { + return operationError( + "rate_limited", + "Overleaf temporarily rate-limited this Git operation.", + true, + ); + } + if ( + /authentication|access denied|forbidden|\b40[13]\b|repository not found|not authorized/iu.test( + stderr, + ) + ) { + return operationError( + "git_access_unavailable", + "Git access to this Overleaf project is unavailable. Check the project or Git URL, collaborator permission, token expiry, Cloud project-owner Git eligibility, and Server Pro Git Bridge configuration.", + false, + ); + } + return operationError( + "network_failed", + "The Overleaf Git operation could not reach the remote service.", + true, + ); +} + +function mapGitError(message: string, retryable = true) { + return (cause: unknown) => + isOverleafOperationError(cause) + ? cause + : operationError("filesystem_failed", message, retryable); +} + +export function parseNameStatus(output: string): ReadonlyArray { + const fields = output.split("\0").filter(Boolean); + const changes: ScientOverleafChange[] = []; + for (let index = 0; index < fields.length; ) { + const status = fields[index++]!; + const code = status[0]; + if (code === "R") { + const oldPath = normalizeManagedPath(fields[index++]!); + const path = normalizeManagedPath(fields[index++]!); + changes.push({ kind: "renamed", oldPath, path, similarity: Number(status.slice(1)) }); + continue; + } + const path = normalizeManagedPath(fields[index++]!); + changes.push({ + kind: code === "A" ? "added" : code === "D" ? "deleted" : "modified", + path, + }); + } + return changes; +} + +interface UnmergedStage { + readonly mode: string; + readonly hash: string; + readonly stage: number; + readonly path: string; +} + +export function parseUnmerged(output: string): ReadonlyArray { + return output + .split("\0") + .filter(Boolean) + .map((record) => { + const match = record.match(/^(\d+) ([0-9a-f]+) ([123])\t(.+)$/u); + if (!match) + throw operationError("conflict", "Git returned an unreadable conflict record.", false); + return { + mode: match[1]!, + hash: match[2]!, + stage: Number(match[3]!), + path: normalizeManagedPath(match[4]!), + }; + }); +} + +export function uncertainPushWasAccepted(input: { + readonly candidateTree: string; + readonly headTree: string; + readonly firstParentTrees: ReadonlyArray; + readonly candidateCommitReachable: boolean; +}): boolean { + return ( + input.headTree === input.candidateTree || + input.firstParentTrees.includes(input.candidateTree) || + input.candidateCommitReachable + ); +} + +function textPreview(bytes: Uint8Array | null): string | null { + if (bytes === null || bytes.byteLength > 2 * 1024 * 1024 || bytes.includes(0)) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +async function replaceMirrorTree(input: { + readonly sourceRoot: string; + readonly mirrorRoot: string; + readonly desired: TreeManifest; + readonly previous: TreeManifest; +}) { + const desiredPaths = new Set(input.desired.files.map((file) => file.path)); + for (const previous of input.previous.files) { + if (desiredPaths.has(previous.path)) continue; + await NodeFSP.rm(NodePath.join(input.mirrorRoot, ...previous.path.split("/")), { force: true }); + } + for (const file of input.desired.files) { + const source = NodePath.join(input.sourceRoot, ...file.path.split("/")); + const target = NodePath.join(input.mirrorRoot, ...file.path.split("/")); + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.copyFile(source, target); + } +} + +export class OverleafMirror extends Context.Service< + OverleafMirror, + { + readonly mirrorRoot: (connectionId: string) => string; + readonly initialize: ( + input: GitContext & { readonly connectionId: string; readonly gitUrl: string }, + ) => Effect.Effect; + readonly fetch: (input: GitContext) => Effect.Effect; + readonly checkout: ( + input: GitContext & { readonly commit: string }, + ) => Effect.Effect; + readonly manifest: ( + input: GitContext & { readonly commit?: string }, + ) => Effect.Effect; + readonly applyWorkspace: ( + input: GitContext & { + readonly workspaceRoot: string; + readonly desired: TreeManifest; + readonly previous: TreeManifest; + }, + ) => Effect.Effect; + readonly stageAll: (input: GitContext) => Effect.Effect; + readonly hasChanges: ( + input: GitContext, + ) => Effect.Effect; + readonly commitSnapshot: ( + input: GitContext & { readonly message: string }, + ) => Effect.Effect; + readonly retainWorkspaceBase: ( + input: GitContext & { readonly commit: string }, + ) => Effect.Effect; + readonly releaseOperationRef: ( + input: GitContext, + ) => Effect.Effect; + readonly rebaseSnapshot: ( + input: GitContext & { readonly snapshotCommit: string | null }, + ) => Effect.Effect< + { readonly candidateCommit: string; readonly conflicted: boolean }, + ScientOverleafOperationError + >; + readonly rebaseRangeOnto: ( + input: GitContext & { readonly upstream: string; readonly fromExclusive: string }, + ) => Effect.Effect< + { readonly candidateCommit: string; readonly conflicted: boolean }, + ScientOverleafOperationError + >; + readonly conflicts: ( + input: GitContext, + ) => Effect.Effect, ScientOverleafOperationError>; + readonly materializeBlob: ( + input: GitContext & { + readonly hash: string; + readonly destination: string; + readonly maxBytes: number; + }, + ) => Effect.Effect; + readonly resolveConflict: ( + input: GitContext & { + readonly conflict: ScientOverleafConflictDetail; + readonly resolution: "overleaf" | "local" | "delete" | "both"; + }, + ) => Effect.Effect; + readonly continueRebase: ( + input: GitContext, + ) => Effect.Effect< + { readonly candidateCommit: string; readonly conflicted: boolean }, + ScientOverleafOperationError + >; + readonly abortRebase: (input: GitContext) => Effect.Effect; + readonly review: ( + input: GitContext & { + readonly connection: PersistedOverleafConnection; + readonly candidateCommit: string; + readonly candidateManifest: TreeManifest; + }, + ) => Effect.Effect; + readonly push: ( + input: GitContext, + ) => Effect.Effect<"confirmed" | "race" | "unknown", ScientOverleafOperationError>; + readonly treeHash: ( + input: GitContext & { readonly commit: string }, + ) => Effect.Effect; + readonly candidateAccepted: ( + input: GitContext & { + readonly candidateCommit: string; + readonly candidateTree: string; + readonly prePushBase: string; + }, + ) => Effect.Effect< + { readonly accepted: boolean; readonly head: string }, + ScientOverleafOperationError + >; + } +>()("t3/scient/overleaf/OverleafMirror") {} + +export const make = Effect.fn("OverleafMirror.make")(function* () { + const git = yield* OverleafGitExecutor; + const state = yield* OverleafStateStore; + const tree = yield* ManagedTree; + const mirrorRoot = (connectionId: string) => + NodePath.join(state.connectionDirectory(connectionId), "mirror"); + const run = ( + context: GitContext, + args: ReadonlyArray, + options?: { + allowNonZeroExit?: boolean; + timeoutMs?: number; + credentialed?: boolean; + unknownOnTimeout?: boolean; + maxOutputBytes?: number; + stdoutFile?: string; + }, + ) => { + const { credentialed = false, unknownOnTimeout = false, ...executeOptions } = options ?? {}; + return git + .execute({ + operationId: context.operationId, + cwd: context.cwd, + args, + ...(credentialed && context.token !== undefined ? { token: context.token } : {}), + ...(context.account === undefined + ? {} + : { identity: { name: context.account.authorName, email: context.account.authorEmail } }), + ...executeOptions, + }) + .pipe( + Effect.mapError((error) => + error.reason === "timeout" + ? operationError( + unknownOnTimeout ? "push_outcome_unknown" : "network_failed", + unknownOnTimeout + ? "Overleaf may have accepted the push. Verify the remote before retrying." + : "The Overleaf Git operation timed out.", + true, + ) + : operationError( + "filesystem_failed", + "The private Overleaf Git operation failed.", + error.retryable, + ), + ), + ); + }; + + const initialize: OverleafMirror["Service"]["initialize"] = (input) => + Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(input.cwd, { recursive: true }), + catch: mapGitError("Unable to prepare the private Overleaf mirror."), + }); + const existing = yield* Effect.tryPromise({ + try: async () => !!(await NodeFSP.stat(NodePath.join(input.cwd, ".git"))).isDirectory(), + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + if (!existing) { + yield* run(input, ["init", "--initial-branch=master"]).pipe( + Effect.mapError(mapGitError("Unable to initialize the private Overleaf mirror.")), + ); + yield* run(input, ["remote", "add", "origin", input.gitUrl]).pipe( + Effect.mapError(mapGitError("Unable to configure the private Overleaf mirror.")), + ); + } + }); + + const fetch: OverleafMirror["Service"]["fetch"] = (input) => + Effect.gen(function* () { + const fetched = yield* run( + input, + [ + "fetch", + "--prune", + "--no-tags", + "origin", + "+refs/heads/master:refs/remotes/origin/master", + ], + { timeoutMs: 180_000, credentialed: true, allowNonZeroExit: true }, + ); + if (fetched.exitCode !== 0) return yield* classifyRemoteFailure(fetched.stderr); + const head = (yield* run(input, ["rev-parse", "refs/remotes/origin/master"]).pipe( + Effect.mapError(mapGitError("The Overleaf master branch is unavailable.", false)), + )).stdout.trim(); + if (input.account !== undefined) yield* state.markAccountValidated(input.account.accountId); + return head; + }); + + const checkout: OverleafMirror["Service"]["checkout"] = (input) => + Effect.gen(function* () { + yield* run(input, ["checkout", "-B", "master", input.commit]).pipe( + Effect.mapError(mapGitError("Unable to prepare the private Overleaf worktree.")), + ); + yield* run(input, ["reset", "--hard", input.commit]).pipe( + Effect.mapError(mapGitError("Unable to reset the private Overleaf worktree.")), + ); + yield* run(input, ["clean", "-fdx"]).pipe( + Effect.mapError(mapGitError("Unable to clean the private Overleaf worktree.")), + ); + }); + + const rawTreePaths = Effect.fnUntraced(function* ( + input: GitContext & { readonly commit: string }, + ) { + const result = yield* run(input, ["ls-tree", "-r", "-z", "--full-tree", input.commit]).pipe( + Effect.mapError(mapGitError("Unable to inspect the Overleaf project tree.")), + ); + const paths: string[] = []; + const foldedPaths = new Map(); + for (const record of result.stdout.split("\0").filter(Boolean)) { + const match = record.match(/^(\d+) (\w+) ([0-9a-f]+)\t(.+)$/u); + if (!match || match[2] !== "blob" || !["100644", "100755"].includes(match[1]!)) + return yield* operationError( + "unsafe_tree", + "The Overleaf project contains an unsupported tree entry.", + false, + ); + const managedPath = normalizeManagedPath(match[4]!); + const folded = managedPath.toLocaleLowerCase("en-US"); + const previous = foldedPaths.get(folded); + if (previous !== undefined && previous !== managedPath) + return yield* operationError( + "unsafe_tree", + "The Overleaf project contains paths that collide by letter case.", + false, + ); + foldedPaths.set(folded, managedPath); + paths.push(managedPath); + } + return paths; + }); + + const manifest: OverleafMirror["Service"]["manifest"] = (input) => + Effect.gen(function* () { + const commit = input.commit ?? "HEAD"; + const paths = yield* rawTreePaths({ ...input, commit }); + for (const attributesPath of paths.filter( + (path) => NodePath.posix.basename(path) === ".gitattributes", + )) { + const attributes = yield* run(input, ["show", `${commit}:${attributesPath}`], { + allowNonZeroExit: true, + maxOutputBytes: 8 * 1024 * 1024, + }); + if (attributes.exitCode !== 0) + return yield* operationError( + "unsafe_tree", + "The Overleaf .gitattributes file could not be inspected safely.", + false, + ); + if (/(?:^|\s)filter=lfs(?:\s|$)/mu.test(attributes.stdout)) + return yield* operationError( + "unsupported_tree", + "Git LFS paths are not supported by Overleaf sync.", + false, + ); + } + yield* checkout({ ...input, commit }); + return yield* tree.scan({ + operationId: input.operationId, + root: input.cwd, + trackedPaths: paths, + }); + }); + + const applyWorkspace: OverleafMirror["Service"]["applyWorkspace"] = (input) => + Effect.gen(function* () { + yield* Effect.tryPromise({ + try: () => + replaceMirrorTree({ + sourceRoot: input.workspaceRoot, + mirrorRoot: input.cwd, + desired: input.desired, + previous: input.previous, + }), + catch: () => + operationError( + "filesystem_failed", + "Unable to copy the workspace snapshot into the private mirror.", + true, + ), + }); + yield* run(input, ["add", "-A"]).pipe( + Effect.mapError(mapGitError("Unable to stage the workspace snapshot.")), + ); + }); + const stageAll: OverleafMirror["Service"]["stageAll"] = (input) => + run(input, ["add", "-A"]).pipe( + Effect.asVoid, + Effect.mapError(mapGitError("Unable to stage the Overleaf candidate.")), + ); + + const hasChanges: OverleafMirror["Service"]["hasChanges"] = (input) => + run(input, ["status", "--porcelain=v1", "-z"]).pipe( + Effect.map((result) => result.stdout.length > 0), + Effect.mapError(mapGitError("Unable to inspect the private Overleaf mirror.")), + ); + + const commitSnapshot: OverleafMirror["Service"]["commitSnapshot"] = (input) => + Effect.gen(function* () { + const status = yield* run(input, ["status", "--porcelain=v1", "-z"]).pipe( + Effect.mapError(mapGitError("Unable to inspect the private Overleaf mirror.")), + ); + if (status.stdout.length === 0) return null; + yield* run(input, ["commit", "--no-verify", "-m", input.message]).pipe( + Effect.mapError(mapGitError("Unable to create the Overleaf snapshot commit.")), + ); + const commit = (yield* run(input, ["rev-parse", "HEAD"]).pipe( + Effect.mapError(mapGitError("Unable to identify the Overleaf snapshot commit.")), + )).stdout.trim(); + yield* run(input, [ + "update-ref", + `refs/scient-overleaf/operations/${input.operationId}/snapshot`, + commit, + ]).pipe(Effect.mapError(mapGitError("Unable to retain the operation snapshot safely."))); + return commit; + }); + + const retainWorkspaceBase: OverleafMirror["Service"]["retainWorkspaceBase"] = (input) => + run(input, [ + "update-ref", + `refs/scient-overleaf/operations/${input.operationId}/workspace-base`, + input.commit, + ]).pipe( + Effect.asVoid, + Effect.mapError(mapGitError("Unable to retain the click-time workspace base safely.")), + ); + + const releaseOperationRef: OverleafMirror["Service"]["releaseOperationRef"] = (input) => + Effect.gen(function* () { + yield* run( + input, + ["update-ref", "-d", `refs/scient-overleaf/operations/${input.operationId}/snapshot`], + { allowNonZeroExit: true }, + ); + yield* run( + input, + ["update-ref", "-d", `refs/scient-overleaf/operations/${input.operationId}/workspace-base`], + { allowNonZeroExit: true }, + ); + }).pipe(Effect.mapError(mapGitError("Unable to release the completed operation snapshot."))); + + const rebaseSnapshot: OverleafMirror["Service"]["rebaseSnapshot"] = (input) => + Effect.gen(function* () { + if (input.snapshotCommit === null) { + yield* checkout({ ...input, commit: "refs/remotes/origin/master" }); + const candidateCommit = (yield* run(input, ["rev-parse", "HEAD"]).pipe( + Effect.mapError(mapGitError("Unable to identify the Overleaf candidate.")), + )).stdout.trim(); + return { candidateCommit, conflicted: false }; + } + const rebased = yield* run(input, ["rebase", "refs/remotes/origin/master"], { + allowNonZeroExit: true, + }); + if (rebased.exitCode !== 0) { + const unmerged = yield* run(input, ["ls-files", "-u", "-z"], { allowNonZeroExit: true }); + if (unmerged.stdout.length > 0) + return { candidateCommit: input.snapshotCommit, conflicted: true }; + return yield* operationError( + "conflict", + "The Overleaf snapshot could not be rebased safely.", + true, + ); + } + const candidateCommit = (yield* run(input, ["rev-parse", "HEAD"]).pipe( + Effect.mapError(mapGitError("Unable to identify the rebased Overleaf candidate.")), + )).stdout.trim(); + return { candidateCommit, conflicted: false }; + }); + + const readBlob = (context: GitContext, hash: string | undefined) => + Effect.gen(function* () { + if (hash === undefined) return null; + const size = Number((yield* run(context, ["cat-file", "-s", hash])).stdout.trim()); + if (!Number.isSafeInteger(size) || size < 0) + return yield* operationError( + "conflict", + "Git returned an invalid conflict blob size.", + false, + ); + if (size > 2 * 1024 * 1024) return { size, bytes: null }; + const result = yield* run(context, ["cat-file", "blob", hash], { + maxOutputBytes: 2 * 1024 * 1024 + 1, + }); + return { size, bytes: result.stdoutBytes }; + }); + + const conflicts: OverleafMirror["Service"]["conflicts"] = (input) => + Effect.gen(function* () { + const parsed = parseUnmerged( + (yield* run(input, ["ls-files", "-u", "-z"]).pipe( + Effect.mapError(mapGitError("Unable to inspect Overleaf conflicts.")), + )).stdout, + ); + const grouped = Map.groupBy(parsed, (stage) => stage.path); + const results: ScientOverleafConflictDetail[] = []; + for (const [relative, stages] of grouped) { + const byStage = new Map(stages.map((stage) => [stage.stage, stage] as const)); + const baseBytes = yield* readBlob(input, byStage.get(1)?.hash); + const overleafBytes = yield* readBlob(input, byStage.get(2)?.hash); + const localBytes = yield* readBlob(input, byStage.get(3)?.hash); + const previewable = [baseBytes, overleafBytes, localBytes].every( + (blob) => blob === null || (blob.bytes !== null && textPreview(blob.bytes) !== null), + ); + const conflict: ScientOverleafConflict = { + conflictId: relative, + kind: !previewable + ? "binary" + : overleafBytes === null || localBytes === null + ? "delete_modify" + : "content", + path: relative, + baseSize: baseBytes?.size ?? null, + overleafSize: overleafBytes?.size ?? null, + localSize: localBytes?.size ?? null, + baseHash: byStage.get(1)?.hash ?? null, + overleafHash: byStage.get(2)?.hash ?? null, + localHash: byStage.get(3)?.hash ?? null, + previewable, + resolved: false, + }; + results.push({ + conflict, + base: textPreview(baseBytes?.bytes ?? null), + overleaf: textPreview(overleafBytes?.bytes ?? null), + local: textPreview(localBytes?.bytes ?? null), + }); + } + return results; + }); + + const materializeBlob: OverleafMirror["Service"]["materializeBlob"] = (input) => + Effect.gen(function* () { + yield* Effect.tryPromise({ + try: async () => { + await NodeFSP.mkdir(NodePath.dirname(input.destination), { recursive: true }); + await NodeFSP.rm(input.destination, { force: true }); + }, + catch: () => + operationError("filesystem_failed", "Unable to prepare conflict material.", true), + }); + yield* run(input, ["cat-file", "blob", input.hash], { + stdoutFile: input.destination, + maxOutputBytes: input.maxBytes, + }).pipe(Effect.asVoid, Effect.mapError(mapGitError("Unable to preserve conflict material."))); + }); + + const resolveConflict: OverleafMirror["Service"]["resolveConflict"] = (input) => + Effect.gen(function* () { + const relative = input.conflict.conflict.path; + if (input.resolution === "delete") { + yield* run(input, ["rm", "--", relative]).pipe( + Effect.mapError(mapGitError("Unable to stage the conflict deletion.")), + ); + return; + } + const stage = input.resolution === "overleaf" ? "2" : "3"; + const hash = + stage === "2" ? input.conflict.conflict.overleafHash : input.conflict.conflict.localHash; + if (hash === null) + return yield* operationError( + "conflict", + "That conflict side does not contain a file.", + false, + ); + yield* run(input, ["checkout", stage === "2" ? "--ours" : "--theirs", "--", relative]).pipe( + Effect.mapError(mapGitError("Unable to write the selected conflict side.")), + ); + yield* run(input, ["add", "--", relative]).pipe( + Effect.mapError(mapGitError("Unable to stage the conflict resolution.")), + ); + }); + + const rebaseRangeOnto: OverleafMirror["Service"]["rebaseRangeOnto"] = (input) => + Effect.gen(function* () { + const rebased = yield* run(input, ["rebase", "--onto", input.upstream, input.fromExclusive], { + allowNonZeroExit: true, + }); + if (rebased.exitCode !== 0) { + const unmerged = yield* run(input, ["ls-files", "-u", "-z"], { allowNonZeroExit: true }); + if (unmerged.stdout.length > 0) return { candidateCommit: "", conflicted: true }; + return yield* operationError( + "conflict", + "The later local edits could not be reconciled safely.", + true, + ); + } + return { + candidateCommit: (yield* run(input, ["rev-parse", "HEAD"])).stdout.trim(), + conflicted: false, + }; + }); + + const continueRebase: OverleafMirror["Service"]["continueRebase"] = (input) => + Effect.gen(function* () { + const remaining = yield* run(input, ["ls-files", "-u", "-z"]); + if (remaining.stdout.length > 0) return { candidateCommit: "", conflicted: true }; + const continued = yield* run(input, ["rebase", "--continue"], { allowNonZeroExit: true }); + if (continued.exitCode !== 0) { + const again = yield* run(input, ["ls-files", "-u", "-z"], { allowNonZeroExit: true }); + if (again.stdout.length > 0) return { candidateCommit: "", conflicted: true }; + return yield* operationError( + "conflict", + "The resolved Overleaf snapshot could not continue rebasing.", + true, + ); + } + return { + candidateCommit: (yield* run(input, ["rev-parse", "HEAD"])).stdout.trim(), + conflicted: false, + }; + }); + + const abortRebase: OverleafMirror["Service"]["abortRebase"] = (input) => + run(input, ["rebase", "--abort"], { allowNonZeroExit: true }).pipe( + Effect.asVoid, + Effect.mapError(mapGitError("Unable to abort the private mirror rebase.")), + ); + + const review: OverleafMirror["Service"]["review"] = (input) => + Effect.gen(function* () { + const changes = parseNameStatus( + (yield* run(input, [ + "diff", + "--find-renames=50%", + "--name-status", + "-z", + "refs/remotes/origin/master", + input.candidateCommit, + ])).stdout, + ); + const warnings: ScientOverleafWarning[] = [...advisoryWarnings(input.candidateManifest)]; + const deletions = changes + .filter((change) => change.kind === "deleted") + .map((change) => change.path); + if (deletions.length > 0) + warnings.push({ + kind: "deletion", + message: "This Sync deletes files currently managed by Overleaf.", + paths: deletions, + blocking: true, + suppressible: false, + }); + const renames = changes + .filter((change) => change.kind === "renamed") + .map((change) => change.path); + if (renames.length > 0 && !input.connection.suppressRenameWarning) + warnings.push({ + kind: "rename", + message: "Renames may displace Overleaf comments or Track Changes metadata.", + paths: renames, + blocking: true, + suppressible: true, + }); + + const headTree = (yield* run(input, [ + "rev-parse", + "refs/remotes/origin/master^{tree}", + ])).stdout.trim(); + const candidateTree = (yield* run(input, [ + "rev-parse", + `${input.candidateCommit}^{tree}`, + ])).stdout.trim(); + const historicalTrees = (yield* run(input, [ + "log", + "--first-parent", + "--format=%T", + "refs/remotes/origin/master", + ])).stdout + .split("\n") + .filter(Boolean) + .slice(1); + for (const treeHash of historicalTrees) { + if (treeHash === candidateTree && treeHash !== headTree) { + warnings.push({ + kind: "whole_tree_revert", + message: "This candidate restores the whole project to an older Overleaf revision.", + paths: [], + blocking: true, + suppressible: false, + }); + break; + } + } + const modified = changes.filter((change) => change.kind === "modified"); + for (const change of modified) { + const candidateBlob = (yield* run( + input, + ["rev-parse", `${input.candidateCommit}:${change.path}`], + { allowNonZeroExit: true }, + )).stdout.trim(); + const headBlob = (yield* run( + input, + ["rev-parse", `refs/remotes/origin/master:${change.path}`], + { allowNonZeroExit: true }, + )).stdout.trim(); + if (!candidateBlob || candidateBlob === headBlob) continue; + const remoteHead = (yield* run(input, [ + "rev-parse", + "refs/remotes/origin/master", + ])).stdout.trim(); + const history = (yield* run(input, [ + "rev-list", + "--first-parent", + "refs/remotes/origin/master", + "--", + change.path, + ])).stdout + .split("\n") + .filter((commit) => Boolean(commit) && commit !== remoteHead); + let reverted = false; + for (const ancestor of history) { + const blob = (yield* run(input, ["rev-parse", `${ancestor}:${change.path}`], { + allowNonZeroExit: true, + })).stdout.trim(); + if (blob === candidateBlob) { + reverted = true; + break; + } + } + if (reverted) + warnings.push({ + kind: "historical_revert", + message: + "This file matches an older Overleaf revision and differs from the current remote file.", + paths: [change.path], + blocking: true, + suppressible: false, + }); + } + return { + candidateCommit: input.candidateCommit, + changes, + warnings, + requiresConfirmation: warnings.some((warning) => warning.blocking), + }; + }); + + const push: OverleafMirror["Service"]["push"] = (input) => + Effect.gen(function* () { + const result: OverleafGitExecuteResult = yield* run( + input, + ["push", "--porcelain", "origin", "HEAD:master"], + { allowNonZeroExit: true, timeoutMs: 180_000, credentialed: true, unknownOnTimeout: true }, + ).pipe( + Effect.catch((error) => + error.code === "push_outcome_unknown" + ? Effect.succeed({ + exitCode: -1, + stdout: "", + stdoutBytes: new Uint8Array(0), + stderr: "", + }) + : Effect.fail(error), + ), + ); + if (result.exitCode === -1) return "unknown"; + if (result.exitCode === 0) return "confirmed"; + if (/non-fast-forward|fetch first|rejected/iu.test(result.stderr)) return "race"; + if (/authentication|access denied|forbidden|403|401/iu.test(result.stderr)) + return yield* classifyRemoteFailure(result.stderr); + if ( + /could not resolve host|certificate|ssl|tls|schannel|\b429\b|rate.?limit/iu.test( + result.stderr, + ) + ) + return yield* classifyRemoteFailure(result.stderr); + return "unknown"; + }); + + const treeHash: OverleafMirror["Service"]["treeHash"] = (input) => + run(input, ["rev-parse", `${input.commit}^{tree}`]).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(mapGitError("Unable to identify an Overleaf tree.")), + ); + + const candidateAccepted: OverleafMirror["Service"]["candidateAccepted"] = (input) => + Effect.gen(function* () { + const head = yield* fetch(input); + const headTree = yield* treeHash({ ...input, commit: head }); + if ( + uncertainPushWasAccepted({ + candidateTree: input.candidateTree, + headTree, + firstParentTrees: [], + candidateCommitReachable: false, + }) + ) + return { accepted: true, head }; + const history = (yield* run(input, [ + "rev-list", + "--first-parent", + `${input.prePushBase}..${head}`, + ])).stdout + .split("\n") + .filter(Boolean); + for (const commit of history) { + const historyTree = yield* treeHash({ ...input, commit }); + if ( + uncertainPushWasAccepted({ + candidateTree: input.candidateTree, + headTree, + firstParentTrees: [historyTree], + candidateCommitReachable: false, + }) + ) + return { accepted: true, head }; + } + const reachable = yield* run( + input, + ["merge-base", "--is-ancestor", input.candidateCommit, head], + { allowNonZeroExit: true }, + ); + return { + accepted: uncertainPushWasAccepted({ + candidateTree: input.candidateTree, + headTree, + firstParentTrees: [], + candidateCommitReachable: reachable.exitCode === 0, + }), + head, + }; + }); + + return OverleafMirror.of({ + mirrorRoot, + initialize, + fetch, + checkout, + manifest, + applyWorkspace, + stageAll, + hasChanges, + commitSnapshot, + retainWorkspaceBase, + releaseOperationRef, + rebaseSnapshot, + rebaseRangeOnto, + conflicts, + materializeBlob, + resolveConflict, + continueRebase, + abortRebase, + review, + push, + treeHash, + candidateAccepted, + }); +}); + +export const layer = Layer.effect(OverleafMirror, make()); diff --git a/apps/server/src/scient/overleaf/OverleafStateStore.ts b/apps/server/src/scient/overleaf/OverleafStateStore.ts new file mode 100644 index 0000000000..6764d76c4a --- /dev/null +++ b/apps/server/src/scient/overleaf/OverleafStateStore.ts @@ -0,0 +1,956 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; + +import type { + ScientOverleafAccount, + ScientOverleafConnection, + ScientOverleafOperationSnapshot, + ScientOverleafSaveAccountRequest, +} from "@t3tools/contracts"; +import { + ScientOverleafAccount as ScientOverleafAccountSchema, + ScientOverleafCommitPolicy, + ScientOverleafConflictDetail, + ScientOverleafConnection as ScientOverleafConnectionSchema, + ScientOverleafOperationError, + ScientOverleafOperationSnapshot as ScientOverleafOperationSnapshotSchema, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { ServerSecretStore } from "../../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../../config.ts"; +import { + type OperationContext, + type PersistedOverleafAccount, + type PersistedOverleafConnection, + type PersistedOverleafOperation, + type TreeManifest, + terminalOverleafPhases, +} from "./model.ts"; + +const TreeFileSchema = Schema.Struct({ + path: Schema.String, + hash: Schema.String, + size: Schema.Number, + executable: Schema.Boolean, +}); +const TreeManifestSchema = Schema.Struct({ + files: Schema.Array(TreeFileSchema), + totalBytes: Schema.Number, +}); +const PersistedAccountSchema = Schema.Struct({ + ...ScientOverleafAccountSchema.fields, + secretRef: Schema.String, +}); +const { + state: _stateSchema, + remoteBaselineCommit: _remoteBaselineCommitSchema, + lastConvergedCommit: _lastConvergedCommitSchema, + localAhead: _localAheadSchema, + lastSyncedAtEpochMs: _lastSyncedAtEpochMsSchema, + ...ConnectionRecordFields +} = ScientOverleafConnectionSchema.fields; +const PersistedConnectionRecordSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + ...ConnectionRecordFields, +}); +const PersistedBaselineSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + state: ScientOverleafConnectionSchema.fields.state, + remoteBaselineCommit: ScientOverleafConnectionSchema.fields.remoteBaselineCommit, + lastConvergedCommit: ScientOverleafConnectionSchema.fields.lastConvergedCommit, + localAhead: ScientOverleafConnectionSchema.fields.localAhead, + lastSyncedAtEpochMs: ScientOverleafConnectionSchema.fields.lastSyncedAtEpochMs, + workspaceBaselineManifest: TreeManifestSchema, + pendingRemoteCommit: Schema.NullOr(Schema.String), + activeOperationId: Schema.NullOr(Schema.String), +}); +const CompanionWriteSchema = Schema.Struct({ + relativePath: Schema.String, + materialPath: Schema.String, +}); +const OperationContextSchema = Schema.Struct({ + workspaceRoot: Schema.optionalKey(Schema.String), + relativeFolder: Schema.optionalKey(Schema.String), + accountId: Schema.optionalKey(Schema.String), + projectUrl: Schema.optionalKey(Schema.String), + gitUrl: Schema.optionalKey(Schema.String), + host: Schema.optionalKey(Schema.String), + label: Schema.optionalKey(Schema.String), + commitPolicy: Schema.optionalKey(ScientOverleafCommitPolicy), + commitMessage: Schema.optionalKey(Schema.String), + clickManifest: Schema.optionalKey(TreeManifestSchema), + remoteManifest: Schema.optionalKey(TreeManifestSchema), + candidateCommit: Schema.optionalKey(Schema.String), + candidateTree: Schema.optionalKey(Schema.String), + prePushBase: Schema.optionalKey(Schema.String), + snapshotCommit: Schema.optionalKey(Schema.String), + workspaceBaseCommit: Schema.optionalKey(Schema.String), + initialMode: Schema.optionalKey( + Schema.Literals(["combine", "replace-local", "replace-overleaf"]), + ), + conflicts: Schema.optionalKey(Schema.Array(ScientOverleafConflictDetail)), + review: Schema.optionalKey( + Schema.Struct({ + candidateCommit: Schema.String, + changes: Schema.Array( + Schema.Struct({ + kind: Schema.Literals(["added", "modified", "renamed", "deleted"]), + path: Schema.String, + oldPath: Schema.optionalKey(Schema.String), + similarity: Schema.optionalKey(Schema.Number), + }), + ), + warnings: Schema.Array( + Schema.Struct({ + kind: Schema.Literals([ + "deletion", + "historical_revert", + "whole_tree_revert", + "rename", + "file_count", + "large_file", + "large_editable_text", + "large_editable_material", + "project_size", + "track_changes_metadata", + "replacement", + ]), + message: Schema.String, + paths: Schema.Array(Schema.String), + blocking: Schema.Boolean, + suppressible: Schema.Boolean, + }), + ), + requiresConfirmation: Schema.Boolean, + }), + ), + companionWrites: Schema.optionalKey(Schema.Array(CompanionWriteSchema)), + disconnectAfterSync: Schema.optionalKey(Schema.Boolean), + localReconcile: Schema.optionalKey(Schema.Boolean), + suppressRenameAfterSuccess: Schema.optionalKey(Schema.Boolean), +}); +const PersistedOperationSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + snapshot: ScientOverleafOperationSnapshotSchema, + context: OperationContextSchema, +}); +const RegistrySchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + accounts: Schema.Array(PersistedAccountSchema), + connectionIds: Schema.Array(Schema.String), + operations: Schema.Array( + Schema.Struct({ + operationId: Schema.String, + connectionId: Schema.NullOr(Schema.String), + }), + ), +}); + +type Registry = typeof RegistrySchema.Type; + +const emptyManifest: TreeManifest = { files: [], totalBytes: 0 }; +const emptyRegistry: Registry = { + schemaVersion: 1, + accounts: [], + connectionIds: [], + operations: [], +}; +const encodeUnknownJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + +function stateError( + code: + | "authentication_failed" + | "corrupt_state" + | "filesystem_failed" + | "invalid_request" + | "not_found" + | "operation_active", + message: string, + retryable = false, +) { + return new ScientOverleafOperationError({ code, message, retryable }); +} + +const decodeRegistryJson = Schema.decodeUnknownEffect(Schema.fromJsonString(RegistrySchema)); +const decodeConnectionJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedConnectionRecordSchema), +); +const decodeBaselineJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedBaselineSchema), +); +const decodeOperationJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(PersistedOperationSchema), +); + +export class OverleafStateStore extends Context.Service< + OverleafStateStore, + { + readonly root: string; + readonly runtimeRoot: string; + readonly newId: Effect.Effect; + readonly overview: (workspaceRoot: string) => Effect.Effect< + { + readonly accounts: ReadonlyArray; + readonly connections: ReadonlyArray; + readonly operations: ReadonlyArray; + }, + ScientOverleafOperationError + >; + readonly saveAccount: ( + input: ScientOverleafSaveAccountRequest, + ) => Effect.Effect; + readonly removeAccount: ( + accountId: string, + ) => Effect.Effect; + readonly accountWithToken: ( + accountId: string, + ) => Effect.Effect< + { account: PersistedOverleafAccount; token: Uint8Array }, + ScientOverleafOperationError + >; + readonly markAccountValidated: ( + accountId: string, + ) => Effect.Effect; + readonly listConnections: Effect.Effect< + ReadonlyArray, + ScientOverleafOperationError + >; + readonly getConnection: ( + connectionId: string, + ) => Effect.Effect; + readonly saveConnection: ( + connection: PersistedOverleafConnection, + ) => Effect.Effect; + readonly createConnection: ( + input: Omit & { + workspaceBaselineManifest?: TreeManifest; + }, + ) => Effect.Effect; + readonly deleteConnection: ( + connectionId: string, + preserveOperationId?: string, + ) => Effect.Effect; + readonly getOperation: ( + operationId: string, + ) => Effect.Effect; + readonly saveOperation: ( + operation: PersistedOverleafOperation, + ) => Effect.Effect; + readonly createOperation: ( + kind: ScientOverleafOperationSnapshot["kind"], + connectionId: string | null, + context: OperationContext, + ) => Effect.Effect; + readonly removeOperation: ( + operationId: string, + ) => Effect.Effect; + readonly connectionDirectory: (connectionId: string) => string; + readonly operationDirectory: (operationId: string) => string; + } +>()("t3/scient/overleaf/OverleafStateStore") {} + +export const make = Effect.fn("OverleafStateStore.make")(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const config = yield* ServerConfig.ServerConfig; + const secrets = yield* ServerSecretStore; + const lock = yield* Semaphore.make(1); + const root = path.join(config.stateDir, "scient", "overleaf", "v1"); + const registryPath = path.join(root, "registry.json"); + const connectionsRoot = path.join(root, "connections"); + const orphanOperationsRoot = path.join(root, "orphan-operations"); + const runtimeRoot = path.join(root, "runtime"); + const operationConnections = new Map(); + + const mapFs = (effect: Effect.Effect, message: string) => + effect.pipe(Effect.mapError(() => stateError("filesystem_failed", message, true))); + const writeJson = (filePath: string, value: unknown) => + Effect.tryPromise({ + try: async () => { + const directory = path.dirname(filePath); + const temporary = path.join( + directory, + `${path.basename(filePath)}.${NodeCrypto.randomUUID()}.tmp`, + ); + await NodeFSP.mkdir(directory, { recursive: true }); + try { + const handle = await NodeFSP.open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${encodeUnknownJson(value)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await NodeFSP.rename(temporary, filePath); + try { + const directoryHandle = await NodeFSP.open(directory, "r"); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } catch { + // Directory fsync is not supported by every platform/filesystem. + } + } finally { + await NodeFSP.rm(temporary, { force: true }); + } + }, + catch: () => stateError("filesystem_failed", "Unable to persist Overleaf state.", true), + }); + const readOptional = (filePath: string) => + fs.readFileString(filePath).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(Option.none()) + : Effect.fail(stateError("filesystem_failed", "Unable to read Overleaf state.", true)), + ), + ); + + yield* mapFs( + fs.makeDirectory(connectionsRoot, { recursive: true }), + "Unable to prepare Overleaf state.", + ); + yield* mapFs( + fs.makeDirectory(orphanOperationsRoot, { recursive: true }), + "Unable to prepare Overleaf state.", + ); + yield* fs.chmod(root, 0o700).pipe(Effect.ignore); + yield* mapFs( + fs.remove(runtimeRoot, { recursive: true, force: true }), + "Unable to clear abandoned Overleaf credentials.", + ); + yield* mapFs( + fs.makeDirectory(runtimeRoot, { recursive: true }), + "Unable to prepare Overleaf runtime state.", + ); + + const readRegistry = Effect.fn("OverleafStateStore.readRegistry")(function* () { + const contents = yield* readOptional(registryPath); + if (Option.isNone(contents)) return emptyRegistry; + const registry = yield* decodeRegistryJson(contents.value).pipe( + Effect.mapError(() => stateError("corrupt_state", "Saved Overleaf registry is unreadable.")), + ); + for (const operation of registry.operations) { + operationConnections.set(operation.operationId, operation.connectionId); + } + return registry; + }); + const writeRegistry = (registry: Registry) => writeJson(registryPath, registry); + const connectionDirectory = (connectionId: string) => path.join(connectionsRoot, connectionId); + const connectionPath = (connectionId: string) => + path.join(connectionDirectory(connectionId), "connection.json"); + const baselinePath = (connectionId: string) => + path.join(connectionDirectory(connectionId), "baseline.json"); + const operationDirectory = (operationId: string) => { + const connectionId = operationConnections.get(operationId) ?? null; + return connectionId === null + ? path.join(orphanOperationsRoot, operationId) + : path.join(connectionDirectory(connectionId), "operations", operationId); + }; + const operationPath = (operationId: string) => + path.join(operationDirectory(operationId), "operation.json"); + const newId = crypto.randomUUIDv4.pipe( + Effect.mapError(() => + stateError("filesystem_failed", "Unable to allocate Overleaf state.", true), + ), + ); + + const writeConnectionFiles = Effect.fnUntraced(function* ( + connection: PersistedOverleafConnection, + ) { + const { + state: connectionState, + remoteBaselineCommit, + lastConvergedCommit, + localAhead, + lastSyncedAtEpochMs, + workspaceBaselineManifest, + pendingRemoteCommit, + activeOperationId, + ...record + } = connection; + yield* writeJson(connectionPath(connection.connectionId), { schemaVersion: 1, ...record }); + yield* writeJson(baselinePath(connection.connectionId), { + schemaVersion: 1, + state: connectionState, + remoteBaselineCommit, + lastConvergedCommit, + localAhead, + lastSyncedAtEpochMs, + workspaceBaselineManifest, + pendingRemoteCommit, + activeOperationId, + }); + }); + const writeOperationFile = (operation: PersistedOverleafOperation) => + writeJson(operationPath(operation.snapshot.operationId), { schemaVersion: 1, ...operation }); + + const readConnection = Effect.fn("OverleafStateStore.readConnection")(function* ( + connectionId: string, + ) { + const [connectionContents, baselineContents] = yield* Effect.all([ + readOptional(connectionPath(connectionId)), + readOptional(baselinePath(connectionId)), + ]); + if (Option.isNone(connectionContents) && Option.isNone(baselineContents)) + return yield* stateError("not_found", "The Overleaf connection was not found."); + if (Option.isNone(connectionContents) || Option.isNone(baselineContents)) + return yield* stateError("corrupt_state", "Saved Overleaf connection state is incomplete."); + const record = yield* decodeConnectionJson(connectionContents.value).pipe( + Effect.mapError(() => + stateError("corrupt_state", "Saved Overleaf connection is unreadable."), + ), + ); + const baseline = yield* decodeBaselineJson(baselineContents.value).pipe( + Effect.mapError(() => stateError("corrupt_state", "Saved Overleaf baseline is unreadable.")), + ); + const { schemaVersion: _recordVersion, ...connectionRecord } = record; + const { schemaVersion: _baselineVersion, ...connectionBaseline } = baseline; + return { ...connectionRecord, ...connectionBaseline } satisfies PersistedOverleafConnection; + }); + const readOperation = Effect.fn("OverleafStateStore.readOperation")(function* ( + operationId: string, + ) { + const contents = yield* readOptional(operationPath(operationId)); + if (Option.isNone(contents)) + return yield* stateError("not_found", "The Overleaf operation was not found."); + const decoded = yield* decodeOperationJson(contents.value).pipe( + Effect.mapError(() => stateError("corrupt_state", "Saved Overleaf operation is unreadable.")), + ); + const { schemaVersion: _schemaVersion, ...operation } = decoded; + return operation; + }); + + const listConnections = Effect.gen(function* () { + const registry = yield* readRegistry(); + return yield* Effect.all(registry.connectionIds.map(readConnection), { concurrency: 8 }); + }); + const listOperations = Effect.gen(function* () { + const registry = yield* readRegistry(); + return yield* Effect.all( + registry.operations.map(({ operationId }) => readOperation(operationId)), + { concurrency: 8 }, + ); + }); + + // A process restart cannot resume a scoped Git child. Human decision points and + // already-known recovery states remain resumable; an in-flight push becomes + // outcome-unknown, while an interrupted local projection remains offline-retryable. + yield* lock.withPermits(1)( + Effect.gen(function* () { + const operations = yield* listOperations; + for (const operation of operations) { + if (terminalOverleafPhases.has(operation.snapshot.phase)) continue; + const now = yield* Clock.currentTimeMillis; + const connection = + operation.snapshot.connectionId === null + ? Option.none() + : yield* readConnection(operation.snapshot.connectionId).pipe(Effect.option); + const resumableDecision = new Set([ + "awaiting_commit_message", + "awaiting_push_confirmation", + "awaiting_conflicts", + "awaiting_local_conflicts", + "push_outcome_unknown", + "remote_synced_local_pending", + ]).has(operation.snapshot.phase); + if (resumableDecision) { + if (Option.isSome(connection)) { + const recoveryState = + operation.snapshot.phase === "push_outcome_unknown" + ? ("push_outcome_unknown" as const) + : operation.snapshot.phase === "remote_synced_local_pending" || + operation.snapshot.phase === "awaiting_local_conflicts" + ? ("local_projection_pending" as const) + : ("operation_active" as const); + yield* writeConnectionFiles({ + ...connection.value, + state: recoveryState, + activeOperationId: operation.snapshot.operationId, + }); + } + continue; + } + if ( + operation.snapshot.phase === "pushing" && + operation.context.candidateTree !== undefined + ) { + yield* writeOperationFile({ + ...operation, + snapshot: { + ...operation.snapshot, + phase: "push_outcome_unknown", + message: + "The app restarted during the push. The local project is untouched; choose Retry to verify Overleaf.", + errorCode: "push_outcome_unknown", + retryable: true, + updatedAtEpochMs: now, + }, + }); + if (Option.isSome(connection)) { + yield* writeConnectionFiles({ + ...connection.value, + state: "push_outcome_unknown", + activeOperationId: operation.snapshot.operationId, + }); + } + continue; + } + if ( + operation.snapshot.phase === "projecting" && + Option.isSome(connection) && + connection.value.pendingRemoteCommit !== null + ) { + yield* writeOperationFile({ + ...operation, + snapshot: { + ...operation.snapshot, + phase: "remote_synced_local_pending", + message: "Overleaf is updated. Retry the interrupted local projection.", + errorCode: "local_projection_pending", + retryable: true, + updatedAtEpochMs: now, + }, + }); + yield* writeConnectionFiles({ + ...connection.value, + state: "local_projection_pending", + activeOperationId: operation.snapshot.operationId, + }); + continue; + } + if ( + operation.snapshot.phase === "publishing" && + Option.isSome(connection) && + connection.value.activeOperationId === null + ) { + yield* writeOperationFile({ + ...operation, + snapshot: { + ...operation.snapshot, + phase: "succeeded", + message: "Overleaf and the local project are synchronized.", + review: operation.context.review ?? null, + conflicts: [], + errorCode: null, + retryable: false, + updatedAtEpochMs: now, + }, + }); + continue; + } + yield* writeOperationFile({ + ...operation, + snapshot: { + ...operation.snapshot, + phase: "interrupted", + message: + "The app restarted before this operation finished. Retry or repair explicitly.", + errorCode: "interrupted_state", + retryable: true, + updatedAtEpochMs: now, + }, + } satisfies PersistedOverleafOperation); + if (Option.isSome(connection)) { + yield* writeConnectionFiles({ + ...connection.value, + state: "repair_required", + activeOperationId: null, + } satisfies PersistedOverleafConnection); + } + } + }), + ); + + const overview: OverleafStateStore["Service"]["overview"] = (workspaceRoot) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + const connections = (yield* listConnections).filter( + (connection) => connection.workspaceRoot === workspaceRoot, + ); + const connectionIds = new Set(connections.map((connection) => connection.connectionId)); + const operations = (yield* listOperations) + .filter( + (operation) => + operation.context.workspaceRoot === workspaceRoot || + (operation.snapshot.connectionId !== null && + connectionIds.has(operation.snapshot.connectionId)), + ) + .map((operation) => operation.snapshot); + return { + accounts: registry.accounts.map(({ secretRef: _secretRef, ...account }) => account), + connections, + operations, + }; + }), + ); + + const saveAccount: OverleafStateStore["Service"]["saveAccount"] = (input) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + const existing = + input.accountId === undefined + ? undefined + : registry.accounts.find((account) => account.accountId === input.accountId); + if (input.accountId !== undefined && existing === undefined) + return yield* stateError("not_found", "The Overleaf account was not found."); + if (existing === undefined && input.token === undefined) + return yield* stateError( + "invalid_request", + "A token is required for a new Overleaf account.", + ); + const accountId = existing?.accountId ?? (yield* newId); + const secretRef = existing?.secretRef ?? `scient-overleaf-${accountId}`; + const token = input.token?.trim(); + if (token !== undefined && token.length === 0) + return yield* stateError("invalid_request", "The Overleaf token cannot be empty."); + const now = yield* Clock.currentTimeMillis; + const rawHost = input.host + .replace(/^https:\/\//iu, "") + .replace(/\/$/u, "") + .toLowerCase(); + let host: string; + try { + const parsed = new URL(`https://${rawHost}`); + if ( + parsed.pathname !== "/" || + parsed.search || + parsed.hash || + parsed.username || + parsed.password + ) + throw new Error("invalid host"); + host = parsed.host.toLowerCase(); + } catch { + return yield* stateError( + "invalid_request", + "Enter the exact Overleaf Git host without a path or credentials.", + ); + } + if (input.kind === "cloud" && host !== "git.overleaf.com") + return yield* stateError( + "invalid_request", + "Overleaf Cloud accounts use git.overleaf.com.", + ); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(input.authorEmail)) + return yield* stateError("invalid_request", "Enter the human author's email address."); + if (existing !== undefined && (existing.host !== host || existing.kind !== input.kind)) { + const connections = yield* listConnections; + if (connections.some((connection) => connection.accountId === existing.accountId)) { + return yield* stateError( + "invalid_request", + "Disconnect projects that use this account before changing its Overleaf host or kind.", + ); + } + } + const account: PersistedOverleafAccount = { + accountId, + label: input.label, + kind: input.kind, + host, + authorName: input.authorName, + authorEmail: input.authorEmail, + credentialStatus: "saved", + createdAtEpochMs: existing?.createdAtEpochMs ?? now, + updatedAtEpochMs: now, + lastValidatedAtEpochMs: existing?.lastValidatedAtEpochMs ?? null, + secretRef, + }; + if (token !== undefined) { + yield* secrets + .set(secretRef, new TextEncoder().encode(token)) + .pipe( + Effect.mapError(() => + stateError("filesystem_failed", "Unable to save the Overleaf token.", true), + ), + ); + } + yield* writeRegistry({ + ...registry, + accounts: [ + ...registry.accounts.filter((candidate) => candidate.accountId !== accountId), + account, + ], + }); + const { secretRef: _secretRef, ...wire } = account; + return wire; + }), + ); + + const removeAccount: OverleafStateStore["Service"]["removeAccount"] = (accountId) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + const account = registry.accounts.find((candidate) => candidate.accountId === accountId); + if (!account) return yield* stateError("not_found", "The Overleaf account was not found."); + const connections = yield* listConnections; + if (connections.some((connection) => connection.accountId === accountId)) + return yield* stateError( + "invalid_request", + "Disconnect projects that use this account before removing it.", + ); + const operations = yield* listOperations; + if ( + operations.some( + (operation) => + !terminalOverleafPhases.has(operation.snapshot.phase) && + operation.context.accountId === accountId, + ) + ) { + return yield* stateError( + "operation_active", + "Cancel the active Overleaf connection check before removing this account.", + ); + } + yield* secrets + .remove(account.secretRef) + .pipe( + Effect.mapError(() => + stateError("filesystem_failed", "Unable to remove the Overleaf token.", true), + ), + ); + yield* writeRegistry({ + ...registry, + accounts: registry.accounts.filter((candidate) => candidate.accountId !== accountId), + }); + }), + ); + + const accountWithToken: OverleafStateStore["Service"]["accountWithToken"] = (accountId) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + const account = registry.accounts.find((candidate) => candidate.accountId === accountId); + if (!account) return yield* stateError("not_found", "The Overleaf account was not found."); + const token = yield* secrets + .get(account.secretRef) + .pipe( + Effect.mapError(() => + stateError("filesystem_failed", "Unable to read the Overleaf token.", true), + ), + ); + if (Option.isNone(token)) + return yield* stateError("authentication_failed", "The saved Overleaf token is missing."); + return { account, token: token.value }; + }), + ); + + const markAccountValidated: OverleafStateStore["Service"]["markAccountValidated"] = (accountId) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + const account = registry.accounts.find((candidate) => candidate.accountId === accountId); + if (!account) return yield* stateError("not_found", "The Overleaf account was not found."); + const now = yield* Clock.currentTimeMillis; + yield* writeRegistry({ + ...registry, + accounts: registry.accounts.map((candidate) => + candidate.accountId === accountId + ? { ...candidate, credentialStatus: "saved" as const, lastValidatedAtEpochMs: now } + : candidate, + ), + }); + }), + ); + + const saveConnection: OverleafStateStore["Service"]["saveConnection"] = (connection) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + yield* mapFs( + fs.makeDirectory(connectionDirectory(connection.connectionId), { recursive: true }), + "Unable to prepare Overleaf connection state.", + ); + yield* writeConnectionFiles(connection); + if (!registry.connectionIds.includes(connection.connectionId)) + yield* writeRegistry({ + ...registry, + connectionIds: [...registry.connectionIds, connection.connectionId], + }); + }), + ); + + const createConnection: OverleafStateStore["Service"]["createConnection"] = (input) => + Effect.gen(function* () { + const connection: PersistedOverleafConnection = { + ...input, + workspaceBaselineManifest: input.workspaceBaselineManifest ?? emptyManifest, + }; + yield* saveConnection(connection); + return connection; + }); + + const deleteConnection: OverleafStateStore["Service"]["deleteConnection"] = ( + connectionId, + preserveOperationId, + ) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + if (preserveOperationId !== undefined) { + const registered = registry.operations.find( + (operation) => + operation.operationId === preserveOperationId && + operation.connectionId === connectionId, + ); + if (registered === undefined) + return yield* stateError( + "not_found", + "The completing Overleaf operation was not found.", + ); + const source = operationDirectory(preserveOperationId); + const destination = path.join(orphanOperationsRoot, preserveOperationId); + yield* mapFs( + fs.makeDirectory(orphanOperationsRoot, { recursive: true }), + "Unable to retain the disconnect result.", + ); + yield* mapFs(fs.rename(source, destination), "Unable to retain the disconnect result."); + operationConnections.set(preserveOperationId, null); + } + yield* mapFs( + fs.remove(connectionDirectory(connectionId), { recursive: true, force: true }), + "Unable to remove Overleaf connection state.", + ); + const removedOperations = registry.operations.filter( + (operation) => + operation.connectionId === connectionId && + operation.operationId !== preserveOperationId, + ); + for (const operation of removedOperations) + operationConnections.delete(operation.operationId); + yield* writeRegistry({ + ...registry, + connectionIds: registry.connectionIds.filter((id) => id !== connectionId), + operations: registry.operations + .filter( + (operation) => + operation.connectionId !== connectionId || + operation.operationId === preserveOperationId, + ) + .map((operation) => + operation.operationId === preserveOperationId + ? { ...operation, connectionId: null } + : operation, + ), + }); + }), + ); + + const saveOperation: OverleafStateStore["Service"]["saveOperation"] = (operation) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + operationConnections.set(operation.snapshot.operationId, operation.snapshot.connectionId); + yield* mapFs( + fs.makeDirectory(operationDirectory(operation.snapshot.operationId), { recursive: true }), + "Unable to prepare Overleaf operation state.", + ); + yield* writeOperationFile(operation); + if ( + !registry.operations.some( + (candidate) => candidate.operationId === operation.snapshot.operationId, + ) + ) { + yield* writeRegistry({ + ...registry, + operations: [ + ...registry.operations, + { + operationId: operation.snapshot.operationId, + connectionId: operation.snapshot.connectionId, + }, + ], + }); + } + }), + ); + + const createOperation: OverleafStateStore["Service"]["createOperation"] = ( + kind, + connectionId, + context, + ) => + Effect.gen(function* () { + const operationId = yield* newId; + operationConnections.set(operationId, connectionId); + const now = yield* Clock.currentTimeMillis; + const operation: PersistedOverleafOperation = { + snapshot: { + operationId, + generation: 1, + kind, + connectStage: kind === "connect" ? "preflight" : null, + connectionId, + phase: "preparing", + startedAtEpochMs: now, + updatedAtEpochMs: now, + message: "Preparing Overleaf operation…", + review: null, + conflicts: [], + errorCode: null, + retryable: false, + }, + context, + }; + yield* saveOperation(operation); + return operation; + }); + + const removeOperation: OverleafStateStore["Service"]["removeOperation"] = (operationId) => + lock.withPermits(1)( + Effect.gen(function* () { + const registry = yield* readRegistry(); + yield* mapFs( + fs.remove(operationDirectory(operationId), { recursive: true, force: true }), + "Unable to remove Overleaf operation state.", + ); + operationConnections.delete(operationId); + yield* writeRegistry({ + ...registry, + operations: registry.operations.filter( + (operation) => operation.operationId !== operationId, + ), + }); + }), + ); + + return OverleafStateStore.of({ + root, + runtimeRoot, + newId, + overview, + saveAccount, + removeAccount, + accountWithToken, + markAccountValidated, + listConnections, + getConnection: readConnection, + saveConnection, + createConnection, + deleteConnection, + getOperation: readOperation, + saveOperation, + createOperation, + removeOperation, + connectionDirectory, + operationDirectory, + }); +}); + +export const layer = Layer.effect(OverleafStateStore, make()); diff --git a/apps/server/src/scient/overleaf/OverleafSyncService.ts b/apps/server/src/scient/overleaf/OverleafSyncService.ts new file mode 100644 index 0000000000..930724aae0 --- /dev/null +++ b/apps/server/src/scient/overleaf/OverleafSyncService.ts @@ -0,0 +1,2437 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import type { + ScientOverleafAccount, + ScientOverleafChange, + ScientOverleafCommitPolicy, + ScientOverleafConflictDetail, + ScientOverleafConflictResolutionRequest, + ScientOverleafConnection, + ScientOverleafConnectionSettingsRequest, + ScientOverleafContinueRequest, + ScientOverleafDisconnectRequest, + ScientOverleafDisconnectResult, + ScientOverleafOperationSnapshot, + ScientOverleafOverview, + ScientOverleafPreflightCompleteRequest, + ScientOverleafPreflightStartRequest, + ScientOverleafReviewConfirmationRequest, + ScientOverleafSaveAccountRequest, + ScientOverleafSyncStartRequest, + ScientOverleafWarning, +} from "@t3tools/contracts"; +import { ScientOverleafOperationError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +import { WorkspaceEntries } from "../../workspace/WorkspaceEntries.ts"; +import { advisoryWarnings, ManagedTree, normalizeManagedPath } from "./ManagedTree.ts"; +import type { + OperationContext, + PersistedOverleafAccount, + PersistedOverleafConnection, + PersistedOverleafOperation, + TreeFile, + TreeManifest, +} from "./model.ts"; +import { manifestMap } from "./model.ts"; +import { OverleafMirror } from "./OverleafMirror.ts"; +import { OverleafStateStore } from "./OverleafStateStore.ts"; +import { normalizeRelativeFolder, parseOverleafProjectInput } from "./urls.ts"; +import { WorkspaceProjector } from "./WorkspaceProjector.ts"; + +const emptyManifest: TreeManifest = { files: [], totalBytes: 0 }; +const isOverleafOperationError = Schema.is(ScientOverleafOperationError); + +function publicError(error: unknown): ScientOverleafOperationError { + if (isOverleafOperationError(error)) return error; + return new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "The Overleaf operation could not be completed.", + retryable: true, + }); +} + +function manifestsEqual(left: TreeManifest, right: TreeManifest): boolean { + if (left.totalBytes !== right.totalBytes || left.files.length !== right.files.length) + return false; + return left.files.every((file, index) => { + const other = right.files[index]; + return ( + other !== undefined && + file.path === other.path && + file.hash === other.hash && + file.size === other.size + ); + }); +} + +function manifestChanges( + base: TreeManifest, + candidate: TreeManifest, +): ReadonlyArray { + const before = manifestMap(base); + const after = manifestMap(candidate); + const deleted = [...before.values()].filter((file) => !after.has(file.path)); + const added = [...after.values()].filter((file) => !before.has(file.path)); + const claimedAdded = new Set(); + const claimedDeleted = new Set(); + const changes: ScientOverleafChange[] = []; + for (const oldFile of deleted) { + const renamed = added.find( + (file) => !claimedAdded.has(file.path) && file.hash === oldFile.hash, + ); + if (!renamed) continue; + claimedDeleted.add(oldFile.path); + claimedAdded.add(renamed.path); + changes.push({ kind: "renamed", oldPath: oldFile.path, path: renamed.path, similarity: 100 }); + } + for (const file of before.values()) { + const next = after.get(file.path); + if (next && next.hash !== file.hash) changes.push({ kind: "modified", path: file.path }); + } + for (const file of deleted) + if (!claimedDeleted.has(file.path)) changes.push({ kind: "deleted", path: file.path }); + for (const file of added) + if (!claimedAdded.has(file.path)) changes.push({ kind: "added", path: file.path }); + return changes.toSorted((left, right) => left.path.localeCompare(right.path)); +} + +function targetFolder(workspaceRoot: string, relativeFolder: string): string { + return relativeFolder.length === 0 + ? NodePath.resolve(workspaceRoot) + : NodePath.resolve(workspaceRoot, ...relativeFolder.split("/")); +} + +function isNestedOrSame(left: string, right: string): boolean { + const relative = NodePath.relative(left, right); + return relative === "" || (!relative.startsWith("..") && !NodePath.isAbsolute(relative)); +} + +function resolveCommitMessage( + connection: Pick, + prompted?: string, +): Effect.Effect { + if (connection.commitPolicy.kind === "neutral") return Effect.succeed("Update project"); + if (connection.commitPolicy.kind === "custom") { + const message = connection.commitPolicy.message?.trim(); + if (message) return Effect.succeed(message); + } + const promptedMessage = prompted?.trim(); + if (promptedMessage) return Effect.succeed(promptedMessage); + return Effect.fail( + new ScientOverleafOperationError({ + code: "invalid_request", + message: "Enter a commit message before synchronizing this project.", + retryable: false, + }), + ); +} + +function validateCommitPolicy( + policy: ScientOverleafCommitPolicy, +): Effect.Effect { + if (policy.kind === "custom" && !policy.message?.trim()) { + return Effect.fail( + new ScientOverleafOperationError({ + code: "invalid_request", + message: "A custom Overleaf commit policy requires a nonempty message.", + retryable: false, + }), + ); + } + return Effect.void; +} + +function preview(bytes: Uint8Array): string | null { + if (bytes.byteLength > 2 * 1024 * 1024 || bytes.includes(0)) return null; + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return null; + } +} + +function conflictMaterialDirectory(operationDirectory: string, conflictId: string) { + return NodePath.join( + operationDirectory, + "conflicts", + Buffer.from(conflictId).toString("base64url"), + ); +} + +export class OverleafSyncService extends Context.Service< + OverleafSyncService, + { + readonly overview: ( + workspaceRoot: string, + ) => Effect.Effect; + readonly saveAccount: ( + input: ScientOverleafSaveAccountRequest, + ) => Effect.Effect; + readonly removeAccount: ( + accountId: string, + ) => Effect.Effect; + readonly startPreflight: ( + input: ScientOverleafPreflightStartRequest, + ) => Effect.Effect; + readonly operationStatus: ( + operationId: string, + ) => Effect.Effect; + readonly completePreflight: ( + input: ScientOverleafPreflightCompleteRequest, + ) => Effect.Effect; + readonly cancelOperation: ( + operationId: string, + ) => Effect.Effect; + readonly updateConnection: ( + input: ScientOverleafConnectionSettingsRequest, + ) => Effect.Effect; + readonly startSync: ( + input: ScientOverleafSyncStartRequest, + ) => Effect.Effect; + readonly retryOperation: ( + operationId: string, + ) => Effect.Effect; + readonly confirmReview: ( + input: ScientOverleafReviewConfirmationRequest, + ) => Effect.Effect; + readonly conflicts: ( + operationId: string, + ) => Effect.Effect, ScientOverleafOperationError>; + readonly conflictDetail: ( + operationId: string, + conflictId: string, + ) => Effect.Effect; + readonly resolveConflict: ( + input: ScientOverleafConflictResolutionRequest, + ) => Effect.Effect; + readonly continueOperation: ( + input: ScientOverleafContinueRequest, + ) => Effect.Effect; + readonly reconcileLocal: ( + connectionId: string, + ) => Effect.Effect; + readonly repair: ( + connectionId: string, + ) => Effect.Effect; + readonly disconnect: ( + input: ScientOverleafDisconnectRequest, + ) => Effect.Effect; + } +>()("t3/scient/overleaf/OverleafSyncService") {} + +export const make = Effect.fn("OverleafSyncService.make")(function* () { + const state = yield* OverleafStateStore; + const mirror = yield* OverleafMirror; + const tree = yield* ManagedTree; + const projector = yield* WorkspaceProjector; + const workspaceEntries = yield* WorkspaceEntries; + const fibers = new Map>(); + const launchingOperations = new Set(); + const completingPreflights = new Set(); + const activeConnections = new Set(); + + const patchOperation = Effect.fnUntraced(function* ( + operationId: string, + patch: Partial, + contextPatch: Partial = {}, + ) { + const current = yield* state.getOperation(operationId); + const now = yield* Clock.currentTimeMillis; + const operation: PersistedOverleafOperation = { + snapshot: { + ...current.snapshot, + ...patch, + generation: current.snapshot.generation + 1, + updatedAtEpochMs: now, + }, + context: { ...current.context, ...contextPatch }, + }; + yield* state.saveOperation(operation); + return operation; + }); + + const releaseConnection = Effect.fnUntraced(function* (connectionId: string | null) { + if (connectionId === null) return; + activeConnections.delete(connectionId); + const connection = yield* state.getConnection(connectionId).pipe(Effect.option); + if (Option.isNone(connection)) return; + if (connection.value.state === "operation_active") { + yield* state.saveConnection({ + ...connection.value, + state: connection.value.localAhead ? "local_ahead" : "ready", + activeOperationId: null, + }); + } + }); + + const failOperation = Effect.fnUntraced(function* ( + operationId: string, + cause: Cause.Cause, + ) { + const failure = cause.reasons.find(Cause.isFailReason)?.error; + const error = publicError(failure); + const current = yield* state.getOperation(operationId).pipe(Effect.option); + if (Option.isNone(current)) return; + if (current.value.snapshot.phase === "cancelled") return; + yield* patchOperation(operationId, { + phase: Cause.hasInterruptsOnly(cause) ? "cancelled" : "failed", + message: Cause.hasInterruptsOnly(cause) ? "Overleaf operation cancelled." : error.message, + errorCode: Cause.hasInterruptsOnly(cause) ? null : error.code, + retryable: !Cause.hasInterruptsOnly(cause) && error.retryable, + }); + if (current.value.snapshot.connectionId !== null) { + yield* mirror + .releaseOperationRef({ + operationId, + cwd: mirror.mirrorRoot(current.value.snapshot.connectionId), + }) + .pipe(Effect.ignore); + } + if (current.value.snapshot.kind === "connect" && current.value.snapshot.connectionId !== null) { + const connection = yield* state + .getConnection(current.value.snapshot.connectionId) + .pipe(Effect.option); + activeConnections.delete(current.value.snapshot.connectionId); + if (Option.isSome(connection)) { + yield* state.saveConnection({ + ...connection.value, + state: "repair_required", + activeOperationId: null, + }); + } + } else { + yield* releaseConnection(current.value.snapshot.connectionId); + } + }); + + const launch = Effect.fnUntraced(function* ( + operationId: string, + task: Effect.Effect, + ) { + if (fibers.has(operationId) || launchingOperations.has(operationId)) { + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: "This Overleaf operation is already running.", + retryable: false, + }); + } + launchingOperations.add(operationId); + const fiber = yield* task.pipe( + Effect.catchCause((cause) => + failOperation(operationId, cause).pipe(Effect.catchCause(() => Effect.void)), + ), + Effect.ensuring(Effect.sync(() => fibers.delete(operationId))), + Effect.forkDetach, + ); + fibers.set(operationId, fiber); + launchingOperations.delete(operationId); + }); + + const scanTarget = Effect.fnUntraced(function* ( + operationId: string, + workspaceRoot: string, + relativeFolder: string, + trackedPaths: ReadonlyArray = [], + excludedPaths: ReadonlyArray = [], + ) { + const folder = targetFolder(workspaceRoot, relativeFolder); + const exists = yield* Effect.tryPromise({ + try: async () => (await NodeFSP.lstat(folder)).isDirectory(), + catch: () => false, + }).pipe(Effect.orElseSucceed(() => false)); + if (!exists) return emptyManifest; + return yield* tree.scan({ operationId, root: folder, trackedPaths, excludedPaths }); + }); + + const ensureProjectionDisk = ( + workspaceRoot: string, + desired: TreeManifest, + current: TreeManifest, + ) => + Effect.tryPromise({ + try: async () => { + const stats = await NodeFSP.statfs(workspaceRoot); + const available = Number(stats.bavail) * Number(stats.bsize); + const required = desired.totalBytes + current.totalBytes + 16 * 1024 * 1024; + if (Number.isFinite(available) && available < required) { + throw new ScientOverleafOperationError({ + code: "disk_failed", + message: "There is not enough free disk space for a crash-safe local projection.", + retryable: true, + }); + } + }, + catch: (cause) => + isOverleafOperationError(cause) + ? cause + : new ScientOverleafOperationError({ + code: "disk_failed", + message: "Unable to verify free disk space for local projection.", + retryable: true, + }), + }); + + const gitContext = ( + operationId: string, + connection: PersistedOverleafConnection, + account: PersistedOverleafAccount, + token: Uint8Array, + ) => ({ + operationId, + cwd: mirror.mirrorRoot(connection.connectionId), + account, + token, + }); + + const credentialsForConnection = Effect.fnUntraced(function* ( + connection: PersistedOverleafConnection, + ) { + const credentials = yield* state.accountWithToken(connection.accountId); + if (credentials.account.host !== connection.host) { + return yield* new ScientOverleafOperationError({ + code: "authentication_failed", + message: "The saved Overleaf account no longer matches this connection's exact Git host.", + retryable: false, + }); + } + return credentials; + }); + + const captureWorkspaceBase = Effect.fnUntraced(function* ( + operationId: string, + connection: PersistedOverleafConnection, + clickManifest: TreeManifest, + remoteManifest: TreeManifest, + remoteCommit: string, + ) { + const credentials = yield* credentialsForConnection(connection); + const context = gitContext(operationId, connection, credentials.account, credentials.token); + yield* mirror.applyWorkspace({ + ...context, + workspaceRoot: targetFolder(connection.workspaceRoot, connection.relativeFolder), + desired: clickManifest, + previous: remoteManifest, + }); + const privateCommit = yield* mirror.commitSnapshot({ ...context, message: "Update project" }); + const workspaceBaseCommit = privateCommit ?? remoteCommit; + yield* mirror.retainWorkspaceBase({ ...context, commit: workspaceBaseCommit }); + yield* patchOperation(operationId, {}, { workspaceBaseCommit }); + yield* mirror.checkout({ ...context, commit: remoteCommit }); + return workspaceBaseCommit; + }); + + const writeCompanions = Effect.fnUntraced(function* ( + operation: PersistedOverleafOperation, + connection: PersistedOverleafConnection, + ) { + if (!operation.context.companionWrites?.length) return connection; + const root = targetFolder(connection.workspaceRoot, connection.relativeFolder); + const companions = [...connection.localOnlyCompanions]; + for (const companion of operation.context.companionWrites) { + const target = NodePath.resolve(root, ...companion.relativePath.split("/")); + if (!target.startsWith(`${root}${NodePath.sep}`)) { + return yield* new ScientOverleafOperationError({ + code: "unsafe_tree", + message: "A conflict companion path escapes the project.", + retryable: false, + }); + } + yield* Effect.tryPromise({ + try: async () => { + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.copyFile(companion.materialPath, target, NodeFS.constants.COPYFILE_EXCL); + }, + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to preserve the unselected conflict side.", + retryable: true, + }), + }); + if (!companions.includes(companion.relativePath)) companions.push(companion.relativePath); + } + return { ...connection, localOnlyCompanions: companions }; + }); + + const completeProjection = Effect.fnUntraced(function* ( + operationId: string, + connection: PersistedOverleafConnection, + acceptedCommit: string, + desired: TreeManifest, + ) { + const operation = yield* state.getOperation(operationId); + const clickManifest = operation.context.clickManifest ?? connection.workspaceBaselineManifest; + const root = targetFolder(connection.workspaceRoot, connection.relativeFolder); + const diskCheck = yield* ensureProjectionDisk( + connection.workspaceRoot, + desired, + clickManifest, + ).pipe(Effect.result); + if (Result.isFailure(diskCheck)) { + yield* state.saveConnection({ + ...connection, + state: "local_projection_pending", + pendingRemoteCommit: acceptedCommit, + activeOperationId: operationId, + }); + yield* patchOperation(operationId, { + phase: "remote_synced_local_pending", + message: + "The remote candidate is safe, but local projection needs more free disk space. Retry after making space.", + errorCode: diskCheck.failure.code, + retryable: true, + }); + return; + } + const observed = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + clickManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ); + const alreadyProjected = manifestsEqual(observed, desired); + if (!manifestsEqual(observed, clickManifest) && !alreadyProjected) { + yield* state.saveConnection({ + ...connection, + state: "local_projection_pending", + pendingRemoteCommit: acceptedCommit, + activeOperationId: operationId, + }); + yield* patchOperation(operationId, { + phase: "remote_synced_local_pending", + message: + "Overleaf is updated, but local files changed before projection. Reconcile local to preserve those edits.", + errorCode: "workspace_changed", + retryable: false, + }); + return; + } + if (!alreadyProjected) { + yield* Effect.tryPromise({ + try: () => NodeFSP.mkdir(root, { recursive: true }), + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to create the selected Overleaf folder.", + retryable: true, + }), + }); + yield* patchOperation(operationId, { + phase: "projecting", + message: "Updating local project files…", + errorCode: null, + retryable: false, + }); + const projected = yield* projector + .project({ + sourceRoot: mirror.mirrorRoot(connection.connectionId), + targetRoot: root, + desired, + expected: clickManifest, + previousManaged: connection.workspaceBaselineManifest, + operationDirectory: state.operationDirectory(operationId), + ...(operation.snapshot.kind === "connect" && + operation.context.initialMode === "replace-local" + ? { + backupDirectory: NodePath.join( + state.connectionDirectory(connection.connectionId), + "preconnect-backup", + ), + } + : {}), + }) + .pipe(Effect.result); + if (Result.isFailure(projected)) { + const pending = { + ...connection, + state: "local_projection_pending" as const, + pendingRemoteCommit: acceptedCommit, + activeOperationId: operationId, + }; + yield* state.saveConnection(pending); + yield* patchOperation(operationId, { + phase: "remote_synced_local_pending", + message: + projected.failure.code === "workspace_changed" + ? "Overleaf is updated, but local files changed before projection. Reconcile local to preserve those edits." + : "Overleaf is updated. Retry the local projection when the filesystem is available.", + errorCode: projected.failure.code, + retryable: true, + }); + return; + } + } + const companionResult = yield* writeCompanions(operation, connection).pipe(Effect.result); + if (Result.isFailure(companionResult)) { + yield* state.saveConnection({ + ...connection, + state: "local_projection_pending", + pendingRemoteCommit: acceptedCommit, + activeOperationId: operationId, + }); + yield* patchOperation(operationId, { + phase: "remote_synced_local_pending", + message: + "The project was updated, but a requested local-only conflict companion could not be preserved. Retry after resolving the filesystem problem.", + errorCode: companionResult.failure.code, + retryable: true, + }); + return; + } + let completed = companionResult.success; + const now = yield* Clock.currentTimeMillis; + completed = { + ...completed, + state: "ready", + remoteBaselineCommit: acceptedCommit, + lastConvergedCommit: acceptedCommit, + workspaceBaselineManifest: desired, + localAhead: false, + pendingRemoteCommit: null, + activeOperationId: null, + lastSyncedAtEpochMs: now, + suppressRenameWarning: + completed.suppressRenameWarning || operation.context.suppressRenameAfterSuccess === true, + }; + yield* state.saveConnection(completed); + activeConnections.delete(connection.connectionId); + yield* mirror + .releaseOperationRef({ + operationId, + cwd: mirror.mirrorRoot(connection.connectionId), + }) + .pipe(Effect.ignore); + yield* workspaceEntries.refresh(connection.workspaceRoot); + yield* patchOperation(operationId, { + phase: "publishing", + message: "Publishing synchronized state…", + }); + yield* patchOperation(operationId, { + phase: "succeeded", + message: operation.context.disconnectAfterSync + ? "Synchronized and disconnected." + : "Overleaf and the local project are synchronized.", + review: operation.context.review ?? null, + conflicts: [], + errorCode: null, + retryable: false, + }); + if (operation.context.disconnectAfterSync) { + yield* state.deleteConnection(connection.connectionId, operationId); + } + }); + + const pushCandidate = Effect.fnUntraced(function* (operationId: string) { + let operation = yield* state.getOperation(operationId); + const connectionId = operation.snapshot.connectionId; + if (connectionId === null) + return yield* new ScientOverleafOperationError({ + code: "not_found", + message: "The Overleaf connection is unavailable.", + retryable: false, + }); + const connection = yield* state.getConnection(connectionId); + const credentials = yield* credentialsForConnection(connection); + const context = gitContext(operationId, connection, credentials.account, credentials.token); + const storedCandidateCommit = operation.context.candidateCommit; + const storedCandidateManifest = operation.context.remoteManifest; + if (!storedCandidateCommit || !storedCandidateManifest) + return yield* new ScientOverleafOperationError({ + code: "corrupt_state", + message: "The pending Overleaf candidate is incomplete.", + retryable: false, + }); + let candidateCommit: string = storedCandidateCommit; + let candidateManifest: TreeManifest = storedCandidateManifest; + const clickManifest = operation.context.clickManifest ?? connection.workspaceBaselineManifest; + yield* ensureProjectionDisk(connection.workspaceRoot, candidateManifest, clickManifest); + + for (let attempt = 0; attempt < 3; attempt += 1) { + const current = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + clickManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ); + if (!manifestsEqual(current, clickManifest)) + return yield* new ScientOverleafOperationError({ + code: "workspace_changed", + message: "The project changed after Sync began. Start Sync again to include those edits.", + retryable: true, + }); + const candidateTree = yield* mirror.treeHash({ ...context, commit: candidateCommit }); + yield* patchOperation( + operationId, + { phase: "pushing", message: "Pushing to Overleaf…", review: null }, + { candidateTree }, + ); + const pushed = yield* mirror.push(context); + if (pushed === "confirmed") { + const pendingConnection = { + ...connection, + state: "local_projection_pending" as const, + remoteBaselineCommit: candidateCommit, + pendingRemoteCommit: candidateCommit, + }; + yield* state.saveConnection(pendingConnection); + return yield* completeProjection( + operationId, + pendingConnection, + candidateCommit, + candidateManifest, + ); + } + if (pushed === "unknown") { + yield* state.saveConnection({ + ...connection, + state: "push_outcome_unknown", + pendingRemoteCommit: candidateCommit, + activeOperationId: operationId, + }); + yield* patchOperation( + operationId, + { + phase: "push_outcome_unknown", + message: + "Overleaf may have accepted the push. The local project is untouched; choose Retry to verify it.", + errorCode: "push_outcome_unknown", + retryable: true, + }, + { candidateTree }, + ); + return; + } + + yield* patchOperation(operationId, { + phase: "fetching", + message: "Overleaf changed; fetching the latest master…", + review: null, + }); + const prePushBase = yield* mirror.fetch(context); + const rebased = yield* mirror.rebaseSnapshot({ + ...context, + snapshotCommit: operation.context.snapshotCommit ?? candidateCommit, + }); + if (rebased.conflicted) { + const conflicts = yield* mirror.conflicts(context); + yield* patchOperation( + operationId, + { + phase: "awaiting_conflicts", + message: "Resolve overlapping Overleaf and local edits.", + conflicts: conflicts.map((item) => item.conflict), + errorCode: "conflict", + retryable: false, + }, + { conflicts, prePushBase }, + ); + return; + } + candidateCommit = rebased.candidateCommit; + candidateManifest = yield* mirror.manifest({ ...context, commit: candidateCommit }); + const review = yield* mirror.review({ + ...context, + connection, + candidateCommit, + candidateManifest, + }); + operation = yield* patchOperation( + operationId, + { + phase: review.requiresConfirmation ? "awaiting_push_confirmation" : "rebasing", + message: review.requiresConfirmation + ? "The rebased candidate needs renewed review." + : "Retrying the fast-forward push…", + review: review.requiresConfirmation ? review : null, + }, + { candidateCommit, remoteManifest: candidateManifest, prePushBase, review }, + ); + if (review.requiresConfirmation) return; + } + return yield* new ScientOverleafOperationError({ + code: "push_race", + message: "Overleaf kept changing during Sync. Try again after collaborators pause editing.", + retryable: true, + }); + }); + + const prepareCandidate = Effect.fnUntraced(function* ( + operationId: string, + connection: PersistedOverleafConnection, + snapshotCommit: string | null, + ) { + const credentials = yield* credentialsForConnection(connection); + const context = gitContext(operationId, connection, credentials.account, credentials.token); + yield* patchOperation(operationId, { phase: "fetching", message: "Fetching Overleaf master…" }); + const prePushBase = yield* mirror.fetch(context); + yield* patchOperation(operationId, { + phase: "rebasing", + message: "Merging Overleaf and local changes…", + }); + const rebased = yield* mirror.rebaseSnapshot({ ...context, snapshotCommit }); + if (rebased.conflicted) { + const conflicts = yield* mirror.conflicts(context); + yield* patchOperation( + operationId, + { + phase: "awaiting_conflicts", + message: "Resolve overlapping Overleaf and local edits.", + conflicts: conflicts.map((item) => item.conflict), + errorCode: "conflict", + retryable: false, + }, + { + conflicts, + prePushBase, + ...(snapshotCommit === null ? {} : { snapshotCommit }), + }, + ); + return; + } + const candidateCommit = rebased.candidateCommit; + const candidateManifest = yield* mirror.manifest({ ...context, commit: candidateCommit }); + const review = yield* mirror.review({ + ...context, + connection, + candidateCommit, + candidateManifest, + }); + yield* patchOperation( + operationId, + { + phase: review.requiresConfirmation ? "awaiting_push_confirmation" : "rebasing", + message: review.requiresConfirmation + ? "Review these outbound changes before pushing." + : "Candidate is ready to push.", + review: review.requiresConfirmation ? review : null, + errorCode: review.requiresConfirmation ? "review_required" : null, + retryable: false, + }, + { + candidateCommit, + remoteManifest: candidateManifest, + prePushBase, + review, + ...(snapshotCommit === null ? {} : { snapshotCommit }), + }, + ); + if (!review.requiresConfirmation) yield* pushCandidate(operationId); + }); + + const runSync = Effect.fnUntraced(function* (operationId: string, promptedMessage?: string) { + const operation = yield* state.getOperation(operationId); + const connectionId = operation.snapshot.connectionId!; + const connection = yield* state.getConnection(connectionId); + const credentials = yield* credentialsForConnection(connection); + const context = gitContext(operationId, connection, credentials.account, credentials.token); + const tracked = connection.workspaceBaselineManifest.files.map((file) => file.path); + const clickManifest = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + tracked, + connection.localOnlyCompanions, + ); + const baseline = connection.remoteBaselineCommit ?? "refs/remotes/origin/master"; + yield* patchOperation( + operationId, + { phase: "preparing", message: "Capturing the local project snapshot…" }, + { clickManifest, workspaceBaseCommit: baseline }, + ); + const remoteManifest = yield* mirror.manifest({ ...context, commit: baseline }); + yield* mirror.applyWorkspace({ + ...context, + workspaceRoot: targetFolder(connection.workspaceRoot, connection.relativeFolder), + desired: clickManifest, + previous: remoteManifest, + }); + if (promptedMessage !== undefined) + yield* patchOperation(operationId, {}, { commitMessage: promptedMessage }); + yield* finishInitialCandidate(operationId); + }); + + const syntheticConflicts = Effect.fnUntraced(function* ( + operationId: string, + connection: PersistedOverleafConnection, + local: TreeManifest, + remote: TreeManifest, + ) { + const root = targetFolder(connection.workspaceRoot, connection.relativeFolder); + const mirrorRoot = mirror.mirrorRoot(connection.connectionId); + const localMap = manifestMap(local); + const remoteMap = manifestMap(remote); + const conflicts: ScientOverleafConflictDetail[] = []; + const structuralRoots = new Set(); + for (const localFile of local.files) { + const parts = localFile.path.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const ancestor = parts.slice(0, index).join("/"); + if (remoteMap.has(ancestor)) structuralRoots.add(ancestor); + } + } + for (const remoteFile of remote.files) { + const parts = remoteFile.path.split("/"); + for (let index = 1; index < parts.length; index += 1) { + const ancestor = parts.slice(0, index).join("/"); + if (localMap.has(ancestor)) structuralRoots.add(ancestor); + } + } + const structuralPaths = new Set(); + for (const rootPath of structuralRoots) { + const localFiles = local.files.filter( + (file) => file.path === rootPath || file.path.startsWith(`${rootPath}/`), + ); + const remoteFiles = remote.files.filter( + (file) => file.path === rootPath || file.path.startsWith(`${rootPath}/`), + ); + for (const file of localFiles) structuralPaths.add(file.path); + const material = conflictMaterialDirectory( + state.operationDirectory(operationId), + `structural:${rootPath}`, + ); + const localTree = NodePath.join(material, "local-tree"); + yield* Effect.tryPromise({ + try: async () => { + for (const file of localFiles) { + const source = NodePath.join(root, ...file.path.split("/")); + const destination = NodePath.join(localTree, ...file.path.split("/")); + await NodeFSP.mkdir(NodePath.dirname(destination), { recursive: true }); + await NodeFSP.copyFile(source, destination); + } + }, + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to snapshot a structural local conflict.", + retryable: true, + }), + }); + const localExact = localMap.get(rootPath); + const remoteExact = remoteMap.get(rootPath); + const previewExact = (file: TreeFile | undefined, baseRoot: string) => + Effect.tryPromise({ + try: async () => + file && file.size <= 2 * 1024 * 1024 + ? preview(await NodeFSP.readFile(NodePath.join(baseRoot, ...file.path.split("/")))) + : null, + catch: () => null, + }).pipe(Effect.orElseSucceed(() => null)); + conflicts.push({ + conflict: { + conflictId: `structural:${rootPath}`, + kind: "file_directory", + path: rootPath, + overleafPath: rootPath, + localPath: rootPath, + baseSize: null, + overleafSize: remoteFiles.reduce((total, file) => total + file.size, 0), + localSize: localFiles.reduce((total, file) => total + file.size, 0), + baseHash: null, + overleafHash: remoteExact?.hash ?? null, + localHash: localExact?.hash ?? null, + previewable: localExact !== undefined && remoteExact !== undefined, + resolved: false, + }, + base: null, + overleaf: yield* previewExact(remoteExact, mirrorRoot), + local: yield* previewExact(localExact, root), + }); + } + for (const [relative, localFile] of localMap) { + if (structuralPaths.has(relative)) continue; + const remoteFile = remoteMap.get(relative); + const localPath = NodePath.join(root, ...relative.split("/")); + const mirrorPath = NodePath.join(mirrorRoot, ...relative.split("/")); + if (remoteFile === undefined) { + yield* Effect.tryPromise({ + try: async () => { + await NodeFSP.mkdir(NodePath.dirname(mirrorPath), { recursive: true }); + await NodeFSP.copyFile(localPath, mirrorPath); + }, + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to combine a local project file.", + retryable: true, + }), + }); + continue; + } + if (remoteFile.hash === localFile.hash) continue; + const material = conflictMaterialDirectory(state.operationDirectory(operationId), relative); + const overleafPath = NodePath.join(material, "overleaf"); + const localMaterialPath = NodePath.join(material, "local"); + const [overleafPreview, localPreview] = yield* Effect.tryPromise({ + try: async () => { + await NodeFSP.mkdir(material, { recursive: true }); + await NodeFSP.copyFile(mirrorPath, overleafPath); + await NodeFSP.copyFile(localPath, localMaterialPath); + const readPreview = async (filePath: string) => { + const size = (await NodeFSP.stat(filePath)).size; + return size > 2 * 1024 * 1024 ? null : preview(await NodeFSP.readFile(filePath)); + }; + return await Promise.all([readPreview(overleafPath), readPreview(localMaterialPath)]); + }, + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to prepare initial conflict material.", + retryable: true, + }), + }); + conflicts.push({ + conflict: { + conflictId: relative, + kind: overleafPreview === null || localPreview === null ? "binary" : "content", + path: relative, + baseSize: null, + overleafSize: remoteFile.size, + localSize: localFile.size, + baseHash: null, + overleafHash: remoteFile.hash, + localHash: localFile.hash, + previewable: overleafPreview !== null && localPreview !== null, + resolved: false, + }, + base: null, + overleaf: overleafPreview, + local: localPreview, + }); + } + const credentials = yield* credentialsForConnection(connection); + yield* mirror.stageAll( + gitContext(operationId, connection, credentials.account, credentials.token), + ); + return conflicts; + }); + + const finishInitialCandidate = Effect.fnUntraced(function* (operationId: string) { + const operation = yield* state.getOperation(operationId); + const connection = yield* state.getConnection(operation.snapshot.connectionId!); + const credentials = yield* credentialsForConnection(connection); + const context = gitContext(operationId, connection, credentials.account, credentials.token); + yield* mirror.stageAll(context); + const hasChanges = yield* mirror.hasChanges(context); + if ( + hasChanges && + connection.commitPolicy.kind === "prompt" && + operation.context.commitMessage === undefined + ) { + yield* patchOperation(operationId, { + phase: "awaiting_commit_message", + message: + "Enter the human commit message for this local snapshot. No fetch or push has started.", + errorCode: null, + retryable: false, + }); + return; + } + const message = hasChanges + ? yield* resolveCommitMessage(connection, operation.context.commitMessage) + : "Update project"; + const snapshotCommit = yield* mirror.commitSnapshot({ ...context, message }); + if (snapshotCommit !== null && operation.context.initialMode === undefined) { + yield* mirror.retainWorkspaceBase({ ...context, commit: snapshotCommit }); + yield* patchOperation(operationId, {}, { workspaceBaseCommit: snapshotCommit }); + } + yield* prepareCandidate(operationId, connection, snapshotCommit); + }); + + const startPreflight: OverleafSyncService["Service"]["startPreflight"] = (input) => + Effect.gen(function* () { + yield* validateCommitPolicy(input.commitPolicy); + const parsed = parseOverleafProjectInput(input.projectInput); + const relativeFolder = normalizeRelativeFolder(input.relativeFolder); + const credentials = yield* state.accountWithToken(input.accountId); + if (credentials.account.host !== parsed.host || credentials.account.kind !== parsed.kind) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "The selected account belongs to a different Overleaf host or deployment kind.", + retryable: false, + }); + const root = NodePath.resolve(input.workspaceRoot); + const folder = targetFolder(root, relativeFolder); + if (!isNestedOrSame(root, folder)) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "The selected Overleaf folder must stay inside the workspace.", + retryable: false, + }); + const existing = yield* state.listConnections; + if (existing.some((connection) => connection.gitUrl === parsed.gitUrl)) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "This Overleaf project is already connected in this environment.", + retryable: false, + }); + if ( + existing.some((connection) => { + const current = targetFolder(connection.workspaceRoot, connection.relativeFolder); + return isNestedOrSame(current, folder) || isNestedOrSame(folder, current); + }) + ) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "Overleaf project mappings cannot overlap.", + retryable: false, + }); + const connectionId = yield* state.newId; + const operation = yield* state.createOperation("connect", connectionId, { + workspaceRoot: root, + relativeFolder, + accountId: input.accountId, + projectUrl: parsed.projectUrl, + gitUrl: parsed.gitUrl, + host: parsed.host, + label: input.label ?? `Overleaf ${parsed.projectId}`, + commitPolicy: input.commitPolicy, + }); + yield* launch( + operation.snapshot.operationId, + Effect.gen(function* () { + const context = { + operationId: operation.snapshot.operationId, + cwd: mirror.mirrorRoot(connectionId), + account: credentials.account, + token: credentials.token, + }; + yield* patchOperation(operation.snapshot.operationId, { + phase: "fetching", + message: "Checking Overleaf Git access…", + }); + yield* mirror.initialize({ ...context, connectionId, gitUrl: parsed.gitUrl }); + const remoteHead = yield* mirror.fetch(context); + const remoteManifest = yield* mirror.manifest({ ...context, commit: remoteHead }); + const localManifest = yield* scanTarget( + operation.snapshot.operationId, + root, + relativeFolder, + ); + const combinedFiles = [ + ...new Map( + [...remoteManifest.files, ...localManifest.files].map((file) => [file.path, file]), + ).values(), + ]; + const combined: TreeManifest = { + files: combinedFiles.toSorted((a, b) => a.path.localeCompare(b.path)), + totalBytes: combinedFiles.reduce((total, file) => total + file.size, 0), + }; + const reviewWarnings = advisoryWarnings(combined); + yield* patchOperation( + operation.snapshot.operationId, + { + phase: "awaiting_push_confirmation", + message: "Choose how the existing local and Overleaf files should be combined.", + review: { + candidateCommit: remoteHead, + changes: [], + warnings: reviewWarnings, + requiresConfirmation: reviewWarnings.length > 0, + }, + }, + { + clickManifest: localManifest, + remoteManifest, + candidateCommit: remoteHead, + prePushBase: remoteHead, + }, + ); + }), + ); + return operation.snapshot; + }); + + const completePreflight: OverleafSyncService["Service"]["completePreflight"] = (input) => { + let claimedConnectionId: string | null = null; + return Effect.suspend(() => { + if (completingPreflights.has(input.operationId)) { + return Effect.fail( + new ScientOverleafOperationError({ + code: "operation_active", + message: "This Overleaf connection is already being completed.", + retryable: false, + }), + ); + } + completingPreflights.add(input.operationId); + return Effect.gen(function* () { + const operation = yield* state.getOperation(input.operationId); + if ( + operation.snapshot.kind !== "connect" || + operation.snapshot.phase !== "awaiting_push_confirmation" || + operation.snapshot.generation !== input.generation + ) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "The connection preflight changed; review it again.", + retryable: false, + }); + const context = operation.context; + if ( + !context.workspaceRoot || + context.relativeFolder === undefined || + !context.accountId || + !context.projectUrl || + !context.gitUrl || + !context.host || + !context.label || + !context.commitPolicy || + !context.candidateCommit || + !context.remoteManifest || + !context.clickManifest + ) + return yield* new ScientOverleafOperationError({ + code: "corrupt_state", + message: "The connection preflight is incomplete.", + retryable: false, + }); + const targetManifest = + input.mode === "replace-local" + ? context.remoteManifest + : input.mode === "replace-overleaf" + ? context.clickManifest + : { + files: [ + ...new Map( + [...context.remoteManifest.files, ...context.clickManifest.files].map( + (file) => [file.path, file], + ), + ).values(), + ].toSorted((left, right) => left.path.localeCompare(right.path)), + totalBytes: 0, + }; + const normalizedTarget = + targetManifest.totalBytes === 0 && targetManifest.files.length > 0 + ? { + ...targetManifest, + totalBytes: targetManifest.files.reduce((total, file) => total + file.size, 0), + } + : targetManifest; + const selectedChanges = + input.mode === "replace-local" + ? manifestChanges(context.clickManifest, context.remoteManifest) + : input.mode === "replace-overleaf" + ? manifestChanges(context.remoteManifest, context.clickManifest) + : []; + const selectedWarnings: ScientOverleafWarning[] = [...advisoryWarnings(normalizedTarget)]; + if (input.mode === "replace-local") + selectedWarnings.push({ + kind: "replacement", + message: + "Overleaf managed files will replace the local managed tree. Replaced and deleted files are retained in the pre-connect backup.", + paths: selectedChanges.map((change) => change.path), + blocking: true, + suppressible: false, + }); + if (input.mode === "replace-overleaf") + selectedWarnings.push({ + kind: "track_changes_metadata", + message: + "The local managed tree will replace Overleaf files. Comments and Track Changes metadata may be displaced.", + paths: selectedChanges.map((change) => change.path), + blocking: true, + suppressible: false, + }); + const selectedReview = { + candidateCommit: context.candidateCommit, + changes: selectedChanges, + warnings: selectedWarnings, + requiresConfirmation: selectedWarnings.some((warning) => warning.blocking), + }; + if ( + context.initialMode !== input.mode && + selectedReview.requiresConfirmation && + (input.mode !== "combine" || !input.acknowledgeWarnings) + ) { + const staged = yield* patchOperation( + input.operationId, + { + phase: "awaiting_push_confirmation", + message: "Review the exact changes for the selected connection mode.", + review: selectedReview, + }, + { initialMode: input.mode, review: selectedReview }, + ); + return staged.snapshot; + } + if (selectedReview.requiresConfirmation && !input.acknowledgeWarnings) + return yield* new ScientOverleafOperationError({ + code: "limit_acknowledgement_required", + message: "Review and acknowledge the selected connection changes before connecting.", + retryable: false, + }); + const connection: PersistedOverleafConnection = { + connectionId: operation.snapshot.connectionId!, + accountId: context.accountId, + label: context.label, + workspaceRoot: context.workspaceRoot, + relativeFolder: context.relativeFolder, + projectUrl: context.projectUrl, + gitUrl: context.gitUrl, + host: context.host, + branch: "master", + commitPolicy: context.commitPolicy, + suppressRenameWarning: false, + state: "operation_active", + remoteBaselineCommit: context.candidateCommit, + lastConvergedCommit: null, + localAhead: false, + localOnlyCompanions: [], + lastSyncedAtEpochMs: null, + workspaceBaselineManifest: context.clickManifest, + pendingRemoteCommit: null, + activeOperationId: input.operationId, + }; + if (activeConnections.has(connection.connectionId)) + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: "This Overleaf connection is already being created.", + retryable: false, + }); + const existing = yield* state.listConnections; + if (existing.some((candidate) => candidate.gitUrl === connection.gitUrl)) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "This Overleaf project is already connected in this environment.", + retryable: false, + }); + if ( + existing.some((candidate) => { + const current = targetFolder(candidate.workspaceRoot, candidate.relativeFolder); + const selected = targetFolder(connection.workspaceRoot, connection.relativeFolder); + return isNestedOrSame(current, selected) || isNestedOrSame(selected, current); + }) + ) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "Overleaf project mappings cannot overlap.", + retryable: false, + }); + activeConnections.add(connection.connectionId); + claimedConnectionId = connection.connectionId; + yield* state.createConnection(connection); + yield* patchOperation( + input.operationId, + { + phase: "preparing", + connectStage: "connected", + message: "Applying the selected connection mode…", + review: null, + }, + { + initialMode: input.mode, + ...(input.commitMessage === undefined ? {} : { commitMessage: input.commitMessage }), + }, + ); + yield* launch( + input.operationId, + Effect.gen(function* () { + const credentials = yield* credentialsForConnection(connection); + const git = gitContext( + input.operationId, + connection, + credentials.account, + credentials.token, + ); + yield* mirror.checkout({ ...git, commit: context.candidateCommit! }); + yield* captureWorkspaceBase( + input.operationId, + connection, + context.clickManifest!, + context.remoteManifest!, + context.candidateCommit!, + ); + if (input.mode === "replace-local") { + return yield* completeProjection( + input.operationId, + connection, + context.candidateCommit!, + context.remoteManifest!, + ); + } + if (input.mode === "replace-overleaf") { + yield* mirror.applyWorkspace({ + ...git, + workspaceRoot: targetFolder(connection.workspaceRoot, connection.relativeFolder), + desired: context.clickManifest!, + previous: context.remoteManifest!, + }); + return yield* finishInitialCandidate(input.operationId); + } + const conflicts = yield* syntheticConflicts( + input.operationId, + connection, + context.clickManifest!, + context.remoteManifest!, + ); + if (conflicts.length > 0) { + yield* patchOperation( + input.operationId, + { + phase: "awaiting_conflicts", + message: "Choose a version for files that differ locally and on Overleaf.", + conflicts: conflicts.map((item) => item.conflict), + errorCode: "conflict", + }, + { conflicts }, + ); + return; + } + yield* finishInitialCandidate(input.operationId); + }), + ); + return (yield* state.getOperation(input.operationId)).snapshot; + }).pipe( + Effect.tapError(() => + claimedConnectionId === null + ? Effect.void + : state + .deleteConnection(claimedConnectionId) + .pipe( + Effect.ensuring( + Effect.sync(() => activeConnections.delete(claimedConnectionId!)), + ), + Effect.ignore, + ), + ), + Effect.ensuring(Effect.sync(() => completingPreflights.delete(input.operationId))), + ); + }); + }; + + const startSync: OverleafSyncService["Service"]["startSync"] = (input) => + Effect.gen(function* () { + const connection = yield* state.getConnection(input.connectionId); + if ( + activeConnections.has(connection.connectionId) || + connection.activeOperationId !== null || + ["push_outcome_unknown", "local_projection_pending"].includes(connection.state) + ) + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: "Resolve the current Overleaf operation before starting another Sync.", + retryable: false, + }); + activeConnections.add(connection.connectionId); + const operation = yield* state.createOperation("sync", connection.connectionId, { + workspaceRoot: connection.workspaceRoot, + relativeFolder: connection.relativeFolder, + }); + yield* state.saveConnection({ + ...connection, + state: "operation_active", + activeOperationId: operation.snapshot.operationId, + }); + yield* launch( + operation.snapshot.operationId, + runSync(operation.snapshot.operationId, input.commitMessage), + ); + return operation.snapshot; + }).pipe(Effect.tapError(() => releaseConnection(input.connectionId))); + + const operationStatus = (operationId: string) => + state.getOperation(operationId).pipe( + Effect.tap((operation) => + operation.snapshot.kind === "disconnect" && operation.snapshot.phase === "succeeded" + ? state.removeOperation(operationId) + : Effect.void, + ), + Effect.map((operation) => operation.snapshot), + ); + + const confirmReview: OverleafSyncService["Service"]["confirmReview"] = (input) => + Effect.gen(function* () { + const operation = yield* state.getOperation(input.operationId); + if ( + operation.snapshot.phase !== "awaiting_push_confirmation" || + operation.snapshot.generation !== input.generation || + operation.context.candidateCommit !== input.candidateCommit || + !input.acknowledgeWarnings + ) + return yield* new ScientOverleafOperationError({ + code: "review_required", + message: "The Overleaf candidate changed or has not been acknowledged.", + retryable: false, + }); + yield* patchOperation( + input.operationId, + { + phase: "rebasing", + message: "Review confirmed; preparing push…", + review: null, + errorCode: null, + }, + input.suppressFutureRenameWarnings ? { suppressRenameAfterSuccess: true } : {}, + ); + yield* launch(input.operationId, pushCandidate(input.operationId)); + return (yield* state.getOperation(input.operationId)).snapshot; + }); + + const getConflicts = (operationId: string) => + state + .getOperation(operationId) + .pipe(Effect.map((operation) => operation.context.conflicts ?? [])); + const conflictDetail = Effect.fnUntraced(function* (operationId: string, conflictId: string) { + const detail = (yield* getConflicts(operationId)).find( + (candidate) => candidate.conflict.conflictId === conflictId, + ); + if (!detail) + return yield* new ScientOverleafOperationError({ + code: "not_found", + message: "The Overleaf conflict was not found.", + retryable: false, + }); + return detail; + }); + + const resolveConflict: OverleafSyncService["Service"]["resolveConflict"] = (input) => + Effect.gen(function* () { + const operation = yield* state.getOperation(input.operationId); + if ( + !["awaiting_conflicts", "awaiting_local_conflicts"].includes(operation.snapshot.phase) || + operation.snapshot.generation !== input.generation + ) + return yield* new ScientOverleafOperationError({ + code: "conflict", + message: "The conflict state changed; reopen the resolver.", + retryable: false, + }); + const detail = yield* conflictDetail(input.operationId, input.conflictId); + const connection = yield* state.getConnection(operation.snapshot.connectionId!); + const material = conflictMaterialDirectory( + state.operationDirectory(input.operationId), + input.conflictId, + ); + const credentials = yield* credentialsForConnection(connection); + const git = gitContext(input.operationId, connection, credentials.account, credentials.token); + let keepBothPath: string | null = null; + if (input.resolution === "both") { + if (!input.keepBothPath) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "Choose a non-colliding managed path for Keep both.", + retryable: false, + }); + keepBothPath = yield* Effect.try({ + try: () => normalizeManagedPath(input.keepBothPath!), + catch: (cause) => publicError(cause), + }); + if ( + keepBothPath === detail.conflict.path || + keepBothPath === detail.conflict.overleafPath || + keepBothPath === detail.conflict.localPath + ) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "The Keep both path must differ from both conflicting paths.", + retryable: false, + }); + const keepBothTarget = NodePath.join( + mirror.mirrorRoot(connection.connectionId), + ...keepBothPath.split("/"), + ); + const occupied = yield* Effect.tryPromise({ + try: async () => { + try { + await NodeFSP.lstat(keepBothTarget); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException).code !== "ENOENT"; + } + }, + catch: () => true, + }).pipe(Effect.orElseSucceed(() => true)); + if (occupied) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "The Keep both path already exists in the candidate.", + retryable: false, + }); + } + if ( + operation.context.initialMode === "combine" && + detail.conflict.kind === "file_directory" + ) { + if (input.keepOtherSide) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: + "Use Keep both for a structural conflict; local-only companion preservation applies to individual files.", + retryable: false, + }); + const destination = NodePath.join( + mirror.mirrorRoot(connection.connectionId), + ...detail.conflict.path.split("/"), + ); + const localSnapshot = NodePath.join( + material, + "local-tree", + ...detail.conflict.path.split("/"), + ); + yield* Effect.tryPromise({ + try: async () => { + if (input.resolution === "delete" || input.resolution === "local") { + await NodeFSP.rm(destination, { recursive: true, force: true }); + } + if (input.resolution === "local") { + await NodeFSP.mkdir(NodePath.dirname(destination), { recursive: true }); + await NodeFSP.cp(localSnapshot, destination, { + recursive: true, + force: false, + errorOnExist: true, + }); + } + if (input.resolution === "both" && keepBothPath !== null) { + const second = NodePath.join( + mirror.mirrorRoot(connection.connectionId), + ...keepBothPath.split("/"), + ); + await NodeFSP.mkdir(NodePath.dirname(second), { recursive: true }); + await NodeFSP.cp(localSnapshot, second, { + recursive: true, + force: false, + errorOnExist: true, + }); + } + }, + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to apply the structural conflict choice.", + retryable: true, + }), + }); + yield* mirror.stageAll(git); + } else if (operation.context.initialMode === "combine") { + const selected = + input.resolution === "local" || input.resolution === "both" + ? NodePath.join(material, "local") + : NodePath.join(material, "overleaf"); + const destination = NodePath.join( + mirror.mirrorRoot(connection.connectionId), + ...detail.conflict.path.split("/"), + ); + yield* Effect.tryPromise({ + try: async () => { + if (input.resolution === "delete") await NodeFSP.rm(destination, { force: true }); + else { + await NodeFSP.mkdir(NodePath.dirname(destination), { recursive: true }); + await NodeFSP.copyFile(selected, destination); + } + if (input.resolution === "both" && keepBothPath !== null) { + const second = NodePath.join( + mirror.mirrorRoot(connection.connectionId), + ...keepBothPath.split("/"), + ); + await NodeFSP.mkdir(NodePath.dirname(second), { recursive: true }); + await NodeFSP.copyFile( + NodePath.join(material, "overleaf"), + second, + NodeFS.constants.COPYFILE_EXCL, + ); + } + }, + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to apply the conflict choice.", + retryable: true, + }), + }); + yield* mirror.stageAll(git); + } else { + yield* mirror.resolveConflict({ ...git, conflict: detail, resolution: input.resolution }); + if (input.resolution === "both" && keepBothPath !== null) { + const overleafHash = detail.conflict.overleafHash; + const overleafSize = detail.conflict.overleafSize; + if (overleafHash === null || overleafSize === null) + return yield* new ScientOverleafOperationError({ + code: "conflict", + message: "The Overleaf side cannot be retained at another path.", + retryable: false, + }); + yield* mirror.materializeBlob({ + ...git, + hash: overleafHash, + destination: NodePath.join( + mirror.mirrorRoot(connection.connectionId), + ...keepBothPath.split("/"), + ), + maxBytes: overleafSize + 1, + }); + yield* mirror.stageAll(git); + } + } + const companions = [...(operation.context.companionWrites ?? [])]; + if (input.keepOtherSide && input.resolution !== "delete" && input.resolution !== "both") { + const chosenLocal = input.resolution === "local"; + const otherHash = chosenLocal ? detail.conflict.overleafHash : detail.conflict.localHash; + const side = chosenLocal ? "overleaf" : "local"; + if (otherHash !== null) { + const extension = NodePath.extname(detail.conflict.path); + const stem = detail.conflict.path.slice( + 0, + detail.conflict.path.length - extension.length, + ); + const companionPath = yield* Effect.try({ + try: () => + normalizeManagedPath( + input.companionPath ?? `${stem}.${side}-${otherHash.slice(0, 8)}${extension}`, + ), + catch: (cause) => publicError(cause), + }); + if ( + NodePath.posix.dirname(companionPath) !== NodePath.posix.dirname(detail.conflict.path) + ) { + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "A preserved conflict side must use a sibling path.", + retryable: false, + }); + } + if (companions.some((candidate) => candidate.relativePath === companionPath)) { + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "That local-only companion path is already reserved by this operation.", + retryable: false, + }); + } + const companionTarget = NodePath.join( + targetFolder(connection.workspaceRoot, connection.relativeFolder), + ...companionPath.split("/"), + ); + const companionExists = yield* Effect.tryPromise({ + try: async () => { + try { + await NodeFSP.lstat(companionTarget); + return true; + } catch (cause) { + return (cause as NodeJS.ErrnoException).code !== "ENOENT"; + } + }, + catch: () => true, + }).pipe(Effect.orElseSucceed(() => true)); + if (companionExists) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "That local-only companion path already exists.", + retryable: false, + }); + const materialPath = + operation.context.initialMode === "combine" + ? NodePath.join(material, side) + : NodePath.join(material, side); + if (operation.context.initialMode !== "combine") { + const otherSize = + side === "overleaf" ? detail.conflict.overleafSize : detail.conflict.localSize; + if (otherSize === null) + return yield* new ScientOverleafOperationError({ + code: "conflict", + message: "The unselected conflict side no longer exists.", + retryable: false, + }); + yield* mirror.materializeBlob({ + ...git, + hash: otherHash, + destination: materialPath, + maxBytes: otherSize + 1, + }); + } + companions.push({ relativePath: companionPath, materialPath }); + } + } + const conflicts = (operation.context.conflicts ?? []).map((candidate) => + candidate.conflict.conflictId === input.conflictId + ? { ...candidate, conflict: { ...candidate.conflict, resolved: true } } + : candidate, + ); + const updated = yield* patchOperation( + input.operationId, + { conflicts: conflicts.map((candidate) => candidate.conflict) }, + { conflicts, companionWrites: companions }, + ); + return updated.snapshot; + }); + + const finishLocalReconcile = Effect.fnUntraced(function* ( + operationId: string, + connection: PersistedOverleafConnection, + acceptedCommit: string, + ) { + const credentials = yield* credentialsForConnection(connection); + const context = gitContext(operationId, connection, credentials.account, credentials.token); + const operation = yield* state.getOperation(operationId); + const desired = yield* mirror.manifest({ ...context, commit: "HEAD" }); + const root = targetFolder(connection.workspaceRoot, connection.relativeFolder); + const expected = + operation.context.clickManifest ?? + (yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + connection.workspaceBaselineManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + )); + yield* projector.project({ + sourceRoot: mirror.mirrorRoot(connection.connectionId), + targetRoot: root, + desired, + expected, + previousManaged: connection.workspaceBaselineManifest, + operationDirectory: state.operationDirectory(operationId), + }); + const resultCommit = yield* mirror.treeHash({ ...context, commit: "HEAD" }); + const remoteTree = yield* mirror.treeHash({ ...context, commit: acceptedCommit }); + yield* state.saveConnection({ + ...connection, + state: resultCommit === remoteTree ? "ready" : "local_ahead", + remoteBaselineCommit: acceptedCommit, + lastConvergedCommit: acceptedCommit, + workspaceBaselineManifest: desired, + localAhead: resultCommit !== remoteTree, + pendingRemoteCommit: null, + activeOperationId: null, + suppressRenameWarning: + connection.suppressRenameWarning || operation.context.suppressRenameAfterSuccess === true, + }); + activeConnections.delete(connection.connectionId); + yield* mirror.releaseOperationRef(context).pipe(Effect.ignore); + yield* workspaceEntries.refresh(connection.workspaceRoot); + yield* patchOperation(operationId, { + phase: "succeeded", + message: + resultCommit === remoteTree + ? "Local files now match Overleaf." + : "Local reconciliation completed. A later manual Sync will push the preserved edits.", + review: operation.context.review ?? null, + conflicts: [], + errorCode: null, + retryable: false, + }); + }); + + const continueOperation: OverleafSyncService["Service"]["continueOperation"] = (input) => + Effect.gen(function* () { + const operationId = input.operationId; + const operation = yield* state.getOperation(operationId); + if (operation.snapshot.phase === "awaiting_commit_message") { + const connection = yield* state.getConnection(operation.snapshot.connectionId!); + const commitMessage = yield* resolveCommitMessage(connection, input.commitMessage); + const clickManifest = + operation.context.clickManifest ?? connection.workspaceBaselineManifest; + const observed = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + clickManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ); + if (!manifestsEqual(observed, clickManifest)) + return yield* new ScientOverleafOperationError({ + code: "workspace_changed", + message: + "The project changed while waiting for a commit message. Cancel and start Sync again.", + retryable: false, + }); + yield* patchOperation( + operationId, + { phase: "preparing", message: "Creating the local snapshot commit…", errorCode: null }, + { commitMessage }, + ); + yield* launch(operationId, finishInitialCandidate(operationId)); + return (yield* state.getOperation(operationId)).snapshot; + } + if (operation.context.conflicts?.some((item) => !item.conflict.resolved)) + return yield* new ScientOverleafOperationError({ + code: "conflict", + message: "Resolve every conflict before continuing.", + retryable: false, + }); + const connection = yield* state.getConnection(operation.snapshot.connectionId!); + yield* patchOperation(operationId, { + phase: "rebasing", + message: "Applying conflict resolutions…", + conflicts: [], + errorCode: null, + }); + yield* launch( + operationId, + Effect.gen(function* () { + if (operation.context.initialMode === "combine") + return yield* finishInitialCandidate(operationId); + const credentials = yield* credentialsForConnection(connection); + const continued = yield* mirror.continueRebase( + gitContext(operationId, connection, credentials.account, credentials.token), + ); + if (continued.conflicted) { + const remaining = yield* mirror.conflicts( + gitContext(operationId, connection, credentials.account, credentials.token), + ); + yield* patchOperation( + operationId, + { + phase: operation.context.localReconcile + ? "awaiting_local_conflicts" + : "awaiting_conflicts", + message: "More overlapping edits need resolution.", + conflicts: remaining.map((item) => item.conflict), + errorCode: "conflict", + }, + { conflicts: remaining }, + ); + return; + } + if (operation.context.localReconcile) + return yield* finishLocalReconcile( + operationId, + connection, + connection.pendingRemoteCommit!, + ); + const candidateManifest = yield* mirror.manifest({ + ...gitContext(operationId, connection, credentials.account, credentials.token), + commit: continued.candidateCommit, + }); + const review = yield* mirror.review({ + ...gitContext(operationId, connection, credentials.account, credentials.token), + connection, + candidateCommit: continued.candidateCommit, + candidateManifest, + }); + yield* patchOperation( + operationId, + { + phase: review.requiresConfirmation ? "awaiting_push_confirmation" : "rebasing", + message: review.requiresConfirmation + ? "Review the resolved candidate before pushing." + : "Conflict resolutions are ready to push.", + review: review.requiresConfirmation ? review : null, + }, + { + candidateCommit: continued.candidateCommit, + remoteManifest: candidateManifest, + review, + }, + ); + if (!review.requiresConfirmation) yield* pushCandidate(operationId); + }), + ); + return (yield* state.getOperation(operationId)).snapshot; + }); + + const retryOperation: OverleafSyncService["Service"]["retryOperation"] = (operationId) => + Effect.gen(function* () { + const operation = yield* state.getOperation(operationId); + const connection = + operation.snapshot.connectionId === null + ? null + : yield* state.getConnection(operation.snapshot.connectionId); + if (operation.snapshot.phase === "push_outcome_unknown" && connection !== null) { + yield* patchOperation(operationId, { + phase: "fetching", + message: "Verifying whether Overleaf accepted the push…", + errorCode: null, + }); + yield* launch( + operationId, + Effect.gen(function* () { + const credentials = yield* credentialsForConnection(connection); + const context = gitContext( + operationId, + connection, + credentials.account, + credentials.token, + ); + const candidateCommit = operation.context.candidateCommit!; + const result = yield* mirror.candidateAccepted({ + ...context, + candidateCommit, + candidateTree: operation.context.candidateTree!, + prePushBase: operation.context.prePushBase!, + }); + if (result.accepted) { + const desired = yield* mirror.manifest({ ...context, commit: result.head }); + return yield* completeProjection(operationId, connection, result.head, desired); + } + const rebased = yield* mirror.rebaseSnapshot({ + ...context, + snapshotCommit: operation.context.snapshotCommit ?? candidateCommit, + }); + if (rebased.conflicted) { + const conflicts = yield* mirror.conflicts(context); + yield* patchOperation( + operationId, + { + phase: "awaiting_conflicts", + message: "Overleaf changed while the push outcome was uncertain.", + conflicts: conflicts.map((item) => item.conflict), + errorCode: "conflict", + }, + { conflicts }, + ); + return; + } + const desired = yield* mirror.manifest({ ...context, commit: rebased.candidateCommit }); + yield* patchOperation( + operationId, + { phase: "rebasing", message: "The earlier push was not accepted; retrying safely…" }, + { candidateCommit: rebased.candidateCommit, remoteManifest: desired }, + ); + if (rebased.candidateCommit === result.head) { + const remoteOnly = { + ...connection, + state: "local_projection_pending" as const, + remoteBaselineCommit: result.head, + pendingRemoteCommit: result.head, + activeOperationId: operationId, + }; + yield* state.saveConnection(remoteOnly); + return yield* completeProjection(operationId, remoteOnly, result.head, desired); + } + yield* pushCandidate(operationId); + }), + ); + return (yield* state.getOperation(operationId)).snapshot; + } + if ( + operation.snapshot.phase === "remote_synced_local_pending" && + connection !== null && + connection.pendingRemoteCommit !== null + ) { + const current = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + connection.workspaceBaselineManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ); + if ( + !manifestsEqual( + current, + operation.context.clickManifest ?? connection.workspaceBaselineManifest, + ) + ) + return yield* patchOperation(operationId, { + phase: "awaiting_local_conflicts", + message: "Local files changed; use Reconcile local to preserve them.", + errorCode: "workspace_changed", + retryable: false, + }).pipe(Effect.map((value) => value.snapshot)); + yield* launch( + operationId, + Effect.gen(function* () { + const credentials = yield* credentialsForConnection(connection); + const context = gitContext( + operationId, + connection, + credentials.account, + credentials.token, + ); + const desired = yield* mirror.manifest({ + ...context, + commit: connection.pendingRemoteCommit!, + }); + yield* completeProjection( + operationId, + connection, + connection.pendingRemoteCommit!, + desired, + ); + }), + ); + return operation.snapshot; + } + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "This Overleaf operation has nothing retryable pending.", + retryable: false, + }); + }); + + const reconcileLocal: OverleafSyncService["Service"]["reconcileLocal"] = (connectionId) => + Effect.gen(function* () { + const connection = yield* state.getConnection(connectionId); + if ( + connection.state !== "local_projection_pending" || + connection.pendingRemoteCommit === null || + connection.activeOperationId === null + ) + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "This connection has no local reconciliation pending.", + retryable: false, + }); + const operationId = connection.activeOperationId; + const operation = yield* state.getOperation(operationId); + const current = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + connection.workspaceBaselineManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ); + yield* patchOperation( + operationId, + { phase: "rebasing", message: "Reconciling current local edits offline…", errorCode: null }, + { clickManifest: current, localReconcile: true }, + ); + yield* launch( + operationId, + Effect.gen(function* () { + const credentials = yield* credentialsForConnection(connection); + const context = gitContext( + operationId, + connection, + credentials.account, + credentials.token, + ); + const base = + operation.context.workspaceBaseCommit ?? + operation.context.snapshotCommit ?? + operation.context.prePushBase ?? + connection.remoteBaselineCommit!; + yield* mirror.checkout({ ...context, commit: base }); + yield* mirror.applyWorkspace({ + ...context, + workspaceRoot: targetFolder(connection.workspaceRoot, connection.relativeFolder), + desired: current, + previous: operation.context.clickManifest ?? connection.workspaceBaselineManifest, + }); + const localCommit = yield* mirror.commitSnapshot({ + ...context, + message: "Update project", + }); + if (localCommit === null) { + yield* mirror.checkout({ ...context, commit: connection.pendingRemoteCommit! }); + return yield* finishLocalReconcile( + operationId, + connection, + connection.pendingRemoteCommit!, + ); + } + const rebased = yield* mirror.rebaseRangeOnto({ + ...context, + upstream: connection.pendingRemoteCommit!, + fromExclusive: base, + }); + if (rebased.conflicted) { + const conflicts = yield* mirror.conflicts(context); + yield* patchOperation( + operationId, + { + phase: "awaiting_local_conflicts", + message: + "Choose how the accepted Overleaf result and later local edits should combine.", + conflicts: conflicts.map((item) => item.conflict), + errorCode: "conflict", + }, + { conflicts }, + ); + return; + } + yield* finishLocalReconcile(operationId, connection, connection.pendingRemoteCommit!); + }), + ); + return (yield* state.getOperation(operationId)).snapshot; + }); + + const updateConnection: OverleafSyncService["Service"]["updateConnection"] = (input) => + Effect.gen(function* () { + const connection = yield* state.getConnection(input.connectionId); + if (connection.activeOperationId !== null) + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: "Wait for the active Overleaf operation before changing settings.", + retryable: false, + }); + if (input.commitPolicy !== undefined) yield* validateCommitPolicy(input.commitPolicy); + const includedCompanion = + input.includeCompanionPath === undefined + ? null + : yield* Effect.try({ + try: () => normalizeManagedPath(input.includeCompanionPath!), + catch: (cause) => publicError(cause), + }); + if ( + includedCompanion !== null && + !connection.localOnlyCompanions.includes(includedCompanion) + ) { + return yield* new ScientOverleafOperationError({ + code: "invalid_request", + message: "That path is not a connection-owned local-only companion.", + retryable: false, + }); + } + const updated = { + ...connection, + ...(input.label === undefined ? {} : { label: input.label }), + ...(input.commitPolicy === undefined ? {} : { commitPolicy: input.commitPolicy }), + ...(input.suppressRenameWarning === undefined + ? {} + : { suppressRenameWarning: input.suppressRenameWarning }), + ...(includedCompanion === null + ? {} + : { + localOnlyCompanions: connection.localOnlyCompanions.filter( + (path) => path !== includedCompanion, + ), + }), + }; + yield* state.saveConnection(updated); + return updated; + }); + + const cancelOperation: OverleafSyncService["Service"]["cancelOperation"] = (operationId) => + Effect.gen(function* () { + const operation = yield* state.getOperation(operationId); + if ( + [ + "pushing", + "push_outcome_unknown", + "projecting", + "remote_synced_local_pending", + "awaiting_local_conflicts", + "publishing", + ].includes(operation.snapshot.phase) + ) { + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: + "This phase cannot be cancelled safely. Wait for it to finish or use the explicit recovery action it provides.", + retryable: false, + }); + } + const fiber = fibers.get(operationId); + if (fiber) yield* Fiber.interrupt(fiber); + if (operation.snapshot.connectionId !== null) { + const connection = yield* state + .getConnection(operation.snapshot.connectionId) + .pipe(Effect.option); + if (Option.isSome(connection)) { + const context = { operationId, cwd: mirror.mirrorRoot(connection.value.connectionId) }; + yield* mirror.abortRebase(context).pipe(Effect.ignore); + yield* mirror.releaseOperationRef(context).pipe(Effect.ignore); + } + } + const cancelled = yield* patchOperation(operationId, { + phase: "cancelled", + message: "Overleaf operation cancelled.", + errorCode: null, + retryable: false, + }); + if (operation.snapshot.kind === "connect" && operation.snapshot.connectionId !== null) { + activeConnections.delete(operation.snapshot.connectionId); + yield* state.deleteConnection(operation.snapshot.connectionId); + return cancelled.snapshot; + } + yield* releaseConnection(operation.snapshot.connectionId); + return cancelled.snapshot; + }); + + const repair: OverleafSyncService["Service"]["repair"] = (connectionId) => + Effect.gen(function* () { + const connection = yield* state.getConnection(connectionId); + if (activeConnections.has(connectionId) || connection.activeOperationId !== null) + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: "Resolve the active Overleaf operation before repairing this connection.", + retryable: false, + }); + activeConnections.add(connectionId); + const operation = yield* state.createOperation("repair", connectionId, { + workspaceRoot: connection.workspaceRoot, + relativeFolder: connection.relativeFolder, + initialMode: "combine", + }); + yield* state.saveConnection({ + ...connection, + state: "operation_active", + activeOperationId: operation.snapshot.operationId, + }); + yield* launch( + operation.snapshot.operationId, + Effect.gen(function* () { + const operationsDirectory = NodePath.join( + state.connectionDirectory(connectionId), + "operations", + ); + const priorOperations = yield* Effect.tryPromise({ + try: async () => await NodeFSP.readdir(operationsDirectory, { withFileTypes: true }), + catch: () => [], + }).pipe(Effect.orElseSucceed(() => [])); + for (const entry of priorOperations) { + if (!entry.isDirectory() || entry.name === operation.snapshot.operationId) continue; + yield* projector.recover({ + operationDirectory: NodePath.join(operationsDirectory, entry.name), + targetRoot: targetFolder(connection.workspaceRoot, connection.relativeFolder), + }); + } + const credentials = yield* credentialsForConnection(connection); + const mirrorRoot = mirror.mirrorRoot(connectionId); + yield* Effect.tryPromise({ + try: () => NodeFSP.rm(mirrorRoot, { recursive: true, force: true }), + catch: () => + new ScientOverleafOperationError({ + code: "filesystem_failed", + message: "Unable to replace the private Overleaf mirror.", + retryable: true, + }), + }); + const context = gitContext( + operation.snapshot.operationId, + connection, + credentials.account, + credentials.token, + ); + yield* mirror.initialize({ ...context, connectionId, gitUrl: connection.gitUrl }); + const head = yield* mirror.fetch(context); + const remote = yield* mirror.manifest({ ...context, commit: head }); + const local = yield* scanTarget( + operation.snapshot.operationId, + connection.workspaceRoot, + connection.relativeFolder, + connection.workspaceBaselineManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ); + const repaired = { ...connection, remoteBaselineCommit: head }; + yield* state.saveConnection(repaired); + yield* patchOperation( + operation.snapshot.operationId, + { phase: "preparing", message: "Comparing the repaired mirror through Safe Combine…" }, + { + clickManifest: local, + remoteManifest: remote, + candidateCommit: head, + prePushBase: head, + initialMode: "combine", + }, + ); + yield* captureWorkspaceBase( + operation.snapshot.operationId, + repaired, + local, + remote, + head, + ); + const conflicts = yield* syntheticConflicts( + operation.snapshot.operationId, + repaired, + local, + remote, + ); + if (conflicts.length > 0) { + yield* patchOperation( + operation.snapshot.operationId, + { + phase: "awaiting_conflicts", + message: "Resolve files that differ after mirror repair.", + conflicts: conflicts.map((item) => item.conflict), + errorCode: "conflict", + }, + { conflicts }, + ); + return; + } + yield* finishInitialCandidate(operation.snapshot.operationId); + }), + ); + return operation.snapshot; + }).pipe(Effect.tapError(() => releaseConnection(connectionId))); + + const disconnect: OverleafSyncService["Service"]["disconnect"] = (input) => + Effect.gen(function* () { + const connection = yield* state.getConnection(input.connectionId); + if (activeConnections.has(connection.connectionId) || connection.activeOperationId !== null) + return yield* new ScientOverleafOperationError({ + code: "operation_active", + message: "Resolve the active Overleaf operation before disconnecting.", + retryable: false, + }); + const operationId = yield* state.newId; + const current = yield* scanTarget( + operationId, + connection.workspaceRoot, + connection.relativeFolder, + connection.workspaceBaselineManifest.files.map((file) => file.path), + connection.localOnlyCompanions, + ).pipe(Effect.ensuring(state.removeOperation(operationId).pipe(Effect.ignore))); + const hasUnsyncedChanges = + connection.localAhead || !manifestsEqual(current, connection.workspaceBaselineManifest); + if (input.mode === "check") + return { + disconnected: false, + hasUnsyncedChanges, + companionPaths: connection.localOnlyCompanions, + operation: null, + }; + if (input.mode === "sync-and-disconnect") { + activeConnections.add(connection.connectionId); + const started = yield* Effect.gen(function* () { + const operation = yield* state.createOperation("disconnect", connection.connectionId, { + workspaceRoot: connection.workspaceRoot, + relativeFolder: connection.relativeFolder, + disconnectAfterSync: true, + }); + yield* state.saveConnection({ + ...connection, + state: "operation_active", + activeOperationId: operation.snapshot.operationId, + }); + yield* launch( + operation.snapshot.operationId, + runSync(operation.snapshot.operationId, input.commitMessage), + ); + return operation.snapshot; + }).pipe(Effect.tapError(() => releaseConnection(connection.connectionId))); + return { + disconnected: false, + hasUnsyncedChanges, + companionPaths: connection.localOnlyCompanions, + operation: started, + }; + } + yield* state.deleteConnection(input.connectionId); + return { + disconnected: true, + hasUnsyncedChanges, + companionPaths: connection.localOnlyCompanions, + operation: null, + }; + }); + + return OverleafSyncService.of({ + overview: state.overview, + saveAccount: state.saveAccount, + removeAccount: state.removeAccount, + startPreflight, + operationStatus, + completePreflight, + cancelOperation, + updateConnection, + startSync, + retryOperation, + confirmReview, + conflicts: getConflicts, + conflictDetail, + resolveConflict, + continueOperation, + reconcileLocal, + repair, + disconnect, + }); +}); + +export const layer = Layer.effect(OverleafSyncService, make()); diff --git a/apps/server/src/scient/overleaf/WorkspaceProjector.test.ts b/apps/server/src/scient/overleaf/WorkspaceProjector.test.ts new file mode 100644 index 0000000000..f44e1a857c --- /dev/null +++ b/apps/server/src/scient/overleaf/WorkspaceProjector.test.ts @@ -0,0 +1,166 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import type { TreeManifest } from "./model.ts"; +import { layer, WorkspaceProjector } from "./WorkspaceProjector.ts"; + +const hash = (contents: string) => NodeCrypto.createHash("sha256").update(contents).digest("hex"); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const manifest = (files: Readonly>): TreeManifest => ({ + files: Object.entries(files).map(([path, contents]) => ({ + path, + hash: hash(contents), + size: Buffer.byteLength(contents), + executable: false, + })), + totalBytes: Object.values(files).reduce( + (total, contents) => total + Buffer.byteLength(contents), + 0, + ), +}); + +const temporaryDirectory = Effect.acquireRelease( + Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "overleaf-projector-"))), + (root) => Effect.promise(() => NodeFSP.rm(root, { recursive: true, force: true })), +); + +const exists = (path: string) => + NodeFSP.stat(path).then( + () => true, + () => false, + ); + +describe("WorkspaceProjector", () => { + it.effect( + "projects binary-safe replacements and deletions from an exact click-time manifest", + () => + Effect.gen(function* () { + const root = yield* temporaryDirectory; + const projector = yield* WorkspaceProjector; + const source = NodePath.join(root, "source"); + const target = NodePath.join(root, "target"); + const operation = NodePath.join(root, "operation"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(source, { recursive: true }); + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(source, "main.tex"), "remote\n"); + await NodeFSP.writeFile( + NodePath.join(source, "figure.bin"), + new Uint8Array([0, 255, 1, 2]), + ); + await NodeFSP.writeFile(NodePath.join(target, "main.tex"), "local\n"); + await NodeFSP.writeFile(NodePath.join(target, "old.tex"), "delete me\n"); + }); + const expected = manifest({ "main.tex": "local\n", "old.tex": "delete me\n" }); + const binaryHash = NodeCrypto.createHash("sha256") + .update(new Uint8Array([0, 255, 1, 2])) + .digest("hex"); + const desired: TreeManifest = { + files: [ + { path: "main.tex", hash: hash("remote\n"), size: 7, executable: false }, + { path: "figure.bin", hash: binaryHash, size: 4, executable: false }, + ], + totalBytes: 11, + }; + yield* projector.project({ + sourceRoot: source, + targetRoot: target, + desired, + expected, + previousManaged: expected, + operationDirectory: operation, + }); + expect( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(target, "main.tex"), "utf8")), + ).toBe("remote\n"); + expect([ + ...(yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(target, "figure.bin")))), + ]).toEqual([0, 255, 1, 2]); + expect(yield* Effect.promise(() => exists(NodePath.join(target, "old.tex")))).toBe(false); + expect( + yield* Effect.promise(() => + exists(NodePath.join(operation, "projection.journal.ndjson")), + ), + ).toBe(false); + }).pipe(Effect.provide(layer), Effect.scoped), + ); + + it.effect("never overwrites a file changed after the click-time manifest", () => + Effect.gen(function* () { + const root = yield* temporaryDirectory; + const projector = yield* WorkspaceProjector; + const source = NodePath.join(root, "source"); + const target = NodePath.join(root, "target"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(source, { recursive: true }); + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(source, "main.tex"), "remote\n"); + await NodeFSP.writeFile(NodePath.join(target, "main.tex"), "edited later\n"); + }); + const expected = manifest({ "main.tex": "click time\n" }); + const desired = manifest({ "main.tex": "remote\n" }); + const failure = yield* projector + .project({ + sourceRoot: source, + targetRoot: target, + desired, + expected, + previousManaged: expected, + operationDirectory: NodePath.join(root, "operation"), + }) + .pipe(Effect.flip); + expect(failure).toMatchObject({ code: "workspace_changed" }); + expect( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(target, "main.tex"), "utf8")), + ).toBe("edited later\n"); + }).pipe(Effect.provide(layer), Effect.scoped), + ); + + it.effect("recovers only partial output that still matches the journaled candidate", () => + Effect.gen(function* () { + const root = yield* temporaryDirectory; + const projector = yield* WorkspaceProjector; + const target = NodePath.join(root, "target"); + const operation = NodePath.join(root, "operation"); + const backup = NodePath.join(operation, "projection-backup", "main.tex"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(target, { recursive: true }); + await NodeFSP.mkdir(NodePath.dirname(backup), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(target, "main.tex"), "partial candidate\n"); + await NodeFSP.writeFile(backup, "before\n"); + }); + const temporary = `${NodePath.join(target, "main.tex")}.scient-overleaf-${NodePath.basename(operation)}.tmp`; + yield* Effect.promise(async () => { + await NodeFSP.writeFile(temporary, "interrupted temporary output\n"); + await NodeFSP.writeFile( + NodePath.join(operation, "projection.journal.ndjson"), + `${encodeJson({ schemaVersion: 1, path: "main.tex", desiredHash: hash("partial candidate\n"), backupPath: backup, applied: false })}\n`, + ); + }); + yield* projector.recover({ operationDirectory: operation, targetRoot: target }); + expect( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(target, "main.tex"), "utf8")), + ).toBe("before\n"); + expect(yield* Effect.promise(() => exists(temporary))).toBe(false); + + yield* Effect.promise(async () => { + await NodeFSP.writeFile(NodePath.join(target, "main.tex"), "later edit\n"); + await NodeFSP.writeFile( + NodePath.join(operation, "projection.journal.ndjson"), + `${encodeJson({ schemaVersion: 1, path: "main.tex", desiredHash: hash("partial candidate\n"), backupPath: backup, applied: true })}\n`, + ); + }); + yield* projector.recover({ operationDirectory: operation, targetRoot: target }); + expect( + yield* Effect.promise(() => NodeFSP.readFile(NodePath.join(target, "main.tex"), "utf8")), + ).toBe("later edit\n"); + }).pipe(Effect.provide(layer), Effect.scoped), + ); +}); diff --git a/apps/server/src/scient/overleaf/WorkspaceProjector.ts b/apps/server/src/scient/overleaf/WorkspaceProjector.ts new file mode 100644 index 0000000000..49f2b46b05 --- /dev/null +++ b/apps/server/src/scient/overleaf/WorkspaceProjector.ts @@ -0,0 +1,323 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { ScientOverleafOperationError } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import { manifestMap, type TreeManifest } from "./model.ts"; + +interface JournalEntry { + readonly schemaVersion: 1; + readonly path: string; + readonly desiredHash: string | null; + readonly backupPath: string | null; + readonly applied: boolean; +} + +async function hashExisting(filePath: string): Promise { + let info; + try { + info = await NodeFSP.lstat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if (!info.isFile() || info.isSymbolicLink()) return "unsupported"; + const handle = await NodeFSP.open(filePath, "r"); + try { + const hash = NodeCrypto.createHash("sha256"); + const buffer = Buffer.allocUnsafe(256 * 1024); + for (;;) { + const { bytesRead } = await handle.read(buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + hash.update(buffer.subarray(0, bytesRead)); + } + return hash.digest("hex"); + } finally { + await handle.close(); + } +} + +async function appendJournal(handle: NodeFSP.FileHandle, entry: JournalEntry) { + await handle.appendFile(`${JSON.stringify(entry)}\n`, "utf8"); + await handle.sync(); +} + +async function removeEmptyParents(root: string, start: string) { + let current = NodePath.dirname(start); + while (current !== root && current.startsWith(`${root}${NodePath.sep}`)) { + try { + await NodeFSP.rmdir(current); + } catch { + return; + } + current = NodePath.dirname(current); + } +} + +function projectedPath(root: string, relative: string): string { + const target = NodePath.resolve(root, ...relative.split("/")); + if (!target.startsWith(`${NodePath.resolve(root)}${NodePath.sep}`)) { + throw projectionError( + "filesystem_failed", + "The projection journal contains an unsafe path.", + false, + ); + } + return target; +} + +function pathWithin(root: string, candidate: string): boolean { + const resolvedRoot = NodePath.resolve(root); + const resolvedCandidate = NodePath.resolve(candidate); + return ( + resolvedCandidate === resolvedRoot || + resolvedCandidate.startsWith(`${resolvedRoot}${NodePath.sep}`) + ); +} + +async function recoverJournal(operationDirectory: string, targetRoot: string): Promise { + const journalPath = NodePath.join(operationDirectory, "projection.journal.ndjson"); + let contents: string; + try { + contents = await NodeFSP.readFile(journalPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + const latest = new Map(); + for (const line of contents.split("\n").filter(Boolean)) { + const parsed = JSON.parse(line) as Partial; + if ( + typeof parsed.path !== "string" || + parsed.schemaVersion !== 1 || + !(typeof parsed.desiredHash === "string" || parsed.desiredHash === null) || + !(typeof parsed.backupPath === "string" || parsed.backupPath === null) || + typeof parsed.applied !== "boolean" + ) + throw projectionError("filesystem_failed", "The projection journal is corrupt.", false); + latest.set(parsed.path, parsed as JournalEntry); + } + for (const entry of [...latest.values()].toReversed()) { + const target = projectedPath(targetRoot, entry.path); + const temporary = `${target}.scient-overleaf-${NodePath.basename(operationDirectory)}.tmp`; + await NodeFSP.rm(temporary, { force: true }); + const current = await hashExisting(target).catch(() => "unreadable"); + if (current !== entry.desiredHash) continue; + if (entry.backupPath === null) { + await NodeFSP.rm(target, { force: true }); + } else { + const operationBackup = NodePath.join(operationDirectory, "projection-backup"); + const connectionBackup = NodePath.join( + NodePath.dirname(NodePath.dirname(operationDirectory)), + "preconnect-backup", + ); + if ( + !pathWithin(operationBackup, entry.backupPath) && + !pathWithin(connectionBackup, entry.backupPath) + ) { + throw projectionError( + "filesystem_failed", + "A projection journal references an unsafe backup path.", + false, + ); + } + const backup = await NodeFSP.lstat(entry.backupPath).catch(() => null); + if (backup?.isFile() !== true || backup.isSymbolicLink()) { + throw projectionError( + "filesystem_failed", + "A projection backup is missing or unsafe.", + false, + ); + } + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.copyFile(entry.backupPath, target); + } + } + await NodeFSP.rm(journalPath, { force: true }); +} + +function projectionError( + code: "workspace_changed" | "filesystem_failed", + message: string, + retryable = true, +) { + return new ScientOverleafOperationError({ code, message, retryable }); +} +const isOverleafOperationError = Schema.is(ScientOverleafOperationError); + +export class WorkspaceProjector extends Context.Service< + WorkspaceProjector, + { + readonly verify: (input: { + readonly targetRoot: string; + readonly expected: TreeManifest; + }) => Effect.Effect; + readonly project: (input: { + readonly sourceRoot: string; + readonly targetRoot: string; + readonly desired: TreeManifest; + readonly expected: TreeManifest; + readonly previousManaged: TreeManifest; + readonly operationDirectory: string; + readonly backupDirectory?: string; + }) => Effect.Effect; + readonly recover: (input: { + readonly operationDirectory: string; + readonly targetRoot: string; + }) => Effect.Effect; + } +>()("t3/scient/overleaf/WorkspaceProjector") {} + +export const make = Effect.fn("WorkspaceProjector.make")(() => + Effect.sync(() => { + const recover: WorkspaceProjector["Service"]["recover"] = (input) => + Effect.tryPromise({ + try: () => recoverJournal(input.operationDirectory, input.targetRoot), + catch: (cause) => + isOverleafOperationError(cause) + ? cause + : projectionError( + "filesystem_failed", + "Unable to recover an interrupted local projection.", + ), + }); + + const verify: WorkspaceProjector["Service"]["verify"] = (input) => + Effect.tryPromise({ + try: async () => { + for (const file of input.expected.files) { + const current = await hashExisting( + NodePath.join(input.targetRoot, ...file.path.split("/")), + ); + if (current !== file.hash) + throw projectionError( + "workspace_changed", + "The project changed after Sync began. No workspace files were overwritten.", + ); + } + }, + catch: (cause) => + isOverleafOperationError(cause) + ? cause + : projectionError( + "filesystem_failed", + "Unable to verify the project before projection.", + ), + }); + + const project: WorkspaceProjector["Service"]["project"] = (input) => + Effect.tryPromise({ + try: async () => { + const desired = manifestMap(input.desired); + const expected = manifestMap(input.expected); + const previous = manifestMap(input.previousManaged); + const paths = [...new Set([...desired.keys(), ...previous.keys()])].sort(); + await recoverJournal(input.operationDirectory, input.targetRoot); + const backupRoot = + input.backupDirectory ?? NodePath.join(input.operationDirectory, "projection-backup"); + const journalPath = NodePath.join(input.operationDirectory, "projection.journal.ndjson"); + if (input.backupDirectory === undefined) + await NodeFSP.rm(backupRoot, { recursive: true, force: true }); + await NodeFSP.mkdir(backupRoot, { recursive: true }); + const journal = await NodeFSP.open(journalPath, "a"); + const applied: JournalEntry[] = []; + try { + for (const relative of paths) { + const target = NodePath.join(input.targetRoot, ...relative.split("/")); + const source = NodePath.join(input.sourceRoot, ...relative.split("/")); + const desiredFile = desired.get(relative) ?? null; + const expectedFile = expected.get(relative) ?? null; + const currentHash = await hashExisting(target); + const expectedHash = expectedFile?.hash ?? null; + if (currentHash !== expectedHash) { + throw projectionError( + "workspace_changed", + "The project changed after Sync began. No conflicting workspace file was overwritten.", + ); + } + if (desiredFile?.hash === currentHash) continue; + const backup = + currentHash === null ? null : NodePath.join(backupRoot, ...relative.split("/")); + const pending: JournalEntry = { + schemaVersion: 1, + path: relative, + desiredHash: desiredFile?.hash ?? null, + backupPath: backup, + applied: false, + }; + await appendJournal(journal, pending); + if (backup !== null) { + await NodeFSP.mkdir(NodePath.dirname(backup), { recursive: true }); + await NodeFSP.copyFile(target, backup); + } + if (desiredFile === null) { + await NodeFSP.rm(target, { force: true }); + await removeEmptyParents(input.targetRoot, target); + } else { + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + const temp = `${target}.scient-overleaf-${NodePath.basename(input.operationDirectory)}.tmp`; + await NodeFSP.rm(temp, { force: true }); + try { + await NodeFSP.copyFile(source, temp); + const tempHandle = await NodeFSP.open(temp, "r+"); + try { + await tempHandle.sync(); + } finally { + await tempHandle.close(); + } + await NodeFSP.rename(temp, target); + } finally { + await NodeFSP.rm(temp, { force: true }); + } + const written = await hashExisting(target); + if (written !== desiredFile.hash) + throw projectionError( + "filesystem_failed", + "A projected Overleaf file failed verification.", + ); + } + const complete = { ...pending, applied: true }; + applied.push(complete); + await appendJournal(journal, complete); + } + } catch (cause) { + for (const entry of applied.toReversed()) { + const target = NodePath.join(input.targetRoot, ...entry.path.split("/")); + const current = await hashExisting(target).catch(() => "unreadable"); + if (current !== entry.desiredHash) continue; + if (entry.backupPath === null) { + await NodeFSP.rm(target, { force: true }).catch(() => undefined); + } else { + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.copyFile(entry.backupPath, target); + } + } + throw cause; + } finally { + await journal.close(); + } + await NodeFSP.rm(journalPath, { force: true }); + if (input.backupDirectory === undefined) { + await NodeFSP.rm(backupRoot, { recursive: true, force: true }); + } + }, + catch: (cause) => + isOverleafOperationError(cause) + ? cause + : projectionError( + "filesystem_failed", + "Unable to project the Overleaf result into the workspace.", + ), + }); + + return WorkspaceProjector.of({ verify, project, recover }); + }), +); + +export const layer = Layer.effect(WorkspaceProjector, make()); diff --git a/apps/server/src/scient/overleaf/http.ts b/apps/server/src/scient/overleaf/http.ts new file mode 100644 index 0000000000..8841332f42 --- /dev/null +++ b/apps/server/src/scient/overleaf/http.ts @@ -0,0 +1,153 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + EnvironmentHttpApi, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest, requireEnvironmentScope } from "../../auth/http.ts"; +import { OverleafSyncService } from "./OverleafSyncService.ts"; + +function handle( + endpointName: string, + scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, + run: Effect.Effect, +) { + return Effect.gen(function* () { + yield* annotateEnvironmentRequest(endpointName); + yield* requireEnvironmentScope(scope); + return yield* run; + }); +} + +export const scientOverleafHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "scientOverleaf", + Effect.fnUntraced(function* (handlers) { + const overleaf = yield* OverleafSyncService; + return handlers + .handle("overview", (args) => + handle( + args.endpoint.name, + AuthOrchestrationReadScope, + overleaf.overview(args.payload.workspaceRoot), + ), + ) + .handle("saveAccount", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.saveAccount(args.payload), + ), + ) + .handle("removeAccount", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.removeAccount(args.payload.accountId).pipe(Effect.as({ ok: true as const })), + ), + ) + .handle("preflightStart", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.startPreflight(args.payload), + ), + ) + .handle("operationStatus", (args) => + handle( + args.endpoint.name, + AuthOrchestrationReadScope, + overleaf.operationStatus(args.payload.operationId), + ), + ) + .handle("preflightComplete", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.completePreflight(args.payload), + ), + ) + .handle("operationCancel", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.cancelOperation(args.payload.operationId), + ), + ) + .handle("updateConnection", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.updateConnection(args.payload), + ), + ) + .handle("syncStart", (args) => + handle(args.endpoint.name, AuthOrchestrationOperateScope, overleaf.startSync(args.payload)), + ) + .handle("operationRetry", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.retryOperation(args.payload.operationId), + ), + ) + .handle("confirmReview", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.confirmReview(args.payload), + ), + ) + .handle("conflicts", (args) => + handle( + args.endpoint.name, + AuthOrchestrationReadScope, + overleaf.conflicts(args.payload.operationId), + ), + ) + .handle("conflictDetail", (args) => + handle( + args.endpoint.name, + AuthOrchestrationReadScope, + overleaf.conflictDetail(args.payload.operationId, args.payload.conflictId), + ), + ) + .handle("resolveConflict", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.resolveConflict(args.payload), + ), + ) + .handle("continueOperation", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.continueOperation(args.payload), + ), + ) + .handle("reconcileLocal", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.reconcileLocal(args.payload.connectionId), + ), + ) + .handle("repair", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.repair(args.payload.connectionId), + ), + ) + .handle("disconnect", (args) => + handle( + args.endpoint.name, + AuthOrchestrationOperateScope, + overleaf.disconnect(args.payload), + ), + ); + }), +); diff --git a/apps/server/src/scient/overleaf/model.ts b/apps/server/src/scient/overleaf/model.ts new file mode 100644 index 0000000000..4e011589ea --- /dev/null +++ b/apps/server/src/scient/overleaf/model.ts @@ -0,0 +1,83 @@ +import type { + ScientOverleafAccount, + ScientOverleafCommitPolicy, + ScientOverleafConnection, + ScientOverleafConflictDetail, + ScientOverleafOperationSnapshot, + ScientOverleafReview, +} from "@t3tools/contracts"; + +export interface TreeFile { + readonly path: string; + readonly hash: string; + readonly size: number; + readonly executable: boolean; +} + +export interface TreeManifest { + readonly files: ReadonlyArray; + readonly totalBytes: number; +} + +export interface PersistedOverleafAccount extends ScientOverleafAccount { + readonly secretRef: string; +} + +export interface PersistedOverleafConnection extends ScientOverleafConnection { + readonly workspaceBaselineManifest: TreeManifest; + readonly pendingRemoteCommit: string | null; + readonly activeOperationId: string | null; +} + +export interface OperationContext { + readonly workspaceRoot?: string; + readonly relativeFolder?: string; + readonly accountId?: string; + readonly projectUrl?: string; + readonly gitUrl?: string; + readonly host?: string; + readonly label?: string; + readonly commitPolicy?: ScientOverleafCommitPolicy; + readonly commitMessage?: string; + readonly clickManifest?: TreeManifest; + readonly remoteManifest?: TreeManifest; + readonly candidateCommit?: string; + readonly candidateTree?: string; + readonly prePushBase?: string; + readonly snapshotCommit?: string; + readonly workspaceBaseCommit?: string; + readonly initialMode?: "combine" | "replace-local" | "replace-overleaf"; + readonly conflicts?: ReadonlyArray; + readonly review?: ScientOverleafReview; + readonly companionWrites?: ReadonlyArray<{ + readonly relativePath: string; + readonly materialPath: string; + }>; + readonly disconnectAfterSync?: boolean; + readonly localReconcile?: boolean; + readonly suppressRenameAfterSuccess?: boolean; +} + +export interface PersistedOverleafOperation { + readonly snapshot: ScientOverleafOperationSnapshot; + readonly context: OperationContext; +} + +export const terminalOverleafPhases = new Set([ + "succeeded", + "failed", + "cancelled", + "interrupted", +]); + +export function projectFolder( + connection: Pick, +) { + return connection.relativeFolder.length === 0 + ? connection.workspaceRoot + : `${connection.workspaceRoot.replace(/[\\/]$/u, "")}/${connection.relativeFolder}`; +} + +export function manifestMap(manifest: TreeManifest): ReadonlyMap { + return new Map(manifest.files.map((file) => [file.path, file] as const)); +} diff --git a/apps/server/src/scient/overleaf/policy.test.ts b/apps/server/src/scient/overleaf/policy.test.ts new file mode 100644 index 0000000000..607bf00af2 --- /dev/null +++ b/apps/server/src/scient/overleaf/policy.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { advisoryWarnings, normalizeManagedPath } from "./ManagedTree.ts"; +import { parseNameStatus, parseUnmerged, uncertainPushWasAccepted } from "./OverleafMirror.ts"; + +describe("managed Overleaf path policy", () => { + it("normalizes safe paths and rejects escapes, platform-invalid names, and .git", () => { + expect(normalizeManagedPath("sections\\intro.tex")).toBe("sections/intro.tex"); + for (const path of [ + "../secret", + "/absolute", + "paper/.git/config", + "CON.tex", + "bad?.tex", + "a//b", + ]) { + expect(() => normalizeManagedPath(path)).toThrow(); + } + }); + + it("treats documented size and count thresholds as blocking acknowledgements", () => { + const files = Array.from({ length: 2_001 }, (_, index) => ({ + path: `chapters/${index}.tex`, + hash: String(index), + size: index === 0 ? 51 * 1024 * 1024 : 1, + executable: false, + })); + const warnings = advisoryWarnings({ + files, + totalBytes: files.reduce((total, file) => total + file.size, 0), + }); + expect(warnings.map(({ kind }) => kind)).toEqual( + expect.arrayContaining([ + "file_count", + "large_file", + "large_editable_text", + "large_editable_material", + ]), + ); + expect(warnings.every(({ blocking }) => blocking)).toBe(true); + }); +}); + +describe("Git machine-output parsing", () => { + it("parses NUL-delimited additions, modifications, deletions, and 50% renames", () => { + expect(parseNameStatus("A\0a.tex\0M\0b.tex\0D\0c.tex\0R050\0old.png\0new.png\0")).toEqual([ + { kind: "added", path: "a.tex" }, + { kind: "modified", path: "b.tex" }, + { kind: "deleted", path: "c.tex" }, + { kind: "renamed", oldPath: "old.png", path: "new.png", similarity: 50 }, + ]); + }); + + it("maps unmerged stages explicitly", () => { + const output = [ + "100644 aaaaa 1\tmain.tex", + "100644 bbbbb 2\tmain.tex", + "100644 ccccc 3\tmain.tex", + "", + ].join("\0"); + expect(parseUnmerged(output)).toEqual([ + { mode: "100644", hash: "aaaaa", stage: 1, path: "main.tex" }, + { mode: "100644", hash: "bbbbb", stage: 2, path: "main.tex" }, + { mode: "100644", hash: "ccccc", stage: 3, path: "main.tex" }, + ]); + }); +}); + +describe("uncertain push recovery", () => { + it("accepts recreated remote commits by tree identity", () => { + expect( + uncertainPushWasAccepted({ + candidateTree: "candidate-tree", + headTree: "candidate-tree", + firstParentTrees: [], + candidateCommitReachable: false, + }), + ).toBe(true); + expect( + uncertainPushWasAccepted({ + candidateTree: "candidate-tree", + headTree: "later-tree", + firstParentTrees: ["later-tree", "candidate-tree"], + candidateCommitReachable: false, + }), + ).toBe(true); + }); + + it("accepts preserved commits by reachability and rejects unrelated history", () => { + expect( + uncertainPushWasAccepted({ + candidateTree: "candidate-tree", + headTree: "later-tree", + firstParentTrees: ["later-tree"], + candidateCommitReachable: true, + }), + ).toBe(true); + expect( + uncertainPushWasAccepted({ + candidateTree: "candidate-tree", + headTree: "unrelated-tree", + firstParentTrees: ["other-tree"], + candidateCommitReachable: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/server/src/scient/overleaf/urls.test.ts b/apps/server/src/scient/overleaf/urls.test.ts new file mode 100644 index 0000000000..1734ea6ee6 --- /dev/null +++ b/apps/server/src/scient/overleaf/urls.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeRelativeFolder, parseOverleafProjectInput } from "./urls.ts"; + +describe("parseOverleafProjectInput", () => { + it("normalizes Cloud project and official Git URLs", () => { + expect(parseOverleafProjectInput("https://www.overleaf.com/project/abc_123")).toEqual({ + host: "git.overleaf.com", + projectId: "abc_123", + projectUrl: "https://www.overleaf.com/project/abc_123", + gitUrl: "https://git@git.overleaf.com/abc_123", + kind: "cloud", + }); + expect(parseOverleafProjectInput("https://git@git.overleaf.com/abc_123").gitUrl).toBe( + "https://git@git.overleaf.com/abc_123", + ); + expect(parseOverleafProjectInput("https://git.overleaf.com/git/abc_123").gitUrl).toBe( + "https://git@git.overleaf.com/abc_123", + ); + }); + + it("parses a pasted clone command as text without accepting shell syntax", () => { + expect( + parseOverleafProjectInput("git clone https://git@git.overleaf.com/paper-id paper"), + ).toMatchObject({ projectId: "paper-id" }); + expect(() => + parseOverleafProjectInput("git clone --depth 1 https://git@git.overleaf.com/paper-id"), + ).toThrow(/options/u); + expect(() => + parseOverleafProjectInput("git clone https://git@git.overleaf.com/paper-id ; calc"), + ).toThrow(/shell syntax/u); + expect(() => + parseOverleafProjectInput("git clone https://git@git.overleaf.com/paper-id paper;calc"), + ).toThrow(/shell syntax/u); + }); + + it("supports Server Pro hosts and ports while stripping no host identity", () => { + expect(parseOverleafProjectInput("https://papers.example.edu:8443/project/xyz")).toEqual({ + host: "papers.example.edu:8443", + projectId: "xyz", + projectUrl: "https://papers.example.edu:8443/project/xyz", + gitUrl: "https://git@papers.example.edu:8443/git/xyz", + kind: "server-pro", + }); + }); + + it("rejects secrets, non-HTTPS URLs, fragments, and unrelated paths", () => { + expect(() => parseOverleafProjectInput("https://user:secret@git.overleaf.com/id")).toThrow( + /credentials/u, + ); + expect(() => parseOverleafProjectInput("http://www.overleaf.com/project/id")).toThrow(/HTTPS/u); + expect(() => parseOverleafProjectInput("https://www.overleaf.com/project/id#history")).toThrow( + /fragments/u, + ); + expect(() => parseOverleafProjectInput("https://www.overleaf.com/learn/id")).toThrow( + /supported/u, + ); + }); +}); + +describe("normalizeRelativeFolder", () => { + it("accepts root and nested mappings", () => { + expect(normalizeRelativeFolder(".")).toBe(""); + expect(normalizeRelativeFolder("")).toBe(""); + expect(normalizeRelativeFolder("papers\\article/")).toBe("papers/article"); + }); + + it("rejects path escape and .git components", () => { + expect(() => normalizeRelativeFolder("../paper")).toThrow(/inside/u); + expect(() => normalizeRelativeFolder("paper/.git/worktrees/x")).toThrow(/inside/u); + expect(() => normalizeRelativeFolder("paper//chapter")).toThrow(/inside/u); + expect(() => normalizeRelativeFolder("papers/CON")).toThrow(/reserved/u); + }); +}); diff --git a/apps/server/src/scient/overleaf/urls.ts b/apps/server/src/scient/overleaf/urls.ts new file mode 100644 index 0000000000..382999bc24 --- /dev/null +++ b/apps/server/src/scient/overleaf/urls.ts @@ -0,0 +1,92 @@ +import { ScientOverleafOperationError } from "@t3tools/contracts"; + +const hasControlCharacter = (value: string) => + [...value].some((character) => character.charCodeAt(0) <= 0x1f); + +export interface ParsedOverleafProject { + readonly host: string; + readonly projectId: string; + readonly projectUrl: string; + readonly gitUrl: string; + readonly kind: "cloud" | "server-pro"; +} + +function invalid(message: string): never { + throw new ScientOverleafOperationError({ + code: "invalid_request", + message, + retryable: false, + }); +} + +function extractUrl(input: string): string { + const trimmed = input.trim(); + if (!/^git\s+clone(?:\s|$)/iu.test(trimmed)) return trimmed; + const matches = [...trimmed.matchAll(/https:\/\/[^\s"']+/giu)]; + if (matches.length !== 1) invalid("Paste a clone command containing exactly one HTTPS URL."); + const before = trimmed.slice(0, matches[0]!.index).trim(); + if (!/^git\s+clone$/iu.test(before)) { + invalid("Clone options and embedded shell syntax are not supported."); + } + const after = trimmed.slice((matches[0]!.index ?? 0) + matches[0]![0].length).trim(); + if (after.length > 0 && !/^[A-Za-z0-9._/-]+$/u.test(after)) { + invalid("Clone options and shell syntax are not supported."); + } + return matches[0]![0]; +} + +export function parseOverleafProjectInput(input: string): ParsedOverleafProject { + const raw = extractUrl(input); + let url: URL; + try { + url = new URL(raw); + } catch { + return invalid("Enter an Overleaf project URL or HTTPS Git URL."); + } + if (url.protocol !== "https:") invalid("Overleaf synchronization requires HTTPS."); + if (url.password || (url.username && url.username !== "git")) + invalid("Embedded credentials are not accepted."); + if (url.search || url.hash) invalid("Query strings and fragments are not accepted."); + const host = url.hostname.toLowerCase(); + const cloudGit = host === "git.overleaf.com"; + const match = url.pathname.match( + cloudGit ? /^\/(?:git\/)?([A-Za-z0-9_-]+)\/?$/u : /^\/(?:project|git)\/([A-Za-z0-9_-]+)\/?$/u, + ); + if (!match) invalid("The URL is not a supported Overleaf project or Git URL."); + const projectId = match[1]!; + const kind = host === "www.overleaf.com" || host === "git.overleaf.com" ? "cloud" : "server-pro"; + const projectHost = kind === "cloud" ? "www.overleaf.com" : url.host; + const gitHost = kind === "cloud" ? "git.overleaf.com" : url.host; + return { + host: gitHost.toLowerCase(), + projectId, + projectUrl: `https://${projectHost}/project/${projectId}`, + gitUrl: + kind === "cloud" + ? `https://git@${gitHost}/${projectId}` + : `https://git@${gitHost}/git/${projectId}`, + kind, + }; +} + +export function normalizeRelativeFolder(input: string): string { + const normalized = input.replaceAll("\\", "/").replace(/^\.\//u, "").replace(/\/$/u, ""); + if (normalized === "." || normalized === "") return ""; + const parts = normalized.split("/"); + if ( + normalized.startsWith("/") || + /^[A-Za-z]:/u.test(normalized) || + normalized.length > 4_096 || + parts.some((part) => part.length === 0 || part === ".." || part === "." || part === ".git") + ) { + invalid("The selected folder must stay inside the workspace and cannot contain .git."); + } + for (const part of parts) { + if (/^[ .]|[ .]$/u.test(part) || /[<>:"|?*]/u.test(part) || hasControlCharacter(part)) + invalid("The selected folder contains a name that is invalid on a supported platform."); + const stem = part.split(".")[0]!.toUpperCase(); + if (/^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/u.test(stem)) + invalid("The selected folder contains a Windows-reserved name."); + } + return normalized; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f8aced8b9b..22b01f8ce0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -144,6 +144,13 @@ import * as LatexSyncTex from "./scient/latex/LatexSyncTex.ts"; import { scientProjectHttpApiLayer } from "./scientProject/http.ts"; import { scientSourcesHttpApiLayer } from "./scient/sources/http.ts"; import { scientLatexHttpApiLayer } from "./scient/latex/http.ts"; +import * as OverleafStateStore from "./scient/overleaf/OverleafStateStore.ts"; +import * as OverleafGitExecutor from "./scient/overleaf/OverleafGitExecutor.ts"; +import * as ManagedOverleafTree from "./scient/overleaf/ManagedTree.ts"; +import * as OverleafMirror from "./scient/overleaf/OverleafMirror.ts"; +import * as WorkspaceProjector from "./scient/overleaf/WorkspaceProjector.ts"; +import * as OverleafSyncService from "./scient/overleaf/OverleafSyncService.ts"; +import { scientOverleafHttpApiLayer } from "./scient/overleaf/http.ts"; import { scientAnalyticsHttpApiLayer } from "./telemetry/http.ts"; // Effect's default preemptive shutdown waits 20s before finalizing request scopes. @@ -509,6 +516,29 @@ const ScientLatexServicesLive = LatexBuildService.layer.pipe( Layer.provideMerge(LatexPackageInstaller.layer), ); +const OverleafStateStoreLive = OverleafStateStore.layer.pipe( + Layer.provide(ServerSecretStore.layer), +); +const OverleafGitExecutorLive = OverleafGitExecutor.layer.pipe( + Layer.provide(OverleafStateStoreLive), +); +const ManagedOverleafTreeLive = ManagedOverleafTree.layer.pipe( + Layer.provide(OverleafGitExecutorLive), + Layer.provide(OverleafStateStoreLive), +); +const OverleafMirrorLive = OverleafMirror.layer.pipe( + Layer.provide(ManagedOverleafTreeLive), + Layer.provide(OverleafGitExecutorLive), + Layer.provide(OverleafStateStoreLive), +); +const OverleafSyncServiceLive = OverleafSyncService.layer.pipe( + Layer.provide(OverleafMirrorLive), + Layer.provide(ManagedOverleafTreeLive), + Layer.provide(WorkspaceProjector.layer), + Layer.provide(OverleafStateStoreLive), + Layer.provide(WorkspaceEntriesLayerLive), +); + export const makeRoutesLayer = Layer.mergeAll( Layer.mergeAll( HttpApiBuilder.layer(EnvironmentHttpApi).pipe( @@ -520,6 +550,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(scientSourcesHttpApiLayer), Layer.provide(scientAnalyticsHttpApiLayer), Layer.provide(scientLatexHttpApiLayer), + Layer.provide(scientOverleafHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), @@ -540,6 +571,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(PullRequestServiceLive), Layer.provide(AnalysisServiceLive), Layer.provide(ScientLatexServicesLive), + Layer.provide(OverleafSyncServiceLive), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(commandReadinessLayer), diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 416df60c12..3009c4c307 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -110,6 +110,7 @@ import { SettingsSection, } from "./settingsLayout"; import { ProjectFaviconPickerDialog } from "./ProjectFaviconPickerDialog"; +import { OverleafProjectSettings } from "../../scient/overleaf/OverleafProjectSettings"; export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", @@ -1148,6 +1149,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ) : null} + + (null); const [syncError, setSyncError] = useState(null); + const [overleafEditingBlocked, setOverleafEditingBlocked] = useState(false); const [forwardSyncTarget, setForwardSyncTarget] = useState(null); const [handledRevealRequestId, setHandledRevealRequestId] = useState(null); const splitRef = useRef(null); @@ -735,6 +737,12 @@ export function ScientLatexSurface(props: ScientLatexSurfaceProps) { )}
+ {status.canCancel ? ( + ) : ( + + )} + + + ); +} diff --git a/apps/web/src/scient/overleaf/OverleafProjectSettings.tsx b/apps/web/src/scient/overleaf/OverleafProjectSettings.tsx new file mode 100644 index 0000000000..6aa303e5bd --- /dev/null +++ b/apps/web/src/scient/overleaf/OverleafProjectSettings.tsx @@ -0,0 +1,1741 @@ +import type { + EnvironmentId, + ScientOverleafAccount, + ScientOverleafCommitPolicy, + ScientOverleafConflictDetail, + ScientOverleafConnection, + ScientOverleafOperationSnapshot, + ScientOverleafOverview, + ScientOverleafReview, +} from "@t3tools/contracts"; +import { + AlertTriangleIcon, + CheckCircle2Icon, + ExternalLinkIcon, + GitCompareArrowsIcon, + LoaderCircleIcon, + PlusIcon, + RefreshCwIcon, + Trash2Icon, + WrenchIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Button } from "../../components/ui/button"; +import { Checkbox } from "../../components/ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "../../components/ui/dialog"; +import { Input } from "../../components/ui/input"; +import { Label } from "../../components/ui/label"; +import { Textarea } from "../../components/ui/textarea"; +import { toastManager } from "../../components/ui/toast"; +import { SettingsRow, SettingsSection } from "../../components/settings/settingsLayout"; +import { filesystemEnvironment } from "../../state/filesystem"; +import { useEnvironmentQuery } from "../../state/query"; +import { overleafClient } from "./client"; +import { OverleafTextDiff } from "./OverleafTextDiff"; + +interface Props { + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly projectName: string; +} + +const ACTIVE_PHASES = new Set([ + "preparing", + "fetching", + "rebasing", + "pushing", + "projecting", + "publishing", +]); + +const TERMINAL_PHASES = new Set([ + "succeeded", + "failed", + "cancelled", +]); + +const ADVISORY_WARNING_KINDS = new Set([ + "file_count", + "large_file", + "large_editable_text", + "large_editable_material", + "project_size", +]); + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return "The Overleaf operation could not be completed."; +} + +function connectionStatus(connection: ScientOverleafConnection): string { + switch (connection.state) { + case "ready": + return connection.lastSyncedAtEpochMs === null + ? "Connected" + : `Last synchronized ${new Date(connection.lastSyncedAtEpochMs).toLocaleString()}`; + case "local_ahead": + return "Local edits are ready for the next manual Sync"; + case "operation_active": + return "Synchronization is in progress"; + case "push_outcome_unknown": + return "The last push needs verification"; + case "local_projection_pending": + return "Overleaf is updated; local projection needs attention"; + case "repair_required": + return "The private mirror needs repair"; + } +} + +function warningLabel(kind: string): string { + return kind.replaceAll("_", " "); +} + +function OutboundSummary({ review }: { readonly review: ScientOverleafReview }) { + return ( +
+ + Last Sync changes: {review.changes.length} file{review.changes.length === 1 ? "" : "s"} + +
    + {review.changes.length === 0 ? ( +
  • No outbound file changes.
  • + ) : ( + review.changes.map((change) => ( +
  • + {change.kind[0]} + {change.oldPath ? `${change.oldPath} → ` : ""} + {change.path} +
  • + )) + )} +
+
+ ); +} + +function AccountEditor(props: { + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly account: ScientOverleafAccount | null; + readonly onOpenChange: (open: boolean) => void; + readonly onSaved: () => void; +}) { + const [label, setLabel] = useState(""); + const [kind, setKind] = useState<"cloud" | "server-pro">("cloud"); + const [host, setHost] = useState("git.overleaf.com"); + const [authorName, setAuthorName] = useState(""); + const [authorEmail, setAuthorEmail] = useState(""); + const [token, setToken] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!props.open) return; + setLabel(props.account?.label ?? "Overleaf"); + setKind(props.account?.kind ?? "cloud"); + setHost(props.account?.host ?? "git.overleaf.com"); + setAuthorName(props.account?.authorName ?? ""); + setAuthorEmail(props.account?.authorEmail ?? ""); + setToken(""); + }, [props.account, props.open]); + + const save = async () => { + setSaving(true); + try { + await overleafClient.saveAccount(props.environmentId, { + ...(props.account === null ? {} : { accountId: props.account.accountId }), + label, + kind, + host, + authorName, + authorEmail, + ...(token.trim() === "" ? {} : { token }), + }); + props.onOpenChange(false); + props.onSaved(); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not save Overleaf account", + description: errorMessage(error), + }); + } finally { + setSaving(false); + } + }; + + return ( + + + + + {props.account === null ? "Add Overleaf account" : "Edit Overleaf account"} + + + Tokens are saved by the environment and are never returned to this browser. Commit + metadata uses the human identity below. + + + + + + +
+ + +
+ +
+ + + + +
+
+ ); +} + +function FolderBrowserDialog(props: { + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly initialPath: string; + readonly onOpenChange: (open: boolean) => void; + readonly onSelect: (relativePath: string) => void; +}) { + const [current, setCurrent] = useState(""); + useEffect(() => { + if (props.open) setCurrent(props.initialPath.replaceAll("\\", "/").replace(/^\/+|\/+$/g, "")); + }, [props.initialPath, props.open]); + const browse = useEnvironmentQuery( + props.open + ? filesystemEnvironment.browse({ + environmentId: props.environmentId, + input: { + cwd: props.workspaceRoot, + partialPath: current ? `${current}/` : "./", + }, + }) + : null, + ); + const parent = current.split("/").filter(Boolean).slice(0, -1).join("/"); + return ( + + + + Choose a folder on the environment + {current || "Workspace root"} + + + {current ? ( + + ) : null} +
+ {browse.isPending ? ( +
+ Loading folders… +
+ ) : null} + {browse.error ? ( +
{browse.error}
+ ) : null} + {browse.data?.entries.map((entry) => ( + + ))} + {!browse.isPending && !browse.error && browse.data?.entries.length === 0 ? ( +
No subfolders here.
+ ) : null} +
+
+ + + + +
+
+ ); +} + +function ConnectionWizard(props: { + readonly open: boolean; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + readonly projectName: string; + readonly accounts: ReadonlyArray; + readonly operation: ScientOverleafOperationSnapshot | null; + readonly onOpenChange: (open: boolean) => void; + readonly onStarted: (operation: ScientOverleafOperationSnapshot) => void; + readonly onCompleted: (operation: ScientOverleafOperationSnapshot) => void; + readonly onAddAccount: () => void; +}) { + const [accountId, setAccountId] = useState(""); + const [projectInput, setProjectInput] = useState(""); + const [relativeFolder, setRelativeFolder] = useState(""); + const [label, setLabel] = useState(props.projectName); + const [commitKind, setCommitKind] = useState("neutral"); + const [customMessage, setCustomMessage] = useState("Update project"); + const [mode, setMode] = useState<"combine" | "replace-local" | "replace-overleaf">("combine"); + const [acknowledged, setAcknowledged] = useState(false); + const [working, setWorking] = useState(false); + const [folderBrowserOpen, setFolderBrowserOpen] = useState(false); + + useEffect(() => { + if (props.open && !accountId && props.accounts[0]) setAccountId(props.accounts[0].accountId); + }, [accountId, props.accounts, props.open]); + useEffect(() => { + setAcknowledged(false); + }, [props.operation?.generation]); + + const preflightReady = + props.operation?.kind === "connect" && + props.operation.connectStage === "preflight" && + props.operation.phase === "awaiting_push_confirmation"; + const preflightRunning = + props.operation?.kind === "connect" && + props.operation.connectStage === "preflight" && + ACTIVE_PHASES.has(props.operation.phase); + const warnings = props.operation?.review?.warnings ?? []; + const selectedAccount = props.accounts.find((account) => account.accountId === accountId) ?? null; + const newProjectUrl = + selectedAccount === null + ? "https://www.overleaf.com/project/new" + : selectedAccount.kind === "cloud" + ? "https://www.overleaf.com/project/new" + : `https://${selectedAccount.host}/project/new`; + + const start = async () => { + setWorking(true); + try { + const commitPolicy: ScientOverleafCommitPolicy = + commitKind === "custom" ? { kind: "custom", message: customMessage } : { kind: commitKind }; + const operation = await overleafClient.startPreflight(props.environmentId, { + accountId, + workspaceRoot: props.workspaceRoot, + relativeFolder, + projectInput, + label, + commitPolicy, + }); + props.onStarted(operation); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not check Overleaf project", + description: errorMessage(error), + }); + } finally { + setWorking(false); + } + }; + + const complete = async () => { + if (!props.operation) return; + setWorking(true); + try { + const operation = await overleafClient.completePreflight(props.environmentId, { + operationId: props.operation.operationId, + generation: props.operation.generation, + mode, + acknowledgeWarnings: acknowledged || warnings.length === 0, + }); + props.onCompleted(operation); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not connect Overleaf project", + description: errorMessage(error), + }); + } finally { + setWorking(false); + } + }; + + return ( + + + + + {preflightReady ? "Choose how to connect" : "Connect an Overleaf project"} + + + Synchronization is manual. The app keeps Git data privately; no .git folder is added to + your workspace. + + + + {preflightRunning ? ( +
+ + {props.operation?.message} +
+ ) : !preflightReady ? ( + <> + {props.accounts.length === 0 ? ( +
+ Add an Overleaf account before connecting a project.{" "} + +
+ ) : ( + + )} +