diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 087a96ea424f..49c2b61c21bd 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1,11 +1,13 @@ -import type { - EnvironmentId, - MessageId, - ModelSelection, - OrchestrationThreadShell, - ProviderInteractionMode, - RuntimeMode, - ServerConfig as T3ServerConfig, +import { + isTeleportedOut, + teleportSendDisabledReason, + type EnvironmentId, + type MessageId, + type ModelSelection, + type OrchestrationThreadShell, + type ProviderInteractionMode, + type RuntimeMode, + type ServerConfig as T3ServerConfig, } from "@t3tools/contracts"; import { detectComposerTrigger, @@ -290,10 +292,13 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const [previewImageUri, setPreviewImageUri] = useState(null); const hasContent = props.draftMessage.trim().length > 0 || props.draftAttachments.length > 0; + const teleportedOut = isTeleportedOut(props.selectedThread.teleport); + const teleportSendBlockReason = teleportSendDisabledReason(props.selectedThread.teleport); // Opening and presentation count as active so the composer stays expanded // while focus moves between its native editor and the settings picker. const isExpanded = isFocused || settingsSheetPresentation.isActive; - const canSend = hasContent; + const canSend = hasContent && !teleportedOut; + const placeholder = teleportSendBlockReason ?? props.placeholder; // Notify the parent from the derived value, not focus events: the parent // sizes the feed inset from this, and blur-during-sheet would otherwise @@ -537,6 +542,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; const handleSend = useCallback(async () => { + if (isTeleportedOut(props.selectedThread.teleport)) { + return; + } const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; inFlightThreadIdsRef.current.add(threadKey); @@ -560,6 +568,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.environmentLabel, props.selectedThread.id, props.selectedThread.title, + props.selectedThread.teleport, ]); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { @@ -788,7 +797,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} onPasteImages={(uris) => void props.onNativePasteImages(uris)} - placeholder={props.placeholder} + placeholder={placeholder} onFocus={handleFocus} onBlur={handleBlur} onSubmit={handleSend} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..f0b2cae38449 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo } from "react"; import { CommandId, + isTeleportedOut, MessageId, type EnvironmentId, type ModelSelection, @@ -137,6 +138,9 @@ export function useThreadComposerState() { const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); const draft = getComposerDraftSnapshot(threadKey); const thread = selectedThreadDetail ?? selectedThreadShell; + if (isTeleportedOut(thread.teleport)) { + return null; + } const text = draft.text.trim(); const attachments = draft.attachments; if (text.length === 0 && attachments.length === 0) { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..9beb707e6c51 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -124,6 +124,9 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, + [WS_METHODS.teleportListSessions]: AuthOrchestrationReadScope, + [WS_METHODS.teleportImportSessions]: AuthOrchestrationOperateScope, + [WS_METHODS.teleportExportSession]: AuthOrchestrationOperateScope, } as const satisfies Readonly>; export function requiredScopeForRpcMethod(method: string): AuthEnvironmentScope { diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index ee30d987591d..27e05f32b33e 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -92,6 +92,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.connectionProbe).toBe(true); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); + expect(second.capabilities.teleport).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); }), ); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..32d8680569b8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + teleport: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 1b89d6d4d8a8..45d112e14f9e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1379,4 +1379,269 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + + it("commits unarchive, T3 ownership, and history replacement together", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + const projectId = asProjectId("project-teleport-import"); + const threadId = ThreadId.make("thread-teleport-import"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-teleport-import-project"), + projectId, + title: "Teleport Import", + workspaceRoot: "/tmp/project-teleport-import", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-teleport-import-thread"), + threadId, + projectId, + title: "Old title", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make("cmd-teleport-import-native"), + threadId, + teleport: { + presence: "native", + provider: "codex", + externalSessionId: "session-import", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: createdAt, + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.history.replace", + commandId: CommandId.make("cmd-teleport-import-old-history"), + threadId, + messages: [ + { + id: asMessageId("old-1"), + role: "user", + text: "old", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-teleport-import-archive"), + threadId, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.teleport.import", + commandId: CommandId.make("cmd-teleport-import-commit"), + threadId, + teleport: { + presence: "t3", + provider: "codex", + externalSessionId: "session-import", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: createdAt, + }, + messages: [ + { + id: asMessageId("imported-1"), + role: "user", + text: "imported", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ); + + const readModel = await system.readModel(); + const thread = readModel.threads.find((candidate) => candidate.id === threadId); + expect(thread?.archivedAt).toBeNull(); + expect(thread?.teleport?.presence).toBe("t3"); + expect(thread?.messages.map((message) => message.text)).toEqual(["imported"]); + + await system.run( + engine.dispatch({ + type: "thread.teleport.import", + commandId: CommandId.make("cmd-teleport-import-retry"), + threadId, + teleport: { + presence: "t3", + provider: "codex", + externalSessionId: "session-import", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: createdAt, + }, + messages: [ + { + id: asMessageId("imported-1"), + role: "user", + text: "imported", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ); + const retried = await system.readModel(); + const retriedThread = retried.threads.find((candidate) => candidate.id === threadId); + expect(retriedThread?.teleport?.presence).toBe("t3"); + expect(retriedThread?.messages.map((message) => message.text)).toEqual(["imported"]); + + await system.dispose(); + }); + + it("rejects turn start against importing presence and recovers to native", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + const projectId = asProjectId("project-teleport-importing"); + const threadId = ThreadId.make("thread-teleport-importing"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-importing-project"), + projectId, + title: "Importing", + workspaceRoot: "/tmp/project-teleport-importing", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-importing-thread"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make("cmd-importing-fence"), + threadId, + teleport: { + presence: "importing", + provider: "codex", + externalSessionId: "session-importing", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: createdAt, + restorePresence: "native", + }, + createdAt, + }), + ); + + await expect( + system.run( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-importing-turn"), + threadId, + message: { + messageId: asMessageId("msg-importing"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }), + ), + ).rejects.toThrow("being imported"); + + await system.run( + engine.dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make("cmd-importing-recover"), + threadId, + teleport: { + presence: "native", + provider: "codex", + externalSessionId: "session-importing", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: createdAt, + }, + createdAt, + }), + ); + + const recovered = await system.readModel(); + const thread = recovered.threads.find((candidate) => candidate.id === threadId); + expect(thread?.teleport?.presence).toBe("native"); + expect(thread?.teleport?.restorePresence).toBeUndefined(); + + await expect( + system.run( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-importing-turn-after-recover"), + threadId, + message: { + messageId: asMessageId("msg-importing-after"), + role: "user", + text: "hello", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + createdAt, + }), + ), + ).rejects.toThrow("native CLI"); + + await system.dispose(); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..3ae2a2cd86ce 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -994,6 +994,297 @@ it.layer( ); }); +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-history-")), +)("OrchestrationProjectionPipeline", (it) => { + it.effect("prunes attachment files when thread history is replaced", () => + 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 threadId = ThreadId.make("Thread History.Files"); + const removeAttachmentId = "thread-history-files-00000000-0000-4000-8000-000000000001"; + const otherThreadAttachmentId = + "thread-history-files-extra-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-history-files-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-history-files"), + occurredAt: now, + commandId: CommandId.make("cmd-history-files-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-history-files-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-history-files"), + title: "Project History Files", + workspaceRoot: "/tmp/project-history-files", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-history-files-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-history-files-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-history-files-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-history-files"), + title: "Thread History Files", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("evt-history-files-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-history-files-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-history-files-3"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("message-history-old"), + role: "user", + text: "with image", + attachments: [ + { + type: "image", + id: removeAttachmentId, + name: "old.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + const removePath = path.join(attachmentsDir, `${removeAttachmentId}.png`); + const otherThreadPath = path.join(attachmentsDir, `${otherThreadAttachmentId}.png`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(removePath, "remove"); + yield* fileSystem.writeFileString(otherThreadPath, "other"); + assert.isTrue(yield* exists(removePath)); + assert.isTrue(yield* exists(otherThreadPath)); + + yield* appendAndProject({ + type: "thread.history-replaced", + eventId: EventId.make("evt-history-files-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("cmd-history-files-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-history-files-4"), + metadata: {}, + payload: { + threadId, + messages: [ + { + id: MessageId.make("message-history-new"), + role: "user", + text: "imported text", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + replacedAt: now, + }, + }); + + assert.isFalse(yield* exists(removePath)); + assert.isTrue(yield* exists(otherThreadPath)); + }), + ); +}); + +it.layer( + Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-collide-")), +)("OrchestrationProjectionPipeline", (it) => { + it.effect("does not prune another thread's attachments that share a sanitized id segment", () => + 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 leftThreadId = ThreadId.make("foo!"); + const rightThreadId = ThreadId.make("foo?"); + const leftAttachmentId = "foo-00000000-0000-4000-8000-000000000001"; + const rightAttachmentId = "foo-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-collide-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-collide"), + occurredAt: now, + commandId: CommandId.make("cmd-collide-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-collide-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-collide"), + title: "Project Collide", + workspaceRoot: "/tmp/project-collide", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + const createThread = (threadId: ThreadId, eventSuffix: string, title: string) => + appendAndProject({ + type: "thread.created", + eventId: EventId.make(`evt-collide-${eventSuffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-collide-${eventSuffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-collide-${eventSuffix}`), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-collide"), + title, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* createThread(leftThreadId, "2", "Left"); + yield* createThread(rightThreadId, "3", "Right"); + + const sendImage = ( + threadId: ThreadId, + messageId: string, + attachmentId: string, + eventSuffix: string, + ) => + appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make(`evt-collide-${eventSuffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make(`cmd-collide-${eventSuffix}`), + causationEventId: null, + correlationId: CorrelationId.make(`cmd-collide-${eventSuffix}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make(messageId), + role: "user", + text: "with image", + attachments: [ + { + type: "image", + id: attachmentId, + name: "shot.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + + yield* sendImage(leftThreadId, "message-left", leftAttachmentId, "4"); + yield* sendImage(rightThreadId, "message-right", rightAttachmentId, "5"); + + const leftPath = path.join(attachmentsDir, `${leftAttachmentId}.png`); + const rightPath = path.join(attachmentsDir, `${rightAttachmentId}.png`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(leftPath, "left"); + yield* fileSystem.writeFileString(rightPath, "right"); + + yield* appendAndProject({ + type: "thread.history-replaced", + eventId: EventId.make("evt-collide-6"), + aggregateKind: "thread", + aggregateId: leftThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-collide-6"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-collide-6"), + metadata: {}, + payload: { + threadId: leftThreadId, + messages: [ + { + id: MessageId.make("message-left-new"), + role: "user", + text: "imported text", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + replacedAt: now, + }, + }); + + assert.isFalse(yield* exists(leftPath)); + assert.isTrue(yield* exists(rightPath)); + }), + ); +}); + it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))( "OrchestrationProjectionPipeline", (it) => { @@ -2789,4 +3080,96 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { ]); }), ); + + it.effect("projects teleport import unarchive, presence, and history together", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngineService; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-01-01T00:00:00.000Z"; + const projectId = ProjectId.make("project-teleport-import"); + const threadId = ThreadId.make("thread-teleport-import"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-pipeline-import-project"), + projectId, + title: "Pipeline Import", + workspaceRoot: "/tmp/project-teleport-import", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-pipeline-import-thread"), + threadId, + projectId, + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: "default", + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + yield* engine.dispatch({ + type: "thread.archive", + commandId: CommandId.make("cmd-pipeline-import-archive"), + threadId, + }); + yield* engine.dispatch({ + type: "thread.teleport.import", + commandId: CommandId.make("cmd-pipeline-import-commit"), + threadId, + teleport: { + presence: "t3", + provider: "codex", + externalSessionId: "session-pipeline", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: createdAt, + }, + messages: [ + { + id: MessageId.make("imported-pipeline"), + role: "user", + text: "imported", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }); + + const threadRows = yield* sql<{ + readonly archivedAt: string | null; + readonly teleportJson: string | null; + }>` + SELECT + archived_at AS "archivedAt", + teleport_json AS "teleportJson" + FROM projection_threads + WHERE thread_id = 'thread-teleport-import' + `; + assert.equal(threadRows[0]?.archivedAt, null); + assert.isTrue(threadRows[0]?.teleportJson?.includes('"presence":"t3"') === true); + + const messageRows = yield* sql<{ readonly text: string }>` + SELECT text + FROM projection_thread_messages + WHERE thread_id = 'thread-teleport-import' + ORDER BY created_at + `; + assert.deepEqual( + messageRows.map((row) => row.text), + ["imported"], + ); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..4725bc341da2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -103,7 +103,12 @@ interface ProjectorDefinition { interface AttachmentSideEffects { readonly deletedThreadIds: Set; - readonly prunedThreadRelativePaths: Map>; + readonly prunedThreadAttachments: Map; +} + +interface ThreadAttachmentPrunePlan { + readonly ownedRelativePaths: ReadonlySet; + readonly keptRelativePaths: ReadonlySet; } const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( @@ -329,7 +334,9 @@ function retainProjectionProposedPlansAfterRevert( function collectThreadAttachmentRelativePaths( threadId: string, - messages: ReadonlyArray, + messages: ReadonlyArray<{ + readonly attachments?: ReadonlyArray | null | undefined; + }>, ): Set { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { @@ -351,6 +358,21 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } +function threadAttachmentPrunePlan( + threadId: string, + ownedMessages: ReadonlyArray<{ + readonly attachments?: ReadonlyArray | null | undefined; + }>, + keptMessages: ReadonlyArray<{ + readonly attachments?: ReadonlyArray | null | undefined; + }>, +): ThreadAttachmentPrunePlan { + return { + ownedRelativePaths: collectThreadAttachmentRelativePaths(threadId, ownedMessages), + keptRelativePaths: collectThreadAttachmentRelativePaths(threadId, keptMessages), + }; +} + const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( sideEffects: AttachmentSideEffects, ) { @@ -404,23 +426,16 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ); }); - const pruneThreadAttachmentEntry = Effect.fn("pruneThreadAttachmentEntry")(function* ( - threadSegment: string, - keptThreadRelativePaths: Set, - entry: string, + const pruneOwnedAttachmentEntry = Effect.fn("pruneOwnedAttachmentEntry")(function* ( + relativePath: string, ) { - const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if (relativePath.length === 0 || relativePath.includes("/")) { + if (relativePath.length === 0 || relativePath.includes("/") || relativePath.includes("\\")) { return; } const attachmentId = parseAttachmentIdFromRelativePath(relativePath); if (!attachmentId) { return; } - const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); - if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { - return; - } const absolutePath = path.join(attachmentsRootDir, relativePath); const fileInfo = yield* fileSystem.stat(absolutePath).pipe(Effect.orElseSucceed(() => null)); @@ -428,31 +443,23 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* return; } - if (!keptThreadRelativePaths.has(relativePath)) { - yield* fileSystem.remove(absolutePath, { force: true }); - } + yield* fileSystem.remove(absolutePath, { force: true }); }); const pruneThreadAttachments = Effect.fn("pruneThreadAttachments")(function* ( threadId: string, - keptThreadRelativePaths: Set, + plan: ThreadAttachmentPrunePlan, ) { if (sideEffects.deletedThreadIds.has(threadId)) { return; } - const threadSegment = toSafeThreadAttachmentSegment(threadId); - if (!threadSegment) { - yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); - return; - } - - const entries = yield* readAttachmentRootEntries; - yield* Effect.forEach( - entries, - (entry) => pruneThreadAttachmentEntry(threadSegment, keptThreadRelativePaths, entry), - { concurrency: 1 }, + const relativePathsToRemove = [...plan.ownedRelativePaths].filter( + (relativePath) => !plan.keptRelativePaths.has(relativePath), ); + yield* Effect.forEach(relativePathsToRemove, pruneOwnedAttachmentEntry, { + concurrency: 1, + }); }); yield* Effect.forEach(sideEffects.deletedThreadIds, deleteThreadAttachments, { @@ -460,9 +467,8 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* }); yield* Effect.forEach( - sideEffects.prunedThreadRelativePaths.entries(), - ([threadId, keptThreadRelativePaths]) => - pruneThreadAttachments(threadId, keptThreadRelativePaths), + sideEffects.prunedThreadAttachments.entries(), + ([threadId, plan]) => pruneThreadAttachments(threadId, plan), { concurrency: 1 }, ); }); @@ -628,6 +634,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingUserInputCount: 0, hasActionableProposedPlan: 0, deletedAt: null, + teleport: null, }); return; @@ -850,6 +857,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.teleported": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + teleport: event.payload.teleport, + updatedAt: event.payload.updatedAt, + }); + return; + } + case "thread.message-sent": case "thread.proposed-plan-upserted": case "thread.activity-appended": @@ -869,6 +891,22 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + latestTurnId: null, + updatedAt: event.occurredAt, + }); + yield* refreshThreadShellSummary(event.payload.threadId); + return; + } + case "thread.session-set": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -1012,9 +1050,41 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* Effect.forEach(keptRows, projectionThreadMessageRepository.upsert, { concurrency: 1, }).pipe(Effect.asVoid); - attachmentSideEffects.prunedThreadRelativePaths.set( + attachmentSideEffects.prunedThreadAttachments.set( event.payload.threadId, - collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows), + threadAttachmentPrunePlan(event.payload.threadId, existingRows, keptRows), + ); + return; + } + + case "thread.history-replaced": { + const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* projectionThreadMessageRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach( + event.payload.messages, + (message) => + projectionThreadMessageRepository.upsert({ + messageId: message.id, + threadId: event.payload.threadId, + turnId: message.turnId, + role: message.role, + text: message.text, + ...(message.attachments !== undefined + ? { attachments: [...message.attachments] } + : {}), + isStreaming: message.streaming, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + attachmentSideEffects.prunedThreadAttachments.set( + event.payload.threadId, + threadAttachmentPrunePlan(event.payload.threadId, existingRows, event.payload.messages), ); return; } @@ -1041,6 +1111,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.history-replaced": + yield* projectionThreadProposedPlanRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadProposedPlanRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1095,6 +1171,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.history-replaced": + yield* projectionThreadActivityRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.reverted": { const existingRows = yield* projectionThreadActivityRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1448,6 +1530,12 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": + yield* projectionTurnRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + case "thread.reverted": { const existingTurns = yield* projectionTurnRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1601,6 +1689,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.history-replaced": { + const pendingRows = yield* projectionPendingApprovalRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* Effect.forEach( + pendingRows, + (row) => + projectionPendingApprovalRepository.deleteByRequestId({ + requestId: row.requestId, + }), + { concurrency: 1 }, + ).pipe(Effect.asVoid); + return; + } + default: return; } @@ -1651,7 +1754,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ) { const attachmentSideEffects: AttachmentSideEffects = { deletedThreadIds: new Set(), - prunedThreadRelativePaths: new Map>(), + prunedThreadAttachments: new Map(), }; yield* sql.withTransaction( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 83ae3cfe049a..37592ee0e8dc 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -2459,4 +2459,71 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = } }), ); + + it.effect("hydrates teleport presence onto thread detail and shell snapshots", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) + VALUES ( + 'project-teleport', 'Teleport', '/tmp/project-teleport', '[]', + '2026-08-14T00:00:00.000Z', '2026-08-14T00:00:00.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + created_at, updated_at, deleted_at, teleport_json + ) + VALUES ( + 'thread-teleport', 'project-teleport', 'Native thread', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 0, 0, 0, '2026-08-14T00:00:00.000Z', '2026-08-14T00:00:00.000Z', NULL, + '{"presence":"native","provider":"codex","externalSessionId":"session-1","nativePath":"/tmp/native","lastSyncedAt":"2026-08-14T00:00:01.000Z"}' + ) + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES (${projector}, 1, '2026-08-14T00:00:01.000Z') + `; + } + + const expectedTeleport = { + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: "2026-08-14T00:00:01.000Z", + }; + const detail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-teleport")); + assert.equal(detail._tag, "Some"); + if (detail._tag === "Some") { + assert.deepEqual(detail.value.teleport, expectedTeleport); + } + const shell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-teleport")); + assert.equal(shell._tag, "Some"); + if (shell._tag === "Some") { + assert.deepEqual(shell.value.teleport, expectedTeleport); + } + + yield* sql` + UPDATE projection_threads + SET archived_at = '2026-08-14T00:00:02.000Z' + WHERE thread_id = 'thread-teleport' + `; + const archived = yield* snapshotQuery.getArchivedShellSnapshot(); + assert.equal(archived.threads[0]?.id, ThreadId.make("thread-teleport")); + assert.deepEqual(archived.threads[0]?.teleport, expectedTeleport); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..413ae1090612 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -24,6 +24,7 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + TeleportThreadState, ThreadId, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; @@ -89,6 +90,9 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + teleport: Schema.NullOr(Schema.fromJsonString(TeleportThreadState)).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -438,7 +442,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads ORDER BY created_at ASC, thread_id ASC `, @@ -474,7 +479,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL @@ -512,7 +518,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL @@ -954,7 +961,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE thread_id = ${threadId} AND deleted_at IS NULL @@ -1150,7 +1158,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { 'thread.activity-appended', 'thread.turn-diff-completed', 'thread.reverted', - 'thread.session-set' + 'thread.session-set', + 'thread.history-replaced', + 'thread.teleported' ) `, }); @@ -1711,6 +1721,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: activitiesByThread.get(row.threadId) ?? [], checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, + ...(row.teleport == null ? {} : { teleport: row.teleport }), })); const snapshot = { @@ -1918,6 +1929,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { activities: [], checkpoints: [], session: sessionByThread.get(row.threadId) ?? null, + ...(row.teleport == null ? {} : { teleport: row.teleport }), }); } @@ -2057,6 +2069,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + ...(row.teleport == null ? {} : { teleport: row.teleport }), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2202,6 +2215,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { row.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(row.threadId), + ...(row.teleport == null ? {} : { teleport: row.teleport }), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2481,6 +2495,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { threadRow.value.threadId, ), planProgress: threadPlanProgress.getThreadPlanProgress(threadRow.value.threadId), + ...(threadRow.value.teleport == null ? {} : { teleport: threadRow.value.teleport }), } satisfies OrchestrationThreadShell); }); @@ -2655,6 +2670,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { completedAt: row.completedAt, })), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + ...(threadRow.value.teleport == null ? {} : { teleport: threadRow.value.teleport }), }; return Option.some( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..cdc3a0a01fa9 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -153,6 +153,9 @@ describe("ProviderCommandReactor", () => { readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; + readonly interruptTurnEffect?: ( + input: unknown, + ) => Effect.Effect; }) { const now = "2026-01-01T00:00:00.000Z"; const baseDir = @@ -235,7 +238,9 @@ describe("ProviderCommandReactor", () => { turnId: asTurnId("turn-1"), }), ); - const interruptTurn = vi.fn((_: unknown) => Effect.void); + const interruptTurn = vi.fn( + (payload: unknown) => input?.interruptTurnEffect?.(payload) ?? Effect.void, + ); const respondToRequest = vi.fn(() => Effect.void); const respondToUserInput = vi.fn(() => Effect.void); const stopSession = vi.fn((input: unknown) => @@ -2469,6 +2474,16 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project", + resumeCursor: { opaque: "resume-live-interrupt" }, + createdAt: now, + updatedAt: now, + }); await Effect.runPromise( harness.engine.dispatch({ @@ -2486,6 +2501,105 @@ describe("ProviderCommandReactor", () => { }); }); + it("settles a projected running session when no live provider session exists", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-zombie"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "opencode", + runtimeMode: "full-access", + activeTurnId: asTurnId("turn-zombie"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-zombie"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-zombie"), + createdAt: now, + }), + ); + + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return thread?.session?.status === "ready" && thread.session.activeTurnId === null; + }); + expect(harness.interruptTurn.mock.calls).toEqual([]); + }); + + it("settles the projected session when provider interrupt fails", async () => { + const harness = await createHarness({ + interruptTurnEffect: () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "turn/interrupt", + detail: "provider interrupt hung", + }), + ), + }); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-interrupt-fail"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-fail"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + harness.runtimeSessions.push({ + provider: ProviderDriverKind.make("codex"), + status: "running", + runtimeMode: "approval-required", + threadId: ThreadId.make("thread-1"), + cwd: "/tmp/provider-project", + resumeCursor: { opaque: "resume-interrupt-fail" }, + createdAt: now, + updatedAt: now, + }); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.interrupt", + commandId: CommandId.make("cmd-turn-interrupt-fail"), + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-fail"), + createdAt: now, + }), + ); + + await waitFor(() => harness.interruptTurn.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return thread?.session?.status === "ready" && thread.session.activeTurnId === null; + }); + }); + it("starts a fresh session when only projected session state exists", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..8df036218a45 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -7,6 +7,7 @@ import { ProviderDriverKind, type ProjectId, type OrchestrationSession, + type OrchestrationThread, ThreadId, type ProviderSession, type RuntimeMode, @@ -1173,6 +1174,32 @@ const make = Effect.gen(function* () { .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); + const settleInterruptedThreadSession = (input: { + readonly thread: OrchestrationThread; + readonly createdAt: string; + }) => { + const session = input.thread.session; + if (!session || session.status === "stopped") { + return Effect.void; + } + return setThreadSession({ + threadId: input.thread.id, + session: { + threadId: input.thread.id, + status: "ready", + providerName: session.providerName, + ...(session.providerInstanceId !== undefined + ? { providerInstanceId: session.providerInstanceId } + : {}), + runtimeMode: session.runtimeMode, + activeTurnId: null, + lastError: session.lastError, + updatedAt: input.createdAt, + }, + createdAt: input.createdAt, + }); + }; + const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { @@ -1192,8 +1219,36 @@ const make = Effect.gen(function* () { }); } - // Orchestration turn ids are not provider turn ids, so interrupt by session. - yield* providerService.interruptTurn({ threadId: event.payload.threadId }); + const liveSession = (yield* providerService.listSessions()).find( + (session) => session.threadId === thread.id, + ); + if (!liveSession) { + // Projection can stay "running" after the in-memory provider session + // is gone (restart, earlier stop). Do not recover just to interrupt. + return yield* settleInterruptedThreadSession({ + thread, + createdAt: event.payload.createdAt, + }); + } + + // Orchestration turn ids are not provider turn ids, so interrupt by + // session. Passing the orchestration id would make Codex interrupt the + // wrong turn and make Grok ignore the request. + yield* providerService.interruptTurn({ threadId: event.payload.threadId }).pipe( + Effect.catchCause((cause) => + settleInterruptedThreadSession({ + thread, + createdAt: event.payload.createdAt, + }).pipe( + Effect.andThen( + Effect.logWarning("provider turn interrupt failed; settled projected session", { + threadId: thread.id, + cause: Cause.pretty(cause), + }), + ), + ), + ), + ); }); const processApprovalResponseRequested = Effect.fn("processApprovalResponseRequested")(function* ( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 1e1374c966b6..e318f0e3972b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -368,6 +368,45 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("turn failed"); }); + it("maps turn.aborted into a ready session with no active turn", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "turn.started", + eventId: asEventId("evt-turn-started-before-abort"), + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-1"), + createdAt: now, + turnId: asTurnId("turn-abort"), + }); + + await waitForThread( + harness.readModel, + (thread) => + thread.session?.status === "running" && thread.session?.activeTurnId === "turn-abort", + ); + + harness.emit({ + type: "turn.aborted", + eventId: asEventId("evt-turn-aborted"), + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-1"), + createdAt: "2026-01-01T00:00:05.000Z", + turnId: asTurnId("turn-abort"), + payload: { + reason: "Interrupted by user.", + }, + }); + + const thread = await waitForThread( + harness.readModel, + (entry) => entry.session?.status === "ready" && entry.session?.activeTurnId === null, + ); + expect(thread.session?.status).toBe("ready"); + expect(thread.session?.activeTurnId).toBeNull(); + }); + it("applies provider session.state.changed transitions directly", async () => { const harness = await createHarness(); const waitingAt = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 953ba1ec9b0d..eb85b1993ad1 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1540,6 +1540,7 @@ const make = Effect.gen(function* () { case "turn.started": return !conflictsWithActiveTurn || conflictingTurnStartIsPendingTurnStart; case "turn.completed": + case "turn.aborted": if (conflictsWithActiveTurn || missingTurnForActiveTurn) { return false; } @@ -1570,7 +1571,8 @@ const make = Effect.gen(function* () { event.type === "session.exited" || event.type === "thread.started" || event.type === "turn.started" || - event.type === "turn.completed" + event.type === "turn.completed" || + event.type === "turn.aborted" ) { const status = (() => { switch (event.type) { @@ -1586,6 +1588,8 @@ const make = Effect.gen(function* () { return normalizeRuntimeTurnState(event.payload.state) === "failed" ? "error" : "ready"; + case "turn.aborted": + return "ready"; case "session.started": case "thread.started": // Provider thread/session start notifications can arrive during an @@ -1596,7 +1600,9 @@ const make = Effect.gen(function* () { const nextActiveTurnId = event.type === "turn.started" ? (eventTurnId ?? null) - : event.type === "turn.completed" || event.type === "session.exited" + : event.type === "turn.completed" || + event.type === "turn.aborted" || + event.type === "session.exited" ? null : event.type === "session.state.changed" && !sessionStatusAllowsActiveTurn( @@ -1837,7 +1843,7 @@ const make = Effect.gen(function* () { }); } - if (event.type === "turn.completed") { + if (event.type === "turn.completed" || event.type === "turn.aborted") { const detailedThread = yield* getLoadedThreadDetail(); const messages = detailedThread?.messages ?? []; const proposedPlans = detailedThread?.proposedPlans ?? []; diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 7e866cf89592..cd6ac08a68eb 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -22,6 +22,8 @@ import { ThreadTurnDiffCompletedPayload as ContractsThreadTurnDiffCompletedPayloadSchema, ThreadRevertedPayload as ContractsThreadRevertedPayloadSchema, ThreadActivityAppendedPayload as ContractsThreadActivityAppendedPayloadSchema, + ThreadHistoryReplacedPayload as ContractsThreadHistoryReplacedPayloadSchema, + ThreadTeleportedPayload as ContractsThreadTeleportedPayloadSchema, ThreadTurnStartRequestedPayload as ContractsThreadTurnStartRequestedPayloadSchema, ThreadTurnInterruptRequestedPayload as ContractsThreadTurnInterruptRequestedPayloadSchema, ThreadApprovalResponseRequestedPayload as ContractsThreadApprovalResponseRequestedPayloadSchema, @@ -55,6 +57,8 @@ export const ThreadSessionSetPayload = ContractsThreadSessionSetPayloadSchema; export const ThreadTurnDiffCompletedPayload = ContractsThreadTurnDiffCompletedPayloadSchema; export const ThreadRevertedPayload = ContractsThreadRevertedPayloadSchema; export const ThreadActivityAppendedPayload = ContractsThreadActivityAppendedPayloadSchema; +export const ThreadHistoryReplacedPayload = ContractsThreadHistoryReplacedPayloadSchema; +export const ThreadTeleportedPayload = ContractsThreadTeleportedPayloadSchema; export const ThreadTurnStartRequestedPayload = ContractsThreadTurnStartRequestedPayloadSchema; export const ThreadTurnInterruptRequestedPayload = diff --git a/apps/server/src/orchestration/decider.teleport.test.ts b/apps/server/src/orchestration/decider.teleport.test.ts new file mode 100644 index 000000000000..5278661c67d0 --- /dev/null +++ b/apps/server/src/orchestration/decider.teleport.test.ts @@ -0,0 +1,330 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, + type OrchestrationThread, + type TeleportThreadState, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +const NATIVE_TELEPORT: TeleportThreadState = { + presence: "native", + provider: "codex", + externalSessionId: "01a00270-6f96-7ce3-9244-ab159194e668", + nativePath: "/home/user/.codex/sessions/session.jsonl", + lastSyncedAt: NOW, +}; + +function makeReadModel(input: { + readonly teleport?: OrchestrationThread["teleport"]; + readonly session?: OrchestrationThread["session"]; + readonly messages?: OrchestrationThread["messages"]; + readonly archivedAt?: OrchestrationThread["archivedAt"]; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: input.archivedAt ?? null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: input.messages ?? [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: input.session ?? null, + ...(input.teleport !== undefined ? { teleport: input.teleport } : {}), + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("teleport thread decider", (it) => { + it.effect("sets teleport presence on a thread", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.teleport.set", + commandId: CommandId.make("cmd-teleport-set"), + threadId: ThreadId.make("thread-1"), + teleport: NATIVE_TELEPORT, + createdAt: NOW, + }, + readModel: makeReadModel({}), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.teleported"); + if (events[0]?.type === "thread.teleported") { + expect(events[0].payload.teleport).toEqual(NATIVE_TELEPORT); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("rejects turn start while the thread is in the native CLI", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "hello", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ teleport: NATIVE_TELEPORT }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("native CLI"); + } + }), + ); + + it.effect("allows turn start while the thread is owned by T3", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "hello", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ + teleport: { + ...NATIVE_TELEPORT, + presence: "t3", + }, + }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events.map((entry) => entry.type)).toContain("thread.turn-start-requested"); + }), + ); + + it.effect("rejects native teleport while the T3 session is running", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.teleport.set", + commandId: CommandId.make("cmd-teleport-busy"), + threadId: ThreadId.make("thread-1"), + teleport: NATIVE_TELEPORT, + createdAt: NOW, + }, + readModel: makeReadModel({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("starting or running"); + } + }), + ); + + it.effect("rejects history replace while the T3 session is running", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.replace", + commandId: CommandId.make("cmd-history-busy"), + threadId: ThreadId.make("thread-1"), + messages: [], + createdAt: NOW, + }, + readModel: makeReadModel({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("starting or running"); + } + }), + ); + + it.effect("rejects turn start while a native import is in progress", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-importing"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "hello", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: NOW, + }, + readModel: makeReadModel({ + teleport: { + ...NATIVE_TELEPORT, + presence: "importing", + restorePresence: "native", + }, + }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("being imported"); + } + }), + ); + + it.effect("imports native history, T3 ownership, and unarchive as one command", () => + Effect.gen(function* () { + const decided = yield* decideOrchestrationCommand({ + command: { + type: "thread.teleport.import", + commandId: CommandId.make("cmd-teleport-import"), + threadId: ThreadId.make("thread-1"), + teleport: { + ...NATIVE_TELEPORT, + presence: "t3", + }, + messages: [ + { + id: MessageId.make("imported-1"), + role: "user", + text: "imported", + turnId: null, + streaming: false, + createdAt: NOW, + updatedAt: NOW, + }, + ], + createdAt: NOW, + }, + readModel: makeReadModel({ + teleport: NATIVE_TELEPORT, + archivedAt: NOW, + messages: [ + { + id: MessageId.make("old-1"), + role: "user", + text: "old", + turnId: null, + streaming: false, + createdAt: "2025-12-01T00:00:00.000Z", + updatedAt: "2025-12-01T00:00:00.000Z", + }, + ], + }), + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events.map((event) => event.type)).toEqual([ + "thread.unarchived", + "thread.teleported", + "thread.history-replaced", + ]); + expect(new Set(events.map((event) => event.commandId)).size).toBe(1); + const teleported = events[1]; + const replaced = events[2]; + expect(teleported?.type).toBe("thread.teleported"); + if (teleported?.type === "thread.teleported") { + expect(teleported.payload.teleport.presence).toBe("t3"); + expect(teleported.payload.teleport.restorePresence).toBeUndefined(); + } + expect(replaced?.type).toBe("thread.history-replaced"); + if (replaced?.type === "thread.history-replaced") { + expect(replaced.payload.messages).toHaveLength(1); + expect(replaced.payload.messages[0]?.text).toBe("imported"); + } + }), + ); + + it.effect("rejects native history import while the T3 session is running", () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: { + type: "thread.teleport.import", + commandId: CommandId.make("cmd-import-busy"), + threadId: ThreadId.make("thread-1"), + teleport: { + ...NATIVE_TELEPORT, + presence: "t3", + }, + messages: [], + createdAt: NOW, + }, + readModel: makeReadModel({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (error._tag === "OrchestrationCommandInvariantError") { + expect(error.detail).toContain("starting or running"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..61449b24888b 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -3,6 +3,7 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + teleportPresenceBlocksThreadTurnStart, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -953,6 +954,15 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Proposed plan '${sourceProposedPlan?.planId}' belongs to thread '${sourceThread.id}' in a different project.`, }); } + if (teleportPresenceBlocksThreadTurnStart(targetThread.teleport?.presence)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + targetThread.teleport?.presence === "importing" + ? "This thread is being imported from the native CLI." + : "This thread is in the native CLI. Import it before sending messages from T3.", + }); + } const userMessageEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", @@ -1402,6 +1412,162 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" return [unsettledEvent, activityAppendedEvent]; } + case "thread.history.replace": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const status = thread.session?.status; + if (status === "starting" || status === "running") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Cannot replace thread history while its T3 session is starting or running.", + }); + } + if (threadHasQueuedTurnStart(thread, command.createdAt)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Cannot replace thread history while a turn start is queued.", + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.history-replaced", + payload: { + threadId: command.threadId, + messages: command.messages, + replacedAt: command.createdAt, + }, + }; + } + + case "thread.teleport.set": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (teleportPresenceBlocksThreadTurnStart(command.teleport.presence)) { + const status = thread.session?.status; + if (status === "starting" || status === "running") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + command.teleport.presence === "importing" + ? "Cannot import a thread while its T3 session is starting or running." + : "Cannot teleport a thread to the native CLI while its T3 session is starting or running.", + }); + } + if (threadHasQueuedTurnStart(thread, command.createdAt)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: + command.teleport.presence === "importing" + ? "Cannot import a thread while a turn start is queued." + : "Cannot teleport a thread to the native CLI while a turn start is queued.", + }); + } + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.teleported", + payload: { + threadId: command.threadId, + teleport: command.teleport, + updatedAt: command.createdAt, + }, + }; + } + + case "thread.teleport.import": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if (command.teleport.presence !== "t3") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Native session import must commit T3 ownership.", + }); + } + const status = thread.session?.status; + if (status === "starting" || status === "running") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Cannot import native history while the T3 session is starting or running.", + }); + } + // Turn admission is already blocked by native/importing presence, or by + // the importing fence TeleportService sets before this command. A recent + // user message with no latestTurn is the history being replaced. + const events: Array> = []; + if (thread.archivedAt !== null) { + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.unarchived", + payload: { + threadId: command.threadId, + updatedAt: command.createdAt, + }, + }); + } + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.teleported", + payload: { + threadId: command.threadId, + teleport: { + presence: "t3", + provider: command.teleport.provider, + ...(command.teleport.providerInstanceId === undefined + ? {} + : { providerInstanceId: command.teleport.providerInstanceId }), + externalSessionId: command.teleport.externalSessionId, + nativePath: command.teleport.nativePath, + lastSyncedAt: command.teleport.lastSyncedAt, + }, + updatedAt: command.createdAt, + }, + }); + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.history-replaced", + payload: { + threadId: command.threadId, + messages: command.messages, + replacedAt: command.createdAt, + }, + }); + return events; + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..3fd943cc450d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -33,6 +33,8 @@ import { ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, + ThreadHistoryReplacedPayload, + ThreadTeleportedPayload, } from "./Schemas.ts"; type ThreadPatch = Partial>; @@ -800,6 +802,51 @@ export function projectEvent( }), ); + case "thread.history-replaced": + return decodeForEvent( + ThreadHistoryReplacedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + messages: payload.messages.slice(-MAX_THREAD_MESSAGES), + proposedPlans: [], + activities: [], + checkpoints: [], + latestTurn: null, + updatedAt: payload.replacedAt, + }), + }; + }), + ); + + case "thread.teleported": + return decodeForEvent(ThreadTeleportedPayload, event.payload, event.type, "payload").pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + teleport: payload.teleport, + updatedAt: payload.updatedAt, + }), + }; + }), + ); + default: return Effect.succeed(nextBase); } diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..16e64cfc292c 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,14 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, TeleportThreadState } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + teleport: Schema.NullOr(Schema.fromJsonString(TeleportThreadState)).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -55,7 +58,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, - deleted_at + deleted_at, + teleport_json ) VALUES ( ${row.threadId}, @@ -82,7 +86,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, ${row.hasActionableProposedPlan}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.teleport == null ? null : JSON.stringify(row.teleport)} ) ON CONFLICT (thread_id) DO UPDATE SET @@ -109,7 +114,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + teleport_json = excluded.teleport_json `, }); @@ -143,7 +149,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE thread_id = ${threadId} `, @@ -179,7 +186,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + teleport_json AS "teleport" FROM projection_threads WHERE project_id = ${projectId} ORDER BY created_at ASC, thread_id ASC diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..c52efe5829e7 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionThreadsTeleport.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionThreadsTeleport", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts new file mode 100644 index 000000000000..d112b7b70238 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadsTeleport.ts @@ -0,0 +1,63 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "teleport_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN teleport_json TEXT + `; + } + + yield* sql` + UPDATE projection_threads + SET + teleport_json = ( + SELECT + json_object( + 'presence', + CASE + WHEN json_extract(runtime.runtime_payload_json, '$.teleport.presence') IS NOT NULL THEN json_extract( + runtime.runtime_payload_json, + '$.teleport.presence' + ) + WHEN json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncDirection') = 'export' THEN 'native' + ELSE 't3' + END, + 'provider', + runtime.provider_name, + 'externalSessionId', + json_extract(runtime.runtime_payload_json, '$.teleport.externalSessionId'), + 'nativePath', + json_extract(runtime.runtime_payload_json, '$.teleport.nativePath'), + 'lastSyncedAt', + json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncedAt') + ) + FROM provider_session_runtime AS runtime + WHERE + runtime.thread_id = projection_threads.thread_id + AND runtime.provider_name IN ('codex', 'claudeAgent', 'opencode', 'grok') + AND json_extract(runtime.runtime_payload_json, '$.teleport.externalSessionId') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.nativePath') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncedAt') IS NOT NULL + ) + WHERE + teleport_json IS NULL + AND EXISTS ( + SELECT + 1 + FROM provider_session_runtime AS runtime + WHERE + runtime.thread_id = projection_threads.thread_id + AND runtime.provider_name IN ('codex', 'claudeAgent', 'opencode', 'grok') + AND json_extract(runtime.runtime_payload_json, '$.teleport.externalSessionId') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.nativePath') IS NOT NULL + AND json_extract(runtime.runtime_payload_json, '$.teleport.lastSyncedAt') IS NOT NULL + ) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..5a3c176fdc03 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,6 +14,7 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + TeleportThreadState, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -50,6 +51,7 @@ export const ProjectionThread = Schema.Struct({ pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), + teleport: Schema.optional(Schema.NullOr(TeleportThreadState)), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index eea328e05d1e..e2450e542a88 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -9,6 +9,7 @@ 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 Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -20,6 +21,7 @@ import { ProviderDriverKind, ProviderInstanceId, ThreadId, + TurnId, } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; @@ -61,6 +63,8 @@ const runtimeMock = { sessionCreateInputs: [] as Array>, authHeaders: [] as Array, abortCalls: [] as string[], + abortHold: null as Promise | null, + abortStarted: null as (() => void) | null, closeCalls: [] as string[], revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, promptCalls: [] as Array, @@ -81,6 +85,8 @@ const runtimeMock = { this.state.sessionCreateInputs.length = 0; this.state.authHeaders.length = 0; this.state.abortCalls.length = 0; + this.state.abortHold = null; + this.state.abortStarted = null; this.state.closeCalls.length = 0; this.state.revertCalls.length = 0; this.state.promptCalls.length = 0; @@ -176,6 +182,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, abort: async ({ sessionID }: { sessionID: string }) => { runtimeMock.state.abortCalls.push(sessionID); + runtimeMock.state.abortStarted?.(); + if (runtimeMock.state.abortHold) { + await runtimeMock.state.abortHold; + } }, promptAsync: async (input: unknown) => { runtimeMock.state.promptCalls.push(input); @@ -809,6 +819,174 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("emits turn.aborted and returns the session to ready on interrupt", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupt"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.aborted"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "keep going", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + yield* adapter.interruptTurn(threadId, turn.turnId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(events[0]?.type, "turn.aborted"); + const sessions = yield* adapter.listSessions(); + const session = sessions.find((entry) => entry.threadId === threadId); + NodeAssert.equal(session?.status, "ready"); + NodeAssert.equal(session?.activeTurnId, undefined); + NodeAssert.equal( + runtimeMock.state.abortCalls.includes("http://127.0.0.1:9999/session"), + true, + ); + }), + ); + + it.effect("does not start a new turn until a pending session.abort finishes", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupt-serialize"); + let releaseAbort: () => void = () => {}; + const abortStarted = new Promise((resolve) => { + runtimeMock.state.abortStarted = resolve; + }); + runtimeMock.state.abortHold = new Promise((resolve) => { + releaseAbort = resolve; + }); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const firstTurn = yield* adapter.sendTurn({ + threadId, + input: "keep going", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + const interruptFiber = yield* adapter + .interruptTurn(threadId, firstTurn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted); + + const sendFiber = yield* adapter + .sendTurn({ + threadId, + input: "next prompt", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }) + .pipe(Effect.forkChild); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 1); + + releaseAbort(); + yield* Fiber.join(interruptFiber); + const secondTurn = yield* Fiber.join(sendFiber); + NodeAssert.equal(runtimeMock.state.promptCalls.length, 2); + NodeAssert.notEqual(String(secondTurn.turnId), String(firstTurn.turnId)); + }), + ); + + it.effect("emits a single turn.aborted when interrupt is called concurrently", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-interrupt-concurrent"); + const abortedCount = yield* Ref.make(0); + yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.aborted"), + Stream.tap(() => Ref.update(abortedCount, (count) => count + 1)), + Stream.runDrain, + Effect.forkChild, + ); + + let releaseAbort: () => void = () => {}; + const abortStarted = new Promise((resolve) => { + runtimeMock.state.abortStarted = resolve; + }); + runtimeMock.state.abortHold = new Promise((resolve) => { + releaseAbort = resolve; + }); + + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const turn = yield* adapter.sendTurn({ + threadId, + input: "keep going", + modelSelection: { + instanceId: ProviderInstanceId.make("opencode"), + model: "openai/gpt-5", + }, + }); + + const firstInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.promise(() => abortStarted); + const secondInterrupt = yield* adapter + .interruptTurn(threadId, turn.turnId) + .pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + + releaseAbort(); + yield* Fiber.join(firstInterrupt); + yield* Fiber.join(secondInterrupt); + NodeAssert.equal(yield* Ref.get(abortedCount), 1); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 1); + }), + ); + + it.effect( + "emits turn.aborted without starting a session when interrupt has no live context", + () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-zombie-interrupt"); + const turnId = TurnId.make("opencode-turn-zombie"); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.aborted"), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + + yield* adapter.interruptTurn(threadId, turnId); + + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + NodeAssert.equal(events[0]?.type, "turn.aborted"); + NodeAssert.equal(String(events[0]?.turnId), String(turnId)); + NodeAssert.equal(runtimeMock.state.startCalls.length, 0); + NodeAssert.equal(runtimeMock.state.abortCalls.length, 0); + }), + ); + it.effect("passes agent and variant options for the adapter's bound custom instance id", () => { const instanceId = ProviderInstanceId.make("opencode_zen"); const adapterLayer = Layer.effect( diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 8f7e42c11d7c..a6b775ad770e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -15,6 +15,7 @@ import { import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -237,6 +238,13 @@ interface OpenCodeSessionContext { activeTurnId: TurnId | undefined; activeAgent: string | undefined; activeVariant: string | undefined; + /** + * While `session.abort` is in flight, `sendTurn` waits on this gate so a + * new prompt cannot land in the session the pending abort will cancel. + * Projected status is still flipped to `ready` immediately so Stop does + * not leave the UI stuck on "Working". + */ + readonly abortGate: Ref.Ref | null>; /** * One-shot guard flipped by `stopOpenCodeContext` / `emitUnexpectedExit`. * The session lifecycle is owned by `sessionScope`; this Ref exists only @@ -1402,6 +1410,7 @@ export function makeOpenCodeAdapter( activeTurnId: undefined, activeAgent: undefined, activeVariant: undefined, + abortGate: yield* Ref.make | null>(null), stopped: yield* Ref.make(false), sessionScope: started.sessionScope, }; @@ -1429,6 +1438,10 @@ export function makeOpenCodeAdapter( const sendTurn: OpenCodeAdapterShape["sendTurn"] = Effect.fn("sendTurn")(function* (input) { const context = yield* ensureSessionContext(sessions, input.threadId); + const pendingAbort = yield* Ref.get(context.abortGate); + if (pendingAbort !== null) { + yield* Deferred.await(pendingAbort); + } // A sendTurn while a turn is active is a steer: OpenCode queues the // prompt into the busy session and the work continues as one turn, so // the active turn id is reused instead of opening a new turn. @@ -1557,15 +1570,60 @@ export function makeOpenCodeAdapter( const interruptTurn: OpenCodeAdapterShape["interruptTurn"] = Effect.fn("interruptTurn")( function* (threadId, turnId) { - const context = yield* ensureSessionContext(sessions, threadId); - yield* runOpenCodeSdk("session.abort", () => - context.client.session.abort({ sessionID: context.openCodeSessionId }), - ).pipe(Effect.mapError(toRequestError)); - if (turnId ?? context.activeTurnId) { + // Do not start or recover a session just to stop it. A vanished + // in-memory context is a zombie projection; emit turn.aborted so + // ingestion can leave "Working". + const context = sessions.get(threadId); + const abortedTurnId = turnId ?? context?.activeTurnId; + if (context && !(yield* Ref.get(context.stopped))) { + const abortInFlight = yield* Deferred.make(); + const claimed = yield* Ref.modify(context.abortGate, (current) => + current !== null + ? ([current, current] as const) + : ([abortInFlight, abortInFlight] as const), + ); + if (claimed !== abortInFlight) { + yield* Deferred.await(claimed); + return; + } + // Settle projected state first so Stop does not leave "Working", + // but keep sendTurn blocked until abort finishes or times out. + yield* Effect.ensuring( + Effect.gen(function* () { + context.activeTurnId = undefined; + yield* updateProviderSession( + context, + { status: "ready" }, + { clearActiveTurnId: true }, + ); + if (abortedTurnId) { + yield* emit({ + ...(yield* buildEventBase({ + threadId, + turnId: abortedTurnId, + })), + type: "turn.aborted", + payload: { + reason: "Interrupted by user.", + }, + }); + } + yield* runOpenCodeSdk("session.abort", () => + context.client.session.abort({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.timeout("2 seconds"), Effect.ignore({ log: true })); + }), + Effect.gen(function* () { + yield* Deferred.succeed(abortInFlight, undefined); + yield* Ref.set(context.abortGate, null); + }), + ); + return; + } + if (abortedTurnId) { yield* emit({ ...(yield* buildEventBase({ threadId, - turnId: turnId ?? context.activeTurnId, + turnId: abortedTurnId, })), type: "turn.aborted", payload: { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 67b4bd9bd37c..c7b76c1e3aea 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -989,6 +989,28 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not recover a stale session just to interrupt a turn", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + const initial = yield* provider.startSession(asThreadId("thread-interrupt-no-recover"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-interrupt-no-recover"), + cwd: "/tmp/project", + runtimeMode: "full-access", + }); + yield* routing.codex.stopSession(initial.threadId); + routing.codex.startSession.mockClear(); + routing.codex.interruptTurn.mockClear(); + + yield* provider.interruptTurn({ threadId: initial.threadId }); + + assert.equal(routing.codex.startSession.mock.calls.length, 0); + assert.deepEqual(routing.codex.interruptTurn.mock.calls, [[initial.threadId, undefined]]); + }), + ); + it.effect("recovers stale persisted sessions for rollback by resuming thread identity", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e7f9147dc3e..f2b78aeede6f 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -838,7 +838,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const routed = yield* resolveRoutableSession({ threadId: input.threadId, operation: "ProviderService.interruptTurn", - allowRecovery: true, + allowRecovery: false, }); metricProvider = routed.adapter.provider; yield* Effect.annotateCurrentSpan({ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..f9d37f597516 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -29,6 +29,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + TELEPORT_SCHEMA_VERSION, WS_METHODS, WsRpcGroup, EditorId, @@ -120,6 +121,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; +import * as TeleportService from "./teleport/TeleportService.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; @@ -420,6 +422,7 @@ const buildAppUnderTest = (options?: { desktopTelemetryReceiver?: Partial< DesktopTelemetryReceiver.DesktopTelemetryReceiver["Service"] >; + teleportService?: Partial; }; }) => Effect.gen(function* () { @@ -770,13 +773,30 @@ const buildAppUnderTest = (options?: { ), ), Layer.provide( - Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ - readEvents: () => Stream.empty, - dispatch: () => Effect.succeed({ sequence: 0 }), - streamDomainEvents: Stream.empty, - latestSequence: Effect.succeed(0), - ...options?.layers?.orchestrationEngine, - }), + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + readEvents: () => Stream.empty, + dispatch: () => Effect.succeed({ sequence: 0 }), + streamDomainEvents: Stream.empty, + latestSequence: Effect.succeed(0), + ...options?.layers?.orchestrationEngine, + }), + Layer.mock(TeleportService.TeleportService)({ + listSessions: () => + Effect.succeed({ + schemaVersion: TELEPORT_SCHEMA_VERSION, + sessions: [], + }), + importSessions: () => + Effect.succeed({ + schemaVersion: TELEPORT_SCHEMA_VERSION, + imported: [], + }), + exportSession: () => + Effect.die("TeleportService.exportSession not stubbed in this test"), + ...options?.layers?.teleportService, + }), + ), ), Layer.provide( Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..55411137a501 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -43,6 +43,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as TeleportService from "./teleport/TeleportService.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -415,7 +416,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); -const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( +const RuntimeDependenciesLive = Layer.mergeAll( + RuntimeCoreDependenciesLive, + TeleportService.layer.pipe(Layer.provide(RuntimeCoreDependenciesLive)), +).pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), diff --git a/apps/server/src/teleport/TeleportService.ts b/apps/server/src/teleport/TeleportService.ts new file mode 100644 index 000000000000..335ff2cb1742 --- /dev/null +++ b/apps/server/src/teleport/TeleportService.ts @@ -0,0 +1,1289 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + MessageId, + ProviderDriverKind, + TELEPORT_NATIVE_FORMAT_VERSION, + TELEPORT_SCHEMA_VERSION, + TeleportDiscoveryError, + TeleportInvalidInputError, + TeleportProjectResolutionError, + TeleportIdentityConflictError, + TeleportNativeWriteError, + TeleportUnsupportedProviderError, + ThreadId, + defaultInstanceIdForDriver, + isTeleportProvider, + resolveTeleportPresence, + type ModelSelection, + type OrchestrationMessage, + type ProjectId, + type ProviderInstanceId, + type TeleportExportError, + type TeleportExportSessionInput, + type TeleportExportSessionResult, + type TeleportImportedSession, + type TeleportImportError, + type TeleportImportSessionsInput, + type TeleportImportSessionsResult, + type TeleportListSessionsError, + type TeleportListSessionsInput, + type TeleportListSessionsResult, + type TeleportProvider, + type TeleportRuntimePayload, + type TeleportThreadState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Ref from "effect/Ref"; + +import { resolveThreadWorkspaceCwd } from "../checkpointing/Utils.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { canReplaceThreadTitle } from "../orchestration/threadTitles.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { ProviderInstanceRegistry } from "../provider/Services/ProviderInstanceRegistry.ts"; +import { ProviderService } from "../provider/Services/ProviderService.ts"; +import { ProviderSessionDirectory } from "../provider/Services/ProviderSessionDirectory.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { + normalizeTeleportCwd, + resolveTeleportCwdPath, + teleportCwdsEquivalent, + uniqueTeleportCwds, +} from "./cwd.ts"; +import { discoverTeleportSessions, loadTeleportSession } from "./discovery.ts"; +import { + pendingTeleportNativePath, + realExportNativePath, + teleportExportPresenceOnFailure, +} from "./exportPresence.ts"; +import * as TeleportFormatRegistry from "./formats/registry.ts"; +import { resolveTeleportHomes, type TeleportHomes } from "./homes.ts"; +import { definedField, firstUserTitle, truncateTitle } from "./json.ts"; +import { + buildTeleportResumeCursor, + readTeleportExternalSessionId, + readTeleportRuntimePayload, + teleportThreadStateFromPayload, + toTeleportProvider, +} from "./resumeCursors.ts"; +import { + MAX_TELEPORT_MESSAGE_CHARS, + MAX_TELEPORT_MESSAGES, + nativeTextMessage, + type NativeTextMessage, + type ParsedNativeSession, +} from "./types.ts"; +import { + committedTeleportImportState, + importingTeleportState, + nativeTranscriptWouldWipeExistingHistory, + recoverInterruptedImportTeleports, + restorePresenceForImport, + runInPlaceTeleportImport, + runNewThreadTeleportImport, + teleportStateWithPresence, +} from "./importTransaction.ts"; + +export class TeleportService extends Context.Service< + TeleportService, + { + readonly listSessions: ( + input: TeleportListSessionsInput, + ) => Effect.Effect; + + readonly importSessions: ( + input: TeleportImportSessionsInput, + ) => Effect.Effect; + + readonly exportSession: ( + input: TeleportExportSessionInput, + ) => Effect.Effect; + } +>()("t3/teleport/TeleportService") {} + +function modelSelectionForProvider( + provider: TeleportProvider, + instanceId?: ProviderInstanceId, +): ModelSelection { + const driver = ProviderDriverKind.make(provider); + return { + instanceId: instanceId ?? defaultInstanceIdForDriver(driver), + model: DEFAULT_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL, + }; +} + +function capMessages(messages: ReadonlyArray): NativeTextMessage[] { + return messages.slice(-MAX_TELEPORT_MESSAGES).map((message) => + message.text.length > MAX_TELEPORT_MESSAGE_CHARS + ? { + ...message, + text: `${message.text.slice(0, MAX_TELEPORT_MESSAGE_CHARS)}\n\n[truncated]`, + } + : message, + ); +} + +function nativeMessagesToOrchestration( + messages: ReadonlyArray, + ids: ReadonlyArray, + now: string, +): OrchestrationMessage[] { + return messages.map((message, index) => ({ + id: MessageId.make(ids[index] ?? `${index}`), + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt ?? now, + updatedAt: message.createdAt ?? now, + })); +} + +function orchestrationToNative(messages: ReadonlyArray): NativeTextMessage[] { + return messages.flatMap((message) => { + if (message.role !== "user" && message.role !== "assistant") { + return []; + } + const text = message.text.trim(); + if (text.length === 0) { + return []; + } + return [ + nativeTextMessage({ + role: message.role, + text: message.text, + createdAt: message.createdAt, + id: message.id, + }), + ]; + }); +} + +function allocateExportSessionId( + existingPayload: TeleportRuntimePayload | undefined, + nextUuid: string, +): string { + return existingPayload?.externalSessionId ?? nextUuid; +} + +function isBusySessionStatus(status: string | undefined): boolean { + return status === "starting" || status === "running"; +} + +export const make = Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory; + const instanceRegistry = yield* ProviderInstanceRegistry; + const providerService = yield* ProviderService; + const crypto = yield* Crypto.Crypto; + const formats = yield* TeleportFormatRegistry.TeleportFormatRegistry; + const nativeContext = yield* Effect.context< + | FileSystem.FileSystem + | Path.Path + | TeleportFormatRegistry.TeleportFormatRegistry + | ProcessRunner.ProcessRunner + >(); + const provideNative = ( + effect: Effect.Effect< + A, + E, + | FileSystem.FileSystem + | Path.Path + | TeleportFormatRegistry.TeleportFormatRegistry + | ProcessRunner.ProcessRunner + >, + ): Effect.Effect => effect.pipe(Effect.provideContext(nativeContext)); + + const nextId = () => crypto.randomUUIDv4; + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const inFlight = new Set(); + const alreadyInFlightError = () => + new TeleportInvalidInputError({ + reason: "Teleport already in progress for this session.", + }); + const withInFlight = ( + keys: string[], + effect: Effect.Effect, + ): Effect.Effect => + Effect.suspend((): Effect.Effect => { + for (const key of keys) { + if (inFlight.has(key)) { + return alreadyInFlightError(); + } + } + for (const key of keys) { + inFlight.add(key); + } + return effect.pipe( + Effect.ensuring( + Effect.sync(() => { + for (const key of keys) { + inFlight.delete(key); + } + }), + ), + ); + }); + const worktreeCwdsFromThreads = ( + threads: ReadonlyArray<{ + readonly projectId: ProjectId; + readonly worktreePath: string | null; + }>, + projectId: ProjectId, + ): string[] => + uniqueTeleportCwds( + threads.flatMap((thread) => { + if (thread.projectId !== projectId || thread.worktreePath === null) { + return []; + } + if (normalizeTeleportCwd(thread.worktreePath) === "/") { + return []; + } + return [thread.worktreePath]; + }), + ); + const loadProjectWorktreeCwds = (projectId: ProjectId) => + snapshotQuery.getShellSnapshot().pipe( + Effect.map((shell) => worktreeCwdsFromThreads(shell.threads, projectId)), + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to load project worktree paths for teleport.", + cause, + }), + ), + ); + const loadWorkspaceWorktreeCwds = (cwd: string) => + snapshotQuery.getShellSnapshot().pipe( + Effect.flatMap((shell) => + Effect.gen(function* () { + for (const project of shell.projects) { + if (yield* teleportCwdsEquivalent(project.workspaceRoot, cwd)) { + return worktreeCwdsFromThreads(shell.threads, project.id); + } + } + return [] as string[]; + }), + ), + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to load project worktree paths for teleport.", + cause, + }), + ), + ); + const claimExtraInFlight = (keys: string[], extra: string) => + Effect.suspend((): Effect.Effect => { + if (keys.includes(extra)) { + return Effect.void; + } + if (inFlight.has(extra)) { + return alreadyInFlightError(); + } + inFlight.add(extra); + keys.push(extra); + return Effect.void; + }); + + const requireParsedSessionUnlocked = (parsed: ParsedNativeSession, homes: TeleportHomes) => { + const adapter = formats.get(parsed.provider); + if (!adapter) { + return new TeleportUnsupportedProviderError({ + provider: ProviderDriverKind.make(parsed.provider), + }); + } + return adapter.requireUnlocked({ + homes, + nativePath: parsed.nativePath, + }); + }; + + const stopThreadProviderSession = (threadId: ThreadId) => + providerService.stopSession({ threadId }).pipe( + Effect.catchTags({ + ProviderValidationError: (error) => + Effect.logDebug("teleport.stop-session-skipped", { + threadId, + reason: error._tag, + }), + ProviderSessionNotFoundError: (error) => + Effect.logDebug("teleport.stop-session-skipped", { + threadId, + reason: error._tag, + }), + ProviderAdapterSessionNotFoundError: (error) => + Effect.logDebug("teleport.stop-session-skipped", { + threadId, + reason: error._tag, + }), + }), + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to stop the T3 provider session.", + cause, + }), + ), + ); + + const dispatchTeleportSet = (input: { + readonly threadId: ThreadId; + readonly teleport: TeleportThreadState; + readonly createdAt: string; + readonly reason: string; + }) => + nextId().pipe( + Effect.flatMap((id) => + engine.dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(id), + threadId: input.threadId, + teleport: input.teleport, + createdAt: input.createdAt, + }), + ), + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: input.reason, + cause, + }), + ), + Effect.asVoid, + ); + + const dispatchTeleportImport = (input: { + readonly threadId: ThreadId; + readonly teleport: TeleportThreadState; + readonly messages: OrchestrationMessage[]; + readonly createdAt: string; + readonly reason: string; + }) => + nextId().pipe( + Effect.flatMap((id) => + engine.dispatch({ + type: "thread.teleport.import", + commandId: CommandId.make(id), + threadId: input.threadId, + teleport: input.teleport, + messages: input.messages, + createdAt: input.createdAt, + }), + ), + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: input.reason, + cause, + }), + ), + Effect.asVoid, + ); + + const listSessions = (input: TeleportListSessionsInput) => + provideNative( + Effect.gen(function* () { + const cwd = yield* resolveTeleportCwdPath(input.cwd); + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Server settings could not be read for teleport discovery.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + const extraCwds = yield* loadWorkspaceWorktreeCwds(cwd); + return yield* discoverTeleportSessions({ + homes, + cwd, + ...definedField("extraCwds", extraCwds.length > 0 ? extraCwds : undefined), + ...(input.providers ? { providers: input.providers } : {}), + }); + }), + ); + + const importSessions = (input: TeleportImportSessionsInput) => { + // Atomic per session, not all-or-nothing for the batch. A later session + // failure retains earlier imported threads and still fails the RPC. + const inFlightKeys = input.sessions.map( + (session) => `session:${session.provider}:${session.externalSessionId}`, + ); + return withInFlight( + inFlightKeys, + provideNative( + Effect.gen(function* () { + const project = yield* snapshotQuery.getProjectShellById(input.projectId).pipe( + Effect.mapError( + (cause) => + new TeleportProjectResolutionError({ + reason: "Failed to load the target project.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new TeleportProjectResolutionError({ + reason: `Project '${input.projectId}' was not found.`, + }); + } + const cwd = yield* resolveTeleportCwdPath(input.cwd); + if (!(yield* teleportCwdsEquivalent(project.value.workspaceRoot, cwd))) { + return yield* new TeleportInvalidInputError({ + reason: "Import cwd must match the selected project's workspace root.", + }); + } + const seenRefs = new Set(); + for (const ref of input.sessions) { + const key = `${ref.provider}:${ref.providerInstanceId ?? ""}:${ref.externalSessionId}`; + if (seenRefs.has(key)) { + return yield* new TeleportInvalidInputError({ + reason: `Duplicate session '${ref.externalSessionId}' in the import batch.`, + }); + } + seenRefs.add(key); + } + + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Server settings could not be read for teleport import.", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + const extraCwds = yield* loadProjectWorktreeCwds(input.projectId); + const bindings = yield* directory.listBindings().pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to read provider session bindings.", + cause, + }), + ), + ); + + const parsedSessions: ParsedNativeSession[] = []; + for (const ref of input.sessions) { + const parsed = yield* loadTeleportSession({ + homes, + provider: ref.provider, + externalSessionId: ref.externalSessionId, + cwd, + ...definedField("extraCwds", extraCwds.length > 0 ? extraCwds : undefined), + ...(ref.providerInstanceId === undefined + ? {} + : { providerInstanceId: ref.providerInstanceId }), + ...(ref.nativePath === undefined ? {} : { nativePath: ref.nativePath }), + }); + yield* requireParsedSessionUnlocked(parsed, homes); + parsedSessions.push({ + ...parsed, + messages: capMessages(parsed.messages), + }); + } + + const imported: TeleportImportedSession[] = []; + const now = yield* nowIso; + const [activeShell, archivedShell] = yield* Effect.all([ + snapshotQuery.getShellSnapshot().pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to load threads for teleport import.", + cause, + }), + ), + ), + snapshotQuery.getArchivedShellSnapshot().pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to load archived threads for teleport import.", + cause, + }), + ), + ), + ]); + const importThreadShells = [...activeShell.threads, ...archivedShell.threads]; + + for (const parsed of parsedSessions) { + const driver = ProviderDriverKind.make(parsed.provider); + let existingThreadId: ThreadId | undefined; + let existingProjectId = input.projectId; + let existingProviderInstanceId: (typeof bindings)[number]["providerInstanceId"]; + for (const binding of bindings) { + if (binding.provider !== driver) { + continue; + } + const externalSessionId = readTeleportExternalSessionId({ + provider: binding.provider, + resumeCursor: binding.resumeCursor, + runtimePayload: binding.runtimePayload, + adapter: isTeleportProvider(binding.provider) + ? formats.get(binding.provider) + : undefined, + }); + if (externalSessionId !== parsed.externalSessionId) { + continue; + } + const expectedInstanceId = + parsed.providerInstanceId ?? defaultInstanceIdForDriver(driver); + if (binding.providerInstanceId !== expectedInstanceId) { + continue; + } + const shell = yield* snapshotQuery.getThreadShellById(binding.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to load an existing teleport thread.", + cause, + }), + ), + ); + if (Option.isNone(shell)) { + continue; + } + if (shell.value.projectId !== input.projectId) { + return yield* new TeleportIdentityConflictError({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + existingThreadId: binding.threadId, + existingProjectId: shell.value.projectId, + }); + } + existingThreadId = binding.threadId; + existingProjectId = shell.value.projectId; + existingProviderInstanceId = binding.providerInstanceId; + if (isBusySessionStatus(shell.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + reason: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, + }); + } + break; + } + + if (existingThreadId === undefined) { + for (const shell of importThreadShells) { + const teleport = shell.teleport; + if ( + teleport == null || + teleport.provider !== parsed.provider || + teleport.externalSessionId !== parsed.externalSessionId + ) { + continue; + } + const expectedInstanceId = + parsed.providerInstanceId ?? defaultInstanceIdForDriver(driver); + if ( + teleport.providerInstanceId !== undefined && + teleport.providerInstanceId !== expectedInstanceId + ) { + continue; + } + if (shell.projectId !== input.projectId) { + return yield* new TeleportIdentityConflictError({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + existingThreadId: shell.id, + existingProjectId: shell.projectId, + }); + } + existingThreadId = shell.id; + existingProjectId = shell.projectId; + existingProviderInstanceId = + teleport.providerInstanceId ?? existingProviderInstanceId; + if (isBusySessionStatus(shell.session?.status)) { + return yield* new TeleportInvalidInputError({ + reason: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, + }); + } + break; + } + } + + const messageIds = yield* Effect.forEach(parsed.messages, () => nextId(), { + concurrency: 1, + }); + const messages = nativeMessagesToOrchestration(parsed.messages, messageIds, now); + const title = + truncateTitle( + parsed.title ?? firstUserTitle(parsed.messages) ?? "Imported session", + ) || "Imported session"; + let threadId = existingThreadId; + let updatedInPlace = false; + const providerInstanceId = + existingProviderInstanceId ?? + parsed.providerInstanceId ?? + defaultInstanceIdForDriver(driver); + const teleportPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId: parsed.externalSessionId, + nativePath: parsed.nativePath, + lastSyncDirection: "import", + lastSyncedAt: now, + nativeFormatVersion: parsed.nativeFormatVersion, + presence: "t3", + }; + const persistDirectoryBinding = ( + boundThreadId: ThreadId, + presence: TeleportRuntimePayload["presence"], + ) => + directory + .upsert({ + threadId: boundThreadId, + provider: driver, + providerInstanceId, + status: "stopped", + resumeCursor: buildTeleportResumeCursor({ + provider: parsed.provider, + externalSessionId: parsed.externalSessionId, + adapter: formats.get(parsed.provider), + }), + runtimePayload: { + teleport: { + ...teleportPayload, + ...(presence === undefined ? {} : { presence }), + }, + }, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to bind the imported native session.", + cause, + }), + ), + ); + type ImportMutationError = + | TeleportInvalidInputError + | TeleportDiscoveryError + | PlatformError.PlatformError; + const committedTeleport = committedTeleportImportState( + teleportThreadStateFromPayload({ + provider: parsed.provider, + providerInstanceId, + payload: teleportPayload, + }), + ); + + if (threadId) { + yield* claimExtraInFlight(inFlightKeys, `thread:${threadId}`); + const latest = yield* snapshotQuery.getThreadDetailById(threadId).pipe( + Effect.mapError( + (cause) => + new TeleportDiscoveryError({ + reason: "Failed to load the existing thread for in-place import.", + cause, + }), + ), + ); + if (Option.isNone(latest)) { + return yield* new TeleportDiscoveryError({ + reason: `Thread '${threadId}' was not found for in-place import.`, + }); + } + if (isBusySessionStatus(latest.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + reason: `Cannot import while T3 session '${parsed.externalSessionId}' is running.`, + }); + } + if ( + nativeTranscriptWouldWipeExistingHistory({ + nativeMessageCount: parsed.messages.length, + existingNativeMessageCount: orchestrationToNative(latest.value.messages).length, + }) + ) { + return yield* new TeleportInvalidInputError({ + reason: "Native session has no messages; refusing to wipe this thread.", + }); + } + updatedInPlace = true; + const importingTeleport = importingTeleportState({ + base: committedTeleport, + restorePresence: restorePresenceForImport(latest.value.teleport), + }); + const revertImporting = + latest.value.teleport === undefined || latest.value.teleport === null + ? dispatchTeleportSet({ + threadId, + teleport: committedTeleport, + createdAt: now, + reason: "Failed to revert teleport import presence.", + }).pipe(Effect.catch(() => Effect.void)) + : dispatchTeleportSet({ + threadId, + teleport: teleportStateWithPresence( + latest.value.teleport, + restorePresenceForImport(latest.value.teleport), + ), + createdAt: now, + reason: "Failed to revert teleport import presence.", + }).pipe(Effect.catch(() => Effect.void)); + yield* runInPlaceTeleportImport({ + beginImporting: dispatchTeleportSet({ + threadId, + teleport: importingTeleport, + createdAt: now, + reason: "Failed to persist teleport import presence.", + }), + stopSession: stopThreadProviderSession(threadId), + persistDirectory: persistDirectoryBinding(threadId, "importing"), + commitOrchestration: dispatchTeleportImport({ + threadId, + teleport: committedTeleport, + messages, + createdAt: now, + reason: "Failed to import native history.", + }), + finalizeDirectory: persistDirectoryBinding(threadId, "t3"), + updateTitle: canReplaceThreadTitle(latest.value.title) + ? engine + .dispatch({ + type: "thread.meta.update", + commandId: CommandId.make(yield* nextId()), + threadId, + title, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to update imported thread title.", + cause, + }), + ), + Effect.asVoid, + ) + : Effect.void, + revertImporting, + }); + } else { + threadId = ThreadId.make(yield* nextId()); + const createdThreadId = threadId; + const cleanupCommandId = CommandId.make(yield* nextId()); + const importingTeleport = importingTeleportState({ + base: committedTeleport, + restorePresence: "t3", + }); + yield* Effect.acquireUseRelease( + engine + .dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* nextId()), + threadId: createdThreadId, + projectId: input.projectId, + title, + modelSelection: modelSelectionForProvider( + parsed.provider, + parsed.providerInstanceId, + ), + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to create an imported thread.", + cause, + }), + ), + ), + () => + runNewThreadTeleportImport({ + beginImporting: dispatchTeleportSet({ + threadId: createdThreadId, + teleport: importingTeleport, + createdAt: now, + reason: "Failed to persist teleport import presence.", + }), + persistDirectory: persistDirectoryBinding(createdThreadId, "importing"), + commitOrchestration: dispatchTeleportImport({ + threadId: createdThreadId, + teleport: committedTeleport, + messages, + createdAt: now, + reason: "Failed to write imported thread history.", + }), + finalizeDirectory: persistDirectoryBinding(createdThreadId, "t3"), + }), + (_acquired, exit) => { + if (Exit.isSuccess(exit)) { + return Effect.void; + } + return engine + .dispatch({ + type: "thread.delete", + commandId: cleanupCommandId, + threadId: createdThreadId, + }) + .pipe( + Effect.catch(() => + Effect.logWarning("teleport.import.created-thread-cleanup-skipped", { + threadId: createdThreadId, + }), + ), + ); + }, + ); + } + + imported.push({ + threadId, + projectId: existingProjectId, + provider: parsed.provider, + providerInstanceId, + externalSessionId: parsed.externalSessionId, + updatedInPlace, + }); + } + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + imported, + }; + }), + ).pipe( + Effect.catchTags({ + PlatformError: (cause: PlatformError.PlatformError) => + new TeleportDiscoveryError({ + reason: "Native filesystem error during teleport import.", + cause, + }), + }), + ), + ); + }; + + const exportSession = (input: TeleportExportSessionInput) => { + const inFlightKeys = [`thread:${input.threadId}`]; + return withInFlight( + inFlightKeys, + provideNative( + Effect.gen(function* () { + const thread = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to load the thread for export.", + cause, + }), + ), + ); + if (Option.isNone(thread)) { + return yield* new TeleportInvalidInputError({ + reason: `Thread '${input.threadId}' was not found.`, + }); + } + if (isBusySessionStatus(thread.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + reason: "Cannot export while this T3 session is running.", + }); + } + + const project = yield* snapshotQuery.getProjectShellById(thread.value.projectId).pipe( + Effect.mapError( + (cause) => + new TeleportProjectResolutionError({ + reason: "Failed to load the thread's project.", + cause, + }), + ), + ); + if (Option.isNone(project)) { + return yield* new TeleportProjectResolutionError({ + reason: "The thread's project was not found.", + }); + } + + const binding = yield* directory.getBinding(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to read the thread's provider binding.", + cause, + }), + ), + ); + const instance = yield* instanceRegistry.getInstance( + thread.value.modelSelection.instanceId, + ); + const driverKind = + Option.getOrUndefined(binding)?.provider ?? + instance?.driverKind ?? + (isTeleportProvider(thread.value.modelSelection.instanceId) + ? ProviderDriverKind.make(thread.value.modelSelection.instanceId) + : undefined); + if (!driverKind) { + return yield* new TeleportInvalidInputError({ + reason: "Teleport export could not resolve a supported provider for this thread.", + }); + } + const provider = yield* toTeleportProvider(driverKind); + const existingPayload = Option.isSome(binding) + ? readTeleportRuntimePayload(binding.value.runtimePayload) + : undefined; + if (resolveTeleportPresence(existingPayload) === "native") { + return yield* new TeleportInvalidInputError({ + reason: "This thread is already in the native CLI. Import it before exporting again.", + }); + } + + yield* stopThreadProviderSession(input.threadId); + const now = yield* nowIso; + yield* engine + .dispatch({ + type: "thread.session.stop", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + createdAt: now, + }) + .pipe( + Effect.catch(() => + Effect.logDebug("teleport.export.session-stop-dispatch-skipped", { + threadId: input.threadId, + }), + ), + ); + + const settings = yield* settingsService.getSettings.pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: project.value.workspaceRoot, + stage: "read-settings", + cause, + }), + ), + ); + const homes = yield* resolveTeleportHomes(settings); + const existingNativePath = realExportNativePath(existingPayload?.nativePath); + const externalSessionId = allocateExportSessionId(existingPayload, yield* nextId()); + yield* claimExtraInFlight(inFlightKeys, `session:${provider}:${externalSessionId}`); + const providerInstanceId = + Option.getOrUndefined(binding)?.providerInstanceId ?? + thread.value.modelSelection.instanceId; + const latest = yield* snapshotQuery.getThreadDetailById(input.threadId).pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to re-check the thread before export.", + cause, + }), + ), + ); + if (Option.isNone(latest)) { + return yield* new TeleportInvalidInputError({ + reason: `Thread '${input.threadId}' was not found.`, + }); + } + if (isBusySessionStatus(latest.value.session?.status)) { + return yield* new TeleportInvalidInputError({ + reason: "Cannot export while this T3 session is running.", + }); + } + const cwdSource = + resolveThreadWorkspaceCwd({ + thread: latest.value, + projects: [project.value], + }) ?? project.value.workspaceRoot; + const cwd = yield* resolveTeleportCwdPath(cwdSource); + const messages = capMessages(orchestrationToNative(latest.value.messages)); + const pendingNativePath = + existingNativePath ?? pendingTeleportNativePath(provider, externalSessionId); + const pendingPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId, + nativePath: pendingNativePath, + lastSyncDirection: "export", + lastSyncedAt: now, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + presence: "native", + }; + const revertExportPresence = engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + teleport: teleportThreadStateFromPayload({ + provider, + providerInstanceId, + payload: existingPayload + ? { ...existingPayload, presence: "t3" } + : { ...pendingPayload, presence: "t3" }, + }), + createdAt: now, + }) + .pipe( + Effect.catch(() => + Effect.logDebug("teleport.export.presence-revert-skipped", { + threadId: input.threadId, + }), + ), + ); + const persistExportedNative = (nativePath: string) => { + const teleportPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId, + nativePath, + lastSyncDirection: "export", + lastSyncedAt: now, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + presence: "native", + }; + const adapter = formats.get(provider); + return Effect.gen(function* () { + if (adapter) { + yield* directory + .upsert({ + threadId: input.threadId, + provider: driverKind, + providerInstanceId, + status: "stopped", + resumeCursor: buildTeleportResumeCursor({ + provider, + externalSessionId, + adapter, + }), + runtimePayload: { teleport: teleportPayload }, + }) + .pipe( + Effect.catch(() => + Effect.logWarning("teleport.export.binding-persist-on-failure", { + threadId: input.threadId, + nativePath, + }), + ), + ); + } + yield* engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + teleport: teleportThreadStateFromPayload({ + provider, + providerInstanceId, + payload: teleportPayload, + }), + createdAt: now, + }) + .pipe( + Effect.catch(() => + Effect.logWarning("teleport.export.presence-persist-on-failure", { + threadId: input.threadId, + nativePath, + }), + ), + ); + }).pipe( + Effect.catchCause(() => + Effect.logWarning("teleport.export.persist-native-failed", { + threadId: input.threadId, + nativePath, + }), + ), + ); + }; + + const nativeSession: ParsedNativeSession = { + provider, + externalSessionId, + cwd, + nativePath: "", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: thread.value.title, + createdAt: thread.value.createdAt, + updatedAt: now, + messages, + providerInstanceId, + }; + const writtenNativePathRef = yield* Ref.make(undefined); + + return yield* Effect.acquireUseRelease( + engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + teleport: teleportThreadStateFromPayload({ + provider, + providerInstanceId, + payload: pendingPayload, + }), + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to persist teleport presence.", + cause, + }), + ), + ), + () => + Effect.gen(function* () { + const adapter = formats.get(provider); + if (!adapter) { + return yield* new TeleportUnsupportedProviderError({ + provider: driverKind, + }); + } + const nativePath = yield* adapter.write({ + homes, + session: nativeSession, + ...(existingNativePath !== undefined ? { existingNativePath } : {}), + }); + yield* Ref.set(writtenNativePathRef, nativePath); + + const teleportPayload: TeleportRuntimePayload = { + schemaVersion: TELEPORT_SCHEMA_VERSION, + externalSessionId, + nativePath, + lastSyncDirection: "export", + lastSyncedAt: now, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + presence: "native", + }; + yield* directory + .upsert({ + threadId: input.threadId, + provider: driverKind, + providerInstanceId, + status: "stopped", + resumeCursor: buildTeleportResumeCursor({ + provider, + externalSessionId, + adapter, + }), + runtimePayload: { teleport: teleportPayload }, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath, + stage: "bind", + cause, + }), + ), + ); + yield* engine + .dispatch({ + type: "thread.teleport.set", + commandId: CommandId.make(yield* nextId()), + threadId: input.threadId, + teleport: teleportThreadStateFromPayload({ + provider, + providerInstanceId, + payload: teleportPayload, + }), + createdAt: now, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportInvalidInputError({ + reason: "Failed to persist teleport presence.", + cause, + }), + ), + ); + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + provider, + providerInstanceId, + externalSessionId, + nativePath, + cwd, + }; + }), + (_acquired, exit) => { + if (Exit.isSuccess(exit)) { + return Effect.void; + } + return Ref.get(writtenNativePathRef).pipe( + Effect.flatMap((writtenNativePath) => + teleportExportPresenceOnFailure({ + writtenNativePath, + revert: revertExportPresence, + persistWritten: persistExportedNative, + }), + ), + ); + }, + ); + }), + ).pipe( + Effect.catchTags({ + PlatformError: (cause: PlatformError.PlatformError) => + new TeleportNativeWriteError({ + stage: "filesystem", + cause, + }), + }), + ), + ); + }; + + const recoverInterruptedImports = Effect.gen(function* () { + const now = yield* nowIso; + const [activeShell, archivedShell] = yield* Effect.all([ + snapshotQuery.getShellSnapshot(), + snapshotQuery.getArchivedShellSnapshot(), + ]); + yield* recoverInterruptedImportTeleports({ + threads: [...activeShell.threads, ...archivedShell.threads].map((thread) => ({ + id: thread.id, + ...(thread.teleport == null ? {} : { teleport: thread.teleport }), + })), + nextCommandId: nextId().pipe(Effect.map(CommandId.make), Effect.orDie), + setTeleport: (threadId, teleport) => + dispatchTeleportSet({ + threadId, + teleport, + createdAt: now, + reason: "Failed to recover an interrupted teleport import.", + }), + }); + }).pipe(Effect.catchCause(() => Effect.logWarning("teleport.import.recovery-failed"))); + + yield* recoverInterruptedImports; + + return TeleportService.of({ + listSessions, + importSessions, + exportSession, + }); +}); + +export const layer = Layer.effect(TeleportService, make).pipe( + Layer.provide(TeleportFormatRegistry.layer), + Layer.provide(ProcessRunner.layer), +); diff --git a/apps/server/src/teleport/cwd.test.ts b/apps/server/src/teleport/cwd.test.ts new file mode 100644 index 000000000000..0a92ac44b9e3 --- /dev/null +++ b/apps/server/src/teleport/cwd.test.ts @@ -0,0 +1,127 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import { + isTeleportCwdWithin, + resolveTeleportCwdPath, + teleportCwdsEquivalent, + teleportCwdsMatch, + teleportSessionBelongsToProject, +} from "./cwd.ts"; + +describe("teleport cwd matching", () => { + it("treats trailing slashes as the same project", () => { + assert.equal(teleportCwdsMatch("/workspace", "/workspace/"), true); + assert.equal(teleportCwdsMatch("/workspace", "/other"), false); + }); + + it("treats a nested project folder as inside its parent cwd", () => { + assert.equal( + isTeleportCwdWithin("/home/user/projects/native/codex", "/home/user/projects/native"), + true, + ); + assert.equal(isTeleportCwdWithin("/tmp/wire-test", "/tmp"), true); + assert.equal(isTeleportCwdWithin("/foobar", "/foo"), false); + }); + + it.effect("treats a symlink cwd as the same project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-cwd-" }); + const real = path.join(root, "real-project"); + const link = path.join(root, "link-project"); + yield* fs.makeDirectory(real, { recursive: true }); + yield* fs.symlink(real, link); + assert.equal(yield* teleportCwdsEquivalent(real, link), true); + assert.equal(yield* teleportCwdsEquivalent(real, path.join(root, "other")), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("treats macOS cwd spellings as the same when they differ only by case", () => + teleportCwdsEquivalent("/Users/Alex/proj", "/Users/alex/proj").pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, true); + }), + ), + ); + + it.effect("does not case-fold missing Unix paths on Linux", () => + teleportCwdsEquivalent("/Users/Alex/proj", "/Users/alex/proj").pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, false); + }), + ), + ); + + it.effect("resolves a persist cwd without lowercasing Unix paths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-persist-cwd-" }); + const project = path.join(root, "MixedCase"); + yield* fs.makeDirectory(project, { recursive: true }); + const resolved = yield* resolveTeleportCwdPath(`${project}/`); + assert.equal(resolved, yield* fs.realPath(project)); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("treats a T3 worktree cwd as part of the project when listed as extra", () => + teleportSessionBelongsToProject({ + sessionCwd: "/home/user/.t3/worktrees/repo/feature", + projectCwd: "/home/user/projects/repo", + extraCwds: ["/home/user/.t3/worktrees/repo/feature"], + }).pipe( + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, true); + }), + ), + ); + + it.effect("treats a project subdirectory cwd as part of the project", () => + teleportSessionBelongsToProject({ + sessionCwd: "/home/user/projects/repo/packages/app", + projectCwd: "/home/user/projects/repo", + }).pipe( + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, true); + }), + ), + ); + + it.effect("does not treat an ancestor cwd as part of the project", () => + teleportSessionBelongsToProject({ + sessionCwd: "/", + projectCwd: "/home/user/projects/repo", + }).pipe( + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, false); + }), + ), + ); + + it.effect("does not treat an unrelated worktree as part of the project", () => + teleportSessionBelongsToProject({ + sessionCwd: "/home/user/.t3/worktrees/other/feature", + projectCwd: "/home/user/projects/repo", + extraCwds: ["/home/user/.t3/worktrees/repo/feature"], + }).pipe( + Effect.provide(NodeServices.layer), + Effect.map((matched) => { + assert.equal(matched, false); + }), + ), + ); +}); diff --git a/apps/server/src/teleport/cwd.ts b/apps/server/src/teleport/cwd.ts new file mode 100644 index 000000000000..45697ccb1d50 --- /dev/null +++ b/apps/server/src/teleport/cwd.ts @@ -0,0 +1,127 @@ +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, +} from "@t3tools/shared/path"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +export function normalizeTeleportCwd(value: string): string { + return normalizeProjectPathForComparison(value); +} + +/** + * On-disk cwd spelling for native session files and folder names. + * Comparison-normalize lowercases Windows paths; the native CLIs look up + * the persisted case and separators instead. + */ +export const resolveTeleportCwdPath = Effect.fn("resolveTeleportCwdPath")(function* ( + value: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = path.resolve(normalizeProjectPathForDispatch(value)); + return yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); +}); + +export const canonicalizeTeleportCwd = Effect.fn("canonicalizeTeleportCwd")(function* ( + value: string, +): Effect.fn.Return { + return normalizeTeleportCwd(yield* resolveTeleportCwdPath(value)); +}); + +export function teleportCwdsMatch(left: string, right: string): boolean { + return normalizeTeleportCwd(left) === normalizeTeleportCwd(right); +} + +export function isTeleportCwdWithin(inner: string, outer: string): boolean { + const innerCwd = normalizeTeleportCwd(inner); + const outerCwd = normalizeTeleportCwd(outer); + if (innerCwd === outerCwd) { + return true; + } + const separator = innerCwd.includes("\\") || outerCwd.includes("\\") ? "\\" : "/"; + const prefix = outerCwd.endsWith(separator) ? outerCwd : `${outerCwd}${separator}`; + return innerCwd.startsWith(prefix); +} + +export function uniqueTeleportCwds(cwds: ReadonlyArray): string[] { + const seen = new Set(); + const unique: string[] = []; + for (const cwd of cwds) { + if (cwd.length === 0) { + continue; + } + const key = normalizeTeleportCwd(cwd); + if (seen.has(key)) { + continue; + } + seen.add(key); + unique.push(cwd); + } + return unique; +} + +export function listingTeleportCwds( + projectCwd: string, + extraCwds: ReadonlyArray | undefined, +): ReadonlyArray { + return uniqueTeleportCwds([projectCwd, ...(extraCwds ?? [])]); +} + +/** + * Same location, including macOS `/tmp` → `/private/tmp` and case-insensitive + * default volumes. Lexical equality short-circuits; otherwise both sides go + * through `realpath` so a native CLI cwd and a T3 project root still match. + */ +export const teleportCwdsEquivalent = Effect.fn("teleportCwdsEquivalent")(function* ( + left: string, + right: string, +) { + if (teleportCwdsMatch(left, right)) { + return true; + } + const leftCanon = yield* canonicalizeTeleportCwd(left); + const rightCanon = yield* canonicalizeTeleportCwd(right); + if (leftCanon === rightCanon) { + return true; + } + const platform = yield* HostProcessPlatform; + // Default macOS volumes are case-insensitive. realpath keeps on-disk case + // when the path exists; missing paths fall back to the input spelling. + if (platform === "darwin") { + return leftCanon.toLowerCase() === rightCanon.toLowerCase(); + } + return false; +}); + +/** + * Native sessions belong to a T3 project when their cwd is the project root, + * a subdirectory of that root, or an extra cwd such as a T3 worktree. Ancestor + * cwds (`/` or `$HOME`) are not accepted: those would bind a session into any + * project. + */ +export const teleportSessionBelongsToProject = Effect.fn("teleportSessionBelongsToProject")( + function* (input: { + readonly sessionCwd: string; + readonly projectCwd: string; + readonly extraCwds?: ReadonlyArray; + }) { + if (yield* teleportCwdsEquivalent(input.sessionCwd, input.projectCwd)) { + return true; + } + if (isTeleportCwdWithin(input.sessionCwd, input.projectCwd)) { + return true; + } + for (const extraCwd of input.extraCwds ?? []) { + if (yield* teleportCwdsEquivalent(input.sessionCwd, extraCwd)) { + return true; + } + if (isTeleportCwdWithin(input.sessionCwd, extraCwd)) { + return true; + } + } + return false; + }, +); diff --git a/apps/server/src/teleport/discovery.test.ts b/apps/server/src/teleport/discovery.test.ts new file mode 100644 index 000000000000..9990dad6b3a7 --- /dev/null +++ b/apps/server/src/teleport/discovery.test.ts @@ -0,0 +1,206 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { discoverTeleportSessions, loadTeleportSession } from "./discovery.ts"; +import { serializeCodexSession } from "./formats/codex.ts"; +import * as TeleportFormatRegistry from "./formats/registry.ts"; +import type { TeleportHomes } from "./homes.ts"; +import { sampleTeleportSession, TELEPORT_TEST_SESSION_ID } from "./testFixtures.ts"; + +function homesFor(root: string, path: Path.Path): TeleportHomes { + return { + codexSessionsRoot: path.join(root, "codex", "sessions"), + extraCodexSessionsRoots: [], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; +} + +describe("teleport discovery", () => { + it.effect("lists nothing when no native formats are registered", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-empty-registry-" }); + const listed = yield* discoverTeleportSessions({ + homes: homesFor(root, path), + cwd: "/workspace", + }); + assert.deepStrictEqual(listed.sessions, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + NodeServices.layer, + Layer.succeed( + TeleportFormatRegistry.TeleportFormatRegistry, + TeleportFormatRegistry.fromAdapters([]), + ), + ), + ), + ), + ); + + it.effect("registers Codex and Claude native formats", () => + Effect.gen(function* () { + const formats = yield* TeleportFormatRegistry.TeleportFormatRegistry; + assert.deepStrictEqual([...formats.providers].toSorted(), ["claudeAgent", "codex"]); + }).pipe(Effect.provide(TeleportFormatRegistry.layer)), + ); + + it.effect("refuses a client nativePath outside the configured instance root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-path-sandbox-" }); + const homes = homesFor(root, path); + const outsidePath = path.join(root, "outside.jsonl"); + yield* fs.writeFileString(outsidePath, serializeCodexSession(sampleTeleportSession("codex"))); + const result = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + nativePath: outsidePath, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("refuses a nativePath that symlink-escapes the instance root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-symlink-escape-" }); + const homes = homesFor(root, path); + const outsidePath = path.join(root, "secret.jsonl"); + const insideLink = path.join(homes.codexSessionsRoot, "escape.jsonl"); + yield* fs.writeFileString(outsidePath, serializeCodexSession(sampleTeleportSession("codex"))); + yield* fs.makeDirectory(homes.codexSessionsRoot, { recursive: true }); + yield* fs.symlink(outsidePath, insideLink); + const result = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + nativePath: insideLink, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("refuses a fabricated provider instance id even when the default root matches", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-fake-instance-" }); + const homes = homesFor(root, path); + const nativePath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(nativePath, serializeCodexSession(sampleTeleportSession("codex"))); + const result = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + providerInstanceId: ProviderInstanceId.make("codex_bogus"), + nativePath, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("loads a worktree session when that worktree is an extra project cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-worktree-load-" }); + const homes = homesFor(root, path); + const worktreeCwd = path.join(root, "worktrees", "feature"); + const nativePath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString( + nativePath, + serializeCodexSession(sampleTeleportSession("codex", worktreeCwd)), + ); + const parsed = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: path.join(root, "project"), + extraCwds: [worktreeCwd], + nativePath, + }); + assert.equal(parsed.cwd, worktreeCwd); + const listed = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: path.join(root, "project"), + extraCwds: [worktreeCwd], + }); + assert.equal(listed.nativePath, nativePath); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("refuses a native session whose cwd is an ancestor of the project", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-ancestor-cwd-" }); + const homes = homesFor(root, path); + const nativePath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString( + nativePath, + serializeCodexSession(sampleTeleportSession("codex", "/")), + ); + const result = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: path.join(root, "project"), + nativePath, + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); +}); diff --git a/apps/server/src/teleport/discovery.ts b/apps/server/src/teleport/discovery.ts new file mode 100644 index 000000000000..0ccfb98b12ff --- /dev/null +++ b/apps/server/src/teleport/discovery.ts @@ -0,0 +1,204 @@ +import { + TELEPORT_SCHEMA_VERSION, + TeleportDiscoveryError, + TeleportSchemaVersionError, + type ProviderInstanceId, + type TeleportListSessionsResult, + type TeleportProvider, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { teleportSessionBelongsToProject } from "./cwd.ts"; +import * as TeleportFormatRegistry from "./formats/registry.ts"; +import { + canonicalizeTeleportNativePath, + configuredInstanceRootsForProvider, + configuredTeleportNativeRootFor, + nativePathIsUnderRoot, + type TeleportHomes, +} from "./homes.ts"; +import { definedField } from "./json.ts"; +import type { ParsedNativeSession } from "./types.ts"; + +export const discoverTeleportSessions = Effect.fn("discoverTeleportSessions")(function* (input: { + readonly homes: TeleportHomes; + readonly cwd: string; + readonly extraCwds?: ReadonlyArray; + readonly providers?: ReadonlyArray; +}): Effect.fn.Return< + TeleportListSessionsResult, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path | TeleportFormatRegistry.TeleportFormatRegistry +> { + const formats = yield* TeleportFormatRegistry.TeleportFormatRegistry; + const providers = input.providers ?? formats.providers; + const sessions = []; + + for (const provider of providers) { + const adapter = formats.get(provider); + if (!adapter) { + continue; + } + sessions.push( + ...(yield* adapter.list({ + homes: input.homes, + cwd: input.cwd, + ...definedField("extraCwds", input.extraCwds), + })), + ); + } + + return { + schemaVersion: TELEPORT_SCHEMA_VERSION, + sessions: sessions.toSorted((left, right) => { + const leftAt = left.updatedAt ?? left.createdAt ?? ""; + const rightAt = right.updatedAt ?? right.createdAt ?? ""; + return rightAt.localeCompare(leftAt); + }), + }; +}); + +function candidateMatchesRequestedInstance( + session: TeleportSessionCandidate, + requested: ProviderInstanceId | undefined, + homes: TeleportHomes, +): boolean { + if (requested === undefined || session.providerInstanceId === requested) { + return true; + } + const requestedRoot = configuredTeleportNativeRootFor(homes, session.provider, requested); + const listedRoot = configuredTeleportNativeRootFor( + homes, + session.provider, + session.providerInstanceId, + ); + if (requestedRoot === undefined || listedRoot === undefined) { + return false; + } + return requestedRoot === listedRoot && nativePathIsUnderRoot(session.nativePath, requestedRoot); +} + +const resolveNativePathInstance = Effect.fn("resolveNativePathInstance")(function* (input: { + readonly homes: TeleportHomes; + readonly provider: TeleportProvider; + readonly nativePath: string; + readonly requestedInstanceId?: ProviderInstanceId; +}): Effect.fn.Return< + { readonly nativePath: string; readonly instanceId: ProviderInstanceId }, + TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path +> { + const canonicalPath = yield* canonicalizeTeleportNativePath(input.nativePath); + const matchingInstanceIds: ProviderInstanceId[] = []; + for (const instance of configuredInstanceRootsForProvider(input.homes, input.provider)) { + const canonicalRoot = yield* canonicalizeTeleportNativePath(instance.root); + if (nativePathIsUnderRoot(canonicalPath, canonicalRoot)) { + matchingInstanceIds.push(instance.instanceId); + } + } + + if (input.requestedInstanceId !== undefined) { + if (matchingInstanceIds.includes(input.requestedInstanceId)) { + return { + nativePath: canonicalPath, + instanceId: input.requestedInstanceId, + }; + } + return yield* new TeleportDiscoveryError({ + reason: `Native ${input.provider} session path is outside instance '${input.requestedInstanceId}'.`, + }); + } + + const instanceId = matchingInstanceIds[0]; + if (instanceId === undefined) { + return yield* new TeleportDiscoveryError({ + reason: `Native ${input.provider} session path is outside the configured CLI home.`, + }); + } + return { + nativePath: canonicalPath, + instanceId, + }; +}); + +export const loadTeleportSession = Effect.fn("loadTeleportSession")(function* (input: { + readonly homes: TeleportHomes; + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly cwd: string; + readonly extraCwds?: ReadonlyArray; + readonly providerInstanceId?: ProviderInstanceId; + readonly nativePath?: string; +}): Effect.fn.Return< + ParsedNativeSession, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path | TeleportFormatRegistry.TeleportFormatRegistry +> { + const formats = yield* TeleportFormatRegistry.TeleportFormatRegistry; + const adapter = formats.get(input.provider); + if (!adapter) { + return yield* new TeleportDiscoveryError({ + reason: `Native ${input.provider} session support is not registered.`, + }); + } + + let nativePath = input.nativePath; + if (nativePath === undefined) { + const listed = yield* discoverTeleportSessions({ + homes: input.homes, + cwd: input.cwd, + providers: [input.provider], + ...definedField("extraCwds", input.extraCwds), + }); + const candidate = listed.sessions.find( + (session) => + session.provider === input.provider && + session.externalSessionId === input.externalSessionId && + candidateMatchesRequestedInstance(session, input.providerInstanceId, input.homes), + ); + if (!candidate) { + return yield* new TeleportDiscoveryError({ + reason: `Native ${input.provider} session '${input.externalSessionId}' was not found for this project.`, + }); + } + nativePath = candidate.nativePath; + } + + const resolved = yield* resolveNativePathInstance({ + homes: input.homes, + provider: input.provider, + nativePath, + ...(input.providerInstanceId === undefined + ? {} + : { requestedInstanceId: input.providerInstanceId }), + }); + + const parsed = yield* adapter.load({ + homes: input.homes, + cwd: input.cwd, + externalSessionId: input.externalSessionId, + nativePath: resolved.nativePath, + }); + if (parsed.externalSessionId !== input.externalSessionId) { + return yield* new TeleportDiscoveryError({ + reason: `Native ${input.provider} session at '${resolved.nativePath}' no longer matches '${input.externalSessionId}'.`, + }); + } + const cwdMatches = yield* teleportSessionBelongsToProject({ + sessionCwd: parsed.cwd, + projectCwd: input.cwd, + ...definedField("extraCwds", input.extraCwds), + }); + if (!cwdMatches) { + return yield* new TeleportDiscoveryError({ + reason: `Native ${input.provider} session '${input.externalSessionId}' no longer belongs to this project.`, + }); + } + return { + ...parsed, + ...definedField("providerInstanceId", resolved.instanceId), + }; +}); diff --git a/apps/server/src/teleport/exportPresence.test.ts b/apps/server/src/teleport/exportPresence.test.ts new file mode 100644 index 000000000000..18e29c97d386 --- /dev/null +++ b/apps/server/src/teleport/exportPresence.test.ts @@ -0,0 +1,52 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { + isPendingTeleportNativePath, + pendingTeleportNativePath, + realExportNativePath, + teleportExportPresenceOnFailure, +} from "./exportPresence.ts"; + +describe("teleport export presence", () => { + it("builds and detects pending native path sentinels", () => { + const pending = pendingTeleportNativePath("codex", "11111111-1111-4111-8111-111111111111"); + assert.equal(pending, "teleport-pending:codex:11111111-1111-4111-8111-111111111111"); + assert.equal(isPendingTeleportNativePath(pending), true); + assert.equal(isPendingTeleportNativePath("/home/user/.codex/sessions/rollout.jsonl"), false); + }); + + it("ignores pending sentinels when reusing an export path", () => { + assert.equal(realExportNativePath("teleport-pending:claudeAgent:abc"), undefined); + assert.equal( + realExportNativePath("/home/user/.codex/sessions/rollout.jsonl"), + "/home/user/.codex/sessions/rollout.jsonl", + ); + assert.equal(realExportNativePath(undefined), undefined); + }); + + it.effect("reverts presence when the native file was never written", () => + Effect.gen(function* () { + const outcome = yield* Ref.make("none"); + yield* teleportExportPresenceOnFailure({ + writtenNativePath: undefined, + revert: Ref.set(outcome, "reverted"), + persistWritten: (nativePath) => Ref.set(outcome, nativePath), + }); + assert.equal(yield* Ref.get(outcome), "reverted"); + }), + ); + + it.effect("persists the real native path when write succeeded and later steps failed", () => + Effect.gen(function* () { + const outcome = yield* Ref.make("none"); + yield* teleportExportPresenceOnFailure({ + writtenNativePath: "/home/user/.codex/sessions/rollout.jsonl", + revert: Ref.set(outcome, "reverted"), + persistWritten: (nativePath) => Ref.set(outcome, nativePath), + }); + assert.equal(yield* Ref.get(outcome), "/home/user/.codex/sessions/rollout.jsonl"); + }), + ); +}); diff --git a/apps/server/src/teleport/exportPresence.ts b/apps/server/src/teleport/exportPresence.ts new file mode 100644 index 000000000000..5b1cd2aa8392 --- /dev/null +++ b/apps/server/src/teleport/exportPresence.ts @@ -0,0 +1,39 @@ +import type { TeleportProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export const PENDING_TELEPORT_NATIVE_PATH_PREFIX = "teleport-pending:"; + +export function pendingTeleportNativePath( + provider: TeleportProvider, + externalSessionId: string, +): string { + return `${PENDING_TELEPORT_NATIVE_PATH_PREFIX}${provider}:${externalSessionId}`; +} + +export function isPendingTeleportNativePath(nativePath: string): boolean { + return nativePath.startsWith(PENDING_TELEPORT_NATIVE_PATH_PREFIX); +} + +export function realExportNativePath(nativePath: string | undefined): string | undefined { + if (nativePath === undefined || isPendingTeleportNativePath(nativePath)) { + return undefined; + } + return nativePath; +} + +/** + * After pending native presence is set, a failed or interrupted export must + * either restore T3 presence or persist the real native path if the file was + * already written. Leaving `teleport-pending:...` strands the thread: the UI + * only offers Import, which then rejects the fake path. + */ +export function teleportExportPresenceOnFailure(input: { + readonly writtenNativePath: string | undefined; + readonly revert: Effect.Effect; + readonly persistWritten: (nativePath: string) => Effect.Effect; +}): Effect.Effect { + if (input.writtenNativePath !== undefined) { + return input.persistWritten(input.writtenNativePath); + } + return input.revert; +} diff --git a/apps/server/src/teleport/fileLock.test.ts b/apps/server/src/teleport/fileLock.test.ts new file mode 100644 index 000000000000..195cc7e7854f --- /dev/null +++ b/apps/server/src/teleport/fileLock.test.ts @@ -0,0 +1,81 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../processRunner.ts"; +import { isNativePathLocked, requireNativePathUnlocked } from "./fileLock.ts"; + +const lockProbeLayer = Layer.merge( + NodeServices.layer, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), +); + +describe("teleport file locks", () => { + it.effect("treats a missing lock probe as TeleportLockProbeError, not a lock", () => + Effect.gen(function* () { + const error = yield* isNativePathLocked("/tmp/teleport-lock-probe-missing").pipe(Effect.flip); + assert.equal(error._tag, "TeleportLockProbeError"); + }).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide( + Layer.merge( + NodeServices.layer, + Layer.succeed(ProcessRunner.ProcessRunner, { + run: () => + new ProcessRunner.ProcessSpawnError({ + command: "lsof", + argumentCount: 2, + cause: new Error("ENOENT"), + }), + }), + ), + ), + ), + ); + + it.effect("treats an unused file as unlocked when the lock probe succeeds", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-lock-free-" }); + const filePath = path.join(root, "session.jsonl"); + yield* fs.writeFileString(filePath, "ok\n"); + assert.equal(yield* isNativePathLocked(filePath), false); + yield* requireNativePathUnlocked(filePath); + }).pipe( + Effect.scoped, + Effect.provide(lockProbeLayer), + Effect.provideService(HostProcessPlatform, "linux"), + ), + ); + + it.effect("reports a held file as locked, not as a probe failure", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-lock-held-" }); + const filePath = path.join(root, "session.jsonl"); + yield* fs.writeFileString(filePath, "ok\n"); + const handle = yield* Effect.tryPromise(() => NodeFSP.open(filePath, "r+")); + try { + assert.equal(yield* isNativePathLocked(filePath), true); + const error = yield* requireNativePathUnlocked(filePath).pipe(Effect.flip); + assert.equal(error._tag, "TeleportFileLockedError"); + } finally { + yield* Effect.promise(() => handle.close()); + } + }).pipe( + Effect.scoped, + Effect.provide(lockProbeLayer), + Effect.provideService(HostProcessPlatform, "linux"), + ), + ); +}); diff --git a/apps/server/src/teleport/fileLock.ts b/apps/server/src/teleport/fileLock.ts new file mode 100644 index 000000000000..2d250277be93 --- /dev/null +++ b/apps/server/src/teleport/fileLock.ts @@ -0,0 +1,109 @@ +import { TeleportFileLockedError, TeleportLockProbeError } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; + +import * as ProcessRunner from "../processRunner.ts"; + +function nodeErrorCode(cause: unknown): string | undefined { + if (typeof cause !== "object" || cause === null) { + return undefined; + } + if ("code" in cause && typeof (cause as { code?: unknown }).code === "string") { + return (cause as { code: string }).code; + } + if ("reason" in cause) { + const nested = (cause as { reason?: { cause?: unknown } }).reason?.cause; + return nodeErrorCode(nested); + } + return undefined; +} + +function platformErrorTag(cause: unknown): string | undefined { + if (cause instanceof PlatformError.PlatformError) { + return cause.reason._tag; + } + return undefined; +} + +function isUnlockedOpenError(cause: unknown): boolean { + const tag = platformErrorTag(cause); + if (tag === "NotFound") { + return true; + } + const code = nodeErrorCode(cause); + return code === "ENOENT" || code === "EISDIR"; +} + +function isLockedOpenError(cause: unknown): boolean { + const tag = platformErrorTag(cause); + if (tag === "Busy" || tag === "PermissionDenied") { + return true; + } + const code = nodeErrorCode(cause); + return code === "EBUSY" || code === "EPERM" || code === "EACCES"; +} + +const isWindowsPathInUse = Effect.fn("isWindowsPathInUse")(function* (nativePath: string) { + const fs = yield* FileSystem.FileSystem; + return yield* Effect.scoped(fs.open(nativePath, { flag: "r+" })).pipe( + Effect.as(false), + Effect.catch((cause: PlatformError.PlatformError) => { + if (isUnlockedOpenError(cause)) { + return Effect.succeed(false); + } + if (isLockedOpenError(cause)) { + return Effect.succeed(true); + } + return Effect.fail(new TeleportLockProbeError({ nativePath, cause })); + }), + ); +}); + +const isUnixPathInUse = Effect.fn("isUnixPathInUse")(function* (nativePath: string) { + const processRunner = yield* ProcessRunner.ProcessRunner; + const result = yield* processRunner + .run({ + command: "lsof", + args: ["-t", nativePath], + timeout: Duration.seconds(2), + maxOutputBytes: 64 * 1024, + outputMode: "truncate", + }) + .pipe( + Effect.catchTags({ + ProcessSpawnError: (cause) => new TeleportLockProbeError({ nativePath, cause }), + ProcessStdinError: (cause) => new TeleportLockProbeError({ nativePath, cause }), + ProcessOutputLimitError: (cause) => new TeleportLockProbeError({ nativePath, cause }), + ProcessReadError: (cause) => new TeleportLockProbeError({ nativePath, cause }), + ProcessTimeoutError: (cause) => new TeleportLockProbeError({ nativePath, cause }), + }), + ); + if (result.code === 0 || result.code === 1) { + return result.stdout.trim().length > 0; + } + return yield* new TeleportLockProbeError({ nativePath, cause: result }); +}); + +export const isNativePathLocked = Effect.fn("isNativePathLocked")(function* (nativePath: string) { + const platform = yield* HostProcessPlatform; + if (platform === "win32") { + // `lsof` is not a Windows tool. Exclusive locks from a native CLI show up + // as EBUSY/EPERM/EACCES on a write-open instead. + return yield* isWindowsPathInUse(nativePath); + } + return yield* isUnixPathInUse(nativePath); +}); + +export const requireNativePathUnlocked = Effect.fn("requireNativePathUnlocked")(function* ( + nativePath: string, +) { + const locked = yield* isNativePathLocked(nativePath); + if (locked) { + return yield* new TeleportFileLockedError({ + nativePath, + }); + } +}); diff --git a/apps/server/src/teleport/formats/adapter.ts b/apps/server/src/teleport/formats/adapter.ts new file mode 100644 index 000000000000..0ddb2b5ea7ad --- /dev/null +++ b/apps/server/src/teleport/formats/adapter.ts @@ -0,0 +1,61 @@ +import { + TeleportDiscoveryError, + TeleportFileLockedError, + TeleportLockProbeError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + type TeleportProvider, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import type * as Effect from "effect/Effect"; +import type * as FileSystem from "effect/FileSystem"; +import type * as Path from "effect/Path"; + +import type * as ProcessRunner from "../../processRunner.ts"; +import type { TeleportHomes } from "../homes.ts"; +import type { ParsedNativeSession } from "../types.ts"; + +export interface TeleportFormatAdapter { + readonly provider: TeleportProvider; + readonly list: (input: { + readonly homes: TeleportHomes; + readonly cwd: string; + readonly extraCwds?: ReadonlyArray; + }) => Effect.Effect< + ReadonlyArray, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path + >; + readonly load: (input: { + readonly homes: TeleportHomes; + readonly cwd: string; + readonly externalSessionId: string; + readonly nativePath: string; + }) => Effect.Effect< + ParsedNativeSession, + TeleportSchemaVersionError | TeleportDiscoveryError, + FileSystem.FileSystem | Path.Path + >; + readonly write: (input: { + readonly homes: TeleportHomes; + readonly session: ParsedNativeSession; + readonly existingNativePath?: string; + }) => Effect.Effect< + string, + | TeleportNativeWriteError + | TeleportFileLockedError + | TeleportLockProbeError + | TeleportSchemaVersionError, + FileSystem.FileSystem | Path.Path | ProcessRunner.ProcessRunner + >; + readonly requireUnlocked: (input: { + readonly homes: TeleportHomes; + readonly nativePath: string; + }) => Effect.Effect< + void, + TeleportFileLockedError | TeleportLockProbeError, + FileSystem.FileSystem | Path.Path | ProcessRunner.ProcessRunner + >; + readonly resumeCursor: (externalSessionId: string) => unknown; + readonly readExternalSessionId: (resumeCursor: unknown) => string | undefined; +} diff --git a/apps/server/src/teleport/formats/claude.test.ts b/apps/server/src/teleport/formats/claude.test.ts new file mode 100644 index 000000000000..7947c80af12b --- /dev/null +++ b/apps/server/src/teleport/formats/claude.test.ts @@ -0,0 +1,299 @@ +// Native session fixtures are JSON/JSONL records, not Effect schemas. +// @effect-diagnostics preferSchemaOverJson:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind, TELEPORT_NATIVE_FORMAT_VERSION } from "@t3tools/contracts"; +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 { discoverTeleportSessions } from "../discovery.ts"; +import type { TeleportHomes } from "../homes.ts"; +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "../resumeCursors.ts"; +import { + sampleTeleportSession, + TELEPORT_TEST_CREATED_AT, + TELEPORT_TEST_SESSION_ID, +} from "../testFixtures.ts"; +import { + encodeClaudeProjectPath, + listClaudeJsonlFiles, + parseClaudeSessionContents, + serializeClaudeSession, + claudeTeleportFormat, +} from "./claude.ts"; +import * as TeleportFormatRegistry from "./registry.ts"; + +describe("teleport Claude format", () => { + it.effect("skips Claude tool_result-only user records", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: "KEEP_NAT_CLAUDE_U1_PINE: create receipts/.gitkeep" }, + })}\n${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: "call-1", content: "ok" }], + }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { + role: "assistant", + content: [{ type: "text", text: "Created the gitkeep file." }], + }, + })}\n`; + const parsed = yield* parseClaudeSessionContents({ + contents, + nativePath: "/tmp/claude-tools.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 2); + assert.equal( + parsed.value.messages[0]?.text, + "KEEP_NAT_CLAUDE_U1_PINE: create receipts/.gitkeep", + ); + assert.equal(parsed.value.messages[1]?.role, "assistant"); + } + }), + ); + + it.effect("skips Claude meta and slash-command caveat records", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + type: "user", + isMeta: true, + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: "/init" }, + })}\n${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { + role: "user", + content: + "Caveat: The messages below were generated by the user.", + }, + })}\n${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: "KEEP_NAT_CLAUDE_U1_PINE: real prompt" }, + })}\n`; + const parsed = yield* parseClaudeSessionContents({ + contents, + nativePath: "/tmp/claude-meta.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 1); + assert.equal(parsed.value.messages[0]?.text, "KEEP_NAT_CLAUDE_U1_PINE: real prompt"); + } + }), + ); + + it.effect("does not list Claude subagent jsonl files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-claude-subagents-" }); + const projectDir = path.join(root, encodeClaudeProjectPath("/workspace")); + const subagentsDir = path.join(projectDir, TELEPORT_TEST_SESSION_ID, "subagents"); + yield* fs.makeDirectory(subagentsDir, { recursive: true }); + const parentPath = path.join(projectDir, `${TELEPORT_TEST_SESSION_ID}.jsonl`); + const childPath = path.join(subagentsDir, "agent-1.jsonl"); + const record = `${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: "hello" }, + })}\n`; + yield* fs.writeFileString(parentPath, record); + yield* fs.writeFileString(childPath, record); + const files = yield* listClaudeJsonlFiles(root, "/workspace"); + assert.equal(files.includes(parentPath), true); + assert.equal(files.includes(childPath), false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("roundtrips Claude jsonl", () => + Effect.gen(function* () { + const session = sampleTeleportSession("claudeAgent"); + const parsed = yield* parseClaudeSessionContents({ + contents: serializeClaudeSession(session), + nativePath: session.nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.messages.length, 2); + } + }), + ); + + it.effect("serializes an empty Claude session with metadata the parser can resume", () => + Effect.gen(function* () { + const session = sampleTeleportSession("claudeAgent"); + const empty = { ...session, messages: [] }; + const parsed = yield* parseClaudeSessionContents({ + contents: serializeClaudeSession(empty), + nativePath: session.nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.cwd, "/workspace"); + assert.equal(parsed.value.messages.length, 0); + } + }), + ); + + it.effect("fails closed on a newer Claude format version", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + type: "user", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION + 1, + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + })}\n`; + const result = yield* parseClaudeSessionContents({ + contents, + nativePath: "/tmp/new-claude.jsonl", + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + }), + ); + + it("encodes Claude project folders without a trailing separator", () => { + assert.equal(encodeClaudeProjectPath("/workspace/"), encodeClaudeProjectPath("/workspace")); + assert.equal( + encodeClaudeProjectPath("C:\\Users\\Foo\\proj\\"), + encodeClaudeProjectPath("C:\\Users\\Foo\\proj"), + ); + }); + + it.effect("lists Claude sessions from the realpath-encoded folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-claude-cwd-" }); + const real = path.join(root, "real-project"); + const link = path.join(root, "link-project"); + const projectsRoot = path.join(root, "projects"); + yield* fs.makeDirectory(real, { recursive: true }); + yield* fs.symlink(real, link); + const lexicalDir = path.join(projectsRoot, encodeClaudeProjectPath(link)); + const realDir = path.join(projectsRoot, encodeClaudeProjectPath(real)); + yield* fs.makeDirectory(lexicalDir, { recursive: true }); + yield* fs.makeDirectory(realDir, { recursive: true }); + const nativePath = path.join(realDir, `${TELEPORT_TEST_SESSION_ID}.jsonl`); + yield* fs.writeFileString( + nativePath, + `${JSON.stringify({ + type: "user", + sessionId: TELEPORT_TEST_SESSION_ID, + cwd: real, + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + })}\n`, + ); + const files = yield* listClaudeJsonlFiles(projectsRoot, link); + assert.equal(files.includes(nativePath), true); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("lists Claude sessions from a project worktree cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-claude-worktree-" }); + const projectCwd = path.join(root, "project"); + const worktreeCwd = path.join(root, "worktrees", "feature"); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + extraCodexSessionsRoots: [], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const nativePath = path.join( + homes.claudeProjectsRoot, + encodeClaudeProjectPath(worktreeCwd), + `${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString( + nativePath, + serializeClaudeSession(sampleTeleportSession("claudeAgent", worktreeCwd)), + ); + const hidden = yield* discoverTeleportSessions({ + homes, + cwd: projectCwd, + providers: ["claudeAgent"], + }); + assert.equal(hidden.sessions.length, 0); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: projectCwd, + extraCwds: [worktreeCwd], + providers: ["claudeAgent"], + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.cwd, worktreeCwd); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("reads a Claude session id from a Windows native path", () => + Effect.gen(function* () { + const parsed = yield* parseClaudeSessionContents({ + nativePath: "C:\\Users\\Foo\\.claude\\projects\\proj\\abc-session.jsonl", + contents: `${JSON.stringify({ + type: "user", + cwd: "C:\\Users\\Foo\\proj", + timestamp: TELEPORT_TEST_CREATED_AT, + message: { role: "user", content: [{ type: "text", text: "hello" }] }, + })}\n`, + }); + assert.equal(parsed._tag, "Some"); + if (parsed._tag === "Some") { + assert.equal(parsed.value.externalSessionId, "abc-session"); + } + }), + ); + + it("roundtrips Claude resume cursors", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("claudeAgent"), + resumeCursor: buildTeleportResumeCursor({ + provider: "claudeAgent", + externalSessionId: TELEPORT_TEST_SESSION_ID, + adapter: claudeTeleportFormat, + }), + runtimePayload: null, + adapter: claudeTeleportFormat, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); +}); diff --git a/apps/server/src/teleport/formats/claude.ts b/apps/server/src/teleport/formats/claude.ts new file mode 100644 index 000000000000..fc0c7e980930 --- /dev/null +++ b/apps/server/src/teleport/formats/claude.ts @@ -0,0 +1,406 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off preferSchemaOverJson:off +import * as NodeCrypto from "node:crypto"; + +import { + TELEPORT_NATIVE_FORMAT_VERSION, + TeleportDiscoveryError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import { normalizeProjectPathForDispatch } from "@t3tools/shared/path"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { + listingTeleportCwds, + resolveTeleportCwdPath, + teleportSessionBelongsToProject, +} from "../cwd.ts"; +import { claudeSearchRoots, resolveClaudeProjectsRootForInstance } from "../homes.ts"; +import { requireNativePathUnlocked } from "../fileLock.ts"; +import { writeNativeSessionAtomically } from "../nativeWrite.ts"; +import { readNativeSessionFile } from "../sessionFile.ts"; +import type { TeleportFormatAdapter } from "./adapter.ts"; +import { + collectTextParts, + definedField, + firstUserTitle, + isRecord, + isSafeTeleportSessionId, + isSyntheticNativeUserText, + nonEmptyString, + parseJsonObject, +} from "../json.ts"; +import { + nativeTextMessage, + parsedNativeSession, + teleportCandidateFields, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; + +const CLAUDE = ProviderDriverKind.make("claudeAgent"); + +function isToolResultOnlyContent(content: unknown): boolean { + if (!Array.isArray(content) || content.length === 0) { + return false; + } + return content.every( + (part) => isRecord(part) && (part.type === "tool_result" || part.type === "tool_use"), + ); +} + +export function encodeClaudeProjectPath(cwd: string): string { + const encoded = normalizeProjectPathForDispatch(cwd).replace(/[^a-zA-Z0-9]/gu, "-"); + if (encoded.length <= 200) { + return encoded; + } + const digest = NodeCrypto.createHash("sha256").update(cwd).digest("hex").slice(0, 16); + return `${encoded.slice(0, 200)}${digest}`; +} + +function extractClaudeMessage(record: Record): NativeTextMessage | undefined { + if (record.isSidechain === true || record.isMeta === true) { + return undefined; + } + const type = record.type; + if (type !== "user" && type !== "assistant") { + return undefined; + } + const message = isRecord(record.message) ? record.message : undefined; + const role = + message?.role === "user" || message?.role === "assistant" + ? message.role + : type === "user" || type === "assistant" + ? type + : undefined; + if (!role) { + return undefined; + } + const text = collectTextParts(message?.content) ?? collectTextParts(record.content); + if (!text) { + return undefined; + } + if (role === "user" && isToolResultOnlyContent(message?.content ?? record.content)) { + return undefined; + } + if (role === "user" && isSyntheticNativeUserText(text)) { + return undefined; + } + return nativeTextMessage({ + role, + text, + createdAt: nonEmptyString(record.timestamp), + id: nonEmptyString(record.uuid), + }); +} + +export function parseClaudeSessionContents(input: { + readonly contents: string; + readonly nativePath: string; +}): Effect.Effect, TeleportSchemaVersionError> { + const lines = input.contents.split(/\r?\n/u).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return Effect.succeed(Option.none()); + } + + let sessionId: string | undefined; + let cwd: string | undefined; + let createdAt: string | undefined; + let nativeFormatVersion: number = TELEPORT_NATIVE_FORMAT_VERSION; + const messages: NativeTextMessage[] = []; + + for (const line of lines) { + const record = parseJsonObject(line); + if (!record) { + continue; + } + const declaredVersion = record.nativeFormatVersion; + if (typeof declaredVersion === "number" && Number.isInteger(declaredVersion)) { + nativeFormatVersion = declaredVersion; + } + if (nativeFormatVersion > TELEPORT_NATIVE_FORMAT_VERSION) { + return Effect.fail( + new TeleportSchemaVersionError({ + provider: "claudeAgent", + nativePath: input.nativePath, + foundVersion: nativeFormatVersion, + supportedVersion: TELEPORT_NATIVE_FORMAT_VERSION, + }), + ); + } + sessionId = nonEmptyString(record.sessionId) ?? sessionId; + cwd = nonEmptyString(record.cwd) ?? cwd; + createdAt = createdAt ?? nonEmptyString(record.timestamp); + const message = extractClaudeMessage(record); + if (message) { + messages.push(message); + } + } + + const externalSessionId = sessionId ?? fileStem(input.nativePath); + if (!externalSessionId || !cwd) { + return Effect.succeed(Option.none()); + } + + const updatedAt = messages.at(-1)?.createdAt ?? createdAt; + return Effect.succeed( + Option.some( + parsedNativeSession({ + provider: "claudeAgent", + externalSessionId, + cwd, + nativePath: input.nativePath, + nativeFormatVersion, + title: firstUserTitle(messages), + createdAt, + updatedAt, + messages, + }), + ), + ); +} + +export function serializeClaudeSession(session: ParsedNativeSession): string { + const lines: string[] = []; + const timestamp = session.createdAt ?? new Date().toISOString(); + if (session.messages.length === 0) { + lines.push( + JSON.stringify({ + type: "session", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + uuid: session.externalSessionId, + parentUuid: null, + sessionId: session.externalSessionId, + cwd: session.cwd, + timestamp, + }), + ); + return `${lines.join("\n")}\n`; + } + let parentUuid: string | null = null; + for (const [index, message] of session.messages.entries()) { + const uuid = message.id ?? `${session.externalSessionId}-${index}`; + const at = message.createdAt ?? timestamp; + lines.push( + JSON.stringify({ + type: message.role, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + uuid, + parentUuid, + sessionId: session.externalSessionId, + cwd: session.cwd, + timestamp: at, + message: { + role: message.role, + content: [{ type: "text", text: message.text }], + }, + }), + ); + parentUuid = uuid; + } + return `${lines.join("\n")}\n`; +} + +export function allocateClaudeSessionPath(input: { + readonly projectsRoot: string; + readonly cwd: string; + readonly sessionId: string; + readonly join: (left: string, ...rest: string[]) => string; +}): string { + return input.join( + input.projectsRoot, + encodeClaudeProjectPath(input.cwd), + `${input.sessionId}.jsonl`, + ); +} + +export const listClaudeJsonlFiles = Effect.fn("listClaudeJsonlFiles")(function* ( + projectsRoot: string, + cwd: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = yield* resolveTeleportCwdPath(cwd); + const encodedNames = [ + ...new Set([encodeClaudeProjectPath(cwd), encodeClaudeProjectPath(resolved)]), + ]; + const roots: string[] = []; + for (const encoded of encodedNames) { + const encodedDir = path.join(projectsRoot, encoded); + if (yield* fs.exists(encodedDir).pipe(Effect.orElseSucceed(() => false))) { + roots.push(encodedDir); + } + } + if (roots.length === 0) { + roots.push(projectsRoot); + } + const files: string[] = []; + for (const root of roots) { + files.push(...(yield* walkJsonl(root))); + } + return files; +}); + +export function toClaudeCandidate( + session: ParsedNativeSession, + instanceId: ProviderInstanceId = defaultInstanceIdForDriver(CLAUDE), +): TeleportSessionCandidate { + return { + provider: "claudeAgent", + providerInstanceId: instanceId, + externalSessionId: session.externalSessionId, + cwd: session.cwd, + nativePath: session.nativePath, + nativeFormatVersion: session.nativeFormatVersion, + ...teleportCandidateFields(session), + }; +} + +function fileStem(filePath: string): string | undefined { + const base = filePath.replaceAll("\\", "/").split("/").at(-1) ?? filePath; + return nonEmptyString(base.replace(/\.jsonl$/u, "")); +} + +const walkJsonl = Effect.fn("walkClaudeJsonl")(function* (root: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = yield* fs.exists(root).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return [] as string[]; + } + const files: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [])); + for (const name of entries) { + const entryPath = path.join(current, name); + const stat = yield* fs.stat(entryPath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) { + continue; + } + if (stat.type === "Directory") { + if (name === "subagents") { + continue; + } + stack.push(entryPath); + } else if (stat.type === "File" && entryPath.endsWith(".jsonl")) { + files.push(entryPath); + } + } + } + return files; +}); + +export const claudeTeleportFormat: TeleportFormatAdapter = { + provider: "claudeAgent", + list: Effect.fn("listClaudeSessions")(function* (input) { + const sessions = []; + const seen = new Set(); + for (const home of claudeSearchRoots(input.homes)) { + const files: string[] = []; + for (const cwd of listingTeleportCwds(input.cwd, input.extraCwds)) { + files.push(...(yield* listClaudeJsonlFiles(home.root, cwd))); + } + for (const nativePath of files) { + if (seen.has(nativePath)) { + continue; + } + const parsed = yield* readNativeSessionFile({ + nativePath, + parse: parseClaudeSessionContents, + }); + if (Option.isNone(parsed) || !isSafeTeleportSessionId(parsed.value.externalSessionId)) { + continue; + } + if ( + !(yield* teleportSessionBelongsToProject({ + sessionCwd: parsed.value.cwd, + projectCwd: input.cwd, + ...definedField("extraCwds", input.extraCwds), + })) + ) { + continue; + } + seen.add(nativePath); + sessions.push(toClaudeCandidate(parsed.value, home.instanceId)); + } + } + return sessions; + }), + load: Effect.fn("loadClaudeSession")(function* (input) { + const parsed = yield* readNativeSessionFile({ + nativePath: input.nativePath, + parse: parseClaudeSessionContents, + }); + if (Option.isNone(parsed)) { + return yield* new TeleportDiscoveryError({ + reason: `Native Claude session '${input.externalSessionId}' could not be parsed.`, + }); + } + return parsed.value; + }), + write: Effect.fn("writeClaudeSession")(function* (input) { + const path = yield* Path.Path; + const projectsRoot = resolveClaudeProjectsRootForInstance( + input.homes, + input.session.providerInstanceId ?? defaultInstanceIdForDriver(CLAUDE), + ); + if (!isSafeTeleportSessionId(input.session.externalSessionId)) { + return yield* new TeleportNativeWriteError({ + nativePath: projectsRoot, + stage: "unsafe-session-id", + sessionId: input.session.externalSessionId, + }); + } + const nativePath = + input.existingNativePath ?? + allocateClaudeSessionPath({ + projectsRoot, + cwd: input.session.cwd, + sessionId: input.session.externalSessionId, + join: path.join, + }); + const contents = serializeClaudeSession({ ...input.session, nativePath }); + yield* writeNativeSessionAtomically({ + filePath: nativePath, + contents, + verify: (written) => + parseClaudeSessionContents({ contents: written, nativePath }).pipe( + Effect.flatMap((parsed) => + Option.isSome(parsed) + ? Effect.void + : new TeleportNativeWriteError({ + nativePath, + stage: "verify", + }), + ), + Effect.catchTags({ + TeleportSchemaVersionError: (error) => + new TeleportNativeWriteError({ + nativePath, + stage: "verify", + cause: error, + }), + }), + ), + }); + return nativePath; + }), + requireUnlocked: (input) => requireNativePathUnlocked(input.nativePath), + resumeCursor: (externalSessionId) => ({ resume: externalSessionId }), + readExternalSessionId: (resumeCursor) => + isRecord(resumeCursor) + ? (nonEmptyString(resumeCursor.resume) ?? nonEmptyString(resumeCursor.threadId)) + : undefined, +}; diff --git a/apps/server/src/teleport/formats/codex.test.ts b/apps/server/src/teleport/formats/codex.test.ts new file mode 100644 index 000000000000..476dccbb8d30 --- /dev/null +++ b/apps/server/src/teleport/formats/codex.test.ts @@ -0,0 +1,517 @@ +// Native session fixtures are JSON/JSONL records, not Effect schemas. +// @effect-diagnostics preferSchemaOverJson:off +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + TELEPORT_NATIVE_FORMAT_VERSION, +} from "@t3tools/contracts"; +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 { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ProcessRunner from "../../processRunner.ts"; +import { discoverTeleportSessions, loadTeleportSession } from "../discovery.ts"; +import type { TeleportHomes } from "../homes.ts"; +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "../resumeCursors.ts"; +import * as TeleportFormatRegistry from "./registry.ts"; +import { + sampleTeleportSession, + TELEPORT_TEST_CREATED_AT, + TELEPORT_TEST_SESSION_ID, +} from "../testFixtures.ts"; +import { codexTeleportFormat, parseCodexSessionContents, serializeCodexSession } from "./codex.ts"; + +describe("teleport Codex format", () => { + it.effect("roundtrips Codex jsonl", () => + Effect.gen(function* () { + const session = sampleTeleportSession("codex"); + const parsed = yield* parseCodexSessionContents({ + contents: serializeCodexSession(session), + nativePath: session.nativePath, + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.externalSessionId, TELEPORT_TEST_SESSION_ID); + assert.equal(parsed.value.cwd, "/workspace"); + assert.equal(parsed.value.messages.length, 2); + assert.equal(parsed.value.messages[0]?.text, "Fix the flaky matcher"); + assert.equal( + parsed.value.messages[1]?.text, + "I'll tighten the path comparison and add a realpath fallback.", + ); + } + }), + ); + + it("writes Codex session_meta the CLI can resume", () => { + const contents = serializeCodexSession(sampleTeleportSession("codex")); + const firstLine = contents.split("\n")[0] ?? ""; + const event = JSON.parse(firstLine) as { + type?: unknown; + ordinal?: unknown; + nativeFormatVersion?: unknown; + payload?: { + id?: unknown; + session_id?: unknown; + cwd?: unknown; + originator?: unknown; + cli_version?: unknown; + source?: unknown; + }; + }; + assert.equal(event.type, "session_meta"); + assert.equal(event.ordinal, 0); + assert.equal(event.nativeFormatVersion, undefined); + assert.equal(event.payload?.id, TELEPORT_TEST_SESSION_ID); + assert.equal(event.payload?.session_id, TELEPORT_TEST_SESSION_ID); + assert.equal(event.payload?.cwd, "/workspace"); + assert.equal(typeof event.payload?.cli_version, "string"); + assert.notEqual(event.payload?.cli_version, ""); + assert.equal(event.payload?.originator, "t3-teleport"); + assert.equal(event.payload?.source, "cli"); + }); + + it.effect("skips Codex environment_context user wrappers", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + payload: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + })}\n${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "\n /workspace\n" }, + ], + }, + })}\n${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "KEEP_NAT_CODEX_U1_CEDAR: add a --json flag" }], + }, + })}\n`; + const parsed = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/codex-env.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages.length, 1); + assert.equal(parsed.value.messages[0]?.text, "KEEP_NAT_CODEX_U1_CEDAR: add a --json flag"); + assert.equal(parsed.value.title, "KEEP_NAT_CODEX_U1_CEDAR: add a --json flag"); + } + }), + ); + + it.effect("preserves leading and trailing whitespace in Codex message text", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + payload: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + })}\n${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: " keep indent \n" }], + }, + })}\n`; + const parsed = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/codex-whitespace.jsonl", + }); + assert.equal(Option.isSome(parsed), true); + if (Option.isSome(parsed)) { + assert.equal(parsed.value.messages[0]?.text, " keep indent \n"); + } + }), + ); + + it.effect("skips forked Codex sessions", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + payload: { + id: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + forked_from_id: "parent-session", + }, + })}\n`; + const parsed = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/fork.jsonl", + }); + assert.equal(Option.isNone(parsed), true); + }), + ); + + it.effect("fails closed on a newer Codex format version", () => + Effect.gen(function* () { + const contents = `${JSON.stringify({ + timestamp: TELEPORT_TEST_CREATED_AT, + type: "session_meta", + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION + 1, + payload: { id: TELEPORT_TEST_SESSION_ID, cwd: "/workspace" }, + })}\n`; + const result = yield* parseCodexSessionContents({ + contents, + nativePath: "/tmp/new-codex.jsonl", + }).pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "TeleportSchemaVersionError"); + } + }), + ); + + it("roundtrips Codex resume cursors", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("codex"), + resumeCursor: buildTeleportResumeCursor({ + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + adapter: codexTeleportFormat, + }), + runtimePayload: null, + adapter: codexTeleportFormat, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); + + it.effect("lists matching Codex sessions and skips unreadable files", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-homes-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + extraCodexSessionsRoots: [], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const codexPath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(codexPath), { recursive: true }); + yield* fs.writeFileString(codexPath, serializeCodexSession(sampleTeleportSession("codex"))); + yield* fs.writeFileString(path.join(path.dirname(codexPath), "garbage.jsonl"), "not-json\n"); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: "/workspace", + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.provider, "codex"); + assert.equal(listed.sessions[0]?.externalSessionId, TELEPORT_TEST_SESSION_ID); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("lists Codex sessions from extra instance homePaths", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-extra-" }); + const workSessionId = "22222222-2222-4222-8222-222222222222"; + const extraRoot = path.join(root, "codex-work", "sessions"); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + extraCodexSessionsRoots: [ + { + root: extraRoot, + instanceId: ProviderInstanceId.make("codex_work"), + }, + ], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const extraPath = path.join( + extraRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${workSessionId}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(extraPath), { recursive: true }); + yield* fs.writeFileString( + extraPath, + serializeCodexSession({ + ...sampleTeleportSession("codex"), + externalSessionId: workSessionId, + }), + ); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: "/workspace", + providers: ["codex"], + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.externalSessionId, workSessionId); + assert.equal(listed.sessions[0]?.providerInstanceId, "codex_work"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("lists Codex sessions from a project worktree cwd", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-worktree-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + extraCodexSessionsRoots: [], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const worktreeCwd = path.join(root, "worktrees", "feature"); + const projectCwd = path.join(root, "project"); + const nativePath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString( + nativePath, + serializeCodexSession(sampleTeleportSession("codex", worktreeCwd)), + ); + const hidden = yield* discoverTeleportSessions({ + homes, + cwd: projectCwd, + providers: ["codex"], + }); + assert.equal(hidden.sessions.length, 0); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: projectCwd, + extraCwds: [worktreeCwd], + providers: ["codex"], + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.cwd, worktreeCwd); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("lists Codex sessions started in a project subdirectory", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-subdir-" }); + const homes: TeleportHomes = { + codexSessionsRoot: path.join(root, "codex", "sessions"), + extraCodexSessionsRoots: [], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const projectCwd = path.join(root, "project"); + const nestedCwd = path.join(projectCwd, "packages", "app"); + const nativePath = path.join( + homes.codexSessionsRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString( + nativePath, + serializeCodexSession(sampleTeleportSession("codex", nestedCwd)), + ); + const listed = yield* discoverTeleportSessions({ + homes, + cwd: projectCwd, + providers: ["codex"], + }); + assert.equal(listed.sessions.length, 1); + assert.equal(listed.sessions[0]?.cwd, nestedCwd); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("loads the Codex session for the requested instance when ids collide", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-collide-" }); + const extraRoot = path.join(root, "codex-work", "sessions"); + const defaultRoot = path.join(root, "codex", "sessions"); + const homes: TeleportHomes = { + codexSessionsRoot: defaultRoot, + extraCodexSessionsRoots: [ + { + root: extraRoot, + instanceId: ProviderInstanceId.make("codex_work"), + }, + ], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const defaultPath = path.join( + defaultRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + const extraPath = path.join( + extraRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + const defaultSession = sampleTeleportSession("codex"); + const workSession = { + ...sampleTeleportSession("codex"), + messages: [ + { + role: "user" as const, + text: "Fix the flaky matcher", + createdAt: TELEPORT_TEST_CREATED_AT, + id: "user-1", + }, + { + role: "assistant" as const, + text: "Work instance transcript", + createdAt: "2026-08-14T06:01:00.000Z", + id: "assistant-work", + }, + ], + }; + yield* fs.makeDirectory(path.dirname(defaultPath), { recursive: true }); + yield* fs.makeDirectory(path.dirname(extraPath), { recursive: true }); + yield* fs.writeFileString(defaultPath, serializeCodexSession(defaultSession)); + yield* fs.writeFileString(extraPath, serializeCodexSession(workSession)); + const parsed = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + providerInstanceId: ProviderInstanceId.make("codex_work"), + }); + assert.equal(parsed.nativePath, extraPath); + assert.equal(parsed.providerInstanceId, "codex_work"); + assert.equal(parsed.messages[1]?.text, "Work instance transcript"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("loads a shared-home custom instance from the default listing label", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-shared-home-" }); + const sharedRoot = path.join(root, "codex", "sessions"); + const homes: TeleportHomes = { + codexSessionsRoot: sharedRoot, + extraCodexSessionsRoots: [ + { + root: sharedRoot, + instanceId: ProviderInstanceId.make("codex_work"), + }, + ], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const nativePath = path.join( + sharedRoot, + "2026", + "08", + "14", + `rollout-2026-08-14T06-00-00-${TELEPORT_TEST_SESSION_ID}.jsonl`, + ); + yield* fs.makeDirectory(path.dirname(nativePath), { recursive: true }); + yield* fs.writeFileString(nativePath, serializeCodexSession(sampleTeleportSession("codex"))); + const parsed = yield* loadTeleportSession({ + homes, + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd: "/workspace", + providerInstanceId: ProviderInstanceId.make("codex_work"), + }); + assert.equal(parsed.nativePath, nativePath); + assert.equal(parsed.providerInstanceId, "codex_work"); + }).pipe( + Effect.scoped, + Effect.provide(Layer.merge(NodeServices.layer, TeleportFormatRegistry.layer)), + ), + ); + + it.effect("writes a new Codex session under the selected instance home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-codex-write-" }); + const extraRoot = path.join(root, "codex-work", "sessions"); + const defaultRoot = path.join(root, "codex", "sessions"); + const homes: TeleportHomes = { + codexSessionsRoot: defaultRoot, + extraCodexSessionsRoots: [ + { + root: extraRoot, + instanceId: ProviderInstanceId.make("codex_work"), + }, + ], + claudeProjectsRoot: path.join(root, "claude", "projects"), + extraClaudeProjectsRoots: [], + }; + const adapter = codexTeleportFormat; + assert.ok(adapter); + const nativePath = yield* adapter.write({ + homes, + session: { + ...sampleTeleportSession("codex"), + providerInstanceId: ProviderInstanceId.make("codex_work"), + }, + }); + assert.equal(nativePath.startsWith(`${extraRoot}${path.sep}`), true); + assert.equal(nativePath.startsWith(`${defaultRoot}${path.sep}`), false); + assert.equal(yield* fs.exists(nativePath), true); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.merge( + NodeServices.layer, + Layer.merge( + TeleportFormatRegistry.layer, + ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), + ), + ), + ), + Effect.provideService(HostProcessPlatform, "linux"), + ), + ); +}); diff --git a/apps/server/src/teleport/formats/codex.ts b/apps/server/src/teleport/formats/codex.ts new file mode 100644 index 000000000000..7a1fada43712 --- /dev/null +++ b/apps/server/src/teleport/formats/codex.ts @@ -0,0 +1,441 @@ +// Native Codex files store wall-clock ISO timestamps and JSONL event records. +// @effect-diagnostics globalDate:off preferSchemaOverJson:off +import { + TELEPORT_NATIVE_FORMAT_VERSION, + TeleportDiscoveryError, + TeleportNativeWriteError, + TeleportSchemaVersionError, + defaultInstanceIdForDriver, + ProviderDriverKind, + type ProviderInstanceId, + type TeleportSessionCandidate, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { teleportSessionBelongsToProject } from "../cwd.ts"; +import { codexSearchRoots, resolveCodexSessionsRoot } from "../homes.ts"; +import { readNativeSessionFile } from "../sessionFile.ts"; +import { requireNativePathUnlocked } from "../fileLock.ts"; +import { + definedField, + firstUserTitle, + isRecord, + isSafeTeleportSessionId, + isSyntheticNativeUserText, + nativeSessionText, + nonEmptyString, + parseJsonObject, + uuidFromPath, +} from "../json.ts"; +import { writeNativeSessionAtomically } from "../nativeWrite.ts"; +import type { TeleportFormatAdapter } from "./adapter.ts"; +import { + nativeTextMessage, + parsedNativeSession, + teleportCandidateFields, + type NativeTextMessage, + type ParsedNativeSession, +} from "../types.ts"; + +const CODEX = ProviderDriverKind.make("codex"); + +function isForkedSessionMeta(payload: Record): boolean { + if (typeof payload.forked_from_id === "string") { + return true; + } + const source = payload.source; + if (!isRecord(source)) { + return false; + } + const subagent = source.subagent; + if (!isRecord(subagent)) { + return false; + } + const spawn = subagent.thread_spawn; + if (!isRecord(spawn)) { + return false; + } + return typeof spawn.parent_thread_id === "string"; +} + +function extractMessage(event: Record): NativeTextMessage | undefined { + if (event.type !== "response_item") { + return undefined; + } + const payload = isRecord(event.payload) ? event.payload : undefined; + if (payload?.type !== "message") { + return undefined; + } + const role = payload.role === "user" || payload.role === "assistant" ? payload.role : undefined; + if (!role) { + return undefined; + } + const text = collectCodexText(payload.content); + if (!text) { + return undefined; + } + if (isSyntheticNativeUserText(text)) { + return undefined; + } + return nativeTextMessage({ + role, + text, + createdAt: nonEmptyString(event.timestamp), + id: undefined, + }); +} + +function collectCodexText(content: unknown): string | undefined { + if (typeof content === "string") { + return nativeSessionText(content); + } + if (!Array.isArray(content)) { + return undefined; + } + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part)) { + continue; + } + const text = nativeSessionText(part.text); + if (text) { + parts.push(text); + } + } + return parts.length > 0 ? parts.join("\n") : undefined; +} + +export function parseCodexSessionContents(input: { + readonly contents: string; + readonly nativePath: string; +}): Effect.Effect, TeleportSchemaVersionError> { + const lines = input.contents.split(/\r?\n/u).filter((line) => line.trim().length > 0); + if (lines.length === 0) { + return Effect.succeed(Option.none()); + } + + let sessionId: string | undefined; + let cwd: string | undefined; + let createdAt: string | undefined; + let title: string | undefined; + let nativeFormatVersion: number = TELEPORT_NATIVE_FORMAT_VERSION; + let forked = false; + const messages: NativeTextMessage[] = []; + + for (const line of lines) { + const event = parseJsonObject(line); + if (!event) { + continue; + } + const declaredVersion = event.nativeFormatVersion; + if (typeof declaredVersion === "number" && Number.isInteger(declaredVersion)) { + nativeFormatVersion = declaredVersion; + } + if (nativeFormatVersion > TELEPORT_NATIVE_FORMAT_VERSION) { + return Effect.fail( + new TeleportSchemaVersionError({ + provider: "codex", + nativePath: input.nativePath, + foundVersion: nativeFormatVersion, + supportedVersion: TELEPORT_NATIVE_FORMAT_VERSION, + }), + ); + } + + if (event.type === "session_meta") { + const payload = isRecord(event.payload) ? event.payload : undefined; + if (!payload) { + continue; + } + if (isForkedSessionMeta(payload)) { + forked = true; + continue; + } + sessionId = nonEmptyString(payload.id) ?? nonEmptyString(payload.session_id) ?? sessionId; + cwd = nonEmptyString(payload.cwd) ?? cwd; + createdAt = nonEmptyString(event.timestamp) ?? nonEmptyString(payload.timestamp) ?? createdAt; + title = nonEmptyString(payload.title) ?? title; + continue; + } + + if (event.type === "turn_context") { + const payload = isRecord(event.payload) ? event.payload : undefined; + cwd = nonEmptyString(payload?.cwd) ?? cwd; + continue; + } + + const message = extractMessage(event); + if (message) { + messages.push(message); + } + } + + if (forked) { + return Effect.succeed(Option.none()); + } + + const externalSessionId = sessionId ?? uuidFromPath(input.nativePath); + if (!externalSessionId || !cwd) { + return Effect.succeed(Option.none()); + } + + const updatedAt = messages.at(-1)?.createdAt ?? createdAt; + return Effect.succeed( + Option.some( + parsedNativeSession({ + provider: "codex", + externalSessionId, + cwd, + nativePath: input.nativePath, + nativeFormatVersion, + title: title ?? firstUserTitle(messages), + createdAt, + updatedAt, + messages, + }), + ), + ); +} + +const CODEX_EXPORT_ORIGINATOR = "t3-teleport"; +const CODEX_EXPORT_CLI_VERSION = "0.0.0"; + +/** + * Codex TUI bootstrap requires the first parseable rollout line to be + * `session_meta`. Extra top-level fields (including our format version) make + * that line fail serde, so resume then treats the first `response_item` as + * the header and errors with "does not start with session metadata". + * `cli_version` is required on SessionMeta. + */ +export function serializeCodexSession(session: ParsedNativeSession): string { + const timestamp = session.createdAt ?? new Date().toISOString(); + let ordinal = 0; + const lines: string[] = [ + JSON.stringify({ + timestamp, + ordinal: ordinal++, + type: "session_meta", + payload: { + id: session.externalSessionId, + session_id: session.externalSessionId, + timestamp, + cwd: session.cwd, + originator: CODEX_EXPORT_ORIGINATOR, + cli_version: CODEX_EXPORT_CLI_VERSION, + source: "cli", + thread_source: "user", + }, + }), + JSON.stringify({ + timestamp, + ordinal: ordinal++, + type: "turn_context", + payload: { + cwd: session.cwd, + }, + }), + ]; + + for (const message of session.messages) { + const at = message.createdAt ?? timestamp; + lines.push( + JSON.stringify({ + timestamp: at, + ordinal: ordinal++, + type: "response_item", + payload: { + type: "message", + role: message.role, + content: [ + { + type: message.role === "user" ? "input_text" : "output_text", + text: message.text, + }, + ], + }, + }), + ); + } + + return `${lines.join("\n")}\n`; +} + +export function allocateCodexSessionPath(input: { + readonly sessionsRoot: string; + readonly sessionId: string; + readonly createdAt: string; + readonly join: (left: string, ...rest: string[]) => string; +}): string { + const created = new Date(input.createdAt); + const safeDate = Number.isNaN(created.getTime()) ? new Date() : created; + const year = String(safeDate.getUTCFullYear()); + const month = String(safeDate.getUTCMonth() + 1).padStart(2, "0"); + const day = String(safeDate.getUTCDate()).padStart(2, "0"); + const stamp = safeDate.toISOString().replaceAll(":", "-"); + return input.join( + input.sessionsRoot, + year, + month, + day, + `rollout-${stamp}-${input.sessionId}.jsonl`, + ); +} + +export const listCodexJsonlFiles = Effect.fn("listCodexJsonlFiles")(function* ( + sessionsRoot: string, +) { + return yield* walkFiles(sessionsRoot, (filePath) => filePath.endsWith(".jsonl")); +}); + +export function toCodexCandidate( + session: ParsedNativeSession, + instanceId: ProviderInstanceId = defaultInstanceIdForDriver(CODEX), +): TeleportSessionCandidate { + return { + provider: "codex", + providerInstanceId: instanceId, + externalSessionId: session.externalSessionId, + cwd: session.cwd, + nativePath: session.nativePath, + nativeFormatVersion: session.nativeFormatVersion, + ...teleportCandidateFields(session), + }; +} + +const walkFiles = Effect.fn("walkFiles")(function* ( + root: string, + predicate: (filePath: string) => boolean, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exists = yield* fs.exists(root).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + return []; + } + + const files: string[] = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) { + continue; + } + const entries = yield* fs.readDirectory(current).pipe(Effect.orElseSucceed(() => [])); + for (const name of entries) { + const entryPath = path.join(current, name); + const stat = yield* fs.stat(entryPath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null) { + continue; + } + if (stat.type === "Directory") { + stack.push(entryPath); + } else if (stat.type === "File" && predicate(entryPath)) { + files.push(entryPath); + } + } + } + return files; +}); + +export const codexTeleportFormat: TeleportFormatAdapter = { + provider: "codex", + list: Effect.fn("listCodexSessions")(function* (input) { + const sessions = []; + const seen = new Set(); + for (const home of codexSearchRoots(input.homes)) { + const files = yield* listCodexJsonlFiles(home.root); + for (const nativePath of files) { + if (seen.has(nativePath)) { + continue; + } + const parsed = yield* readNativeSessionFile({ + nativePath, + parse: parseCodexSessionContents, + }); + if (Option.isNone(parsed) || !isSafeTeleportSessionId(parsed.value.externalSessionId)) { + continue; + } + if ( + !(yield* teleportSessionBelongsToProject({ + sessionCwd: parsed.value.cwd, + projectCwd: input.cwd, + ...definedField("extraCwds", input.extraCwds), + })) + ) { + continue; + } + seen.add(nativePath); + sessions.push(toCodexCandidate(parsed.value, home.instanceId)); + } + } + return sessions; + }), + load: Effect.fn("loadCodexSession")(function* (input) { + const parsed = yield* readNativeSessionFile({ + nativePath: input.nativePath, + parse: parseCodexSessionContents, + }); + if (Option.isNone(parsed)) { + return yield* new TeleportDiscoveryError({ + reason: `Native Codex session '${input.externalSessionId}' could not be parsed.`, + }); + } + return parsed.value; + }), + write: Effect.fn("writeCodexSession")(function* (input) { + const path = yield* Path.Path; + const sessionsRoot = resolveCodexSessionsRoot( + input.homes, + input.session.providerInstanceId ?? defaultInstanceIdForDriver(CODEX), + ); + if (!isSafeTeleportSessionId(input.session.externalSessionId)) { + return yield* new TeleportNativeWriteError({ + nativePath: sessionsRoot, + stage: "unsafe-session-id", + sessionId: input.session.externalSessionId, + }); + } + const now = yield* DateTime.now; + const nativePath = + input.existingNativePath ?? + allocateCodexSessionPath({ + sessionsRoot, + sessionId: input.session.externalSessionId, + createdAt: input.session.createdAt ?? input.session.updatedAt ?? DateTime.formatIso(now), + join: path.join, + }); + const contents = serializeCodexSession({ ...input.session, nativePath }); + yield* writeNativeSessionAtomically({ + filePath: nativePath, + contents, + verify: (written) => + parseCodexSessionContents({ contents: written, nativePath }).pipe( + Effect.flatMap((parsed) => + Option.isSome(parsed) + ? Effect.void + : new TeleportNativeWriteError({ + nativePath, + stage: "verify", + }), + ), + Effect.catchTags({ + TeleportSchemaVersionError: (error) => + new TeleportNativeWriteError({ + nativePath, + stage: "verify", + cause: error, + }), + }), + ), + }); + return nativePath; + }), + requireUnlocked: (input) => requireNativePathUnlocked(input.nativePath), + resumeCursor: (externalSessionId) => ({ threadId: externalSessionId }), + readExternalSessionId: (resumeCursor) => + isRecord(resumeCursor) ? nonEmptyString(resumeCursor.threadId) : undefined, +}; diff --git a/apps/server/src/teleport/formats/registry.ts b/apps/server/src/teleport/formats/registry.ts new file mode 100644 index 000000000000..e4fea42222ca --- /dev/null +++ b/apps/server/src/teleport/formats/registry.ts @@ -0,0 +1,31 @@ +import type { TeleportProvider } from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import type { TeleportFormatAdapter } from "./adapter.ts"; +import { claudeTeleportFormat } from "./claude.ts"; +import { codexTeleportFormat } from "./codex.ts"; + +export class TeleportFormatRegistry extends Context.Service< + TeleportFormatRegistry, + { + readonly get: (provider: TeleportProvider) => TeleportFormatAdapter | undefined; + readonly providers: ReadonlyArray; + } +>()("t3/teleport/formats/registry/TeleportFormatRegistry") {} + +/** Exported for tests, which stand a registry up from adapters they supply themselves. */ +export function fromAdapters( + adapters: ReadonlyArray, +): TeleportFormatRegistry["Service"] { + const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); + return { + get: (provider) => byProvider.get(provider), + providers: adapters.map((adapter) => adapter.provider), + }; +} + +export const make = Effect.sync(() => fromAdapters([codexTeleportFormat, claudeTeleportFormat])); + +export const layer = Layer.effect(TeleportFormatRegistry, make); diff --git a/apps/server/src/teleport/homes.test.ts b/apps/server/src/teleport/homes.test.ts new file mode 100644 index 000000000000..fcc68c28d65c --- /dev/null +++ b/apps/server/src/teleport/homes.test.ts @@ -0,0 +1,157 @@ +import * as NodeOS from "node:os"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { ProviderInstanceId, ServerSettings } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { + resolveClaudeProjectsRootForInstance, + resolveCodexSessionsRoot, + resolveTeleportHomes, + codexSearchRoots, +} from "./homes.ts"; + +const decodeServerSettings = Schema.decodeSync(ServerSettings); + +describe("resolveTeleportHomes", () => { + it.effect("includes extra Codex instance homes that use a custom homePath", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-homes-" }); + const defaultHome = path.join(root, "codex-default"); + const workHome = path.join(root, "codex-work"); + const homes = yield* resolveTeleportHomes( + decodeServerSettings({ + providers: { + codex: { homePath: defaultHome }, + }, + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { + driver: "codex", + config: { homePath: workHome }, + }, + }, + }), + ); + + assert.equal(homes.codexSessionsRoot, path.join(defaultHome, "sessions")); + assert.equal(homes.extraCodexSessionsRoots.length, 1); + assert.equal(homes.extraCodexSessionsRoots[0]?.instanceId, "codex_work"); + assert.equal(homes.extraCodexSessionsRoots[0]?.root, path.join(workHome, "sessions")); + assert.equal( + resolveCodexSessionsRoot(homes, ProviderInstanceId.make("codex_work")), + path.join(workHome, "sessions"), + ); + assert.equal( + resolveCodexSessionsRoot(homes, ProviderInstanceId.make("codex")), + path.join(defaultHome, "sessions"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("does not duplicate Codex homes that resolve to the same sessions root", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-homes-dup-" }); + const sharedHome = path.join(root, "codex-shared"); + const homes = yield* resolveTeleportHomes( + decodeServerSettings({ + providers: { + codex: { homePath: sharedHome }, + }, + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { + driver: "codex", + config: { homePath: sharedHome }, + }, + }, + }), + ); + + assert.equal(homes.codexSessionsRoot, path.join(sharedHome, "sessions")); + assert.equal(homes.extraCodexSessionsRoots.length, 1); + assert.equal(homes.extraCodexSessionsRoots[0]?.instanceId, "codex_work"); + assert.equal(homes.extraCodexSessionsRoots[0]?.root, path.join(sharedHome, "sessions")); + assert.equal( + resolveCodexSessionsRoot(homes, ProviderInstanceId.make("codex_work")), + path.join(sharedHome, "sessions"), + ); + assert.equal(codexSearchRoots(homes).length, 1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("includes extra Claude instance homes that use a custom homePath", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-claude-homes-" }); + const defaultHome = path.join(root, "claude-default"); + const workHome = path.join(root, "claude-work"); + yield* fs.makeDirectory(path.join(workHome, ".claude", "projects"), { recursive: true }); + const homes = yield* resolveTeleportHomes( + decodeServerSettings({ + providers: { + claudeAgent: { homePath: defaultHome }, + }, + providerInstances: { + [ProviderInstanceId.make("claude_work")]: { + driver: "claudeAgent", + config: { homePath: workHome }, + }, + }, + }), + ); + + assert.equal(homes.claudeProjectsRoot, path.join(defaultHome, "projects")); + assert.equal(homes.extraClaudeProjectsRoots.length, 1); + assert.equal(homes.extraClaudeProjectsRoots[0]?.instanceId, "claude_work"); + assert.equal( + homes.extraClaudeProjectsRoots[0]?.root, + path.join(workHome, ".claude", "projects"), + ); + assert.equal( + resolveClaudeProjectsRootForInstance(homes, ProviderInstanceId.make("claude_work")), + path.join(workHome, ".claude", "projects"), + ); + assert.equal( + resolveClaudeProjectsRootForInstance(homes, ProviderInstanceId.make("claudeAgent")), + path.join(defaultHome, "projects"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("uses the provider default home when an extra instance sets an empty homePath", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "teleport-homes-empty-" }); + const defaultHome = path.join(root, "codex-default"); + const homes = yield* resolveTeleportHomes( + decodeServerSettings({ + providers: { + codex: { homePath: defaultHome }, + }, + providerInstances: { + [ProviderInstanceId.make("codex_work")]: { + driver: "codex", + config: { homePath: "" }, + }, + }, + }), + ); + + assert.equal(homes.codexSessionsRoot, path.join(defaultHome, "sessions")); + assert.equal(homes.extraCodexSessionsRoots.length, 1); + assert.equal(homes.extraCodexSessionsRoots[0]?.instanceId, "codex_work"); + assert.equal( + homes.extraCodexSessionsRoots[0]?.root, + path.join(NodeOS.homedir(), ".codex", "sessions"), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/teleport/homes.ts b/apps/server/src/teleport/homes.ts new file mode 100644 index 000000000000..98e7cbc36aca --- /dev/null +++ b/apps/server/src/teleport/homes.ts @@ -0,0 +1,275 @@ +import { + defaultInstanceIdForDriver, + ProviderDriverKind, + ProviderInstanceId, + type ClaudeSettings, + type CodexSettings, + type ServerSettings, + type TeleportProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; + +const CODEX_DRIVER = ProviderDriverKind.make("codex"); +const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); + +export interface TeleportInstanceRoot { + readonly root: string; + readonly instanceId: ProviderInstanceId; +} + +export interface TeleportHomes { + readonly codexSessionsRoot: string; + readonly extraCodexSessionsRoots: ReadonlyArray; + readonly claudeProjectsRoot: string; + readonly extraClaudeProjectsRoots: ReadonlyArray; +} + +function uniqueInstanceRoots( + roots: ReadonlyArray, +): ReadonlyArray { + const seen = new Set(); + const unique: TeleportInstanceRoot[] = []; + for (const root of roots) { + if (seen.has(root.root)) { + continue; + } + seen.add(root.root); + unique.push(root); + } + return unique; +} + +export function codexSearchRoots(homes: TeleportHomes): ReadonlyArray { + return uniqueInstanceRoots([ + { + root: homes.codexSessionsRoot, + instanceId: defaultInstanceIdForDriver(CODEX_DRIVER), + }, + ...homes.extraCodexSessionsRoots, + ]); +} + +export function resolveCodexSessionsRoot( + homes: TeleportHomes, + instanceId: ProviderInstanceId, +): string { + return ( + homes.extraCodexSessionsRoots.find((root) => root.instanceId === instanceId)?.root ?? + homes.codexSessionsRoot + ); +} + +export function resolveClaudeProjectsRootForInstance( + homes: TeleportHomes, + instanceId: ProviderInstanceId, +): string { + return ( + homes.extraClaudeProjectsRoots.find((root) => root.instanceId === instanceId)?.root ?? + homes.claudeProjectsRoot + ); +} + +export function teleportNativeRootFor( + homes: TeleportHomes, + provider: TeleportProvider, + instanceId: ProviderInstanceId, +): string { + return ( + configuredTeleportNativeRootFor(homes, provider, instanceId) ?? + fallbackNativeRoot(homes, provider) + ); +} + +export function configuredInstanceRootsForProvider( + homes: TeleportHomes, + provider: TeleportProvider, +): ReadonlyArray { + switch (provider) { + case "codex": + return [ + { + root: homes.codexSessionsRoot, + instanceId: defaultInstanceIdForDriver(CODEX_DRIVER), + }, + ...homes.extraCodexSessionsRoots, + ]; + case "claudeAgent": + return [ + { + root: homes.claudeProjectsRoot, + instanceId: defaultInstanceIdForDriver(CLAUDE_DRIVER), + }, + ...homes.extraClaudeProjectsRoots, + ]; + default: { + const _exhaustive: never = provider; + return _exhaustive; + } + } +} + +export function configuredTeleportNativeRootFor( + homes: TeleportHomes, + provider: TeleportProvider, + instanceId: ProviderInstanceId, +): string | undefined { + const match = configuredInstanceRootsForProvider(homes, provider).find( + (root) => root.instanceId === instanceId, + ); + return match?.root; +} + +function fallbackNativeRoot(homes: TeleportHomes, provider: TeleportProvider): string { + switch (provider) { + case "codex": + return homes.codexSessionsRoot; + case "claudeAgent": + return homes.claudeProjectsRoot; + default: { + const _exhaustive: never = provider; + return _exhaustive; + } + } +} + +export function nativePathIsUnderRoot(nativePath: string, root: string): boolean { + const normalizedPath = nativePath.replaceAll("\\", "/"); + const normalizedRoot = root.replaceAll("\\", "/").replace(/\/+$/u, ""); + return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`); +} + +export const canonicalizeTeleportNativePath = Effect.fn("canonicalizeTeleportNativePath")( + function* (value: string): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolved = path.resolve(value); + return yield* fs.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); + }, +); + +export function claudeSearchRoots(homes: TeleportHomes): ReadonlyArray { + return uniqueInstanceRoots([ + { + root: homes.claudeProjectsRoot, + instanceId: defaultInstanceIdForDriver(CLAUDE_DRIVER), + }, + ...homes.extraClaudeProjectsRoots, + ]); +} + +function instanceConfigString(config: unknown, key: string): string | undefined { + if (config === null || typeof config !== "object" || Array.isArray(config)) { + return undefined; + } + const value = (config as Record)[key]; + if (typeof value !== "string") { + return undefined; + } + // Keep explicit empty strings so extra instances can opt into the provider + // default home instead of inheriting the default instance's configured path. + return value.trim(); +} + +function instanceIdsForDriver( + settings: ServerSettings, + driver: ProviderDriverKind, +): ReadonlyArray { + const ids = [defaultInstanceIdForDriver(driver)]; + const seen = new Set(ids); + for (const [instanceId, envelope] of Object.entries(settings.providerInstances)) { + if (envelope.driver !== driver || seen.has(instanceId)) { + continue; + } + seen.add(instanceId); + ids.push(ProviderInstanceId.make(instanceId)); + } + return ids; +} + +function codexSettingsForInstance( + settings: ServerSettings, + instanceId: ProviderInstanceId, +): CodexSettings { + const envelope = settings.providerInstances[instanceId]; + if (envelope === undefined || envelope.driver !== CODEX_DRIVER) { + return settings.providers.codex; + } + return { + ...settings.providers.codex, + homePath: + instanceConfigString(envelope.config, "homePath") ?? settings.providers.codex.homePath, + shadowHomePath: + instanceConfigString(envelope.config, "shadowHomePath") ?? + settings.providers.codex.shadowHomePath, + }; +} + +function claudeSettingsForInstance( + settings: ServerSettings, + instanceId: ProviderInstanceId, +): Pick { + const envelope = settings.providerInstances[instanceId]; + if (envelope === undefined || envelope.driver !== CLAUDE_DRIVER) { + return settings.providers.claudeAgent; + } + return { + homePath: + instanceConfigString(envelope.config, "homePath") ?? settings.providers.claudeAgent.homePath, + }; +} + +const resolveClaudeProjectsRoot = Effect.fn("resolveClaudeProjectsRoot")(function* ( + claudeHome: string, +): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nestedClaude = path.join(claudeHome, ".claude", "projects"); + const nestedExists = yield* fs.exists(nestedClaude).pipe(Effect.orElseSucceed(() => false)); + return nestedExists ? nestedClaude : path.join(claudeHome, "projects"); +}); + +export const resolveTeleportHomes = Effect.fn("resolveTeleportHomes")(function* ( + settings: ServerSettings, +): Effect.fn.Return { + const path = yield* Path.Path; + const defaultCodexId = defaultInstanceIdForDriver(CODEX_DRIVER); + const defaultClaudeId = defaultInstanceIdForDriver(CLAUDE_DRIVER); + + let codexSessionsRoot = ""; + const extraCodexSessionsRoots: TeleportInstanceRoot[] = []; + for (const instanceId of instanceIdsForDriver(settings, CODEX_DRIVER)) { + const layout = yield* resolveCodexHomeLayout(codexSettingsForInstance(settings, instanceId)); + const sessionsRoot = path.join(layout.sharedHomePath, "sessions"); + if (instanceId === defaultCodexId) { + codexSessionsRoot = sessionsRoot; + continue; + } + extraCodexSessionsRoots.push({ root: sessionsRoot, instanceId }); + } + + let claudeProjectsRoot = ""; + const extraClaudeProjectsRoots: TeleportInstanceRoot[] = []; + for (const instanceId of instanceIdsForDriver(settings, CLAUDE_DRIVER)) { + const claudeHome = yield* resolveClaudeHomePath( + claudeSettingsForInstance(settings, instanceId), + ); + const projectsRoot = yield* resolveClaudeProjectsRoot(claudeHome); + if (instanceId === defaultClaudeId) { + claudeProjectsRoot = projectsRoot; + continue; + } + extraClaudeProjectsRoots.push({ root: projectsRoot, instanceId }); + } + + return { + codexSessionsRoot, + extraCodexSessionsRoots, + claudeProjectsRoot, + extraClaudeProjectsRoots, + }; +}); diff --git a/apps/server/src/teleport/importTransaction.test.ts b/apps/server/src/teleport/importTransaction.test.ts new file mode 100644 index 000000000000..e4a36706eddc --- /dev/null +++ b/apps/server/src/teleport/importTransaction.test.ts @@ -0,0 +1,309 @@ +import { CommandId, TELEPORT_IMPORT_BATCH_SEMANTICS, ThreadId } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import { + committedTeleportImportState, + importSessionBatch, + importingTeleportState, + nativeTranscriptWouldWipeExistingHistory, + recoverInterruptedImportTeleports, + restorePresenceForImport, + restoredTeleportStateAfterInterruptedImport, + runInPlaceTeleportImport, +} from "./importTransaction.ts"; + +const BASE_TELEPORT = { + provider: "codex" as const, + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", +}; + +type ImportStepError = { + readonly _tag: "ImportStepError"; + readonly step: string; +}; + +function importStepError(step: string): ImportStepError { + return { _tag: "ImportStepError", step }; +} + +describe("teleport import transaction", () => { + it("documents per-session batch atomicity", () => { + assert.equal(TELEPORT_IMPORT_BATCH_SEMANTICS, "per-session"); + }); + + it("refuses to wipe existing history with an empty native transcript", () => { + assert.equal( + nativeTranscriptWouldWipeExistingHistory({ + nativeMessageCount: 0, + existingNativeMessageCount: 2, + }), + true, + ); + assert.equal( + nativeTranscriptWouldWipeExistingHistory({ + nativeMessageCount: 0, + existingNativeMessageCount: 0, + }), + false, + ); + assert.equal( + nativeTranscriptWouldWipeExistingHistory({ + nativeMessageCount: 1, + existingNativeMessageCount: 2, + }), + false, + ); + }); + + it("restores native presence after an interrupted import", () => { + const importing = importingTeleportState({ + base: BASE_TELEPORT, + restorePresence: "native", + }); + assert.equal(importing.presence, "importing"); + assert.equal(restorePresenceForImport(importing), "native"); + assert.deepEqual(restoredTeleportStateAfterInterruptedImport(importing), { + ...BASE_TELEPORT, + presence: "native", + }); + assert.equal( + restoredTeleportStateAfterInterruptedImport(committedTeleportImportState(importing)), + null, + ); + }); + + it.effect("keeps earlier sessions when a later session in the batch fails", () => + Effect.gen(function* () { + const retained: string[] = []; + const error = yield* importSessionBatch(["one", "two"], (session) => { + if (session === "two") { + return Effect.fail(importStepError("session-two")); + } + return Effect.sync(() => { + retained.push(session); + return session; + }); + }).pipe(Effect.flip); + assert.equal(error.step, "session-two"); + assert.deepEqual(retained, ["one"]); + }), + ); + + it.effect("reverts the importing fence when binding persistence fails before T3 commit", () => + Effect.gen(function* () { + const steps = yield* Ref.make([]); + const record = (step: string) => Ref.update(steps, (current) => [...current, step]); + const error = yield* runInPlaceTeleportImport({ + beginImporting: record("beginImporting"), + stopSession: record("stopSession"), + persistDirectory: record("persistDirectory").pipe( + Effect.flatMap(() => Effect.fail(importStepError("persistDirectory"))), + ), + commitOrchestration: record("commitOrchestration"), + finalizeDirectory: record("finalizeDirectory"), + updateTitle: record("updateTitle"), + revertImporting: record("revertImporting"), + }).pipe(Effect.flip); + assert.equal(error.step, "persistDirectory"); + assert.deepEqual(yield* Ref.get(steps), [ + "beginImporting", + "stopSession", + "persistDirectory", + "revertImporting", + ]); + }), + ); + + it.effect("reverts the importing fence when the T3 import commit fails", () => + Effect.gen(function* () { + const steps = yield* Ref.make([]); + const record = (step: string) => Ref.update(steps, (current) => [...current, step]); + const error = yield* runInPlaceTeleportImport({ + beginImporting: record("beginImporting"), + stopSession: record("stopSession"), + persistDirectory: record("persistDirectory"), + commitOrchestration: record("commitOrchestration").pipe( + Effect.flatMap(() => Effect.fail(importStepError("commitOrchestration"))), + ), + finalizeDirectory: record("finalizeDirectory"), + updateTitle: record("updateTitle"), + revertImporting: record("revertImporting"), + }).pipe(Effect.flip); + assert.equal(error.step, "commitOrchestration"); + assert.deepEqual(yield* Ref.get(steps), [ + "beginImporting", + "stopSession", + "persistDirectory", + "commitOrchestration", + "revertImporting", + ]); + }), + ); + + it.effect("reverts the importing fence when a pre-commit step dies", () => + Effect.gen(function* () { + const steps = yield* Ref.make([]); + const record = (step: string) => Ref.update(steps, (current) => [...current, step]); + const defect = new Error("commitOrchestration"); + const exit = yield* runInPlaceTeleportImport({ + beginImporting: record("beginImporting"), + stopSession: record("stopSession"), + persistDirectory: record("persistDirectory"), + commitOrchestration: record("commitOrchestration").pipe( + Effect.flatMap(() => Effect.die(defect)), + ), + finalizeDirectory: record("finalizeDirectory"), + updateTitle: record("updateTitle"), + revertImporting: record("revertImporting"), + }).pipe(Effect.exit); + assert.equal(Exit.isFailure(exit), true); + if (Exit.isFailure(exit)) { + assert.equal(Cause.hasDies(exit.cause), true); + assert.equal(Cause.squash(exit.cause), defect); + } + assert.deepEqual(yield* Ref.get(steps), [ + "beginImporting", + "stopSession", + "persistDirectory", + "commitOrchestration", + "revertImporting", + ]); + }), + ); + + it.effect("reverts the importing fence when a pre-commit step is interrupted", () => + Effect.gen(function* () { + const steps = yield* Ref.make([]); + const record = (step: string) => Ref.update(steps, (current) => [...current, step]); + const exit = yield* runInPlaceTeleportImport({ + beginImporting: record("beginImporting"), + stopSession: record("stopSession"), + persistDirectory: record("persistDirectory").pipe(Effect.flatMap(() => Effect.interrupt)), + commitOrchestration: record("commitOrchestration"), + finalizeDirectory: record("finalizeDirectory"), + updateTitle: record("updateTitle"), + revertImporting: record("revertImporting"), + }).pipe(Effect.exit); + assert.equal(Exit.isFailure(exit), true); + if (Exit.isFailure(exit)) { + assert.equal(Cause.hasInterrupts(exit.cause), true); + } + assert.deepEqual(yield* Ref.get(steps), [ + "beginImporting", + "stopSession", + "persistDirectory", + "revertImporting", + ]); + }), + ); + + it.effect("reverts the importing fence when the import fiber is interrupted", () => + Effect.gen(function* () { + const steps = yield* Ref.make([]); + const started = yield* Deferred.make(); + const record = (step: string) => Ref.update(steps, (current) => [...current, step]); + const fiber = yield* runInPlaceTeleportImport({ + beginImporting: record("beginImporting"), + stopSession: record("stopSession").pipe( + Effect.flatMap(() => Deferred.succeed(started, undefined)), + Effect.flatMap(() => Effect.never), + ), + persistDirectory: record("persistDirectory"), + commitOrchestration: record("commitOrchestration"), + finalizeDirectory: record("finalizeDirectory"), + updateTitle: record("updateTitle"), + revertImporting: record("revertImporting"), + }).pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(fiber); + assert.deepEqual(yield* Ref.get(steps), ["beginImporting", "stopSession", "revertImporting"]); + }), + ); + + it.effect("succeeds when title update fails after the T3 commit", () => + Effect.gen(function* () { + const steps = yield* Ref.make([]); + const record = (step: string) => Ref.update(steps, (current) => [...current, step]); + yield* runInPlaceTeleportImport({ + beginImporting: record("beginImporting"), + stopSession: record("stopSession"), + persistDirectory: record("persistDirectory"), + commitOrchestration: record("commitOrchestration"), + finalizeDirectory: record("finalizeDirectory"), + updateTitle: record("updateTitle").pipe( + Effect.flatMap(() => Effect.fail(importStepError("updateTitle"))), + ), + revertImporting: record("revertImporting"), + }); + assert.deepEqual(yield* Ref.get(steps), [ + "beginImporting", + "stopSession", + "persistDirectory", + "commitOrchestration", + "finalizeDirectory", + "updateTitle", + ]); + }), + ); + + it.effect("retries after a directory failure then commits", () => + Effect.gen(function* () { + const attempts = yield* Ref.make(0); + const first = yield* runInPlaceTeleportImport({ + beginImporting: Effect.void, + stopSession: Effect.void, + persistDirectory: Effect.fail(importStepError("persistDirectory")), + commitOrchestration: Effect.void, + finalizeDirectory: Effect.void, + updateTitle: Effect.void, + revertImporting: Effect.void, + }).pipe(Effect.flip); + assert.equal(first.step, "persistDirectory"); + yield* runInPlaceTeleportImport({ + beginImporting: Effect.void, + stopSession: Effect.void, + persistDirectory: Ref.update(attempts, (count) => count + 1), + commitOrchestration: Effect.void, + finalizeDirectory: Effect.void, + updateTitle: Effect.void, + revertImporting: Effect.void, + }); + assert.equal(yield* Ref.get(attempts), 1); + }), + ); + + it.effect("recovers leftover importing presence without stranding the thread", () => + Effect.gen(function* () { + const restored = yield* Ref.make(null); + yield* recoverInterruptedImportTeleports({ + threads: [ + { + id: ThreadId.make("thread-1"), + teleport: importingTeleportState({ + base: BASE_TELEPORT, + restorePresence: "native", + }), + }, + { + id: ThreadId.make("thread-2"), + teleport: { + ...BASE_TELEPORT, + presence: "t3", + }, + }, + ], + nextCommandId: Effect.succeed(CommandId.make("cmd-recover")), + setTeleport: (threadId, teleport) => Ref.set(restored, `${threadId}:${teleport.presence}`), + }); + assert.equal(yield* Ref.get(restored), "thread-1:native"); + }), + ); +}); diff --git a/apps/server/src/teleport/importTransaction.ts b/apps/server/src/teleport/importTransaction.ts new file mode 100644 index 000000000000..60f6c99db4b4 --- /dev/null +++ b/apps/server/src/teleport/importTransaction.ts @@ -0,0 +1,190 @@ +import { + CommandId, + TELEPORT_IMPORT_BATCH_SEMANTICS, + ThreadId, + type TeleportRestorePresence, + type TeleportThreadState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +export { TELEPORT_IMPORT_BATCH_SEMANTICS }; + +export function nativeTranscriptWouldWipeExistingHistory(input: { + readonly nativeMessageCount: number; + readonly existingNativeMessageCount: number; +}): boolean { + return input.nativeMessageCount === 0 && input.existingNativeMessageCount > 0; +} + +export function importingTeleportState(input: { + readonly base: Omit; + readonly restorePresence?: TeleportRestorePresence; +}): TeleportThreadState { + return { + ...input.base, + presence: "importing", + ...(input.restorePresence === undefined ? {} : { restorePresence: input.restorePresence }), + }; +} + +export function teleportStateWithPresence( + input: TeleportThreadState, + presence: TeleportRestorePresence, +): TeleportThreadState { + return { + presence, + provider: input.provider, + ...(input.providerInstanceId === undefined + ? {} + : { providerInstanceId: input.providerInstanceId }), + externalSessionId: input.externalSessionId, + nativePath: input.nativePath, + lastSyncedAt: input.lastSyncedAt, + }; +} + +export function committedTeleportImportState(input: TeleportThreadState): TeleportThreadState { + return teleportStateWithPresence(input, "t3"); +} + +export function restorePresenceForImport( + teleport: TeleportThreadState | null | undefined, +): TeleportRestorePresence { + if (teleport?.presence === "importing") { + return teleport.restorePresence ?? "t3"; + } + return teleport?.presence === "native" ? "native" : "t3"; +} + +export function restoredTeleportStateAfterInterruptedImport( + teleport: TeleportThreadState | null | undefined, +): TeleportThreadState | null { + if (teleport == null || teleport.presence !== "importing") { + return null; + } + return teleportStateWithPresence(teleport, teleport.restorePresence ?? "t3"); +} + +export type InPlaceTeleportImportPorts = { + readonly beginImporting: Effect.Effect; + readonly stopSession: Effect.Effect; + readonly persistDirectory: Effect.Effect; + readonly commitOrchestration: Effect.Effect; + readonly finalizeDirectory: Effect.Effect; + readonly updateTitle: Effect.Effect; + readonly revertImporting: Effect.Effect; +}; + +const revertOnError = ( + effect: Effect.Effect, + revert: Effect.Effect, +): Effect.Effect => + // `onError` observes typed failures, defects, and interruption, and the + // revert itself is uninterruptible so a cancelled RPC cannot leave + // `presence: "importing"` stranded until process restart. + effect.pipe(Effect.onError(() => revert)); + +/** + * In-place import durability protocol: + * 1. orchestration fence (`importing`) so `thread.turn.start` cannot admit work + * 2. stop leftover provider runtime + * 3. provider-directory claim (separate durability boundary) + * 4. atomic T3 commit (unarchive + t3 presence + history) + * 5. directory presence finalize (best-effort) + * 6. title (best-effort) + * + * Typed failures, defects, and interruptions before the T3 commit revert the + * importing fence. Title and the post-commit directory finalize cannot + * invalidate a successful T3 commit. + */ +export const runInPlaceTeleportImport = ( + ports: InPlaceTeleportImportPorts, +): Effect.Effect => + Effect.gen(function* () { + yield* ports.beginImporting; + yield* revertOnError( + ports.stopSession.pipe( + Effect.flatMap(() => ports.persistDirectory), + Effect.flatMap(() => ports.commitOrchestration), + ), + ports.revertImporting, + ); + yield* ports.finalizeDirectory.pipe( + Effect.catchCause((cause) => + Effect.logWarning("teleport.import.directory-finalize-skipped").pipe( + Effect.annotateLogs({ cause: String(cause) }), + ), + ), + ); + yield* ports.updateTitle.pipe( + Effect.catchCause((cause) => + Effect.logWarning("teleport.import.title-update-skipped").pipe( + Effect.annotateLogs({ cause: String(cause) }), + ), + ), + ); + }); + +export type NewThreadTeleportImportPorts = { + readonly beginImporting: Effect.Effect; + readonly persistDirectory: Effect.Effect; + readonly commitOrchestration: Effect.Effect; + readonly finalizeDirectory: Effect.Effect; +}; + +export const runNewThreadTeleportImport = ( + ports: NewThreadTeleportImportPorts, +): Effect.Effect => + Effect.gen(function* () { + yield* ports.beginImporting; + yield* ports.persistDirectory; + yield* ports.commitOrchestration; + yield* ports.finalizeDirectory; + }); + +/** + * Sequential per-session import. A later failure does not undo earlier + * successes; the caller still sees the failure. + */ +export const importSessionBatch = ( + sessions: ReadonlyArray, + importOne: (session: A) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const imported: A[] = []; + for (const session of sessions) { + imported.push(yield* importOne(session)); + } + return imported; + }); + +export const recoverInterruptedImportTeleports = (input: { + readonly threads: ReadonlyArray<{ + readonly id: ThreadId; + readonly teleport?: TeleportThreadState | null; + }>; + readonly nextCommandId: Effect.Effect; + readonly setTeleport: ( + threadId: ThreadId, + teleport: TeleportThreadState, + commandId: CommandId, + ) => Effect.Effect; +}): Effect.Effect => + Effect.gen(function* () { + for (const thread of input.threads) { + const restored = restoredTeleportStateAfterInterruptedImport(thread.teleport); + if (restored === null) { + continue; + } + const commandId = yield* input.nextCommandId; + yield* input + .setTeleport(thread.id, restored, commandId) + .pipe( + Effect.catchCause(() => + Effect.logWarning("teleport.import.recovery-skipped").pipe( + Effect.annotateLogs({ threadId: thread.id }), + ), + ), + ); + } + }); diff --git a/apps/server/src/teleport/json.test.ts b/apps/server/src/teleport/json.test.ts new file mode 100644 index 000000000000..ce3eba24669c --- /dev/null +++ b/apps/server/src/teleport/json.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { isSafeTeleportSessionId, isSyntheticNativeUserText, nativeSessionText } from "./json.ts"; +import { isOversizeTeleportSession, MAX_TELEPORT_SESSION_BYTES } from "./types.ts"; + +describe("teleport json helpers", () => { + it("keeps leading and trailing whitespace on native session text", () => { + expect(nativeSessionText(" keep this \n")).toBe(" keep this \n"); + expect(nativeSessionText(" ")).toBeUndefined(); + expect(nativeSessionText(1)).toBeUndefined(); + }); + + it("rejects session ids that can traverse out of the native root", () => { + expect(isSafeTeleportSessionId("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee")).toBe(true); + expect(isSafeTeleportSessionId("ses_sample")).toBe(true); + expect(isSafeTeleportSessionId("../.codex/sessions")).toBe(false); + expect(isSafeTeleportSessionId("..\\..\\.codex")).toBe(false); + expect(isSafeTeleportSessionId("..")).toBe(false); + expect(isSafeTeleportSessionId(".")).toBe(false); + }); + + it("treats Claude slash-command records as synthetic user text", () => { + expect(isSyntheticNativeUserText("\nCaveat:\n")).toBe(true); + expect(isSyntheticNativeUserText("/init")).toBe(true); + expect(isSyntheticNativeUserText("stay on task")).toBe(true); + expect(isSyntheticNativeUserText("ok")).toBe(true); + expect(isSyntheticNativeUserText("Use a light theme")).toBe(false); + }); + + it("compares native session sizes as bigint", () => { + expect(isOversizeTeleportSession(BigInt(MAX_TELEPORT_SESSION_BYTES) + 1n)).toBe(true); + expect(isOversizeTeleportSession(MAX_TELEPORT_SESSION_BYTES)).toBe(false); + expect(isOversizeTeleportSession(1)).toBe(false); + }); +}); diff --git a/apps/server/src/teleport/json.ts b/apps/server/src/teleport/json.ts new file mode 100644 index 000000000000..2e51bcb2ec36 --- /dev/null +++ b/apps/server/src/teleport/json.ts @@ -0,0 +1,121 @@ +export function definedField( + key: K, + value: V | undefined, +): Record | { readonly [P in K]: V } { + if (value === undefined) { + return {}; + } + return { [key]: value } as { readonly [P in K]: V }; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +/** + * Message text from a native session. Empty-after-trim is skipped, but + * leading/trailing whitespace on real content is preserved so export can + * round-trip the original transcript. + */ +export function nativeSessionText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + return value.trim().length > 0 ? value : undefined; +} + +export function isSafeTeleportSessionId(value: string): boolean { + if (value.length === 0 || value.length > 200) { + return false; + } + if (value.includes("\0") || value.includes("/") || value.includes("\\")) { + return false; + } + return value !== "." && value !== ".."; +} + +export function parseJsonObject(raw: string): Record | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return undefined; + } + return isRecord(parsed) ? parsed : undefined; +} + +export function collectTextParts(content: unknown): string | undefined { + if (typeof content === "string") { + return nonEmptyString(content); + } + if (!Array.isArray(content)) { + return undefined; + } + const parts: string[] = []; + for (const part of content) { + if (!isRecord(part)) { + continue; + } + const text = nonEmptyString(part.text); + if (text) { + parts.push(text); + } + } + return nonEmptyString(parts.join("\n")); +} + +export function truncateTitle(input: string): string { + const normalized = input.replace(/\s+/gu, " ").trim(); + if (normalized.length <= 80) { + return normalized; + } + return `${normalized.slice(0, 77).trimEnd()}...`; +} + +export function isSyntheticNativeUserText(text: string): boolean { + const trimmed = text.trim(); + return ( + trimmed.startsWith("") || + trimmed.startsWith("") || + trimmed.startsWith("") || + trimmed.startsWith("") || + trimmed.startsWith("") || + trimmed.startsWith("") || + trimmed.startsWith("") + ); +} + +export function firstUserTitle( + messages: ReadonlyArray<{ role: string; text: string }>, +): string | undefined { + for (const message of messages) { + if (message.role !== "user") { + continue; + } + if (isSyntheticNativeUserText(message.text)) { + continue; + } + const line = message.text + .split("\n") + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + if (line) { + return truncateTitle(line); + } + } + return undefined; +} + +export const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/iu; + +export function uuidFromPath(filePath: string): string | undefined { + return filePath.match(UUID_RE)?.[0]?.toLowerCase(); +} diff --git a/apps/server/src/teleport/nativeWrite.ts b/apps/server/src/teleport/nativeWrite.ts new file mode 100644 index 000000000000..560f467d924a --- /dev/null +++ b/apps/server/src/teleport/nativeWrite.ts @@ -0,0 +1,193 @@ +import { + TeleportFileLockedError, + TeleportLockProbeError, + TeleportNativeWriteError, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; + +import type * as ProcessRunner from "../processRunner.ts"; +import { requireNativePathUnlocked } from "./fileLock.ts"; + +function platformErrorTag(cause: unknown): string | undefined { + if (typeof cause !== "object" || cause === null) { + return undefined; + } + if ("reason" in cause) { + const reason = (cause as { reason?: { _tag?: unknown } }).reason; + if (reason && typeof reason._tag === "string") { + return reason._tag; + } + } + if ("_tag" in cause && typeof (cause as { _tag?: unknown })._tag === "string") { + return (cause as { _tag: string })._tag; + } + return undefined; +} + +function nodeErrorCode(cause: unknown): string | undefined { + if (typeof cause !== "object" || cause === null) { + return undefined; + } + if ("code" in cause && typeof (cause as { code?: unknown }).code === "string") { + return (cause as { code: string }).code; + } + if ("reason" in cause) { + const nested = (cause as { reason?: { cause?: unknown } }).reason?.cause; + return nodeErrorCode(nested); + } + return undefined; +} + +export function isNativeFileBusy(cause: unknown): boolean { + const tag = platformErrorTag(cause); + if (tag === "Busy") { + return true; + } + const code = nodeErrorCode(cause); + return code === "EBUSY"; +} + +function isReplaceConflict(cause: unknown): boolean { + const tag = platformErrorTag(cause); + if (tag === "AlreadyExists" || tag === "PermissionDenied") { + return true; + } + const code = nodeErrorCode(cause); + return code === "EEXIST" || code === "EPERM" || code === "EACCES"; +} + +/** + * Rename `from` onto `to`. Unix rename replaces an existing file; Windows + * throws if the destination exists, so we move the destination aside, then + * rename. If the replacement rename fails, the original is restored. A locked + * destination becomes `TeleportFileLockedError`. + */ +export const replaceNativeFile = Effect.fn("replaceNativeFile")(function* (input: { + readonly from: string; + readonly to: string; +}) { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const locked = (cause: unknown) => + new TeleportFileLockedError({ + nativePath: input.to, + cause, + }); + const failed = (cause: unknown) => + new TeleportNativeWriteError({ + nativePath: input.to, + stage: "replace", + cause, + }); + + const asReplaceError = (cause: unknown): TeleportFileLockedError | TeleportNativeWriteError => + isNativeFileBusy(cause) ? locked(cause) : failed(cause); + + const rename = fs.rename(input.from, input.to); + return yield* rename.pipe( + Effect.catch( + ( + cause: PlatformError.PlatformError, + ): Effect.Effect => { + if (platform === "win32" && isReplaceConflict(cause) && !isNativeFileBusy(cause)) { + const backupPath = `${input.to}.teleport-bak`; + return fs.rename(input.to, backupPath).pipe( + Effect.mapError(asReplaceError), + Effect.andThen( + rename.pipe( + Effect.tapError(() => + fs.rename(backupPath, input.to).pipe(Effect.catch(() => Effect.void)), + ), + Effect.mapError(asReplaceError), + Effect.andThen(fs.remove(backupPath).pipe(Effect.catch(() => Effect.void))), + ), + ), + ); + } + return Effect.fail(asReplaceError(cause)); + }, + ), + ); +}); + +export const writeNativeSessionAtomically = Effect.fn("writeNativeSessionAtomically")( + function* (input: { + readonly filePath: string; + readonly contents: string; + readonly verify: (contents: string) => Effect.Effect; + }): Effect.fn.Return< + void, + TeleportNativeWriteError | TeleportFileLockedError | TeleportLockProbeError, + FileSystem.FileSystem | Path.Path | ProcessRunner.ProcessRunner + > { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetDirectory = path.dirname(input.filePath); + + yield* requireNativePathUnlocked(input.filePath); + + const exists = yield* fs.exists(input.filePath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + yield* requireNativePathUnlocked(input.filePath); + } + + yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + stage: "create-directory", + cause, + }), + ), + ); + + yield* Effect.scoped( + Effect.gen(function* () { + const tempDirectory = yield* fs + .makeTempDirectoryScoped({ + directory: targetDirectory, + prefix: `${path.basename(input.filePath)}.`, + }) + .pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + stage: "create-temp", + cause, + }), + ), + ); + const tempPath = path.join(tempDirectory, "contents.tmp"); + yield* fs.writeFileString(tempPath, input.contents).pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + stage: "write-temp", + cause, + }), + ), + ); + const written = yield* fs.readFileString(tempPath).pipe( + Effect.mapError( + (cause) => + new TeleportNativeWriteError({ + nativePath: input.filePath, + stage: "read-temp", + cause, + }), + ), + ); + yield* input.verify(written); + yield* requireNativePathUnlocked(input.filePath); + yield* replaceNativeFile({ from: tempPath, to: input.filePath }); + }), + ); + }, +); diff --git a/apps/server/src/teleport/resumeCursors.test.ts b/apps/server/src/teleport/resumeCursors.test.ts new file mode 100644 index 000000000000..265eeffeb771 --- /dev/null +++ b/apps/server/src/teleport/resumeCursors.test.ts @@ -0,0 +1,38 @@ +import { assert, describe, it } from "@effect/vitest"; +import { ProviderDriverKind } from "@t3tools/contracts"; + +import { buildTeleportResumeCursor, readTeleportExternalSessionId } from "./resumeCursors.ts"; +import { TELEPORT_TEST_SESSION_ID } from "./testFixtures.ts"; + +describe("teleport resume cursors", () => { + it("prefers the teleport payload over a resume cursor", () => { + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("grok"), + resumeCursor: { schemaVersion: 1, sessionId: "other" }, + runtimePayload: { + teleport: { externalSessionId: TELEPORT_TEST_SESSION_ID }, + }, + }), + TELEPORT_TEST_SESSION_ID, + ); + }); + + it("falls back to a generic session id when no format adapter is supplied", () => { + assert.deepStrictEqual( + buildTeleportResumeCursor({ + provider: "codex", + externalSessionId: TELEPORT_TEST_SESSION_ID, + }), + { sessionId: TELEPORT_TEST_SESSION_ID }, + ); + assert.equal( + readTeleportExternalSessionId({ + provider: ProviderDriverKind.make("codex"), + resumeCursor: { threadId: TELEPORT_TEST_SESSION_ID }, + runtimePayload: null, + }), + undefined, + ); + }); +}); diff --git a/apps/server/src/teleport/resumeCursors.ts b/apps/server/src/teleport/resumeCursors.ts new file mode 100644 index 000000000000..689eba9b4963 --- /dev/null +++ b/apps/server/src/teleport/resumeCursors.ts @@ -0,0 +1,91 @@ +import { + isTeleportProvider, + ProviderDriverKind, + resolveTeleportPresence, + TeleportRuntimePayload, + TeleportUnsupportedProviderError, + type TeleportProvider, + type TeleportThreadState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { TeleportFormatAdapter } from "./formats/adapter.ts"; +import { isRecord, nonEmptyString } from "./json.ts"; + +const decodeTeleportRuntimePayload = Schema.decodeUnknownOption(TeleportRuntimePayload); + +export function toTeleportProvider( + provider: ProviderDriverKind, +): Effect.Effect { + if (isTeleportProvider(provider)) { + return Effect.succeed(provider); + } + return Effect.fail( + new TeleportUnsupportedProviderError({ + provider, + }), + ); +} + +export function buildTeleportResumeCursor(input: { + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly adapter?: TeleportFormatAdapter | undefined; +}): unknown { + return ( + input.adapter?.resumeCursor(input.externalSessionId) ?? { + sessionId: input.externalSessionId, + } + ); +} + +export function readTeleportRuntimePayload( + runtimePayload: unknown, +): TeleportRuntimePayload | undefined { + if (!isRecord(runtimePayload)) { + return undefined; + } + return Option.getOrUndefined(decodeTeleportRuntimePayload(runtimePayload.teleport)); +} + +export function teleportThreadStateFromPayload(input: { + readonly provider: TeleportProvider; + readonly providerInstanceId?: TeleportThreadState["providerInstanceId"]; + readonly payload: TeleportRuntimePayload; +}): TeleportThreadState { + return { + presence: resolveTeleportPresence(input.payload), + provider: input.provider, + ...(input.providerInstanceId === undefined + ? {} + : { providerInstanceId: input.providerInstanceId }), + externalSessionId: input.payload.externalSessionId, + nativePath: input.payload.nativePath, + lastSyncedAt: input.payload.lastSyncedAt, + }; +} + +export function readTeleportExternalSessionId(input: { + readonly provider: ProviderDriverKind; + readonly resumeCursor: unknown; + readonly runtimePayload: unknown; + readonly adapter?: TeleportFormatAdapter | undefined; +}): string | undefined { + if (isRecord(input.runtimePayload)) { + const teleport = input.runtimePayload.teleport; + if (isRecord(teleport)) { + const externalSessionId = nonEmptyString(teleport.externalSessionId); + if (externalSessionId) { + return externalSessionId; + } + } + } + + if (!isRecord(input.resumeCursor)) { + return undefined; + } + + return input.adapter?.readExternalSessionId(input.resumeCursor); +} diff --git a/apps/server/src/teleport/sessionFile.ts b/apps/server/src/teleport/sessionFile.ts new file mode 100644 index 000000000000..ec207f45e5cf --- /dev/null +++ b/apps/server/src/teleport/sessionFile.ts @@ -0,0 +1,32 @@ +import { TeleportSchemaVersionError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; + +import { isOversizeTeleportSession, type ParsedNativeSession } from "./types.ts"; + +export const readNativeSessionFile = Effect.fn("readNativeSessionFile")(function* (input: { + readonly nativePath: string; + readonly parse: (args: { + readonly contents: string; + readonly nativePath: string; + }) => Effect.Effect, TeleportSchemaVersionError>; +}): Effect.fn.Return< + Option.Option, + TeleportSchemaVersionError, + FileSystem.FileSystem +> { + const fs = yield* FileSystem.FileSystem; + const stat = yield* fs.stat(input.nativePath).pipe(Effect.orElseSucceed(() => null)); + if (stat === null || stat.type !== "File") { + return Option.none(); + } + if (isOversizeTeleportSession(stat.size)) { + return Option.none(); + } + const contents = yield* fs.readFileString(input.nativePath).pipe(Effect.orElseSucceed(() => "")); + if (contents.length === 0) { + return Option.none(); + } + return yield* input.parse({ contents, nativePath: input.nativePath }); +}); diff --git a/apps/server/src/teleport/testFixtures.ts b/apps/server/src/teleport/testFixtures.ts new file mode 100644 index 000000000000..6b85290b09fc --- /dev/null +++ b/apps/server/src/teleport/testFixtures.ts @@ -0,0 +1,36 @@ +import { TELEPORT_NATIVE_FORMAT_VERSION } from "@t3tools/contracts"; + +import type { ParsedNativeSession } from "./types.ts"; + +export const TELEPORT_TEST_SESSION_ID = "11111111-1111-4111-8111-111111111111"; +export const TELEPORT_TEST_CREATED_AT = "2026-08-14T06:00:00.000Z"; + +export function sampleTeleportSession( + provider: ParsedNativeSession["provider"], + cwd = "/workspace", +): ParsedNativeSession { + return { + provider, + externalSessionId: TELEPORT_TEST_SESSION_ID, + cwd, + nativePath: `/tmp/${provider}.session`, + nativeFormatVersion: TELEPORT_NATIVE_FORMAT_VERSION, + title: "Fix the flaky matcher", + createdAt: TELEPORT_TEST_CREATED_AT, + updatedAt: "2026-08-14T06:01:00.000Z", + messages: [ + { + role: "user", + text: "Fix the flaky matcher", + createdAt: TELEPORT_TEST_CREATED_AT, + id: "user-1", + }, + { + role: "assistant", + text: "I'll tighten the path comparison and add a realpath fallback.", + createdAt: "2026-08-14T06:01:00.000Z", + id: "assistant-1", + }, + ], + }; +} diff --git a/apps/server/src/teleport/types.ts b/apps/server/src/teleport/types.ts new file mode 100644 index 000000000000..8944126ab089 --- /dev/null +++ b/apps/server/src/teleport/types.ts @@ -0,0 +1,80 @@ +import type { TeleportProvider, TeleportSessionCandidate } from "@t3tools/contracts"; + +import { definedField } from "./json.ts"; + +export interface NativeTextMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt?: string; + readonly id?: string; +} + +export interface ParsedNativeSession { + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly cwd: string; + readonly nativePath: string; + readonly nativeFormatVersion: number; + readonly title?: string; + readonly createdAt?: string; + readonly updatedAt?: string; + readonly messages: ReadonlyArray; + readonly providerInstanceId?: TeleportSessionCandidate["providerInstanceId"]; +} + +export function nativeTextMessage(input: { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string | undefined; + readonly id: string | undefined; +}): NativeTextMessage { + return { + role: input.role, + text: input.text, + ...definedField("createdAt", input.createdAt), + ...definedField("id", input.id), + }; +} + +export function parsedNativeSession(input: { + readonly provider: TeleportProvider; + readonly externalSessionId: string; + readonly cwd: string; + readonly nativePath: string; + readonly nativeFormatVersion: number; + readonly title: string | undefined; + readonly createdAt: string | undefined; + readonly updatedAt: string | undefined; + readonly messages: ReadonlyArray; +}): ParsedNativeSession { + return { + provider: input.provider, + externalSessionId: input.externalSessionId, + cwd: input.cwd, + nativePath: input.nativePath, + nativeFormatVersion: input.nativeFormatVersion, + ...definedField("title", input.title), + ...definedField("createdAt", input.createdAt), + ...definedField("updatedAt", input.updatedAt), + messages: input.messages, + }; +} + +export function teleportCandidateFields( + session: ParsedNativeSession, +): Pick { + return { + ...definedField("title", session.title), + ...definedField("createdAt", session.createdAt), + ...definedField("updatedAt", session.updatedAt), + }; +} + +export const MAX_TELEPORT_MESSAGES = 2_000; +export const MAX_TELEPORT_MESSAGE_CHARS = 100_000; +export const MAX_TELEPORT_SESSION_BYTES = 20 * 1024 * 1024; + +export function isOversizeTeleportSession(size: number | bigint): boolean { + const bytes = typeof size === "bigint" ? size : BigInt(size); + return bytes > BigInt(MAX_TELEPORT_SESSION_BYTES); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ebcf65e4b47c..07ae0625eccb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -124,6 +124,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; +import * as TeleportService from "./teleport/TeleportService.ts"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -280,7 +281,9 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract | "thread.activity-appended" | "thread.turn-diff-completed" | "thread.reverted" - | "thread.session-set"; + | "thread.session-set" + | "thread.history-replaced" + | "thread.teleported"; } > { return ( @@ -289,7 +292,9 @@ export function isThreadDetailEvent(event: OrchestrationEvent): event is Extract event.type === "thread.activity-appended" || event.type === "thread.turn-diff-completed" || event.type === "thread.reverted" || - event.type === "thread.session-set" + event.type === "thread.session-set" || + event.type === "thread.history-replaced" || + event.type === "thread.teleported" ); } @@ -419,6 +424,7 @@ const makeWsRpcLayer = ( const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; const relayClient = yield* RelayClient.RelayClient; + const teleport = yield* TeleportService.TeleportService; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -2294,6 +2300,18 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + [WS_METHODS.teleportListSessions]: (input) => + observeRpcEffect(WS_METHODS.teleportListSessions, teleport.listSessions(input), { + "rpc.aggregate": "teleport", + }), + [WS_METHODS.teleportImportSessions]: (input) => + observeRpcEffect(WS_METHODS.teleportImportSessions, teleport.importSessions(input), { + "rpc.aggregate": "teleport", + }), + [WS_METHODS.teleportExportSession]: (input) => + observeRpcEffect(WS_METHODS.teleportExportSession, teleport.exportSession(input), { + "rpc.aggregate": "teleport", + }), }); }), ); diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 2a953132992c..ba8ff2cfeaf4 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -1,9 +1,13 @@ +import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; + // Tiny event bus allowing components to programmatically open the command palette // without owning its React state. const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette"; export interface CommandPaletteOpenDetail { - readonly open?: "add-project" | "new-thread-in"; + readonly open?: "add-project" | "new-thread-in" | "import-sessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; } export function openCommandPalette(detail?: CommandPaletteOpenDetail): void { diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 5c026c94a138..da66908649fa 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -168,6 +168,46 @@ describe("buildLoadingThreadFromShell", () => { checkpoints: [], }); }); + + it("preserves shell teleport presence while detail is still loading", () => { + const teleport = { + presence: "native" as const, + provider: "codex" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: now, + }; + const shell = { + environmentId, + id: threadId, + projectId, + title: "Loading thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: "main", + worktreePath: null, + latestTurn: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + session: null, + latestUserMessageAt: now, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + teleport, + } satisfies ThreadShell; + + expect(buildLoadingThreadFromShell(shell).teleport).toEqual(teleport); + }); }); describe("resolveThreadMetadataUpdateForNextTurn", () => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 336f7ed828c1..5efe2638716c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -174,6 +174,7 @@ import { WifiOffIcon, } from "lucide-react"; import { cn, randomHex } from "~/lib/utils"; +import { teleportSendDisabledReason } from "~/lib/teleport"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybindings"; import { type NewProjectScriptInput } from "./ProjectScriptsControl"; @@ -5025,6 +5026,20 @@ function ChatViewContent(props: ChatViewProps) { notifyDirectAnnotationAttached(); return; } + const teleportSendBlockReason = teleportSendDisabledReason(activeThread.teleport); + if (teleportSendBlockReason !== null) { + toastManager.add( + stackedThreadToast({ + type: "warning", + title: + activeThread.teleport?.presence === "importing" + ? "Thread is being imported" + : "Thread is in the native CLI", + description: teleportSendBlockReason, + }), + ); + return; + } if (activeEnvironmentUnavailable) { toastManager.add( stackedThreadToast({ @@ -6517,7 +6532,11 @@ function ChatViewContent(props: ChatViewProps) { phase={phase} isConnecting={isConnecting} isSendBusy={isSendBusy} - sendDisabledReason={threadDetailLoading ? "Messages loading" : null} + sendDisabledReason={ + threadDetailLoading + ? "Messages loading" + : teleportSendDisabledReason(activeThread?.teleport) + } isPreparingWorktree={isPreparingWorktree} externalDrawerAttached={externalComposerDrawerAttached} environmentUnavailable={activeEnvironmentUnavailableState} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index d521c119303d..6facd264abd7 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -9,6 +9,7 @@ import { filterPinnedBrowseEntries, filterCommandPaletteGroups, reduceCommandPaletteUiState, + selectTeleportImportProjects, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -91,6 +92,26 @@ describe("reduceCommandPaletteUiState", () => { mode: "command", openIntent: { kind: "new-thread-in" }, }); + expect(reduceCommandPaletteUiState(filesOpen, { _tag: "OpenImportSessions" })).toEqual({ + open: true, + mode: "command", + openIntent: { kind: "import-sessions" }, + }); + expect( + reduceCommandPaletteUiState(filesOpen, { + _tag: "OpenImportSessions", + environmentId: EnvironmentId.make("environment-local"), + projectId: ProjectId.make("project-1"), + }), + ).toEqual({ + open: true, + mode: "command", + openIntent: { + kind: "import-sessions", + environmentId: EnvironmentId.make("environment-local"), + projectId: ProjectId.make("project-1"), + }, + }); }); it("preserves the mode on close and resets it on open", () => { @@ -408,3 +429,32 @@ describe("filterPinnedBrowseEntries", () => { }); }); }); + +describe("selectTeleportImportProjects", () => { + const local = EnvironmentId.make("local"); + const remote = EnvironmentId.make("remote"); + + it("keeps every physical project whose environment supports teleport", () => { + expect( + selectTeleportImportProjects( + [ + { environmentId: local, id: "alpha" }, + { environmentId: remote, id: "alpha-remote" }, + ], + (environmentId) => environmentId === local || environmentId === remote, + ).map((project) => project.id), + ).toEqual(["alpha", "alpha-remote"]); + }); + + it("omits physical projects on environments without teleport", () => { + expect( + selectTeleportImportProjects( + [ + { environmentId: local, id: "alpha" }, + { environmentId: remote, id: "alpha-remote" }, + ], + (environmentId) => environmentId === local, + ).map((project) => project.id), + ).toEqual(["alpha"]); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 688a8a8ea791..6584205ef362 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,6 +1,8 @@ import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, + type ProjectId, THREAD_JUMP_KEYBINDING_COMMANDS, } from "@t3tools/contracts"; import { filterFilesystemBrowseEntries } from "@t3tools/client-runtime/state/filesystem"; @@ -37,9 +39,14 @@ export function browseInputEndPaddingClass(input: { */ export type SearchOverlayMode = "command" | "files" | "content"; -export interface CommandPaletteOpenIntent { - readonly kind: "add-project" | "new-thread-in"; -} +export type CommandPaletteOpenIntent = + | { readonly kind: "add-project" } + | { readonly kind: "new-thread-in" } + | { + readonly kind: "import-sessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + }; export interface CommandPaletteUiState { readonly open: boolean; @@ -52,6 +59,11 @@ export type CommandPaletteUiAction = | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode } | { readonly _tag: "OpenAddProject" } | { readonly _tag: "OpenNewThreadIn" } + | { + readonly _tag: "OpenImportSessions"; + readonly environmentId?: EnvironmentId; + readonly projectId?: ProjectId; + } | { readonly _tag: "ClearOpenIntent" }; export function reduceCommandPaletteUiState( @@ -71,8 +83,22 @@ export function reduceCommandPaletteUiState( return { open: true, mode: "command", openIntent: { kind: "add-project" } }; case "OpenNewThreadIn": return { open: true, mode: "command", openIntent: { kind: "new-thread-in" } }; + case "OpenImportSessions": + return { + open: true, + mode: "command", + openIntent: { + kind: "import-sessions", + ...(action.environmentId === undefined ? {} : { environmentId: action.environmentId }), + ...(action.projectId === undefined ? {} : { projectId: action.projectId }), + }, + }; case "ClearOpenIntent": return state.openIntent ? { ...state, openIntent: null } : state; + default: { + const _exhaustive: never = action; + return _exhaustive; + } } } @@ -456,3 +482,10 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str return "Enter path (e.g. ~/projects/my-app)"; } } + +export function selectTeleportImportProjects( + projects: ReadonlyArray, + environmentSupportsTeleport: (environmentId: EnvironmentId) => boolean, +): ReadonlyArray { + return projects.filter((project) => environmentSupportsTeleport(project.environmentId)); +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b96eac7b0d71..deeb7f53652a 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -25,9 +25,11 @@ import { type EnvironmentId, type FilesystemBrowseResult, type ProjectId, + type ProviderInstanceId, type SourceControlDiscoveryResult, type SourceControlProviderKind, type SourceControlRepositoryInfo, + type TeleportProvider, PRIMARY_LOCAL_ENVIRONMENT_ID, } from "@t3tools/contracts"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -38,7 +40,9 @@ import { FileSearchIcon, FolderIcon, FolderPlusIcon, + ImportIcon, LinkIcon, + LoaderIcon, MessageSquareIcon, PaletteIcon, ServerIcon, @@ -89,6 +93,7 @@ import { resolveProjectPathForDispatch, } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; +import { formatRelativeTimeLabel } from "../timestampFormat"; import { isPreviewFocused } from "../lib/previewFocus"; import { isTerminalFocused } from "../lib/terminalFocus"; import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; @@ -121,8 +126,78 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, reduceCommandPaletteUiState, + selectTeleportImportProjects, type SearchOverlayMode, } from "./CommandPalette.logic"; + +function nativeSessionsPaletteView( + projectTitle: string, + items: CommandPaletteActionItem[], +): CommandPaletteView { + return { + addonIcon: , + groups: [ + { + value: "native-sessions", + label: `Sessions in ${projectTitle}`, + items, + }, + ], + }; +} + +function importSessionsStatusItem(input: { + readonly value: string; + readonly title: string; + readonly description?: string; + readonly icon: ReactNode; + readonly run?: () => Promise; +}): CommandPaletteActionItem { + return { + kind: "action", + value: input.value, + searchTerms: [], + title: input.title, + ...(input.description === undefined ? {} : { description: input.description }), + icon: input.icon, + disabled: input.run === undefined, + keepOpen: input.run !== undefined, + run: input.run ?? (async () => undefined), + }; +} + +function importSessionsLoadingView(projectTitle: string): CommandPaletteView { + return nativeSessionsPaletteView(projectTitle, [ + importSessionsStatusItem({ + value: "import-sessions-loading", + title: "Looking for native sessions…", + icon: , + }), + ]); +} + +function importIntoProjectPaletteView( + items: ReadonlyArray, +): CommandPaletteView { + return { + addonIcon: , + groups: [ + { + value: "projects", + label: "Import into project", + items: enumerateCommandPaletteItems(items), + }, + ], + }; +} + +function toInstalledPaletteView(view: CommandPaletteView): CommandPaletteView { + return { + addonIcon: view.addonIcon, + groups: view.groups, + ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), + }; +} import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent } from "./CommandPaletteContent"; @@ -138,7 +213,14 @@ import { ThreadCommandSubtitle, } from "./ThreadCommandSubtitle"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; +import { + environmentSupportsTeleport, + isTeleportedOut, + teleportFailureMessage, + teleportProviderLabel, +} from "../lib/teleport"; import { primaryServerKeybindingsAtom, primaryServerProvidersAtom } from "../state/server"; +import { teleportEnvironment } from "../state/teleport"; import { deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, @@ -405,6 +487,15 @@ export function CommandPalette({ children }: { children: ReactNode }) { ); const openAddProject = useCallback(() => dispatch({ _tag: "OpenAddProject" }), []); const openNewThreadIn = useCallback(() => dispatch({ _tag: "OpenNewThreadIn" }), []); + const openImportSessions = useCallback( + (input?: { readonly environmentId?: EnvironmentId; readonly projectId?: ProjectId }) => + dispatch({ + _tag: "OpenImportSessions", + ...(input?.environmentId === undefined ? {} : { environmentId: input.environmentId }), + ...(input?.projectId === undefined ? {} : { projectId: input.projectId }), + }), + [], + ); const clearOpenIntent = useCallback(() => dispatch({ _tag: "ClearOpenIntent" }), []); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const { theme, themeHalves, resolvedTheme } = useTheme(); @@ -475,15 +566,33 @@ export function CommandPalette({ children }: { children: ReactNode }) { useEffect( () => onOpenCommandPalette((detail) => { - if (detail.open === "new-thread-in") { - openNewThreadIn(); - } else if (detail.open === "add-project") { - openAddProject(); - } else { + const open = detail.open; + if (open === undefined) { setOpen(true); + return; + } + switch (open) { + case "new-thread-in": + openNewThreadIn(); + return; + case "add-project": + openAddProject(); + return; + case "import-sessions": + openImportSessions({ + ...(detail.environmentId === undefined + ? {} + : { environmentId: detail.environmentId }), + ...(detail.projectId === undefined ? {} : { projectId: detail.projectId }), + }); + return; + default: { + const _exhaustive: never = open; + return _exhaustive; + } } }), - [openAddProject, openNewThreadIn, setOpen], + [openAddProject, openImportSessions, openNewThreadIn, setOpen], ); return ( @@ -565,7 +674,7 @@ function OpenCommandPaletteDialog(props: { readonly clearOpenIntent: () => void; }) { const navigate = useNavigate(); - const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props; + const { clearOpenIntent, openIntent, openOverlayMode, setOpen: setPaletteOpen } = props; const [query, setQuery] = useState(""); const deferredQuery = useDeferredValue(query); const isActionsOnly = deferredQuery.startsWith(">"); @@ -584,6 +693,12 @@ function OpenCommandPaletteDialog(props: { const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { reportFailure: false, }); + const listTeleportSessions = useAtomCommand(teleportEnvironment.listSessions, { + reportFailure: false, + }); + const importTeleportSessions = useAtomCommand(teleportEnvironment.importSessions, { + reportFailure: false, + }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -637,6 +752,17 @@ function OpenCommandPaletteDialog(props: { browseNavigationRef.current = createBrowseNavigationCoordinator(); } const browseNavigation = browseNavigationRef.current; + const teleportImportPendingRef = useRef(false); + const importListGenerationRef = useRef(0); + const setOpen = useCallback( + (open: boolean) => { + if (!open) { + importListGenerationRef.current += 1; + } + setPaletteOpen(open); + }, + [setPaletteOpen], + ); const [addProjectEnvironmentId, setAddProjectEnvironmentId] = useState( null, ); @@ -1156,14 +1282,31 @@ function OpenCommandPaletteDialog(props: { const pushPaletteView = useCallback( (view: CommandPaletteView): void => { browseNavigation.invalidate(); - setViewStack((previousViews) => [ - ...previousViews, - { - addonIcon: view.addonIcon, - groups: view.groups, - ...(view.initialQuery ? { initialQuery: view.initialQuery } : {}), - }, - ]); + setViewStack((previousViews) => [...previousViews, toInstalledPaletteView(view)]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); + + const replacePaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setViewStack([toInstalledPaletteView(view)]); + setHighlightedItemValue(null); + setQuery(view.initialQuery ?? ""); + }, + [browseNavigation], + ); + + const replaceTopPaletteView = useCallback( + (view: CommandPaletteView): void => { + browseNavigation.invalidate(); + setViewStack((previousViews) => { + const nextView = toInstalledPaletteView(view); + return previousViews.length === 0 ? [nextView] : [...previousViews.slice(0, -1), nextView]; + }); setHighlightedItemValue(null); setQuery(view.initialQuery ?? ""); }, @@ -1179,6 +1322,7 @@ function OpenCommandPaletteDialog(props: { } function popView(): void { + importListGenerationRef.current += 1; browseNavigation.invalidate(); setAddProjectCloneFlow(null); if (viewStack.length <= 1) { @@ -1198,6 +1342,228 @@ function OpenCommandPaletteDialog(props: { } } + const importNativeSession = useCallback( + async ( + project: Project, + session: { + readonly provider: TeleportProvider; + readonly providerInstanceId?: ProviderInstanceId; + readonly externalSessionId: string; + }, + ): Promise => { + if (teleportImportPendingRef.current) { + return; + } + teleportImportPendingRef.current = true; + try { + const result = await importTeleportSessions({ + environmentId: project.environmentId, + input: { + projectId: project.id, + cwd: project.workspaceRoot, + sessions: [ + { + provider: session.provider, + ...(session.providerInstanceId === undefined + ? {} + : { providerInstanceId: session.providerInstanceId }), + externalSessionId: session.externalSessionId, + }, + ], + }, + }); + if (result._tag === "Success") { + const imported = result.value.imported[0]; + if (imported) { + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams( + scopeThreadRef(project.environmentId, imported.threadId), + ), + }); + toastManager.add({ + type: "success", + title: imported.updatedInPlace + ? "Updated thread from native session" + : "Imported native session", + }); + } + setOpen(false); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not import session", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + } finally { + teleportImportPendingRef.current = false; + } + }, + [importTeleportSessions, navigate, setOpen], + ); + + const loadNativeSessionsIntoView = useCallback( + async (project: Project): Promise => { + const generation = (importListGenerationRef.current += 1); + const result = await listTeleportSessions({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot }, + }); + if (generation !== importListGenerationRef.current) { + return; + } + if (result._tag !== "Success") { + if (isAtomCommandInterrupted(result)) { + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-interrupted", + title: "Could not list native sessions", + description: "The request was interrupted. Try again.", + icon: , + run: async () => { + replaceTopPaletteView(importSessionsLoadingView(project.title)); + await loadNativeSessionsIntoView(project); + }, + }), + ]), + ); + return; + } + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-error", + title: "Could not list native sessions", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + icon: , + run: async () => { + replaceTopPaletteView(importSessionsLoadingView(project.title)); + await loadNativeSessionsIntoView(project); + }, + }), + ]), + ); + return; + } + if (result.value.sessions.length === 0) { + replaceTopPaletteView( + nativeSessionsPaletteView(project.title, [ + importSessionsStatusItem({ + value: "import-sessions-empty", + title: "No native sessions in this project", + description: project.workspaceRoot, + icon: , + }), + ]), + ); + return; + } + replaceTopPaletteView( + nativeSessionsPaletteView( + project.title, + result.value.sessions.map((session) => ({ + kind: "action" as const, + value: `import-session:${session.provider}:${session.providerInstanceId}:${session.externalSessionId}`, + searchTerms: [ + session.title ?? "", + session.externalSessionId, + teleportProviderLabel(session.provider), + session.cwd, + ], + title: session.title ?? session.externalSessionId, + description: [ + teleportProviderLabel(session.provider), + session.updatedAt ? formatRelativeTimeLabel(session.updatedAt) : null, + ] + .filter((part): part is string => part !== null) + .join(" · "), + icon: , + run: async () => { + await importNativeSession(project, session); + }, + })), + ), + ); + }, + [importNativeSession, listTeleportSessions, replaceTopPaletteView], + ); + + const openImportSessionsForProject = useCallback( + (project: Project): void => { + pushPaletteView(importSessionsLoadingView(project.title)); + void loadNativeSessionsIntoView(project); + }, + [loadNativeSessionsIntoView, pushPaletteView], + ); + + const importProjects = useMemo( + () => + selectTeleportImportProjects(projects, (environmentId) => + environmentSupportsTeleport( + environments.find((environment) => environment.environmentId === environmentId) + ?.serverConfig?.environment.capabilities, + ), + ), + [environments, projects], + ); + + const importProjectItems = useMemo( + () => + importProjects.map((project) => ({ + kind: "action" as const, + value: `import-sessions:${project.environmentId}:${project.id}`, + searchTerms: [project.title, project.workspaceRoot, "import", "teleport"], + title: project.title, + description: project.workspaceRoot, + icon: projectFavicon(project), + keepOpen: true, + run: async () => { + openImportSessionsForProject(project); + }, + })), + [importProjects, openImportSessionsForProject], + ); + + const openImportSessionsFlow = useCallback(() => { + if (importProjectItems.length === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: projects.length > 0 ? "Teleport is not available" : "No projects available", + description: + projects.length > 0 + ? "This server does not support importing native CLI sessions." + : "Add a project before importing native sessions.", + }), + ); + return; + } + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `import-sessions:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + const prioritized = currentPrefix + ? [ + ...importProjectItems.filter((item) => item.value === currentPrefix), + ...importProjectItems.filter((item) => item.value !== currentPrefix), + ] + : importProjectItems; + pushPaletteView(importIntoProjectPaletteView(prioritized)); + }, [ + currentProjectEnvironmentId, + currentProjectId, + importProjectItems, + projects.length, + pushPaletteView, + ]); + const startAddProjectBrowse = useCallback( async (environmentId: EnvironmentId): Promise => { const initialQuery = getAddProjectInitialQueryForEnvironment(environmentId); @@ -1486,6 +1852,67 @@ function OpenCommandPaletteDialog(props: { pushPaletteView, ]); + useLayoutEffect(() => { + if (openIntent?.kind !== "import-sessions") { + return; + } + const environmentId = openIntent.environmentId; + const projectId = openIntent.projectId; + clearOpenIntent(); + browseNavigation.invalidate(); + setAddProjectCloneFlow(null); + setQuery(""); + if (environmentId !== undefined && projectId !== undefined) { + const project = projects.find( + (candidate) => candidate.environmentId === environmentId && candidate.id === projectId, + ); + if (project) { + replacePaletteView(importSessionsLoadingView(project.title)); + void loadNativeSessionsIntoView(project); + return; + } + } + if (importProjectItems.length === 0) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: projects.length > 0 ? "Teleport is not available" : "No projects available", + description: + projects.length > 0 + ? "This server does not support importing native CLI sessions." + : "Add a project before importing native sessions.", + }), + ); + setOpen(false); + return; + } + const currentPrefix = + currentProjectEnvironmentId && currentProjectId + ? `import-sessions:${currentProjectEnvironmentId}:${currentProjectId}` + : null; + replacePaletteView( + importIntoProjectPaletteView( + currentPrefix + ? [ + ...importProjectItems.filter((item) => item.value === currentPrefix), + ...importProjectItems.filter((item) => item.value !== currentPrefix), + ] + : importProjectItems, + ), + ); + }, [ + browseNavigation, + clearOpenIntent, + currentProjectEnvironmentId, + currentProjectId, + importProjectItems, + loadNativeSessionsIntoView, + openIntent, + projects, + replacePaletteView, + setOpen, + ]); + const actionItems: Array = []; if (projects.length > 0) { @@ -1525,6 +1952,63 @@ function OpenCommandPaletteDialog(props: { addonIcon: , groups: [{ value: "projects", label: "Projects", items: projectThreadItems }], }); + + const boundTeleport = isTeleportedOut(activeThread?.teleport) + ? (activeThread?.teleport ?? null) + : null; + const boundImportProject = + boundTeleport && activeThread + ? (projects.find( + (project) => + project.id === activeThread.projectId && + project.environmentId === activeThread.environmentId, + ) ?? null) + : null; + if (boundTeleport && boundImportProject) { + const boundSupportsTeleport = environmentSupportsTeleport( + environments.find( + (environment) => environment.environmentId === boundImportProject.environmentId, + )?.serverConfig?.environment.capabilities, + ); + if (boundSupportsTeleport) { + actionItems.push({ + kind: "action", + value: "action:import-this-thread", + searchTerms: [ + "import this thread", + "teleport in", + "native", + "cli", + teleportProviderLabel(boundTeleport.provider), + ], + title: "Import this thread from native CLI", + icon: , + run: async () => { + await importNativeSession(boundImportProject, { + provider: boundTeleport.provider, + ...(boundTeleport.providerInstanceId === undefined + ? {} + : { providerInstanceId: boundTeleport.providerInstanceId }), + externalSessionId: boundTeleport.externalSessionId, + }); + }, + }); + } + } + + if (importProjectItems.length > 0) { + actionItems.push({ + kind: "action", + value: "action:import-sessions", + searchTerms: ["import sessions", "teleport", "codex", "claude", "native", "cli"], + title: "Import sessions...", + icon: , + keepOpen: true, + run: async () => { + openImportSessionsFlow(); + }, + }); + } } actionItems.push({ diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index f808f4ffe0d7..d905c5d18a35 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -115,6 +115,7 @@ import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic import { buildExpandedImagePreview, type ExpandedImagePreview } from "./ExpandedImagePreview"; import { basenameOfPath } from "../../pierre-icons"; import { cn, randomUUID } from "~/lib/utils"; +import { isTeleportSendDisabledReason } from "~/lib/teleport"; import { Separator } from "../ui/separator"; import { getComposerPromptLengthValidationMessage, @@ -707,6 +708,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onExpandImage, } = props; const isSendDisabled = sendDisabledReason !== null; + const teleportLockReason = isTeleportSendDisabledReason(sendDisabledReason) + ? sendDisabledReason + : null; + const isTeleportComposerLocked = teleportLockReason !== null; + const sendDisabledReasonRef = useRef(sendDisabledReason); + sendDisabledReasonRef.current = sendDisabledReason; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) @@ -2016,6 +2023,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const restoreStashEntry = useCallback( (entry: PromptStashEntry) => { + if (isTeleportSendDisabledReason(sendDisabledReasonRef.current)) { + toastManager.add({ + type: "error", + title: "Unable to restore stash", + description: + sendDisabledReasonRef.current ?? "The composer is busy; try again once it is ready.", + }); + return; + } // Remove first so a double activation (click + Enter) can't restore twice. const { entry: taken, durable } = takeStashEntry(entry.id); if (!taken) return; @@ -2154,6 +2170,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setIsStashMenuOpen((open) => !open); return; } + if (isTeleportSendDisabledReason(sendDisabledReasonRef.current)) { + toastManager.add({ + type: "error", + title: "Unable to stash prompt", + description: + sendDisabledReasonRef.current ?? "The composer is busy; try again once it is ready.", + }); + return; + } // A repeat ⌘S on the *same* still-unencoded snapshot would stash it // twice. Guard on the snapshot itself rather than a bare boolean: once // the composer has been cleared the user can type something genuinely @@ -2434,6 +2459,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ------------------------------------------------------------------ const addComposerImages = async (files: File[]) => { if (!activeThreadId || files.length === 0) return; + if (isSendDisabled) { + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: sendDisabledReason ?? "The composer is busy; try again once it is ready.", + }); + return; + } if (pendingUserInputs.length > 0) { toastManager.add({ type: "error", @@ -2480,6 +2513,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Images over the wire cap are downscaled to fit rather than // refused; files already within it pass through byte-for-byte. const compressed = await compressImageToByteLimit(file, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES); + if (sendDisabledReasonRef.current !== null) { + break; + } if (!compressed.ok) { compressionError = compressed.reason === "unreadable" @@ -2499,6 +2535,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) file: attachmentFile, }); } + const currentSendDisabledReason = sendDisabledReasonRef.current; + // Teleport/send-disabled can flip while compression is in flight. Do + // not commit attachments into a draft the user can no longer edit. + if (currentSendDisabledReason !== null) { + for (const image of nextImages) { + URL.revokeObjectURL(image.previewUrl); + } + toastManager.add({ + type: "error", + title: "Unable to add to chat", + description: currentSendDisabledReason, + }); + return; + } if (nextImages.length === 1 && nextImages[0]) { addComposerImage(nextImages[0]); } else if (nextImages.length > 1) { @@ -2544,6 +2594,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ): boolean => { if ( text.length === 0 || + isSendDisabled || isConnecting || isComposerApprovalState || pendingUserInputs.length > 0 || @@ -3210,22 +3261,29 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onCommandKeyDown={onComposerCommandKey} onPaste={onComposerPaste} placeholder={ - isComposerApprovalState - ? (activePendingApproval?.detail ?? - "Resolve this approval request to continue") - : activePendingProgress - ? "Type your own answer, or leave this blank to use the selected option" - : showPlanFollowUpPrompt && activeProposedPlan - ? "Add feedback to refine the plan, or leave this blank to implement it" - : projectSelectionRequired - ? "Choose a project above to start a thread" - : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? DISCONNECTED_COMPOSER_PLACEHOLDER - : "Ask anything, @tag files/folders, $use skills, or / for commands" + teleportLockReason !== null + ? teleportLockReason + : isComposerApprovalState + ? (activePendingApproval?.detail ?? + "Resolve this approval request to continue") + : activePendingProgress + ? "Type your own answer, or leave this blank to use the selected option" + : showPlanFollowUpPrompt && activeProposedPlan + ? "Add feedback to refine the plan, or leave this blank to implement it" + : projectSelectionRequired + ? "Choose a project above to start a thread" + : noProviderAvailable + ? "Enable a provider in Settings to send a message" + : phase === "disconnected" + ? DISCONNECTED_COMPOSER_PLACEHOLDER + : "Ask anything, @tag files/folders, $use skills, or / for commands" + } + disabled={ + isConnecting || + isComposerApprovalState || + projectSelectionRequired || + isTeleportComposerLocked } - disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> {showMobilePendingAnswerActions ? (
)} +
); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 57f9d7792251..758bbb16f527 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -1,9 +1,10 @@ import type { ScopedProjectRef } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; -import { FolderPlusIcon } from "lucide-react"; +import { FolderPlusIcon, ImportIcon } from "lucide-react"; import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; +import { environmentSupportsTeleport } from "~/lib/teleport"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useClientSettings } from "~/hooks/useSettings"; import { selectProjectGroupingSettings } from "~/logicalProject"; @@ -42,6 +43,17 @@ export function DraftHeroHeadline({ const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); + const openImportSessions = useCallback(() => { + if (activeProjectRef === null) { + openCommandPalette({ open: "import-sessions" }); + return; + } + openCommandPalette({ + open: "import-sessions", + environmentId: activeProjectRef.environmentId, + projectId: activeProjectRef.projectId, + }); + }, [activeProjectRef]); const environmentLabelById = useMemo( () => @@ -97,6 +109,11 @@ export function DraftHeroHeadline({ const hasResolvedProject = activeProjectTitle !== null; const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; + const teleportEnvironmentId = activeProjectRef?.environmentId ?? primaryEnvironmentId; + const supportsTeleport = environmentSupportsTeleport( + environments.find((environment) => environment.environmentId === teleportEnvironmentId) + ?.serverConfig?.environment.capabilities, + ); const projectSelector = shouldShowProjectMenu ? ( @@ -167,14 +184,26 @@ export function DraftHeroHeadline({ ); return ( -

- {hasResolvedProject ? ( - <>What should we build in {projectSelector}? - ) : canChooseProject ? ( - <>{projectSelector} to start - ) : ( - <>Add a project to start - )} -

+
+

+ {hasResolvedProject ? ( + <>What should we build in {projectSelector}? + ) : canChooseProject ? ( + <>{projectSelector} to start + ) : ( + <>Add a project to start + )} +

+ {hasResolvedProject && supportsTeleport ? ( + + ) : null} +
); } diff --git a/apps/web/src/components/teleport/TeleportOutButton.tsx b/apps/web/src/components/teleport/TeleportOutButton.tsx new file mode 100644 index 000000000000..6a9482ade3d4 --- /dev/null +++ b/apps/web/src/components/teleport/TeleportOutButton.tsx @@ -0,0 +1,188 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { LogOutIcon } from "lucide-react"; +import { useRef, useState } from "react"; + +import { buildLoadingThreadFromShell } from "../ChatView.logic"; +import { + environmentSupportsTeleport, + isTeleportedOut, + teleportFailureMessage, + threadSupportsTeleportExport, +} from "../../lib/teleport"; +import { useServerConfigs, useThread, useThreadShell } from "../../state/entities"; +import { teleportEnvironment } from "../../state/teleport"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface TeleportOutButtonProps { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly isServerThread: boolean; + readonly cwd: string | null; +} + +export function TeleportOutButton({ + environmentId, + threadId, + isServerThread, + cwd, +}: TeleportOutButtonProps) { + const threadRef = scopeThreadRef(environmentId, threadId); + const detail = useThread(threadRef); + const shell = useThreadShell(threadRef); + const serverConfigs = useServerConfigs(); + const exportSession = useAtomCommand(teleportEnvironment.exportSession, { + reportFailure: false, + }); + const importSessions = useAtomCommand(teleportEnvironment.importSessions, { + reportFailure: false, + }); + const pendingRef = useRef(false); + const [pending, setPending] = useState(false); + const thread = detail ?? (shell === null ? null : buildLoadingThreadFromShell(shell)); + if ( + !isServerThread || + thread === null || + !environmentSupportsTeleport(serverConfigs.get(environmentId)?.environment.capabilities) + ) { + return null; + } + + const teleport = thread.teleport ?? null; + const teleportedOut = isTeleportedOut(teleport); + const supported = threadSupportsTeleportExport({ + teleportedOut, + providerName: thread.session?.providerName ?? undefined, + instanceId: thread.modelSelection.instanceId, + providers: serverConfigs.get(environmentId)?.providers ?? [], + }); + if (!supported) { + return null; + } + + const sessionBusy = thread.session?.status === "starting" || thread.session?.status === "running"; + const importNeedsCwd = teleportedOut && (cwd === null || cwd.length === 0); + const busy = pending || sessionBusy || importNeedsCwd; + + return ( + + { + if (pendingRef.current || busy) { + return; + } + pendingRef.current = true; + setPending(true); + void (async () => { + try { + if (teleportedOut) { + if (teleport === null || cwd === null || cwd.length === 0) { + return; + } + const result = await importSessions({ + environmentId, + input: { + projectId: thread.projectId, + cwd, + sessions: [ + { + provider: teleport.provider, + ...(teleport.providerInstanceId === undefined + ? {} + : { providerInstanceId: teleport.providerInstanceId }), + externalSessionId: teleport.externalSessionId, + nativePath: teleport.nativePath, + }, + ], + }, + }); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Imported from native session", + description: teleport.nativePath, + }); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not teleport in", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + return; + } + + const result = await exportSession({ + environmentId, + input: { threadId }, + }); + if (result._tag === "Success") { + toastManager.add({ + type: "success", + title: "Teleported to native session", + description: result.value.nativePath, + }); + return; + } + if (isAtomCommandInterrupted(result)) { + return; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not teleport out", + description: teleportFailureMessage(squashAtomCommandFailure(result)), + }), + ); + } finally { + pendingRef.current = false; + setPending(false); + } + })(); + }} + > + + + } + /> + + {teleportedOut + ? importNeedsCwd + ? "Import needs the project workspace path" + : sessionBusy + ? "Import is available when this provider thread is idle" + : "Read this thread back from the native CLI session" + : sessionBusy + ? "Teleport out is available when this provider thread is idle" + : "Write this idle thread to the native CLI session"} + + + ); +} diff --git a/apps/web/src/lib/teleport.test.ts b/apps/web/src/lib/teleport.test.ts new file mode 100644 index 000000000000..b121b01305e0 --- /dev/null +++ b/apps/web/src/lib/teleport.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { environmentSupportsTeleport, threadSupportsTeleportExport } from "./teleport"; + +describe("environmentSupportsTeleport", () => { + it("is false when the capability is absent, matching older servers", () => { + expect(environmentSupportsTeleport(undefined)).toBe(false); + expect(environmentSupportsTeleport({ repositoryIdentity: true })).toBe(false); + }); + + it("is true only when the server advertises teleport", () => { + expect( + environmentSupportsTeleport({ + repositoryIdentity: true, + teleport: true, + }), + ).toBe(true); + expect( + environmentSupportsTeleport({ + repositoryIdentity: true, + teleport: false, + }), + ).toBe(false); + }); +}); + +describe("threadSupportsTeleportExport", () => { + it("accepts custom instances whose driver is a teleport provider", () => { + expect( + threadSupportsTeleportExport({ + teleportedOut: false, + providerName: undefined, + instanceId: "codex_work", + providers: [{ instanceId: "codex_work", driver: "codex" }], + }), + ).toBe(true); + }); + + it("rejects custom instances whose driver is not a teleport provider", () => { + expect( + threadSupportsTeleportExport({ + teleportedOut: false, + providerName: undefined, + instanceId: "cursor_work", + providers: [{ instanceId: "cursor_work", driver: "cursor" }], + }), + ).toBe(false); + }); + + it("does not treat Grok or OpenCode as teleport providers", () => { + expect( + threadSupportsTeleportExport({ + teleportedOut: false, + providerName: undefined, + instanceId: "grok", + providers: [{ instanceId: "grok", driver: "grok" }], + }), + ).toBe(false); + expect( + threadSupportsTeleportExport({ + teleportedOut: false, + providerName: "opencode", + instanceId: "opencode", + providers: [{ instanceId: "opencode", driver: "opencode" }], + }), + ).toBe(false); + }); + + it("keeps teleported-out threads exportable so they can be imported back", () => { + expect( + threadSupportsTeleportExport({ + teleportedOut: true, + providerName: undefined, + instanceId: "codex_work", + providers: [], + }), + ).toBe(true); + }); +}); diff --git a/apps/web/src/lib/teleport.ts b/apps/web/src/lib/teleport.ts new file mode 100644 index 000000000000..77328be791ef --- /dev/null +++ b/apps/web/src/lib/teleport.ts @@ -0,0 +1,74 @@ +import { + isTeleportProvider, + isTeleportedOut, + TELEPORTED_OUT_SEND_DISABLED_REASON, + TELEPORT_IMPORTING_SEND_DISABLED_REASON, + isTeleportSendDisabledReason, + teleportSendDisabledReason, + type ExecutionEnvironmentCapabilities, + type ProviderInstanceId, + type TeleportProvider, +} from "@t3tools/contracts"; + +export { + isTeleportedOut, + TELEPORTED_OUT_SEND_DISABLED_REASON, + TELEPORT_IMPORTING_SEND_DISABLED_REASON, + isTeleportSendDisabledReason, + teleportSendDisabledReason, +}; + +export function environmentSupportsTeleport( + capabilities: ExecutionEnvironmentCapabilities | null | undefined, +): boolean { + return capabilities?.teleport === true; +} + +export function threadSupportsTeleportExport(input: { + readonly teleportedOut: boolean; + readonly providerName: string | undefined; + readonly instanceId: string; + readonly providers: ReadonlyArray<{ + readonly instanceId: ProviderInstanceId | string; + readonly driver: string; + }>; +}): boolean { + if (input.teleportedOut) { + return true; + } + if (typeof input.providerName === "string" && isTeleportProvider(input.providerName)) { + return true; + } + const instance = input.providers.find((provider) => provider.instanceId === input.instanceId); + const driver = instance?.driver ?? input.instanceId; + return isTeleportProvider(driver); +} + +export function teleportProviderLabel(provider: TeleportProvider): string { + switch (provider) { + case "codex": + return "Codex"; + case "claudeAgent": + return "Claude"; + default: { + const _exhaustive: never = provider; + return _exhaustive; + } + } +} + +export function teleportFailureMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof error.message === "string" && + error.message.trim().length > 0 + ) { + return error.message; + } + return "Teleport failed."; +} diff --git a/apps/web/src/state/teleport.ts b/apps/web/src/state/teleport.ts new file mode 100644 index 000000000000..2d0802de3306 --- /dev/null +++ b/apps/web/src/state/teleport.ts @@ -0,0 +1,5 @@ +import { createTeleportEnvironmentAtoms } from "@t3tools/client-runtime/state/teleport"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const teleportEnvironment = createTeleportEnvironmentAtoms(connectionAtomRuntime); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index f75be5bc44bb..51c5e6babef4 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -123,6 +123,10 @@ "types": "./src/state/sourceControl.ts", "default": "./src/state/sourceControl.ts" }, + "./state/teleport": { + "types": "./src/state/teleport.ts", + "default": "./src/state/teleport.ts" + }, "./state/terminal": { "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index d3bb6680208a..fc04584039b1 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -229,6 +229,64 @@ describe("environment entity projections", () => { expect(merged?.messages).toBe(messages); }); + it("fills missing detail teleport presence from the shell snapshot", () => { + const nativeTeleport = { + presence: "native" as const, + provider: "codex" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: "2026-08-14T23:00:00.000Z", + }; + const detail = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; + const shell = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + teleport: nativeTeleport, + }; + + expect(mergeEnvironmentThread(detail, shell)?.teleport).toEqual(nativeTeleport); + }); + + it("keeps live detail teleport presence over a stale shell snapshot", () => { + const nativeTeleport = { + presence: "native" as const, + provider: "codex" as const, + externalSessionId: "session-1", + nativePath: "/tmp/native", + lastSyncedAt: "2026-08-14T23:00:00.000Z", + }; + const t3Teleport = { + ...nativeTeleport, + presence: "t3" as const, + lastSyncedAt: "2026-08-14T23:05:00.000Z", + }; + const detail = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + teleport: t3Teleport, + } satisfies OrchestrationThread & { readonly environmentId: EnvironmentId }; + const shell = { + ...THREAD_SHELL, + environmentId: ENVIRONMENT_ID, + teleport: nativeTeleport, + }; + + expect(mergeEnvironmentThread(detail, shell)?.teleport).toEqual(t3Teleport); + }); + it("preserves untouched project and thread identities across unrelated shell updates", () => { const harness = makeHarness(); const projectRefsAtom = harness.projects.environmentProjectRefsAtom(ENVIRONMENT_ID); diff --git a/packages/client-runtime/src/state/teleport.ts b/packages/client-runtime/src/state/teleport.ts new file mode 100644 index 000000000000..9588a665a49b --- /dev/null +++ b/packages/client-runtime/src/state/teleport.ts @@ -0,0 +1,24 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +export function createTeleportEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + listSessions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:teleport:list-sessions", + tag: WS_METHODS.teleportListSessions, + }), + importSessions: createEnvironmentRpcCommand(runtime, { + label: "environment-data:teleport:import-sessions", + tag: WS_METHODS.teleportImportSessions, + }), + exportSession: createEnvironmentRpcCommand(runtime, { + label: "environment-data:teleport:export-session", + tag: WS_METHODS.teleportExportSession, + }), + }; +} diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 5a2ffa442e04..e6b5c1c5606a 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -64,6 +64,10 @@ export function mergeEnvironmentThread( pinnedAt: shell.pinnedAt, pinOrderKey: shell.pinOrderKey, session: shell.session, + // Detail can resume from a cached snapshot and skip HTTP, so a backfilled + // `teleport_json` never arrives as `thread.teleported`. The shell snapshot + // is refetched per session; use it when detail has no presence yet. + teleport: detail.teleport ?? shell.teleport ?? null, }; } diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..913227e7d59a 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -882,6 +882,138 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("thread.history-replaced", () => { + it("replaces messages and clears turn-derived state", () => { + const threadWithHistory: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("old-1"), + role: "user", + text: "old", + turnId: TurnId.make("turn-1"), + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + proposedPlans: [ + { + id: "plan-1", + turnId: TurnId.make("turn-1"), + planMarkdown: "do things", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + activities: [ + { + id: EventId.make("act-1"), + turnId: TurnId.make("turn-1"), + tone: "tool", + kind: "file-edit", + summary: "old activity", + payload: {}, + createdAt: "2026-04-01T01:00:00.000Z", + }, + ], + checkpoints: [ + { + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("old-2"), + completedAt: "2026-04-01T01:00:00.000Z", + }, + ], + latestTurn: { + turnId: TurnId.make("turn-1"), + state: "completed", + requestedAt: "2026-04-01T01:00:00.000Z", + startedAt: "2026-04-01T01:00:00.000Z", + completedAt: "2026-04-01T01:00:00.000Z", + assistantMessageId: MessageId.make("old-2"), + }, + }; + + const messages = [ + { + id: MessageId.make("new-1"), + role: "user" as const, + text: "imported", + turnId: null, + streaming: false, + createdAt: "2026-08-14T06:00:00.000Z", + updatedAt: "2026-08-14T06:00:00.000Z", + }, + ]; + + const result = applyThreadDetailEvent(threadWithHistory, { + ...baseEventFields, + sequence: 20, + occurredAt: "2026-08-14T06:02:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.history-replaced", + payload: { + threadId: ThreadId.make("thread-1"), + messages, + replacedAt: "2026-08-14T06:02:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages).toEqual(messages); + expect(result.thread.proposedPlans).toEqual([]); + expect(result.thread.activities).toEqual([]); + expect(result.thread.checkpoints).toEqual([]); + expect(result.thread.latestTurn).toBeNull(); + expect(result.thread.updatedAt).toBe("2026-08-14T06:02:00.000Z"); + } + }); + }); + + describe("thread.teleported", () => { + it("stores teleport presence on the thread", () => { + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-08-14T22:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.teleported", + payload: { + threadId: ThreadId.make("thread-1"), + teleport: { + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }, + updatedAt: "2026-08-14T22:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.teleport).toEqual({ + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }); + expect(result.thread.updatedAt).toBe("2026-08-14T22:00:00.000Z"); + } + }); + }); + describe("no-op events", () => { it("returns unchanged for approval-response-requested", () => { const result = applyThreadDetailEvent(baseThread, { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..748e1b2881d6 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -559,6 +559,32 @@ export function applyThreadDetailEvent( }; } + case "thread.history-replaced": { + return { + kind: "updated", + thread: { + ...thread, + messages: event.payload.messages, + proposedPlans: [], + activities: [], + checkpoints: [], + latestTurn: null, + updatedAt: event.payload.replacedAt, + }, + }; + } + + case "thread.teleported": { + return { + kind: "updated", + thread: { + ...thread, + teleport: event.payload.teleport, + updatedAt: event.payload.updatedAt, + }, + }; + } + // ── Activities ────────────────────────────────────────────────── case "thread.activity-appended": { const activity = event.payload.activity; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1777bcebc2f8..fd47feedd16b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -80,6 +80,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** Server exposes teleport.listSessions / importSessions / exportSession. + Absent on older servers and on clients that have not wired the feature + (including mobile), so those clients must hide teleport controls instead + of probing unsupported RPCs. */ + teleport: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..6793c9f8dda2 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,4 +30,5 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./teleport.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index f403e6de26cc..37c4b8941d70 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -942,3 +942,33 @@ it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/svg+xml"), false); }); + +it.effect("decodes thread.teleport.import commands", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.teleport.import", + commandId: "cmd-teleport-import", + threadId: "thread-1", + teleport: { + presence: "t3", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }, + messages: [ + { + id: "message-1", + role: "user", + text: "imported", + turnId: null, + streaming: false, + createdAt: "2026-08-14T22:00:00.000Z", + updatedAt: "2026-08-14T22:00:00.000Z", + }, + ], + createdAt: "2026-08-14T22:00:00.000Z", + }); + assert.strictEqual(parsed.type, "thread.teleport.import"); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..ad937b607200 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { TeleportThreadState } from "./teleport.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -419,6 +420,8 @@ export const OrchestrationThread = Schema.Struct({ activities: Schema.Array(OrchestrationThreadActivity), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), + // Optional so payloads from pre-teleport servers still decode. + teleport: Schema.optional(Schema.NullOr(TeleportThreadState)), }); export type OrchestrationThread = typeof OrchestrationThread.Type; @@ -494,6 +497,11 @@ export const OrchestrationThreadShell = Schema.Struct({ }), ), ), + // Thread-level native/T3 presence. Optional so payloads from pre-teleport + // servers still decode. Lives on the shell because the environment snapshot + // is refreshed on every new session, unlike cached thread detail which can + // resume from events and miss a backfilled projection column. + teleport: Schema.optional(Schema.NullOr(TeleportThreadState)), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; @@ -1037,6 +1045,31 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadHistoryReplaceCommand = Schema.Struct({ + type: Schema.Literal("thread.history.replace"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array(OrchestrationMessage), + createdAt: IsoDateTime, +}); + +const ThreadTeleportSetCommand = Schema.Struct({ + type: Schema.Literal("thread.teleport.set"), + commandId: CommandId, + threadId: ThreadId, + teleport: TeleportThreadState, + createdAt: IsoDateTime, +}); + +const ThreadTeleportImportCommand = Schema.Struct({ + type: Schema.Literal("thread.teleport.import"), + commandId: CommandId, + threadId: ThreadId, + teleport: TeleportThreadState, + messages: Schema.Array(OrchestrationMessage), + createdAt: IsoDateTime, +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, @@ -1046,6 +1079,9 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadHistoryReplaceCommand, + ThreadTeleportSetCommand, + ThreadTeleportImportCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1085,6 +1121,8 @@ export const OrchestrationEventType = Schema.Literals([ "thread.proposed-plan-upserted", "thread.turn-diff-completed", "thread.activity-appended", + "thread.history-replaced", + "thread.teleported", ]); export type OrchestrationEventType = typeof OrchestrationEventType.Type; @@ -1319,6 +1357,18 @@ export const ThreadActivityAppendedPayload = Schema.Struct({ activity: OrchestrationThreadActivity, }); +export const ThreadHistoryReplacedPayload = Schema.Struct({ + threadId: ThreadId, + messages: Schema.Array(OrchestrationMessage), + replacedAt: IsoDateTime, +}); + +export const ThreadTeleportedPayload = Schema.Struct({ + threadId: ThreadId, + teleport: TeleportThreadState, + updatedAt: IsoDateTime, +}); + export const OrchestrationEventMetadata = Schema.Struct({ providerTurnId: Schema.optional(TrimmedNonEmptyString), providerItemId: Schema.optional(ProviderItemId), @@ -1486,6 +1536,16 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.activity-appended"), payload: ThreadActivityAppendedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.history-replaced"), + payload: ThreadHistoryReplacedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.teleported"), + payload: ThreadTeleportedPayload, + }), ]); export type OrchestrationEvent = typeof OrchestrationEvent.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..091be22ebaa5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -192,6 +192,17 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + TeleportExportError, + TeleportExportSessionInput, + TeleportExportSessionResult, + TeleportImportError, + TeleportImportSessionsInput, + TeleportImportSessionsResult, + TeleportListSessionsError, + TeleportListSessionsInput, + TeleportListSessionsResult, +} from "./teleport.ts"; export const WS_METHODS = { // Project registry methods @@ -302,6 +313,11 @@ export const WS_METHODS = { sourceControlCloneRepository: "sourceControl.cloneRepository", sourceControlPublishRepository: "sourceControl.publishRepository", + // Teleport session sync + teleportListSessions: "teleport.listSessions", + teleportImportSessions: "teleport.importSessions", + teleportExportSession: "teleport.exportSession", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -982,6 +998,24 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso stream: true, }); +export const WsTeleportListSessionsRpc = Rpc.make(WS_METHODS.teleportListSessions, { + payload: TeleportListSessionsInput, + success: TeleportListSessionsResult, + error: Schema.Union([TeleportListSessionsError, EnvironmentAuthorizationError]), +}); + +export const WsTeleportImportSessionsRpc = Rpc.make(WS_METHODS.teleportImportSessions, { + payload: TeleportImportSessionsInput, + success: TeleportImportSessionsResult, + error: Schema.Union([TeleportImportError, EnvironmentAuthorizationError]), +}); + +export const WsTeleportExportSessionRpc = Rpc.make(WS_METHODS.teleportExportSession, { + payload: TeleportExportSessionInput, + success: TeleportExportSessionResult, + error: Schema.Union([TeleportExportError, EnvironmentAuthorizationError]), +}); + export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, @@ -1074,6 +1108,9 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeAuthAccessRpc, WsSubscribeBackgroundPolicyRpc, WsSubscribeResourceTelemetryRpc, + WsTeleportListSessionsRpc, + WsTeleportImportSessionsRpc, + WsTeleportExportSessionRpc, WsOrchestrationDispatchCommandRpc, WsOrchestrationGetWorkflowScriptRpc, WsOrchestrationGetTurnDiffRpc, diff --git a/packages/contracts/src/teleport.test.ts b/packages/contracts/src/teleport.test.ts new file mode 100644 index 000000000000..f8469265b08a --- /dev/null +++ b/packages/contracts/src/teleport.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { ProjectId, ThreadId } from "./baseSchemas.ts"; +import { ProviderDriverKind } from "./providerInstance.ts"; +import { + isTeleportedOut, + isTeleportProvider, + resolveTeleportPresence, + TELEPORTED_OUT_SEND_DISABLED_REASON, + TELEPORT_IMPORTING_SEND_DISABLED_REASON, + isTeleportSendDisabledReason, + teleportSendDisabledReason, + TeleportDiscoveryError, + TeleportExportError, + TeleportFileLockedError, + TeleportIdentityConflictError, + TeleportInvalidInputError, + TeleportLockProbeError, + TeleportNativeWriteError, + TeleportRuntimePayload, + TeleportSchemaVersionError, + TeleportThreadState, + TeleportUnsupportedProviderError, +} from "./teleport.ts"; + +const decodeTeleportThreadState = Schema.decodeUnknownSync(TeleportThreadState); +const decodeTeleportRuntimePayload = Schema.decodeUnknownSync(TeleportRuntimePayload); +const decodeTeleportExportError = Schema.decodeUnknownSync(TeleportExportError); + +describe("teleport providers", () => { + it("supports Codex and Claude native CLIs only", () => { + expect(isTeleportProvider("codex")).toBe(true); + expect(isTeleportProvider("claudeAgent")).toBe(true); + expect(isTeleportProvider("grok")).toBe(false); + expect(isTeleportProvider("opencode")).toBe(false); + }); +}); + +describe("teleport presence", () => { + it("decodes a complete thread teleport state", () => { + const parsed = decodeTeleportThreadState({ + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/home/user/.codex/sessions/session-1", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }); + expect(parsed.presence).toBe("native"); + expect(parsed.provider).toBe("codex"); + }); + + it("decodes importing presence and restorePresence", () => { + const parsed = decodeTeleportThreadState({ + presence: "importing", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + restorePresence: "native", + }); + expect(parsed.presence).toBe("importing"); + expect(parsed.restorePresence).toBe("native"); + }); + + it("uses an explicit presence on the runtime payload", () => { + const parsed = decodeTeleportRuntimePayload({ + schemaVersion: 1, + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncDirection: "export", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + nativeFormatVersion: 1, + presence: "t3", + }); + expect(resolveTeleportPresence(parsed)).toBe("t3"); + }); + + it("treats a legacy export as native presence", () => { + const parsed = decodeTeleportRuntimePayload({ + schemaVersion: 1, + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncDirection: "export", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + nativeFormatVersion: 1, + }); + expect(parsed.presence).toBeUndefined(); + expect(resolveTeleportPresence(parsed)).toBe("native"); + }); + + it("treats a legacy import as t3 presence", () => { + expect( + resolveTeleportPresence({ + lastSyncDirection: "import", + }), + ).toBe("t3"); + }); + + it("reports native presence as teleported out", () => { + expect( + isTeleportedOut({ + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }), + ).toBe(true); + expect(TELEPORTED_OUT_SEND_DISABLED_REASON.length).toBeGreaterThan(0); + }); + + it("treats importing presence as teleported-out for composer and import retry", () => { + expect( + isTeleportedOut({ + presence: "importing", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + restorePresence: "native", + }), + ).toBe(true); + }); + + it("uses a distinct send-disabled reason while import is in progress", () => { + expect( + teleportSendDisabledReason({ + presence: "importing", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }), + ).toBe(TELEPORT_IMPORTING_SEND_DISABLED_REASON); + expect( + teleportSendDisabledReason({ + presence: "native", + provider: "codex", + externalSessionId: "session-1", + nativePath: "/tmp/session.jsonl", + lastSyncedAt: "2026-08-14T22:00:00.000Z", + }), + ).toBe(TELEPORTED_OUT_SEND_DISABLED_REASON); + }); + + it("identifies teleport send-disabled reasons for post-await composer guards", () => { + expect(isTeleportSendDisabledReason(TELEPORTED_OUT_SEND_DISABLED_REASON)).toBe(true); + expect(isTeleportSendDisabledReason(TELEPORT_IMPORTING_SEND_DISABLED_REASON)).toBe(true); + expect(isTeleportSendDisabledReason("Messages loading")).toBe(false); + expect(isTeleportSendDisabledReason(null)).toBe(false); + }); +}); + +describe("teleport lock probe errors", () => { + it("are part of the export error union", () => { + const parsed = decodeTeleportExportError({ + _tag: "TeleportLockProbeError", + nativePath: "/tmp/session.jsonl", + }); + expect(parsed._tag).toBe("TeleportLockProbeError"); + expect(parsed).toBeInstanceOf(TeleportLockProbeError); + expect(parsed.message).toBe("Failed to check whether /tmp/session.jsonl is locked."); + }); +}); + +describe("teleport tagged errors", () => { + it("derives TeleportInvalidInputError.message from reason", () => { + const error = new TeleportInvalidInputError({ + reason: "Cannot export while this T3 session is running.", + }); + expect(error.message).toBe("Cannot export while this T3 session is running."); + const parsed = decodeTeleportExportError({ + _tag: "TeleportInvalidInputError", + reason: "Cannot export while this T3 session is running.", + }); + expect(parsed).toBeInstanceOf(TeleportInvalidInputError); + expect(parsed.message).toBe("Cannot export while this T3 session is running."); + }); + + it("derives TeleportDiscoveryError.message from reason", () => { + const error = new TeleportDiscoveryError({ + reason: "Native session was not found for this project.", + }); + expect(error.message).toBe("Native session was not found for this project."); + }); + + it("derives TeleportFileLockedError.message from nativePath", () => { + const error = new TeleportFileLockedError({ + nativePath: "/tmp/session.jsonl", + }); + expect(error.message).toBe("Native session file is locked: /tmp/session.jsonl"); + }); + + it("derives TeleportSchemaVersionError.message from provider and version", () => { + const error = new TeleportSchemaVersionError({ + provider: "codex", + nativePath: "/tmp/session.jsonl", + foundVersion: 2, + supportedVersion: 1, + }); + expect(error.message).toBe("Unsupported Codex session format version 2 in /tmp/session.jsonl."); + }); + + it("derives TeleportIdentityConflictError.message from the session id", () => { + const error = new TeleportIdentityConflictError({ + provider: "codex", + externalSessionId: "session-1", + existingThreadId: ThreadId.make("thread-1"), + existingProjectId: ProjectId.make("project-1"), + }); + expect(error.message).toBe("Session 'session-1' is already bound to another T3 project."); + }); + + it("derives TeleportUnsupportedProviderError.message from the provider", () => { + const error = new TeleportUnsupportedProviderError({ + provider: ProviderDriverKind.make("grok"), + }); + expect(error.message).toBe("Teleport does not support provider 'grok'."); + }); + + it("derives TeleportNativeWriteError.message from stage and path", () => { + const error = new TeleportNativeWriteError({ + nativePath: "/tmp/session.jsonl", + stage: "verify", + }); + expect(error.message).toBe("Exported session failed verification: /tmp/session.jsonl"); + const parsed = decodeTeleportExportError({ + _tag: "TeleportNativeWriteError", + stage: "filesystem", + }); + expect(parsed).toBeInstanceOf(TeleportNativeWriteError); + expect(parsed.message).toBe("Native filesystem error during teleport export."); + }); +}); diff --git a/packages/contracts/src/teleport.ts b/packages/contracts/src/teleport.ts new file mode 100644 index 000000000000..fecfef4e04be --- /dev/null +++ b/packages/contracts/src/teleport.ts @@ -0,0 +1,401 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import { IsoDateTime, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; + +export const TELEPORT_SCHEMA_VERSION = 1 as const; +export const TELEPORT_NATIVE_FORMAT_VERSION = 1 as const; + +export const TeleportProvider = Schema.Literals(["codex", "claudeAgent"]); +export type TeleportProvider = typeof TeleportProvider.Type; + +export const TeleportSyncDirection = Schema.Literals(["import", "export"]); +export type TeleportSyncDirection = typeof TeleportSyncDirection.Type; + +export const TeleportPresence = Schema.Literals(["t3", "native", "importing"]); +export type TeleportPresence = typeof TeleportPresence.Type; + +export const TeleportRestorePresence = Schema.Literals(["t3", "native"]); +export type TeleportRestorePresence = typeof TeleportRestorePresence.Type; + +export const TeleportThreadState = Schema.Struct({ + presence: TeleportPresence, + provider: TeleportProvider, + providerInstanceId: Schema.optional(ProviderInstanceId), + externalSessionId: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + lastSyncedAt: IsoDateTime, + // Set only while presence is "importing". Restart recovery restores this + // presence so a crashed import cannot strand the thread. + restorePresence: Schema.optional(TeleportRestorePresence), +}); +export type TeleportThreadState = typeof TeleportThreadState.Type; + +export const TeleportSessionRef = Schema.Struct({ + provider: TeleportProvider, + providerInstanceId: Schema.optional(ProviderInstanceId), + externalSessionId: TrimmedNonEmptyString, + nativePath: Schema.optional(TrimmedNonEmptyString), +}); +export type TeleportSessionRef = typeof TeleportSessionRef.Type; + +export const TeleportListSessionsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + providers: Schema.optional(Schema.Array(TeleportProvider)), +}); +export type TeleportListSessionsInput = typeof TeleportListSessionsInput.Type; + +export const TeleportSessionCandidate = Schema.Struct({ + provider: TeleportProvider, + providerInstanceId: ProviderInstanceId, + externalSessionId: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + nativeFormatVersion: Schema.Int, + title: Schema.optional(TrimmedNonEmptyString), + createdAt: Schema.optional(IsoDateTime), + updatedAt: Schema.optional(IsoDateTime), +}); +export type TeleportSessionCandidate = typeof TeleportSessionCandidate.Type; + +export const TeleportListSessionsResult = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION).pipe( + Schema.withDecodingDefault(Effect.succeed(TELEPORT_SCHEMA_VERSION)), + ), + sessions: Schema.Array(TeleportSessionCandidate), +}); +export type TeleportListSessionsResult = typeof TeleportListSessionsResult.Type; + +/** + * Import is atomic per listed session, not all-or-nothing for the batch. + * If a later session fails, earlier successful imports are retained and the + * RPC still fails. + */ +export const TELEPORT_IMPORT_BATCH_SEMANTICS = "per-session" as const; + +export const TeleportImportSessionsInput = Schema.Struct({ + projectId: ProjectId, + cwd: TrimmedNonEmptyString, + sessions: Schema.Array(TeleportSessionRef).check(Schema.isMinLength(1)), +}); +export type TeleportImportSessionsInput = typeof TeleportImportSessionsInput.Type; + +export const TeleportImportedSession = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + provider: TeleportProvider, + providerInstanceId: ProviderInstanceId, + externalSessionId: TrimmedNonEmptyString, + updatedInPlace: Schema.Boolean, +}); +export type TeleportImportedSession = typeof TeleportImportedSession.Type; + +export const TeleportImportSessionsResult = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION).pipe( + Schema.withDecodingDefault(Effect.succeed(TELEPORT_SCHEMA_VERSION)), + ), + imported: Schema.Array(TeleportImportedSession), +}); +export type TeleportImportSessionsResult = typeof TeleportImportSessionsResult.Type; + +export const TeleportExportSessionInput = Schema.Struct({ + threadId: ThreadId, +}); +export type TeleportExportSessionInput = typeof TeleportExportSessionInput.Type; + +export const TeleportExportSessionResult = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION).pipe( + Schema.withDecodingDefault(Effect.succeed(TELEPORT_SCHEMA_VERSION)), + ), + provider: TeleportProvider, + providerInstanceId: ProviderInstanceId, + externalSessionId: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyString, +}); +export type TeleportExportSessionResult = typeof TeleportExportSessionResult.Type; + +export const TeleportRuntimePayload = Schema.Struct({ + schemaVersion: Schema.Literal(TELEPORT_SCHEMA_VERSION), + externalSessionId: TrimmedNonEmptyString, + nativePath: TrimmedNonEmptyString, + lastSyncDirection: TeleportSyncDirection, + lastSyncedAt: IsoDateTime, + nativeFormatVersion: Schema.Int, + presence: Schema.optional(TeleportPresence), +}); +export type TeleportRuntimePayload = typeof TeleportRuntimePayload.Type; + +export function isTeleportProvider(value: string): value is TeleportProvider { + return value === "codex" || value === "claudeAgent"; +} + +export function resolveTeleportPresence( + payload: Pick | null | undefined, +): TeleportPresence { + if (payload?.presence) { + switch (payload.presence) { + case "t3": + case "native": + case "importing": + return payload.presence; + default: { + const _exhaustive: never = payload.presence; + return _exhaustive; + } + } + } + return payload?.lastSyncDirection === "export" ? "native" : "t3"; +} + +export function teleportPresenceBlocksThreadTurnStart( + presence: TeleportPresence | null | undefined, +): boolean { + return presence === "native" || presence === "importing"; +} + +export function isTeleportedOut(teleport: TeleportThreadState | null | undefined): boolean { + return teleportPresenceBlocksThreadTurnStart(teleport?.presence); +} + +export const TELEPORTED_OUT_SEND_DISABLED_REASON = + "This thread is in the native CLI. Import it to keep chatting here."; + +export const TELEPORT_IMPORTING_SEND_DISABLED_REASON = + "This thread is being imported from the native CLI."; + +export function isTeleportSendDisabledReason( + reason: string | null | undefined, +): reason is + | typeof TELEPORTED_OUT_SEND_DISABLED_REASON + | typeof TELEPORT_IMPORTING_SEND_DISABLED_REASON { + return ( + reason === TELEPORTED_OUT_SEND_DISABLED_REASON || + reason === TELEPORT_IMPORTING_SEND_DISABLED_REASON + ); +} + +export function teleportSendDisabledReason( + teleport: TeleportThreadState | null | undefined, +): string | null { + if (teleport == null) { + return null; + } + switch (teleport.presence) { + case "native": + return TELEPORTED_OUT_SEND_DISABLED_REASON; + case "importing": + return TELEPORT_IMPORTING_SEND_DISABLED_REASON; + case "t3": + return null; + default: { + const _exhaustive: never = teleport.presence; + return _exhaustive; + } + } +} + +export class TeleportInvalidInputError extends Schema.TaggedErrorClass()( + "TeleportInvalidInputError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason; + } +} + +export class TeleportUnsupportedProviderError extends Schema.TaggedErrorClass()( + "TeleportUnsupportedProviderError", + { + provider: ProviderDriverKind, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Teleport does not support provider '${this.provider}'.`; + } +} + +export class TeleportSchemaVersionError extends Schema.TaggedErrorClass()( + "TeleportSchemaVersionError", + { + provider: Schema.optional(TeleportProvider), + nativePath: Schema.optional(TrimmedNonEmptyString), + foundVersion: Schema.optional(Schema.Int), + supportedVersion: Schema.Int, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + let kind = "native"; + if (this.provider !== undefined) { + switch (this.provider) { + case "codex": + kind = "Codex"; + break; + case "claudeAgent": + kind = "Claude"; + break; + default: { + const _exhaustive: never = this.provider; + return _exhaustive; + } + } + } + const version = this.foundVersion === undefined ? "" : ` ${this.foundVersion}`; + const location = this.nativePath === undefined ? "" : ` in ${this.nativePath}`; + return `Unsupported ${kind} session format version${version}${location}.`; + } +} + +export class TeleportFileLockedError extends Schema.TaggedErrorClass()( + "TeleportFileLockedError", + { + nativePath: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Native session file is locked: ${this.nativePath}`; + } +} + +export class TeleportLockProbeError extends Schema.TaggedErrorClass()( + "TeleportLockProbeError", + { + nativePath: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Failed to check whether ${this.nativePath} is locked.`; + } +} + +export class TeleportIdentityConflictError extends Schema.TaggedErrorClass()( + "TeleportIdentityConflictError", + { + provider: TeleportProvider, + externalSessionId: TrimmedNonEmptyString, + existingThreadId: ThreadId, + existingProjectId: ProjectId, + }, +) { + override get message(): string { + return `Session '${this.externalSessionId}' is already bound to another T3 project.`; + } +} + +export class TeleportProjectResolutionError extends Schema.TaggedErrorClass()( + "TeleportProjectResolutionError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason; + } +} + +export class TeleportDiscoveryError extends Schema.TaggedErrorClass()( + "TeleportDiscoveryError", + { + reason: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason; + } +} + +export const TeleportNativeWriteStage = Schema.Literals([ + "create-directory", + "create-temp", + "write-temp", + "read-temp", + "replace", + "verify", + "unsafe-session-id", + "bind", + "read-settings", + "filesystem", +]); +export type TeleportNativeWriteStage = typeof TeleportNativeWriteStage.Type; + +export class TeleportNativeWriteError extends Schema.TaggedErrorClass()( + "TeleportNativeWriteError", + { + nativePath: Schema.optional(TrimmedNonEmptyString), + stage: TeleportNativeWriteStage, + sessionId: Schema.optional(TrimmedNonEmptyString), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const nativePath = this.nativePath ?? "native session"; + switch (this.stage) { + case "create-directory": + return `Failed to create directory for ${nativePath}.`; + case "create-temp": + return `Failed to create a temp file for ${nativePath}.`; + case "write-temp": + return `Failed to write temp session file for ${nativePath}.`; + case "read-temp": + return `Failed to re-read temp session file for ${nativePath}.`; + case "replace": + return `Failed to replace ${nativePath}.`; + case "verify": + return `Exported session failed verification: ${nativePath}`; + case "unsafe-session-id": + return this.sessionId === undefined + ? "Refusing to write a native session with an unsafe id." + : `Refusing to write a native session with an unsafe id '${this.sessionId}'.`; + case "bind": + return "Failed to bind the exported native session."; + case "read-settings": + return "Server settings could not be read for teleport export."; + case "filesystem": + return "Native filesystem error during teleport export."; + default: { + const _exhaustive: never = this.stage; + return _exhaustive; + } + } + } +} + +export const TeleportListSessionsError = Schema.Union([ + TeleportInvalidInputError, + TeleportDiscoveryError, + TeleportSchemaVersionError, +]); +export type TeleportListSessionsError = typeof TeleportListSessionsError.Type; + +export const TeleportImportError = Schema.Union([ + TeleportInvalidInputError, + TeleportUnsupportedProviderError, + TeleportSchemaVersionError, + TeleportFileLockedError, + TeleportLockProbeError, + TeleportIdentityConflictError, + TeleportProjectResolutionError, + TeleportDiscoveryError, +]); +export type TeleportImportError = typeof TeleportImportError.Type; + +export const TeleportExportError = Schema.Union([ + TeleportInvalidInputError, + TeleportUnsupportedProviderError, + TeleportSchemaVersionError, + TeleportFileLockedError, + TeleportLockProbeError, + TeleportProjectResolutionError, + TeleportNativeWriteError, +]); +export type TeleportExportError = typeof TeleportExportError.Type;