Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 48 additions & 1 deletion packages/core/src/docs/__tests__/section-write-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ import { updateDocBySlug } from '../docs-update.js';
import { buildDocsFetchResult } from '../fetch-result.js';
import { listSectionLeases, SectionLeaseManager } from '../section-leases.js';
import { sectionIds, sectionStateText, splitDocIntoSections } from '../section-merge.js';
import { projectDocUpdateThroughSections, releaseSectionLeases } from '../section-write-path.js';
import {
previewDocUpdateThroughSections,
projectDocUpdateThroughSections,
releaseSectionLeases,
} from '../section-write-path.js';

let tempDir: string;

Expand Down Expand Up @@ -1012,6 +1016,49 @@ describe('projectDocUpdateThroughSections (T13355)', () => {
).toBe(true);
});

it('T13842: previewDocUpdateThroughSections returns the merged text the projection lands, and writes nothing', async () => {
await projectDocUpdateThroughSections(tempDir, {
slug: 'plan-doc',
authorAgentId: 'agent-seed',
content: DOC_V1,
hlc: hlcAt(1000),
});
const a = await projectDocUpdateThroughSections(tempDir, {
slug: 'plan-doc',
authorAgentId: 'agent-a',
content: DOC_V2,
hlc: hlcAt(2000),
});
const staleB = {
slug: 'plan-doc',
authorAgentId: 'agent-b',
sessionId: 'ses-preview',
content: DOC_V1.replace('beta body', 'beta body\n\nB beta line.'),
baseSections: splitDocIntoSections(DOC_V1),
hlc: hlcAt(3000),
};
const { getConduitDocHead, listConduitAttachmentVersions, readSectionCrdtState } = await import(
'../../store/conduit-doc-history.js'
);
const betaBefore = await readSectionCrdtState(tempDir, a.docUid, 'h-beta');

const preview = await previewDocUpdateThroughSections(tempDir, staleB);
expect(preview.wouldAdvance).toBe(true);
expect(preview.conflicts).toEqual([]);
expect(preview.mergedText).toContain('an appended line from agent B.');
expect(preview.mergedText).toContain('B beta line.');
// Nothing landed: same versions, same head, same section state, no lease.
expect(await listConduitAttachmentVersions(tempDir, a.docUid)).toHaveLength(2);
expect((await getConduitDocHead(tempDir, a.docUid))?.headVersionUid).toBe(a.version.uid);
expect((await readSectionCrdtState(tempDir, a.docUid, 'h-beta'))?.snapshot).toEqual(
betaBefore?.snapshot,
);
expect(await listSectionLeases(tempDir, 'plan-doc')).toEqual([]);

const real = await projectDocUpdateThroughSections(tempDir, staleB);
expect(real.mergedText).toBe(preview.mergedText);
});

it('T13604: updateDocBySlug threads section conflicts into the result envelope and emits a W_DOC_SECTION_CONFLICT warning', async () => {
// Seed the legacy row so updateDocBySlug finds the slug.
const v0 = '# Plan\n\nv0 body';
Expand Down
70 changes: 68 additions & 2 deletions packages/core/src/docs/section-write-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,68 @@ function writeMergedBlob(projectRoot: string, storageKey: string, text: string):
export async function projectDocUpdateThroughSections(
projectRoot: string,
input: ProjectDocUpdateInput,
): Promise<ProjectDocUpdateResult> {
return projectDocUpdate(projectRoot, input, false);
}

/** Outcome of {@link previewDocUpdateThroughSections}. */
export interface DocUpdatePreview {
/** The merged text the projection would land. */
readonly mergedText: string;
/** Whether the head would advance to that text (max-HLC rule). */
readonly wouldAdvance: boolean;
/**
* The section conflicts the projection would record. Their version uids
* name rolled-back rows: use `sectionId` only.
*/
readonly conflicts: readonly SectionConflict[];
}

/** Carries a preview's result out of its transaction, forcing the rollback. */
class PreviewRollback extends Error {
constructor(readonly result: ProjectDocUpdateResult) {
super('docs projection preview rolled back by design');
}
}

/**
* Preview a docs update without committing it (T13842): runs the exact
* projection — merge, adopt, version append, head upsert — inside its
* transaction and rolls it back, so a caller can write the fetch-visible row
* from the merged text BEFORE the projection commits. Acquires no leases.
* Another writer can still land between the preview and the real
* projection; callers compare the committed `mergedText` with the preview.
* A content-addressed blob may remain as a harmless orphan.
*
* @param projectRoot - Absolute path to the CLEO project root.
* @param input - The update facts, exactly as for {@link projectDocUpdateThroughSections}.
* @returns The merged text, whether the head would advance, and the conflicts.
*/
export async function previewDocUpdateThroughSections(
projectRoot: string,
input: ProjectDocUpdateInput,
): Promise<DocUpdatePreview> {
const result = await projectDocUpdate(projectRoot, input, true);
return {
mergedText: result.mergedText,
wouldAdvance: result.head.advanced,
conflicts: result.conflicts,
};
}

/**
* The projection behind {@link projectDocUpdateThroughSections} and
* {@link previewDocUpdateThroughSections}.
*
* @param projectRoot - Absolute path to the CLEO project root.
* @param input - The update facts.
* @param preview - Roll the transaction back and skip leases.
* @returns The merge outcome.
*/
async function projectDocUpdate(
projectRoot: string,
input: ProjectDocUpdateInput,
preview: boolean,
): Promise<ProjectDocUpdateResult> {
const existingUid = await resolveConduitDocUid(projectRoot, input.slug);
const contentBuffer = Buffer.from(input.content, 'utf-8');
Expand All @@ -336,7 +398,7 @@ export async function projectDocUpdateThroughSections(
input.sessionId ?? (await resolveBoundSession(projectRoot))?.session.id ?? null;
const leaseNotices: SectionLeaseNotice[] = [];
const acquiredThisRun: string[] = [];
if (boundSessionId !== null) {
if (boundSessionId !== null && !preview) {
const leaseManager = new SectionLeaseManager(projectRoot, {
sessionId: boundSessionId,
holder: input.authorAgentId,
Expand Down Expand Up @@ -545,7 +607,7 @@ export async function projectDocUpdateThroughSections(
}
await rebuildConduitAttachmentContributors(projectRoot, docUid);

return {
const result: ProjectDocUpdateResult = {
docUid,
adopted,
mergedText,
Expand All @@ -556,8 +618,12 @@ export async function projectDocUpdateThroughSections(
conflicts,
leaseNotices,
};
// @sync-invariant none:input-shape a preview rolls its own transaction back by design; nothing is written
if (preview) throw new PreviewRollback(result);
return result;
});
} catch (err) {
if (err instanceof PreviewRollback) return err.result;
// The projection rolled back: release the leases acquired for it, so a
// failed edit leaves no stale claim. (Renewed pre-existing leases stay —
// the session still holds them.)
Expand Down
Loading