diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index cc51b56ee70..57bb2830db1 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -184,6 +184,8 @@ const providedCapabilitiesLayer = capabilitiesLayer.pipe( const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ + prepare: () => Effect.void, + resume: () => Effect.void, clear: (environmentId) => Effect.all( [ diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf..08dc3a9c49e 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -6,9 +6,19 @@ import * as NodePath from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { + collectTextAttachmentRelativePaths, + claimTextAttachment, createAttachmentId, + createTextAttachmentPath, parseThreadSegmentFromAttachmentId, + reconcileTextAttachments, + releaseTextAttachment, resolveAttachmentPathById, + TEXT_ATTACHMENT_DELETE_GRACE_MS, + TEXT_ATTACHMENT_METADATA_FILE, + TEXT_ATTACHMENT_PENDING_DIRECTORY, + writeClaimedTextAttachment, + textAttachmentDirectory, } from "./attachmentStore.ts"; describe("attachmentStore", () => { @@ -77,4 +87,253 @@ describe("attachmentStore", () => { NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); } }); + + it("creates normalized text attachment paths under the environment attachment store", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const attachmentPath = createTextAttachmentPath({ + attachmentsDir, + fileName: "../unsafe name.ts", + }); + + expect(NodePath.relative(attachmentsDir, attachmentPath)).toMatch( + /^text[/\\][0-9a-f-]+[/\\]\.\.-unsafe-name\.ts$/, + ); + expect(attachmentPath).not.toContain(`${NodePath.sep}..${NodePath.sep}`); + }); + + it("removes a written attachment when its initial claim fails", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-write-claim-"), + ); + try { + expect(() => + writeClaimedTextAttachment( + { + attachmentsDir, + fileName: "context.md", + contents: "context", + draftOwnerId: "draft-owner", + }, + () => false, + ), + ).toThrow(/initial text attachment claim/); + expect(NodeFS.readdirSync(NodePath.join(attachmentsDir, "text"))).toEqual([]); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("bounds text attachment basenames and avoids Windows reserved names", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const reservedPath = createTextAttachmentPath({ attachmentsDir, fileName: "CON.ts" }); + const longPath = createTextAttachmentPath({ + attachmentsDir, + fileName: `${"a".repeat(300)}.tsx`, + }); + + expect(NodePath.basename(reservedPath)).toBe("_CON.ts"); + expect(NodePath.basename(longPath)).toHaveLength(120); + expect(NodePath.basename(longPath)).toMatch(/\.tsx$/); + }); + + it("reserves internal text attachment metadata basenames", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-internal-name-"), + ); + try { + const generatedPath = createTextAttachmentPath({ + attachmentsDir, + fileName: TEXT_ATTACHMENT_METADATA_FILE, + }); + expect(NodePath.basename(generatedPath)).toBe("_t3-attachment.json"); + + const attachmentPath = writeClaimedTextAttachment({ + attachmentsDir, + fileName: TEXT_ATTACHMENT_METADATA_FILE, + contents: "user attachment contents", + draftOwnerId: "internal-name-owner", + }); + const metadataPath = NodePath.join( + NodePath.dirname(attachmentPath), + TEXT_ATTACHMENT_METADATA_FILE, + ); + expect(NodePath.basename(attachmentPath)).toBe("_t3-attachment.json"); + expect(attachmentPath).not.toBe(metadataPath); + expect(NodeFS.readFileSync(attachmentPath, "utf8")).toBe("user attachment contents"); + expect(NodeFS.existsSync(metadataPath)).toBe(true); + expect( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "internal-name-owner", + nowMs: 0, + }), + ).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("validates and collects server-owned text attachment paths", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-(attachments)"); + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" }); + const encodedPath = encodeURI(attachmentPath).replaceAll("\\", "%5C"); + + expect(textAttachmentDirectory({ attachmentsDir, path: attachmentPath })).toBe( + NodePath.dirname(attachmentPath), + ); + expect( + collectTextAttachmentRelativePaths({ + attachmentsDir, + text: `before[notes.txt](${encodedPath}),after`, + }), + ).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")])); + expect( + textAttachmentDirectory({ attachmentsDir, path: NodePath.join(attachmentsDir, "../nope") }), + ).toBeNull(); + }); + + it("ignores generated paths that are not Markdown links or are inside code", () => { + const attachmentsDir = NodePath.join(NodeOS.tmpdir(), "t3code-attachments"); + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "notes.txt" }); + + expect( + collectTextAttachmentRelativePaths({ + attachmentsDir, + text: [ + `missing](${attachmentPath})`, + `\`[inline](${attachmentPath})\``, + "```md", + `[fenced](${attachmentPath})`, + "```", + ].join("\n"), + }), + ).toEqual(new Set()); + expect( + collectTextAttachmentRelativePaths({ + attachmentsDir, + text: `paragraph\n [continued](${attachmentPath})`, + }), + ).toEqual(new Set([NodePath.relative(attachmentsDir, attachmentPath).replaceAll("\\", "/")])); + }); + + it("persists draft claims across reconciliation and restart-style reloads", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-claims-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "draft.md" }); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "draft"); + expect( + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "draft-owner", + }), + ).toBe(true); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: TEXT_ATTACHMENT_DELETE_GRACE_MS * 10, + }); + + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("cancels pending deletion when a copied draft reclaims an attachment", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-reclaim-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "draft.md" }); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "draft"); + claimTextAttachment({ attachmentsDir, path: attachmentPath, draftOwnerId: "original" }); + expect( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "original", + nowMs: 1_000, + }), + ).toBe(true); + expect( + claimTextAttachment({ attachmentsDir, path: attachmentPath, draftOwnerId: "copy" }), + ).toBe(true); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 1_000 + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + }); + + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("deletes only expired unclaimed and unreferenced text attachments", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-expiry-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "orphan.md" }); + NodeFS.mkdirSync(NodePath.dirname(attachmentPath), { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "orphan"); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 1_000, + }); + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 1_000 + TEXT_ATTACHMENT_DELETE_GRACE_MS + 1, + }); + expect(NodeFS.existsSync(attachmentPath)).toBe(false); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); + + it("preserves malformed metadata during full reconciliation", () => { + const attachmentsDir = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "t3code-text-invalid-metadata-"), + ); + try { + const attachmentPath = createTextAttachmentPath({ attachmentsDir, fileName: "keep.md" }); + const directory = NodePath.dirname(attachmentPath); + const metadataPath = NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE); + const pendingPath = NodePath.join( + attachmentsDir, + "text", + TEXT_ATTACHMENT_PENDING_DIRECTORY, + `${NodePath.basename(directory)}.json`, + ); + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(attachmentPath, "keep"); + NodeFS.writeFileSync(metadataPath, "malformed"); + + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: Number.MAX_SAFE_INTEGER, + }); + + expect(NodeFS.existsSync(attachmentPath)).toBe(true); + expect(NodeFS.readFileSync(metadataPath, "utf8")).toBe("malformed"); + expect(NodeFS.existsSync(pendingPath)).toBe(false); + } finally { + NodeFS.rmSync(attachmentsDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..94becb35927 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -1,8 +1,10 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; import type { ChatAttachment } from "@t3tools/contracts"; +import { markdownLinkDestinations } from "@t3tools/shared/markdownLinks"; import { normalizeAttachmentRelativePath, @@ -11,6 +13,13 @@ import { import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; +const TEXT_ATTACHMENT_DIRECTORY = "text"; +export const TEXT_ATTACHMENT_METADATA_FILE = ".t3-attachment.json"; +export const TEXT_ATTACHMENT_PENDING_DIRECTORY = ".pending"; +const INTERNAL_TEXT_ATTACHMENT_FILE_NAME_PATTERN = /^\.t3-attachment\.json(?:\.|$)/i; +export const TEXT_ATTACHMENT_DELETE_GRACE_MS = 60_000; +const TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS = 120; +const WINDOWS_RESERVED_FILE_NAME_PATTERN = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i; const ATTACHMENT_ID_THREAD_SEGMENT_MAX_CHARS = 80; const ATTACHMENT_ID_THREAD_SEGMENT_PATTERN = "[a-z0-9_]+(?:-[a-z0-9_]+)*"; const ATTACHMENT_ID_UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -108,3 +117,307 @@ export function parseAttachmentIdFromRelativePath(relativePath: string): string const id = normalized.slice(0, extensionIndex); return id.length > 0 && !id.includes(".") ? id : null; } + +export function createTextAttachmentPath(input: { + readonly attachmentsDir: string; + readonly fileName: string; +}): string { + const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/\.+$/, ""); + let safeName = + sanitizedName.length > 0 && sanitizedName !== "." && sanitizedName !== ".." + ? sanitizedName + : "context.txt"; + if (WINDOWS_RESERVED_FILE_NAME_PATTERN.test(safeName)) { + safeName = `_${safeName}`; + } + if (INTERNAL_TEXT_ATTACHMENT_FILE_NAME_PATTERN.test(safeName)) { + safeName = `_${safeName.slice(1)}`; + } + if (safeName.length > TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS) { + const extensionIndex = safeName.lastIndexOf("."); + const extension = extensionIndex > 0 ? safeName.slice(extensionIndex).slice(0, 20) : ""; + safeName = `${safeName.slice(0, TEXT_ATTACHMENT_FILE_NAME_MAX_CHARS - extension.length)}${extension}`; + } + return NodePath.join( + input.attachmentsDir, + TEXT_ATTACHMENT_DIRECTORY, + NodeCrypto.randomUUID(), + safeName, + ); +} + +export function textAttachmentRelativePath(input: { + readonly attachmentsDir: string; + readonly path: string; +}): string | null { + const relativePath = NodePath.relative( + NodePath.resolve(input.attachmentsDir), + NodePath.resolve(input.path), + ).replaceAll("\\", "/"); + return /^text\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\/[^/]+$/i.test( + relativePath, + ) + ? relativePath + : null; +} + +export function textAttachmentDirectory(input: { + readonly attachmentsDir: string; + readonly path: string; +}): string | null { + const relativePath = textAttachmentRelativePath(input); + return relativePath ? NodePath.join(input.attachmentsDir, NodePath.dirname(relativePath)) : null; +} + +export function collectTextAttachmentRelativePaths(input: { + readonly attachmentsDir: string; + readonly text: string; +}): Set { + const paths = new Set(); + for (const encodedPath of markdownLinkDestinations(input.text)) { + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + continue; + } + const relativePath = textAttachmentRelativePath({ + attachmentsDir: input.attachmentsDir, + path, + }); + if (relativePath) paths.add(relativePath); + } + return paths; +} + +interface TextAttachmentMetadata { + readonly version: 1; + readonly claims: ReadonlyArray; + readonly deleteAfter: number | null; +} + +type TextAttachmentMetadataReadResult = + | { readonly _tag: "Missing" } + | { readonly _tag: "Invalid" } + | { readonly _tag: "Valid"; readonly metadata: TextAttachmentMetadata }; + +const emptyTextAttachmentMetadata = (): TextAttachmentMetadata => ({ + version: 1, + claims: [], + deleteAfter: null, +}); + +function readTextAttachmentMetadata(directory: string): TextAttachmentMetadataReadResult { + try { + const parsed = JSON.parse( + NodeFS.readFileSync(NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE), "utf8"), + ) as Partial; + if (parsed.version !== 1 || !Array.isArray(parsed.claims)) { + return { _tag: "Invalid" }; + } + if ( + parsed.claims.some((claim) => typeof claim !== "string") || + (parsed.deleteAfter !== null && typeof parsed.deleteAfter !== "number") + ) { + return { _tag: "Invalid" }; + } + return { + _tag: "Valid", + metadata: { + version: 1, + claims: [...new Set(parsed.claims as ReadonlyArray)], + deleteAfter: parsed.deleteAfter ?? null, + }, + }; + } catch (cause) { + return typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT" + ? { _tag: "Missing" } + : { _tag: "Invalid" }; + } +} + +function metadataForMutation(directory: string): TextAttachmentMetadata { + const result = readTextAttachmentMetadata(directory); + if (result._tag === "Invalid") { + throw new Error("Text attachment metadata is unreadable or malformed."); + } + return result._tag === "Valid" ? result.metadata : emptyTextAttachmentMetadata(); +} + +function writeTextAttachmentMetadata(directory: string, metadata: TextAttachmentMetadata): void { + const metadataPath = NodePath.join(directory, TEXT_ATTACHMENT_METADATA_FILE); + const temporaryPath = `${metadataPath}.${NodeCrypto.randomUUID()}.tmp`; + NodeFS.writeFileSync(temporaryPath, JSON.stringify(metadata)); + NodeFS.renameSync(temporaryPath, metadataPath); +} + +function textAttachmentPendingPath(attachmentsDir: string, directory: string): string { + return NodePath.join( + attachmentsDir, + TEXT_ATTACHMENT_DIRECTORY, + TEXT_ATTACHMENT_PENDING_DIRECTORY, + `${NodePath.basename(directory)}.json`, + ); +} + +function removeTextAttachmentPendingMarker(attachmentsDir: string, directory: string): void { + NodeFS.rmSync(textAttachmentPendingPath(attachmentsDir, directory), { force: true }); +} + +function writeTextAttachmentPendingMarker( + attachmentsDir: string, + directory: string, + deleteAfter: number, +): void { + const pendingPath = textAttachmentPendingPath(attachmentsDir, directory); + NodeFS.mkdirSync(NodePath.dirname(pendingPath), { recursive: true }); + const temporaryPath = `${pendingPath}.${NodeCrypto.randomUUID()}.tmp`; + NodeFS.writeFileSync(temporaryPath, JSON.stringify({ deleteAfter })); + NodeFS.renameSync(temporaryPath, pendingPath); +} + +export function claimTextAttachment(input: { + readonly attachmentsDir: string; + readonly path: string; + readonly draftOwnerId: string; +}): boolean { + const directory = textAttachmentDirectory(input); + if (!directory || !NodeFS.existsSync(input.path)) return false; + const metadata = metadataForMutation(directory); + writeTextAttachmentMetadata(directory, { + version: 1, + claims: [...new Set([...metadata.claims, input.draftOwnerId])], + deleteAfter: null, + }); + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); + return true; +} + +export function writeClaimedTextAttachment( + input: { + readonly attachmentsDir: string; + readonly fileName: string; + readonly contents: string; + readonly draftOwnerId: string; + }, + claim: typeof claimTextAttachment = claimTextAttachment, +): string { + const attachmentPath = createTextAttachmentPath(input); + const directory = NodePath.dirname(attachmentPath); + try { + NodeFS.mkdirSync(directory, { recursive: true }); + NodeFS.writeFileSync(attachmentPath, input.contents); + if ( + !claim({ + attachmentsDir: input.attachmentsDir, + path: attachmentPath, + draftOwnerId: input.draftOwnerId, + }) + ) { + throw new Error("Failed to create the initial text attachment claim."); + } + return attachmentPath; + } catch (cause) { + NodeFS.rmSync(directory, { recursive: true, force: true }); + throw cause; + } +} + +export function releaseTextAttachment(input: { + readonly attachmentsDir: string; + readonly path: string; + readonly draftOwnerId: string; + readonly nowMs: number; +}): boolean { + const directory = textAttachmentDirectory(input); + if (!directory || !NodeFS.existsSync(input.path)) return false; + const metadata = metadataForMutation(directory); + if (!metadata.claims.includes(input.draftOwnerId)) { + if (metadata.deleteAfter === null) return false; + writeTextAttachmentPendingMarker(input.attachmentsDir, directory, metadata.deleteAfter); + return true; + } + const claims = metadata.claims.filter((claim) => claim !== input.draftOwnerId); + writeTextAttachmentMetadata(directory, { + version: 1, + claims, + deleteAfter: claims.length === 0 ? input.nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS : null, + }); + if (claims.length === 0) { + writeTextAttachmentPendingMarker( + input.attachmentsDir, + directory, + input.nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, + ); + } else { + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); + } + return true; +} + +export function reconcileTextAttachments(input: { + readonly attachmentsDir: string; + readonly retainedRelativePaths: ReadonlySet; + readonly nowMs: number; +}): { readonly pending: number; readonly removed: number } { + const textRoot = NodePath.join(input.attachmentsDir, TEXT_ATTACHMENT_DIRECTORY); + let pending = 0; + let removed = 0; + let entries: ReadonlyArray; + try { + entries = NodeFS.readdirSync(textRoot); + } catch { + return { pending, removed }; + } + const retainedDirectories = new Set( + [...input.retainedRelativePaths].map((relativePath) => NodePath.dirname(relativePath)), + ); + const nowMs = input.nowMs; + for (const entry of entries) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(entry)) { + continue; + } + const directory = NodePath.join(textRoot, entry); + let stat: NodeFS.Stats; + try { + stat = NodeFS.statSync(directory); + } catch { + continue; + } + if (!stat.isDirectory()) continue; + const metadataResult = readTextAttachmentMetadata(directory); + if (metadataResult._tag === "Invalid") continue; + const metadata = + metadataResult._tag === "Valid" ? metadataResult.metadata : emptyTextAttachmentMetadata(); + const relativeDirectory = `${TEXT_ATTACHMENT_DIRECTORY}/${entry}`; + if (retainedDirectories.has(relativeDirectory) || metadata.claims.length > 0) { + if (metadata.deleteAfter !== null) { + writeTextAttachmentMetadata(directory, { ...metadata, deleteAfter: null }); + } + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); + continue; + } + if (metadata.deleteAfter === null) { + writeTextAttachmentMetadata(directory, { + ...metadata, + deleteAfter: nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, + }); + writeTextAttachmentPendingMarker( + input.attachmentsDir, + directory, + nowMs + TEXT_ATTACHMENT_DELETE_GRACE_MS, + ); + pending += 1; + continue; + } + if (metadata.deleteAfter > nowMs) { + writeTextAttachmentPendingMarker(input.attachmentsDir, directory, metadata.deleteAfter); + pending += 1; + continue; + } + NodeFS.rmSync(directory, { recursive: true, force: true }); + removeTextAttachmentPendingMarker(input.attachmentsDir, directory); + removed += 1; + } + return { pending, removed }; +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0999000ed4f..77f37fe78c6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -11,7 +11,9 @@ import { } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -29,11 +31,22 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { ORCHESTRATION_PROJECTOR_NAMES, OrchestrationProjectionPipelineLive, + reconcileDueTextAttachments, } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { OrchestrationProjectionPipeline } from "../Services/ProjectionPipeline.ts"; import { ServerConfig } from "../../config.ts"; +import { withTextAttachmentMutationLock } from "../../textAttachmentMutationLock.ts"; +import { + claimTextAttachment, + reconcileTextAttachments, + releaseTextAttachment, + textAttachmentRelativePath, + TEXT_ATTACHMENT_DELETE_GRACE_MS, + TEXT_ATTACHMENT_METADATA_FILE, + TEXT_ATTACHMENT_PENDING_DIRECTORY, +} from "../../attachmentStore.ts"; const makeProjectionPipelinePrefixedTestLayer = (prefix: string) => OrchestrationProjectionPipelineLive.pipe( @@ -52,6 +65,221 @@ const exists = (filePath: string) => const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-text-expiry-sweep-")))( + "text attachment expiry sweep", + (it) => { + it.effect("loads retained messages only when a pending attachment is due", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + let retainedLoads = 0; + const loadRetained = Effect.sync(() => { + retainedLoads += 1; + return new Set(); + }); + + yield* reconcileDueTextAttachments(attachmentsDir, loadRetained); + assert.equal(retainedLoads, 0); + + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000010", + "orphan.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "orphan"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + }); + assert.isTrue( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }), + ); + const pendingMarkerPath = path.join( + attachmentsDir, + "text", + TEXT_ATTACHMENT_PENDING_DIRECTORY, + "00000000-0000-4000-8000-000000000010.json", + ); + yield* fileSystem.remove(pendingMarkerPath, { force: true }); + assert.isFalse(yield* exists(pendingMarkerPath)); + assert.isTrue( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "expiry-owner", + nowMs: 0, + }), + ); + assert.isTrue(yield* exists(pendingMarkerPath)); + + yield* reconcileDueTextAttachments(attachmentsDir, loadRetained); + + assert.equal(retainedLoads, 1); + assert.isFalse(yield* exists(attachmentPath)); + }), + ); + + it.effect("preserves a due attachment when metadata cannot be decoded", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000011", + "corrupt.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "preserve"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "corrupt-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "corrupt-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + const metadataPath = path.join(path.dirname(attachmentPath), TEXT_ATTACHMENT_METADATA_FILE); + const markerPath = path.join( + attachmentsDir, + "text", + TEXT_ATTACHMENT_PENDING_DIRECTORY, + "00000000-0000-4000-8000-000000000011.json", + ); + yield* fileSystem.writeFileString(metadataPath, "not-json"); + + yield* reconcileDueTextAttachments(attachmentsDir, Effect.succeed(new Set())); + + assert.isTrue(yield* exists(attachmentPath)); + assert.isTrue(yield* exists(markerPath)); + }), + ); + + it.effect("clears pending expiry when a due attachment becomes retained", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000012", + "retained.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "retained"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "retained-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "retained-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + const relativePath = textAttachmentRelativePath({ + attachmentsDir, + path: attachmentPath, + }); + assert.isNotNull(relativePath); + + yield* reconcileDueTextAttachments( + attachmentsDir, + Effect.succeed(new Set(relativePath ? [relativePath] : [])), + ); + + const directoryEntries = yield* fileSystem.readDirectory(path.dirname(attachmentPath), { + recursive: false, + }); + assert.deepEqual( + [...directoryEntries].sort(), + [TEXT_ATTACHMENT_METADATA_FILE, "retained.txt"].sort(), + ); + + assert.isFalse( + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "retained-owner", + nowMs: 0, + }), + ); + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: 0, + }); + yield* reconcileDueTextAttachments(attachmentsDir, Effect.succeed(new Set())); + assert.isTrue(yield* exists(attachmentPath)); + }), + ); + + it.effect("serializes a concurrent claim ahead of due deletion", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { attachmentsDir } = yield* ServerConfig; + const attachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000013", + "race.txt", + ); + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "race"); + claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "original-race-owner", + }); + releaseTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "original-race-owner", + nowMs: -TEXT_ATTACHMENT_DELETE_GRACE_MS - 1, + }); + const claimStarted = yield* Deferred.make(); + const finishClaim = yield* Deferred.make(); + const claimFiber = yield* withTextAttachmentMutationLock( + Effect.gen(function* () { + yield* Deferred.succeed(claimStarted, undefined); + yield* Deferred.await(finishClaim); + return claimTextAttachment({ + attachmentsDir, + path: attachmentPath, + draftOwnerId: "concurrent-race-owner", + }); + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(claimStarted); + const sweepFiber = yield* withTextAttachmentMutationLock( + reconcileDueTextAttachments(attachmentsDir, Effect.succeed(new Set())), + ).pipe(Effect.forkScoped); + yield* Deferred.succeed(finishClaim, undefined); + + assert.isTrue(yield* Fiber.join(claimFiber)); + yield* Fiber.join(sweepFiber); + assert.isTrue(yield* exists(attachmentPath)); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { @@ -735,6 +963,12 @@ it.layer( const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const otherThreadAttachmentId = "thread-revert-files-extra-00000000-0000-4000-8000-000000000003"; + const removeTextAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000008", + "remove.txt", + ); const appendAndProject = (event: Parameters[0]) => eventStore @@ -877,7 +1111,7 @@ it.layer( threadId, messageId: MessageId.make("message-remove"), role: "assistant", - text: "Remove", + text: `[remove.txt](${encodeURI(removeTextAttachmentPath)})`, attachments: [ { type: "image", @@ -924,6 +1158,31 @@ it.layer( assert.isTrue(yield* exists(keepPath)); assert.isFalse(yield* exists(removePath)); assert.isTrue(yield* exists(otherThreadPath)); + + // Simulate a crash after the projection transaction commits but before + // its filesystem cleanup completes. + yield* fileSystem.writeFileString(removePath, "remove after restart"); + yield* fileSystem.makeDirectory(path.dirname(removeTextAttachmentPath), { + recursive: true, + }); + yield* fileSystem.writeFileString(removeTextAttachmentPath, "remove after restart"); + claimTextAttachment({ + attachmentsDir, + path: removeTextAttachmentPath, + draftOwnerId: "copied-revert-draft", + }); + + yield* projectionPipeline.bootstrap; + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(), + nowMs: Number.MAX_SAFE_INTEGER, + }); + + assert.isTrue(yield* exists(keepPath)); + assert.isFalse(yield* exists(removePath)); + assert.isTrue(yield* exists(removeTextAttachmentPath)); + assert.isFalse(yield* exists(otherThreadPath)); }), ); }); @@ -943,6 +1202,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001"; const otherThreadAttachmentId = "thread-delete-files-extra-00000000-0000-4000-8000-000000000002"; + const textAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000003", + "notes.txt", + ); + const unrelatedTextAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000004", + "other.txt", + ); const appendAndProject = (event: Parameters[0]) => eventStore @@ -1010,7 +1281,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta threadId, messageId: MessageId.make("message-delete-files"), role: "user", - text: "Delete", + text: `Delete [notes.txt](${encodeURI(textAttachmentPath)})`, attachments: [ { type: "image", @@ -1035,8 +1306,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); yield* fileSystem.writeFileString(threadAttachmentPath, "delete"); yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); + yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(unrelatedTextAttachmentPath), { + recursive: true, + }); + yield* fileSystem.writeFileString(textAttachmentPath, "delete text"); + yield* fileSystem.writeFileString(unrelatedTextAttachmentPath, "keep text"); assert.isTrue(yield* exists(threadAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); + assert.isTrue(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); yield* appendAndProject({ type: "thread.deleted", @@ -1056,6 +1335,190 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isFalse(yield* exists(threadAttachmentPath)); assert.isTrue(yield* exists(otherThreadAttachmentPath)); + assert.isTrue(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); + + yield* fileSystem.writeFileString(threadAttachmentPath, "delete after restart"); + yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.writeFileString(textAttachmentPath, "delete after restart"); + + yield* projectionPipeline.bootstrap; + + assert.isFalse(yield* exists(threadAttachmentPath)); + assert.isFalse(yield* exists(otherThreadAttachmentPath)); + assert.isTrue(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(unrelatedTextAttachmentPath)); + }), + ); + }, +); + +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-replay-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("does not delete shared text attachments while rebuilding projections", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-attachment-replay"); + const deletedThreadId = ThreadId.make("thread-attachment-replay-deleted"); + const retainedThreadId = ThreadId.make("thread-attachment-replay-retained"); + const textAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000005", + "shared.txt", + ); + const deletedTextAttachmentPath = path.join( + attachmentsDir, + "text", + "00000000-0000-4000-8000-000000000006", + "deleted.txt", + ); + const deletedImageAttachmentId = + "thread-attachment-replay-deleted-00000000-0000-4000-8000-000000000007"; + const deletedImageAttachmentPath = path.join( + attachmentsDir, + `${deletedImageAttachmentId}.png`, + ); + const attachmentLink = `[shared.txt](${encodeURI(textAttachmentPath)})`; + const append = (event: Parameters[0]) => + eventStore.append(event).pipe(Effect.asVoid); + + yield* append({ + type: "project.created", + eventId: EventId.make("evt-attachment-replay-1"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.make("cmd-attachment-replay-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-attachment-replay-1"), + metadata: {}, + payload: { + projectId, + title: "Attachment Replay", + workspaceRoot: "/tmp/project-attachment-replay", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + for (const [index, threadId] of [deletedThreadId, retainedThreadId].entries()) { + yield* append({ + type: "thread.created", + eventId: EventId.make(`evt-attachment-replay-thread-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-attachment-replay-thread-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-attachment-replay-thread-${index}`), + metadata: {}, + payload: { + threadId, + projectId, + title: `Attachment Replay ${index}`, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* append({ + type: "thread.message-sent", + eventId: EventId.make(`evt-attachment-replay-message-${index}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-attachment-replay-message-${index}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-attachment-replay-message-${index}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(`message-attachment-replay-${index}`), + role: "user", + text: + index === 0 + ? `${attachmentLink} [deleted.txt](${encodeURI(deletedTextAttachmentPath)})` + : attachmentLink, + ...(index === 0 + ? { + attachments: [ + { + type: "image" as const, + id: deletedImageAttachmentId, + name: "deleted.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + } + : {}), + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + } + + yield* append({ + type: "thread.deleted", + eventId: EventId.make("evt-attachment-replay-delete"), + aggregateKind: "thread", + aggregateId: deletedThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-attachment-replay-delete"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-attachment-replay-delete"), + metadata: {}, + payload: { + threadId: deletedThreadId, + deletedAt: now, + }, + }); + + yield* fileSystem.makeDirectory(path.dirname(textAttachmentPath), { recursive: true }); + yield* fileSystem.makeDirectory(path.dirname(deletedTextAttachmentPath), { + recursive: true, + }); + yield* fileSystem.writeFileString(textAttachmentPath, "shared attachment"); + yield* fileSystem.writeFileString(deletedTextAttachmentPath, "deleted attachment"); + yield* fileSystem.writeFileString(deletedImageAttachmentPath, "deleted image"); + claimTextAttachment({ + attachmentsDir, + path: deletedTextAttachmentPath, + draftOwnerId: "copied-delete-draft", + }); + + yield* projectionPipeline.bootstrap; + const retainedSharedPath = textAttachmentRelativePath({ + attachmentsDir, + path: textAttachmentPath, + }); + assert.isNotNull(retainedSharedPath); + reconcileTextAttachments({ + attachmentsDir, + retainedRelativePaths: new Set(retainedSharedPath ? [retainedSharedPath] : []), + nowMs: Number.MAX_SAFE_INTEGER, + }); + + assert.isTrue(yield* exists(textAttachmentPath)); + assert.isTrue(yield* exists(deletedTextAttachmentPath)); + assert.isFalse(yield* exists(deletedImageAttachmentPath)); }), ); }, @@ -1073,9 +1536,18 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta const now = "2026-01-01T00:00:00.000Z"; const { attachmentsDir: attachmentsRootDir, stateDir } = yield* ServerConfig; const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); + const unknownAttachmentPath = path.join( + attachmentsRootDir, + "unknown-00000000-0000-4000-8000-000000000009.txt", + ); + const unknownTextDirectoryPath = path.join(attachmentsRootDir, "text", "manual"); + const unknownTextPath = path.join(unknownTextDirectoryPath, "notes.txt"); const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); + yield* fileSystem.makeDirectory(unknownTextDirectoryPath, { recursive: true }); yield* fileSystem.writeFileString(attachmentsSentinelPath, "keep-attachments-root"); + yield* fileSystem.writeFileString(unknownAttachmentPath, "keep-unknown-attachment"); + yield* fileSystem.writeFileString(unknownTextPath, "keep-unknown-text"); yield* fileSystem.writeFileString(stateDirSentinelPath, "keep-state-dir"); yield* eventStore.append({ @@ -1098,6 +1570,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta assert.isTrue(yield* exists(attachmentsRootDir)); assert.isTrue(yield* exists(attachmentsSentinelPath)); + assert.isTrue(yield* exists(unknownAttachmentPath)); + assert.isTrue(yield* exists(unknownTextPath)); assert.isTrue(yield* exists(stateDirSentinelPath)); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f12df850941..1fd788daf55 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -5,11 +5,14 @@ import { type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; 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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -44,14 +47,21 @@ import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; +import { SAFE_IMAGE_FILE_EXTENSIONS } from "../../imageMime.ts"; +import { writeFileStringAtomically } from "../../atomicWrite.ts"; +import { withTextAttachmentMutationLock } from "../../textAttachmentMutationLock.ts"; import { OrchestrationProjectionPipeline, type OrchestrationProjectionPipelineShape, } from "../Services/ProjectionPipeline.ts"; import { attachmentRelativePath, + collectTextAttachmentRelativePaths, parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, + reconcileTextAttachments, + TEXT_ATTACHMENT_METADATA_FILE, + TEXT_ATTACHMENT_PENDING_DIRECTORY, toSafeThreadAttachmentSegment, } from "../../attachmentStore.ts"; @@ -104,6 +114,15 @@ interface ProjectorDefinition { interface AttachmentSideEffects { readonly deletedThreadIds: Set; readonly prunedThreadRelativePaths: Map>; + readonly textAttachmentRelativePathsToRemove: Set; +} + +function makeAttachmentSideEffects(): AttachmentSideEffects { + return { + deletedThreadIds: new Set(), + prunedThreadRelativePaths: new Map>(), + textAttachmentRelativePathsToRemove: new Set(), + }; } const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( @@ -351,8 +370,25 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } +function collectThreadTextAttachmentRelativePaths( + attachmentsDir: string, + messages: ReadonlyArray, +): Set { + const relativePaths = new Set(); + for (const message of messages) { + for (const relativePath of collectTextAttachmentRelativePaths({ + attachmentsDir, + text: message.text, + })) { + relativePaths.add(relativePath); + } + } + return relativePaths; +} + const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( sideEffects: AttachmentSideEffects, + projectionThreadMessageRepository: ProjectionThreadMessageRepository["Service"], ) { const serverConfig = yield* Effect.service(ServerConfig); const fileSystem = yield* Effect.service(FileSystem.FileSystem); @@ -465,8 +501,213 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* pruneThreadAttachments(threadId, keptThreadRelativePaths), { concurrency: 1 }, ); + + if ( + sideEffects.textAttachmentRelativePathsToRemove.size > 0 || + sideEffects.deletedThreadIds.size > 0 + ) { + const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + attachmentsRootDir, + (yield* projectionThreadMessageRepository.listRetained()).filter( + (message) => !sideEffects.deletedThreadIds.has(message.threadId), + ), + ); + const nowMs = yield* Clock.currentTimeMillis; + yield* Effect.sync(() => + reconcileTextAttachments({ + attachmentsDir: attachmentsRootDir, + retainedRelativePaths: retainedTextAttachmentPaths, + nowMs, + }), + ); + } +}); + +const GENERATED_IMAGE_ATTACHMENT_EXTENSIONS = new Set([...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]); + +function isGeneratedImageAttachmentEntry(entry: string): boolean { + const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { + return false; + } + const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry); + if (!attachmentId || !parseThreadSegmentFromAttachmentId(attachmentId)) { + return false; + } + const extension = normalizedEntry.slice(attachmentId.length).toLowerCase(); + return GENERATED_IMAGE_ATTACHMENT_EXTENSIONS.has(extension); +} + +const reconcileGeneratedAttachments = Effect.fn("reconcileGeneratedAttachments")(function* ( + projectionThreadMessageRepository: ProjectionThreadMessageRepository["Service"], +) { + const serverConfig = yield* Effect.service(ServerConfig); + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + const attachmentsRootDir = serverConfig.attachmentsDir; + const retainedMessages = yield* projectionThreadMessageRepository.listRetained(); + const retainedImagePaths = new Set(); + for (const message of retainedMessages) { + for (const attachment of message.attachments ?? []) { + if (attachment.type === "image") { + retainedImagePaths.add(attachmentRelativePath(attachment)); + } + } + } + const retainedTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + attachmentsRootDir, + retainedMessages, + ); + const rootEntries = yield* fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + + for (const entry of rootEntries) { + const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (normalizedEntry === "text") { + continue; + } + if (!isGeneratedImageAttachmentEntry(normalizedEntry)) { + continue; + } + const absolutePath = path.join(attachmentsRootDir, normalizedEntry); + const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null)); + if (fileInfo?.type === "File" && !retainedImagePaths.has(normalizedEntry)) { + yield* fileSystem.remove(absolutePath, { force: true }); + } + } + const nowMs = yield* Clock.currentTimeMillis; + yield* Effect.sync(() => + reconcileTextAttachments({ + attachmentsDir: attachmentsRootDir, + retainedRelativePaths: retainedTextAttachmentPaths, + nowMs, + }), + ); +}); + +interface DueTextAttachment { + readonly directory: string; + readonly markerPath: string; + readonly relativeDirectory: string; +} + +const TextAttachmentMetadataSchema = Schema.Struct({ + version: Schema.Literal(1), + claims: Schema.Array(Schema.String), + deleteAfter: Schema.NullOr(Schema.Number), +}); +const decodeTextAttachmentMetadata = Schema.decodeUnknownEffect( + Schema.fromJsonString(TextAttachmentMetadataSchema), +); +const encodeTextAttachmentMetadata = Schema.encodeEffect( + Schema.fromJsonString(TextAttachmentMetadataSchema), +); + +const readDueTextAttachments = Effect.fn("readDueTextAttachments")(function* ( + attachmentsDir: string, + nowMs: number, +) { + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + const pendingDirectory = path.join(attachmentsDir, "text", TEXT_ATTACHMENT_PENDING_DIRECTORY); + const entries = yield* fileSystem + .readDirectory(pendingDirectory, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); + const candidates = yield* Effect.forEach( + entries, + (entry) => + Effect.gen(function* () { + const match = + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.json$/i.exec(entry); + if (!match?.[1]) return null; + const markerPath = path.join(pendingDirectory, entry); + const marker = yield* fileSystem + .readFileString(markerPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.UnknownFromJsonString))); + if ( + typeof marker !== "object" || + marker === null || + !("deleteAfter" in marker) || + typeof marker.deleteAfter !== "number" || + marker.deleteAfter > nowMs + ) { + return null; + } + return { + directory: path.join(attachmentsDir, "text", match[1]), + markerPath, + relativeDirectory: `text/${match[1]}`, + } satisfies DueTextAttachment; + }).pipe(Effect.orElseSucceed(() => null)), + { concurrency: 16 }, + ); + return candidates.filter((candidate): candidate is DueTextAttachment => candidate !== null); +}); + +const removeDueTextAttachments = Effect.fn("removeDueTextAttachments")(function* ( + attachmentsDir: string, + candidates: ReadonlyArray, + retainedRelativePaths: ReadonlySet, + nowMs: number, +) { + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + const retainedDirectories = new Set( + [...retainedRelativePaths].map((relativePath) => + path.dirname(relativePath).replace(/\\/g, "/"), + ), + ); + yield* Effect.forEach( + candidates, + (candidate) => + Effect.gen(function* () { + if (retainedDirectories.has(candidate.relativeDirectory)) { + const metadataPath = path.join(candidate.directory, TEXT_ATTACHMENT_METADATA_FILE); + const metadataResult = yield* fileSystem + .readFileString(metadataPath) + .pipe(Effect.flatMap(decodeTextAttachmentMetadata), Effect.result); + if (metadataResult._tag === "Failure") return; + if (metadataResult.success.deleteAfter !== null) { + const encoded = yield* encodeTextAttachmentMetadata({ + ...metadataResult.success, + deleteAfter: null, + }); + yield* writeFileStringAtomically({ filePath: metadataPath, contents: encoded }); + } + yield* fileSystem.remove(candidate.markerPath, { force: true }); + return; + } + const metadataResult = yield* fileSystem + .readFileString(path.join(candidate.directory, TEXT_ATTACHMENT_METADATA_FILE)) + .pipe(Effect.flatMap(decodeTextAttachmentMetadata), Effect.result); + if (metadataResult._tag === "Failure") return; + const metadata = metadataResult.success; + if (metadata.claims.length > 0 || metadata.deleteAfter === null) { + yield* fileSystem.remove(candidate.markerPath, { force: true }); + return; + } + if (metadata.deleteAfter > nowMs) return; + yield* fileSystem.remove(candidate.directory, { recursive: true, force: true }); + yield* fileSystem.remove(candidate.markerPath, { force: true }); + }), + { concurrency: 4 }, + ); }); +export function reconcileDueTextAttachments( + attachmentsDir: string, + loadRetainedRelativePaths: Effect.Effect, E, R>, +) { + return Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + const dueCandidates = yield* readDueTextAttachments(attachmentsDir, nowMs); + if (dueCandidates.length === 0) return; + const retainedRelativePaths = yield* loadRetainedRelativePaths; + yield* removeDueTextAttachments(attachmentsDir, dueCandidates, retainedRelativePaths, nowMs); + }); +} + const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjectionPipeline")( function* () { const sql = yield* SqlClient.SqlClient; @@ -484,6 +725,37 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; + const bootstrapComplete = yield* Ref.make(false); + + const reconcileTextAttachmentStore = withTextAttachmentMutationLock( + reconcileDueTextAttachments( + serverConfig.attachmentsDir, + projectionThreadMessageRepository + .listRetained() + .pipe( + Effect.map((retainedMessages) => + collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + retainedMessages, + ), + ), + ), + ), + ).pipe( + Effect.catch((cause) => Effect.logWarning("failed to reconcile text attachments", { cause })), + ); + yield* Effect.forkScoped( + Effect.sleep("30 seconds").pipe( + Effect.andThen( + Ref.get(bootstrapComplete).pipe( + Effect.flatMap((isComplete) => + isComplete ? reconcileTextAttachmentStore : Effect.void, + ), + ), + ), + Effect.forever, + ), + ); const applyProjectsProjection: ProjectorDefinition["apply"] = Effect.fn( "applyProjectsProjection", @@ -811,6 +1083,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadMessagesProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { + case "thread.deleted": { + attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + for (const relativePath of collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + existingRows, + )) { + attachmentSideEffects.textAttachmentRelativePathsToRemove.add(relativePath); + } + return; + } + case "thread.message-sent": { const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, @@ -864,6 +1150,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti existingTurns, event.payload.turnCount, ); + const keptTextAttachmentPaths = collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + keptRows, + ); + for (const relativePath of collectThreadTextAttachmentRelativePaths( + serverConfig.attachmentsDir, + existingRows, + )) { + if (!keptTextAttachmentPaths.has(relativePath)) { + attachmentSideEffects.textAttachmentRelativePathsToRemove.add(relativePath); + } + } if (keptRows.length === existingRows.length) { return; } @@ -1501,11 +1799,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const runProjectorForEvent = Effect.fn("runProjectorForEvent")(function* ( projector: ProjectorDefinition, event: OrchestrationEvent, + applyAttachmentSideEffects: boolean, ) { - const attachmentSideEffects: AttachmentSideEffects = { - deletedThreadIds: new Set(), - prunedThreadRelativePaths: new Map>(), - }; + const attachmentSideEffects = makeAttachmentSideEffects(); yield* sql.withTransaction( projector.apply(event, attachmentSideEffects).pipe( @@ -1519,16 +1815,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ), ); - yield* runAttachmentSideEffects(attachmentSideEffects).pipe( - Effect.catch((cause) => - Effect.logWarning("failed to apply projected attachment side-effects", { - projector: projector.name, - sequence: event.sequence, - eventType: event.type, - cause, - }), - ), - ); + if (applyAttachmentSideEffects) { + yield* withTextAttachmentMutationLock( + runAttachmentSideEffects(attachmentSideEffects, projectionThreadMessageRepository), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to apply projected attachment side-effects", { + projector: projector.name, + sequence: event.sequence, + eventType: event.type, + cause, + }), + ), + ); + } }); const bootstrapProjector = (projector: ProjectorDefinition) => @@ -1542,32 +1842,40 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti eventStore.readFromSequence( Option.isSome(stateRow) ? stateRow.value.lastAppliedSequence : 0, ), - (event) => runProjectorForEvent(projector, event), + (event) => runProjectorForEvent(projector, event, false), ), ), ); const projectEvent: OrchestrationProjectionPipelineShape["projectEvent"] = (event) => - Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event), { + Effect.forEach(projectors, (projector) => runProjectorForEvent(projector, event, true), { concurrency: 1, }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), + Effect.provideService(ProjectionThreadMessageRepository, projectionThreadMessageRepository), Effect.asVoid, Effect.catchTag("SqlError", (sqlError) => Effect.fail(toPersistenceSqlError("ProjectionPipeline.projectEvent:query")(sqlError)), ), ); - const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.forEach( - projectors, - bootstrapProjector, - { concurrency: 1 }, - ).pipe( + const bootstrap: OrchestrationProjectionPipelineShape["bootstrap"] = Effect.gen(function* () { + yield* Effect.forEach(projectors, bootstrapProjector, { concurrency: 1 }); + yield* Ref.set(bootstrapComplete, true); + yield* withTextAttachmentMutationLock( + reconcileGeneratedAttachments(projectionThreadMessageRepository), + ).pipe( + Effect.catch((cause) => + Effect.logWarning("failed to reconcile generated attachments", { cause }), + ), + ); + }).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), Effect.provideService(ServerConfig, serverConfig), + Effect.provideService(ProjectionThreadMessageRepository, projectionThreadMessageRepository), Effect.asVoid, Effect.tap(() => Effect.logDebug("orchestration projection pipeline bootstrapped").pipe( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 71919166886..0c1f988c3c7 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -137,6 +137,27 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const listRetainedProjectionThreadMessageRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadMessageDbRowSchema, + execute: () => sql` + SELECT + messages.message_id AS "messageId", + messages.thread_id AS "threadId", + messages.turn_id AS "turnId", + messages.role, + messages.text, + messages.attachments_json AS "attachments", + messages.is_streaming AS "isStreaming", + messages.created_at AS "createdAt", + messages.updated_at AS "updatedAt" + FROM projection_thread_messages AS messages + INNER JOIN projection_threads AS threads ON threads.thread_id = messages.thread_id + WHERE threads.deleted_at IS NULL + ORDER BY messages.created_at ASC, messages.message_id ASC + `, + }); + const deleteProjectionThreadMessageRows = SqlSchema.void({ Request: DeleteProjectionThreadMessagesInput, execute: ({ threadId }) => @@ -167,6 +188,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const listRetained: ProjectionThreadMessageRepositoryShape["listRetained"] = () => + listRetainedProjectionThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.listRetained:query"), + ), + Effect.map((rows) => rows.map(toProjectionThreadMessage)), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -178,6 +207,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { upsert, getByMessageId, listByThreadId, + listRetained, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff320256..f8a30146e3f 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -78,6 +78,12 @@ export interface ProjectionThreadMessageRepositoryShape { input: ListProjectionThreadMessagesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** List messages belonging to threads that have not been deleted. */ + readonly listRetained: () => Effect.Effect< + ReadonlyArray, + ProjectionRepositoryError + >; + /** * Delete projected thread messages by thread. */ diff --git a/apps/server/src/textAttachmentMutationLock.ts b/apps/server/src/textAttachmentMutationLock.ts new file mode 100644 index 00000000000..7367145f12b --- /dev/null +++ b/apps/server/src/textAttachmentMutationLock.ts @@ -0,0 +1,10 @@ +import * as Effect from "effect/Effect"; +import * as Semaphore from "effect/Semaphore"; + +const lock = Effect.runSync(Semaphore.make(1)); + +export function withTextAttachmentMutationLock( + effect: Effect.Effect, +): Effect.Effect { + return lock.withPermits(1)(effect); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fafdbfa6814..b0da1e5a308 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -50,6 +51,9 @@ import { FilesystemBrowseError, AssetWorkspaceContextNotFoundError, AssetWorkspaceContextResolutionError, + AssetTextAttachmentWriteError, + AssetTextAttachmentClaimError, + AssetTextAttachmentReleaseError, EnvironmentAuthorizationError, ThreadId, type TerminalAttachStreamEvent, @@ -84,6 +88,11 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; +import { + claimTextAttachment, + releaseTextAttachment, + writeClaimedTextAttachment, +} from "./attachmentStore.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; @@ -99,6 +108,7 @@ import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; +import { withTextAttachmentMutationLock } from "./textAttachmentMutationLock.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -308,6 +318,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], [WS_METHODS.assetsCreateUrl, AuthOrchestrationReadScope], + [WS_METHODS.assetsWriteTextAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.assetsClaimTextAttachment, AuthOrchestrationOperateScope], + [WS_METHODS.assetsReleaseTextAttachment, AuthOrchestrationOperateScope], [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], @@ -1519,6 +1532,64 @@ const makeWsRpcLayer = ( }), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.assetsWriteTextAttachment]: (input) => + observeRpcEffect( + WS_METHODS.assetsWriteTextAttachment, + withTextAttachmentMutationLock( + Effect.try({ + try: () => ({ + path: writeClaimedTextAttachment({ + attachmentsDir: config.attachmentsDir, + fileName: input.fileName, + contents: input.contents, + draftOwnerId: input.draftOwnerId, + }), + }), + catch: (cause) => + new AssetTextAttachmentWriteError({ fileName: input.fileName, cause }), + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.assetsClaimTextAttachment]: (input) => + observeRpcEffect( + WS_METHODS.assetsClaimTextAttachment, + withTextAttachmentMutationLock( + Effect.try({ + try: () => ({ + claimed: claimTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + }), + }), + catch: (cause) => new AssetTextAttachmentClaimError({ path: input.path, cause }), + }), + ), + { "rpc.aggregate": "workspace" }, + ), + [WS_METHODS.assetsReleaseTextAttachment]: (input) => + observeRpcEffect( + WS_METHODS.assetsReleaseTextAttachment, + withTextAttachmentMutationLock( + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + return yield* Effect.try({ + try: () => ({ + released: releaseTextAttachment({ + attachmentsDir: config.attachmentsDir, + path: input.path, + draftOwnerId: input.draftOwnerId, + nowMs, + }), + }), + catch: (cause) => + new AssetTextAttachmentReleaseError({ path: input.path, cause }), + }); + }), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.subscribeVcsStatus]: (input) => observeRpcStream( WS_METHODS.subscribeVcsStatus, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index b04e98a8a60..96046ae840e 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -65,6 +65,7 @@ import { resolveMarkdownFileLinkMeta, rewriteMarkdownFileUriHref, } from "../markdown-links"; +import { isTextAttachmentPath } from "../textAttachmentPaths"; import { readLocalApi } from "../localApi"; import { cn } from "../lib/utils"; import { useRightPanelStore } from "../rightPanelStore"; @@ -1476,6 +1477,7 @@ function ChatMarkdown({ onOpenInBrowser={ threadRef && isPreviewSupportedInRuntime() && + !isTextAttachmentPath(fileLinkMeta.filePath) && isBrowserPreviewFile(fileLinkMeta.filePath) ? () => openMarkdownFileInPreview(fileLinkMeta.filePath) : undefined diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 0a0103df183..730b84adf42 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -14,6 +14,7 @@ import { reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, resolveSendEnvMode, + shouldReleaseTextAttachmentClaims, shouldWriteThreadErrorToCurrentServerThread, } from "./ChatView.logic"; @@ -22,6 +23,20 @@ const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); const now = "2026-03-29T00:00:00.000Z"; +describe("text attachment claim send ordering", () => { + it("retains claims throughout slow worktree preparation", () => { + expect(shouldReleaseTextAttachmentClaims(false)).toBe(false); + }); + + it("retains claims after metadata or other pre-send failures", () => { + expect(shouldReleaseTextAttachmentClaims(false)).toBe(false); + }); + + it("releases claims only after turn start succeeds", () => { + expect(shouldReleaseTextAttachmentClaims(true)).toBe(true); + }); +}); + function makeThread(overrides: Partial = {}): Thread { return { id: threadId, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 705793ec77e..dd32e86289f 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -460,3 +460,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { input.localDispatch.sessionUpdatedAt !== (session?.updatedAt ?? null) ); } + +export function shouldReleaseTextAttachmentClaims(turnStartSucceeded: boolean): boolean { + return turnStartSucceeded; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..fb4eab1fa8b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -231,6 +231,7 @@ import { readFileAsDataUrl, reconcileMountedTerminalThreadIds, resolveSendEnvMode, + shouldReleaseTextAttachmentClaims, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, waitForStartedServerThread, @@ -3923,13 +3924,26 @@ function ChatViewContent(props: ChatViewProps) { draftText: trimmed, planMarkdown: activeProposedPlan.planMarkdown, }); + composerRef.current?.holdTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); - await onSubmitPlanFollowUp({ + const sent = await onSubmitPlanFollowUp({ text: followUp.text, interactionMode: followUp.interactionMode, }); + if (sent) { + composerRef.current?.releaseTextAttachmentClaims(); + } else { + promptRef.current = promptForSend; + setComposerDraftPrompt(composerDraftTarget, promptForSend); + composerRef.current?.resumeTextAttachmentClaims(); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), + prompt: promptForSend, + detectTrigger: true, + }); + } return; } const standaloneSlashCommand = @@ -3942,6 +3956,7 @@ function ChatViewContent(props: ChatViewProps) { : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); + composerRef.current?.releaseTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -4067,6 +4082,7 @@ function ChatViewContent(props: ChatViewProps) { }), ); } + composerRef.current?.holdTextAttachmentClaims(); promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -4185,6 +4201,9 @@ function ChatViewContent(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + if (shouldReleaseTextAttachmentClaims(turnStartSucceeded)) { + composerRef.current?.releaseTextAttachmentClaims(); + } } } @@ -4231,6 +4250,7 @@ function ChatViewContent(props: ChatViewProps) { error instanceof Error ? error.message : "Failed to send message.", ); } + composerRef.current?.resumeTextAttachmentClaims(); } sendInFlightRef.current = false; if (!turnStartSucceeded) { @@ -4430,17 +4450,17 @@ function ChatViewContent(props: ChatViewProps) { isConnecting || sendInFlightRef.current ) { - return; + return false; } const trimmed = text.trim(); if (!trimmed) { - return; + return false; } const sendCtx = composerRef.current?.getSendContext(); if (!sendCtx) { - return; + return false; } const { selectedProvider: ctxSelectedProvider, @@ -4548,7 +4568,7 @@ function ChatViewContent(props: ChatViewProps) { } } sendInFlightRef.current = false; - return; + return true; } setOptimisticUserMessages((existing) => @@ -4563,6 +4583,7 @@ function ChatViewContent(props: ChatViewProps) { } sendInFlightRef.current = false; resetLocalDispatch(); + return false; }, [ activeThread, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 574e33d4dab..32eb5a94401 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,6 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { createThreadJumpHintVisibilityController, + getProjectRemovalThreadRefs, + getProjectRemovalConfirmationMessage, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, @@ -17,6 +19,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, + runStableProjectRemovalConfirmation, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, THREAD_JUMP_HINT_SHOW_DELAY_MS, @@ -28,6 +31,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -37,6 +41,96 @@ import { const localEnvironmentId = EnvironmentId.make("environment-local"); +describe("getProjectRemovalThreadRefs", () => { + it("includes archived server threads with composer drafts", () => { + const projectId = ProjectId.make("project-a"); + const liveThreadId = ThreadId.make("thread-live"); + const archivedThreadId = ThreadId.make("thread-archived"); + + expect( + getProjectRemovalThreadRefs({ + environmentId: localEnvironmentId, + projectId, + liveThreads: [{ environmentId: localEnvironmentId, id: liveThreadId, projectId }], + archivedThreads: [ + { environmentId: localEnvironmentId, id: archivedThreadId, projectId }, + { environmentId: localEnvironmentId, id: liveThreadId, projectId }, + { + environmentId: localEnvironmentId, + id: ThreadId.make("thread-other-project"), + projectId: ProjectId.make("project-b"), + }, + ], + }), + ).toEqual([ + scopeThreadRef(localEnvironmentId, liveThreadId), + scopeThreadRef(localEnvironmentId, archivedThreadId), + ]); + }); + + it("uses permanent-history copy for an archived-only project", () => { + const message = getProjectRemovalConfirmationMessage({ + title: "Archived project", + workspaceRoot: "/workspace/archived", + environmentLabel: null, + threadCount: 1, + }); + + expect(message).toContain("delete its 1 thread?"); + expect(message).toContain("permanently clears conversation history"); + expect(message).toContain("cannot be undone"); + }); +}); + +describe("runStableProjectRemovalConfirmation", () => { + it("does not remove when a second snapshot adds an archived thread", async () => { + const firstThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-first")); + const archivedThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-archived")); + const readSnapshot = vi + .fn() + .mockResolvedValueOnce({ threadRefs: [firstThreadRef], value: "before" }) + .mockResolvedValueOnce({ + threadRefs: [firstThreadRef, archivedThreadRef], + value: "after", + }); + const remove = vi.fn(async () => undefined); + + const result = await runStableProjectRemovalConfirmation({ + readSnapshot, + confirm: vi.fn(async () => true), + remove, + }); + + expect(result).toEqual({ status: "changed" }); + expect(remove).not.toHaveBeenCalled(); + }); + + it("removes when the exact scoped thread set is unchanged", async () => { + const firstThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-first")); + const secondThreadRef = scopeThreadRef(localEnvironmentId, ThreadId.make("thread-second")); + const readSnapshot = vi + .fn() + .mockResolvedValueOnce({ + threadRefs: [firstThreadRef, secondThreadRef], + value: "before", + }) + .mockResolvedValueOnce({ + threadRefs: [secondThreadRef, firstThreadRef], + value: "after", + }); + const remove = vi.fn(async (snapshot: { value: string }) => snapshot.value); + + const result = await runStableProjectRemovalConfirmation({ + readSnapshot, + confirm: vi.fn(async () => true), + remove, + }); + + expect(result).toEqual({ status: "removed", result: "after" }); + expect(remove).toHaveBeenCalledOnce(); + }); +}); + describe("resolveSidebarStageBadgeLabel", () => { it("returns Nightly for nightly primary server versions", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4e7614ed551..4328a00b306 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -10,6 +10,8 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; import { resolveServerBackedAppStageLabel } from "../branding.logic"; +import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import type { EnvironmentId, ProjectId, ScopedThreadRef, ThreadId } from "@t3tools/contracts"; export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 100; @@ -26,6 +28,92 @@ type SidebarProject = { export type ThreadTraversalDirection = "previous" | "next"; +export function getProjectRemovalThreadRefs(input: { + environmentId: EnvironmentId; + projectId: ProjectId; + liveThreads: ReadonlyArray<{ + environmentId: EnvironmentId; + id: ThreadId; + projectId: ProjectId; + }>; + archivedThreads: ReadonlyArray<{ + environmentId: EnvironmentId; + id: ThreadId; + projectId: ProjectId; + }>; +}): ScopedThreadRef[] { + const refs = new Map(); + for (const thread of [...input.liveThreads, ...input.archivedThreads]) { + if (thread.environmentId !== input.environmentId || thread.projectId !== input.projectId) { + continue; + } + const ref = scopeThreadRef(thread.environmentId, thread.id); + refs.set(scopedThreadKey(ref), ref); + } + return [...refs.values()]; +} + +export function getProjectRemovalConfirmationMessage(input: { + title: string; + workspaceRoot: string; + environmentLabel: string | null; + threadCount: number; +}): string { + const context = [ + `Path: ${input.workspaceRoot}`, + ...(input.environmentLabel ? [`Environment: ${input.environmentLabel}`] : []), + ]; + if (input.threadCount > 0) { + return [ + `Remove project "${input.title}" and delete its ${input.threadCount} thread${ + input.threadCount === 1 ? "" : "s" + }?`, + ...context, + "This permanently clears conversation history for those threads.", + "This removes only this project entry.", + "This action cannot be undone.", + ].join("\n"); + } + return [ + `Remove project "${input.title}"?`, + ...context, + "This removes only this project entry.", + ].join("\n"); +} + +export async function runStableProjectRemovalConfirmation(input: { + readSnapshot: () => Promise<{ + readonly threadRefs: ReadonlyArray; + readonly value: TSnapshot; + } | null>; + confirm: (snapshot: { + readonly threadRefs: ReadonlyArray; + readonly value: TSnapshot; + }) => Promise; + remove: (snapshot: { + readonly threadRefs: ReadonlyArray; + readonly value: TSnapshot; + }) => Promise; +}): Promise< + | { readonly status: "unavailable" | "cancelled" | "changed" } + | { readonly status: "removed"; readonly result: TResult } +> { + const confirmedSnapshot = await input.readSnapshot(); + if (confirmedSnapshot === null) return { status: "unavailable" }; + if (!(await input.confirm(confirmedSnapshot))) return { status: "cancelled" }; + const latestSnapshot = await input.readSnapshot(); + if (latestSnapshot === null) return { status: "unavailable" }; + const confirmedKeys = new Set(confirmedSnapshot.threadRefs.map(scopedThreadKey)); + const latestKeys = new Set(latestSnapshot.threadRefs.map(scopedThreadKey)); + if ( + confirmedKeys.size !== latestKeys.size || + [...confirmedKeys].some((key) => !latestKeys.has(key)) + ) { + return { status: "changed" }; + } + return { status: "removed", result: await input.remove(latestSnapshot) }; +} + export interface ThreadStatusPill { label: | "Working" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 21525b56b77..5b3ee19bb18 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -76,6 +76,10 @@ import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstra import { isElectron } from "../env"; import { APP_STAGE_LABEL } from "../branding"; import { useOpenPrLink } from "../lib/openPullRequestLink"; +import { + refreshAndReadArchivedThreadSnapshots, + useArchivedThreadSnapshots, +} from "../lib/archivedThreadsState"; import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { @@ -108,16 +112,26 @@ import { import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; +import { composerDraftTargetsProject, useComposerDraftStore } from "../composerDraftStore"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; +import { assetEnvironment } from "../state/assets"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + tombstoneTextAttachmentUploadOwner, + detachedTextAttachmentReleaseComplete, + fenceTextAttachmentUploadOwner, + releaseTextAttachmentClaimsInBackground, + resumeTextAttachmentUploadOwner, + textAttachmentClaims, + textAttachmentDraftOwnerId, +} from "../textAttachmentClaims"; import { buildThreadRouteParams, resolveThreadRouteRef, @@ -185,6 +199,8 @@ import { useThreadSelectionStore } from "../threadSelectionStore"; import { useOpenAddProjectCommandPalette } from "../commandPaletteContext"; import { getSidebarThreadIdsToPrewarm, + getProjectRemovalThreadRefs, + getProjectRemovalConfirmationMessage, resolveAdjacentThreadId, isContextMenuPointerDown, isTrailingDoubleClick, @@ -194,6 +210,7 @@ import { resolveSidebarStageBadgeLabel, resolveThreadRowClassName, resolveThreadStatusPill, + runStableProjectRemovalConfirmation, orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, @@ -1113,6 +1130,9 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { + reportFailure: false, + }); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false, }); @@ -1190,6 +1210,38 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const sidebarThreadByKeyRef = useRef(sidebarThreadByKey); sidebarThreadByKeyRef.current = sidebarThreadByKey; const projectThreads = sidebarThreads; + const archivedEnvironmentIds = useMemo( + () => [...new Set(project.memberProjects.map((member) => member.environmentId))], + [project.memberProjects], + ); + const { + snapshots: archivedSnapshots, + isLoading: isLoadingArchivedThreads, + error: archivedThreadsError, + refresh: refreshArchivedThreads, + } = useArchivedThreadSnapshots(archivedEnvironmentIds); + const archivedThreads = useMemo( + () => + archivedSnapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ), + [archivedSnapshots], + ); + const readLatestArchivedThreads = useCallback(async () => { + const result = await refreshAndReadArchivedThreadSnapshots(archivedEnvironmentIds); + if (result.error !== null) { + refreshArchivedThreads(); + toastManager.add({ + type: "warning", + title: "Could not refresh archived threads", + description: "Try removing the project again in a moment.", + }); + return null; + } + return result.snapshots.flatMap(({ environmentId, snapshot }) => + snapshot.threads.map((thread) => ({ ...thread, environmentId })), + ); + }, [archivedEnvironmentIds, refreshArchivedThreads]); const projectPreferenceKeys = useMemo(() => projectExpansionPreferenceKeys(project), [project]); const projectExpanded = useUiStateStore((state) => resolveProjectExpanded(state.projectExpandedById, projectPreferenceKeys), @@ -1233,7 +1285,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const counts = new Map( project.memberProjects.map((member) => [member.physicalProjectKey, 0] as const), ); - for (const thread of projectThreads) { + for (const thread of [...projectThreads, ...archivedThreads]) { const member = memberProjectByScopedKey.get( scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), ); @@ -1243,7 +1295,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec counts.set(member.physicalProjectKey, (counts.get(member.physicalProjectKey) ?? 0) + 1); } return counts; - }, [memberProjectByScopedKey, project.memberProjects, projectThreads]); + }, [archivedThreads, memberProjectByScopedKey, project.memberProjects, projectThreads]); const { projectStatus, visibleProjectThreads, orderedProjectThreadKeys } = useMemo(() => { const lastVisitedAtByThreadKey = new Map( @@ -1435,8 +1487,30 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const removeProject = useCallback( - async (member: SidebarProjectGroupMember, options: { force?: boolean } = {}) => { + async ( + member: SidebarProjectGroupMember, + options: { + force?: boolean; + archivedThreads?: Parameters[0]["archivedThreads"]; + } = {}, + ) => { const memberProjectRef = scopeProjectRef(member.environmentId, member.id); + const draftStore = useComposerDraftStore.getState(); + const projectThreadRefs = getProjectRemovalThreadRefs({ + environmentId: member.environmentId, + projectId: member.id, + liveThreads: projectThreads, + archivedThreads: options.archivedThreads ?? archivedThreads, + }); + const discardedTargets = composerDraftTargetsProject(memberProjectRef, projectThreadRefs); + const discardedClaims = discardedTargets.flatMap((target) => + textAttachmentClaims(target, draftStore.getComposerDraft(target)?.prompt ?? ""), + ); + await Promise.all( + discardedTargets.map((target) => + fenceTextAttachmentUploadOwner(member.environmentId, textAttachmentDraftOwnerId(target)), + ), + ); const result = await deleteProject({ environmentId: member.environmentId, input: { @@ -1445,17 +1519,34 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }, }); if (result._tag === "Failure") { + for (const target of discardedTargets) { + resumeTextAttachmentUploadOwner(member.environmentId, textAttachmentDraftOwnerId(target)); + } return result; } - const draftStore = useComposerDraftStore.getState(); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(memberProjectRef); - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); + await releaseTextAttachmentClaimsInBackground({ + environmentId: member.environmentId, + claims: discardedClaims, + draftOwnerIds: discardedTargets.map(textAttachmentDraftOwnerId), + release: async ({ path, draftOwnerId }) => { + const result = await releaseTextAttachment({ + environmentId: member.environmentId, + input: { path, draftOwnerId }, + }); + return detachedTextAttachmentReleaseComplete(result); + }, + }); + for (const target of discardedTargets) { + draftStore.clearDraftThread(target); + tombstoneTextAttachmentUploadOwner( + member.environmentId, + textAttachmentDraftOwnerId(target), + ); } draftStore.clearProjectDraftThreadId(memberProjectRef); return result; }, - [deleteProject], + [archivedThreads, deleteProject, projectThreads, releaseTextAttachment], ); const handleRemoveProject = useCallback( @@ -1464,6 +1555,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!api) { return; } + if (isLoadingArchivedThreads || archivedThreadsError !== null) { + if (archivedThreadsError !== null) refreshArchivedThreads(); + toastManager.add({ + type: "warning", + title: "Archived threads are still loading", + description: "Try removing the project again in a moment.", + }); + return; + } const memberProjectRef = scopeProjectRef(member.environmentId, member.id); const memberThreadCount = memberThreadCountByPhysicalKey.get(member.physicalProjectKey) ?? 0; @@ -1483,41 +1583,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec window.setTimeout(resolve, 180); }); - const latestProjectThreads = Array.from( - sidebarThreadByKeyRef.current.values(), - ).filter( - (thread) => - thread.environmentId === memberProjectRef.environmentId && - thread.projectId === memberProjectRef.projectId, - ); - const confirmed = await api.dialogs.confirm( - latestProjectThreads.length > 0 - ? [ - `Remove project "${member.title}" and delete its ${latestProjectThreads.length} thread${ - latestProjectThreads.length === 1 ? "" : "s" - }?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This permanently clears conversation history for those threads.", - "This removes only this project entry.", - "This action cannot be undone.", - ].join("\n") - : [ - `Remove project "${member.title}"?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel - ? [`Environment: ${member.environmentLabel}`] - : []), - "This removes only this project entry.", - ].join("\n"), - ); - if (!confirmed) { + const outcome = await runStableProjectRemovalConfirmation({ + readSnapshot: async () => { + const latestArchivedThreads = await readLatestArchivedThreads(); + if (latestArchivedThreads === null) return null; + return { + threadRefs: getProjectRemovalThreadRefs({ + environmentId: memberProjectRef.environmentId, + projectId: memberProjectRef.projectId, + liveThreads: Array.from(sidebarThreadByKeyRef.current.values()), + archivedThreads: latestArchivedThreads, + }), + value: latestArchivedThreads, + }; + }, + confirm: ({ threadRefs }) => + api.dialogs.confirm( + getProjectRemovalConfirmationMessage({ + title: member.title, + workspaceRoot: member.workspaceRoot, + environmentLabel: member.environmentLabel, + threadCount: threadRefs.length, + }), + ), + remove: ({ value: latestArchivedThreads }) => + removeProject(member, { + force: true, + archivedThreads: latestArchivedThreads, + }), + }); + if (outcome.status === "changed") { + toastManager.add({ + type: "warning", + title: "Project threads changed", + description: "Review the updated thread count and confirm removal again.", + }); return; } - - const result = await removeProject(member, { force: true }); + if (outcome.status !== "removed") return; + const result = outcome.result; if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( @@ -1554,18 +1658,45 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } - const message = [ - `Remove project "${member.title}"?`, - `Path: ${member.workspaceRoot}`, - ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []), - "This removes only this project entry.", - ].join("\n"); - const confirmed = await api.dialogs.confirm(message); - if (!confirmed) { + const outcome = await runStableProjectRemovalConfirmation({ + readSnapshot: async () => { + const latestArchivedThreads = await readLatestArchivedThreads(); + if (latestArchivedThreads === null) return null; + return { + threadRefs: getProjectRemovalThreadRefs({ + environmentId: memberProjectRef.environmentId, + projectId: memberProjectRef.projectId, + liveThreads: Array.from(sidebarThreadByKeyRef.current.values()), + archivedThreads: latestArchivedThreads, + }), + value: latestArchivedThreads, + }; + }, + confirm: ({ threadRefs }) => + api.dialogs.confirm( + getProjectRemovalConfirmationMessage({ + title: member.title, + workspaceRoot: member.workspaceRoot, + environmentLabel: member.environmentLabel, + threadCount: threadRefs.length, + }), + ), + remove: ({ threadRefs, value: latestArchivedThreads }) => + removeProject(member, { + ...(threadRefs.length > 0 ? { force: true } : {}), + archivedThreads: latestArchivedThreads, + }), + }); + if (outcome.status === "changed") { + toastManager.add({ + type: "warning", + title: "Project threads changed", + description: "Review the updated thread count and confirm removal again.", + }); return; } - - const result = await removeProject(member); + if (outcome.status !== "removed") return; + const result = outcome.result; if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); const message = error instanceof Error ? error.message : "Unknown error removing project."; @@ -1583,7 +1714,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); } }, - [memberThreadCountByPhysicalKey, removeProject], + [ + archivedThreadsError, + isLoadingArchivedThreads, + memberThreadCountByPhysicalKey, + readLatestArchivedThreads, + refreshArchivedThreads, + removeProject, + ], ); const handleProjectButtonContextMenu = useCallback( diff --git a/apps/web/src/components/TextAttachmentClaimLifecycle.test.ts b/apps/web/src/components/TextAttachmentClaimLifecycle.test.ts new file mode 100644 index 00000000000..f505d540134 --- /dev/null +++ b/apps/web/src/components/TextAttachmentClaimLifecycle.test.ts @@ -0,0 +1,65 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + pendingTextAttachmentReleasesEnvironment, + persistPendingTextAttachmentReleases, + useComposerDraftStore, +} from "../composerDraftStore"; +import { resetTextAttachmentClaimRegistryForTest } from "../textAttachmentClaims"; +import { reconcileConnectedTextAttachmentClaimEnvironments } from "./TextAttachmentClaimLifecycle"; + +afterEach(() => { + resetTextAttachmentClaimRegistryForTest(); + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); +}); + +describe("TextAttachmentClaimLifecycle", () => { + it("restores a background environment outbox without a mounted composer", async () => { + const visibleEnvironmentId = EnvironmentId.make("visible-environment"); + const backgroundEnvironmentId = EnvironmentId.make("background-environment"); + const pendingRelease = { + environmentId: backgroundEnvironmentId, + path: "/tmp/background-attachment.txt", + draftOwnerId: "thread:background:deleted", + }; + persistPendingTextAttachmentReleases([pendingRelease]); + resetTextAttachmentClaimRegistryForTest(); + const release = vi.fn( + async (_environmentId: EnvironmentId, _path: string, _draftOwnerId: string) => true, + ); + const operationsForEnvironment = (environmentId: EnvironmentId) => ({ + claim: vi.fn(async () => true), + release: (path: string, draftOwnerId: string) => release(environmentId, path, draftOwnerId), + }); + + reconcileConnectedTextAttachmentClaimEnvironments( + [ + { environmentId: visibleEnvironmentId, connection: { phase: "connected" } }, + { environmentId: backgroundEnvironmentId, connection: { phase: "connecting" } }, + ], + operationsForEnvironment, + ); + expect(release).not.toHaveBeenCalled(); + expect(pendingTextAttachmentReleasesEnvironment(backgroundEnvironmentId)).toEqual([ + pendingRelease, + ]); + + reconcileConnectedTextAttachmentClaimEnvironments( + [ + { environmentId: visibleEnvironmentId, connection: { phase: "connected" } }, + { environmentId: backgroundEnvironmentId, connection: { phase: "connected" } }, + ], + operationsForEnvironment, + ); + await vi.waitFor(() => + expect(pendingTextAttachmentReleasesEnvironment(backgroundEnvironmentId)).toEqual([]), + ); + + expect(release).toHaveBeenCalledWith( + backgroundEnvironmentId, + pendingRelease.path, + pendingRelease.draftOwnerId, + ); + }); +}); diff --git a/apps/web/src/components/TextAttachmentClaimLifecycle.tsx b/apps/web/src/components/TextAttachmentClaimLifecycle.tsx new file mode 100644 index 00000000000..27bf1de36e0 --- /dev/null +++ b/apps/web/src/components/TextAttachmentClaimLifecycle.tsx @@ -0,0 +1,83 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { useCallback, useEffect, useRef } from "react"; + +import { composerDraftEntriesEnvironment } from "../composerDraftStore"; +import { useEnvironments } from "../state/environments"; +import { assetEnvironment } from "../state/assets"; +import { useAtomCommand } from "../state/use-atom-command"; +import { + reconcileTextAttachmentClaimsEnvironment, + type TextAttachmentClaimOperations, +} from "../textAttachmentClaims"; + +interface TextAttachmentClaimLifecycleEnvironment { + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; +} + +export function reconcileConnectedTextAttachmentClaimEnvironments( + environments: ReadonlyArray, + operationsForEnvironment: (environmentId: EnvironmentId) => TextAttachmentClaimOperations, + forceEnvironmentIds: ReadonlySet = new Set(), +): void { + for (const environment of environments) { + if (environment.connection.phase !== "connected") continue; + reconcileTextAttachmentClaimsEnvironment( + environment.environmentId, + composerDraftEntriesEnvironment(environment.environmentId), + operationsForEnvironment(environment.environmentId), + { force: forceEnvironmentIds.has(environment.environmentId) }, + ); + } +} + +export function TextAttachmentClaimLifecycle() { + const { environments } = useEnvironments(); + const connectedEnvironmentIdsRef = useRef(new Set()); + const claimTextAttachment = useAtomCommand(assetEnvironment.claimTextAttachment, { + reportFailure: false, + }); + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { + reportFailure: false, + }); + const operationsForEnvironment = useCallback( + (environmentId: EnvironmentId): TextAttachmentClaimOperations => ({ + claim: async (path, draftOwnerId) => { + const result = await claimTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success" && result.value.claimed; + }, + release: async (path, draftOwnerId) => { + const result = await releaseTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success"; + }, + }), + [claimTextAttachment, releaseTextAttachment], + ); + + useEffect(() => { + const connectedEnvironmentIds = new Set( + environments.flatMap((environment) => + environment.connection.phase === "connected" ? [environment.environmentId] : [], + ), + ); + const newlyConnectedEnvironmentIds = new Set( + [...connectedEnvironmentIds].filter( + (environmentId) => !connectedEnvironmentIdsRef.current.has(environmentId), + ), + ); + reconcileConnectedTextAttachmentClaimEnvironments( + environments, + operationsForEnvironment, + newlyConnectedEnvironmentIds, + ); + connectedEnvironmentIdsRef.current = connectedEnvironmentIds; + }, [environments, operationsForEnvironment]); + + return null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f9aec837ca..6c4ffc895b6 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -76,6 +76,7 @@ import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; import { resolveComposerMenuActiveItemId } from "./composerMenuHighlight"; +import { currentComposerImageCount } from "./composerAttachmentBatch"; import { searchSlashCommandItems } from "./composerSlashCommandSearch"; import { getComposerPromptInjectionState, @@ -86,6 +87,13 @@ import { import { ContextWindowMeter } from "./ContextWindowMeter"; import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; +import { + textAttachmentClaimChanges, + textAttachmentDraftOwnerId, + getTextAttachmentClaimReconciler as getRegisteredTextAttachmentClaimReconciler, + releaseTextAttachmentClaimsInBackground, + runTextAttachmentUpload, +} from "../../textAttachmentClaims"; import { cn, randomUUID } from "~/lib/utils"; import { Separator } from "../ui/separator"; import { Button } from "../ui/button"; @@ -123,6 +131,18 @@ import { } from "../../lib/contextWindow"; import { formatProviderSkillDisplayName } from "../../providerSkillPresentation"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { assetEnvironment } from "~/state/assets"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { resolvePathLinkTarget } from "~/terminal-links"; + +const TEXT_ATTACHMENT_MAX_BYTES = 1024 * 1024; + +function composerAttachmentTargetKey(target: ScopedThreadRef | DraftId): string { + return typeof target === "string" + ? `draft:${target}` + : `thread:${target.environmentId}:${target.threadId}`; +} + import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; @@ -407,6 +427,12 @@ export interface ChatComposerHandle { }) => void; /** Insert a terminal context from the terminal drawer. */ addTerminalContext: (selection: TerminalContextSelection) => void; + /** Release draft claims before the parent destructively clears composer content. */ + releaseTextAttachmentClaims: () => void; + /** Keep claims alive while a send is preparing and may still fail. */ + holdTextAttachmentClaims: () => void; + /** Resume prompt-driven claim synchronization after a failed send. */ + resumeTextAttachmentClaims: () => void; /** Get the current prompt/effort/model state for use in send. */ getSendContext: () => { prompt: string; @@ -613,6 +639,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ const composerDraft = useComposerThreadDraft(composerDraftTarget); + const writeTextAttachment = useAtomCommand(assetEnvironment.writeTextAttachment, { + reportFailure: false, + }); + const claimTextAttachment = useAtomCommand(assetEnvironment.claimTextAttachment, { + reportFailure: false, + }); + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { + reportFailure: false, + }); const prompt = composerDraft.prompt; const composerImages = composerDraft.images; const composerTerminalContexts = composerDraft.terminalContexts; @@ -881,8 +916,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const [isComposerFocused, setIsComposerFocused] = useState(false); + const [pendingAttachmentCounts, setPendingAttachmentCounts] = useState>( + {}, + ); + const [textAttachmentClaimsHeld, setTextAttachmentClaimsHeld] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isComposerCollapsedMobile = isMobileViewport && !isComposerFocused; + const composerAttachmentKey = composerAttachmentTargetKey(composerDraftTarget); + const isAttachingFiles = (pendingAttachmentCounts[composerAttachmentKey] ?? 0) > 0; // ------------------------------------------------------------------ // Refs @@ -899,6 +940,41 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); const dragDepthRef = useRef(0); + const attachmentQueueRef = useRef>(Promise.resolve()); + const composerAttachmentKeyRef = useRef(composerAttachmentKey); + composerAttachmentKeyRef.current = composerAttachmentKey; + const textAttachmentClaimOperations = useMemo( + () => ({ + claim: async (path: string, draftOwnerId: string) => { + const result = await claimTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success" && result.value.claimed; + }, + release: async (path: string, draftOwnerId: string) => { + const result = await releaseTextAttachment({ + environmentId, + input: { path, draftOwnerId }, + }); + return result._tag === "Success"; + }, + }), + [claimTextAttachment, environmentId, releaseTextAttachment], + ); + const getTextAttachmentClaimReconciler = useCallback(() => { + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + return getRegisteredTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations: textAttachmentClaimOperations, + }); + }, [composerDraftTarget, environmentId, textAttachmentClaimOperations]); + + useEffect(() => { + if (textAttachmentClaimsHeld) return; + getTextAttachmentClaimReconciler().setDesiredPrompt(prompt); + }, [getTextAttachmentClaimReconciler, prompt, textAttachmentClaimsHeld]); // ------------------------------------------------------------------ // Derived: composer send state @@ -1135,7 +1211,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isConnecting || !composerSendState.hasSendableContent; + phase === "running" || + isSendBusy || + isAttachingFiles || + isConnecting || + !composerSendState.hasSendableContent; const collapsedComposerPrimaryActionLabel = "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1691,12 +1771,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const submitComposer = useCallback( (event?: { preventDefault: () => void }) => { + if (isAttachingFiles) { + event?.preventDefault(); + return; + } onSend(event); if (shouldBlurMobileComposerOnSubmit()) { blurMobileComposerAfterSend(); } }, - [blurMobileComposerAfterSend, onSend, shouldBlurMobileComposerOnSubmit], + [blurMobileComposerAfterSend, isAttachingFiles, onSend, shouldBlurMobileComposerOnSubmit], ); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { @@ -1758,32 +1842,92 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; // ------------------------------------------------------------------ - // Callbacks: images + // Callbacks: attachments // ------------------------------------------------------------------ - const addComposerImages = (files: File[]) => { + const addComposerTextAttachment = async (file: File): Promise => { + if (!gitCwd) return `Could not resolve the workspace path for '${file.name}'.`; + if (file.size > TEXT_ATTACHMENT_MAX_BYTES) { + return `'${file.name}' exceeds the 1 MB text attachment limit.`; + } + const result = await file + .arrayBuffer() + .then((buffer) => { + const bytes = new Uint8Array(buffer); + if (bytes.includes(0)) return null; + const contents = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const draftOwnerId = textAttachmentDraftOwnerId(composerDraftTarget); + return runTextAttachmentUpload({ + environmentId, + draftOwnerId, + upload: () => + writeTextAttachment({ + environmentId, + input: { fileName: file.name || "context.txt", contents, draftOwnerId }, + }), + path: (result) => (result._tag === "Success" ? result.value.path : null), + release: (path) => + releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [{ path, draftOwnerId }], + release: async ({ path: releasePath, draftOwnerId: ownerId }) => { + const released = await releaseTextAttachment({ + environmentId, + input: { path: releasePath, draftOwnerId: ownerId }, + }); + return released._tag === "Success"; + }, + }).then(() => undefined), + }); + }) + .catch(() => null); + if (result === null) return `'${file.name}' is not a supported text file.`; + if (result._tag === "Failure") return `Could not attach '${file.name}'.`; + + getTextAttachmentClaimReconciler().confirmPaths([result.value.path]); + const currentPrompt = getComposerDraft(composerDraftTarget)?.prompt ?? ""; + const separator = currentPrompt.length > 0 && !/\s$/.test(currentPrompt) ? " " : ""; + const nextPrompt = `${currentPrompt}${separator}${serializeComposerFileLink( + resolvePathLinkTarget(result.value.path, gitCwd), + )} `; + if (composerAttachmentKeyRef.current === composerAttachmentKey) { + promptRef.current = nextPrompt; + } + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + return null; + }; + + const addComposerAttachments = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", - title: "Attach images after answering plan questions.", + title: "Attach files after answering plan questions.", }); return; } const nextImages: ComposerImageAttachment[] = []; - let nextImageCount = composerImagesRef.current.length; - let error: string | null = null; + let nextImageCount = currentComposerImageCount( + getComposerDraft(composerDraftTarget), + composerImagesRef.current, + ); + const errors: string[] = []; + let attachedCount = 0; for (const file of files) { if (!file.type.startsWith("image/")) { - error = `Unsupported file type for '${file.name}'. Please attach image files only.`; + const error = await addComposerTextAttachment(file); + if (error) errors.push(error); + else attachedCount += 1; continue; } if (file.size > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - error = `'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`; + errors.push(`'${file.name}' exceeds the ${IMAGE_SIZE_LIMIT_LABEL} attachment limit.`); continue; } if (nextImageCount >= PROVIDER_SEND_TURN_MAX_ATTACHMENTS) { - error = `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`; - break; + errors.push( + `You can attach up to ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} images per message.`, + ); + continue; } const previewUrl = URL.createObjectURL(file); nextImages.push({ @@ -1796,13 +1940,44 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) file, }); nextImageCount += 1; + attachedCount += 1; } if (nextImages.length === 1 && nextImages[0]) { addComposerImage(nextImages[0]); } else if (nextImages.length > 1) { addComposerImagesToDraft(nextImages); } - setThreadError(activeThreadId, error); + if (attachedCount > 0 && errors.length > 0) { + toastManager.add({ + type: "error", + title: "Some files could not be attached", + description: errors.join(" "), + }); + } + setThreadError( + activeThreadId, + attachedCount === 0 ? (errors[errors.length - 1] ?? null) : null, + ); + }; + + const enqueueComposerAttachments = (files: File[]) => { + const targetKey = composerAttachmentTargetKey(composerDraftTarget); + setPendingAttachmentCounts((current) => ({ + ...current, + [targetKey]: (current[targetKey] ?? 0) + 1, + })); + const pending = attachmentQueueRef.current.then(() => addComposerAttachments(files)); + attachmentQueueRef.current = pending.catch(() => undefined); + const settle = () => { + setPendingAttachmentCounts((current) => { + const nextCount = (current[targetKey] ?? 1) - 1; + if (nextCount > 0) return { ...current, [targetKey]: nextCount }; + const { [targetKey]: _settled, ...remaining } = current; + return remaining; + }); + }; + void pending.then(settle, settle); + return pending; }; const removeComposerImage = (imageId: string) => { @@ -1815,10 +1990,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const onComposerPaste = (event: React.ClipboardEvent) => { const files = Array.from(event.clipboardData.files); if (files.length === 0) return; - const imageFiles = files.filter((file) => file.type.startsWith("image/")); - if (imageFiles.length === 0) return; event.preventDefault(); - addComposerImages(imageFiles); + void enqueueComposerAttachments(files); }; const onComposerDragEnter = (event: React.DragEvent) => { @@ -1852,7 +2025,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) dragDepthRef.current = 0; setIsDragOverComposer(false); const files = Array.from(event.dataTransfer.files); - addComposerImages(files); + void enqueueComposerAttachments(files); focusComposer(); }; const handleInterruptPrimaryAction = useCallback(() => { @@ -1994,6 +2167,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerEditorRef.current?.focusAt(nextCollapsedCursor); }); }, + releaseTextAttachmentClaims: () => { + setTextAttachmentClaimsHeld(false); + const reconciler = getTextAttachmentClaimReconciler(); + reconciler.confirmPaths(textAttachmentClaimChanges(new Set(), promptRef.current).nextPaths); + reconciler.setDesiredPaths([]); + }, + holdTextAttachmentClaims: () => { + setTextAttachmentClaimsHeld(true); + }, + resumeTextAttachmentClaims: () => { + setTextAttachmentClaimsHeld(false); + }, getSendContext: () => ({ prompt: promptRef.current, images: composerImagesRef.current, @@ -2015,6 +2200,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerCursor, composerTerminalContexts, insertComposerDraftTerminalContext, + getTextAttachmentClaimReconciler, promptRef, composerImagesRef, composerTerminalContextsRef, @@ -2545,7 +2731,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isRunning={phase === "running"} showPlanFollowUpPrompt={pendingUserInputs.length === 0 && showPlanFollowUpPrompt} promptHasText={prompt.trim().length > 0} - isSendBusy={isSendBusy} + isSendBusy={isSendBusy || isAttachingFiles} isConnecting={isConnecting} isEnvironmentUnavailable={environmentUnavailable !== null} isPreparingWorktree={isPreparingWorktree} diff --git a/apps/web/src/components/chat/FileTagChip.test.tsx b/apps/web/src/components/chat/FileTagChip.test.tsx new file mode 100644 index 00000000000..361744cf3c5 --- /dev/null +++ b/apps/web/src/components/chat/FileTagChip.test.tsx @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { inferFileTagEntryKind } from "./FileTagChip"; + +describe("inferFileTagEntryKind", () => { + it("renders extensionless generated attachments as files", () => { + expect( + inferFileTagEntryKind( + "/Users/test/.t3/attachments/12345678-1234-1234-1234-123456789abc/extensionless-test", + ), + ).toBe("file"); + expect( + inferFileTagEntryKind( + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/extensionless-test", + ), + ).toBe("file"); + expect( + inferFileTagEntryKind( + "/srv/project/.t3/attachments/12345678-1234-1234-1234-123456789abc-extensionless-test", + ), + ).toBe("file"); + }); + + it("preserves directory inference for ordinary extensionless paths", () => { + expect(inferFileTagEntryKind("packages/client-runtime")).toBe("directory"); + }); +}); diff --git a/apps/web/src/components/chat/FileTagChip.tsx b/apps/web/src/components/chat/FileTagChip.tsx index d3cd656b37c..ded409b42bf 100644 --- a/apps/web/src/components/chat/FileTagChip.tsx +++ b/apps/web/src/components/chat/FileTagChip.tsx @@ -1,4 +1,5 @@ import { inferEntryKindFromPath } from "../../pierre-icons"; +import { isTextAttachmentPath } from "../../textAttachmentPaths"; import { CHAT_INLINE_CHIP_CLASS_NAME, CHAT_INLINE_CHIP_LABEL_CLASS_NAME, @@ -11,6 +12,10 @@ import { PierreEntryIcon } from "./PierreEntryIcon"; export const FILE_TAG_CHIP_CLASS_NAME = COMPOSER_INLINE_CHIP_CLASS_NAME; export const CHAT_FILE_TAG_CHIP_CLASS_NAME = CHAT_INLINE_CHIP_CLASS_NAME; +export function inferFileTagEntryKind(path: string): "file" | "directory" { + return isTextAttachmentPath(path) ? "file" : inferEntryKindFromPath(path); +} + export function FileTagChipContent(props: { path: string; label: string; @@ -21,7 +26,7 @@ export function FileTagChipContent(props: { <> diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..b39ebee90dd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -338,6 +338,29 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); + it("does not collapse short visible prompts with long file-link destinations", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const fileLinks = Array.from( + { length: 15 }, + (_, index) => + `[test-${index}.ts](/Users/test/project/.t3/attachments/12345678-1234-1234-1234-123456789abc/test-${index}.ts)`, + ); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("test-0.ts"); + expect(markup).toContain("test-14.ts"); + expect(markup).toContain("tell me the contents of each file"); + expect(markup).toContain('data-user-message-body="true"'); + expect(markup).not.toContain("Show full message"); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..87173caba1f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1375,15 +1375,19 @@ const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; +const USER_MESSAGE_FILE_LINK_PATTERN = /\[((?:\\.|[^\]\\])*)\]\([^)\s]+\)/g; function shouldCollapseUserMessage(text: string): boolean { - if (text.trim().length === 0) { + const visibleText = text.replace(USER_MESSAGE_FILE_LINK_PATTERN, (_source, label: string) => + label.replace(/\\(.)/g, "$1"), + ); + if (visibleText.trim().length === 0) { return false; } return ( - text.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - text.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES + visibleText.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || + visibleText.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES ); } diff --git a/apps/web/src/components/chat/composerAttachmentBatch.test.ts b/apps/web/src/components/chat/composerAttachmentBatch.test.ts new file mode 100644 index 00000000000..fff23353d7e --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentBatch.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { currentComposerImageCount } from "./composerAttachmentBatch"; + +describe("currentComposerImageCount", () => { + it("uses the synchronous draft count between queued batches", () => { + const staleRefImages = [{ id: "first" }]; + const latestDraft = { images: [{ id: "first" }, { id: "second" }] }; + + expect(currentComposerImageCount(latestDraft, staleRefImages)).toBe(2); + }); + + it("falls back to the synchronized ref before a draft exists", () => { + expect(currentComposerImageCount(null, [{ id: "first" }])).toBe(1); + }); +}); diff --git a/apps/web/src/components/chat/composerAttachmentBatch.ts b/apps/web/src/components/chat/composerAttachmentBatch.ts new file mode 100644 index 00000000000..f1dda3e2504 --- /dev/null +++ b/apps/web/src/components/chat/composerAttachmentBatch.ts @@ -0,0 +1,6 @@ +export function currentComposerImageCount( + draft: { readonly images: ReadonlyArray } | null, + fallbackImages: ReadonlyArray, +): number { + return draft?.images.length ?? fallbackImages.length; +} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index bc1b7107306..916dc8386b4 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -60,11 +60,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { COMPOSER_DRAFT_STORAGE_KEY, clearComposerDraftsEnvironment, + composerDraftEntriesEnvironment, + composerDraftPromptsEnvironment, + composerDraftPromptsEnvironmentExcept, + composerDraftTargetsProject, finalizePromotedDraftThreadByRef, markPromotedDraftThread, markPromotedDraftThreadByRef, markPromotedDraftThreads, markPromotedDraftThreadsByRef, + MAX_PENDING_TEXT_ATTACHMENT_RELEASES, + persistPendingTextAttachmentReleases, type ComposerImageAttachment, useComposerDraftStore, DraftId, @@ -76,6 +82,7 @@ import { type TerminalContextDraft, } from "./lib/terminalContext"; import { createDebouncedStorage } from "./lib/storage"; +import { textAttachmentClaims } from "./textAttachmentClaims"; function makeImage(input: { id: string; @@ -149,6 +156,55 @@ function providerModelOptions( } const TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); + +describe("composerDraftStore text attachment release outbox capacity", () => { + beforeEach(() => { + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); + }); + + afterEach(() => { + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + removeLocalStorageItem(COMPOSER_DRAFT_STORAGE_KEY); + vi.restoreAllMocks(); + }); + + it("rejects the newest live append without truncating durable records", async () => { + const existing = Array.from({ length: MAX_PENDING_TEXT_ATTACHMENT_RELEASES }, (_, index) => ({ + environmentId: TEST_ENVIRONMENT_ID, + path: `/tmp/attachment-${index}.txt`, + draftOwnerId: `thread:env:${index}`, + })); + expect(persistPendingTextAttachmentReleases(existing).accepted).toBe(true); + const rejected = { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/rejected-attachment.txt", + draftOwnerId: "thread:env:rejected", + }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + const result = persistPendingTextAttachmentReleases([rejected]); + + expect(result).toEqual({ accepted: false, rejected: [rejected] }); + expect(warn).toHaveBeenCalledOnce(); + expect(useComposerDraftStore.getState().pendingTextAttachmentReleases).toEqual(existing); + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + storage: { + getItem: (key: string) => Promise | unknown; + }; + }; + }; + const stored = (await persistApi.getOptions().storage.getItem(COMPOSER_DRAFT_STORAGE_KEY)) as { + state: { pendingTextAttachmentReleases: unknown[] }; + }; + expect(stored.state.pendingTextAttachmentReleases).toHaveLength( + MAX_PENDING_TEXT_ATTACHMENT_RELEASES, + ); + expect(stored.state.pendingTextAttachmentReleases).not.toContainEqual(rejected); + }); +}); const OTHER_TEST_ENVIRONMENT_ID = EnvironmentId.make("environment-remote"); const LEGACY_TEST_ENVIRONMENT_ID = EnvironmentId.make("__legacy__"); @@ -513,6 +569,24 @@ describe("composerDraftStore terminal contexts", () => { }, draftThreadsByThreadId: "not-an-object", projectDraftThreadIdByProjectKey: "not-an-object", + pendingTextAttachmentReleases: [ + { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/attachment.txt", + draftOwnerId: "thread:env:valid", + }, + { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/attachment.txt", + draftOwnerId: "thread:env:valid", + }, + { environmentId: 42, path: null, draftOwnerId: [] }, + { + environmentId: TEST_ENVIRONMENT_ID, + path: "x".repeat(9_000), + draftOwnerId: "thread:env:oversized", + }, + ], }, useComposerDraftStore.getInitialState(), ); @@ -520,6 +594,13 @@ describe("composerDraftStore terminal contexts", () => { expect(mergedState.draftsByThreadKey[threadKeyFor(threadId)]).toBeUndefined(); expect(mergedState.draftThreadsByThreadKey).toEqual({}); expect(mergedState.logicalProjectDraftThreadKeyByLogicalProjectKey).toEqual({}); + expect(mergedState.pendingTextAttachmentReleases).toEqual([ + { + environmentId: TEST_ENVIRONMENT_ID, + path: "/tmp/attachment.txt", + draftOwnerId: "thread:env:valid", + }, + ]); }); }); @@ -716,6 +797,8 @@ describe("composerDraftStore project draft thread mapping", () => { store.setPrompt(localThreadRef, "local thread draft"); store.setPrompt(remoteThreadRef, "remote thread draft"); + expect(composerDraftPromptsEnvironment(TEST_ENVIRONMENT_ID)).toEqual(["local thread draft"]); + clearComposerDraftsEnvironment(TEST_ENVIRONMENT_ID); const next = useComposerDraftStore.getState(); @@ -731,6 +814,66 @@ describe("composerDraftStore project draft thread mapping", () => { } }); + it("lists stable targets and prompts before a bulk environment clear", () => { + const store = useComposerDraftStore.getState(); + const serverThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, otherThreadId); + const draftPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/draft.txt"; + const threadPath = + "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/thread.txt"; + store.setProjectDraftThreadId(projectRef, draftId, { + threadId: ThreadId.make("draft-thread"), + }); + store.setPrompt(draftId, `[draft.txt](${draftPath})`); + store.setPrompt(serverThreadRef, `[thread.txt](${threadPath})`); + + const entries = composerDraftEntriesEnvironment(TEST_ENVIRONMENT_ID); + expect(entries.flatMap(({ target, prompt }) => textAttachmentClaims(target, prompt))).toEqual([ + { path: draftPath, draftOwnerId: `draft:${draftId}` }, + { + path: threadPath, + draftOwnerId: `thread:${TEST_ENVIRONMENT_ID}:${otherThreadId}`, + }, + ]); + }); + + it("collects live and archived draft targets removed with a forced project deletion", () => { + const store = useComposerDraftStore.getState(); + const projectThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, threadId); + const archivedThreadRef = scopeThreadRef( + TEST_ENVIRONMENT_ID, + ThreadId.make("archived-project-thread"), + ); + const retainedThreadRef = scopeThreadRef(TEST_ENVIRONMENT_ID, otherThreadId); + const projectDraftThreadId = ThreadId.make("project-draft-thread"); + + store.setPrompt(projectThreadRef, "project server-thread draft"); + store.setPrompt(archivedThreadRef, "archived server-thread draft"); + store.setProjectDraftThreadId(projectRef, draftId, { threadId: projectDraftThreadId }); + store.setPrompt(draftId, "project new-thread draft"); + store.setPrompt(retainedThreadRef, "other project draft"); + + const discardedTargets = composerDraftTargetsProject(projectRef, [ + projectThreadRef, + archivedThreadRef, + ]); + expect(discardedTargets).toEqual([projectThreadRef, archivedThreadRef, draftId]); + expect(discardedTargets.map((target) => store.getComposerDraft(target)?.prompt)).toEqual([ + "project server-thread draft", + "archived server-thread draft", + "project new-thread draft", + ]); + expect(composerDraftPromptsEnvironmentExcept(TEST_ENVIRONMENT_ID, discardedTargets)).toEqual([ + "other project draft", + ]); + + for (const target of discardedTargets) store.clearDraftThread(target); + expect(store.getComposerDraft(projectThreadRef)).toBeNull(); + expect(store.getComposerDraft(archivedThreadRef)).toBeNull(); + expect(store.getComposerDraft(draftId)).toBeNull(); + expect(store.getComposerDraft(retainedThreadRef)?.prompt).toBe("other project draft"); + }); + it("stores and reads project draft thread ids via actions", () => { const store = useComposerDraftStore.getState(); expect(store.getDraftThreadByProjectRef(projectRef)).toBeNull(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index fdb8bfe7b18..3681a228d63 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -57,7 +57,7 @@ const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; -const COMPOSER_DRAFT_STORAGE_VERSION = 8; +const COMPOSER_DRAFT_STORAGE_VERSION = 9; const DraftThreadEnvModeSchema = Schema.Literals(["local", "worktree"]); export type DraftThreadEnvMode = typeof DraftThreadEnvModeSchema.Type; @@ -227,6 +227,22 @@ const PersistedDraftThreadState = Schema.Struct({ }); type PersistedDraftThreadState = typeof PersistedDraftThreadState.Type; +export interface PendingTextAttachmentRelease { + readonly environmentId: EnvironmentId; + readonly path: string; + readonly draftOwnerId: string; +} + +const PersistedTextAttachmentRelease = Schema.Struct({ + environmentId: Schema.String, + path: Schema.String, + draftOwnerId: Schema.String, +}); +export const MAX_PENDING_TEXT_ATTACHMENT_RELEASES = 1_000; +const MAX_PENDING_TEXT_ATTACHMENT_ENVIRONMENT_LENGTH = 1_024; +const MAX_PENDING_TEXT_ATTACHMENT_PATH_LENGTH = 8_192; +const MAX_PENDING_TEXT_ATTACHMENT_OWNER_LENGTH = 1_024; + const PersistedComposerDraftStoreState = Schema.Struct({ draftsByThreadKey: Schema.Record(Schema.String, PersistedComposerThreadDraftState), draftThreadsByThreadKey: Schema.Record(Schema.String, PersistedDraftThreadState), @@ -235,6 +251,7 @@ const PersistedComposerDraftStoreState = Schema.Struct({ Schema.Record(ProviderInstanceId, ModelSelection), ), stickyActiveProvider: Schema.optionalKey(Schema.NullOr(ProviderInstanceId)), + pendingTextAttachmentReleases: Schema.optionalKey(Schema.Array(PersistedTextAttachmentRelease)), }); type PersistedComposerDraftStoreState = typeof PersistedComposerDraftStoreState.Type; @@ -315,7 +332,7 @@ interface ProjectDraftSession extends DraftSessionState { * Raw `ThreadId` is intentionally excluded so callers cannot drop environment * identity for real threads. */ -type ComposerThreadTarget = ScopedThreadRef | DraftId; +export type ComposerThreadTarget = ScopedThreadRef | DraftId; /** * Persisted store for composer content plus draft-session metadata. @@ -330,6 +347,7 @@ interface ComposerDraftStoreState { logicalProjectDraftThreadKeyByLogicalProjectKey: Record; stickyModelSelectionByProvider: Partial>; stickyActiveProvider: ProviderInstanceId | null; + pendingTextAttachmentReleases: PendingTextAttachmentRelease[]; /** Returns the editable composer content for a draft session or server thread. */ getComposerDraft: (target: ComposerThreadTarget) => ComposerThreadDraftState | null; /** Looks up the active draft session for a logical project identity. */ @@ -549,6 +567,7 @@ const EMPTY_PERSISTED_DRAFT_STORE_STATE = Object.freeze(); + for (const candidate of value) { + if (releases.length >= MAX_PENDING_TEXT_ATTACHMENT_RELEASES) break; + if (!candidate || typeof candidate !== "object") continue; + const { environmentId, path, draftOwnerId } = candidate as Record; + if ( + typeof environmentId !== "string" || + environmentId.length === 0 || + environmentId.length > MAX_PENDING_TEXT_ATTACHMENT_ENVIRONMENT_LENGTH || + typeof path !== "string" || + path.length === 0 || + path.length > MAX_PENDING_TEXT_ATTACHMENT_PATH_LENGTH || + typeof draftOwnerId !== "string" || + draftOwnerId.length === 0 || + draftOwnerId.length > MAX_PENDING_TEXT_ATTACHMENT_OWNER_LENGTH + ) { + continue; + } + const key = `${environmentId}\u0000${draftOwnerId}\u0000${path}`; + if (seen.has(key)) continue; + seen.add(key); + releases.push({ environmentId: environmentId as EnvironmentId, path, draftOwnerId }); + } + return releases; +} + function partializeComposerDraftStoreState( state: ComposerDraftStoreState, ): PersistedComposerDraftStoreState { @@ -1907,6 +1958,7 @@ function partializeComposerDraftStoreState( state.stickyModelSelectionByProvider, ), stickyActiveProvider: state.stickyActiveProvider, + pendingTextAttachmentReleases: state.pendingTextAttachmentReleases, }; } @@ -1977,6 +2029,9 @@ function normalizeCurrentPersistedComposerDraftStoreState( logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: compactModelSelectionByProvider(stickyModelSelectionByProvider), stickyActiveProvider, + pendingTextAttachmentReleases: normalizePendingTextAttachmentReleases( + normalizedPersistedState.pendingTextAttachmentReleases, + ), }; } @@ -2177,6 +2232,7 @@ const composerDraftStore = create()( logicalProjectDraftThreadKeyByLogicalProjectKey: {}, stickyModelSelectionByProvider: {}, stickyActiveProvider: null, + pendingTextAttachmentReleases: [], getComposerDraft: (target) => getComposerDraftState(get(), target), getDraftThreadByLogicalProjectKey: (logicalProjectKey) => { return get().getDraftSessionByLogicalProjectKey(logicalProjectKey); @@ -3366,6 +3422,9 @@ const composerDraftStore = create()( normalizedPersisted.logicalProjectDraftThreadKeyByLogicalProjectKey, stickyModelSelectionByProvider: normalizedPersisted.stickyModelSelectionByProvider ?? {}, stickyActiveProvider: normalizedPersisted.stickyActiveProvider ?? null, + pendingTextAttachmentReleases: normalizePendingTextAttachmentReleases( + normalizedPersisted.pendingTextAttachmentReleases, + ), }; }, }, @@ -3374,27 +3433,146 @@ const composerDraftStore = create()( export const useComposerDraftStore = composerDraftStore; -export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { - useComposerDraftStore.setState((state) => { - const removedThreadKeys = new Set(); - - for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { - if (draftThread.environmentId === environmentId) { - removedThreadKeys.add(threadKey); - } +function composerDraftKeysEnvironment( + state: ComposerDraftStoreState, + environmentId: EnvironmentId, +): Set { + const keys = new Set(); + for (const [threadKey, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if (draftThread.environmentId === environmentId) keys.add(threadKey); + } + for (const threadKey of Object.keys(state.draftsByThreadKey)) { + if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) keys.add(threadKey); + } + for (const [logicalProjectKey, threadKey] of Object.entries( + state.logicalProjectDraftThreadKeyByLogicalProjectKey, + )) { + if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { + keys.add(threadKey); } - for (const threadKey of Object.keys(state.draftsByThreadKey)) { - if (parseScopedThreadKey(threadKey)?.environmentId === environmentId) { - removedThreadKeys.add(threadKey); - } + } + return keys; +} + +export function composerDraftPromptsEnvironment(environmentId: EnvironmentId): string[] { + const state = useComposerDraftStore.getState(); + const keys = composerDraftKeysEnvironment(state, environmentId); + return [...keys].flatMap((key) => { + const draft = state.draftsByThreadKey[key]; + return draft ? [draft.prompt] : []; + }); +} + +export function composerDraftEntriesEnvironment( + environmentId: EnvironmentId, +): Array<{ target: ComposerThreadTarget; prompt: string }> { + const state = useComposerDraftStore.getState(); + return [...composerDraftKeysEnvironment(state, environmentId)].flatMap((key) => { + const draft = state.draftsByThreadKey[key]; + if (!draft) return []; + const target = state.draftThreadsByThreadKey[key] + ? DraftId.make(key) + : parseScopedThreadKey(key); + return target ? [{ target, prompt: draft.prompt }] : []; + }); +} + +export function composerDraftPromptsEnvironmentExcept( + environmentId: EnvironmentId, + excludedTargets: ReadonlyArray, +): string[] { + const state = useComposerDraftStore.getState(); + const excludedKeys = new Set( + excludedTargets.flatMap((target) => { + const key = resolveComposerDraftKey(state, target); + return key ? [key] : []; + }), + ); + return [...composerDraftKeysEnvironment(state, environmentId)].flatMap((key) => { + const draft = state.draftsByThreadKey[key]; + return draft && !excludedKeys.has(key) ? [draft.prompt] : []; + }); +} + +export function composerDraftTargetsProject( + projectRef: ScopedProjectRef, + threadRefs: ReadonlyArray, +): ComposerThreadTarget[] { + const state = useComposerDraftStore.getState(); + const targets: ComposerThreadTarget[] = [...threadRefs]; + for (const [draftId, draftThread] of Object.entries(state.draftThreadsByThreadKey)) { + if ( + draftThread.environmentId === projectRef.environmentId && + draftThread.projectId === projectRef.projectId + ) { + targets.push(DraftId.make(draftId)); } - for (const [logicalProjectKey, threadKey] of Object.entries( - state.logicalProjectDraftThreadKeyByLogicalProjectKey, - )) { - if (parseScopedProjectKey(logicalProjectKey)?.environmentId === environmentId) { - removedThreadKeys.add(threadKey); + } + const seenKeys = new Set(); + return targets.filter((target) => { + const key = resolveComposerDraftKey(state, target); + if (!key || seenKeys.has(key)) return false; + seenKeys.add(key); + return true; + }); +} + +function pendingTextAttachmentReleaseKey(release: PendingTextAttachmentRelease): string { + return `${release.environmentId}\u0000${release.draftOwnerId}\u0000${release.path}`; +} + +export function pendingTextAttachmentReleasesEnvironment( + environmentId: EnvironmentId, +): PendingTextAttachmentRelease[] { + return useComposerDraftStore + .getState() + .pendingTextAttachmentReleases.filter((release) => release.environmentId === environmentId); +} + +export function persistPendingTextAttachmentReleases( + releases: ReadonlyArray, +): { readonly accepted: boolean; readonly rejected: ReadonlyArray } { + if (releases.length === 0) return { accepted: true, rejected: [] }; + const rejected: PendingTextAttachmentRelease[] = []; + useComposerDraftStore.setState((state) => { + const next = [...state.pendingTextAttachmentReleases]; + const seen = new Set(next.map(pendingTextAttachmentReleaseKey)); + for (const release of releases) { + const normalized = normalizePendingTextAttachmentReleases([release])[0]; + if (!normalized) continue; + const key = pendingTextAttachmentReleaseKey(normalized); + if (seen.has(key)) continue; + if (next.length >= MAX_PENDING_TEXT_ATTACHMENT_RELEASES) { + rejected.push(normalized); + continue; } + seen.add(key); + next.push(normalized); } + return { pendingTextAttachmentReleases: next }; + }); + composerDebouncedStorage.flush(); + if (rejected.length > 0) { + console.warn("Text attachment release outbox is full; new releases remain memory-only.", { + rejectedCount: rejected.length, + }); + } + return { accepted: rejected.length === 0, rejected }; +} + +export function completePendingTextAttachmentRelease(release: PendingTextAttachmentRelease): void { + const completedKey = pendingTextAttachmentReleaseKey(release); + useComposerDraftStore.setState((state) => ({ + pendingTextAttachmentReleases: state.pendingTextAttachmentReleases.filter( + (candidate) => pendingTextAttachmentReleaseKey(candidate) !== completedKey, + ), + })); + composerDebouncedStorage.flush(); +} + +export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): void { + useComposerDraftStore.setState((state) => { + const removedThreadKeys = composerDraftKeysEnvironment(state, environmentId); const nextLogicalMappings = Object.fromEntries( Object.entries(state.logicalProjectDraftThreadKeyByLogicalProjectKey).filter( @@ -3423,6 +3601,9 @@ export function clearComposerDraftsEnvironment(environmentId: EnvironmentId): vo draftsByThreadKey: nextDrafts, draftThreadsByThreadKey: nextDraftThreads, logicalProjectDraftThreadKeyByLogicalProjectKey: nextLogicalMappings, + pendingTextAttachmentReleases: state.pendingTextAttachmentReleases.filter( + (release) => release.environmentId !== environmentId, + ), }; }); composerDebouncedStorage.flush(); diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..706094a98c3 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -1,6 +1,7 @@ import { ClientPresentation, CloudSession, + ConnectionPersistenceError, EnvironmentOwnedDataCleanup, PlatformConnectionSource, PrimaryEnvironmentAuth, @@ -20,17 +21,19 @@ import { PrimaryConnectionRegistration, PrimaryConnectionTarget, Wakeups, + EnvironmentSupervisor, } from "@t3tools/client-runtime/connection"; import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; -import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; +import { EnvironmentRpcRequestObserver, request } from "@t3tools/client-runtime/rpc"; import { AuthStandardClientScopes, type DesktopBridge, type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, PRIMARY_LOCAL_ENVIRONMENT_ID, + WS_METHODS, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -48,7 +51,20 @@ import { readPrimaryEnvironmentTarget, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; -import { clearComposerDraftsEnvironment } from "../composerDraftStore"; +import { + clearComposerDraftsEnvironment, + composerDraftEntriesEnvironment, +} from "../composerDraftStore"; +import { + disposeTextAttachmentClaimEnvironment, + clearTextAttachmentUploadEnvironment, + fenceTextAttachmentUploadEnvironment, + pauseTextAttachmentClaimEnvironment, + pendingTextAttachmentClaimReleases, + resumeTextAttachmentClaimEnvironment, + resumeTextAttachmentUploadEnvironment, + textAttachmentClaims, +} from "../textAttachmentClaims"; import { isHostedStaticApp } from "../hostedPairing"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { acknowledgeRpcRequest, trackRpcRequestSent } from "../rpc/requestLatencyState"; @@ -576,8 +592,61 @@ const platformConnectionSourceLayer = Layer.effect( const environmentOwnedDataCleanupLayer = Layer.succeed( EnvironmentOwnedDataCleanup, EnvironmentOwnedDataCleanup.of({ + prepare: (environmentId, supervisor) => + Effect.gen(function* () { + yield* Effect.promise(() => fenceTextAttachmentUploadEnvironment(environmentId)); + yield* Effect.promise(() => pauseTextAttachmentClaimEnvironment(environmentId)); + const claims = [ + ...composerDraftEntriesEnvironment(environmentId).flatMap(({ target, prompt }) => + textAttachmentClaims(target, prompt), + ), + ...pendingTextAttachmentClaimReleases(environmentId), + ].filter( + (claim, index, all) => + all.findIndex( + (candidate) => + candidate.path === claim.path && candidate.draftOwnerId === claim.draftOwnerId, + ) === index, + ); + if (claims.length > 0 && !supervisor) { + resumeTextAttachmentClaimEnvironment(environmentId); + resumeTextAttachmentUploadEnvironment(environmentId); + return yield* new ConnectionPersistenceError({ + operation: "clear-environment", + message: "Could not release text attachment draft claims without a connection.", + }); + } + if (supervisor) { + const releaseResult = yield* Effect.exit( + Effect.forEach( + claims, + ({ path, draftOwnerId }) => + request(WS_METHODS.assetsReleaseTextAttachment, { path, draftOwnerId }).pipe( + Effect.provideService(EnvironmentSupervisor, supervisor), + Effect.retry({ times: 2 }), + ), + { concurrency: 1, discard: true }, + ), + ); + if (releaseResult._tag === "Failure") { + resumeTextAttachmentClaimEnvironment(environmentId); + resumeTextAttachmentUploadEnvironment(environmentId); + return yield* new ConnectionPersistenceError({ + operation: "clear-environment", + message: "Could not release text attachment draft claims.", + }); + } + } + }), + resume: (environmentId) => + Effect.sync(() => { + resumeTextAttachmentClaimEnvironment(environmentId); + resumeTextAttachmentUploadEnvironment(environmentId); + }), clear: (environmentId) => Effect.sync(() => { + disposeTextAttachmentClaimEnvironment(environmentId); + clearTextAttachmentUploadEnvironment(environmentId); clearComposerDraftsEnvironment(environmentId); }), }), diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 07655ad30d7..54ecb7b0f8c 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -15,6 +15,7 @@ import { getFallbackThreadIdAfterDelete } from "../components/Sidebar.logic"; import { useComposerDraftStore } from "../composerDraftStore"; import { terminalEnvironment } from "../state/terminal"; import { threadEnvironment } from "../state/threads"; +import { assetEnvironment } from "../state/assets"; import { vcsEnvironment } from "../state/vcs"; import { useNewThreadHandler } from "./useHandleNewThread"; import { refreshArchivedThreadsForEnvironment } from "../lib/archivedThreadsState"; @@ -26,6 +27,15 @@ import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from " import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useClientSettings } from "./useSettings"; import { useAtomCommand } from "../state/use-atom-command"; +import { + tombstoneTextAttachmentUploadOwner, + detachedTextAttachmentReleaseComplete, + fenceTextAttachmentUploadOwner, + releaseTextAttachmentClaimsInBackground, + resumeTextAttachmentUploadOwner, + textAttachmentClaims, + textAttachmentDraftOwnerId, +} from "../textAttachmentClaims"; export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( "ThreadArchiveBlockedError", @@ -50,6 +60,9 @@ export function useThreadActions() { const deleteThreadMutation = useAtomCommand(threadEnvironment.delete, { reportFailure: false, }); + const releaseTextAttachment = useAtomCommand(assetEnvironment.releaseTextAttachment, { + reportFailure: false, + }); const stopThreadSession = useAtomCommand(threadEnvironment.stopSession); const removeWorktree = useAtomCommand(vcsEnvironment.removeWorktree, { reportFailure: false, @@ -151,11 +164,32 @@ export function useThreadActions() { async (target: ScopedThreadRef, opts: { deletedThreadKeys?: ReadonlySet } = {}) => { const resolved = resolveThreadTarget(target); if (!resolved) { - // Thread not in main store (e.g. archived thread) — dispatch delete directly. + const draftOwnerId = textAttachmentDraftOwnerId(target); + const discardedPrompt = + useComposerDraftStore.getState().getComposerDraft(target)?.prompt ?? ""; + await fenceTextAttachmentUploadOwner(target.environmentId, draftOwnerId); const result = await deleteThreadMutation({ environmentId: target.environmentId, input: { threadId: target.threadId }, }); + if (result._tag === "Failure") { + resumeTextAttachmentUploadOwner(target.environmentId, draftOwnerId); + return result; + } + await releaseTextAttachmentClaimsInBackground({ + environmentId: target.environmentId, + claims: textAttachmentClaims(target, discardedPrompt), + draftOwnerIds: [draftOwnerId], + release: async ({ path, draftOwnerId: ownerId }) => { + const released = await releaseTextAttachment({ + environmentId: target.environmentId, + input: { path, draftOwnerId: ownerId }, + }); + return detachedTextAttachmentReleaseComplete(released); + }, + }); + clearComposerDraftForThread(target); + tombstoneTextAttachmentUploadOwner(target.environmentId, draftOwnerId); if (result._tag === "Success") { refreshArchivedThreadsForEnvironment(target.environmentId); } @@ -217,6 +251,11 @@ export function useThreadActions() { }); } + await fenceTextAttachmentUploadOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); + await closeTerminal({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId, deleteHistory: true }, @@ -233,13 +272,31 @@ export function useThreadActions() { deletedThreadIds, sortOrder: sidebarThreadSortOrder, }); + const discardedPrompt = + useComposerDraftStore.getState().getComposerDraft(threadRef)?.prompt ?? ""; const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, }); if (deleteResult._tag === "Failure") { + resumeTextAttachmentUploadOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); return deleteResult; } + await releaseTextAttachmentClaimsInBackground({ + environmentId: threadRef.environmentId, + claims: textAttachmentClaims(threadRef, discardedPrompt), + draftOwnerIds: [textAttachmentDraftOwnerId(threadRef)], + release: async ({ path, draftOwnerId }) => { + const result = await releaseTextAttachment({ + environmentId: threadRef.environmentId, + input: { path, draftOwnerId }, + }); + return detachedTextAttachmentReleaseComplete(result); + }, + }); refreshArchivedThreadsForEnvironment(threadRef.environmentId); clearComposerDraftForThread(threadRef); clearProjectDraftThreadById( @@ -247,6 +304,10 @@ export function useThreadActions() { threadRef, ); clearTerminalUiState(threadRef); + tombstoneTextAttachmentUploadOwner( + threadRef.environmentId, + textAttachmentDraftOwnerId(threadRef), + ); if (shouldNavigateToFallback) { if (fallbackThreadId) { @@ -334,6 +395,7 @@ export function useThreadActions() { clearProjectDraftThreadById, clearTerminalUiState, closeTerminal, + releaseTextAttachment, deleteThreadMutation, getCurrentRouteThreadRef, refreshVcsStatus, diff --git a/apps/web/src/lib/archivedThreadsState.ts b/apps/web/src/lib/archivedThreadsState.ts index 2d52383c02c..a98144876a3 100644 --- a/apps/web/src/lib/archivedThreadsState.ts +++ b/apps/web/src/lib/archivedThreadsState.ts @@ -4,6 +4,7 @@ import { createArchivedThreadSnapshotsAtomFamily, makeArchivedThreadsEnvironmentKey, } from "@t3tools/client-runtime/state/threads"; +import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId } from "@t3tools/contracts"; import { useCallback, useMemo } from "react"; @@ -26,6 +27,32 @@ export function refreshArchivedThreadsForEnvironment(environmentId: EnvironmentI appAtomRegistry.refresh(archivedSnapshotAtom(environmentId)); } +export async function refreshAndReadArchivedThreadSnapshots( + environmentIds: ReadonlyArray, +): Promise<{ + readonly snapshots: ReadonlyArray; + readonly error: string | null; +}> { + const results = await Promise.all( + environmentIds.map(async (environmentId) => { + const atom = archivedSnapshotAtom(environmentId); + appAtomRegistry.refresh(atom); + const result = await executeAtomQuery(appAtomRegistry, atom, { + reportDefect: false, + reportFailure: false, + }); + return { environmentId, result }; + }), + ); + const snapshots: ArchivedSnapshotEntry[] = []; + let error: string | null = null; + for (const { environmentId, result } of results) { + if (result._tag === "Success") snapshots.push({ environmentId, snapshot: result.value }); + else error = "Failed to load archived threads."; + } + return { snapshots, error }; +} + export function useArchivedThreadSnapshots(environmentIds: ReadonlyArray): { readonly snapshots: ReadonlyArray; readonly error: string | null; diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index 5691ffa8895..ec03ae3f814 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -107,6 +107,26 @@ describe("resolveMarkdownFileLinkTarget", () => { }); }); + it("recognizes extensionless generated attachments outside standard posix roots", () => { + const attachmentPath = + "/srv/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/extensionless-test"; + + expect(resolveMarkdownFileLinkMeta(attachmentPath, "/repo/project")).toMatchObject({ + filePath: attachmentPath, + basename: "extensionless-test", + workspaceRelativePath: null, + }); + expect( + resolveMarkdownFileLinkMeta( + "/srv/project/.t3/attachments/12345678-1234-1234-1234-123456789abc-extensionless-test", + "/repo/project", + ), + ).toMatchObject({ + basename: "12345678-1234-1234-1234-123456789abc-extensionless-test", + workspaceRelativePath: null, + }); + }); + it("normalizes slash-prefixed windows drive paths before resolving", () => { expect( resolveMarkdownFileLinkTarget( diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 1e24de8bb1d..62dbfac9a71 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -1,5 +1,6 @@ import { formatWorkspaceRelativePath } from "./filePathDisplay"; import { resolvePathLinkTarget, splitPathAndPosition } from "./terminal-links"; +import { isTextAttachmentPath } from "./textAttachmentPaths"; const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/; const WINDOWS_UNC_PATH_PATTERN = /^\\\\/; @@ -94,6 +95,7 @@ export function rewriteMarkdownFileUriHref(href: string | undefined): string | n function looksLikePosixFilesystemPath(path: string): boolean { if (!path.startsWith("/")) return false; + if (isTextAttachmentPath(path)) return true; if (POSIX_FILE_ROOT_PREFIXES.some((prefix) => path.startsWith(prefix))) return true; if (POSITION_SUFFIX_PATTERN.test(path)) return true; const basename = path.slice(path.lastIndexOf("/") + 1); diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 69739ce4570..a74dba2795a 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -19,6 +19,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; +import { TextAttachmentClaimLifecycle } from "../components/TextAttachmentClaimLifecycle"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, @@ -133,6 +134,7 @@ function RootRouteView() { + {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {appShell} diff --git a/apps/web/src/textAttachmentClaims.test.ts b/apps/web/src/textAttachmentClaims.test.ts new file mode 100644 index 00000000000..ac985ea2b09 --- /dev/null +++ b/apps/web/src/textAttachmentClaims.test.ts @@ -0,0 +1,770 @@ +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { composerDraftTargetsProject, DraftId, useComposerDraftStore } from "./composerDraftStore"; +import { getProjectRemovalThreadRefs } from "./components/Sidebar.logic"; +import { + textAttachmentClaimChanges, + textAttachmentClaims, + textAttachmentDraftOwnerId, + TextAttachmentClaimReconciler, + detachTextAttachmentClaimOwner, + detachedTextAttachmentReleaseComplete, + tombstoneTextAttachmentUploadOwner, + fenceTextAttachmentUploadEnvironment, + fenceTextAttachmentUploadOwner, + getTextAttachmentClaimReconciler, + pauseTextAttachmentClaimEnvironment, + pendingTextAttachmentClaimReleases, + reconcileTextAttachmentClaimsEnvironment, + releaseTextAttachmentClaimsInBackground, + resetTextAttachmentClaimRegistryForTest, + resumeTextAttachmentClaimEnvironment, + resumeTextAttachmentUploadEnvironment, + resumeTextAttachmentUploadOwner, + runTextAttachmentUpload, + retryTextAttachmentOperation, +} from "./textAttachmentClaims"; + +const PATH = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; + +afterEach(() => { + resetTextAttachmentClaimRegistryForTest(); + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + vi.useRealTimers(); +}); + +describe("text attachment claims", () => { + it("uses stable owner ids for draft and server composer targets", () => { + expect(textAttachmentDraftOwnerId(DraftId.make("draft-a"))).toBe("draft:draft-a"); + expect( + textAttachmentDraftOwnerId( + scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-a")), + ), + ).toBe("thread:local:thread-a"); + }); + + it("claims every hydrated or copied generated link after remount", () => { + expect(textAttachmentClaimChanges(new Set(), `[shared.txt](${PATH})`)).toMatchObject({ + claim: [PATH], + release: [], + }); + }); + + it("releases a removed link and reclaims it after rapid undo", () => { + const removed = textAttachmentClaimChanges(new Set([PATH]), ""); + const restored = textAttachmentClaimChanges(removed.nextPaths, `[shared.txt](${PATH})`); + + expect(removed.release).toEqual([PATH]); + expect(restored.claim).toEqual([PATH]); + }); + + it("gives copied shared links independent draft claims", () => { + expect(textAttachmentClaims(DraftId.make("draft-a"), `[shared.txt](${PATH})`)).toEqual([ + { path: PATH, draftOwnerId: "draft:draft-a" }, + ]); + expect(textAttachmentClaims(DraftId.make("draft-b"), `[shared.txt](${PATH})`)).toEqual([ + { path: PATH, draftOwnerId: "draft:draft-b" }, + ]); + }); + + it("retries a failed claim without marking it confirmed", async () => { + vi.useFakeTimers(); + const claim = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 100, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + expect(reconciler.snapshot()).toEqual({ desired: new Set([PATH]), confirmed: new Set() }); + + await vi.advanceTimersByTimeAsync(100); + await reconciler.settled(); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + expect(claim).toHaveBeenCalledTimes(2); + }); + + it("continues retrying claims beyond three failures with capped backoff", async () => { + vi.useFakeTimers(); + const claim = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 100, + maxRetryDelayMs: 200, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + await vi.advanceTimersByTimeAsync(700); + await reconciler.settled(); + + expect(claim).toHaveBeenCalledTimes(5); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("coalesces mounted composer and lifecycle reconciliation for unchanged paths", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("coalesced-lifecycle-environment"); + const draft = DraftId.make("coalesced-draft"); + const draftOwnerId = textAttachmentDraftOwnerId(draft); + const operations = { + claim: vi.fn(async () => false), + release: vi.fn(async () => true), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + const entries = [{ target: draft, prompt: `[shared.txt](${PATH})` }]; + + reconciler.setDesiredPrompt(entries[0]!.prompt); + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + await reconciler.settled(); + expect(operations.claim).toHaveBeenCalledOnce(); + + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + await vi.advanceTimersByTimeAsync(249); + expect(operations.claim).toHaveBeenCalledOnce(); + + await vi.advanceTimersByTimeAsync(1); + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledTimes(2)); + }); + + it("runs one immediate follow-up when reconnect arrives during an in-flight claim", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("in-flight-reconnect-environment"); + const draft = DraftId.make("in-flight-reconnect-draft"); + let finishFirstClaim: (claimed: boolean) => void = () => undefined; + const operations = { + claim: vi + .fn<(path: string, draftOwnerId: string) => Promise>() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstClaim = resolve; + }), + ) + .mockResolvedValueOnce(true), + release: vi.fn(async () => true), + }; + const entries = [{ target: draft, prompt: `[shared.txt](${PATH})` }]; + + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations); + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledOnce()); + reconcileTextAttachmentClaimsEnvironment(environmentId, entries, operations, { force: true }); + finishFirstClaim(false); + + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledTimes(2)); + await vi.advanceTimersByTimeAsync(1_000); + + expect(operations.claim).toHaveBeenCalledTimes(2); + }); + + it("reconciles immediately when a connection resumes", async () => { + vi.useFakeTimers(); + const claim = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 10_000, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + reconciler.reconcileNow(); + await reconciler.settled(); + + expect(claim).toHaveBeenCalledTimes(2); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("retries a failed imperative release without forgetting the claim", async () => { + vi.useFakeTimers(); + const release = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim: vi.fn().mockResolvedValue(true), + release, + retryDelayMs: 100, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + + reconciler.setDesiredPaths([]); + await reconciler.settled(); + expect(reconciler.snapshot()).toEqual({ desired: new Set(), confirmed: new Set([PATH]) }); + + await vi.advanceTimersByTimeAsync(100); + await reconciler.settled(); + expect(reconciler.snapshot().confirmed).toEqual(new Set()); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("reconciles rapid remove and undo to a confirmed claim", async () => { + const release = vi.fn().mockResolvedValue(true); + const reconciler = new TextAttachmentClaimReconciler({ + claim: vi.fn().mockResolvedValue(true), + release, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + + reconciler.setDesiredPaths([]); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + + expect(reconciler.snapshot()).toEqual({ + desired: new Set([PATH]), + confirmed: new Set([PATH]), + }); + expect(release).not.toHaveBeenCalled(); + }); + + it("retries destructive bulk releases beyond three failures", async () => { + vi.useFakeTimers(); + const operation = vi + .fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + + const result = retryTextAttachmentOperation(operation, { + retryDelayMs: 100, + maxRetryDelayMs: 200, + }); + await vi.advanceTimersByTimeAsync(700); + + await expect(result).resolves.toBe(true); + expect(operation).toHaveBeenCalledTimes(5); + }); + + it("bounds permanently failing destructive releases", async () => { + vi.useFakeTimers(); + const operation = vi.fn().mockResolvedValue(false); + const result = retryTextAttachmentOperation(operation, { + retryDelayMs: 10, + maxRetryDelayMs: 10, + maxAttempts: 3, + }); + await vi.advanceTimersByTimeAsync(30); + await expect(result).resolves.toBe(false); + expect(operation).toHaveBeenCalledTimes(3); + }); + + it("waits for an in-flight claim before detaching a destructively cleared owner", async () => { + const environmentId = EnvironmentId.make("destructive-environment"); + const draftOwnerId = "draft:destructive"; + let finishClaim: (claimed: boolean) => void = () => undefined; + const claim = vi.fn<(path: string) => Promise>( + (_path) => + new Promise((resolve) => { + finishClaim = resolve; + }), + ); + const operations = { + claim: (path: string) => claim(path), + release: vi.fn(async () => true), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + reconciler.setDesiredPaths([PATH]); + await vi.waitFor(() => expect(claim).toHaveBeenCalledOnce()); + + let detached = false; + const detach = detachTextAttachmentClaimOwner(environmentId, draftOwnerId).then(() => { + detached = true; + }); + await Promise.resolve(); + expect(detached).toBe(false); + + finishClaim(true); + await detach; + expect(detached).toBe(true); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("retries a released false result before destructive clear completes", async () => { + vi.useFakeTimers(); + const release = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const result = retryTextAttachmentOperation(release, { retryDelayMs: 100 }); + await vi.advanceTimersByTimeAsync(100); + + await expect(result).resolves.toBe(true); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("does not retry a permanently absent detached owner", async () => { + const releaseRpc = vi.fn(async () => ({ + _tag: "Success" as const, + value: { released: false }, + })); + + const completed = await retryTextAttachmentOperation(async () => + detachedTextAttachmentReleaseComplete(await releaseRpc()), + ); + + expect(completed).toBe(true); + expect(releaseRpc).toHaveBeenCalledOnce(); + }); + + it("keeps destructive owner releases retrying after the foreground deadline", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("destructive-release-outbox"); + const claims = [ + { path: `${PATH}.normal`, draftOwnerId: "thread:env:normal" }, + { path: `${PATH}.archived`, draftOwnerId: "thread:env:archived" }, + { path: `${PATH}.project`, draftOwnerId: "draft:project" }, + { path: `${PATH}.fenced-upload`, draftOwnerId: "draft:fenced-upload" }, + ]; + const attempts = new Map(); + const release = vi.fn(async (claim: (typeof claims)[number]) => { + const next = (attempts.get(claim.path) ?? 0) + 1; + attempts.set(claim.path, next); + return next > 1; + }); + + const foreground = releaseTextAttachmentClaimsInBackground({ + environmentId, + claims, + release, + foregroundWaitMs: 25, + retryDelayMs: 100, + }); + await vi.advanceTimersByTimeAsync(25); + await foreground; + + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual(claims); + expect(release).toHaveBeenCalledTimes(4); + + await vi.advanceTimersByTimeAsync(100); + + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); + expect(release).toHaveBeenCalledTimes(8); + }); + + it("times out a hung background release and retries it", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("hung-release-outbox"); + const releaseClaim = { path: PATH, draftOwnerId: "thread:env:hung" }; + const release = vi + .fn<(claim: typeof releaseClaim) => Promise>() + .mockImplementationOnce(() => new Promise(() => undefined)) + .mockResolvedValueOnce(true); + + const foreground = releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [releaseClaim], + release, + foregroundWaitMs: 10, + operationTimeoutMs: 50, + retryDelayMs: 100, + }); + await vi.advanceTimersByTimeAsync(10); + await foreground; + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([releaseClaim]); + + await vi.advanceTimersByTimeAsync(140); + + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); + expect(release).toHaveBeenCalledTimes(2); + }); + + it("releases confirmed owner paths even when the cleared prompt has no links", async () => { + const environmentId = EnvironmentId.make("destroyed-owner-with-stale-confirmation"); + const draftOwnerId = "thread:env:destroyed"; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations: { + claim: vi.fn(async () => true), + release: vi.fn(async () => false), + }, + }); + reconciler.confirmPaths([PATH]); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + const release = vi.fn(async (_path: string, _draftOwnerId: string) => true); + + await releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [], + draftOwnerIds: [draftOwnerId], + release: ({ path, draftOwnerId: ownerId }) => release(path, ownerId), + }); + + expect(release).toHaveBeenCalledWith(PATH, draftOwnerId); + expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([]); + }); + + it("cleans a draft owner when its thread archives between click and confirmation", async () => { + const environmentId = EnvironmentId.make("project-removal-archive-race"); + const projectId = ProjectId.make("project-removal-archive-race"); + const threadId = ThreadId.make("thread-archived-before-confirm"); + const threadRef = scopeThreadRef(environmentId, threadId); + const ownerId = textAttachmentDraftOwnerId(threadRef); + const store = useComposerDraftStore.getState(); + store.setPrompt(threadRef, `[shared.txt](${PATH})`); + + // The thread was live when removal was clicked, then moved into the fresh + // archived snapshot while the final confirmation dialog was open. + const latestThreadRefs = getProjectRemovalThreadRefs({ + environmentId, + projectId, + liveThreads: [], + archivedThreads: [{ environmentId, projectId, id: threadId }], + }); + const targets = composerDraftTargetsProject( + scopeProjectRef(environmentId, projectId), + latestThreadRefs, + ); + const claims = targets.flatMap((target) => + textAttachmentClaims(target, store.getComposerDraft(target)?.prompt ?? ""), + ); + const release = vi.fn(async (_path: string, _draftOwnerId: string) => true); + + await Promise.all( + targets.map((target) => + fenceTextAttachmentUploadOwner(environmentId, textAttachmentDraftOwnerId(target)), + ), + ); + await releaseTextAttachmentClaimsInBackground({ + environmentId, + claims, + draftOwnerIds: targets.map(textAttachmentDraftOwnerId), + release: ({ path, draftOwnerId }) => release(path, draftOwnerId), + }); + for (const target of targets) { + store.clearDraftThread(target); + tombstoneTextAttachmentUploadOwner(environmentId, textAttachmentDraftOwnerId(target)); + } + + expect(targets).toEqual([threadRef]); + expect(release).toHaveBeenCalledWith(PATH, ownerId); + expect(store.getComposerDraft(threadRef)).toBeNull(); + const upload = vi.fn(async () => ({ path: PATH })); + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: ownerId, + upload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(upload).not.toHaveBeenCalled(); + }); + + it("reconstructs the release outbox after a module restart and retries on reconnect", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("restarted-release-outbox"); + const claim = { path: PATH, draftOwnerId: "thread:env:restarted" }; + const firstRelease = vi.fn(async () => false); + const foreground = releaseTextAttachmentClaimsInBackground({ + environmentId, + claims: [claim], + release: firstRelease, + foregroundWaitMs: 10, + retryDelayMs: 10_000, + }); + await vi.advanceTimersByTimeAsync(10); + await foreground; + + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + partialize: (state: ReturnType) => unknown; + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const options = persistApi.getOptions(); + const persistedState = options.partialize(useComposerDraftStore.getState()); + resetTextAttachmentClaimRegistryForTest(); + useComposerDraftStore.setState({ pendingTextAttachmentReleases: [] }); + const hydrated = options.merge(persistedState, useComposerDraftStore.getInitialState()); + useComposerDraftStore.setState({ + pendingTextAttachmentReleases: hydrated.pendingTextAttachmentReleases, + }); + const reconnectRelease = vi.fn(async (_path: string, _draftOwnerId: string) => true); + const operations = { + claim: vi.fn(async () => true), + release: (path: string, draftOwnerId: string) => reconnectRelease(path, draftOwnerId), + }; + + reconcileTextAttachmentClaimsEnvironment(environmentId, [], operations); + await vi.waitFor(() => expect(pendingTextAttachmentClaimReleases(environmentId)).toEqual([])); + + expect(reconnectRelease).toHaveBeenCalledWith(PATH, claim.draftOwnerId); + }); + + it("cancels pending retry timers when disposed on unmount", async () => { + vi.useFakeTimers(); + const claim = vi.fn().mockResolvedValue(false); + const reconciler = new TextAttachmentClaimReconciler({ + claim, + release: vi.fn().mockResolvedValue(true), + retryDelayMs: 100, + }); + + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + reconciler.dispose(); + await vi.advanceTimersByTimeAsync(10_000); + + expect(claim).toHaveBeenCalledOnce(); + }); + + it("retries a background draft claim after navigation and reconnect", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("offline-environment"); + const draft = DraftId.make("background-draft"); + const draftOwnerId = textAttachmentDraftOwnerId(draft); + let online = false; + const operations = { + claim: vi.fn(async () => online), + release: vi.fn(async () => online), + }; + + reconcileTextAttachmentClaimsEnvironment( + environmentId, + [{ target: draft, prompt: `[shared.txt](${PATH})` }], + operations, + ); + const background = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + await background.settled(); + expect(background.snapshot().confirmed).toEqual(new Set()); + + // Navigating away does not dispose the owner. A later reconnect reconciles + // every persisted draft, including this now-background draft. + online = true; + reconcileTextAttachmentClaimsEnvironment( + environmentId, + [{ target: draft, prompt: `[shared.txt](${PATH})` }], + operations, + { force: true }, + ); + await background.settled(); + + expect(background.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("waits for an in-flight claim before environment release preparation", async () => { + const environmentId = EnvironmentId.make("preparing-environment"); + let finishClaim: (claimed: boolean) => void = () => undefined; + const operations = { + claim: vi.fn( + () => + new Promise((resolve) => { + finishClaim = resolve; + }), + ), + release: vi.fn(async () => true), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId: "draft:preparing", + operations, + }); + reconciler.setDesiredPaths([PATH]); + await vi.waitFor(() => expect(operations.claim).toHaveBeenCalledOnce()); + + let prepared = false; + const prepare = pauseTextAttachmentClaimEnvironment(environmentId).then(() => { + prepared = true; + }); + await Promise.resolve(); + expect(prepared).toBe(false); + + finishClaim(true); + await prepare; + expect(prepared).toBe(true); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("resumes claim reconciliation after environment preparation fails", async () => { + vi.useFakeTimers(); + const environmentId = EnvironmentId.make("recoverable-environment"); + let online = false; + const operations = { + claim: vi.fn(async () => online), + release: vi.fn(async () => online), + }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId: "draft:recoverable", + operations, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + await pauseTextAttachmentClaimEnvironment(environmentId); + + online = true; + resumeTextAttachmentClaimEnvironment(environmentId); + await reconciler.settled(); + + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("reclaims paths partially released before environment preparation fails", async () => { + const environmentId = EnvironmentId.make("partially-released-environment"); + const claim = vi.fn(async () => true); + const operations = { claim, release: vi.fn(async () => true) }; + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId: "draft:partial-release", + operations, + }); + reconciler.setDesiredPaths([PATH]); + await reconciler.settled(); + expect(claim).toHaveBeenCalledOnce(); + + await pauseTextAttachmentClaimEnvironment(environmentId); + // Preparation released this claim, then a later release failed. + resumeTextAttachmentClaimEnvironment(environmentId); + await reconciler.settled(); + + expect(claim).toHaveBeenCalledTimes(2); + expect(reconciler.snapshot().confirmed).toEqual(new Set([PATH])); + }); + + it("fences an in-flight thread upload and releases its late claim", async () => { + const environmentId = EnvironmentId.make("thread-upload-environment"); + let finishUpload: (result: { path: string }) => void = () => undefined; + const release = vi.fn(async () => undefined); + const upload = runTextAttachmentUpload({ + environmentId, + draftOwnerId: "thread:env:thread-a", + upload: () => + new Promise<{ path: string }>((resolve) => { + finishUpload = resolve; + }), + path: (result) => result.path, + release, + }); + + const fence = fenceTextAttachmentUploadOwner(environmentId, "thread:env:thread-a"); + finishUpload({ path: PATH }); + await fence; + + await expect(upload).resolves.toBeNull(); + expect(release).toHaveBeenCalledWith(PATH); + }); + + it("rejects new project-owner uploads after destructive fencing", async () => { + const environmentId = EnvironmentId.make("project-upload-environment"); + await fenceTextAttachmentUploadOwner(environmentId, "draft:project-draft"); + const upload = vi.fn(async () => ({ path: PATH })); + + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:project-draft", + upload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(upload).not.toHaveBeenCalled(); + }); + + it("fences all environment uploads and resumes them after cleanup abort", async () => { + const environmentId = EnvironmentId.make("environment-upload-fence"); + let finishUpload: (result: { path: string }) => void = () => undefined; + const first = runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:environment-draft", + upload: () => + new Promise<{ path: string }>((resolve) => { + finishUpload = resolve; + }), + path: (result) => result.path, + release: vi.fn(async () => undefined), + }); + const fence = fenceTextAttachmentUploadEnvironment(environmentId); + finishUpload({ path: PATH }); + await fence; + await expect(first).resolves.toBeNull(); + + const newOwnerUpload = vi.fn(async () => ({ path: PATH })); + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:first-seen-after-prepare", + upload: newOwnerUpload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(newOwnerUpload).not.toHaveBeenCalled(); + + resumeTextAttachmentUploadEnvironment(environmentId); + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId: "draft:environment-draft", + upload: async () => ({ path: PATH }), + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toEqual({ path: PATH }); + }); + + it("resumes an owner upload fence after thread or project deletion fails", async () => { + const environmentId = EnvironmentId.make("failed-owner-delete"); + const draftOwnerId = "draft:failed-delete"; + await fenceTextAttachmentUploadOwner(environmentId, draftOwnerId); + resumeTextAttachmentUploadOwner(environmentId, draftOwnerId); + + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId, + upload: async () => ({ path: PATH }), + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toEqual({ path: PATH }); + }); + + it("keeps a successful destruction owner tombstoned", async () => { + const environmentId = EnvironmentId.make("successful-owner-delete"); + const draftOwnerId = "draft:successful-delete"; + await fenceTextAttachmentUploadOwner(environmentId, draftOwnerId); + tombstoneTextAttachmentUploadOwner(environmentId, draftOwnerId); + const upload = vi.fn(async () => ({ path: PATH })); + + await expect( + runTextAttachmentUpload({ + environmentId, + draftOwnerId, + upload, + path: (result) => result.path, + release: vi.fn(async () => undefined), + }), + ).resolves.toBeNull(); + expect(upload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/textAttachmentClaims.ts b/apps/web/src/textAttachmentClaims.ts new file mode 100644 index 00000000000..bc210d3787b --- /dev/null +++ b/apps/web/src/textAttachmentClaims.ts @@ -0,0 +1,600 @@ +import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; + +import { + completePendingTextAttachmentRelease, + pendingTextAttachmentReleasesEnvironment, + persistPendingTextAttachmentReleases, + type ComposerThreadTarget, + type DraftId, +} from "./composerDraftStore"; +import { textAttachmentPaths } from "./textAttachmentPaths"; + +export function textAttachmentDraftOwnerId(target: ScopedThreadRef | DraftId): string { + return typeof target === "string" + ? `draft:${target}` + : `thread:${target.environmentId}:${target.threadId}`; +} + +export function textAttachmentClaimChanges( + previousPaths: ReadonlySet, + prompt: string, +): { claim: string[]; release: string[]; nextPaths: Set } { + const nextPaths = new Set(textAttachmentPaths(prompt)); + return { + claim: [...nextPaths].filter((path) => !previousPaths.has(path)), + release: [...previousPaths].filter((path) => !nextPaths.has(path)), + nextPaths, + }; +} + +export function textAttachmentClaims( + target: ComposerThreadTarget, + prompt: string, +): Array<{ path: string; draftOwnerId: string }> { + const draftOwnerId = textAttachmentDraftOwnerId(target); + return textAttachmentPaths(prompt).map((path) => ({ path, draftOwnerId })); +} + +export class TextAttachmentClaimReconciler { + #claim: (path: string) => Promise; + #release: (path: string) => Promise; + readonly #retryDelayMs: number; + readonly #maxRetryDelayMs: number; + readonly #operationTimeoutMs: number; + #desired = new Set(); + #confirmed = new Set(); + #queue: Promise = Promise.resolve(); + #retryTimer: ReturnType | null = null; + #retryCount = 0; + #reconcilePending = false; + #reconcileRequested = false; + #disposed = false; + #paused = false; + + constructor(options: { + claim: (path: string) => Promise; + release: (path: string) => Promise; + retryDelayMs?: number; + maxRetryDelayMs?: number; + operationTimeoutMs?: number; + }) { + this.#claim = options.claim; + this.#release = options.release; + this.#retryDelayMs = options.retryDelayMs ?? 250; + this.#maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000; + this.#operationTimeoutMs = options.operationTimeoutMs ?? 10_000; + } + + setDesiredPrompt(prompt: string): boolean { + return this.setDesiredPaths(textAttachmentPaths(prompt)); + } + + setOperations(operations: { + claim: (path: string) => Promise; + release: (path: string) => Promise; + }): void { + this.#claim = operations.claim; + this.#release = operations.release; + } + + setDesiredPaths(paths: Iterable): boolean { + if (this.#disposed) return false; + const nextDesired = new Set(paths); + if ( + nextDesired.size === this.#desired.size && + [...nextDesired].every((path) => this.#desired.has(path)) + ) { + return false; + } + this.#desired = nextDesired; + this.#retryCount = 0; + this.#clearRetry(); + if (!this.#paused) this.#enqueueReconcile(true); + return true; + } + + confirmPaths(paths: Iterable): void { + if (this.#disposed) return; + for (const path of paths) this.#confirmed.add(path); + } + + invalidateDesiredConfirmations(): void { + if (this.#disposed) return; + for (const path of this.#desired) this.#confirmed.delete(path); + } + + reconcileNow(): void { + if (this.#disposed || this.#paused) return; + this.#retryCount = 0; + this.#clearRetry(); + if (this.#reconcilePending) { + this.#reconcileRequested = true; + return; + } + this.#enqueueReconcile(true); + } + + reconcileIfNeeded(): void { + if (this.#disposed || this.#paused || this.#retryTimer !== null || !this.#hasPendingChanges()) { + return; + } + this.#enqueueReconcile(false); + } + + dispose(): void { + this.#disposed = true; + this.#reconcileRequested = false; + this.#clearRetry(); + } + + async pause(): Promise { + if (this.#disposed) return; + this.#paused = true; + this.#reconcileRequested = false; + this.#clearRetry(); + await this.#queue; + } + + resume(): void { + if (this.#disposed || !this.#paused) return; + this.#paused = false; + this.reconcileNow(); + } + + snapshot(): { desired: Set; confirmed: Set } { + return { + desired: new Set(this.#desired), + confirmed: new Set(this.#confirmed), + }; + } + + async settled(): Promise { + await this.#queue; + } + + #enqueueReconcile(requestAfterPending: boolean): void { + if (this.#disposed || this.#paused) return; + if (this.#reconcilePending) { + if (requestAfterPending) this.#reconcileRequested = true; + return; + } + this.#reconcilePending = true; + this.#queue = this.#queue + .catch(() => undefined) + .then(() => this.#reconcile()) + .finally(() => { + this.#reconcilePending = false; + if (!this.#reconcileRequested || this.#disposed || this.#paused) return; + this.#reconcileRequested = false; + this.#retryCount = 0; + this.#clearRetry(); + this.#enqueueReconcile(false); + }); + } + + async #reconcile(): Promise { + if (this.#disposed) return; + let failed = false; + for (const path of this.#desired) { + if (this.#confirmed.has(path)) continue; + if (await this.#runOperation(() => this.#claim(path))) this.#confirmed.add(path); + else failed = true; + } + for (const path of this.#confirmed) { + if (this.#desired.has(path)) continue; + if (await this.#runOperation(() => this.#release(path))) this.#confirmed.delete(path); + else failed = true; + } + if (!failed) { + this.#retryCount = 0; + return; + } + if (this.#retryTimer !== null || this.#disposed || this.#paused) return; + const delay = Math.min( + this.#retryDelayMs * 2 ** Math.min(this.#retryCount, 30), + this.#maxRetryDelayMs, + ); + this.#retryCount += 1; + this.#retryTimer = setTimeout(() => { + this.#retryTimer = null; + this.#enqueueReconcile(false); + }, delay); + } + + #clearRetry(): void { + if (this.#retryTimer === null) return; + clearTimeout(this.#retryTimer); + this.#retryTimer = null; + } + + async #runOperation(operation: () => Promise): Promise { + let timeout: ReturnType | null = null; + return Promise.race([ + Promise.resolve() + .then(operation) + .catch(() => false), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), this.#operationTimeoutMs); + }), + ]).finally(() => { + if (timeout !== null) clearTimeout(timeout); + }); + } + + #hasPendingChanges(): boolean { + for (const path of this.#desired) { + if (!this.#confirmed.has(path)) return true; + } + for (const path of this.#confirmed) { + if (!this.#desired.has(path)) return true; + } + return false; + } +} + +export async function retryTextAttachmentOperation( + operation: () => Promise, + options: { + retryDelayMs?: number; + maxRetryDelayMs?: number; + maxAttempts?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const retryDelayMs = options.retryDelayMs ?? 100; + const maxRetryDelayMs = options.maxRetryDelayMs ?? 30_000; + const maxAttempts = options.maxAttempts ?? 5; + for (let attempt = 0; attempt < maxAttempts && !options.signal?.aborted; attempt += 1) { + if (await operation()) return true; + if (attempt + 1 >= maxAttempts) return false; + const delay = Math.min(retryDelayMs * 2 ** Math.min(attempt, 30), maxRetryDelayMs); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + return false; +} + +export function detachedTextAttachmentReleaseComplete(result: { readonly _tag: string }): boolean { + return result._tag === "Success"; +} + +export interface TextAttachmentClaimRelease { + readonly path: string; + readonly draftOwnerId: string; +} + +export interface TextAttachmentClaimOperations { + claim: (path: string, draftOwnerId: string) => Promise; + release: (path: string, draftOwnerId: string) => Promise; +} + +const textAttachmentClaimReconcilerRegistry = new Map(); + +function textAttachmentClaimRegistryKey( + environmentId: EnvironmentId, + draftOwnerId: string, +): string { + return `${environmentId}:${draftOwnerId}`; +} + +export function getTextAttachmentClaimReconciler(input: { + environmentId: EnvironmentId; + draftOwnerId: string; + operations: TextAttachmentClaimOperations; + retryDelayMs?: number; + maxRetryDelayMs?: number; + operationTimeoutMs?: number; +}): TextAttachmentClaimReconciler { + const key = textAttachmentClaimRegistryKey(input.environmentId, input.draftOwnerId); + const existing = textAttachmentClaimReconcilerRegistry.get(key); + if (existing) { + existing.setOperations({ + claim: (path) => input.operations.claim(path, input.draftOwnerId), + release: (path) => input.operations.release(path, input.draftOwnerId), + }); + return existing; + } + const reconciler = new TextAttachmentClaimReconciler({ + claim: (path) => input.operations.claim(path, input.draftOwnerId), + release: (path) => input.operations.release(path, input.draftOwnerId), + ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }), + ...(input.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: input.maxRetryDelayMs }), + ...(input.operationTimeoutMs === undefined + ? {} + : { operationTimeoutMs: input.operationTimeoutMs }), + }); + textAttachmentClaimReconcilerRegistry.set(key, reconciler); + return reconciler; +} + +export async function releaseTextAttachmentClaimsInBackground(input: { + environmentId: EnvironmentId; + claims: ReadonlyArray; + draftOwnerIds?: ReadonlyArray; + release: (claim: TextAttachmentClaimRelease) => Promise; + foregroundWaitMs?: number; + retryDelayMs?: number; + maxRetryDelayMs?: number; + operationTimeoutMs?: number; +}): Promise<{ + readonly durable: boolean; + readonly rejected: ReadonlyArray; +}> { + const claimsByOwner = new Map>(); + for (const draftOwnerId of input.draftOwnerIds ?? []) { + claimsByOwner.set(draftOwnerId, new Set()); + } + for (const { path, draftOwnerId } of input.claims) { + const paths = claimsByOwner.get(draftOwnerId) ?? new Set(); + paths.add(path); + claimsByOwner.set(draftOwnerId, paths); + } + const owners = [...claimsByOwner].map(([draftOwnerId, paths]) => { + const reconciler = getTextAttachmentClaimReconciler({ + environmentId: input.environmentId, + draftOwnerId, + operations: { + claim: async () => false, + release: async (path, ownerId) => { + const release = { environmentId: input.environmentId, path, draftOwnerId: ownerId }; + const released = await input.release(release); + if (released) completePendingTextAttachmentRelease(release); + return released; + }, + }, + ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }), + ...(input.maxRetryDelayMs === undefined ? {} : { maxRetryDelayMs: input.maxRetryDelayMs }), + ...(input.operationTimeoutMs === undefined + ? {} + : { operationTimeoutMs: input.operationTimeoutMs }), + }); + for (const path of reconciler.snapshot().confirmed) paths.add(path); + return { draftOwnerId, paths, reconciler }; + }); + const persistence = persistPendingTextAttachmentReleases( + owners.flatMap(({ draftOwnerId, paths }) => + [...paths].map((path) => ({ + environmentId: input.environmentId, + path, + draftOwnerId, + })), + ), + ); + const reconciliations = owners.map(({ paths, reconciler }) => { + reconciler.confirmPaths(paths); + reconciler.setDesiredPaths([]); + reconciler.reconcileIfNeeded(); + return reconciler.settled(); + }); + if (reconciliations.length === 0) { + return { + durable: persistence.accepted, + rejected: persistence.rejected.map(({ path, draftOwnerId }) => ({ path, draftOwnerId })), + }; + } + let timeout: ReturnType | null = null; + await Promise.race([ + Promise.all(reconciliations), + new Promise((resolve) => { + timeout = setTimeout(resolve, input.foregroundWaitMs ?? 1_000); + }), + ]).finally(() => { + if (timeout !== null) clearTimeout(timeout); + }); + return { + durable: persistence.accepted, + rejected: persistence.rejected.map(({ path, draftOwnerId }) => ({ path, draftOwnerId })), + }; +} + +export function pendingTextAttachmentClaimReleases( + environmentId: EnvironmentId, +): TextAttachmentClaimRelease[] { + return pendingTextAttachmentReleasesEnvironment(environmentId).map(({ path, draftOwnerId }) => ({ + path, + draftOwnerId, + })); +} + +function restorePendingTextAttachmentClaimReleases( + environmentId: EnvironmentId, + operations: TextAttachmentClaimOperations, + force: boolean, +): void { + const claimsByOwner = new Map>(); + for (const { path, draftOwnerId } of pendingTextAttachmentReleasesEnvironment(environmentId)) { + const paths = claimsByOwner.get(draftOwnerId) ?? new Set(); + paths.add(path); + claimsByOwner.set(draftOwnerId, paths); + } + for (const [draftOwnerId, paths] of claimsByOwner) { + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations: { + claim: operations.claim, + release: async (path, ownerId) => { + const release = { environmentId, path, draftOwnerId: ownerId }; + const released = await operations.release(path, ownerId); + if (released) completePendingTextAttachmentRelease(release); + return released; + }, + }, + }); + reconciler.confirmPaths(paths); + reconciler.setDesiredPaths([]); + if (force) reconciler.reconcileNow(); + else reconciler.reconcileIfNeeded(); + } +} + +export function reconcileTextAttachmentClaimsEnvironment( + environmentId: EnvironmentId, + entries: ReadonlyArray<{ target: ComposerThreadTarget; prompt: string }>, + operations: TextAttachmentClaimOperations, + options: { readonly force?: boolean } = {}, +): void { + for (const { target, prompt } of entries) { + const draftOwnerId = textAttachmentDraftOwnerId(target); + const reconciler = getTextAttachmentClaimReconciler({ + environmentId, + draftOwnerId, + operations, + }); + const desiredChanged = reconciler.setDesiredPrompt(prompt); + if (options.force === true && !desiredChanged) reconciler.reconcileNow(); + } + restorePendingTextAttachmentClaimReleases(environmentId, operations, options.force === true); +} + +export async function detachTextAttachmentClaimOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): Promise { + const key = textAttachmentClaimRegistryKey(environmentId, draftOwnerId); + const reconciler = textAttachmentClaimReconcilerRegistry.get(key); + if (!reconciler) return; + textAttachmentClaimReconcilerRegistry.delete(key); + reconciler.dispose(); + await reconciler.settled(); +} + +export function disposeTextAttachmentClaimEnvironment(environmentId: EnvironmentId): void { + const prefix = `${environmentId}:`; + for (const [key, reconciler] of textAttachmentClaimReconcilerRegistry) { + if (!key.startsWith(prefix)) continue; + reconciler.dispose(); + textAttachmentClaimReconcilerRegistry.delete(key); + } +} + +export async function pauseTextAttachmentClaimEnvironment( + environmentId: EnvironmentId, +): Promise { + const prefix = `${environmentId}:`; + await Promise.all( + [...textAttachmentClaimReconcilerRegistry.entries()].flatMap(([key, reconciler]) => + key.startsWith(prefix) ? [reconciler.pause()] : [], + ), + ); +} + +export function resumeTextAttachmentClaimEnvironment(environmentId: EnvironmentId): void { + const prefix = `${environmentId}:`; + for (const [key, reconciler] of textAttachmentClaimReconcilerRegistry) { + if (!key.startsWith(prefix)) continue; + reconciler.invalidateDesiredConfirmations(); + reconciler.resume(); + } +} + +export function resetTextAttachmentClaimRegistryForTest(): void { + for (const reconciler of textAttachmentClaimReconcilerRegistry.values()) reconciler.dispose(); + textAttachmentClaimReconcilerRegistry.clear(); + textAttachmentUploadRegistry.clear(); + fencedTextAttachmentUploadEnvironmentIds.clear(); +} + +interface TextAttachmentUploadOwnerState { + fenced: boolean; + pending: Set>; +} + +const textAttachmentUploadRegistry = new Map(); +const fencedTextAttachmentUploadEnvironmentIds = new Set(); + +function textAttachmentUploadOwnerState( + environmentId: EnvironmentId, + draftOwnerId: string, +): TextAttachmentUploadOwnerState { + const key = textAttachmentClaimRegistryKey(environmentId, draftOwnerId); + const existing = textAttachmentUploadRegistry.get(key); + if (existing) return existing; + const state = { fenced: false, pending: new Set>() }; + textAttachmentUploadRegistry.set(key, state); + return state; +} + +export async function runTextAttachmentUpload(input: { + environmentId: EnvironmentId; + draftOwnerId: string; + upload: () => Promise; + path: (result: T) => string | null; + release: (path: string) => Promise; +}): Promise { + if (fencedTextAttachmentUploadEnvironmentIds.has(input.environmentId)) return null; + const state = textAttachmentUploadOwnerState(input.environmentId, input.draftOwnerId); + if (state.fenced) return null; + let finish: () => void = () => undefined; + const pending = new Promise((resolve) => { + finish = resolve; + }); + state.pending.add(pending); + try { + const result = await input.upload(); + if (!state.fenced) return result; + const path = input.path(result); + if (path) await input.release(path); + return null; + } finally { + state.pending.delete(pending); + finish(); + } +} + +export async function fenceTextAttachmentUploadOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): Promise { + const state = textAttachmentUploadOwnerState(environmentId, draftOwnerId); + state.fenced = true; + await Promise.all(state.pending); +} + +export function resumeTextAttachmentUploadOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): void { + const state = textAttachmentUploadRegistry.get( + textAttachmentClaimRegistryKey(environmentId, draftOwnerId), + ); + if (state) state.fenced = false; +} + +export function tombstoneTextAttachmentUploadOwner( + environmentId: EnvironmentId, + draftOwnerId: string, +): void { + const state = textAttachmentUploadOwnerState(environmentId, draftOwnerId); + state.fenced = true; + state.pending.clear(); +} + +export async function fenceTextAttachmentUploadEnvironment( + environmentId: EnvironmentId, +): Promise { + fencedTextAttachmentUploadEnvironmentIds.add(environmentId); + const prefix = `${environmentId}:`; + const waits: Promise[] = []; + for (const [key, state] of textAttachmentUploadRegistry) { + if (!key.startsWith(prefix)) continue; + state.fenced = true; + waits.push(...state.pending); + } + await Promise.all(waits); +} + +export function resumeTextAttachmentUploadEnvironment(environmentId: EnvironmentId): void { + fencedTextAttachmentUploadEnvironmentIds.delete(environmentId); + const prefix = `${environmentId}:`; + for (const [key, state] of textAttachmentUploadRegistry) { + if (key.startsWith(prefix)) state.fenced = false; + } +} + +export function clearTextAttachmentUploadEnvironment(environmentId: EnvironmentId): void { + fencedTextAttachmentUploadEnvironmentIds.delete(environmentId); + const prefix = `${environmentId}:`; + for (const key of textAttachmentUploadRegistry.keys()) { + if (key.startsWith(prefix)) textAttachmentUploadRegistry.delete(key); + } +} diff --git a/apps/web/src/textAttachmentPaths.test.ts b/apps/web/src/textAttachmentPaths.test.ts new file mode 100644 index 00000000000..ab4bc0bc285 --- /dev/null +++ b/apps/web/src/textAttachmentPaths.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + removedTextAttachmentPaths, + textAttachmentPaths, + unreferencedTextAttachmentPaths, +} from "./textAttachmentPaths"; + +describe("textAttachmentPaths", () => { + it("collects unique generated attachment links from a discarded draft", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(textAttachmentPaths(`[notes.txt](${path}) keep [notes.txt](${path})`)).toEqual([path]); + expect(textAttachmentPaths("ordinary prompt")).toEqual([]); + }); + + it("collects a generated attachment whose Markdown link ends at EOF", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(textAttachmentPaths(`[notes.txt](${path})`)).toEqual([path]); + }); + + it("collects adjacent links whose absolute paths contain parentheses", () => { + const path = "/var/t3-(data)/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(textAttachmentPaths(`before[notes.txt](${path}),after`)).toEqual([path]); + }); + + it("ignores generated paths outside valid Markdown links and inside code", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect( + textAttachmentPaths( + [`missing](${path})`, `\`[inline](${path})\``, "```", `[fenced](${path})`, "```"].join( + "\n", + ), + ), + ).toEqual([]); + }); +}); + +describe("removedTextAttachmentPaths", () => { + it("collects a generated attachment after its link is removed", () => { + const removedPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + const retainedPath = + "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/copied.txt"; + + expect( + removedTextAttachmentPaths( + `[notes.txt](${removedPath}) [copied.txt](${retainedPath})`, + `[copied.txt](${retainedPath})`, + ), + ).toEqual([removedPath]); + }); + + it("keeps a copied attachment while its link remains", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(removedTextAttachmentPaths(`[notes.txt](${path})`, `[notes](${path})`)).toEqual([]); + }); + + it("detects EOF removal after ownership state is lost on remount", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(removedTextAttachmentPaths(`[notes.txt](${path})`, "")).toEqual([path]); + }); + + it("preserves an attachment whose next link ends at EOF", () => { + const path = "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/notes.txt"; + + expect(removedTextAttachmentPaths(`[notes.txt](${path}) `, `[notes.txt](${path})`)).toEqual([]); + }); +}); + +describe("unreferencedTextAttachmentPaths", () => { + it("protects an attachment referenced by another unsent draft", () => { + const sharedPath = + "/var/t3-data/attachments/text/12345678-1234-1234-1234-123456789abc/shared.txt"; + const uniquePath = + "/var/t3-data/attachments/text/87654321-4321-4321-4321-cba987654321/unique.txt"; + + expect( + unreferencedTextAttachmentPaths( + [`[shared.txt](${sharedPath}) [unique.txt](${uniquePath}) `], + [`Still using [shared.txt](${sharedPath}) `], + ), + ).toEqual([uniquePath]); + }); +}); diff --git a/apps/web/src/textAttachmentPaths.ts b/apps/web/src/textAttachmentPaths.ts new file mode 100644 index 00000000000..f2d668309c6 --- /dev/null +++ b/apps/web/src/textAttachmentPaths.ts @@ -0,0 +1,46 @@ +const UUID_PATH_SEGMENT = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; +const TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( + `(?:^|[\\\\/])(?:\\.t3[\\\\/]attachments|attachments[\\\\/]text)[\\\\/]${UUID_PATH_SEGMENT}[\\\\/]`, + "i", +); +const LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN = new RegExp( + `(?:^|[\\\\/])\\.t3[\\\\/]attachments[\\\\/]${UUID_PATH_SEGMENT}-[^\\\\/]+$`, + "i", +); +export function isTextAttachmentPath(path: string): boolean { + return ( + TEXT_ATTACHMENT_PATH_PATTERN.test(path) || LEGACY_FLAT_TEXT_ATTACHMENT_PATH_PATTERN.test(path) + ); +} + +export function textAttachmentPaths(prompt: string): string[] { + const paths = new Set(); + for (const encodedPath of markdownLinkDestinations(prompt)) { + let path = encodedPath; + try { + path = decodeURIComponent(encodedPath); + } catch { + // Preserve malformed source rather than dropping a generated path. + } + if (isTextAttachmentPath(path)) paths.add(path); + } + return [...paths]; +} + +export function removedTextAttachmentPaths(previousPrompt: string, nextPrompt: string): string[] { + const nextPaths = new Set(textAttachmentPaths(nextPrompt)); + return textAttachmentPaths(previousPrompt).filter((path) => !nextPaths.has(path)); +} + +export function unreferencedTextAttachmentPaths( + discardedPrompts: ReadonlyArray, + retainedPrompts: ReadonlyArray, +): string[] { + const retainedPaths = new Set(retainedPrompts.flatMap(textAttachmentPaths)); + return [ + ...new Set( + discardedPrompts.flatMap(textAttachmentPaths).filter((path) => !retainedPaths.has(path)), + ), + ]; +} +import { markdownLinkDestinations } from "@t3tools/shared/markdownLinks"; diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c75cc2fad4b..9ed069dd10a 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -135,6 +135,10 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( readonly beforeRegistrationRemove?: ( target: ConnectionTarget, ) => Effect.Effect; + readonly beforeOwnedDataClear?: ( + environmentId: EnvironmentId, + ) => Effect.Effect; + readonly beforeCacheClear?: (environmentId: EnvironmentId) => Effect.Effect; }, ) { const storedTargets = yield* Ref.make( @@ -143,6 +147,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( const shellCache = yield* Ref.make(new Map([[TARGET.environmentId, CACHED_SNAPSHOT]])); const cacheClears = yield* Ref.make>([]); const ownedDataClears = yield* Ref.make>([]); + const ownedDataResumes = yield* Ref.make>([]); const sessions = yield* Ref.make>([]); const releasedSessions = yield* Ref.make(0); const storedProfiles = yield* Ref.make( @@ -252,17 +257,23 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( loadVcsRefs: () => Effect.succeed(Option.none()), saveVcsRefs: () => Effect.void, clear: (environmentId) => - Ref.update(shellCache, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }).pipe( + (options?.beforeCacheClear?.(environmentId) ?? Effect.void).pipe( + Effect.andThen( + Ref.update(shellCache, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }), + ), Effect.andThen( Ref.update(cacheClears, (environmentIds) => [...environmentIds, environmentId]), ), ), }); const ownedDataCleanup = Persistence.EnvironmentOwnedDataCleanup.of({ + prepare: (environmentId) => options?.beforeOwnedDataClear?.(environmentId) ?? Effect.void, + resume: (environmentId) => + Ref.update(ownedDataResumes, (environmentIds) => [...environmentIds, environmentId]), clear: (environmentId) => Ref.update(ownedDataClears, (environmentIds) => [...environmentIds, environmentId]), }); @@ -390,6 +401,7 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( shellCache, cacheClears, ownedDataClears, + ownedDataResumes, sessions, releasedSessions, storedProfiles, @@ -595,6 +607,70 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("keeps an environment registered when owned-data cleanup fails", () => + Effect.gen(function* () { + const cleanupError = new Persistence.ConnectionPersistenceError({ + operation: "clear-environment", + message: "Attachment claims could not be released.", + }); + const harness = yield* makeHarness([TARGET], [], [], { + beforeOwnedDataClear: () => Effect.fail(cleanupError), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + + const error = yield* Effect.flip(registry.remove(TARGET.environmentId)); + expect(error).toBe(cleanupError); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(true); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataResumes)).toEqual([TARGET.environmentId]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("resumes owned data when removal is interrupted", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET], [], [], { + beforeRegistrationRemove: () => Effect.interrupt, + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + + const exit = yield* Effect.exit(registry.remove(TARGET.environmentId)); + + expect(exit._tag).toBe("Failure"); + expect((yield* Ref.get(harness.storedTargets)).has(TARGET.environmentId)).toBe(true); + expect((yield* SubscriptionRef.get(registry.entries)).has(TARGET.environmentId)).toBe(true); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataResumes)).toEqual([TARGET.environmentId]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + + it.effect("resumes owned data when platform removal defects", () => + Effect.gen(function* () { + const harness = yield* makeHarness([], [], [], { + beforeCacheClear: () => Effect.die("cache defect"), + }); + + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.registerPlatform(new PrimaryConnectionRegistration({ target: TARGET })); + + const exit = yield* Effect.exit(registry.reconcilePlatform([])); + + expect(exit._tag).toBe("Failure"); + expect(yield* Ref.get(harness.ownedDataClears)).toEqual([]); + expect(yield* Ref.get(harness.ownedDataResumes)).toEqual([TARGET.environmentId]); + }).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("moves durable streams to a replacement supervisor", () => Effect.gen(function* () { const replacement = new RelayConnectionTarget({ diff --git a/packages/client-runtime/src/connection/registry.ts b/packages/client-runtime/src/connection/registry.ts index a3a36272f32..eba0e25e848 100644 --- a/packages/client-runtime/src/connection/registry.ts +++ b/packages/client-runtime/src/connection/registry.ts @@ -70,10 +70,12 @@ export class EnvironmentRegistry extends Context.Service< readonly register: ( registration: ConnectionRegistration, ) => Effect.Effect; - readonly registerPlatform: (registration: PrimaryConnectionRegistration) => Effect.Effect; + readonly registerPlatform: ( + registration: PrimaryConnectionRegistration, + ) => Effect.Effect; readonly reconcilePlatform: ( registrations: ReadonlyArray, - ) => Effect.Effect; + ) => Effect.Effect; readonly remove: ( environmentId: EnvironmentId, ) => Effect.Effect< @@ -244,6 +246,16 @@ export const make = Effect.gen(function* () { yield* Scope.close(lease.scope, Exit.void); }); + const resumeOwnedDataOnFailure = ( + environmentId: EnvironmentId, + effect: Effect.Effect, + ): Effect.Effect => + effect.pipe( + Effect.onExit((exit) => + Exit.isFailure(exit) ? ownedDataCleanup.resume(environmentId) : Effect.void, + ), + ); + const createServiceScope = Effect.fn("EnvironmentRegistry.createServiceScope")( (entry: ConnectionCatalogEntry) => Effect.uninterruptible( @@ -474,44 +486,49 @@ export const make = Effect.gen(function* () { function* (environmentId: EnvironmentId) { yield* withLeaseLock( environmentId, - Effect.gen(function* () { - const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); - yield* Ref.update(platformEnvironmentIds, (current) => { - const next = new Set(current); - next.delete(environmentId); - return next; - }); - yield* closeServiceScope(environmentId); - yield* SubscriptionRef.update(entries, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }); - if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") { - yield* credentials.remove(entry.target.connectionId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear the platform bearer credential.", { - environmentId, - error, - }), - ), - ); - } - yield* Effect.all( - [ - cache.clear(environmentId).pipe( + resumeOwnedDataOnFailure( + environmentId, + Effect.gen(function* () { + const entry = (yield* SubscriptionRef.get(entries)).get(environmentId); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); + yield* Ref.update(platformEnvironmentIds, (current) => { + const next = new Set(current); + next.delete(environmentId); + return next; + }); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + if (entry !== undefined && entry.target._tag === "BearerConnectionTarget") { + yield* credentials.remove(entry.target.connectionId).pipe( Effect.catch((error) => - Effect.logWarning("Could not clear cached environment data after removal.", { + Effect.logWarning("Could not clear the platform bearer credential.", { environmentId, error, }), ), - ), - ownedDataCleanup.clear(environmentId), - ], - { concurrency: "unbounded", discard: true }, - ); - }), + ); + } + yield* Effect.all( + [ + cache.clear(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear cached environment data after removal.", { + environmentId, + error, + }), + ), + ), + ], + { concurrency: "unbounded", discard: true }, + ); + yield* ownedDataCleanup.clear(environmentId); + }), + ), ); }, ); @@ -556,48 +573,55 @@ export const make = Effect.gen(function* () { ? yield* profiles.get(target.connectionId) : Option.none(); - yield* registrations.remove(target); - yield* Ref.update(persistedTargetsByEnvironment, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }); - yield* closeServiceScope(environmentId); - yield* SubscriptionRef.update(entries, (current) => { - const next = new Map(current); - next.delete(environmentId); - return next; - }); - yield* Effect.all( - [ - cache.clear(environmentId).pipe( - Effect.catch((error) => - Effect.logWarning("Could not clear cached environment data after removal.", { - environmentId, - error, - }), - ), - ), - ownedDataCleanup.clear(environmentId), - ], - { concurrency: "unbounded", discard: true }, - ); + const serviceScope = (yield* SubscriptionRef.get(serviceScopes)).get(environmentId); + yield* resumeOwnedDataOnFailure( + environmentId, + Effect.gen(function* () { + yield* ownedDataCleanup.prepare(environmentId, serviceScope?.supervisor); + yield* registrations.remove(target); + yield* Ref.update(persistedTargetsByEnvironment, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* closeServiceScope(environmentId); + yield* SubscriptionRef.update(entries, (current) => { + const next = new Map(current); + next.delete(environmentId); + return next; + }); + yield* Effect.all( + [ + cache.clear(environmentId).pipe( + Effect.catch((error) => + Effect.logWarning("Could not clear cached environment data after removal.", { + environmentId, + error, + }), + ), + ), + ], + { concurrency: "unbounded", discard: true }, + ); - if ( - target._tag === "SshConnectionTarget" && - Option.isSome(profile) && - isSshConnectionProfile(profile.value) - ) { - yield* ssh.disconnect(profile.value.target).pipe( - Effect.tapError((error) => - Effect.logWarning("Could not disconnect the managed SSH environment.", { - environmentId, - error, - }), - ), - Effect.ignore, - ); - } + if ( + target._tag === "SshConnectionTarget" && + Option.isSome(profile) && + isSshConnectionProfile(profile.value) + ) { + yield* ssh.disconnect(profile.value.target).pipe( + Effect.tapError((error) => + Effect.logWarning("Could not disconnect the managed SSH environment.", { + environmentId, + error, + }), + ), + Effect.ignore, + ); + } + yield* ownedDataCleanup.clear(environmentId); + }), + ); }), ); }); diff --git a/packages/client-runtime/src/platform/persistence.ts b/packages/client-runtime/src/platform/persistence.ts index 8723ac06939..94a8108e948 100644 --- a/packages/client-runtime/src/platform/persistence.ts +++ b/packages/client-runtime/src/platform/persistence.ts @@ -13,6 +13,7 @@ import * as Schema from "effect/Schema"; import type { ConnectionRegistration } from "../connection/catalog.ts"; import type { ConnectionTarget } from "../connection/model.ts"; +import type { EnvironmentSupervisor } from "../connection/supervisor.ts"; export class ConnectionPersistenceError extends Schema.TaggedErrorClass()( "ConnectionPersistenceError", @@ -109,9 +110,16 @@ export class EnvironmentCacheStore extends Context.Service< >()("@t3tools/client-runtime/platform/persistence/EnvironmentCacheStore") {} export class EnvironmentOwnedDataCleanup extends Context.Reference<{ + readonly prepare: ( + environmentId: EnvironmentId, + supervisor: EnvironmentSupervisor["Service"] | undefined, + ) => Effect.Effect; + readonly resume: (environmentId: EnvironmentId) => Effect.Effect; readonly clear: (environmentId: EnvironmentId) => Effect.Effect; }>("@t3tools/client-runtime/platform/persistence/EnvironmentOwnedDataCleanup", { defaultValue: () => ({ + prepare: () => Effect.void, + resume: () => Effect.void, clear: () => Effect.void, }), }) {} diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index e407f5d0028..cdc2cff38cf 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; const ASSET_URL_STALE_TIME_MS = 5 * 60_000; @@ -72,6 +72,18 @@ export function createAssetEnvironmentAtoms( return { createUrl, + writeTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:write-text-attachment", + tag: WS_METHODS.assetsWriteTextAttachment, + }), + claimTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:claim-text-attachment", + tag: WS_METHODS.assetsClaimTextAttachment, + }), + releaseTextAttachment: createEnvironmentRpcCommand(runtime, { + label: "environment-data:assets:release-text-attachment", + tag: WS_METHODS.assetsReleaseTextAttachment, + }), createUrls: (target: { readonly environmentId: EnvironmentId; readonly resources: ReadonlyArray; diff --git a/packages/contracts/src/assets.ts b/packages/contracts/src/assets.ts index 0dbe7d9fa25..34323491bf1 100644 --- a/packages/contracts/src/assets.ts +++ b/packages/contracts/src/assets.ts @@ -3,6 +3,12 @@ import * as Schema from "effect/Schema"; import { ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; const ASSET_PATH_MAX_LENGTH = 1024; +const TEXT_ATTACHMENT_CONTENT_MAX_LENGTH = 1024 * 1024; +const TEXT_ATTACHMENT_DRAFT_OWNER_ID_MAX_LENGTH = 256; + +const TextAttachmentDraftOwnerId = TrimmedNonEmptyString.check( + Schema.isMaxLength(TEXT_ATTACHMENT_DRAFT_OWNER_ID_MAX_LENGTH), +); export const AssetResource = Schema.Union([ Schema.TaggedStruct("workspace-file", { @@ -29,6 +35,73 @@ export const AssetCreateUrlResult = Schema.Struct({ }); export type AssetCreateUrlResult = typeof AssetCreateUrlResult.Type; +export const AssetWriteTextAttachmentInput = Schema.Struct({ + fileName: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + contents: Schema.String.check(Schema.isMaxLength(TEXT_ATTACHMENT_CONTENT_MAX_LENGTH)), + draftOwnerId: TextAttachmentDraftOwnerId, +}); +export type AssetWriteTextAttachmentInput = typeof AssetWriteTextAttachmentInput.Type; + +export const AssetWriteTextAttachmentResult = Schema.Struct({ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), +}); +export type AssetWriteTextAttachmentResult = typeof AssetWriteTextAttachmentResult.Type; + +export const AssetClaimTextAttachmentInput = Schema.Struct({ + path: TrimmedNonEmptyString.check(Schema.isMaxLength(4096)), + draftOwnerId: TextAttachmentDraftOwnerId, +}); +export type AssetClaimTextAttachmentInput = typeof AssetClaimTextAttachmentInput.Type; + +export const AssetClaimTextAttachmentResult = Schema.Struct({ + claimed: Schema.Boolean, +}); +export type AssetClaimTextAttachmentResult = typeof AssetClaimTextAttachmentResult.Type; + +export const AssetReleaseTextAttachmentInput = AssetClaimTextAttachmentInput; +export type AssetReleaseTextAttachmentInput = typeof AssetReleaseTextAttachmentInput.Type; + +export const AssetReleaseTextAttachmentResult = Schema.Struct({ + released: Schema.Boolean, +}); +export type AssetReleaseTextAttachmentResult = typeof AssetReleaseTextAttachmentResult.Type; + +export class AssetTextAttachmentWriteError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentWriteError", + { + fileName: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to store text attachment '${this.fileName}'.`; + } +} + +export class AssetTextAttachmentClaimError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentClaimError", + { + path: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to claim text attachment."; + } +} + +export class AssetTextAttachmentReleaseError extends Schema.TaggedErrorClass()( + "AssetTextAttachmentReleaseError", + { + path: TrimmedNonEmptyString, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to release text attachment."; + } +} + export class AssetWorkspaceContextNotFoundError extends Schema.TaggedErrorClass()( "AssetWorkspaceContextNotFoundError", { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index bbb4d934ca2..9d86e4ae09b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -20,7 +20,16 @@ import type { } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; -import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; +import type { + AssetCreateUrlInput, + AssetCreateUrlResult, + AssetClaimTextAttachmentInput, + AssetClaimTextAttachmentResult, + AssetReleaseTextAttachmentInput, + AssetReleaseTextAttachmentResult, + AssetWriteTextAttachmentInput, + AssetWriteTextAttachmentResult, +} from "./assets.ts"; import type { ProjectListEntriesInput, ProjectListEntriesResult, @@ -1165,6 +1174,15 @@ export interface EnvironmentApi { }; assets: { createUrl: (input: AssetCreateUrlInput) => Promise; + writeTextAttachment: ( + input: AssetWriteTextAttachmentInput, + ) => Promise; + claimTextAttachment: ( + input: AssetClaimTextAttachmentInput, + ) => Promise; + releaseTextAttachment: ( + input: AssetReleaseTextAttachmentInput, + ) => Promise; }; sourceControl: { lookupRepository: ( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..15d60e40a4d 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -13,7 +13,20 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; -import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; +import { + AssetAccessError, + AssetCreateUrlInput, + AssetCreateUrlResult, + AssetClaimTextAttachmentInput, + AssetClaimTextAttachmentResult, + AssetReleaseTextAttachmentInput, + AssetReleaseTextAttachmentResult, + AssetTextAttachmentClaimError, + AssetTextAttachmentReleaseError, + AssetTextAttachmentWriteError, + AssetWriteTextAttachmentInput, + AssetWriteTextAttachmentResult, +} from "./assets.ts"; import { GitActionProgressEvent, VcsSwitchRefInput, @@ -160,6 +173,9 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", + assetsWriteTextAttachment: "assets.writeTextAttachment", + assetsClaimTextAttachment: "assets.claimTextAttachment", + assetsReleaseTextAttachment: "assets.releaseTextAttachment", // VCS methods vcsPull: "vcs.pull", @@ -395,6 +411,24 @@ export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { error: Schema.Union([AssetAccessError, EnvironmentAuthorizationError]), }); +export const WsAssetsWriteTextAttachmentRpc = Rpc.make(WS_METHODS.assetsWriteTextAttachment, { + payload: AssetWriteTextAttachmentInput, + success: AssetWriteTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentWriteError, EnvironmentAuthorizationError]), +}); + +export const WsAssetsClaimTextAttachmentRpc = Rpc.make(WS_METHODS.assetsClaimTextAttachment, { + payload: AssetClaimTextAttachmentInput, + success: AssetClaimTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentClaimError, EnvironmentAuthorizationError]), +}); + +export const WsAssetsReleaseTextAttachmentRpc = Rpc.make(WS_METHODS.assetsReleaseTextAttachment, { + payload: AssetReleaseTextAttachmentInput, + success: AssetReleaseTextAttachmentResult, + error: Schema.Union([AssetTextAttachmentReleaseError, EnvironmentAuthorizationError]), +}); + export const WsSubscribeVcsStatusRpc = Rpc.make(WS_METHODS.subscribeVcsStatus, { payload: VcsStatusInput, success: VcsStatusStreamEvent, @@ -706,6 +740,9 @@ export const WsRpcGroup = RpcGroup.make( WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, WsAssetsCreateUrlRpc, + WsAssetsWriteTextAttachmentRpc, + WsAssetsClaimTextAttachmentRpc, + WsAssetsReleaseTextAttachmentRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index e08844cbfae..abf32c7e959 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -147,6 +147,10 @@ "types": "./src/composerInlineTokens.ts", "import": "./src/composerInlineTokens.ts" }, + "./markdownLinks": { + "types": "./src/markdownLinks.ts", + "import": "./src/markdownLinks.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" @@ -194,6 +198,7 @@ "@t3tools/contracts": "workspace:*", "effect": "catalog:", "jose": "catalog:", + "mdast-util-from-markdown": "^2.0.3", "yaml": "catalog:" }, "devDependencies": { diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts new file mode 100644 index 00000000000..41d41b40200 --- /dev/null +++ b/packages/shared/src/markdownLinks.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { markdownLinkDestinations } from "./markdownLinks.ts"; + +describe("markdownLinkDestinations", () => { + it("parses adjacent punctuation and balanced destination parentheses", () => { + expect( + markdownLinkDestinations( + "prefix[one](/tmp/attachments/text/id/one.txt),[two](/tmp/a(b)/two.txt).", + ), + ).toEqual(["/tmp/attachments/text/id/one.txt", "/tmp/a(b)/two.txt"]); + }); + + it("requires balanced unescaped labels and destinations", () => { + expect( + markdownLinkDestinations( + String.raw`missing](/missing) \[escaped](/escaped) [label]\(/escaped-open) [nested [label]](/a(b)/c)`, + ), + ).toEqual(["/a(b)/c"]); + }); + + it("ignores links inside inline and fenced code", () => { + expect( + markdownLinkDestinations( + [ + "`[inline](/inline)` [real](/real)", + "```md", + "[fenced](/fenced)", + "```", + "~~~", + "[tilde](/tilde)", + "~~~", + ].join("\n"), + ), + ).toEqual(["/real"]); + expect(markdownLinkDestinations("unmatched ` [still-real](/real)")).toEqual(["/real"]); + }); + + it("requires a valid closing fence and skips indented code blocks", () => { + expect( + markdownLinkDestinations( + [ + "```", + "[inside](/inside)", + "```not-a-close", + "[still-inside](/still-inside)", + "``` ", + "[outside](/outside)", + "", + " [space-indented](/space-indented)", + "\t[tab-indented](/tab-indented)", + "[final](/final)", + ].join("\n"), + ), + ).toEqual(["/outside", "/final"]); + }); + + it("allows indented paragraph and list continuations", () => { + expect( + markdownLinkDestinations( + [ + "paragraph", + " [paragraph-continuation](/paragraph)", + "- list item", + "\t[list-continuation](/list)", + ].join("\n"), + ), + ).toEqual(["/paragraph", "/list"]); + expect(markdownLinkDestinations(" [actual-code](/code)\n\t[more-code](/more-code)")).toEqual( + [], + ); + }); + + it("keeps block constructs from activating paragraph continuation", () => { + expect( + markdownLinkDestinations( + [ + "# heading", + " [after-heading-code](/after-heading-code)", + "", + "---", + " [after-break-code](/after-break-code)", + "", + "> [quoted-code](/quoted-code)", + "", + "- list item", + "", + " [list-code](/list-code)", + "- next item", + " [list-paragraph](/list-paragraph)", + "", + "paragraph", + " [paragraph-continuation](/paragraph-continuation)", + ].join("\n"), + ), + ).toEqual(["/list-paragraph", "/paragraph-continuation"]); + }); +}); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts new file mode 100644 index 00000000000..88d6aea4d17 --- /dev/null +++ b/packages/shared/src/markdownLinks.ts @@ -0,0 +1,19 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +interface MarkdownNode { + readonly type: string; + readonly url?: string; + readonly children?: ReadonlyArray; +} + +export function markdownLinkDestinations(markdown: string): ReadonlyArray { + const destinations: Array = []; + const visit = (node: MarkdownNode): void => { + if (node.type === "link" && typeof node.url === "string") { + destinations.push(node.url); + } + for (const child of node.children ?? []) visit(child); + }; + visit(fromMarkdown(markdown)); + return destinations; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee737f9e750..0b785e8e5b9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,6 +819,9 @@ importers: jose: specifier: 'catalog:' version: 6.2.2 + mdast-util-from-markdown: + specifier: ^2.0.3 + version: 2.0.3 yaml: specifier: ^2.9.0 version: 2.9.0