diff --git a/apps/code/src/main/di/container.ts b/apps/code/src/main/di/container.ts index 04bce7fad..dc51823bc 100644 --- a/apps/code/src/main/di/container.ts +++ b/apps/code/src/main/di/container.ts @@ -147,6 +147,7 @@ import { } from "@posthog/workspace-server/services/archive/identifiers"; import { authProxyModule } from "@posthog/workspace-server/services/auth-proxy/auth-proxy.module"; import { AUTH_PROXY_AUTH } from "@posthog/workspace-server/services/auth-proxy/identifiers"; +import { claudeCliSessionsModule } from "@posthog/workspace-server/services/claude-cli-sessions/claude-cli-sessions.module"; import { enrichmentModule } from "@posthog/workspace-server/services/enrichment/enrichment.module"; import { ENRICHMENT_AUTH, @@ -586,6 +587,7 @@ container.bind(MAIN_POSTHOG_PLUGIN_SERVICE).toService(POSTHOG_PLUGIN_SERVICE); container.load(skillsModule); container.load(skillsMarketplaceModule); container.load(onboardingImportModule); +container.load(claudeCliSessionsModule); container.load(additionalDirectoriesModule); container.bind(MAIN_SLEEP_SERVICE).to(SleepService); container.bind(SLEEP_SERVICE).toService(MAIN_SLEEP_SERVICE); diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 70b159e87..9f1365fb8 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -6,6 +6,7 @@ import { authRouter } from "@posthog/host-router/routers/auth.router"; import { canvasDataRouter } from "@posthog/host-router/routers/canvas-data.router"; import { canvasTemplatesRouter } from "@posthog/host-router/routers/canvas-templates.router"; import { channelTasksRouter } from "@posthog/host-router/routers/channel-tasks.router"; +import { claudeCliSessionsRouter } from "@posthog/host-router/routers/claude-cli-sessions.router"; import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router"; import { connectivityRouter } from "@posthog/host-router/routers/connectivity.router"; import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; @@ -55,6 +56,7 @@ export const trpcRouter = router({ canvasData: canvasDataRouter, canvasTemplates: canvasTemplatesRouter, channelTasks: channelTasksRouter, + claudeCliSessions: claudeCliSessionsRouter, dashboards: dashboardsRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, diff --git a/packages/host-router/src/router.ts b/packages/host-router/src/router.ts index 7379a427c..a298a50e7 100644 --- a/packages/host-router/src/router.ts +++ b/packages/host-router/src/router.ts @@ -7,6 +7,7 @@ import { authRouter } from "./routers/auth.router"; import { canvasDataRouter } from "./routers/canvas-data.router"; import { canvasTemplatesRouter } from "./routers/canvas-templates.router"; import { channelTasksRouter } from "./routers/channel-tasks.router"; +import { claudeCliSessionsRouter } from "./routers/claude-cli-sessions.router"; import { cloudTaskRouter } from "./routers/cloud-task.router"; import { connectivityRouter } from "./routers/connectivity.router"; import { contextMenuRouter } from "./routers/context-menu.router"; @@ -53,6 +54,7 @@ export const hostRouter = router({ canvasData: canvasDataRouter, canvasTemplates: canvasTemplatesRouter, channelTasks: channelTasksRouter, + claudeCliSessions: claudeCliSessionsRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, contextMenu: contextMenuRouter, diff --git a/packages/host-router/src/routers/claude-cli-sessions.router.ts b/packages/host-router/src/routers/claude-cli-sessions.router.ts new file mode 100644 index 000000000..359bb1b90 --- /dev/null +++ b/packages/host-router/src/routers/claude-cli-sessions.router.ts @@ -0,0 +1,58 @@ +import { publicProcedure, router } from "@posthog/host-trpc/trpc"; +import { + CLAUDE_CLI_SESSIONS_SERVICE, + type ClaudeCliSessionsService, +} from "@posthog/workspace-server/services/claude-cli-sessions/identifiers"; +import { + deleteImportedCliSessionInput, + deleteImportRecordInput, + importCliSessionInput, + importCliSessionOutput, + listCliSessionsInput, + listCliSessionsOutput, + recordCliImportInput, +} from "@posthog/workspace-server/services/claude-cli-sessions/schemas"; + +export const claudeCliSessionsRouter = router({ + list: publicProcedure + .input(listCliSessionsInput) + .output(listCliSessionsOutput) + .query(({ ctx, input }) => + ctx.container + .get(CLAUDE_CLI_SESSIONS_SERVICE) + .listForRepo(input), + ), + + import: publicProcedure + .input(importCliSessionInput) + .output(importCliSessionOutput) + .mutation(({ ctx, input }) => + ctx.container + .get(CLAUDE_CLI_SESSIONS_SERVICE) + .importSession(input), + ), + + deleteImport: publicProcedure + .input(deleteImportedCliSessionInput) + .mutation(({ ctx, input }) => + ctx.container + .get(CLAUDE_CLI_SESSIONS_SERVICE) + .deleteImportedSession(input), + ), + + recordImport: publicProcedure + .input(recordCliImportInput) + .mutation(({ ctx, input }) => + ctx.container + .get(CLAUDE_CLI_SESSIONS_SERVICE) + .recordImport(input), + ), + + deleteImportRecord: publicProcedure + .input(deleteImportRecordInput) + .mutation(({ ctx, input }) => + ctx.container + .get(CLAUDE_CLI_SESSIONS_SERVICE) + .deleteImportRecord(input), + ), +}); diff --git a/packages/workspace-server/src/db/identifiers.ts b/packages/workspace-server/src/db/identifiers.ts index 1125aea55..f00517bfa 100644 --- a/packages/workspace-server/src/db/identifiers.ts +++ b/packages/workspace-server/src/db/identifiers.ts @@ -27,3 +27,6 @@ export const DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY = Symbol.for( export const TASK_METADATA_REPOSITORY = Symbol.for( "posthog.workspace.taskMetadataRepository", ); +export const CLAUDE_SESSION_IMPORT_REPOSITORY = Symbol.for( + "posthog.workspace.claudeSessionImportRepository", +); diff --git a/packages/workspace-server/src/db/migrations/0012_claude_session_imports.sql b/packages/workspace-server/src/db/migrations/0012_claude_session_imports.sql new file mode 100644 index 000000000..55aba4b4b --- /dev/null +++ b/packages/workspace-server/src/db/migrations/0012_claude_session_imports.sql @@ -0,0 +1,15 @@ +CREATE TABLE `claude_session_imports` ( + `id` text PRIMARY KEY NOT NULL, + `source_session_id` text NOT NULL, + `imported_session_id` text NOT NULL, + `task_id` text NOT NULL, + `repo_path` text NOT NULL, + `source_mtime_ms` integer NOT NULL, + `source_size_bytes` integer NOT NULL, + `source_last_entry_uuid` text, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `claude_session_imports_importedSessionId_unique` ON `claude_session_imports` (`imported_session_id`);--> statement-breakpoint +CREATE INDEX `claude_session_imports_source_idx` ON `claude_session_imports` (`source_session_id`);--> statement-breakpoint +CREATE INDEX `claude_session_imports_task_idx` ON `claude_session_imports` (`task_id`); \ No newline at end of file diff --git a/packages/workspace-server/src/db/migrations/meta/0012_snapshot.json b/packages/workspace-server/src/db/migrations/meta/0012_snapshot.json new file mode 100644 index 000000000..f8ee8442c --- /dev/null +++ b/packages/workspace-server/src/db/migrations/meta/0012_snapshot.json @@ -0,0 +1,791 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "c4551266-7dd0-4385-bec7-8841b7f186be", + "prevId": "a4447433-8286-4769-8ce8-d2b573fc8862", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_org_project_preferences": { + "name": "auth_org_project_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "claude_session_imports": { + "name": "claude_session_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_session_id": { + "name": "source_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_session_id": { + "name": "imported_session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_mtime_ms": { + "name": "source_mtime_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_size_bytes": { + "name": "source_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_last_entry_uuid": { + "name": "source_last_entry_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "claude_session_imports_importedSessionId_unique": { + "name": "claude_session_imports_importedSessionId_unique", + "columns": ["imported_session_id"], + "isUnique": true + }, + "claude_session_imports_source_idx": { + "name": "claude_session_imports_source_idx", + "columns": ["source_session_id"], + "isUnique": false + }, + "claude_session_imports_task_idx": { + "name": "claude_session_imports_task_idx", + "columns": ["task_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_metadata": { + "name": "task_metadata", + "columns": { + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/packages/workspace-server/src/db/migrations/meta/_journal.json b/packages/workspace-server/src/db/migrations/meta/_journal.json index 7130d3692..947a7e5a2 100644 --- a/packages/workspace-server/src/db/migrations/meta/_journal.json +++ b/packages/workspace-server/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1782359760458, "tag": "0011_spotty_paibok", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1782484832959, + "tag": "0012_claude_session_imports", + "breakpoints": true } ] } diff --git a/packages/workspace-server/src/db/repositories.module.ts b/packages/workspace-server/src/db/repositories.module.ts index aebcabdbd..a23e35397 100644 --- a/packages/workspace-server/src/db/repositories.module.ts +++ b/packages/workspace-server/src/db/repositories.module.ts @@ -3,6 +3,7 @@ import { ARCHIVE_REPOSITORY, AUTH_PREFERENCE_REPOSITORY, AUTH_SESSION_REPOSITORY, + CLAUDE_SESSION_IMPORT_REPOSITORY, DEFAULT_ADDITIONAL_DIRECTORY_REPOSITORY, REPOSITORY_REPOSITORY, SUSPENSION_REPOSITORY, @@ -13,6 +14,7 @@ import { import { ArchiveRepository } from "./repositories/archive-repository"; import { AuthPreferenceRepository } from "./repositories/auth-preference-repository"; import { AuthSessionRepository } from "./repositories/auth-session-repository"; +import { ClaudeSessionImportRepository } from "./repositories/claude-session-import-repository"; import { DefaultAdditionalDirectoryRepository } from "./repositories/default-additional-directory-repository"; import { RepositoryRepository } from "./repositories/repository-repository"; import { SuspensionRepositoryImpl } from "./repositories/suspension-repository"; @@ -34,4 +36,7 @@ export const repositoriesModule = new ContainerModule(({ bind }) => { .to(DefaultAdditionalDirectoryRepository) .inSingletonScope(); bind(TASK_METADATA_REPOSITORY).to(TaskMetadataRepository).inSingletonScope(); + bind(CLAUDE_SESSION_IMPORT_REPOSITORY) + .to(ClaudeSessionImportRepository) + .inSingletonScope(); }); diff --git a/packages/workspace-server/src/db/repositories/claude-session-import-repository.mock.ts b/packages/workspace-server/src/db/repositories/claude-session-import-repository.mock.ts new file mode 100644 index 000000000..5c82dc155 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/claude-session-import-repository.mock.ts @@ -0,0 +1,51 @@ +import type { + ClaudeSessionImport, + IClaudeSessionImportRepository, + RecordClaudeSessionImportData, +} from "./claude-session-import-repository"; + +export interface MockClaudeSessionImportRepository + extends IClaudeSessionImportRepository { + _imports: Map; +} + +export function createMockClaudeSessionImportRepository(): MockClaudeSessionImportRepository { + const imports = new Map(); + + return { + _imports: imports, + recordImport: (data: RecordClaudeSessionImportData) => { + const row: ClaudeSessionImport = { + id: crypto.randomUUID(), + ...data, + createdAt: new Date().toISOString(), + }; + imports.set(row.id, row); + return { ...row }; + }, + // Map iteration is insertion order; reverse for newest-first, matching the + // repo's createdAt + rowid ordering. + listBySourceSessionIds: (sourceSessionIds: string[]) => + Array.from(imports.values()) + .filter((row) => sourceSessionIds.includes(row.sourceSessionId)) + .reverse() + .map((row) => ({ ...row })), + findByTaskId: (taskId: string) => { + const row = Array.from(imports.values()).find((r) => r.taskId === taskId); + return row ? { ...row } : null; + }, + deleteByTaskId: (taskId: string) => { + for (const [id, row] of imports) { + if (row.taskId === taskId) imports.delete(id); + } + }, + deleteByImportedSessionId: (importedSessionId: string) => { + for (const [id, row] of imports) { + if (row.importedSessionId === importedSessionId) imports.delete(id); + } + }, + deleteAll: () => { + imports.clear(); + }, + }; +} diff --git a/packages/workspace-server/src/db/repositories/claude-session-import-repository.test.ts b/packages/workspace-server/src/db/repositories/claude-session-import-repository.test.ts new file mode 100644 index 000000000..8d7ec6292 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/claude-session-import-repository.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { DatabaseService } from "../service"; +import { createTestDb, type TestDatabase } from "../test-helpers"; +import { + ClaudeSessionImportRepository, + type RecordClaudeSessionImportData, +} from "./claude-session-import-repository"; + +let testDb: TestDatabase; +let imports: ClaudeSessionImportRepository; + +beforeEach(() => { + testDb = createTestDb(); + const databaseService = { db: testDb.db } as unknown as DatabaseService; + imports = new ClaudeSessionImportRepository(databaseService); +}); + +afterEach(() => { + testDb.close(); +}); + +const importData = ( + overrides: Partial = {}, +): RecordClaudeSessionImportData => ({ + sourceSessionId: "5e4f5423-0287-4473-ae06-24df41c62993", + importedSessionId: crypto.randomUUID(), + taskId: "task-1", + repoPath: "/repos/twig", + sourceMtimeMs: 1_700_000_000_000, + sourceSizeBytes: 4096, + sourceLastEntryUuid: "entry-uuid-1", + ...overrides, +}); + +describe("ClaudeSessionImportRepository", () => { + it("records an import and reads it back by task id", () => { + const created = imports.recordImport(importData()); + + const found = imports.findByTaskId("task-1"); + + expect(found?.id).toBe(created.id); + expect(found?.sourceSessionId).toBe("5e4f5423-0287-4473-ae06-24df41c62993"); + expect(found?.sourceMtimeMs).toBe(1_700_000_000_000); + expect(found?.sourceSizeBytes).toBe(4096); + expect(found?.sourceLastEntryUuid).toBe("entry-uuid-1"); + }); + + it("lists imports for the requested source session ids only", () => { + imports.recordImport(importData({ taskId: "task-1" })); + imports.recordImport( + importData({ sourceSessionId: "other-source", taskId: "task-2" }), + ); + + const rows = imports.listBySourceSessionIds([ + "5e4f5423-0287-4473-ae06-24df41c62993", + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]?.taskId).toBe("task-1"); + }); + + it("returns an empty list for no source session ids", () => { + imports.recordImport(importData()); + + expect(imports.listBySourceSessionIds([])).toEqual([]); + }); + + it("allows multiple imports of the same source under distinct imported ids", () => { + imports.recordImport(importData({ taskId: "task-1" })); + imports.recordImport(importData({ taskId: "task-2" })); + + const rows = imports.listBySourceSessionIds([ + "5e4f5423-0287-4473-ae06-24df41c62993", + ]); + + expect(rows).toHaveLength(2); + }); + + it("orders same-second imports of one source newest first", () => { + // Both rows share a CURRENT_TIMESTAMP second; the rowid tiebreak must still + // put the later insert first so the service reads the latest import. + imports.recordImport(importData({ taskId: "task-1" })); + imports.recordImport(importData({ taskId: "task-2" })); + + const rows = imports.listBySourceSessionIds([ + "5e4f5423-0287-4473-ae06-24df41c62993", + ]); + + expect(rows.map((r) => r.taskId)).toEqual(["task-2", "task-1"]); + }); + + it("rejects duplicate imported session ids", () => { + const data = importData(); + imports.recordImport(data); + + expect(() => imports.recordImport({ ...data, taskId: "task-2" })).toThrow(); + }); + + it("deletes imports by task id, leaving others intact", () => { + imports.recordImport(importData({ taskId: "task-1" })); + imports.recordImport( + importData({ sourceSessionId: "other-source", taskId: "task-2" }), + ); + + imports.deleteByTaskId("task-1"); + + expect(imports.findByTaskId("task-1")).toBeNull(); + expect(imports.findByTaskId("task-2")).not.toBeNull(); + }); + + it("deletes an import by imported session id, leaving others intact", () => { + const target = importData({ taskId: "task-1" }); + imports.recordImport(target); + imports.recordImport( + importData({ sourceSessionId: "other-source", taskId: "task-2" }), + ); + + imports.deleteByImportedSessionId(target.importedSessionId); + + expect(imports.findByTaskId("task-1")).toBeNull(); + expect(imports.findByTaskId("task-2")).not.toBeNull(); + }); + + it("deletes nothing when no row matches the imported session id", () => { + imports.recordImport(importData({ taskId: "task-1" })); + + imports.deleteByImportedSessionId(crypto.randomUUID()); + + expect(imports.findByTaskId("task-1")).not.toBeNull(); + }); +}); diff --git a/packages/workspace-server/src/db/repositories/claude-session-import-repository.ts b/packages/workspace-server/src/db/repositories/claude-session-import-repository.ts new file mode 100644 index 000000000..78f40d8e1 --- /dev/null +++ b/packages/workspace-server/src/db/repositories/claude-session-import-repository.ts @@ -0,0 +1,100 @@ +import { desc, eq, inArray, sql } from "drizzle-orm"; +import { inject, injectable } from "inversify"; +import { DATABASE_SERVICE } from "../identifiers"; +import { claudeSessionImports } from "../schema"; +import type { DatabaseService } from "../service"; + +export type ClaudeSessionImport = typeof claudeSessionImports.$inferSelect; +export type NewClaudeSessionImport = typeof claudeSessionImports.$inferInsert; + +export interface RecordClaudeSessionImportData { + sourceSessionId: string; + importedSessionId: string; + taskId: string; + repoPath: string; + sourceMtimeMs: number; + sourceSizeBytes: number; + sourceLastEntryUuid: string | null; +} + +export interface IClaudeSessionImportRepository { + recordImport(data: RecordClaudeSessionImportData): ClaudeSessionImport; + /** Latest import per source session id, newest first within each source. */ + listBySourceSessionIds(sourceSessionIds: string[]): ClaudeSessionImport[]; + findByTaskId(taskId: string): ClaudeSessionImport | null; + deleteByTaskId(taskId: string): void; + deleteByImportedSessionId(importedSessionId: string): void; + deleteAll(): void; +} + +@injectable() +export class ClaudeSessionImportRepository + implements IClaudeSessionImportRepository +{ + constructor( + @inject(DATABASE_SERVICE) + private readonly databaseService: DatabaseService, + ) {} + + private get db() { + return this.databaseService.db; + } + + recordImport(data: RecordClaudeSessionImportData): ClaudeSessionImport { + const id = crypto.randomUUID(); + this.db + .insert(claudeSessionImports) + .values({ id, ...data }) + .run(); + const created = this.db + .select() + .from(claudeSessionImports) + .where(eq(claudeSessionImports.id, id)) + .get(); + if (!created) { + throw new Error(`Failed to record claude session import ${id}`); + } + return created; + } + + listBySourceSessionIds(sourceSessionIds: string[]): ClaudeSessionImport[] { + if (sourceSessionIds.length === 0) return []; + return ( + this.db + .select() + .from(claudeSessionImports) + .where(inArray(claudeSessionImports.sourceSessionId, sourceSessionIds)) + // rowid tiebreaks same-second createdAt, so "newest first" is stable. + .orderBy(desc(claudeSessionImports.createdAt), desc(sql`rowid`)) + .all() + ); + } + + findByTaskId(taskId: string): ClaudeSessionImport | null { + return ( + this.db + .select() + .from(claudeSessionImports) + .where(eq(claudeSessionImports.taskId, taskId)) + .get() ?? null + ); + } + + deleteByTaskId(taskId: string): void { + this.db + .delete(claudeSessionImports) + .where(eq(claudeSessionImports.taskId, taskId)) + .run(); + } + + deleteByImportedSessionId(importedSessionId: string): void { + this.db + .delete(claudeSessionImports) + .where(eq(claudeSessionImports.importedSessionId, importedSessionId)) + .run(); + } + + deleteAll(): void { + this.db.delete(claudeSessionImports).run(); + } +} diff --git a/packages/workspace-server/src/db/schema.ts b/packages/workspace-server/src/db/schema.ts index 48724553c..ac57e6299 100644 --- a/packages/workspace-server/src/db/schema.ts +++ b/packages/workspace-server/src/db/schema.ts @@ -121,6 +121,28 @@ export const defaultAdditionalDirectories = sqliteTable( }, ); +export const claudeSessionImports = sqliteTable( + "claude_session_imports", + { + id: id(), + /** Session id of the original Claude Code CLI session in ~/.claude. */ + sourceSessionId: text().notNull(), + /** Fresh session id the imported snapshot lives under in CLAUDE_CONFIG_DIR. */ + importedSessionId: text().notNull().unique(), + taskId: text().notNull(), + repoPath: text().notNull(), + /** Fingerprint of the source file at import time, for divergence detection. */ + sourceMtimeMs: integer().notNull(), + sourceSizeBytes: integer().notNull(), + sourceLastEntryUuid: text(), + createdAt: createdAt(), + }, + (t) => [ + index("claude_session_imports_source_idx").on(t.sourceSessionId), + index("claude_session_imports_task_idx").on(t.taskId), + ], +); + export const authPreferences = sqliteTable( "auth_preferences", { diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index d8ee377ef..7d28a3d71 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -274,6 +274,12 @@ interface SessionConfig { model?: string; /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ jsonSchema?: Record | null; + /** + * Session ID of an imported Claude Code CLI transcript already present in + * CLAUDE_CONFIG_DIR. Starts the session via loadSession so prior history is + * replayed to the client. Claude adapter only. + */ + importedSessionId?: string; } interface ManagedSession { @@ -899,7 +905,50 @@ If a repository IS genuinely required, attach one in this priority order: }); let configOptions: SessionConfigOption[] | undefined; - let agentSessionId: string; + let agentSessionId: string | undefined; + + // Imported Claude Code CLI session: the transcript JSONL was copied + // into CLAUDE_CONFIG_DIR at import time, so load it directly and let + // the adapter replay its history to the client. On failure, fall + // through to a fresh session so the task still starts. + if (!isReconnect && config.importedSessionId && adapter !== "codex") { + const importedSessionId = config.importedSessionId; + try { + const loadResponse = await connection.loadSession({ + sessionId: importedSessionId, + cwd: repoPath, + mcpServers: sessionMcpServers, + _meta: { + ...(logUrl && { + persistence: { taskId, runId: taskRunId, logUrl }, + }), + taskRunId, + environment: "local", + sessionId: importedSessionId, + systemPrompt, + ...(channelMode && { channelMode }), + mcpToolApprovals: toolApprovals, + ...(permissionMode && { permissionMode }), + ...(model != null && { model }), + ...(jsonSchema && { jsonSchema }), + claudeCode: { + options: claudeCodeOptions, + }, + }, + }); + configOptions = loadResponse?.configOptions ?? undefined; + agentSessionId = importedSessionId; + } catch (err) { + this.log.warn( + "Failed to load imported session, creating new session instead", + { + taskId, + taskRunId, + error: err instanceof Error ? err.message : String(err), + }, + ); + } + } // Claude-specific: hydrate session JSONL from PostHog before resuming. // If hydration finds no conversation to restore, skip the resume and @@ -961,7 +1010,7 @@ If a repository IS genuinely required, attach one in this priority order: }); configOptions = resumeResponse?.configOptions ?? undefined; agentSessionId = existingSessionId; - } else { + } else if (agentSessionId === undefined) { if (isReconnect) { this.log.info("No sessionId for reconnect, creating new session", { taskId, @@ -1863,6 +1912,8 @@ For git operations while detached: effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, + importedSessionId: + "importedSessionId" in params ? params.importedSessionId : undefined, }; } diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 5cfb8cd01..493e79943 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -30,6 +30,12 @@ export const sessionConfigSchema = z.object({ additionalDirectories: z.array(z.string()).optional(), /** Permission mode to use for the session (e.g. "default", "acceptEdits", "plan", "bypassPermissions") */ permissionMode: z.string().optional(), + /** + * Session ID of an imported Claude Code CLI transcript already present in + * CLAUDE_CONFIG_DIR. Starts the session via loadSession so the prior + * history is replayed to the client. Claude adapter only. + */ + importedSessionId: z.string().optional(), }); export type SessionConfig = z.infer; @@ -63,6 +69,12 @@ export const startSessionInput = z.object({ effort: effortLevelSchema.optional(), model: z.string().optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), + /** + * Session ID of an imported Claude Code CLI transcript already present in + * CLAUDE_CONFIG_DIR. Starts the session via loadSession so the prior + * history is replayed to the client. Claude adapter only. + */ + importedSessionId: z.string().optional(), }); export type StartSessionInput = z.infer; diff --git a/packages/workspace-server/src/services/archive/archive.integration.test.ts b/packages/workspace-server/src/services/archive/archive.integration.test.ts index 192bc507b..b5c4b9a08 100644 --- a/packages/workspace-server/src/services/archive/archive.integration.test.ts +++ b/packages/workspace-server/src/services/archive/archive.integration.test.ts @@ -153,6 +153,7 @@ async function withTestContext( suspensionRepo as never, taskMetadataRepo as never, workspaceSettings as never, + { deleteImportForTask: async () => {} }, archiveLogger as never, ); diff --git a/packages/workspace-server/src/services/archive/archive.ts b/packages/workspace-server/src/services/archive/archive.ts index decc29010..a07994ff6 100644 --- a/packages/workspace-server/src/services/archive/archive.ts +++ b/packages/workspace-server/src/services/archive/archive.ts @@ -40,6 +40,10 @@ import type { WorkspaceRepository, } from "../../db/repositories/workspace-repository"; import type { WorktreeRepository } from "../../db/repositories/worktree-repository"; +import { + IMPORTED_SESSION_CLEANER, + type ImportedSessionCleaner, +} from "../claude-cli-sessions/identifiers"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; import type { ProcessTrackingService } from "../process-tracking/process-tracking"; import { @@ -77,6 +81,8 @@ export class ArchiveService { private readonly taskMetadataRepo: ITaskMetadataRepository, @inject(WORKSPACE_SETTINGS_SERVICE) private readonly workspaceSettings: IWorkspaceSettings, + @inject(IMPORTED_SESSION_CLEANER) + private readonly importedSessionCleaner: ImportedSessionCleaner, @inject(ROOT_LOGGER) rootLogger: RootLogger, ) { @@ -502,6 +508,14 @@ export class ArchiveService { async deleteArchivedTask(taskId: string): Promise { this.log.info(`Deleting archived task ${taskId}`); + // Drop any imported CLI snapshot for this task. Best-effort: a cleanup + // failure must not block deleting the archived task. + await this.importedSessionCleaner + .deleteImportForTask(taskId) + .catch((error) => { + this.log.warn("Failed to clean up imported session", { taskId, error }); + }); + const workspace = this.workspaceRepo.findByTaskId(taskId); if (!workspace) { // Rowless channel task: its archived state lives in task_metadata. diff --git a/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.module.ts b/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.module.ts new file mode 100644 index 000000000..a1e3e7991 --- /dev/null +++ b/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.module.ts @@ -0,0 +1,15 @@ +import { ContainerModule } from "inversify"; +import { ClaudeCliSessionsServiceImpl } from "./claude-cli-sessions"; +import { + CLAUDE_CLI_SESSIONS_SERVICE, + IMPORTED_SESSION_CLEANER, +} from "./identifiers"; + +export const claudeCliSessionsModule = new ContainerModule(({ bind }) => { + bind(CLAUDE_CLI_SESSIONS_SERVICE) + .to(ClaudeCliSessionsServiceImpl) + .inSingletonScope(); + // Alias the narrow cleaner contract to the same singleton, so the workspace + // and archive services can compensate a deleted task without the full service. + bind(IMPORTED_SESSION_CLEANER).toService(CLAUDE_CLI_SESSIONS_SERVICE); +}); diff --git a/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.test.ts b/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.test.ts new file mode 100644 index 000000000..c85ca2bf3 --- /dev/null +++ b/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.test.ts @@ -0,0 +1,479 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { encodeCwdToProjectKey } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createMockClaudeSessionImportRepository } from "../../db/repositories/claude-session-import-repository.mock"; +import { ClaudeCliSessionsServiceImpl } from "./claude-cli-sessions"; + +const SOURCE_SESSION_ID = "5e4f5423-0287-4473-ae06-24df41c62993"; + +let tmpDir: string; +let homeDir: string; +let configDir: string; +let repoPath: string; +let originalHome: string | undefined; +let originalConfigDir: string | undefined; +let importRepository: ReturnType< + typeof createMockClaudeSessionImportRepository +>; +let service: ClaudeCliSessionsServiceImpl; + +function cliProjectDir(): string { + return path.join( + homeDir, + ".claude", + "projects", + encodeCwdToProjectKey(repoPath), + ); +} + +function writeSessionFile( + sessionId: string, + lines: Record[], +): string { + const dir = cliProjectDir(); + fs.mkdirSync(dir, { recursive: true }); + const filePath = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync( + filePath, + `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`, + ); + return filePath; +} + +function conversationLines( + sessionId: string, + overrides: { cwd?: string; entrypoint?: string } = {}, +): Record[] { + const cwd = overrides.cwd ?? repoPath; + const entrypoint = overrides.entrypoint ?? "cli"; + return [ + { type: "mode", mode: "normal", sessionId }, + { + type: "user", + uuid: "user-uuid-1", + sessionId, + cwd, + entrypoint, + gitBranch: "main", + message: { role: "user", content: "hello" }, + }, + { + type: "assistant", + uuid: "assistant-uuid-1", + sessionId, + cwd, + entrypoint, + gitBranch: "main", + message: { role: "assistant", content: "hi" }, + }, + { type: "ai-title", aiTitle: "Fix the login flow", sessionId }, + { type: "last-prompt", lastPrompt: "hello", sessionId }, + ]; +} + +function importedTranscriptPath(importedSessionId: string): string { + return path.join( + configDir, + "projects", + encodeCwdToProjectKey(repoPath), + `${importedSessionId}.jsonl`, + ); +} + +function importedSidecarPath(importedSessionId: string): string { + return path.join(configDir, "tasks", importedSessionId); +} + +async function importAndRecord(taskId = "task-1") { + const imported = await service.importSession({ + repoPath, + sourceSessionId: SOURCE_SESSION_ID, + }); + await service.recordImport({ + sourceSessionId: SOURCE_SESSION_ID, + importedSessionId: imported.importedSessionId, + repoPath, + taskId, + fingerprint: imported.fingerprint, + }); + return imported; +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-sessions-test-")); + homeDir = path.join(tmpDir, "home"); + configDir = path.join(tmpDir, "app-claude"); + repoPath = path.join(tmpDir, "repo"); + fs.mkdirSync(homeDir, { recursive: true }); + fs.mkdirSync(configDir, { recursive: true }); + fs.mkdirSync(repoPath, { recursive: true }); + + originalHome = process.env.HOME; + originalConfigDir = process.env.CLAUDE_CONFIG_DIR; + process.env.HOME = homeDir; + process.env.CLAUDE_CONFIG_DIR = configDir; + + importRepository = createMockClaudeSessionImportRepository(); + service = new ClaudeCliSessionsServiceImpl(importRepository); +}); + +afterEach(() => { + process.env.HOME = originalHome; + if (originalConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("listForRepo", () => { + it("returns an empty list when ~/.claude does not exist", async () => { + const result = await service.listForRepo({ repoPath }); + expect(result.sessions).toEqual([]); + }); + + it("lists a CLI session with metadata from the transcript", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + + const result = await service.listForRepo({ repoPath }); + + expect(result.sessions).toHaveLength(1); + const session = result.sessions[0]; + expect(session?.sourceSessionId).toBe(SOURCE_SESSION_ID); + expect(session?.title).toBe("Fix the login flow"); + expect(session?.lastPrompt).toBe("hello"); + expect(session?.gitBranch).toBe("main"); + expect(session?.status).toBe("new"); + expect(session?.importedTaskId).toBeNull(); + }); + + it("skips sessions whose cwd does not match the repo", async () => { + writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID, { cwd: "/somewhere/else" }), + ); + + const result = await service.listForRepo({ repoPath }); + expect(result.sessions).toEqual([]); + }); + + it("skips sessions with a non-cli entrypoint", async () => { + writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID, { entrypoint: "sdk-ts" }), + ); + + const result = await service.listForRepo({ repoPath }); + expect(result.sessions).toEqual([]); + }); + + it("skips metadata-only files with no conversation", async () => { + writeSessionFile(SOURCE_SESSION_ID, [ + { type: "mode", mode: "normal", sessionId: SOURCE_SESSION_ID }, + { + type: "ai-title", + aiTitle: "Empty", + sessionId: SOURCE_SESSION_ID, + }, + ]); + + const result = await service.listForRepo({ repoPath }); + expect(result.sessions).toEqual([]); + }); + + it("reads metadata at the end of a mid-sized (>16KB) transcript", async () => { + // Pad the conversation so the file lands in the 16KB–64KB band, where the + // head read stops short of the end. The title/last-prompt live on the final + // lines, so the scan must read the tail window to surface them. + const filler = Array.from({ length: 80 }, (_, i) => ({ + type: i % 2 === 0 ? "user" : "assistant", + uuid: `filler-${i}`, + sessionId: SOURCE_SESSION_ID, + cwd: repoPath, + entrypoint: "cli", + gitBranch: "main", + message: { + role: i % 2 === 0 ? "user" : "assistant", + content: "x".repeat(300), + }, + })); + const filePath = writeSessionFile(SOURCE_SESSION_ID, [ + { type: "mode", mode: "normal", sessionId: SOURCE_SESSION_ID }, + { + type: "user", + uuid: "user-uuid-1", + sessionId: SOURCE_SESSION_ID, + cwd: repoPath, + entrypoint: "cli", + gitBranch: "main", + message: { role: "user", content: "hello" }, + }, + ...filler, + { type: "ai-title", aiTitle: "Tail title", sessionId: SOURCE_SESSION_ID }, + { + type: "last-prompt", + lastPrompt: "final prompt", + sessionId: SOURCE_SESSION_ID, + }, + ]); + const size = fs.statSync(filePath).size; + expect(size).toBeGreaterThan(16 * 1024); + expect(size).toBeLessThan(64 * 1024); + + const result = await service.listForRepo({ repoPath }); + + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0]?.title).toBe("Tail title"); + expect(result.sessions[0]?.lastPrompt).toBe("final prompt"); + }); + + it("orders sessions newest first", async () => { + const olderId = "11111111-1111-4111-8111-111111111111"; + const newerId = "22222222-2222-4222-8222-222222222222"; + const olderPath = writeSessionFile(olderId, conversationLines(olderId)); + writeSessionFile(newerId, conversationLines(newerId)); + const past = new Date(Date.now() - 60_000); + fs.utimesSync(olderPath, past, past); + + const result = await service.listForRepo({ repoPath }); + + expect(result.sessions.map((s) => s.sourceSessionId)).toEqual([ + newerId, + olderId, + ]); + }); + + it("derives imported status when the source is unchanged", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + await importAndRecord(); + + const result = await service.listForRepo({ repoPath }); + + expect(result.sessions[0]?.status).toBe("imported"); + expect(result.sessions[0]?.importedTaskId).toBe("task-1"); + }); + + it("derives updated status when the source changed after import", async () => { + const filePath = writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID), + ); + await importAndRecord(); + + fs.appendFileSync( + filePath, + `${JSON.stringify({ + type: "user", + uuid: "user-uuid-2", + sessionId: SOURCE_SESSION_ID, + cwd: repoPath, + entrypoint: "cli", + message: { role: "user", content: "more" }, + })}\n`, + ); + + const result = await service.listForRepo({ repoPath }); + + expect(result.sessions[0]?.status).toBe("updated"); + expect(result.sessions[0]?.importedTaskId).toBe("task-1"); + }); + + it("stays imported when only the mtime changes (no content change)", async () => { + const filePath = writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID), + ); + await importAndRecord(); + + // Touch the file: bump mtime without touching its contents. + const later = new Date(Date.now() + 60_000); + fs.utimesSync(filePath, later, later); + + const result = await service.listForRepo({ repoPath }); + + expect(result.sessions[0]?.status).toBe("imported"); + }); +}); + +describe("importSession", () => { + it("copies the transcript under a fresh session id, rewriting sessionId", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + + const result = await service.importSession({ + repoPath, + sourceSessionId: SOURCE_SESSION_ID, + }); + + expect(result.importedSessionId).not.toBe(SOURCE_SESSION_ID); + const lines = fs + .readFileSync(importedTranscriptPath(result.importedSessionId), "utf-8") + .trim() + .split("\n") + .map((l) => JSON.parse(l) as { sessionId?: string }); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + if ("sessionId" in line) { + expect(line.sessionId).toBe(result.importedSessionId); + } + } + }); + + it("leaves the source file untouched", async () => { + const filePath = writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID), + ); + const before = fs.readFileSync(filePath, "utf-8"); + + await service.importSession({ + repoPath, + sourceSessionId: SOURCE_SESSION_ID, + }); + + expect(fs.readFileSync(filePath, "utf-8")).toBe(before); + }); + + it("returns a fingerprint with the last entry uuid", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + + const result = await service.importSession({ + repoPath, + sourceSessionId: SOURCE_SESSION_ID, + }); + + expect(result.fingerprint.sourceLastEntryUuid).toBe("assistant-uuid-1"); + expect(result.fingerprint.sourceSizeBytes).toBeGreaterThan(0); + }); + + it("rejects sessions whose cwd does not match the repo", async () => { + writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID, { cwd: "/somewhere/else" }), + ); + + await expect( + service.importSession({ repoPath, sourceSessionId: SOURCE_SESSION_ID }), + ).rejects.toThrow(/belongs to/); + }); + + it("rejects sessions with a non-cli entrypoint", async () => { + writeSessionFile( + SOURCE_SESSION_ID, + conversationLines(SOURCE_SESSION_ID, { entrypoint: "sdk-ts" }), + ); + + await expect( + service.importSession({ repoPath, sourceSessionId: SOURCE_SESSION_ID }), + ).rejects.toThrow(/not a CLI session/); + }); + + it("copies the tasks sidecar under the imported session id", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + const tasksDir = path.join(homeDir, ".claude", "tasks", SOURCE_SESSION_ID); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, "1.json"), JSON.stringify({ id: 1 })); + + const result = await service.importSession({ + repoPath, + sourceSessionId: SOURCE_SESSION_ID, + }); + + const copied = path.join( + importedSidecarPath(result.importedSessionId), + "1.json", + ); + expect(fs.existsSync(copied)).toBe(true); + }); +}); + +describe("deleteImportedSession", () => { + it("removes the copied transcript and task sidecar", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + const tasksDir = path.join(homeDir, ".claude", "tasks", SOURCE_SESSION_ID); + fs.mkdirSync(tasksDir, { recursive: true }); + fs.writeFileSync(path.join(tasksDir, "1.json"), JSON.stringify({ id: 1 })); + + const imported = await service.importSession({ + repoPath, + sourceSessionId: SOURCE_SESSION_ID, + }); + const transcriptPath = importedTranscriptPath(imported.importedSessionId); + const sidecarPath = importedSidecarPath(imported.importedSessionId); + expect(fs.existsSync(transcriptPath)).toBe(true); + expect(fs.existsSync(sidecarPath)).toBe(true); + + await service.deleteImportedSession({ + repoPath, + importedSessionId: imported.importedSessionId, + }); + + expect(fs.existsSync(transcriptPath)).toBe(false); + expect(fs.existsSync(sidecarPath)).toBe(false); + }); + + it("does not throw when nothing was imported", async () => { + await expect( + service.deleteImportedSession({ + repoPath, + importedSessionId: "33333333-3333-4333-8333-333333333333", + }), + ).resolves.toBeUndefined(); + }); +}); + +describe("deleteImportForTask", () => { + it("removes the snapshot and record so the source lists as new again", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + const imported = await importAndRecord(); + const transcriptPath = importedTranscriptPath(imported.importedSessionId); + expect(fs.existsSync(transcriptPath)).toBe(true); + const before = await service.listForRepo({ repoPath }); + expect(before.sessions[0]?.status).toBe("imported"); + + await service.deleteImportForTask("task-1"); + + expect(fs.existsSync(transcriptPath)).toBe(false); + const after = await service.listForRepo({ repoPath }); + expect(after.sessions[0]?.status).toBe("new"); + expect(after.sessions[0]?.importedTaskId).toBeNull(); + }); + + it("is a no-op for a task that was never imported", async () => { + await expect( + service.deleteImportForTask("unknown-task"), + ).resolves.toBeUndefined(); + }); +}); + +describe("deleteImportRecord", () => { + it("drops the tracking row but leaves the snapshot files", async () => { + writeSessionFile(SOURCE_SESSION_ID, conversationLines(SOURCE_SESSION_ID)); + const imported = await importAndRecord(); + const transcriptPath = importedTranscriptPath(imported.importedSessionId); + expect((await service.listForRepo({ repoPath })).sessions[0]?.status).toBe( + "imported", + ); + + await service.deleteImportRecord({ + importedSessionId: imported.importedSessionId, + }); + + // Row gone — source lists as new again — but the snapshot file remains, + // since removing it is the import step's own compensation. + const after = await service.listForRepo({ repoPath }); + expect(after.sessions[0]?.status).toBe("new"); + expect(after.sessions[0]?.importedTaskId).toBeNull(); + expect(fs.existsSync(transcriptPath)).toBe(true); + }); + + it("is a no-op when no row matches the imported session id", async () => { + await expect( + service.deleteImportRecord({ + importedSessionId: "44444444-4444-4444-8444-444444444444", + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.ts b/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.ts new file mode 100644 index 000000000..cf59ef2b1 --- /dev/null +++ b/packages/workspace-server/src/services/claude-cli-sessions/claude-cli-sessions.ts @@ -0,0 +1,436 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { + encodeCwdToProjectKey, + getSessionJsonlPath, +} from "@posthog/agent/adapters/claude/session/jsonl-hydration"; +import { mapWithConcurrency } from "@posthog/git/concurrency"; +import { inject, injectable } from "inversify"; +import { CLAUDE_SESSION_IMPORT_REPOSITORY } from "../../db/identifiers"; +import type { IClaudeSessionImportRepository } from "../../db/repositories/claude-session-import-repository"; +import type { ClaudeCliSessionsService } from "./identifiers"; +import type { + CliSessionFingerprint, + CliSessionSummary, + DeleteImportedCliSessionInput, + DeleteImportRecordInput, + ImportCliSessionInput, + ImportCliSessionOutput, + ListCliSessionsInput, + ListCliSessionsOutput, + RecordCliImportInput, +} from "./schemas"; + +// Bounds the head/tail reads per scan; stat-ing every file to sort by mtime is +// unaffected. Generous enough to surface a repo's full recent history while +// still capping work on pathological session counts. +const MAX_SESSIONS = 50; +const HEAD_BYTES = 16 * 1024; +const TAIL_BYTES = 64 * 1024; +const SCAN_CONCURRENCY = 8; + +interface JsonlEntry { + type?: string; + cwd?: string; + entrypoint?: string; + uuid?: string; + gitBranch?: string; + aiTitle?: string; + lastPrompt?: string; +} + +interface ScannedSession { + sourceSessionId: string; + cwd: string; + title: string | null; + lastPrompt: string | null; + mtimeMs: number; + sizeBytes: number; + gitBranch: string | null; + lastEntryUuid: string | null; +} + +function claudeCliDir(): string { + return path.join(os.homedir(), ".claude"); +} + +/** Repo paths may arrive tilde-prefixed from the renderer. */ +function expandHome(p: string): string { + return p.startsWith("~") ? path.join(os.homedir(), p.slice(1)) : p; +} + +function parseLines(chunk: string): JsonlEntry[] { + const entries: JsonlEntry[] = []; + for (const line of chunk.split("\n")) { + if (!line.trim()) continue; + try { + entries.push(JSON.parse(line) as JsonlEntry); + } catch { + // Partial line at a chunk boundary, or corrupt — skip. + } + } + return entries; +} + +async function readSlice( + filePath: string, + start: number, + length: number, +): Promise { + const handle = await fs.open(filePath, "r"); + try { + const buffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(buffer, 0, length, start); + return buffer.subarray(0, bytesRead).toString("utf-8"); + } finally { + await handle.close(); + } +} + +function isConversationEntry(entry: JsonlEntry): boolean { + return entry.type === "user" || entry.type === "assistant"; +} + +@injectable() +export class ClaudeCliSessionsServiceImpl implements ClaudeCliSessionsService { + constructor( + @inject(CLAUDE_SESSION_IMPORT_REPOSITORY) + private readonly importRepository: IClaudeSessionImportRepository, + ) {} + + async listForRepo( + input: ListCliSessionsInput, + ): Promise { + const repoPath = expandHome(input.repoPath); + const acceptedCwds = await this.acceptedCwds(repoPath); + const projectDir = path.join( + claudeCliDir(), + "projects", + encodeCwdToProjectKey(repoPath), + ); + + let fileNames: string[]; + try { + const dirents = await fs.readdir(projectDir, { withFileTypes: true }); + fileNames = dirents + .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) + .map((d) => d.name); + } catch { + // No CLI sessions recorded for this repo (or ~/.claude is absent). + return { sessions: [] }; + } + + const stats = await mapWithConcurrency( + fileNames, + SCAN_CONCURRENCY, + async (name) => { + try { + const stat = await fs.stat(path.join(projectDir, name)); + return { name, mtimeMs: stat.mtimeMs, sizeBytes: stat.size }; + } catch { + return null; + } + }, + ); + + const candidates = stats + .filter((s): s is NonNullable => s !== null) + .sort((a, b) => b.mtimeMs - a.mtimeMs) + .slice(0, MAX_SESSIONS); + + const scanned = await mapWithConcurrency( + candidates, + SCAN_CONCURRENCY, + (candidate) => + this.scanSessionFile(projectDir, candidate, acceptedCwds).catch( + () => null, + ), + ); + + const sessions = scanned.filter((s): s is ScannedSession => s !== null); + return { sessions: this.withImportStatus(sessions) }; + } + + async importSession( + input: ImportCliSessionInput, + ): Promise { + const repoPath = expandHome(input.repoPath); + const acceptedCwds = await this.acceptedCwds(repoPath); + const sourcePath = path.join( + claudeCliDir(), + "projects", + encodeCwdToProjectKey(repoPath), + `${input.sourceSessionId}.jsonl`, + ); + + const stat = await fs.stat(sourcePath); + const content = await fs.readFile(sourcePath, "utf-8"); + const importedSessionId = crypto.randomUUID(); + + let cwdValidated = false; + let lastEntryUuid: string | null = null; + const rewritten: string[] = []; + for (const line of content.split("\n")) { + if (!line.trim()) continue; + let entry: Record; + try { + entry = JSON.parse(line) as Record; + } catch { + rewritten.push(line); + continue; + } + if (!cwdValidated && typeof entry.cwd === "string") { + if (!acceptedCwds.has(entry.cwd)) { + throw new Error( + `Session ${input.sourceSessionId} belongs to ${entry.cwd}, not ${input.repoPath}`, + ); + } + // Mirror the listing filter: only a non-cli entrypoint disqualifies + // (older CLI versions omit the field). Guards the public import + // mutation against pulling in PostHog Code's own sdk-ts sessions. + if ( + typeof entry.entrypoint === "string" && + entry.entrypoint !== "cli" + ) { + throw new Error( + `Session ${input.sourceSessionId} is not a CLI session (entrypoint ${entry.entrypoint})`, + ); + } + cwdValidated = true; + } + if (typeof entry.uuid === "string") lastEntryUuid = entry.uuid; + if ("sessionId" in entry) entry.sessionId = importedSessionId; + rewritten.push(JSON.stringify(entry)); + } + if (!cwdValidated) { + throw new Error( + `Session ${input.sourceSessionId} has no cwd entry for ${input.repoPath}`, + ); + } + + const destPath = getSessionJsonlPath(importedSessionId, repoPath); + await fs.mkdir(path.dirname(destPath), { recursive: true }); + const tmpPath = `${destPath}.tmp.${Date.now()}`; + await fs.writeFile(tmpPath, `${rewritten.join("\n")}\n`); + await fs.rename(tmpPath, destPath); + + await this.copyTaskSidecar(input.sourceSessionId, importedSessionId); + + return { + importedSessionId, + fingerprint: { + sourceMtimeMs: Math.floor(stat.mtimeMs), + sourceSizeBytes: stat.size, + sourceLastEntryUuid: lastEntryUuid, + }, + }; + } + + /** + * Remove a copied transcript (and its task sidecar) from the app's config + * dir. Used to compensate an import when task creation later rolls back, so + * abandoned snapshots don't accumulate. + */ + async deleteImportedSession( + input: DeleteImportedCliSessionInput, + ): Promise { + await this.removeSnapshotFiles(input.importedSessionId, input.repoPath); + } + + /** + * Compensate a deleted task: drop its import record and the copied snapshot + * so the source session lists as `new` again (rather than `imported` with a + * dangling task). No-op when the task was never an import. + */ + async deleteImportForTask(taskId: string): Promise { + const row = this.importRepository.findByTaskId(taskId); + if (!row) return; + // Drop the row before the files: a later file-removal failure then leaves the + // source `new` (re-importable, only leaking disk) instead of stuck `imported`. + this.importRepository.deleteByTaskId(taskId); + await this.removeSnapshotFiles(row.importedSessionId, row.repoPath); + } + + /** Delete the imported transcript JSONL and its task sidecar, if present. */ + private async removeSnapshotFiles( + importedSessionId: string, + repoPath: string, + ): Promise { + const transcriptPath = getSessionJsonlPath( + importedSessionId, + expandHome(repoPath), + ); + await fs.rm(transcriptPath, { force: true }); + const configDir = process.env.CLAUDE_CONFIG_DIR; + if (configDir) { + await fs.rm(path.join(configDir, "tasks", importedSessionId), { + recursive: true, + force: true, + }); + } + } + + async recordImport(input: RecordCliImportInput): Promise { + this.importRepository.recordImport({ + sourceSessionId: input.sourceSessionId, + importedSessionId: input.importedSessionId, + taskId: input.taskId, + repoPath: input.repoPath, + sourceMtimeMs: input.fingerprint.sourceMtimeMs, + sourceSizeBytes: input.fingerprint.sourceSizeBytes, + sourceLastEntryUuid: input.fingerprint.sourceLastEntryUuid, + }); + } + + /** + * Inverse of `recordImport`: drop the tracking row for an imported snapshot. + * Compensates the record step when task creation rolls back, so no row is + * left pointing at a discarded task. Leaves the snapshot files alone — those + * are owned by the import step's own compensation. + */ + async deleteImportRecord(input: DeleteImportRecordInput): Promise { + this.importRepository.deleteByImportedSessionId(input.importedSessionId); + } + + /** The repo path plus its realpath, so symlinked checkouts still match. */ + private async acceptedCwds(repoPath: string): Promise> { + const accepted = new Set([repoPath]); + try { + accepted.add(await fs.realpath(repoPath)); + } catch { + // Repo path may not resolve (e.g. in tests); the literal path suffices. + } + return accepted; + } + + private async scanSessionFile( + projectDir: string, + candidate: { name: string; mtimeMs: number; sizeBytes: number }, + acceptedCwds: Set, + ): Promise { + const filePath = path.join(projectDir, candidate.name); + const head = parseLines( + await readSlice(filePath, 0, Math.min(HEAD_BYTES, candidate.sizeBytes)), + ); + + const firstWithCwd = head.find((e) => typeof e.cwd === "string"); + if (!firstWithCwd?.cwd || !acceptedCwds.has(firstWithCwd.cwd)) return null; + // PostHog Code's own sessions write entrypoint "sdk-ts"; older CLI + // versions omit the field, so only a non-cli value disqualifies. + if (firstWithCwd.entrypoint && firstWithCwd.entrypoint !== "cli") { + return null; + } + + // Reuse the head only when it already spans the whole file. The head + // stops at HEAD_BYTES, so for any file larger than that we must read the + // tail window (where the title/last-prompt/branch metadata lives) — for a + // file up to TAIL_BYTES that window is the remainder of the file. + const tailStart = Math.max(0, candidate.sizeBytes - TAIL_BYTES); + const tail = + candidate.sizeBytes <= HEAD_BYTES + ? head + : parseLines( + await readSlice( + filePath, + tailStart, + candidate.sizeBytes - tailStart, + ), + ); + + if (![...head, ...tail].some(isConversationEntry)) return null; + + let title: string | null = null; + let lastPrompt: string | null = null; + let gitBranch: string | null = null; + let lastEntryUuid: string | null = null; + for (let i = tail.length - 1; i >= 0; i--) { + const entry = tail[i] as JsonlEntry; + if (title === null && entry.aiTitle) title = entry.aiTitle; + if (lastPrompt === null && entry.lastPrompt) { + lastPrompt = entry.lastPrompt; + } + if (gitBranch === null && entry.gitBranch) gitBranch = entry.gitBranch; + if (lastEntryUuid === null && entry.uuid) lastEntryUuid = entry.uuid; + if (title && lastPrompt && gitBranch && lastEntryUuid) break; + } + + return { + sourceSessionId: candidate.name.replace(/\.jsonl$/, ""), + cwd: firstWithCwd.cwd, + title, + lastPrompt, + mtimeMs: candidate.mtimeMs, + sizeBytes: candidate.sizeBytes, + gitBranch, + lastEntryUuid, + }; + } + + private withImportStatus(sessions: ScannedSession[]): CliSessionSummary[] { + const rows = this.importRepository.listBySourceSessionIds( + sessions.map((s) => s.sourceSessionId), + ); + // Rows are sorted newest-first; keep the latest import per source. + const latestBySource = new Map(); + for (const row of rows) { + if (!latestBySource.has(row.sourceSessionId)) { + latestBySource.set(row.sourceSessionId, row); + } + } + + return sessions.map((session) => { + const row = latestBySource.get(session.sourceSessionId); + let status: CliSessionSummary["status"] = "new"; + if (row) { + const fingerprint: CliSessionFingerprint = { + sourceMtimeMs: Math.floor(session.mtimeMs), + sourceSizeBytes: session.sizeBytes, + sourceLastEntryUuid: session.lastEntryUuid, + }; + // Content-based divergence: size plus the last entry's uuid. mtime is + // deliberately excluded — a touch with no content change (backups, git + // operations, re-opening in the CLI) must not flip a session to + // "updated". Appending a turn changes both signals; an in-place edit + // changes the size. + const unchanged = + row.sourceSizeBytes === fingerprint.sourceSizeBytes && + row.sourceLastEntryUuid === fingerprint.sourceLastEntryUuid; + status = unchanged ? "imported" : "updated"; + } + return { + sourceSessionId: session.sourceSessionId, + cwd: session.cwd, + title: session.title, + lastPrompt: session.lastPrompt, + updatedAt: new Date(session.mtimeMs).toISOString(), + sizeBytes: session.sizeBytes, + gitBranch: session.gitBranch, + status, + importedTaskId: row?.taskId ?? null, + }; + }); + } + + private async copyTaskSidecar( + sourceSessionId: string, + importedSessionId: string, + ): Promise { + const configDir = process.env.CLAUDE_CONFIG_DIR; + if (!configDir) return; + const sourceTasksDir = path.join(claudeCliDir(), "tasks", sourceSessionId); + try { + await fs.cp( + sourceTasksDir, + path.join(configDir, "tasks", importedSessionId), + { + recursive: true, + errorOnExist: false, + force: false, + }, + ); + } catch { + // Task sidecar is optional; the transcript alone is enough to resume. + } + } +} diff --git a/packages/workspace-server/src/services/claude-cli-sessions/identifiers.ts b/packages/workspace-server/src/services/claude-cli-sessions/identifiers.ts new file mode 100644 index 000000000..ce89b1a31 --- /dev/null +++ b/packages/workspace-server/src/services/claude-cli-sessions/identifiers.ts @@ -0,0 +1,35 @@ +import type { + DeleteImportedCliSessionInput, + DeleteImportRecordInput, + ImportCliSessionInput, + ImportCliSessionOutput, + ListCliSessionsInput, + ListCliSessionsOutput, + RecordCliImportInput, +} from "./schemas"; + +export const CLAUDE_CLI_SESSIONS_SERVICE = Symbol.for( + "posthog.workspace.claudeCliSessions", +); + +export const IMPORTED_SESSION_CLEANER = Symbol.for( + "posthog.workspace.importedSessionCleaner", +); + +/** + * Narrow contract for removing a task's imported CLI snapshot + record when the + * task is deleted. Consumed by the workspace and archive services so neither + * needs the full sessions service. Implemented by ClaudeCliSessionsService. + */ +export interface ImportedSessionCleaner { + /** Remove the imported snapshot + record for a task (no-op if not imported). */ + deleteImportForTask(taskId: string): Promise; +} + +export interface ClaudeCliSessionsService extends ImportedSessionCleaner { + listForRepo(input: ListCliSessionsInput): Promise; + importSession(input: ImportCliSessionInput): Promise; + deleteImportedSession(input: DeleteImportedCliSessionInput): Promise; + recordImport(input: RecordCliImportInput): Promise; + deleteImportRecord(input: DeleteImportRecordInput): Promise; +} diff --git a/packages/workspace-server/src/services/claude-cli-sessions/schemas.ts b/packages/workspace-server/src/services/claude-cli-sessions/schemas.ts new file mode 100644 index 000000000..4a9c24951 --- /dev/null +++ b/packages/workspace-server/src/services/claude-cli-sessions/schemas.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +export const cliSessionFingerprintSchema = z.object({ + sourceMtimeMs: z.number(), + sourceSizeBytes: z.number(), + sourceLastEntryUuid: z.string().nullable(), +}); + +export const listCliSessionsInput = z.object({ + repoPath: z.string(), +}); + +export const cliSessionSummarySchema = z.object({ + sourceSessionId: z.string(), + cwd: z.string(), + title: z.string().nullable(), + lastPrompt: z.string().nullable(), + /** ISO timestamp from the source file's mtime. */ + updatedAt: z.string(), + sizeBytes: z.number(), + gitBranch: z.string().nullable(), + /** + * new: never imported. imported: snapshot matches the source. + * updated: the CLI session changed after the last import. + */ + status: z.enum(["new", "imported", "updated"]), + importedTaskId: z.string().nullable(), +}); + +export const listCliSessionsOutput = z.object({ + sessions: z.array(cliSessionSummarySchema), +}); + +export const importCliSessionInput = z.object({ + repoPath: z.string(), + /** uuid keeps the value safe to use as a path segment. */ + sourceSessionId: z.string().uuid(), +}); + +export const importCliSessionOutput = z.object({ + importedSessionId: z.string(), + fingerprint: cliSessionFingerprintSchema, +}); + +export const deleteImportedCliSessionInput = z.object({ + repoPath: z.string(), + /** uuid keeps the value safe to use as a path segment. */ + importedSessionId: z.string().uuid(), +}); + +export const deleteImportRecordInput = z.object({ + /** uuid keeps the value safe to use as a path segment. */ + importedSessionId: z.string().uuid(), +}); + +export const recordCliImportInput = z.object({ + sourceSessionId: z.string().uuid(), + /** uuid keeps the value safe to use as a path segment. */ + importedSessionId: z.string().uuid(), + repoPath: z.string(), + taskId: z.string(), + fingerprint: cliSessionFingerprintSchema, +}); + +export type CliSessionFingerprint = z.infer; +export type CliSessionSummary = z.infer; +export type ListCliSessionsInput = z.infer; +export type ListCliSessionsOutput = z.infer; +export type ImportCliSessionInput = z.infer; +export type ImportCliSessionOutput = z.infer; +export type DeleteImportedCliSessionInput = z.infer< + typeof deleteImportedCliSessionInput +>; +export type DeleteImportRecordInput = z.infer; +export type RecordCliImportInput = z.infer; diff --git a/packages/workspace-server/src/services/workspace/workspace.test.ts b/packages/workspace-server/src/services/workspace/workspace.test.ts index 20417860b..d13d543f1 100644 --- a/packages/workspace-server/src/services/workspace/workspace.test.ts +++ b/packages/workspace-server/src/services/workspace/workspace.test.ts @@ -164,6 +164,7 @@ function makeService(mocks: ReturnType): WorkspaceService { mocks.focus, mocks.workspaceSettings, mocks.analytics, + { deleteImportForTask: async () => {} }, mocks.log, ); } diff --git a/packages/workspace-server/src/services/workspace/workspace.ts b/packages/workspace-server/src/services/workspace/workspace.ts index fff1f489a..299884009 100644 --- a/packages/workspace-server/src/services/workspace/workspace.ts +++ b/packages/workspace-server/src/services/workspace/workspace.ts @@ -34,6 +34,10 @@ import { import type { IRepositoryRepository } from "../../db/repositories/repository-repository"; import type { IWorkspaceRepository } from "../../db/repositories/workspace-repository"; import type { IWorktreeRepository } from "../../db/repositories/worktree-repository"; +import { + IMPORTED_SESSION_CLEANER, + type ImportedSessionCleaner, +} from "../claude-cli-sessions/identifiers"; import { PROCESS_TRACKING_SERVICE } from "../process-tracking/identifiers"; import type { ProcessTrackingService } from "../process-tracking/process-tracking"; import { getBranchFromPath, hasAnyFiles } from "../repo-fs-query/repo-fs-query"; @@ -142,6 +146,8 @@ export class WorkspaceService extends TypedEventEmitter private readonly workspaceSettings: IWorkspaceSettings, @inject(ANALYTICS_SERVICE) private readonly analytics: IAnalytics, + @inject(IMPORTED_SESSION_CLEANER) + private readonly importedSessionCleaner: ImportedSessionCleaner, @inject(ROOT_LOGGER) logger: RootLogger, ) { @@ -780,6 +786,14 @@ export class WorkspaceService extends TypedEventEmitter async deleteWorkspace(taskId: string, mainRepoPath: string): Promise { this.log.info(`Deleting workspace for task ${taskId}`); + // Drop any imported CLI snapshot first, before the early returns below. + // Best-effort: a cleanup failure must not block deleting the task. + await this.importedSessionCleaner + .deleteImportForTask(taskId) + .catch((error) => { + this.log.warn("Failed to clean up imported session", { taskId, error }); + }); + const association = this.findTaskAssociation(taskId); if (!association) { // Repo-less channel task: no workspace row, just a scratch dir to remove. diff --git a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts index e287f2ad3..67b45244f 100644 --- a/packages/workspace-server/src/services/workspace/workspace.verify.test.ts +++ b/packages/workspace-server/src/services/workspace/workspace.verify.test.ts @@ -60,6 +60,7 @@ function createService(worktreeBasePath: string) { getWorktreeLocation: () => worktreeBasePath, } as unknown as IWorkspaceSettings, { track: vi.fn() } as unknown as IAnalytics, + { deleteImportForTask: async () => {} }, log, );