Skip to content
Draft
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
123 changes: 123 additions & 0 deletions ts/packages/agents/markdown/src/agent/documentUpdatePersistence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { applyDocumentOperations } from "./documentOperations.js";
import type { DocumentOperation } from "./markdownOperationSchema.js";
import {
resolveRealDirectory,
resolveWritableFileWithinRoot,
} from "./pathPolicy.js";

export interface DocumentBinding {
token: string | undefined;
root: string;
relativePath: string;
filePath: string;
}

export interface UpdateExpectations {
bindingToken: string | undefined;
root: string | undefined;
relativePath: string | undefined;
revision: string;
updatedRevision: string | undefined;
}

export function computeContentRevision(content: string): string {
return createHash("sha256").update(content, "utf8").digest("hex");
}

function validateIdentity(
binding: DocumentBinding,
expected: UpdateExpectations,
): void {
if (
expected.bindingToken !== undefined &&
expected.bindingToken !== binding.token
) {
throw new Error("Document binding token changed");
}
if (expected.root !== undefined && expected.root !== binding.root) {
throw new Error("Document binding root changed");
}
if (
expected.relativePath !== undefined &&
expected.relativePath !== binding.relativePath
) {
throw new Error("Document binding path changed");
}
}

function resolveBoundFile(binding: DocumentBinding): string {
if (resolveRealDirectory(binding.root) !== binding.root) {
throw new Error("The authorized markdown workspace root changed");
}
const resolved = resolveWritableFileWithinRoot(
binding.root,
binding.relativePath,
);
if (
resolved === undefined ||
path.relative(resolved, binding.filePath) !== ""
) {
throw new Error(
"The markdown document binding changed or is outside its authorized workspace",
);
}
return resolved;
}

export function readBoundDocument(binding: DocumentBinding) {
const filePath = resolveBoundFile(binding);
const content = fs.readFileSync(filePath, "utf-8");
return { content, revision: computeContentRevision(content), filePath };
}

export function persistDocumentOperations(
binding: DocumentBinding,
operations: DocumentOperation[],
expected: UpdateExpectations,
) {
validateIdentity(binding, expected);
let filePath = resolveBoundFile(binding);
const currentContent = fs.readFileSync(filePath, "utf-8");
const currentRevision = computeContentRevision(currentContent);
if (expected.updatedRevision === currentRevision) {
return {
content: currentContent,
revision: currentRevision,
alreadyApplied: true,
filePath,
};
}
if (currentRevision !== expected.revision) {
throw new Error(
"Document changed between read and apply (revision mismatch)",
);
}

const content = applyDocumentOperations(currentContent, operations);
const revision = computeContentRevision(content);
if (
expected.updatedRevision !== undefined &&
expected.updatedRevision !== revision
) {
throw new Error("Updated document revision does not match operations");
}

validateIdentity(binding, expected);
filePath = resolveBoundFile(binding);
if (
computeContentRevision(fs.readFileSync(filePath, "utf-8")) !==
currentRevision
) {
throw new Error(
"Document changed between validation and write (revision mismatch)",
);
}
fs.writeFileSync(filePath, content, "utf-8");
return { content, revision, alreadyApplied: false, filePath };
}
24 changes: 23 additions & 1 deletion ts/packages/agents/markdown/src/agent/ipcTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,28 +36,50 @@ export interface UICommandResult {
// Agent → View: Content requests
export interface GetDocumentContentMessage {
type: "getDocumentContent";
requestId: string;
expectedBindingToken?: string;
expectedRoot?: string;
expectedRelativePath?: string;
}

export interface DocumentContentMessage {
type: "documentContent";
requestId: string;
content: string;
source?: "client-serializer" | "yjs-fallback" | "error";
source?: "file" | "error";
error?: string;
timestamp: number;
bindingToken: string | null;
boundFilePath: string | null;
boundRoot: string | null;
boundRelativePath: string | null;
revision: string | null;
identityMismatch?: boolean;
}

// Agent → View: LLM operations
export interface LLMOperationsMessage {
type: "applyLLMOperations";
requestId: string;
operations: any[]; // DocumentOperation[]
timestamp: number;
expectedBindingToken?: string;
expectedRoot?: string;
expectedRelativePath?: string;
expectedRevision: string;
expectedUpdatedRevision?: string;
}

export interface OperationsAppliedMessage {
type: "operationsApplied";
requestId: string;
success: boolean;
operationCount?: number;
error?: string;
identityMismatch?: boolean;
revisionMismatch?: boolean;
bindingToken?: string | null;
revision?: string | null;
}

// View → Frontend: Auto-save notifications
Expand Down
Loading
Loading