Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9cfb0cb
Merge remote-tracking branches 'origin/claude/overleaf-resumable-step…
yaacovcorcos Oct 22, 2026
538de99
wip(overleaf): coordinator local half (step 3b)
yaacovcorcos Oct 22, 2026
8cfd5cc
Merge remote-tracking branches 'origin/claude/overleaf-resumable-step…
yaacovcorcos Oct 22, 2026
f2dd342
feat(overleaf): coordinator settles via barrier keys; forced-conflict…
yaacovcorcos Oct 22, 2026
d0a83a1
feat(overleaf): kept-copy list and restore; Effect test hooks
yaacovcorcos Oct 22, 2026
13e4d99
Merge remote-tracking branches 'origin/claude/overleaf-resumable-step…
yaacovcorcos Oct 22, 2026
cf08d87
fix(overleaf): coordinator round 1 — every kept version, fail closed,…
yaacovcorcos Oct 22, 2026
468cf03
fix(overleaf): coordinator round 2 — one record per kept path, rechec…
yaacovcorcos Oct 22, 2026
def72a3
fix(overleaf): coordinator round 3 — versions in the record, stamps, …
yaacovcorcos Oct 22, 2026
c4b3676
Merge remote-tracking branch 'origin/main' into claude/overleaf-coord…
yaacovcorcos Oct 23, 2026
813fede
Merge remote-tracking branch 'origin/main' into claude/overleaf-coord…
yaacovcorcos Oct 23, 2026
96da6b1
chore(overleaf): coordinator follows main's new lint rules
yaacovcorcos Oct 23, 2026
4fcf49e
fix(overleaf): coordinator round 4 — verified stamps, forced paths ca…
yaacovcorcos Oct 23, 2026
a4de01d
fix(overleaf): coordinator round 5 — group room, path identity, folde…
yaacovcorcos Oct 23, 2026
bb82c47
fix(overleaf): coordinator round 6 — room for restored conflict material
yaacovcorcos Oct 23, 2026
b4e2569
fix(overleaf): coordinator round 7 — an unfinished restore stays a fo…
yaacovcorcos Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/server/src/scient/overleaf/OverleafConnectionState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,9 @@ describe("overleafTransitionViolation", () => {
planDigest: "a".repeat(64),
baseGeneration: 1,
intentTree: oid("5"),
remoteCommit: oid("7"),
localOnly: [],
priorMarkers: [],
};
const applying = next(first, { activeApply: link });
// The apply's result advances the base and clears its link in one replacement.
Expand Down Expand Up @@ -276,6 +279,9 @@ describe("overleafTransitionViolation", () => {
planDigest: "a".repeat(64),
baseGeneration: 1,
intentTree: oid("5"),
remoteCommit: oid("7"),
localOnly: [],
priorMarkers: [],
};
const applying = next(synced, { activeApply: link });
expect(overleafTransitionViolation(synced, applying, NOW)).toBeNull();
Expand All @@ -301,6 +307,10 @@ describe("overleafTransitionViolation", () => {
state: "changed",
retainedAt: "2026-10-12T12:00:00.000Z",
retainedAtSyncCount: 1,
kept: "apply-1/files/step-0/retained",
markers: false,
versions: [],
stamp: "1:1",
};
const holding = next(synced, { retention: { ...synced.retention, copies: [copy] } });
expect(violation(holding, { retention: { ...freshVault, copies: [copy] } })).toBe(
Expand All @@ -315,6 +325,9 @@ describe("overleafTransitionViolation", () => {
planDigest: "a".repeat(64),
baseGeneration: 1,
intentTree: oid("5"),
remoteCommit: oid("7"),
localOnly: [],
priorMarkers: [],
},
});
expect(violation(applying, { retention: freshVault })).toBe("vault-replaced-while-referenced");
Expand All @@ -334,6 +347,10 @@ describe("overleafTransitionViolation", () => {
state: "releasable",
retainedAt: "2026-10-05T12:00:00.000Z",
retainedAtSyncCount: 1,
kept: "apply-1/files/step-0/retained",
markers: false,
versions: [],
stamp: "1:1",
};
const holding = (change: Partial<typeof copy>, completedSyncCount: number) =>
next(synced, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ export const overleafConnectionFixture = (
localSnapshots: [],
managedPaths: [],
localOnlyPaths: [],
attentionPaths: [],
forcedConflicts: [],
markerPaths: [],
stagingPaths: [],
retention: {
vaultIdentity: { volume: "0A1B2C3D-0000-4000-8000-000000000001", fileId: "4" },
copies: [],
Expand Down
54 changes: 52 additions & 2 deletions apps/server/src/scient/overleaf/OverleafConnectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ const UNRESOLVED_ATTEMPT_LIMIT = 16;
/** All attempts kept, resolved history included; resolved ones may be pruned. */
const ATTEMPT_LIMIT = 64;
const RETAINED_COPY_LIMIT = 4_096;
const MANAGED_PATH_LIMIT = 2_000;
/** Paths a connection can manage: Overleaf's file limit per project. */
export const MANAGED_PATH_LIMIT = 2_000;
/**
* Forced conflicts one record holds. Each change of a sync may be interrupted on
* its own, and a sync changes at most the files it captured and the files of its
* result, each within the managed-path limit.
*/
export const FORCED_CONFLICT_LIMIT = 2 * MANAGED_PATH_LIMIT;
const RETENTION_PERIOD_MILLIS = 7 * 24 * 60 * 60 * 1_000;

const Oid = Schema.String.check(Schema.isPattern(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u));
Expand Down Expand Up @@ -90,6 +97,11 @@ const ApplyLink = Schema.Struct({
baseGeneration: Count,
/** Pinned private ref holding the immutable target tree. */
intentTree: Oid,
/** What adoption needs that the applier does not keep, so recovery can adopt without the review. */
remoteCommit: Oid,
/** The reviewed local-only set after this apply: kept entries less promoted collisions, plus new ones. */
localOnly: Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
priorMarkers: Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
});

export const OverleafAttemptOutcome = Schema.Literals([
Expand Down Expand Up @@ -147,6 +159,14 @@ const PublicationAttempt = Schema.Struct({
);
export type OverleafPublicationAttempt = typeof PublicationAttempt.Type;

/** A kept version's place in the retention folder: `<apply>/files/<step>/<leaf>`. */
const KeptPath = Schema.String.check(
Schema.isPattern(
/^[a-z0-9][a-z0-9-]{0,63}\/files\/step-\d{1,5}(?:-\d{1,5})?\/(?:retained|slot|provisional)$/u,
),
);
/** Further versions recorded for one kept path; beyond this the record is ambiguous. */
export const KEPT_VERSION_LIMIT = 16;
const RetainedCopy = Schema.Struct({
applyId: OverleafServerId,
relativePath: ManuscriptPath,
Expand All @@ -156,12 +176,29 @@ const RetainedCopy = Schema.Struct({
retainedAt: IsoTime,
/** The connection's completed-sync count when retained; release needs a later one. */
retainedAtSyncCount: Count,
/**
* The file's main kept version, relative to the retention folder
* (`<apply>/files/<step>/<leaf>`). A step may keep further versions of the
* same path beside it; they are found on disk, under the same apply.
*/
kept: KeptPath,
/** The kept bytes were Scient conflict material still unresolved when kept. */
markers: Schema.Boolean,
/**
* Further versions the apply reported for the same path (a staged, provisional
* or retried step's copy), in the same form as `kept`. They appear only when
* something went wrong, so a record with any is never released automatically.
*/
versions: Schema.Array(KeptPath).check(Schema.isMaxLength(KEPT_VERSION_LIMIT)),
/** Size and change time of the main version when kept; a later difference means a late writer. */
stamp: Schema.String.check(Schema.isPattern(/^\d{1,20}:\d{1,30}$/u)),
});
type RetainedCopy = typeof RetainedCopy.Type;

const isUnresolved = (outcome: OverleafAttemptOutcome) =>
outcome === "unknown" || outcome === "superseded-unresolved";

/** One record per file an apply kept; further versions of it are found on disk. */
const retainedKey = (copy: RetainedCopy) => `${copy.applyId}\0${copy.relativePath}`;

/** Structural equality of decoded JSON values, independent of key order. */
Expand Down Expand Up @@ -208,7 +245,16 @@ export const OverleafConnectionDocument = Schema.Struct({
localSnapshots: Schema.Array(LocalSnapshot).check(Schema.isMaxLength(LOCAL_SNAPSHOT_LIMIT)),
managedPaths: Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
localOnlyPaths: Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
attentionPaths: Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
/** Groups an earlier apply left half done: forced conflicts until a reviewed resolution. */
forcedConflicts: Schema.Array(
Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
).check(Schema.isMaxLength(FORCED_CONFLICT_LIMIT)),
/** Files where Scient wrote conflict material that may still be unresolved. */
markerPaths: Schema.Array(ManuscriptPath).check(Schema.isMaxLength(MANAGED_PATH_LIMIT)),
/** The applier's structural source copies in the folder; never captured, never deleted automatically. */
stagingPaths: Schema.Array(Schema.String.check(Schema.isMaxLength(4_096))).check(
Schema.isMaxLength(MANAGED_PATH_LIMIT),
),
retention: Schema.Struct({
vaultIdentity: VolumeFileIdentity,
copies: Schema.Array(RetainedCopy).check(Schema.isMaxLength(RETAINED_COPY_LIMIT)),
Expand Down Expand Up @@ -411,6 +457,10 @@ export function overleafTransitionViolation(
kept.revision !== copy.revision ||
kept.retainedAt !== copy.retainedAt ||
kept.retainedAtSyncCount !== copy.retainedAtSyncCount ||
kept.kept !== copy.kept ||
kept.markers !== copy.markers ||
kept.stamp !== copy.stamp ||
kept.versions.join("\0") !== copy.versions.join("\0") ||
// A changed, ambiguous, interrupted or kept copy never becomes releasable again.
(kept.state === "releasable" && copy.state !== "releasable")
)
Expand Down
Loading
Loading