From 16fd3e3e091c9f425ba8c6a4df86b870d94cb031 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Thu, 27 Aug 2026 23:27:40 +0200 Subject: [PATCH 01/14] feat(cloud-agent-next): support multiple chats per worktree --- .specs/cloud-agent-session.md | 93 +- .../share/share-destinations.test.ts | 1 + .../mobile/src/lib/session-list-cache.test.ts | 1 + .../src/app/(app)/cloud/(agent)/layout.tsx | 7 +- .../[id]/cloud/(agent)/layout.tsx | 7 +- .../ChatInput.finalize.test.ts | 60 + .../components/cloud-agent-next/ChatInput.tsx | 35 +- .../cloud-agent-next/ChatSidebar.tsx | 458 +- .../cloud-agent-next/CloudAgentProvider.tsx | 10 +- .../CloudAgentWorkspaceTabs.test.ts | 653 + .../CloudAgentWorkspaceTabs.tsx | 622 +- .../cloud-agent-next/CloudChatPage.tsx | 184 +- .../cloud-agent-next/CloudSidebarLayout.tsx | 591 +- .../cloud-agent-next/MobileSidebarToggle.tsx | 2 +- .../cloud-agent-next/NewSessionPanel.tsx | 40 +- .../cloud-agent-next/PrHoverCardContent.tsx | 1 + .../chat-input-attachments.test.ts | 80 +- .../chat-input-attachments.ts | 27 +- .../hooks/useClosedWorktreeChatTabs.ts | 86 + .../hooks/useSidebarSessions.test.ts | 912 +- .../hooks/useSidebarSessions.ts | 396 +- .../cloud-agent-next/session-deletion.test.ts | 28 +- .../cloud-agent-next/session-deletion.ts | 1 + .../store/db-session-atoms.test.ts | 25 + .../store/db-session-atoms.ts | 3 + .../cloud-agent-next/terminal-tabs.test.ts | 102 +- .../cloud-agent-next/terminal-tabs.ts | 18 +- .../components/cloud-agent-next/types.test.ts | 67 +- .../src/components/cloud-agent-next/types.ts | 36 + .../worktree-chat-tabs.test.ts | 421 + .../cloud-agent-next/worktree-chat-tabs.ts | 150 + .../cloud-agent-client.test.ts | 338 +- .../cloud-agent-next/cloud-agent-client.ts | 91 +- .../cloud-agent-next/worktree-chat.test.ts | 274 + .../src/lib/cloud-agent-next/worktree-chat.ts | 180 + apps/web/src/lib/user/index.test.ts | 38 + apps/web/src/lib/user/index.ts | 5 + .../routers/cli-sessions-v2-router.test.ts | 340 + .../web/src/routers/cli-sessions-v2-router.ts | 348 +- .../routers/cli-sessions-v2-worktree.test.ts | 1288 + .../routers/cloud-agent-next-router.test.ts | 110 +- .../src/routers/cloud-agent-next-router.ts | 17 + .../routers/cloud-agent-next-schemas.test.ts | 61 + .../src/routers/cloud-agent-next-schemas.ts | 20 + ...ganization-cloud-agent-next-router.test.ts | 157 +- .../organization-cloud-agent-next-router.ts | 22 + packages/cloud-agent-sdk/src/schemas.test.ts | 50 +- packages/cloud-agent-sdk/src/schemas.ts | 1 + .../src/session-manager.test.ts | 60 + .../cloud-agent-sdk/src/session-manager.ts | 1 + .../src/user-web-connection.test.ts | 25 + .../src/migrations/0234_amused_sasquatch.sql | 31 + .../db/src/migrations/meta/0234_snapshot.json | 39945 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 42 + .../src/cloud-agent-session-scope.ts | 8 + .../session-ingest-contracts/src/index.ts | 7 + .../src/rpc-contract.ts | 125 + .../kilo-facade/public-sdk-projection.test.ts | 68 + .../src/kilo-facade/public-sdk-projection.ts | 54 +- .../src/kilo-facade/user-kilo-facade.test.ts | 284 +- .../src/kilo-facade/user-kilo-facade.ts | 116 + .../src/persistence/CloudAgentSession.ts | 9 + .../src/persistence/SandboxControl.ts | 505 +- .../src/persistence/session-metadata.test.ts | 36 + .../src/persistence/session-metadata.ts | 10 +- services/cloud-agent-next/src/router.ts | 4 + .../router/handlers/session-prepare.test.ts | 25 + .../src/router/handlers/session-prepare.ts | 1 + .../router/handlers/session-worktree.test.ts | 937 + .../src/router/handlers/session-worktree.ts | 600 + .../router/handlers/worktree-deletion.test.ts | 286 + .../src/router/handlers/worktree-deletion.ts | 174 + .../src/router/schemas.test.ts | 36 + .../cloud-agent-next/src/router/schemas.ts | 1 + .../cloudflare-provider.test.ts | 93 +- .../src/sandbox-control/durable-state.ts | 2 +- .../src/sandbox-control/frames.ts | 3 + .../session-credentials.test.ts | 24 +- .../sandbox-control/session-routes.test.ts | 167 +- .../src/sandbox-control/session-routes.ts | 19 +- .../src/sandbox-control/vercel-provider.ts | 11 + .../src/sandbox-control/worktree-deletion.ts | 165 + .../worktree-ownership.test.ts | 163 + .../src/sandbox-control/worktree-ownership.ts | 93 + .../src/sandbox-session/SandboxSession.ts | 532 +- .../control-plane-ingest.test.ts | 113 + .../sandbox-session/control-plane-ingest.ts | 65 +- .../src/sandbox-session/control-rpc.ts | 26 +- .../session-message-queue.test.ts | 190 +- .../sandbox-session/session-message-queue.ts | 22 +- .../src/sandbox-session/terminal-lifecycle.ts | 15 - .../src/session-service.test.ts | 82 + .../cloud-agent-next/src/session-service.ts | 26 +- .../src/session/session-prepare.test.ts | 695 +- .../src/session/session-registration.ts | 237 +- .../src/session/session-requests.ts | 1 + .../src/shared/sandbox-control-protocol.ts | 33 +- services/cloud-agent-next/src/types/ids.ts | 2 +- .../src/websocket/stream.test.ts | 265 + .../cloud-agent-next/src/workspace.test.ts | 51 + services/cloud-agent-next/src/workspace.ts | 23 +- services/cloud-agent-next/test/e2e/client.ts | 174 +- .../test/e2e/fake-llm-server.ts | 594 +- .../cloud-agent-next/test/e2e/lifecycle.ts | 962 + services/cloud-agent-next/test/e2e/run.ts | 5 +- .../test/e2e/sandbox-control.ts | 710 + .../test/integration/sandbox-control.test.ts | 2309 +- .../test/integration/sandbox-terminal.test.ts | 3 +- .../integration/worktree-deletion.test.ts | 1438 + .../test/unit/fake-llm-server.test.ts | 699 +- .../wrapper/src/control/apply-attach.test.ts | 524 +- .../wrapper/src/control/apply-attach.ts | 21 +- .../src/control/delete-worktree.test.ts | 692 + .../wrapper/src/control/delete-worktree.ts | 228 + .../wrapper/src/control/feed.test.ts | 462 +- .../wrapper/src/control/feed.ts | 39 +- .../wrapper/src/control/main.ts | 30 +- .../control/sandbox-control-handlers.test.ts | 1691 +- .../src/control/sandbox-control-handlers.ts | 319 +- .../control/sandbox-control-runtime.test.ts | 249 +- .../src/control/sandbox-control-runtime.ts | 38 +- .../src/control/session-directories.test.ts | 105 +- .../src/control/session-directories.ts | 69 +- .../src/control/terminal-runtime.test.ts | 217 +- .../wrapper/src/control/terminal-runtime.ts | 66 +- .../src/control/worktree-operations.ts | 34 + .../src/control/worktree-runtime.test.ts | 375 +- .../wrapper/src/control/worktree-runtime.ts | 63 +- .../wrapper/src/kilo-api.test.ts | 93 + .../wrapper/src/restore-session.test.ts | 84 +- .../wrapper/src/restore-session.ts | 25 +- .../src/dos/SessionAccessCacheDO.ts | 16 + .../src/dos/SessionIngestDO.test.ts | 41 +- .../session-ingest/src/dos/SessionIngestDO.ts | 157 +- .../src/dos/UserConnectionDO.test.ts | 11 + .../src/dos/UserConnectionDO.ts | 118 +- .../src/ingest/default-session-title.ts | 19 +- .../src/ingest/direct-ingest.ts | 8 +- .../src/ingest/metadata.test.ts | 99 +- .../session-ingest/src/ingest/metadata.ts | 14 +- .../src/ingest/stage-and-enqueue.ts | 18 +- .../session-ingest/src/queue-consumer.test.ts | 49 +- services/session-ingest/src/queue-consumer.ts | 14 +- .../session-ingest/src/routes/api.test.ts | 32 +- services/session-ingest/src/routes/api.ts | 4 + .../routes/cloud-agent-session-scope.test.ts | 248 +- .../src/routes/cloud-agent-session-scope.ts | 103 +- .../src/services/worktree-allocation.ts | 85 + .../src/services/worktree-deletion.test.ts | 865 + .../src/services/worktree-deletion.ts | 849 + .../session-ingest/src/session-events.test.ts | 41 +- services/session-ingest/src/session-events.ts | 2 + .../src/session-ingest-rpc.test.ts | 241 +- .../session-ingest/src/session-ingest-rpc.ts | 75 +- .../types/user-connection-protocol.test.ts | 47 + .../src/types/user-connection-protocol.ts | 1 + .../integration/worktree-deletion.test.ts | 330 + services/session-ingest/wrangler.test.jsonc | 4 + 159 files changed, 69699 insertions(+), 1082 deletions(-) create mode 100644 apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/hooks/useClosedWorktreeChatTabs.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-chat-tabs.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/worktree-chat-tabs.ts create mode 100644 apps/web/src/lib/cloud-agent-next/worktree-chat.test.ts create mode 100644 apps/web/src/lib/cloud-agent-next/worktree-chat.ts create mode 100644 apps/web/src/routers/cli-sessions-v2-worktree.test.ts create mode 100644 packages/db/src/migrations/0234_amused_sasquatch.sql create mode 100644 packages/db/src/migrations/meta/0234_snapshot.json create mode 100644 services/cloud-agent-next/src/router/handlers/session-worktree.test.ts create mode 100644 services/cloud-agent-next/src/router/handlers/session-worktree.ts create mode 100644 services/cloud-agent-next/src/router/handlers/worktree-deletion.test.ts create mode 100644 services/cloud-agent-next/src/router/handlers/worktree-deletion.ts create mode 100644 services/cloud-agent-next/src/sandbox-control/worktree-deletion.ts create mode 100644 services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts create mode 100644 services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts create mode 100644 services/cloud-agent-next/test/integration/worktree-deletion.test.ts create mode 100644 services/cloud-agent-next/wrapper/src/control/delete-worktree.test.ts create mode 100644 services/cloud-agent-next/wrapper/src/control/delete-worktree.ts create mode 100644 services/cloud-agent-next/wrapper/src/control/worktree-operations.ts create mode 100644 services/session-ingest/src/services/worktree-allocation.ts create mode 100644 services/session-ingest/src/services/worktree-deletion.test.ts create mode 100644 services/session-ingest/src/services/worktree-deletion.ts create mode 100644 services/session-ingest/test/integration/worktree-deletion.test.ts diff --git a/.specs/cloud-agent-session.md b/.specs/cloud-agent-session.md index ae2e66b3f4..03f48a2174 100644 --- a/.specs/cloud-agent-session.md +++ b/.specs/cloud-agent-session.md @@ -12,8 +12,8 @@ which implementation they are on. ## Status -Draft -- created 2026-08-24, revised 2026-08-24 after an audit against the -implementation. +Draft -- created 2026-08-24, revised 2026-08-26 for worktree navigation, grouped +browser-chat tabs, and their filesystem durability boundary. ## Conventions @@ -26,6 +26,8 @@ they appear in all capitals, as shown here. - **Session**: one chat bound to one repository and one environment. - **Environment**: the isolated workspace where the agent runs. +- **Worktree**: a group of independent browser chats that share the same + checked-out repository files while their environment exists. - **Preparation**: visible setup of that environment -- clone, branch, setup commands, restore. - **Turn**: one user message and the agent work it triggers. @@ -108,6 +110,72 @@ repository. the chat, and close it. A read-only session MUST NOT offer terminals. 2. A pull request the session opened MUST be visible with its current state. +### Shared Worktrees + +1. Eligible browser chats for the same worktree MUST appear as exactly one + selectable row in the sidebar, without nested chat rows. Its label MUST use + the user-set worktree name, otherwise the first surviving chat's non-default + title, otherwise the repository and branch. This fallback MUST NOT depend on + which chats are visible in the current search or recent-session page. + Selecting the worktree MUST open its latest chat. The row MUST remain visibly + selected while any sibling is active and MUST reflect busy, question, or + permission activity across all its chats. Its timestamp and overflow menu + MUST share the session-row action slot. Associated pull-request information + MUST use the existing session indicator and refresh behavior. +2. Each open chat in the selected worktree MUST appear as a distinct tab in the + main chat header. Each tab MUST reflect its chat's live title, status, and + progress, with only an **X** close action and no overflow menu. Double-clicking + or double-tapping a tab MUST rename it inline; keyboard rename MUST remain + available. Selecting a tab MUST open that chat's own address and transcript. + The header MUST offer a split **+** / downward-chevron control: **+** opens a + new chat, and the chevron offers **New chat** and eligible **New terminal** + actions. Creation controls and the session list MUST remain accessible when + tabs overflow, without a vertical scrollbar in the tab strip. +3. The worktree-row action **New chat**, the header action **New chat**, and an + eligible chat's exact trimmed `/new` command MUST immediately open a new + empty chat in the same worktree without stopping, waiting for, or adding a + message to an already-running chat. The composer MUST suggest `/new` while + typing. Header and worktree-row actions MUST add a tab; `/new` MUST replace + the current tab in the same position only after creation succeeds, preserving + the previous chat. +4. A failed new-chat action MUST keep the current chat and tab open, restore its + composer, and show a retryable error. Attachments MUST NOT be discarded. +5. Grouped chats MUST see the same uncommitted files and Git changes while their + shared environment exists. They MAY run simultaneously; concurrent file or + Git changes are visible shared-state races. +6. Each chat MUST keep its own streamed output, transcript, questions, + permissions, stop control, and recovery. Stopping or answering one chat MUST + NOT affect another chat in the worktree. +7. Grouped chats MUST NOT create platform-managed auto-commits. An agent MAY + still perform explicit Git operations during its chat. +8. Deleting one chat MUST leave its siblings usable. Deleting the final chat + MUST remove the worktree group from the session list. +9. Existing ungrouped and legacy chats MUST remain individual sidebar rows and + retain their current behavior. Worktree grouping MUST NOT change + native-mobile chat behavior. +10. Closing a chat tab MUST NOT delete its saved session, discard its transcript, + or stop its running work. Closed-tab preferences and tab order MUST survive + refresh and remain scoped to the current user and personal or organization + context. +11. The selected worktree MUST offer a **Sessions** list inside the split control's + chevron menu, containing only closed chats and excluding already-open tabs. + Most recently closed chats MUST appear first, including after refresh; + session creation or activity timestamps MUST NOT determine this order. + Selecting a closed chat MUST reopen it and remove it from that list. Closing + the last chat MUST retain the selected worktree and access to its saved + sessions and new-chat action; existing terminal tabs MUST remain usable. +12. The user MUST be able to rename a worktree independently of its chats. + A custom name MUST survive refresh and new sibling creation without changing + chat titles or activity timestamps. Later chat-title changes MUST NOT + override a custom worktree name. +13. Deleting a worktree MUST require confirmation and remove all its chats, + descendants, saved content, terminals, checkout files, and associated runtime + state. It MUST preserve other worktrees and destroy a physical sandbox only + when that sandbox is exclusively owned by the deleted worktree. Ownership + and current personal or organization access MUST be checked server-side. + Incomplete cleanup MUST remain recoverable, MUST NOT report success, and + MUST NOT permit late creation or ingestion to resurrect the worktree. + ### Persistence 1. Refresh MUST restore the transcript, the preparation history that was shown, @@ -115,6 +183,9 @@ repository. 2. Refresh MUST NOT start a new turn. 3. If the environment dies, the next prompt MUST recover in the same chat. The user MUST NOT be forced to start a new session. +4. Recovery MUST preserve each worktree chat, its transcript, and its grouping. + Uncommitted files are not guaranteed to survive replacement of the shared + physical environment. ### Errors @@ -136,11 +207,11 @@ repository. ## Out of Scope -The Cloud pages around the chat (sessions list, MCP Gateway, triggers, -webhooks), profile and organization administration, the mobile app, and the CLI. -Also out of scope, and deliberately so: which sandbox provider runs the -environment, the shape of session ids, and the streaming wire protocol. A tester -MUST NOT be able to tell those apart from the chat. +Cloud administration surfaces beyond the existing chat sidebar (MCP Gateway, +triggers, webhooks), profile and organization administration, the mobile app, +and the CLI. Also out of scope, and deliberately so: which sandbox provider +runs the environment, the shape of session ids, and the streaming wire protocol. +A tester MUST NOT be able to tell those apart from the chat. ## Not Yet Implemented @@ -157,6 +228,14 @@ The following use SHOULD and are not enforced today: ## Changelog +### 2026-08-26 -- Shared worktree pilot + +- Defined selectable worktree sidebar rows, live per-chat header tabs, + concurrent sibling creation, shared files, independent chat controls, + disabled auto-commit, and group deletion. +- Defined transcript and group recovery separately from physical filesystem + durability without changing terminal or permission requirements. + ### 2026-08-24 -- Audit revision - Corrected: a failing setup command fails the turn on a rebuild too, not only diff --git a/apps/mobile/src/components/share/share-destinations.test.ts b/apps/mobile/src/components/share/share-destinations.test.ts index 8e9fefacb0..2a8748e990 100644 --- a/apps/mobile/src/components/share/share-destinations.test.ts +++ b/apps/mobile/src/components/share/share-destinations.test.ts @@ -9,6 +9,7 @@ function session(id: string, over: Partial = {}): StoredSession { session_id: id, title: id, cloud_agent_session_id: null, + cloud_agent_worktree_id: null, parent_session_id: null, organization_id: null, created_on_platform: 'cloud-agent', diff --git a/apps/mobile/src/lib/session-list-cache.test.ts b/apps/mobile/src/lib/session-list-cache.test.ts index 6fa6bfec97..afbaf456ee 100644 --- a/apps/mobile/src/lib/session-list-cache.test.ts +++ b/apps/mobile/src/lib/session-list-cache.test.ts @@ -14,6 +14,7 @@ function makeSession( session_id: 's1', title: 'Untitled', cloud_agent_session_id: null, + cloud_agent_worktree_id: null, parent_session_id: null, organization_id: null, created_on_platform: 'cli', diff --git a/apps/web/src/app/(app)/cloud/(agent)/layout.tsx b/apps/web/src/app/(app)/cloud/(agent)/layout.tsx index 3d6f7a3cac..a68e15cc4f 100644 --- a/apps/web/src/app/(app)/cloud/(agent)/layout.tsx +++ b/apps/web/src/app/(app)/cloud/(agent)/layout.tsx @@ -1,12 +1,15 @@ import { Suspense } from 'react'; import { CloudAgentProvider } from '@/components/cloud-agent-next/CloudAgentProvider'; import { CloudSidebarLayout } from '@/components/cloud-agent-next/CloudSidebarLayout'; +import { getUserFromAuthOrRedirect } from '@/lib/user/server'; + +export default async function CloudAgentLayout({ children }: { children: React.ReactNode }) { + const user = await getUserFromAuthOrRedirect(); -export default function CloudAgentLayout({ children }: { children: React.ReactNode }) { return ( Loading...}> - {children} + {children} ); diff --git a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/layout.tsx index f7f605f547..443ab50578 100644 --- a/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/cloud/(agent)/layout.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react'; import { CloudAgentProvider } from '@/components/cloud-agent-next/CloudAgentProvider'; import { CloudSidebarLayout } from '@/components/cloud-agent-next/CloudSidebarLayout'; +import { getUserFromAuthOrRedirect } from '@/lib/user/server'; export default async function OrgCloudAgentLayout({ children, @@ -9,13 +10,15 @@ export default async function OrgCloudAgentLayout({ children: React.ReactNode; params: Promise<{ id: string }>; }) { - const { id } = await params; + const [{ id }, user] = await Promise.all([params, getUserFromAuthOrRedirect()]); const organizationId = decodeURIComponent(id); return ( Loading...}> - {children} + + {children} + ); diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.finalize.test.ts b/apps/web/src/components/cloud-agent-next/ChatInput.finalize.test.ts index adf5487cfd..498986011b 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.finalize.test.ts +++ b/apps/web/src/components/cloud-agent-next/ChatInput.finalize.test.ts @@ -258,6 +258,66 @@ describe('ChatInput finalize failure', () => { } }); + it.each([true, false])( + 'routes /new locally and rejects files before finalization with attachments=%s', + async hasAttachments => { + const upload = buildMockUpload({ + attachments: hasAttachments + ? [ + { + id: 'attachment', + file: new File(['notes'], 'notes.txt', { type: 'text/plain' }), + contentType: 'text/plain', + kind: 'document', + status: 'complete', + progress: 100, + r2Key: 'owner/cloud-agent/message/notes.txt', + }, + ] + : [], + }); + mockedUseCloudAgentAttachmentUpload.mockReturnValue(upload); + const onSend = jest.fn(async () => true); + const onNewChat = jest.fn(async () => true); + const dom = installLinkedomDom(); + let root: Root | undefined; + try { + act(() => { + root = createRoot(dom.container); + root.render( + createElement(ChatInput, { + onSend, + onNewChat, + initialValue: '/new ', + attachmentUploadOptions: { messageUuid: 'test-message-uuid' }, + }) + ); + }); + await act(async () => { + pressEnter(dom.container); + }); + + expect(onSend).not.toHaveBeenCalled(); + if (hasAttachments) { + expect(upload.finalizeAttachments).not.toHaveBeenCalled(); + expect(onNewChat).not.toHaveBeenCalled(); + expect(upload.removeAttachment).not.toHaveBeenCalled(); + expect(toastError).toHaveBeenCalledWith('Files cannot be attached to slash commands', { + description: 'Remove the files or type a plain prompt instead.', + }); + expect(dom.container.querySelector('textarea')?.value).toBe('/new '); + } else { + expect(upload.finalizeAttachments).toHaveBeenCalledTimes(1); + expect(onNewChat).toHaveBeenCalledTimes(1); + expect(dom.container.querySelector('textarea')?.value).toBe(''); + } + } finally { + act(() => root?.unmount()); + dom.cleanup(); + } + } + ); + it('ignores a second Enter while the link RPC is still in flight', async () => { let resolveFinalize!: (value: CloudAgentAttachments | undefined) => void; const finalizeAttachments = jest.fn( diff --git a/apps/web/src/components/cloud-agent-next/ChatInput.tsx b/apps/web/src/components/cloud-agent-next/ChatInput.tsx index e108deab32..754955141e 100644 --- a/apps/web/src/components/cloud-agent-next/ChatInput.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatInput.tsx @@ -32,7 +32,9 @@ import { import type { AgentMode } from './types'; import { acceptedSubmissionAttachmentIdsToRemove, + getChatInputPresentationCommands, hasSubmissionAttachmentPayload, + isWorktreeNewChatCommand, shouldRejectAttachedSlashCommand, } from './chat-input-attachments'; @@ -49,6 +51,7 @@ type ChatInputProps = { args: string, attachments?: CloudAgentAttachments ) => Promise; + onNewChat?: () => Promise; onStop?: () => void; disabled?: boolean; isStreaming?: boolean; @@ -97,6 +100,7 @@ type ChatInputProps = { export function ChatInput({ onSend, onSendCommand, + onNewChat, onStop, disabled = false, isStreaming = false, @@ -189,6 +193,8 @@ export function ChatInput({ if (attachmentSubmissionPendingRef.current) return false; if (attachmentsEnabled && attachmentUpload.hasUploadingAttachments) return false; + const createsWorktreeChat = isWorktreeNewChatCommand(trimmed, onNewChat !== undefined); + // Re-match against the trimmed value at submit time, and reject a slash // command carrying files BEFORE finalizing the attachment ledger: a // client-rejected send must leave its rows pending so the reaper can @@ -206,7 +212,8 @@ export function ChatInput({ shouldRejectAttachedSlashCommand( trimmed, slashCommands, - attachmentsEnabled && attachmentUpload.attachments.length > 0 + attachmentsEnabled && attachmentUpload.attachments.length > 0, + createsWorktreeChat ) ) { toast.error('Files cannot be attached to slash commands', { @@ -244,7 +251,9 @@ export function ChatInput({ } try { - if (slashCommand && onSendCommand) { + if (createsWorktreeChat && onNewChat) { + accepted = await onNewChat(); + } else if (slashCommand && onSendCommand) { accepted = await onSendCommand(slashCommand.command, slashCommand.args, attachmentsData); } else { accepted = await onSend(trimmed, attachmentsData); @@ -272,6 +281,7 @@ export function ChatInput({ disabled, onSend, onSendCommand, + onNewChat, setAttachmentSubmissionPending, setInputValue, slashCommands, @@ -308,6 +318,11 @@ export function ChatInput({ [sendMessage, setInputValue] ); + const presentationSlashCommands = useMemo( + () => getChatInputPresentationCommands(slashCommands, onNewChat !== undefined), + [slashCommands, onNewChat] + ); + const { showAutocomplete, selectedIndex, @@ -317,7 +332,7 @@ export function ChatInput({ setShowAutocomplete, } = useSlashCommandAutocomplete({ value, - slashCommands, + slashCommands: presentationSlashCommands, onSelect: handleSelectCommand, listRef: commandListRef, }); @@ -325,6 +340,16 @@ export function ChatInput({ const handleKeyDown = (e: KeyboardEvent) => { if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return; + if ( + e.key === 'Enter' && + !e.shiftKey && + isWorktreeNewChatCommand(valueRef.current, onNewChat !== undefined) + ) { + e.preventDefault(); + handleSend(); + return; + } + if (handleAutocompleteKeyDown(e)) return; if (e.key === 'Enter' && !e.shiftKey) { @@ -610,9 +635,9 @@ export function ChatInput({ )} - {slashCommands.length > 0 && ( + {presentationSlashCommands.length > 0 && (
- +
)}
diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index abc3894285..3d10a32b15 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { SquarePen, Search, @@ -10,6 +10,7 @@ import { X, Pencil, LoaderCircle, + Plus, } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { TimeAgo } from '@/components/shared/TimeAgo'; @@ -18,8 +19,15 @@ import { SessionStatusIndicator, } from '@/components/shared/SessionStatusIndicator'; import { usePathname, useRouter } from 'next/navigation'; -import { isToday, isYesterday, startOfDay, differenceInCalendarDays, format } from 'date-fns'; import type { StoredSession } from './types'; +import { + getSidebarWorktreeActivity, + getSidebarWorktreeLabel, + getSidebarWorktreePrSession, + groupSidebarSessionsByDate, + type SidebarWorktreeDetails, + type SidebarWorktreeGroup, +} from './hooks/useSidebarSessions'; import { SessionPrIndicator } from './SessionPrIndicator'; import { isNewSession } from '@/lib/cloud-agent/session-type'; import { cn } from '@/lib/utils'; @@ -33,67 +41,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -type DateGroup = { - label: string; - sessions: StoredSession[]; -}; - -function groupSessionsByDate(sessions: StoredSession[]): DateGroup[] { - const today: StoredSession[] = []; - const yesterday: StoredSession[] = []; - const namedDayBuckets = new Map(); - const older: StoredSession[] = []; - - const now = new Date(); - const todayStart = startOfDay(now); - - for (const session of sessions) { - const date = new Date(session.updatedAt); - if (isToday(date)) { - today.push(session); - } else if (isYesterday(date)) { - yesterday.push(session); - } else { - const daysAgo = differenceInCalendarDays(todayStart, startOfDay(date)); - if (daysAgo <= 7) { - const dayName = format(date, 'EEEE'); - const bucket = namedDayBuckets.get(dayName); - if (bucket) { - bucket.sessions.push(session); - } else { - namedDayBuckets.set(dayName, { daysAgo, sessions: [session] }); - } - } else { - older.push(session); - } - } - } - - const groups: DateGroup[] = []; - if (today.length > 0) groups.push({ label: 'Today', sessions: today }); - if (yesterday.length > 0) groups.push({ label: 'Yesterday', sessions: yesterday }); - - const sortedNamedDays = [...namedDayBuckets.entries()].sort( - (a, b) => a[1].daysAgo - b[1].daysAgo - ); - - const MAX_NAMED_DAYS = 3; - for (const [i, [dayName, bucket]] of sortedNamedDays.entries()) { - if (i < MAX_NAMED_DAYS) { - groups.push({ label: dayName, sessions: bucket.sessions }); - } else { - older.push(...bucket.sessions); - } - } - - if (older.length > 0) { - older.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); - groups.push({ label: 'Older', sessions: older }); - } - - return groups; -} - type ActiveSession = { id: string; status: string; @@ -106,10 +53,18 @@ type ActiveSession = { type ChatSidebarProps = { sessions: StoredSession[]; currentSessionId?: string; + selectedWorktreeId?: string | null; organizationId?: string; + onOpenSession?: (sessionId: string) => void; onDeleteSession?: (sessionId: string) => void; deletingSessionIds?: string[]; onRenameSession?: (sessionId: string, title: string) => Promise; + onCreateWorktreeChat?: (sourceKiloSessionId: string) => Promise; + creatingWorktreeSourceSessionId?: string | null; + worktreeDetails?: Record; + onRenameWorktree?: (worktreeId: string, name: string) => Promise; + onDeleteWorktree?: (worktreeId: string) => void; + deletingWorktreeId?: string; isInSheet?: boolean; activeSessions?: ActiveSession[]; searchQuery?: string; @@ -206,7 +161,7 @@ function SessionRow({ <> {session.prompt} - + {isDeleting ? ( @@ -229,7 +184,7 @@ function SessionRow({ ) : ( @@ -239,15 +194,17 @@ function SessionRow({ {!isDeleting && (onDeleteSession || onStartRename) && ( @@ -288,6 +245,271 @@ function SessionRow({ ); } +function WorktreeGroupRow({ + group, + currentSessionId, + selectedWorktreeId, + onOpenSession, + onCreateWorktreeChat, + creatingWorktreeSourceSessionId, + onRenameWorktree, + onDeleteWorktree, + isDeleting, + activeSessionStatuses, +}: { + group: SidebarWorktreeGroup; + currentSessionId?: string; + selectedWorktreeId?: string | null; + onOpenSession: (sessionId: string) => void; + onCreateWorktreeChat?: (sourceKiloSessionId: string) => Promise; + creatingWorktreeSourceSessionId?: string | null; + onRenameWorktree?: (worktreeId: string, name: string) => Promise; + onDeleteWorktree?: (worktreeId: string) => void; + isDeleting: boolean; + activeSessionStatuses: ReadonlyMap; +}) { + const [hovered, setHovered] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editName, setEditName] = useState(''); + const [renameError, setRenameError] = useState(null); + const [isSavingRename, setIsSavingRename] = useState(false); + const inputRef = useRef(null); + const openButtonRef = useRef(null); + const editingRef = useRef(false); + const savingRenameRef = useRef(false); + const restoreFocusRef = useRef(false); + const renameErrorId = useId(); + const label = getSidebarWorktreeLabel(group); + const prSession = getSidebarWorktreePrSession(group); + const activity = getSidebarWorktreeActivity(group.sessions, activeSessionStatuses); + const shouldReplaceTime = activity.isLive || activity.status !== null; + const hasActions = Boolean(onCreateWorktreeChat || onRenameWorktree || onDeleteWorktree); + const isActive = + selectedWorktreeId === group.worktreeId || + group.sessions.some(session => session.sessionId === currentSessionId); + const isCreatingThisGroup = group.sessions.some( + session => session.sessionId === creatingWorktreeSourceSessionId + ); + const isCreationPending = creatingWorktreeSourceSessionId != null; + const showActions = hasActions && (hovered || menuOpen || isCreatingThisGroup); + + useEffect(() => { + if (isEditing) { + inputRef.current?.focus(); + inputRef.current?.select(); + } else if (restoreFocusRef.current) { + restoreFocusRef.current = false; + openButtonRef.current?.focus({ preventScroll: true }); + } + }, [isEditing]); + + useEffect(() => { + if (renameError) inputRef.current?.focus(); + }, [renameError]); + + const finishRename = () => { + editingRef.current = false; + restoreFocusRef.current = document.activeElement === inputRef.current; + setIsEditing(false); + setRenameError(null); + }; + + const saveRename = async () => { + if (!editingRef.current || !onRenameWorktree || savingRenameRef.current || isDeleting) return; + const name = editName.trim(); + if (!name || name === label) { + finishRename(); + return; + } + + savingRenameRef.current = true; + setIsSavingRename(true); + setRenameError(null); + try { + await onRenameWorktree(group.worktreeId, name); + finishRename(); + } catch { + setRenameError('Failed to rename worktree. Please try again.'); + } finally { + savingRenameRef.current = false; + setIsSavingRename(false); + } + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + aria-busy={isDeleting || isSavingRename || isCreatingThisGroup || undefined} + className={cn( + 'hover:bg-accent rounded-lg text-sm transition-colors', + isDeleting && 'cursor-wait opacity-60', + isActive && 'bg-accent font-medium' + )} + > +
+ {isEditing ? ( + <> + { + setEditName(event.target.value); + setRenameError(null); + }} + onBlur={() => void saveRename()} + onKeyDown={event => { + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + void saveRename(); + } else if (event.key === 'Escape') { + event.preventDefault(); + if (!savingRenameRef.current) finishRename(); + } + }} + className="bg-muted focus:ring-ring min-w-0 flex-1 rounded px-1 py-0.5 text-sm leading-snug outline-none focus:ring-1" + /> + {(isSavingRename || isDeleting) && ( + + )} + + ) : ( + <> + + {prSession && } + + {isDeleting ? ( + + ) : shouldReplaceTime ? ( + + {activity.status && ( + + )} + + ) : ( + + + + )} + {!isDeleting && hasActions && ( + + + + + + { + if (editingRef.current) event.preventDefault(); + }} + > + {onCreateWorktreeChat && ( + { + void onCreateWorktreeChat(group.latestSession.sessionId); + }} + > + + New chat + + )} + {onRenameWorktree && ( + { + editingRef.current = true; + setEditName(label); + setRenameError(null); + setIsEditing(true); + setMenuOpen(false); + }} + > + + Rename worktree + + )} + {onDeleteWorktree && ( + onDeleteWorktree(group.worktreeId)} + > + + Delete worktree + + )} + + + + )} + + + )} +
+ {isEditing && renameError && ( + + )} +
+ ); +} + const PLATFORM_FILTERS = [ 'cloud-agent', 'extension', @@ -322,10 +544,18 @@ function platformFilterLabel(p: string): string { export function ChatSidebar({ sessions, currentSessionId, + selectedWorktreeId, organizationId, + onOpenSession, onDeleteSession, deletingSessionIds, onRenameSession, + onCreateWorktreeChat, + creatingWorktreeSourceSessionId, + worktreeDetails = {}, + onRenameWorktree, + onDeleteWorktree, + deletingWorktreeId, isInSheet = false, activeSessions = [], searchQuery = '', @@ -377,6 +607,12 @@ export function ChatSidebar({ const handleSessionClick = useCallback( (sessionId: string) => { + if (onOpenSession) { + onOpenSession(sessionId); + onMobileSheetOpenChange?.(false); + return; + } + const targetUrl = `${chatPath}?sessionId=${sessionId}`; // When already on the chat page viewing a new-format session, update the // URL via pushState to avoid a full server-component re-execution which @@ -389,7 +625,7 @@ export function ChatSidebar({ } onMobileSheetOpenChange?.(false); }, - [chatPath, pathname, router, onMobileSheetOpenChange] + [chatPath, pathname, router, onOpenSession, onMobileSheetOpenChange] ); const toggleSearch = useCallback(() => { @@ -401,7 +637,14 @@ export function ChatSidebar({ }); }, [onSearchChange]); - const activeSessionIds = new Set(activeSessions.map(s => s.id)); + const activeSessionIds = useMemo( + () => new Set(activeSessions.map(session => session.id)), + [activeSessions] + ); + const activeSessionStatuses = useMemo( + () => new Map(activeSessions.map(session => [session.id, session.status])), + [activeSessions] + ); const liveOnlySessions = activeSessions.filter( activeS => !sessions.some(s => s.sessionId === activeS.id) @@ -409,7 +652,42 @@ export function ChatSidebar({ const hasActiveFilter = (platformFilter?.length ?? 0) > 0 || (projectFilter?.length ?? 0) > 0; - const dateGroups = useMemo(() => groupSessionsByDate(sessions), [sessions]); + const dateGroups = useMemo( + () => groupSidebarSessionsByDate(sessions, undefined, worktreeDetails), + [sessions, worktreeDetails] + ); + const renderSession = useCallback( + (session: StoredSession) => ( + handleStartRename(session) : undefined} + isDeleting={deletingSessionIds?.includes(session.sessionId) ?? false} + isEditing={editingSessionId === session.sessionId} + editTitle={editTitle} + onEditTitleChange={setEditTitle} + onSaveRename={handleSaveRename} + onCancelRename={handleCancelRename} + onClick={() => handleSessionClick(session.sessionId)} + /> + ), + [ + activeSessionIds, + currentSessionId, + deletingSessionIds, + editingSessionId, + editTitle, + handleCancelRename, + handleSaveRename, + handleSessionClick, + handleStartRename, + onDeleteSession, + onRenameSession, + ] + ); return (
@@ -600,23 +878,25 @@ export function ChatSidebar({ > {group.label}
- {group.sessions.map(session => ( - handleStartRename(session) : undefined} - isEditing={editingSessionId === session.sessionId} - editTitle={editTitle} - onEditTitleChange={setEditTitle} - onSaveRename={handleSaveRename} - onCancelRename={handleCancelRename} - onClick={() => handleSessionClick(session.sessionId)} - /> - ))} + {group.items.map(item => { + if (item.type === 'session') return renderSession(item.session); + + return ( + + ); + })}
))} diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index 603b39c7ed..8a22138cf1 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -158,12 +158,17 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi api: { send: async input => { + const activeSession = managerRef.current + ? storeRef.current.get(managerRef.current.atoms.fetchedSessionData) + : null; + const autoCommit = + activeSession?.cloudAgentSessionId !== input.sessionId || !activeSession.worktreeId; if (organizationId) { return trpcClient.organizations.cloudAgentNext.sendMessage.mutate( { cloudAgentSessionId: input.sessionId, payload: input.payload, - autoCommit: true, + autoCommit, organizationId, messageId: input.messageId, attachments: input.attachments ?? input.images, @@ -175,7 +180,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi { cloudAgentSessionId: input.sessionId, payload: input.payload, - autoCommit: true, + autoCommit, messageId: input.messageId, attachments: input.attachments ?? input.images, }, @@ -311,6 +316,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi organizationId: sessionResult.organization_id, gitUrl: sessionResult.git_url, gitBranch: rs?.upstreamBranch ?? sessionResult.git_branch, + worktreeId: sessionResult.cloud_agent_worktree_id ?? null, mode: normalizeAlias(rs?.mode), model: rs?.model ?? null, variant: rs?.variant ?? null, diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts new file mode 100644 index 0000000000..fb649b8167 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts @@ -0,0 +1,653 @@ +import { describe, expect, it } from '@jest/globals'; +import React, { createElement, type ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { Button } from '@/components/ui/button'; +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import type * as DropdownMenuComponents from '@/components/ui/dropdown-menu'; +import type * as DropdownMenuPrimitives from '@radix-ui/react-dropdown-menu'; +import { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs'; +import { CHAT_TAB_ID, terminalTabId, type WorkspaceTabId } from './terminal-tabs'; +import type { StoredSession } from './types'; + +Object.assign(globalThis, { React }); + +let mockRenderOpenMenus = false; + +jest.mock('@/components/ui/button', () => { + const actual = jest.requireActual<{ Button: typeof Button }>('@/components/ui/button'); + return { ...actual, Button: jest.fn(actual.Button) }; +}); + +jest.mock('@/components/ui/dropdown-menu', () => { + const actual = jest.requireActual('@/components/ui/dropdown-menu'); + const primitives = jest.requireActual( + '@radix-ui/react-dropdown-menu' + ); + const { createElement } = jest.requireActual('react'); + + return { + ...actual, + DropdownMenu: (props: ComponentProps) => + createElement(actual.DropdownMenu, { ...props, defaultOpen: mockRenderOpenMenus }), + DropdownMenuContent: (props: ComponentProps) => + createElement(primitives.Content, props), + DropdownMenuItem: jest.fn(actual.DropdownMenuItem), + }; +}); + +type WorkspaceTabsProps = ComponentProps; + +function makeSession( + sessionId: string, + title: string, + overrides: Partial = {} +): StoredSession { + return { + sessionId, + repository: 'kilo/repository', + prompt: title, + mode: 'code', + model: 'test-model', + status: 'active', + createdAt: '2026-08-26T10:00:00.000Z', + updatedAt: '2026-08-26T10:00:00.000Z', + messages: [], + cloudAgentSessionId: `agent_${sessionId}`, + worktreeId: 'worktree_shared', + ...overrides, + }; +} + +function renderWorkspaceTabs( + overrides: Partial = {}, + openMenus = false +): string { + mockRenderOpenMenus = openMenus; + jest.mocked(Button).mockClear(); + jest.mocked(DropdownMenuItem).mockClear(); + const session = makeSession('ses_first', 'First worktree chat'); + const props: WorkspaceTabsProps = { + activeTabId: CHAT_TAB_ID, + chatSessions: [session], + currentSessionId: session.sessionId, + onSelectChat: () => undefined, + onCloseChat: () => undefined, + onCreateChat: () => undefined, + onRenameChat: async () => undefined, + terminals: [], + terminalStatuses: {}, + canCreateTerminal: false, + onSelectTab: () => undefined, + onCreateTerminal: () => undefined, + onCloseTerminal: () => undefined, + ...overrides, + }; + + return renderToStaticMarkup(createElement(CloudAgentWorkspaceTabs, props)); +} + +function findButtonMarkup(html: string, text: string): string { + const buttons = html.match(/]*>[\s\S]*?<\/button>/g) ?? []; + const button = buttons.find(markup => markup.includes(text)); + expect(button).toBeDefined(); + return button ?? ''; +} + +function findMenuItemMarkup(html: string, text: string): string { + const items = html.match(/]*role="menuitem"[^>]*>[\s\S]*?<\/div>/g) ?? []; + const item = items.find(markup => markup.includes(text)); + expect(item).toBeDefined(); + return item ?? ''; +} + +function getButtonProps(label: string): ComponentProps { + const props = jest + .mocked(Button) + .mock.calls.find(([props]) => props['aria-label'] === label)?.[0]; + if (!props) throw new Error(`Button not rendered: ${label}`); + return props; +} + +function getSessionMenuItemProps(title: string): ComponentProps { + const props = jest + .mocked(DropdownMenuItem) + .mock.calls.find(([props]) => props.textValue === title)?.[0]; + if (!props) throw new Error(`Session menu item not rendered: ${title}`); + return props; +} + +describe('CloudAgentWorkspaceTabs', () => { + it('renders complete grouped chat titles and selects only the current session tab', () => { + const firstTitle = 'Investigate the complete authentication regression across every provider'; + const secondTitle = 'Fix the separate billing synchronization flow'; + const first = makeSession('ses_first', firstTitle, { + associatedPr: { + url: 'https://github.com/kilo/repository/pull/42', + number: 42, + state: 'open', + title: 'Authentication fix', + headSha: 'abc123', + lastSyncedAt: '2026-08-26T10:00:00.000Z', + reviewDecision: null, + reviewDecisionPending: false, + platform: 'github', + }, + }); + const second = makeSession('ses_second', secondTitle, { branch: 'feature/billing' }); + const html = renderWorkspaceTabs({ + chatSessions: [first, second], + currentSessionId: second.sessionId, + }); + + const firstChatTrigger = findButtonMarkup(html, firstTitle); + + expect(html).toContain('role="tablist"'); + expect(html).toContain(firstTitle); + expect(html).toContain(secondTitle); + expect(firstChatTrigger).toContain('aria-selected="false"'); + expect(firstChatTrigger).toContain('data-state="inactive"'); + expect(findButtonMarkup(html, secondTitle)).toContain('aria-selected="true"'); + expect(findButtonMarkup(html, secondTitle)).toContain('data-state="active"'); + expect(html).toContain('aria-label="open pull request #42"'); + expect(html).not.toContain('animate-pulse'); + expect(firstChatTrigger).not.toContain('aria-label="open pull request #42"'); + expect(html).not.toMatch(/]*>(?:(?!<\/button>)[\s\S])* { + const first = makeSession('ses_first', 'First chat'); + const second = makeSession('ses_second', 'Second chat'); + const third = makeSession('ses_third', 'Third chat'); + const foreign = makeSession('ses_foreign', 'Foreign chat', { worktreeId: 'worktree_other' }); + + for (const chatSessions of [ + [first, second, third, foreign], + [second, foreign, third, first], + ]) { + const html = renderWorkspaceTabs({ + chatSessions, + currentSessionId: first.sessionId, + openChatSessionIds: [ + third.sessionId, + 'ses_unknown', + first.sessionId, + foreign.sessionId, + second.sessionId, + ], + }); + + expect(html.indexOf(findButtonMarkup(html, third.prompt))).toBeLessThan( + html.indexOf(findButtonMarkup(html, first.prompt)) + ); + expect(html.indexOf(findButtonMarkup(html, first.prompt))).toBeLessThan( + html.indexOf(findButtonMarkup(html, second.prompt)) + ); + expect(html).not.toContain('ses_unknown'); + expect(html).not.toContain(foreign.prompt); + } + }); + + it('renders existing shared busy and attention indicators for different chats', () => { + const busy = makeSession('ses_busy', 'Running investigation', { sessionStatus: 'busy' }); + const attention = makeSession('ses_attention', 'Waiting on an answer', { + sessionStatus: 'question', + }); + const html = renderWorkspaceTabs({ + chatSessions: [busy, attention], + currentSessionId: busy.sessionId, + }); + + expect(findButtonMarkup(html, busy.prompt)).toContain('Busy'); + expect(findButtonMarkup(html, attention.prompt)).toContain('aria-label="Waiting for answer"'); + }); + + it('keeps the split chat action busy and disables only chat creation while pending', () => { + const html = renderWorkspaceTabs({ isCreatingChat: true, canCreateTerminal: true }, true); + const action = findButtonMarkup(html, 'aria-label="New chat"'); + const options = findButtonMarkup(html, 'aria-label="Tab options"'); + + expect(action).toContain('disabled=""'); + expect(action).toContain('aria-busy="true"'); + expect(action).toContain('animate-spin'); + expect(action).not.toContain('role="tab"'); + expect(options).toContain('aria-haspopup="menu"'); + expect(options).not.toContain('disabled=""'); + expect(findMenuItemMarkup(html, 'New chat')).toContain('aria-disabled="true"'); + expect(findMenuItemMarkup(html, 'New terminal')).not.toContain('aria-disabled="true"'); + }); + + it('creates chat from the labeled plus action and exposes terminal creation only in the menu', () => { + let createdChats = 0; + const html = renderWorkspaceTabs( + { canCreateTerminal: true, onCreateChat: () => createdChats++ }, + true + ); + + expect(findButtonMarkup(html, 'aria-label="New chat"')).toContain('lucide-plus'); + expect(findButtonMarkup(html, 'aria-label="Tab options"')).toContain('lucide-chevron-down'); + expect(html).not.toContain('aria-label="New terminal"'); + expect(findMenuItemMarkup(html, 'New chat')).not.toContain('aria-disabled="true"'); + expect(findMenuItemMarkup(html, 'New terminal')).not.toContain('aria-disabled="true"'); + getButtonProps('New chat').onClick?.({} as React.MouseEvent); + expect(createdChats).toBe(1); + }); + + it('does not expose terminal creation without the terminal capability', () => { + const html = renderWorkspaceTabs({ canCreateTerminal: false }, true); + + expect(html).toContain('aria-label="New chat"'); + expect(html).not.toContain('New terminal'); + }); + + it('offers eligible terminal creation and history in the caret without chat creation', () => { + const html = renderWorkspaceTabs( + { onCreateChat: undefined, canCreateTerminal: true, isCreatingChat: true }, + true + ); + + expect(findButtonMarkup(html, 'aria-label="Tab options"')).not.toContain('disabled=""'); + expect(findMenuItemMarkup(html, 'New terminal')).not.toContain('aria-disabled="true"'); + expect(html).toContain('>Sessions<'); + expect(html).not.toContain('New chat'); + expect(html).not.toContain('aria-label="New terminal"'); + expect(html).not.toContain('aria-label="Sessions"'); + expect(html.match(/aria-haspopup="menu"/g)).toHaveLength(1); + }); + + it('lists only closed worktree sessions below creation actions in the caret menu', () => { + const current = makeSession('ses_current', 'Current chat'); + const open = makeSession('ses_open', 'Another open chat'); + const closed = makeSession( + 'ses_closed', + 'A complete archived investigation title that remains readable when its tab is closed', + { sessionStatus: 'permission' } + ); + const unrelated = makeSession('ses_other', 'A different worktree', { + worktreeId: 'worktree_other', + }); + const props = { + chatSessions: [current, open, closed, unrelated], + currentSessionId: current.sessionId, + openChatSessionIds: [current.sessionId, open.sessionId, unrelated.sessionId], + canCreateTerminal: true, + }; + const tabsHtml = renderWorkspaceTabs(props); + + expect(tabsHtml).toContain(current.prompt); + expect(tabsHtml).toContain(open.prompt); + expect(tabsHtml).not.toContain(closed.prompt); + expect(tabsHtml).not.toContain(unrelated.prompt); + expect(tabsHtml).not.toContain('aria-label="Sessions"'); + expect(tabsHtml.match(/aria-haspopup="menu"/g)).toHaveLength(1); + const optionsTrigger = findButtonMarkup(tabsHtml, 'aria-label="Tab options"'); + expect(optionsTrigger).toContain('title="Tab options and Sessions"'); + expect(optionsTrigger).toContain('aria-haspopup="menu"'); + expect(optionsTrigger).not.toContain('role="tab"'); + + const historyHtml = renderWorkspaceTabs(props, true); + const closedItem = findMenuItemMarkup(historyHtml, closed.prompt); + const sessionsLabelIndex = historyHtml.indexOf('>Sessions<'); + const historyTitles = jest + .mocked(DropdownMenuItem) + .mock.calls.map(([props]) => props.textValue) + .filter(title => title !== undefined); + + expect(historyTitles).toEqual([closed.prompt]); + expect(sessionsLabelIndex).toBeGreaterThan(0); + expect(historyHtml.indexOf(findMenuItemMarkup(historyHtml, 'New chat'))).toBeLessThan( + historyHtml.indexOf(findMenuItemMarkup(historyHtml, 'New terminal')) + ); + expect(historyHtml.indexOf(findMenuItemMarkup(historyHtml, 'New terminal'))).toBeLessThan( + sessionsLabelIndex + ); + expect(sessionsLabelIndex).toBeLessThan(historyHtml.indexOf(closedItem)); + expect(closedItem).toContain('Closed tab'); + expect(closedItem).toContain('aria-label="Waiting for permission"'); + expect(closedItem).not.toContain('truncate'); + expect(historyHtml).not.toContain('Current tab'); + expect(historyHtml).not.toContain('Open tab'); + expect(historyHtml).not.toContain(unrelated.prompt); + }); + + it('uses the supplied closed-session order without exposing open or foreign chats', () => { + const first = makeSession('ses_first', 'First chat'); + const second = makeSession('ses_second', 'Second chat'); + const third = makeSession('ses_third', 'Third chat'); + renderWorkspaceTabs( + { + chatSessions: [first, second, third], + currentSessionId: second.sessionId, + openChatSessionIds: [second.sessionId], + closedChatSessionIds: [third.sessionId, 'ses_unknown', second.sessionId, first.sessionId], + }, + true + ); + + const historyTitles = jest + .mocked(DropdownMenuItem) + .mock.calls.map(([props]) => props.textValue) + .filter(title => title !== undefined); + expect(historyTitles).toEqual([third.prompt, first.prompt]); + }); + + it('preserves grouped order and treats every grouped session as open when the list is omitted', () => { + const first = makeSession('ses_first', 'First chat'); + const second = makeSession('ses_second', 'Second chat'); + const html = renderWorkspaceTabs( + { chatSessions: [second, first], currentSessionId: first.sessionId }, + true + ); + + expect(findButtonMarkup(html, first.prompt)).toContain('role="tab"'); + expect(findButtonMarkup(html, second.prompt)).toContain('role="tab"'); + expect(html.indexOf(findButtonMarkup(html, second.prompt))).toBeLessThan( + html.indexOf(findButtonMarkup(html, first.prompt)) + ); + expect(findMenuItemMarkup(html, 'No closed sessions')).toContain('aria-disabled="true"'); + expect(html).not.toContain('Closed tab'); + }); + + it('closes a chat non-destructively with no tab action menu or deletion action', () => { + const closedIds: string[] = []; + const html = renderWorkspaceTabs({ onCloseChat: sessionId => closedIds.push(sessionId) }, true); + const closeButton = findButtonMarkup(html, 'aria-label="Close First worktree chat"'); + + expect(closeButton).toContain('Close tab. Reopen from Sessions in the tab options menu.'); + expect(closeButton).toContain('lucide-x'); + expect(closeButton).not.toContain('destructive'); + expect(html).not.toContain('Session actions for'); + expect(html).not.toContain('Delete session'); + expect(html).not.toContain('>Rename<'); + getButtonProps('Close First worktree chat').onClick?.( + {} as React.MouseEvent + ); + expect(closedIds).toEqual(['ses_first']); + }); + + it.each([true, false])( + 'retains caret history after the final chat closes with chat creation=%s', + canCreateChat => { + const closed = makeSession('ses_closed', 'Closed worktree chat'); + const props = { + worktreeId: 'worktree_shared', + currentSessionId: null, + chatSessions: [closed], + openChatSessionIds: [], + onCreateChat: canCreateChat ? () => undefined : undefined, + }; + const tabsHtml = renderWorkspaceTabs(props); + + expect(tabsHtml).not.toContain('role="tab"'); + expect(tabsHtml).not.toContain(closed.prompt); + expect(tabsHtml.includes('aria-label="New chat"')).toBe(canCreateChat); + expect(tabsHtml).not.toContain('aria-label="Sessions"'); + const optionsTrigger = findButtonMarkup(tabsHtml, 'aria-label="Tab options"'); + expect(optionsTrigger).toContain('aria-haspopup="menu"'); + expect(optionsTrigger).not.toContain('disabled=""'); + expect(findMenuItemMarkup(renderWorkspaceTabs(props, true), closed.prompt)).toContain( + 'Closed tab' + ); + } + ); + + it('keeps an empty selected worktree scoped instead of rendering a generic Chat tab', () => { + const html = renderWorkspaceTabs( + { + worktreeId: 'worktree_empty', + currentSessionId: null, + chatSessions: [], + openChatSessionIds: [], + }, + true + ); + + expect(html).not.toContain('role="tab"'); + expect(html).toContain('aria-label="New chat"'); + expect(findButtonMarkup(html, 'aria-label="Tab options"')).toContain('aria-haspopup="menu"'); + expect(html).not.toContain('aria-label="Sessions"'); + expect(findMenuItemMarkup(html, 'No closed sessions')).toContain('aria-disabled="true"'); + }); + + it('uses the explicitly selected worktree instead of the current session for history', () => { + const previous = makeSession('ses_previous', 'Previous worktree chat'); + const selected = makeSession('ses_selected', 'Selected worktree chat', { + worktreeId: 'worktree_selected', + }); + const html = renderWorkspaceTabs( + { + worktreeId: selected.worktreeId, + chatSessions: [previous, selected], + currentSessionId: previous.sessionId, + openChatSessionIds: [], + }, + true + ); + + expect(html).not.toContain(previous.prompt); + expect(html).not.toContain('role="tab"'); + expect(findMenuItemMarkup(html, selected.prompt)).toContain('Closed tab'); + }); + + it.each([ + { activeTabId: CHAT_TAB_ID, currentSessionId: null }, + { activeTabId: CHAT_TAB_ID, currentSessionId: 'ses_closed' }, + { activeTabId: terminalTabId('terminal_first'), currentSessionId: 'ses_closed' }, + ])('reopens history from $activeTabId with current session $currentSessionId', initial => { + const closed = makeSession('ses_closed', 'Closed chat'); + let selectedSessionId: string | null = null; + let selectedTabId: WorkspaceTabId = initial.activeTabId; + const openedIds: string[] = []; + renderWorkspaceTabs( + { + ...initial, + worktreeId: 'worktree_shared', + chatSessions: [closed], + openChatSessionIds: [], + onSelectChat: sessionId => { + selectedSessionId = sessionId; + openedIds.push(sessionId); + }, + onSelectTab: tabId => { + selectedTabId = tabId; + }, + }, + true + ); + + getSessionMenuItemProps(closed.prompt).onSelect?.(new Event('select')); + + expect(selectedSessionId).toBe(closed.sessionId); + expect(openedIds).toEqual([closed.sessionId]); + expect(selectedTabId).toBe(CHAT_TAB_ID); + const reopenedHtml = renderWorkspaceTabs({ + worktreeId: 'worktree_shared', + chatSessions: [closed], + currentSessionId: selectedSessionId, + openChatSessionIds: openedIds, + activeTabId: selectedTabId, + }); + expect(findButtonMarkup(reopenedHtml, closed.prompt)).toContain('aria-selected="true"'); + }); + + it('keeps caret history and local close available when mutation permissions are absent', () => { + const open = makeSession('ses_open', 'Open worktree chat'); + const closed = makeSession('ses_closed', 'Closed worktree chat'); + const html = renderWorkspaceTabs( + { + chatSessions: [open, closed], + currentSessionId: open.sessionId, + openChatSessionIds: [open.sessionId], + onCreateChat: undefined, + onRenameChat: undefined, + canCreateTerminal: false, + }, + true + ); + + expect(html).not.toContain('New chat'); + expect(findButtonMarkup(html, 'aria-label="Tab options"')).toContain('aria-haspopup="menu"'); + expect(html).not.toContain('New terminal'); + expect(html).not.toContain('Session actions for'); + expect(html).not.toContain('Delete session'); + expect(html).not.toContain('aria-keyshortcuts="F2"'); + expect(html).not.toContain('Double-click'); + expect(html).toContain(`aria-label="Close ${open.prompt}"`); + expect(findMenuItemMarkup(html, closed.prompt)).toContain('Closed tab'); + expect(html).not.toContain('Current tab'); + expect(html).not.toContain('Open tab'); + }); + + it.each([true, false])( + 'exposes inline rename guidance only with rename capability=%s', + canRename => { + const html = renderWorkspaceTabs( + { onRenameChat: canRename ? async () => undefined : undefined }, + true + ); + const tab = findButtonMarkup(html, 'First worktree chat'); + + expect(tab.includes('aria-keyshortcuts="F2"')).toBe(canRename); + expect( + tab.includes('aria-description="Double-click to rename. Press F2 when focused."') + ).toBe(canRename); + expect(html).not.toContain('>Rename<'); + expect(html).not.toContain('Delete session'); + expect(html).not.toContain('Session actions for'); + } + ); + + it('does not offer closing when the optional close callback is absent', () => { + const html = renderWorkspaceTabs({ onCloseChat: undefined }); + + expect(html).not.toContain('aria-label="Close First worktree chat"'); + expect(html).not.toContain('Session actions for'); + expect(findButtonMarkup(html, 'First worktree chat')).toContain('aria-keyshortcuts="F2"'); + }); + + it('disables selection, closing, and rename guidance while a session is being deleted', () => { + const html = renderWorkspaceTabs({ deletingSessionIds: ['ses_first'] }, true); + + expect(findButtonMarkup(html, 'First worktree chat')).toContain('disabled=""'); + expect(findButtonMarkup(html, 'aria-label="Close First worktree chat"')).toContain( + 'disabled=""' + ); + expect(html).toContain('aria-label="Deleting session"'); + expect(html).not.toContain('aria-keyshortcuts="F2"'); + expect(html).not.toContain('Double-click'); + expect(html).not.toContain('Session actions for'); + }); + + it('keeps concurrent deletions disabled without blocking another worktree chat', () => { + const first = makeSession('ses_first', 'First chat'); + const second = makeSession('ses_second', 'Second chat'); + const remaining = makeSession('ses_remaining', 'Remaining chat'); + const html = renderWorkspaceTabs({ + chatSessions: [first, second, remaining], + currentSessionId: remaining.sessionId, + deletingSessionIds: [first.sessionId, second.sessionId], + }); + + for (const session of [first, second]) { + const tab = findButtonMarkup(html, session.prompt); + expect(tab).toContain('disabled=""'); + expect(tab).not.toContain('aria-keyshortcuts="F2"'); + expect(findButtonMarkup(html, `aria-label="Close ${session.prompt}"`)).toContain( + 'disabled=""' + ); + } + expect(findButtonMarkup(html, remaining.prompt)).not.toContain('disabled=""'); + expect(findButtonMarkup(html, remaining.prompt)).toContain('aria-keyshortcuts="F2"'); + expect(findButtonMarkup(html, `aria-label="Close ${remaining.prompt}"`)).not.toContain( + 'disabled=""' + ); + }); + + it('disables reopening a closed session while it is being deleted', () => { + const html = renderWorkspaceTabs( + { deletingSessionIds: ['ses_first'], openChatSessionIds: [] }, + true + ); + const item = findMenuItemMarkup(html, 'First worktree chat'); + + expect(item).toContain('aria-disabled="true"'); + expect(item).toContain('aria-label="Deleting session"'); + expect(item).toContain('Closed tab'); + }); + + it('retains the generic Chat tab and hides sibling creation for standalone sessions', () => { + const standalone = makeSession('ses_standalone', 'Standalone session title', { + worktreeId: null, + }); + const html = renderWorkspaceTabs({ + chatSessions: [standalone], + currentSessionId: standalone.sessionId, + }); + + expect(findButtonMarkup(html, '>Chat<')).toContain('aria-selected="true"'); + expect(html).not.toContain(standalone.prompt); + expect(html).not.toContain('New chat'); + }); + + it('treats an explicit null worktree as standalone even when the session has a worktree', () => { + const html = renderWorkspaceTabs({ worktreeId: null }); + + expect(findButtonMarkup(html, '>Chat<')).toContain('aria-selected="true"'); + expect(html).not.toContain('First worktree chat'); + expect(html).not.toContain('New chat'); + expect(html).not.toContain('Sessions'); + }); + + it('preserves a standalone terminal-only creation action without chat or split actions', () => { + let createdTerminals = 0; + const html = renderWorkspaceTabs({ + worktreeId: null, + canCreateTerminal: true, + onCreateTerminal: () => createdTerminals++, + }); + + expect(findButtonMarkup(html, 'aria-label="New terminal"')).not.toContain( + 'aria-haspopup="menu"' + ); + expect(html).not.toContain('New chat'); + expect(html).not.toContain('Tab options'); + expect(html).not.toContain('Sessions'); + getButtonProps('New terminal').onClick?.({} as React.MouseEvent); + expect(createdTerminals).toBe(1); + }); + + it('keeps terminal selection, accessible status, close actions, and creation alongside chats', () => { + const terminal = { + id: 'terminal_first', + title: 'Terminal 1', + cloudAgentSessionId: 'agent_first', + }; + const html = renderWorkspaceTabs( + { + activeTabId: terminalTabId(terminal.id), + terminals: [terminal], + terminalStatuses: { + [terminal.id]: { status: 'connected', statusText: 'Connected to workspace' }, + }, + canCreateTerminal: true, + }, + true + ); + + expect(findButtonMarkup(html, 'First worktree chat')).toContain('aria-selected="false"'); + expect(findButtonMarkup(html, terminal.title)).toContain('aria-selected="true"'); + expect(findButtonMarkup(html, terminal.title)).toContain('data-state="active"'); + expect(findButtonMarkup(html, terminal.title)).toContain('Connected to workspace'); + expect(findMenuItemMarkup(html, 'No closed sessions')).toContain('aria-disabled="true"'); + expect(html).not.toContain('Open tab'); + expect(html).toContain('aria-label="Close Terminal 1"'); + expect(html).not.toContain('Session actions for'); + expect(html).toContain('New chat'); + expect(html).toContain('New terminal'); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx index 1b67e4d669..babea90250 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx @@ -1,24 +1,67 @@ 'use client'; +import React, { useEffect, useRef, useState } from 'react'; +import { SessionStatusIndicator } from '@/components/shared/SessionStatusIndicator'; +import { TimeAgo } from '@/components/shared/TimeAgo'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -import { MessageSquare, Terminal, X } from 'lucide-react'; +import { ChevronDown, LoaderCircle, MessageSquare, Plus, Terminal, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { SessionPrIndicator } from './SessionPrIndicator'; import { CHAT_TAB_ID, terminalTabId } from './terminal-tabs'; import type { TerminalWorkspaceTab, WorkspaceTabId } from './terminal-tabs'; +import type { StoredSession } from './types'; type TerminalStatusSummary = { status: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'exited' | 'error'; statusText: string; }; +type RenameTap = { + sessionId: string; + x: number; + y: number; + time: number; +}; + function statusDotClass(status: TerminalStatusSummary['status']): string { if (status === 'connected') return 'bg-emerald-500'; if (status === 'error' || status === 'exited') return 'bg-destructive'; return 'bg-amber-500'; } +const tabClassName = + 'border-border bg-muted/40 text-muted-foreground flex h-8 min-w-0 max-w-full shrink-0 items-center rounded-md border [@media(any-pointer:coarse)]:h-12'; +const activeTabClassName = 'border-foreground/30 bg-secondary text-foreground shadow-sm'; +const tabTriggerClassName = + 'h-full min-w-0 gap-1.5 px-2 text-xs hover:bg-accent/60 focus-visible:ring-offset-0 focus-visible:ring-inset data-[state=active]:border-0 data-[state=active]:bg-transparent data-[state=active]:shadow-none [@media(any-pointer:coarse)]:min-w-11'; +const tabActionClassName = + 'text-muted-foreground h-full w-8 shrink-0 rounded-none px-0 focus-visible:ring-inset [@media(any-pointer:coarse)]:w-11'; +const renameHint = 'Double-click to rename. Press F2 when focused.'; + export function CloudAgentWorkspaceTabs({ activeTabId, + chatSessions, + currentSessionId, + worktreeId, + openChatSessionIds, + closedChatSessionIds, + onSelectChat, + onCloseChat, + onCreateChat, + isCreatingChat = false, + onRenameChat, + deletingSessionIds = [], terminals, terminalStatuses, canCreateTerminal, @@ -28,6 +71,17 @@ export function CloudAgentWorkspaceTabs({ className, }: { activeTabId: WorkspaceTabId; + chatSessions: StoredSession[]; + currentSessionId: string | null; + worktreeId?: string | null; + openChatSessionIds?: readonly string[]; + closedChatSessionIds?: readonly string[]; + onSelectChat: (sessionId: string) => void; + onCloseChat?: (sessionId: string) => void; + onCreateChat?: () => void; + isCreatingChat?: boolean; + onRenameChat?: (sessionId: string, title: string) => Promise; + deletingSessionIds?: readonly string[]; terminals: TerminalWorkspaceTab[]; terminalStatuses: Record; canCreateTerminal: boolean; @@ -36,85 +90,513 @@ export function CloudAgentWorkspaceTabs({ onCloseTerminal: (terminalId: string) => void; className?: string; }) { - const chatActive = activeTabId === CHAT_TAB_ID; + const [editingSessionId, setEditingSessionId] = useState(null); + const [editTitle, setEditTitle] = useState(''); + const editInputRef = useRef(null); + const renameTriggerRef = useRef(null); + const savingRenameRef = useRef(false); + const cancelledRenameRef = useRef(false); + const touchStartRef = useRef(null); + const lastTapRef = useRef(null); + const lastPointerTypeRef = useRef(''); + const selectedTabRef = useRef(null); + const activeTabWrapperRef = useRef(null); + const tabListRef = useRef(null); + const tabOptionsTriggerRef = useRef(null); + const restoreTabFocusRef = useRef(false); + const currentSession = chatSessions.find(session => session.sessionId === currentSessionId); + const selectedWorktreeId = worktreeId === undefined ? currentSession?.worktreeId : worktreeId; + const groupedChatSessions = selectedWorktreeId + ? chatSessions.filter(session => session.worktreeId === selectedWorktreeId) + : []; + const groupedSessionsById = new Map( + groupedChatSessions.map(session => [session.sessionId, session]) + ); + const visibleChatSessions = + openChatSessionIds === undefined + ? groupedChatSessions + : openChatSessionIds + .map(sessionId => groupedSessionsById.get(sessionId)) + .filter(session => session !== undefined); + const openSessionIds = new Set(visibleChatSessions.map(session => session.sessionId)); + const closedChatSessions = ( + closedChatSessionIds ?? groupedChatSessions.map(session => session.sessionId) + ).flatMap(sessionId => { + const session = groupedSessionsById.get(sessionId); + return session && !openSessionIds.has(sessionId) ? [session] : []; + }); + const selectedValue = + activeTabId === CHAT_TAB_ID && selectedWorktreeId && currentSessionId + ? `chat:${currentSessionId}` + : activeTabId; + + useEffect(() => { + if (!editingSessionId) return; + editInputRef.current?.focus(); + editInputRef.current?.select(); + }, [editingSessionId]); + + useEffect(() => { + const revealActiveTab = () => { + activeTabWrapperRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + }; + revealActiveTab(); + if (restoreTabFocusRef.current) { + restoreTabFocusRef.current = false; + (selectedTabRef.current ?? tabOptionsTriggerRef.current)?.focus({ preventScroll: true }); + } + + const tabList = tabListRef.current; + if (!tabList) return; + const resizeObserver = new ResizeObserver(revealActiveTab); + resizeObserver.observe(tabList); + if (activeTabWrapperRef.current) resizeObserver.observe(activeTabWrapperRef.current); + return () => resizeObserver.disconnect(); + }, [selectedValue, visibleChatSessions.length, terminals.length]); + + const handleStartRename = (session: StoredSession, trigger: HTMLButtonElement) => { + if ( + !onRenameChat || + deletingSessionIds.includes(session.sessionId) || + editingSessionId === session.sessionId || + savingRenameRef.current + ) { + return; + } + cancelledRenameRef.current = false; + renameTriggerRef.current = trigger; + lastTapRef.current = null; + setEditingSessionId(session.sessionId); + setEditTitle(session.prompt); + }; + + const handleFinishRename = () => { + cancelledRenameRef.current = true; + setEditingSessionId(null); + if (document.activeElement === editInputRef.current) { + renameTriggerRef.current?.focus({ preventScroll: true }); + } + }; + + const handleSaveRename = async () => { + if (!editingSessionId || !onRenameChat || savingRenameRef.current) return; + if (cancelledRenameRef.current) { + cancelledRenameRef.current = false; + return; + } + + const title = editTitle.trim(); + const session = groupedSessionsById.get(editingSessionId); + if (!title || title === session?.prompt) { + handleFinishRename(); + return; + } + + savingRenameRef.current = true; + try { + await onRenameChat(editingSessionId, title); + } catch { + toast.error('Failed to rename session. Please try again.'); + } finally { + handleFinishRename(); + savingRenameRef.current = false; + } + }; + + const handleValueChange = (value: string) => { + if (value === CHAT_TAB_ID) { + onSelectTab(CHAT_TAB_ID); + return; + } + + const selectedChat = visibleChatSessions.find(session => value === `chat:${session.sessionId}`); + if (selectedChat) { + if (activeTabId !== CHAT_TAB_ID) onSelectTab(CHAT_TAB_ID); + if (selectedChat.sessionId !== currentSessionId) onSelectChat(selectedChat.sessionId); + return; + } + + const selectedTerminal = terminals.find(terminal => value === terminalTabId(terminal.id)); + if (selectedTerminal) onSelectTab(terminalTabId(selectedTerminal.id)); + }; return ( -
- + {selectedWorktreeId ? ( + visibleChatSessions.map(session => { + const active = activeTabId === CHAT_TAB_ID && session.sessionId === currentSessionId; + const isDeleting = deletingSessionIds.includes(session.sessionId); + const isEditing = editingSessionId === session.sessionId; + const canRename = Boolean(onRenameChat) && !isDeleting; + + return ( +
+ + { + if (!canRename || lastPointerTypeRef.current === 'touch') return; + event.preventDefault(); + handleStartRename(session, event.currentTarget); + }} + onPointerDown={event => { + lastPointerTypeRef.current = event.pointerType; + if (event.pointerType !== 'touch' || !event.isPrimary || !canRename) { + touchStartRef.current = null; + lastTapRef.current = null; + return; + } + touchStartRef.current = { + sessionId: session.sessionId, + x: event.clientX, + y: event.clientY, + time: event.timeStamp, + }; + }} + onPointerMove={event => { + const start = touchStartRef.current; + if ( + start && + Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10 + ) { + touchStartRef.current = null; + lastTapRef.current = null; + } + }} + onPointerCancel={() => { + touchStartRef.current = null; + lastTapRef.current = null; + }} + onClick={event => { + const start = touchStartRef.current; + touchStartRef.current = null; + if (!start || start.sessionId !== session.sessionId) return; + if ( + event.timeStamp - start.time > 500 || + Math.hypot(event.clientX - start.x, event.clientY - start.y) > 10 + ) { + lastTapRef.current = null; + return; + } + const lastTap = lastTapRef.current; + if ( + lastTap?.sessionId === session.sessionId && + event.timeStamp - lastTap.time <= 350 && + Math.hypot(event.clientX - lastTap.x, event.clientY - lastTap.y) <= 24 + ) { + handleStartRename(session, event.currentTarget); + } else { + lastTapRef.current = { + sessionId: session.sessionId, + x: event.clientX, + y: event.clientY, + time: event.timeStamp, + }; + } + }} + onKeyDown={event => { + if (event.key === 'F2' && canRename) { + event.preventDefault(); + event.stopPropagation(); + handleStartRename(session, event.currentTarget); + } + }} + > + + + + + {session.prompt} + {canRename &&

{renameHint}

} +
+
+ + {!isEditing && session.associatedPr && ( + + + + )} - {terminals.map(tab => { - const tabId = terminalTabId(tab.id); - const active = activeTabId === tabId; - const status = terminalStatuses[tab.id]?.status ?? 'connecting'; + {isEditing ? ( + setEditTitle(event.target.value)} + onBlur={() => void handleSaveRename()} + onKeyDown={event => { + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + void handleSaveRename(); + } else if (event.key === 'Escape') { + event.preventDefault(); + if (!savingRenameRef.current) handleFinishRename(); + } + }} + className="bg-muted focus-visible:ring-ring h-7 w-40 min-w-0 rounded-md px-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-inset [@media(any-pointer:coarse)]:h-11" + /> + ) : isDeleting ? ( + + ) : null} - return ( + {onCloseChat && ( + + )} +
+ ); + }) + ) : (
- -
+ )} + + {terminals.map(tab => { + const tabId = terminalTabId(tab.id); + const active = activeTabId === tabId; + const terminalStatus = terminalStatuses[tab.id]; + const status = terminalStatus?.status ?? 'connecting'; + const statusText = terminalStatus?.statusText ?? 'Connecting'; + + return ( +
- - + + + + + + + {tab.title} + + + +
+ ); + })} + + +
+ {selectedWorktreeId ? ( +
+ {onCreateChat && ( + + )} + + + + + + {onCreateChat && ( + + + )} + {canCreateTerminal && ( + + + )} + {(onCreateChat || canCreateTerminal) && } + Sessions + {closedChatSessions.length === 0 ? ( + No closed sessions + ) : ( + closedChatSessions.map(session => { + const isDeleting = deletingSessionIds.includes(session.sessionId); + + return ( + { + if (activeTabId !== CHAT_TAB_ID) onSelectTab(CHAT_TAB_ID); + onSelectChat(session.sessionId); + }} + > + + ); + }) + )} + +
- ); - })} - - {canCreateTerminal && ( - - )} -
+ ) : canCreateTerminal ? ( + + ) : null} +
+ ); } diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index 3029435269..dbf048b8d1 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -5,11 +5,13 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { useSearchParams } from 'next/navigation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; -import { ArrowDown, GitBranch } from 'lucide-react'; +import { ArrowDown, GitBranch, MessageSquare } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { v4 as uuidv4 } from 'uuid'; import type { KiloSessionId } from '@kilocode/cloud-agent-sdk'; import { useManager } from './CloudAgentProvider'; +import { useWorktreeChatCreation, useWorktreeChatTabs } from './CloudSidebarLayout'; import { MobileSidebarToggle } from './MobileSidebarToggle'; import { ChatHeader } from './ChatHeader'; import { ChatInput } from './ChatInput'; @@ -57,6 +59,7 @@ import { addTerminalTab, closeTerminalTab, createWorkspaceTabsState, + getWorkspaceTabScope, resetWorkspaceTabs, selectWorkspaceTab, terminalTabId, @@ -75,7 +78,6 @@ import { useCliSessionPresence } from '@/hooks/useCliSessionPresence'; import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; import { SetPageTitle } from '@/components/SetPageTitle'; -import { Button } from '@/components/ui/button'; import { formatShortModelDisplayName } from '@/lib/format-model-name'; import type { AgentMode } from './types'; import type { @@ -248,6 +250,17 @@ export default function CloudChatPage({ organizationRole, }: CloudChatPageProps) { const manager = useManager(); + const { createWorktreeChat, creatingWorktreeSourceSessionId } = useWorktreeChatCreation(); + const { + selectedWorktreeId, + worktreeChats, + openWorktreeChats, + closedWorktreeChats, + openSession, + closeSession, + renameSession, + deletingSessionIds, + } = useWorktreeChatTabs(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); const trpc = useTRPC(); @@ -269,12 +282,14 @@ export default function CloudChatPage({ // URL-driven session switching const sessionIdFromParams = searchParams?.get('sessionId') ?? null; useEffect(() => { + childSessionDrawerFocusTargetRef.current = null; + setChildSessionStack([]); + preparationDrawerFocusTargetRef.current = null; + setPreparationDrawerAttemptId(null); if (sessionIdFromParams) { - childSessionDrawerFocusTargetRef.current = null; - setChildSessionStack([]); - preparationDrawerFocusTargetRef.current = null; - setPreparationDrawerAttemptId(null); void manager.switchSession(sessionIdFromParams as KiloSessionId); + } else { + manager.destroy(); } }, [sessionIdFromParams, manager]); @@ -340,12 +355,26 @@ export default function CloudChatPage({ const [terminalStatuses, setTerminalStatuses] = useState< Record >({}); - const chatTabActive = workspaceTabs.activeTabId === CHAT_TAB_ID; + const preserveTerminalSelectionRef = useRef(false); + const chatTabActive = + workspaceTabs.activeTabId === CHAT_TAB_ID && + (sessionIdFromParams !== null || selectedWorktreeId === null); + const workspaceTabScope = getWorkspaceTabScope(selectedWorktreeId, sessionIdFromParams); useEffect(() => { setWorkspaceTabs(resetWorkspaceTabs); setTerminalStatuses({}); - }, [sessionId]); + }, [workspaceTabScope]); + + useEffect(() => { + if (preserveTerminalSelectionRef.current) { + preserveTerminalSelectionRef.current = false; + return; + } + if (sessionIdFromParams) { + setWorkspaceTabs(state => selectWorkspaceTab(state, CHAT_TAB_ID)); + } + }, [sessionIdFromParams]); // -- Session models ------------------------------------------------------- const sessionModels = useSessionModels({ @@ -575,6 +604,23 @@ export default function CloudChatPage({ }, [scheduleScrollToBottom, setChatUI]); // -- Handlers ------------------------------------------------------------- + const worktreeChatSourceSessionId = sessionIdFromParams + ? activeSessionType === 'cloud-agent' && + fetchedSessionData?.worktreeId && + fetchedSessionData.kiloSessionId === sessionIdFromParams + ? sessionIdFromParams + : null + : (worktreeChats[0]?.sessionId ?? null); + const canCreateWorktreeChat = worktreeChatSourceSessionId !== null; + const handleCreateWorktreeChat = useCallback(async () => { + if (!worktreeChatSourceSessionId) return false; + return createWorktreeChat(worktreeChatSourceSessionId); + }, [createWorktreeChat, worktreeChatSourceSessionId]); + const handleReplaceWorktreeChat = useCallback(async () => { + if (!canCreateWorktreeChat || !sessionIdFromParams) return false; + return createWorktreeChat(sessionIdFromParams, 'replace'); + }, [canCreateWorktreeChat, createWorktreeChat, sessionIdFromParams]); + const handleSendMessage = useCallback( async (prompt: string, attachments?: CloudAgentAttachments) => { setChatUI({ shouldAutoScroll: true }); @@ -638,14 +684,51 @@ export default function CloudChatPage({ }, [setSoundEnabled]); const handleCreateTerminalTab = useCallback(() => { + const preparedSiblingSessionId = + selectedWorktreeId && fetchedSessionData?.isInitiated === false + ? worktreeChats.find( + chat => chat.sessionId !== sessionIdFromParams && chat.cloudAgentSessionId + )?.cloudAgentSessionId + : null; + const terminalSessionId = sessionIdFromParams + ? (preparedSiblingSessionId ?? sessionId) + : worktreeChats.find(chat => chat.cloudAgentSessionId)?.cloudAgentSessionId; + if (!terminalSessionId) return; + const terminalId = uuidv4(); - setWorkspaceTabs(state => addTerminalTab(state, terminalId)); - }, []); + setWorkspaceTabs(state => addTerminalTab(state, terminalId, terminalSessionId)); + }, [ + fetchedSessionData?.isInitiated, + selectedWorktreeId, + sessionId, + sessionIdFromParams, + worktreeChats, + ]); const handleSelectWorkspaceTab = useCallback((tabId: WorkspaceTabId) => { setWorkspaceTabs(state => selectWorkspaceTab(state, tabId)); }, []); + const handleCloseChat = useCallback( + (closingSessionId: string) => { + if (closingSessionId === sessionIdFromParams) { + preserveTerminalSelectionRef.current = !chatTabActive; + const fallbackTerminal = workspaceTabs.terminals.at(-1); + if (chatTabActive && openWorktreeChats.length === 1 && fallbackTerminal) { + setWorkspaceTabs(state => selectWorkspaceTab(state, terminalTabId(fallbackTerminal.id))); + } + } + closeSession(closingSessionId); + }, + [ + chatTabActive, + closeSession, + openWorktreeChats.length, + sessionIdFromParams, + workspaceTabs.terminals, + ] + ); + const handleCloseTerminalTab = useCallback((terminalId: string) => { setWorkspaceTabs(state => closeTerminalTab(state, terminalId)); setTerminalStatuses(current => { @@ -669,7 +752,7 @@ export default function CloudChatPage({ key={tab.id} terminalId={tab.id} active={active} - sessionId={sessionId} + sessionId={tab.cloudAgentSessionId} organizationId={organizationId} onStatusChange={handleTerminalStatusChange} /> @@ -897,7 +980,8 @@ export default function CloudChatPage({ }, [isLoading]); // -- Derived state -------------------------------------------------------- - const showChatInterface = Boolean(sessionConfig) || Boolean(sessionIdFromParams); + const showChatInterface = + Boolean(selectedWorktreeId) || Boolean(sessionConfig) || Boolean(sessionIdFromParams); const currentModelOption = modelOptions.find(model => activeSessionType === 'remote' ? model.id === sessionModels.selectedValue @@ -941,7 +1025,10 @@ export default function CloudChatPage({ ? 'Wrapping up…' : 'Ask anything…'; - const canOpenTerminal = Boolean(sessionId) && !isReadOnly; + const canOpenTerminal = + !sessionIdFromParams && selectedWorktreeId + ? worktreeChats.some(chat => Boolean(chat.cloudAgentSessionId)) + : Boolean(sessionId) && !isReadOnly; const sessionActions = ( -
+
- {isRenderableSessionCost(totalCostUsd) && ( + {sessionIdFromParams && isRenderableSessionCost(totalCostUsd) && ( + )} +
+ )} <>
void; + closeSession: (sessionId: string) => void; + renameSession: (sessionId: string, title: string) => Promise; + deletingSessionIds: string[]; +}; + +type SidebarLayoutContextValue = WorktreeChatTabs & { toggleMobileSidebar: () => void; + createWorktreeChat: ( + sourceKiloSessionId: string, + placement?: 'append' | 'replace' + ) => Promise; + creatingWorktreeSourceSessionId: string | null; +}; + +type PendingWorktreeCreationOperation = CloudSessionCreationOperation & { + placement: 'append' | 'replace'; + promise: Promise | null; }; const SidebarLayoutContext = createContext({ + currentSessionId: null, + selectedWorktreeId: null, + worktreeChats: [], + openWorktreeChats: [], + closedWorktreeChats: [], + openSession: () => {}, + closeSession: () => {}, + renameSession: async () => {}, + deletingSessionIds: [], toggleMobileSidebar: () => {}, + createWorktreeChat: async () => false, + creatingWorktreeSourceSessionId: null, }); export function useSidebarToggle() { return useContext(SidebarLayoutContext); } +export function useWorktreeChatCreation() { + const { createWorktreeChat, creatingWorktreeSourceSessionId } = useContext(SidebarLayoutContext); + return { createWorktreeChat, creatingWorktreeSourceSessionId }; +} + +export function useWorktreeChatTabs(): WorktreeChatTabs { + const { + currentSessionId, + selectedWorktreeId, + worktreeChats, + openWorktreeChats, + closedWorktreeChats, + openSession, + closeSession, + renameSession, + deletingSessionIds, + } = useContext(SidebarLayoutContext); + return { + currentSessionId, + selectedWorktreeId, + worktreeChats, + openWorktreeChats, + closedWorktreeChats, + openSession, + closeSession, + renameSession, + deletingSessionIds, + }; +} + type CloudSidebarLayoutProps = { + currentUserId: string; organizationId?: string; children: ReactNode; }; -export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLayoutProps) { +export function CloudSidebarLayout({ + currentUserId, + organizationId, + children, +}: CloudSidebarLayoutProps) { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); const currentSessionId = searchParams.get('sessionId') ?? undefined; + const worktreeIdFromParams = searchParams.get('worktreeId'); + const { + closedSessionIds, + sessionOrderByWorktree, + openChatTab, + closeChatTab, + replaceChatTab, + forgetWorktreeTabs, + } = useClosedWorktreeChatTabs(getClosedWorktreeChatTabsStorageKey(currentUserId, organizationId)); + + useEffect(() => { + if (currentSessionId) openChatTab(currentSessionId); + }, [currentSessionId, openChatTab]); + + const manager = useManager(); + const activity = useAtomValue(manager.atoms.activity); + const isStreaming = useAtomValue(manager.atoms.isStreaming); + const activeQuestion = useAtomValue(manager.atoms.activeQuestion); + const activePermission = useAtomValue(manager.atoms.activePermission); + const cloudStatus = useAtomValue(manager.atoms.cloudStatus); + const pendingMessages = useAtomValue(manager.atoms.pendingMessages); + const activeSessionType = useAtomValue(manager.atoms.activeSessionType); + const fetchedSessionData = useAtomValue(manager.atoms.fetchedSessionData); const [searchQuery, setSearchQuery] = useState(''); const projectFilterKey = `cloud-sessions:project-filter:${organizationId ?? 'personal'}`; @@ -61,6 +195,12 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay }); const [mobileSheetOpen, setMobileSheetOpen] = useState(false); const [sessionPendingDeletion, setSessionPendingDeletion] = useState(); + const [worktreePendingDeletion, setWorktreePendingDeletion] = useState(); + const [deletingWorktreeId, setDeletingWorktreeId] = useState(); + const [creatingWorktreeSourceSessionId, setCreatingWorktreeSourceSessionId] = useState< + string | null + >(null); + const pendingWorktreeCreationRef = useRef(null); const repoUpdatedSince = useMemo(() => startOfDay(subDays(new Date(), 30)).toISOString(), []); const createdOnPlatform = useMemo(() => { @@ -79,16 +219,128 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay }); }, [platformFilter]); - const { sessions, refetchSessions, renameSessionLocally } = useSidebarSessions({ - organizationId: organizationId ?? null, - searchQuery, - createdOnPlatform, - gitUrl: projectFilter.length > 0 ? projectFilter : undefined, - }); + const { sessions, cachedSessions, worktreeDetails, refetchSessions, renameSessionLocally } = + useSidebarSessions({ + organizationId: organizationId ?? null, + searchQuery, + createdOnPlatform, + gitUrl: projectFilter.length > 0 ? projectFilter : undefined, + }); + const foregroundSessionStatus = useMemo( + () => + deriveForegroundSessionStatus({ + currentSessionId: currentSessionId ?? null, + organizationId: organizationId ?? null, + activeSessionType, + fetchedSessionData, + activity, + isStreaming, + activeQuestion, + activePermission, + cloudStatus, + pendingMessages, + }), + [ + activePermission, + activeQuestion, + activeSessionType, + activity, + cloudStatus, + currentSessionId, + fetchedSessionData, + isStreaming, + organizationId, + pendingMessages, + ] + ); + const sidebarSessions = useMemo(() => { + if (foregroundSessionStatus === null) return sessions; + return sessions.map(session => + session.sessionId === currentSessionId && session.sessionStatus !== foregroundSessionStatus + ? { ...session, sessionStatus: foregroundSessionStatus } + : session + ); + }, [currentSessionId, foregroundSessionStatus, sessions]); const { activeSessions } = useActiveSessions(); // Session deletion (lightweight - no stream cleanup, container handles that on unmount) const trpc = useTRPC(); + const selectedWorktreeId = useMemo(() => { + if (!currentSessionId) { + const parsedWorktreeId = cloudAgentWorktreeIdSchema.safeParse(worktreeIdFromParams); + return parsedWorktreeId.success ? parsedWorktreeId.data : null; + } + + const cachedSession = cachedSessions.find(session => session.sessionId === currentSessionId); + const fetchedWorktreeId = + fetchedSessionData?.kiloSessionId === currentSessionId && + fetchedSessionData.organizationId === (organizationId ?? null) + ? fetchedSessionData.worktreeId + : null; + const parsedWorktreeId = cloudAgentWorktreeIdSchema.safeParse( + cachedSession?.worktreeId ?? fetchedWorktreeId + ); + return parsedWorktreeId.success ? parsedWorktreeId.data : null; + }, [cachedSessions, currentSessionId, fetchedSessionData, organizationId, worktreeIdFromParams]); + const { data: worktreeSessionsData } = useQuery( + trpc.cliSessionsV2.list.queryOptions( + selectedWorktreeId + ? { + organizationId: organizationId ?? null, + worktreeId: selectedWorktreeId, + limit: 200, + orderBy: 'updated_at', + } + : skipToken + ) + ); + const worktreeChats = useMemo(() => { + if (!selectedWorktreeId) return []; + + const authoritativeSessions = + worktreeSessionsData?.cliSessions + .filter(session => session.organization_id === (organizationId ?? null)) + .map(session => dbSessionToStoredSession(apiSessionToDbSession(session))) ?? []; + const mergedSessions = mergeWorktreeChatSessions( + selectedWorktreeId, + authoritativeSessions, + cachedSessions + ); + if (foregroundSessionStatus === null) return mergedSessions; + return mergedSessions.map(session => + session.sessionId === currentSessionId && session.sessionStatus !== foregroundSessionStatus + ? { ...session, sessionStatus: foregroundSessionStatus } + : session + ); + }, [ + cachedSessions, + currentSessionId, + foregroundSessionStatus, + organizationId, + selectedWorktreeId, + worktreeSessionsData?.cliSessions, + ]); + const openWorktreeChats = useMemo(() => { + const sessionsById = new Map(worktreeChats.map(session => [session.sessionId, session])); + const openSessionIds = getOpenWorktreeChatSessionIds( + [...sessionsById.keys()], + closedSessionIds, + selectedWorktreeId ? sessionOrderByWorktree[selectedWorktreeId] : undefined + ); + return openSessionIds.flatMap(sessionId => { + const session = sessionsById.get(sessionId); + return session ? [session] : []; + }); + }, [closedSessionIds, selectedWorktreeId, sessionOrderByWorktree, worktreeChats]); + const closedWorktreeChats = useMemo(() => { + const sessionsById = new Map(worktreeChats.map(session => [session.sessionId, session])); + return getClosedWorktreeChatSessionIds([...sessionsById.keys()], closedSessionIds).flatMap( + sessionId => { + const session = sessionsById.get(sessionId); + return session ? [session] : []; + } + ); + }, [closedSessionIds, worktreeChats]); const { data: recentReposData } = useQuery({ ...trpc.cliSessionsV2.recentRepositories.queryOptions({ @@ -139,6 +391,151 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay const { mutateAsync: renameCliSessionV2 } = useMutation( trpc.cliSessionsV2.rename.mutationOptions() ); + const { mutateAsync: renameWorktree } = useMutation( + trpc.cliSessionsV2.renameWorktree.mutationOptions() + ); + const { mutateAsync: deleteWorktree } = useMutation( + trpc.cliSessionsV2.deleteWorktree.mutationOptions() + ); + const { mutateAsync: createPersonalWorktreeChat } = useMutation( + trpc.cloudAgentNext.createWorktreeChat.mutationOptions() + ); + const { mutateAsync: createOrganizationWorktreeChat } = useMutation( + trpc.organizations.cloudAgentNext.createWorktreeChat.mutationOptions() + ); + + const openSession = useCallback( + (sessionId: string) => { + openChatTab(sessionId); + const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; + const chatPath = `${basePath}/chat`; + const targetUrl = `${chatPath}?sessionId=${sessionId}`; + if (pathname === chatPath && isNewSession(sessionId)) { + window.history.pushState(null, '', targetUrl); + } else { + router.push(targetUrl); + } + setMobileSheetOpen(false); + }, + [openChatTab, organizationId, pathname, router] + ); + + const openWorktree = useCallback( + (worktreeId: string) => { + const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; + const chatPath = `${basePath}/chat`; + const targetUrl = `${chatPath}?worktreeId=${worktreeId}`; + if (pathname === chatPath) { + window.history.pushState(null, '', targetUrl); + } else { + router.push(targetUrl); + } + setMobileSheetOpen(false); + }, + [organizationId, pathname, router] + ); + + const closeSession = useCallback( + (sessionId: string) => { + if (!selectedWorktreeId || !worktreeChats.some(session => session.sessionId === sessionId)) { + return; + } + closeChatTab(sessionId); + if (sessionId !== currentSessionId) return; + + const nextSessionId = getNextOpenChatSessionId( + openWorktreeChats.map(session => session.sessionId), + sessionId + ); + if (nextSessionId) { + openSession(nextSessionId); + } else { + openWorktree(selectedWorktreeId); + } + }, + [ + closeChatTab, + currentSessionId, + openSession, + openWorktree, + openWorktreeChats, + selectedWorktreeId, + worktreeChats, + ] + ); + + const handleCreateWorktreeChat = useCallback( + (sourceKiloSessionId: string, placement: 'append' | 'replace' = 'append'): Promise => { + const intent = JSON.stringify({ + sourceKiloSessionId, + organizationId: organizationId ?? null, + }); + const pending = pendingWorktreeCreationRef.current; + if (pending?.promise) { + return pending.intent === intent && pending.placement === placement + ? pending.promise + : Promise.resolve(false); + } + + const operation = getCloudSessionCreationOperation(pending, intent, uuidv4); + setCreatingWorktreeSourceSessionId(sourceKiloSessionId); + + const request = (async () => { + try { + const input = { sourceKiloSessionId, operationKey: operation.operationKey }; + const result = organizationId + ? await createOrganizationWorktreeChat({ ...input, organizationId }) + : await createPersonalWorktreeChat(input); + + if (pendingWorktreeCreationRef.current?.operationKey === operation.operationKey) { + pendingWorktreeCreationRef.current = null; + } + + void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()); + + if (placement === 'replace') { + replaceChatTab( + result.worktreeId, + sourceKiloSessionId, + result.kiloSessionId, + openWorktreeChats.map(session => session.sessionId) + ); + } + openSession(result.kiloSessionId); + return true; + } catch (error) { + if ( + !isAmbiguousCloudSessionCreationError(error) && + pendingWorktreeCreationRef.current?.operationKey === operation.operationKey + ) { + pendingWorktreeCreationRef.current = null; + } + toast.error('Failed to create chat', { description: formatSessionError(error) }); + return false; + } finally { + if (pendingWorktreeCreationRef.current?.operationKey === operation.operationKey) { + pendingWorktreeCreationRef.current = { ...operation, placement, promise: null }; + } + setCreatingWorktreeSourceSessionId(null); + } + })(); + + pendingWorktreeCreationRef.current = { ...operation, placement, promise: request }; + return request; + }, + [ + createOrganizationWorktreeChat, + createPersonalWorktreeChat, + openSession, + openWorktreeChats, + organizationId, + queryClient, + replaceChatTab, + trpc.cliSessionsV2.list, + trpc.cliSessionsV2.search, + ] + ); const handleConfirmDelete = useCallback(() => { if (!sessionPendingDeletion || deletingSessionIds.includes(sessionPendingDeletion)) return; @@ -146,22 +543,46 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay setSessionPendingDeletion(undefined); deleteCliSessionV2({ session_id: sessionPendingDeletion }); if (sessionPendingDeletion === currentSessionId) { - const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; - router.replace(basePath); + const survivingWorktreeChat = openWorktreeChats.find( + session => + session.sessionId !== sessionPendingDeletion && + !deletingSessionIds.includes(session.sessionId) + ); + if (survivingWorktreeChat) { + openSession(survivingWorktreeChat.sessionId); + } else if ( + selectedWorktreeId && + worktreeChats.some( + session => + session.sessionId !== sessionPendingDeletion && + !deletingSessionIds.includes(session.sessionId) + ) + ) { + openWorktree(selectedWorktreeId); + } else { + const basePath = organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'; + router.replace(basePath); + } } }, [ sessionPendingDeletion, deletingSessionIds, deleteCliSessionV2, currentSessionId, + openSession, + openWorktree, + openWorktreeChats, organizationId, router, + selectedWorktreeId, + worktreeChats, ]); const handleRenameSession = useCallback( async (sessionId: string, title: string) => { await renameCliSessionV2({ session_id: sessionId, title }); renameSessionLocally(sessionId, title); + void queryClient.invalidateQueries(trpc.cliSessionsV2.worktreeDetails.pathFilter()); void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); void queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()); refetchSessions(); @@ -169,23 +590,112 @@ export function CloudSidebarLayout({ organizationId, children }: CloudSidebarLay [renameCliSessionV2, renameSessionLocally, queryClient, trpc, refetchSessions] ); + const handleRenameWorktree = useCallback( + async (worktreeId: string, name: string) => { + await renameWorktree({ + worktreeId: cloudAgentWorktreeIdSchema.parse(worktreeId), + name, + organizationId: organizationId ?? null, + }); + await Promise.all([ + queryClient.invalidateQueries(trpc.cliSessionsV2.worktreeDetails.pathFilter()), + queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()), + ]); + }, + [organizationId, queryClient, renameWorktree, trpc] + ); + + const handleConfirmWorktreeDelete = useCallback(async () => { + if (!worktreePendingDeletion || deletingWorktreeId) return; + + setDeletingWorktreeId(worktreePendingDeletion); + try { + const { deletedSessionIds } = await deleteWorktree({ + worktreeId: cloudAgentWorktreeIdSchema.parse(worktreePendingDeletion), + organizationId: organizationId ?? null, + }); + await Promise.all( + deletedSessionIds.map(sessionId => + removeDeletedSession({ + sessionId, + queryClient, + trpc, + setDbSessions, + deleteSessionFromStore, + }) + ) + ); + forgetWorktreeTabs(worktreePendingDeletion, deletedSessionIds); + setWorktreePendingDeletion(undefined); + if ( + selectedWorktreeId === worktreePendingDeletion || + (currentSessionId && deletedSessionIds.includes(currentSessionId)) + ) { + router.push(organizationId ? `/organizations/${organizationId}/cloud` : '/cloud'); + } + void invalidateSessionQueries({ queryClient, trpc }); + toast.success('Worktree deleted'); + } catch (error) { + toast.error('Failed to delete worktree', { description: formatSessionError(error) }); + } finally { + setDeletingWorktreeId(undefined); + } + }, [ + currentSessionId, + deleteSessionFromStore, + deleteWorktree, + deletingWorktreeId, + forgetWorktreeTabs, + organizationId, + queryClient, + router, + selectedWorktreeId, + setDbSessions, + trpc, + worktreePendingDeletion, + ]); + return ( setMobileSheetOpen(prev => !prev) }} + value={{ + currentSessionId: currentSessionId ?? null, + selectedWorktreeId, + worktreeChats, + openWorktreeChats, + closedWorktreeChats, + openSession, + closeSession, + renameSession: handleRenameSession, + deletingSessionIds, + toggleMobileSidebar: () => setMobileSheetOpen(prev => !prev), + createWorktreeChat: handleCreateWorktreeChat, + creatingWorktreeSourceSessionId, + }} >
{/* Mobile Sheet */} - + Sessions {/* Main Content */} -
{children}
+
{children}
+ { + if (!open && !deletingWorktreeId) setWorktreePendingDeletion(undefined); + }} + > + + + Delete worktree? + + This permanently deletes every chat and the worktree’s files, stops its running work, + and cleans up its sandbox resources. Other worktrees are not affected. + + + + + Cancel + + { + event.preventDefault(); + void handleConfirmWorktreeDelete(); + }} + > + {deletingWorktreeId ? 'Deleting...' : 'Delete worktree'} + + + +
); } diff --git a/apps/web/src/components/cloud-agent-next/MobileSidebarToggle.tsx b/apps/web/src/components/cloud-agent-next/MobileSidebarToggle.tsx index 158cde608d..389dd92da1 100644 --- a/apps/web/src/components/cloud-agent-next/MobileSidebarToggle.tsx +++ b/apps/web/src/components/cloud-agent-next/MobileSidebarToggle.tsx @@ -18,7 +18,7 @@ export function MobileSidebarToggle({ type="button" onClick={toggleMobileSidebar} className={cn( - 'text-muted-foreground hover:text-foreground border-border hover:bg-accent cursor-pointer rounded-md border px-2.5 py-1 text-sm transition-colors lg:hidden', + 'text-muted-foreground hover:text-foreground border-border hover:bg-accent cursor-pointer rounded-md border px-2.5 py-1 text-sm transition-colors lg:hidden [@media(any-pointer:coarse)]:min-h-11', variant === 'floating' ? 'absolute left-3 top-3 z-10' : 'shrink-0' )} > diff --git a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx index abcfaa0add..14d9779cf3 100644 --- a/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx +++ b/apps/web/src/components/cloud-agent-next/NewSessionPanel.tsx @@ -68,7 +68,12 @@ import { findAllGitPlatformUrls, detectGitPlatform, } from '@/components/cloud-agent-next/utils/git-utils'; -import type { AgentMode } from './types'; +import { + getCloudSessionCreationOperation, + isAmbiguousCloudSessionCreationError, + type AgentMode, + type CloudSessionCreationOperation, +} from './types'; import { formatSessionError } from '@kilocode/cloud-agent-sdk'; import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; import type { CustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; @@ -148,6 +153,9 @@ export function NewSessionPanel({ const textareaRef = useRef(null); const fileInputRef = useRef(null); const commandListRef = useRef(null); + const firstChatCreationOperationRef = useRef< + (CloudSessionCreationOperation & { initialMessageId: string }) | null + >(null); const [devcontainer, setDevcontainer] = useState(false); const [isGitHubIdentityHintDismissed, setIsGitHubIdentityHintDismissed] = useState< boolean | null @@ -1028,7 +1036,6 @@ export function NewSessionPanel({ setIsPreparing(true); try { - const initialMessageId = generateMessageId(); const trimmed = prompt.trim(); // Parse slash command: if the input matches a known command, send a @@ -1048,7 +1055,7 @@ export function NewSessionPanel({ return; } - const baseInput = { + const creationInput = { prompt: trimmed, mode, model: displayModel, @@ -1056,7 +1063,6 @@ export function NewSessionPanel({ profileId: selectedProfileId ?? undefined, autoCommit: true, autoInitiate: true, - initialMessageId, attachments: await attachmentUpload.finalizeAttachments(), ...(slashCommand ? { @@ -1069,6 +1075,25 @@ export function NewSessionPanel({ : {}), ...(effectiveDevcontainer ? { devcontainer: true } : {}), }; + const intent = JSON.stringify({ + ...creationInput, + organizationId: organizationId ?? null, + repository: selectedRepo, + platform: selectedPlatform, + bitbucketRepo, + }); + const previousOperation = firstChatCreationOperationRef.current; + const pendingOperation = getCloudSessionCreationOperation(previousOperation, intent, uuidv4); + const operation = + previousOperation?.operationKey === pendingOperation.operationKey + ? previousOperation + : { ...pendingOperation, initialMessageId: generateMessageId() }; + firstChatCreationOperationRef.current = operation; + const baseInput = { + ...creationInput, + initialMessageId: operation.initialMessageId, + operationKey: operation.operationKey, + }; let result: { kiloSessionId: string; cloudAgentSessionId: string }; if (organizationId) { @@ -1104,6 +1129,10 @@ export function NewSessionPanel({ }); } + if (firstChatCreationOperationRef.current?.operationKey === operation.operationKey) { + firstChatCreationOperationRef.current = null; + } + if (!hasAgentModelOverride) { setLastUsedModel(model, organizationId); } @@ -1127,6 +1156,9 @@ export function NewSessionPanel({ router.push(`${basePath}/chat?sessionId=${result.kiloSessionId}`); setBillingFailure(null); } catch (error) { + if (!isAmbiguousCloudSessionCreationError(error)) { + firstChatCreationOperationRef.current = null; + } const failure = parseCustomerBillingFailure(error); setBillingFailure(failure); console.error('Failed to prepare session:', error); diff --git a/apps/web/src/components/cloud-agent-next/PrHoverCardContent.tsx b/apps/web/src/components/cloud-agent-next/PrHoverCardContent.tsx index 6b58ea057a..2afdbe9c37 100644 --- a/apps/web/src/components/cloud-agent-next/PrHoverCardContent.tsx +++ b/apps/web/src/components/cloud-agent-next/PrHoverCardContent.tsx @@ -66,6 +66,7 @@ export function PrHoverCardContent({ pr, sessionId, gitBranch }: PrHoverCardCont onSuccess: () => { void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); void queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.worktreeDetails.pathFilter()); }, onError: error => { toast.error(error?.message ?? 'Failed to refresh pull request status'); diff --git a/apps/web/src/components/cloud-agent-next/chat-input-attachments.test.ts b/apps/web/src/components/cloud-agent-next/chat-input-attachments.test.ts index 0d75397273..ae868146f1 100644 --- a/apps/web/src/components/cloud-agent-next/chat-input-attachments.test.ts +++ b/apps/web/src/components/cloud-agent-next/chat-input-attachments.test.ts @@ -1,15 +1,93 @@ +import type { SlashCommand } from '@/lib/cloud-agent/slash-commands'; import { acceptedSubmissionAttachmentIdsToRemove, + getChatInputPresentationCommands, hasSubmissionAttachmentPayload, + isWorktreeNewChatCommand, shouldRejectAttachedSlashCommand, } from './chat-input-attachments'; -const slashCommands = [{ trigger: 'compact' }, { trigger: 'review' }]; +const slashCommands: SlashCommand[] = [ + { trigger: 'compact', label: 'Compact', description: 'Compact the conversation', expansion: '' }, + { trigger: 'review', label: 'Review', description: 'Review changes', expansion: '' }, +]; + +describe('getChatInputPresentationCommands', () => { + const newChatCommand: SlashCommand = { + trigger: 'new', + label: 'New chat', + description: 'Start a new chat in this worktree', + expansion: '', + }; + + it.each([ + { name: 'populated', commands: slashCommands }, + { name: 'empty', commands: [] }, + ])('adds /new to a $name catalog when a worktree action is available', ({ commands }) => { + expect(getChatInputPresentationCommands(commands, true)).toEqual([...commands, newChatCommand]); + }); + + it.each([ + { name: 'populated', commands: slashCommands }, + { name: 'empty', commands: [] }, + ])('preserves a $name catalog without a worktree action', ({ commands }) => { + const originalCommands = commands.map(command => ({ ...command })); + + expect(getChatInputPresentationCommands(commands, false)).toEqual(originalCommands); + expect(commands).toEqual(originalCommands); + }); + + it('preserves an existing /new command without duplicating or replacing it', () => { + const existingNewCommand: SlashCommand = { + trigger: 'new', + label: 'Existing new', + description: 'Existing command', + expansion: 'Keep me', + }; + const commands = [...slashCommands, existingNewCommand]; + + expect(getChatInputPresentationCommands(commands, true)).toEqual([ + ...slashCommands, + existingNewCommand, + ]); + }); + + it('leaves the backend catalog unchanged for recognition and attachment checks', () => { + const commands = slashCommands.map(command => ({ ...command })); + const presentationCommands = getChatInputPresentationCommands(commands, true); + + expect(presentationCommands).not.toBe(commands); + expect(commands).toEqual(slashCommands); + for (const message of ['/new extra', '/newer', '/NEW']) { + expect(shouldRejectAttachedSlashCommand(message, commands, true)).toBe(false); + } + }); +}); + +describe('isWorktreeNewChatCommand', () => { + it('intercepts only exact trimmed lowercase /new for a grouped web session', () => { + expect(isWorktreeNewChatCommand('/new', true)).toBe(true); + expect(isWorktreeNewChatCommand(' /new\n', true)).toBe(true); + expect(isWorktreeNewChatCommand('/new extra', true)).toBe(false); + expect(isWorktreeNewChatCommand('/newer', true)).toBe(false); + expect(isWorktreeNewChatCommand('/NEW', true)).toBe(false); + }); + + it('preserves ordinary slash dispatch for sessions without a worktree action', () => { + expect(isWorktreeNewChatCommand('/new', false)).toBe(false); + }); +}); + describe('shouldRejectAttachedSlashCommand', () => { it('rejects recognized slash commands before dispatch when displayed files are attached', () => { expect(shouldRejectAttachedSlashCommand('/compact now', slashCommands, true)).toBe(true); }); + it('rejects worktree creation with attachments even when /new is absent from the command catalog', () => { + expect(shouldRejectAttachedSlashCommand('/new', slashCommands, true, true)).toBe(true); + expect(shouldRejectAttachedSlashCommand('/new', slashCommands, false, true)).toBe(false); + }); + it('allows normal prompts, unknown slash text, and commands without files', () => { expect(shouldRejectAttachedSlashCommand('summarize this', slashCommands, true)).toBe(false); expect(shouldRejectAttachedSlashCommand('/unknown', slashCommands, true)).toBe(false); diff --git a/apps/web/src/components/cloud-agent-next/chat-input-attachments.ts b/apps/web/src/components/cloud-agent-next/chat-input-attachments.ts index 69b919417d..f149426b5c 100644 --- a/apps/web/src/components/cloud-agent-next/chat-input-attachments.ts +++ b/apps/web/src/components/cloud-agent-next/chat-input-attachments.ts @@ -27,12 +27,37 @@ export function acceptedSubmissionAttachmentIdsToRemove( .map(attachment => attachment.id); } +export function getChatInputPresentationCommands( + slashCommands: SlashCommand[], + hasWorktreeAction: boolean +): SlashCommand[] { + if (!hasWorktreeAction || slashCommands.some(command => command.trigger === 'new')) { + return slashCommands; + } + + return [ + ...slashCommands, + { + trigger: 'new', + label: 'New chat', + description: 'Start a new chat in this worktree', + expansion: '', + }, + ]; +} + +export function isWorktreeNewChatCommand(message: string, hasWorktreeAction: boolean): boolean { + return hasWorktreeAction && message.trim() === '/new'; +} + export function shouldRejectAttachedSlashCommand( message: string, slashCommands: Pick[], - hasAttachments: boolean + hasAttachments: boolean, + isWorktreeChatCommand = false ): boolean { if (!hasAttachments) return false; + if (isWorktreeChatCommand) return true; const slashMatch = /^\s*\/([\w.-]+)(?:\s+([\s\S]*))?\s*$/.exec(message.trim()); return Boolean(slashMatch && slashCommands.some(command => command.trigger === slashMatch[1])); diff --git a/apps/web/src/components/cloud-agent-next/hooks/useClosedWorktreeChatTabs.ts b/apps/web/src/components/cloud-agent-next/hooks/useClosedWorktreeChatTabs.ts new file mode 100644 index 0000000000..2237dc1c63 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/hooks/useClosedWorktreeChatTabs.ts @@ -0,0 +1,86 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { safeLocalStorage } from '@/lib/localStorage'; +import { + parseWorktreeChatTabs, + reduceWorktreeChatTabs, + type WorktreeChatTabsAction, + type WorktreeChatTabsState, +} from '../worktree-chat-tabs'; + +type ScopedClosedWorktreeChatTabs = { + storageKey: string | null; + tabs: WorktreeChatTabsState; +}; + +export function useClosedWorktreeChatTabs(storageKey: string | null) { + const [state, setState] = useState(null); + const stateRef = useRef(null); + + const getScopedState = useCallback(() => { + const current = stateRef.current; + if (current?.storageKey === storageKey) return current; + + const loaded = { + storageKey, + tabs: parseWorktreeChatTabs( + storageKey === null ? null : safeLocalStorage.getItem(storageKey) + ), + }; + stateRef.current = loaded; + return loaded; + }, [storageKey]); + + useEffect(() => { + setState(getScopedState()); + }, [getScopedState]); + + const update = useCallback( + (action: WorktreeChatTabsAction) => { + const current = getScopedState(); + const tabs = reduceWorktreeChatTabs(current.tabs, action); + const next = tabs === current.tabs ? current : { storageKey, tabs }; + stateRef.current = next; + setState(next); + if (storageKey !== null && next !== current) { + safeLocalStorage.setItem(storageKey, JSON.stringify(tabs)); + } + }, + [getScopedState, storageKey] + ); + + const openChatTab = useCallback( + (sessionId: string) => update({ type: 'open', sessionId }), + [update] + ); + const closeChatTab = useCallback( + (sessionId: string) => update({ type: 'close', sessionId }), + [update] + ); + const replaceChatTab = useCallback( + ( + worktreeId: string, + oldSessionId: string, + newSessionId: string, + openSessionIds: readonly string[] + ) => update({ type: 'replace', worktreeId, oldSessionId, newSessionId, openSessionIds }), + [update] + ); + + const forgetWorktreeTabs = useCallback( + (worktreeId: string, sessionIds: readonly string[]) => + update({ type: 'forgetWorktree', worktreeId, sessionIds }), + [update] + ); + + return { + closedSessionIds: state?.storageKey === storageKey ? state.tabs.closedSessionIds : [], + sessionOrderByWorktree: + state?.storageKey === storageKey ? state.tabs.sessionOrderByWorktree : {}, + openChatTab, + closeChatTab, + replaceChatTab, + forgetWorktreeTabs, + }; +} diff --git a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts index ebd84c96f5..f767554003 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts @@ -1,15 +1,81 @@ -import type { UserWebSessionEventData } from '@kilocode/cloud-agent-sdk'; +import type { MessageDeliveryState, UserWebSessionEventData } from '@kilocode/cloud-agent-sdk'; +import React, { createElement, type ComponentProps } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import type * as DropdownMenuComponents from '@/components/ui/dropdown-menu'; +import type * as DropdownMenuPrimitives from '@radix-ui/react-dropdown-menu'; +import { ChatSidebar } from '../ChatSidebar'; +import { SessionPrIndicator } from '../SessionPrIndicator'; import type { DbSessionV2 } from '../store/db-session-atoms'; +import type { StoredSession } from '../types'; import { createSidebarQueryReconciler, dbSessionToStoredSession, dbSessionMatchesSearch, + deriveForegroundSessionStatus, eventRowMatchesSidebarFilters, + eventRowToDbSession, + getSidebarWorktreeActivity, + getSidebarWorktreeLabel, + getSidebarWorktreePrSession, + groupSidebarSessions, + groupSidebarSessionsByDate, + mergeWorktreeChatSessions, removeSidebarDbSession, + sessionCacheKey, SIDEBAR_RECONCILE_DELAY_MS, upsertSidebarDbSession, + type SidebarWorktreeDetails, } from './useSidebarSessions'; +Object.assign(globalThis, { React }); + +let mockRenderOpenMenus = false; + +jest.mock('next/navigation', () => ({ + usePathname: () => '/cloud/chat', + useRouter: () => ({ push: jest.fn() }), +})); + +jest.mock('@/components/ui/dropdown-menu', () => { + const actual = jest.requireActual('@/components/ui/dropdown-menu'); + const primitives = jest.requireActual( + '@radix-ui/react-dropdown-menu' + ); + const { createElement } = jest.requireActual('react'); + + return { + ...actual, + DropdownMenu: (props: ComponentProps) => + createElement(actual.DropdownMenu, { + ...props, + open: mockRenderOpenMenus ? true : props.open, + }), + DropdownMenuContent: (props: ComponentProps) => + createElement(primitives.Content, props), + DropdownMenuItem: jest.fn(actual.DropdownMenuItem), + }; +}); + +jest.mock('../SessionPrIndicator', () => { + const actual = jest.requireActual<{ SessionPrIndicator: typeof SessionPrIndicator }>( + '../SessionPrIndicator' + ); + return { ...actual, SessionPrIndicator: jest.fn(actual.SessionPrIndicator) }; +}); + +const associatedPr = { + url: 'https://github.com/kilo/repo/pull/42', + number: 42, + state: 'open', + title: 'Worktree changes', + headSha: 'abc123', + lastSyncedAt: '2026-01-01T00:00:00.000Z', + reviewDecision: null, + reviewDecisionPending: false, + platform: 'github', +} satisfies NonNullable; + function makeDbSession(sessionId: string, updatedAt: string): DbSessionV2 { return { session_id: sessionId, @@ -28,6 +94,34 @@ function makeDbSession(sessionId: string, updatedAt: string): DbSessionV2 { }; } +function makeStoredSession( + sessionId: string, + updatedAt: string, + overrides: Partial = {} +): StoredSession { + return { ...dbSessionToStoredSession(makeDbSession(sessionId, updatedAt)), ...overrides }; +} + +type ForegroundSessionStatusInput = Parameters[0]; + +function makeForegroundSessionStatusInput( + overrides: Partial = {} +): ForegroundSessionStatusInput { + return { + currentSessionId: 'ses_foreground', + organizationId: null, + activeSessionType: 'cloud-agent', + fetchedSessionData: { kiloSessionId: 'ses_foreground', organizationId: null }, + activity: { type: 'idle' }, + isStreaming: false, + activeQuestion: null, + activePermission: null, + cloudStatus: null, + pendingMessages: new Map(), + ...overrides, + }; +} + function makeEventRow(overrides?: Partial['session']>) { return { source: 'v2', @@ -105,6 +199,48 @@ describe('useSidebarSessions live update helpers', () => { }); }); + it('preserves an existing group when an older live event omits its worktree', () => { + const cached = { + ...makeDbSession('ses_grouped', '2026-01-01T00:00:00.000Z'), + cloud_agent_worktree_id: 'worktree_existing', + }; + const olderEvent = eventRowToDbSession( + makeEventRow({ sessionId: 'ses_grouped', updatedAt: '2026-01-02T00:00:00.000Z' }) + ); + + expect(upsertSidebarDbSession([cached], olderEvent)[0]).toMatchObject({ + cloud_agent_worktree_id: 'worktree_existing', + }); + }); + + it('adopts a user-wide event worktree and maps it through the stored sidebar session', () => { + const cached = makeDbSession('ses_grouped', '2026-01-01T00:00:00.000Z'); + const groupedEvent = eventRowToDbSession( + makeEventRow({ + sessionId: 'ses_grouped', + updatedAt: '2026-01-02T00:00:00.000Z', + worktreeId: 'worktree_added_live', + }) + ); + + const [updated] = upsertSidebarDbSession([cached], groupedEvent); + + expect(dbSessionToStoredSession(updated).worktreeId).toBe('worktree_added_live'); + }); + + it('invalidates sidebar equality when only the group identity changes', () => { + const base = { + session_id: 'ses_grouped', + updated_at: '2026-01-01T00:00:00.000Z', + status: 'idle', + status_updated_at: null, + }; + + expect(sessionCacheKey(base)).not.toBe( + sessionCacheKey({ ...base, cloud_agent_worktree_id: 'worktree_added_live' }) + ); + }); + it('removes deleted rows immediately from the visible list', () => { const result = removeSidebarDbSession( [ @@ -151,6 +287,501 @@ describe('useSidebarSessions live update helpers', () => { expect(dbSessionToStoredSession(session).associatedPr).toEqual(session.associatedPr); }); + describe('deriveForegroundSessionStatus', () => { + it('prioritizes permissions over questions, retries, and running work', () => { + const result = deriveForegroundSessionStatus( + makeForegroundSessionStatusInput({ + activePermission: { requestId: 'permission' }, + activeQuestion: { requestId: 'question' }, + activity: { type: 'retrying', attempt: 1, message: 'Retrying' }, + isStreaming: true, + cloudStatus: { type: 'preparing' }, + pendingMessages: new Map([ + ['message', { status: 'queued' }], + ]), + }) + ); + + expect(result).toBe('permission'); + }); + + it('prioritizes questions over retries and running work', () => { + const result = deriveForegroundSessionStatus( + makeForegroundSessionStatusInput({ + activeQuestion: { requestId: 'question' }, + activity: { type: 'retrying', attempt: 1, message: 'Retrying' }, + isStreaming: true, + }) + ); + + expect(result).toBe('question'); + }); + + it('prioritizes retries over streaming, cloud preparation, and queued messages', () => { + const result = deriveForegroundSessionStatus( + makeForegroundSessionStatusInput({ + activity: { type: 'retrying', attempt: 1, message: 'Retrying' }, + isStreaming: true, + cloudStatus: { type: 'preparing' }, + pendingMessages: new Map([ + ['message', { status: 'queued' }], + ]), + }) + ); + + expect(result).toBe('retry'); + }); + + it.each<[string, Partial]>([ + ['busy activity', { activity: { type: 'busy' } }], + ['streaming', { isStreaming: true }], + ['cloud preparation', { cloudStatus: { type: 'preparing' } }], + ['cloud finalization', { cloudStatus: { type: 'finalizing' } }], + [ + 'queued message delivery', + { + pendingMessages: new Map([ + ['message', { status: 'queued' }], + ]), + }, + ], + ])('reports busy for %s', (_label, overrides) => { + expect(deriveForegroundSessionStatus(makeForegroundSessionStatusInput(overrides))).toBe( + 'busy' + ); + }); + + it('returns idle instead of treating failed queued-message history as active', () => { + const result = deriveForegroundSessionStatus( + makeForegroundSessionStatusInput({ + pendingMessages: new Map([ + [ + 'failed-message', + { status: 'failed', error: 'Delivery failed', reason: 'exhausted', attempts: 3 }, + ], + ]), + }) + ); + + expect(result).toBe('idle'); + }); + + it('derives foreground status within the exact organization scope', () => { + const result = deriveForegroundSessionStatus( + makeForegroundSessionStatusInput({ + organizationId: 'organization-current', + fetchedSessionData: { + kiloSessionId: 'ses_foreground', + organizationId: 'organization-current', + }, + isStreaming: true, + }) + ); + + expect(result).toBe('busy'); + }); + + it.each<[string, Partial]>([ + ['a remote session', { activeSessionType: 'remote' }], + ['a missing current session', { currentSessionId: null }], + ['missing fetched session data', { fetchedSessionData: null }], + [ + 'a different fetched session', + { fetchedSessionData: { kiloSessionId: 'ses_different', organizationId: null } }, + ], + [ + 'an organization session on a personal route', + { + fetchedSessionData: { + kiloSessionId: 'ses_foreground', + organizationId: 'organization-foreign', + }, + }, + ], + ['a personal session on an organization route', { organizationId: 'organization-current' }], + [ + 'a different organization', + { + organizationId: 'organization-current', + fetchedSessionData: { + kiloSessionId: 'ses_foreground', + organizationId: 'organization-foreign', + }, + }, + ], + ])('does not override status for %s', (_label, overrides) => { + expect( + deriveForegroundSessionStatus( + makeForegroundSessionStatusInput({ ...overrides, isStreaming: true }) + ) + ).toBeNull(); + }); + }); + + describe('mergeWorktreeChatSessions', () => { + const worktreeId = 'worktree_shared'; + + it('applies newer live titles and statuses while preserving authoritative runtime identity', () => { + const authoritative = makeStoredSession('ses_existing', '2026-01-01T00:00:00.000Z', { + worktreeId, + prompt: 'Original title', + cloudAgentSessionId: 'workspace_authoritative', + status: 'active', + sessionStatus: 'idle', + }); + const live = makeStoredSession('ses_existing', '2026-01-02T00:00:00.000Z', { + worktreeId, + prompt: 'Live title', + cloudAgentSessionId: null, + status: 'completed', + sessionStatus: 'busy', + sessionStatusUpdatedAt: '2026-01-02T00:00:00.000Z', + }); + + expect(mergeWorktreeChatSessions(worktreeId, [authoritative], [live])).toEqual([ + expect.objectContaining({ + sessionId: 'ses_existing', + prompt: 'Live title', + cloudAgentSessionId: 'workspace_authoritative', + status: 'active', + sessionStatus: 'busy', + sessionStatusUpdatedAt: '2026-01-02T00:00:00.000Z', + }), + ]); + }); + + it('keeps authoritative siblings and includes newly cached siblings from the selected worktree', () => { + const authoritative = makeStoredSession('ses_authoritative', '2026-01-02T00:00:00.000Z', { + worktreeId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + const newlyCreated = makeStoredSession('ses_new', '2026-01-03T00:00:00.000Z', { + worktreeId, + createdAt: '2026-01-03T00:00:00.000Z', + }); + const unrelatedAuthoritative = makeStoredSession( + 'ses_authoritative_foreign', + '2026-01-03T00:00:00.000Z', + { worktreeId: 'worktree_foreign' } + ); + const unrelatedCached = makeStoredSession('ses_cached_foreign', '2026-01-03T00:00:00.000Z', { + worktreeId: 'worktree_foreign', + }); + + const result = mergeWorktreeChatSessions( + worktreeId, + [unrelatedAuthoritative, authoritative], + [unrelatedCached, newlyCreated] + ); + + expect(result.map(session => session.sessionId)).toEqual(['ses_authoritative', 'ses_new']); + }); + + it('does not replace newer authoritative data with stale cached session data', () => { + const authoritative = makeStoredSession('ses_existing', '2026-01-03T00:00:00.000Z', { + worktreeId, + prompt: 'Authoritative title', + sessionStatus: 'idle', + }); + const stale = makeStoredSession('ses_existing', '2026-01-02T00:00:00.000Z', { + worktreeId, + prompt: 'Stale title', + sessionStatus: 'busy', + }); + + expect(mergeWorktreeChatSessions(worktreeId, [authoritative], [stale])).toEqual([ + expect.objectContaining({ prompt: 'Authoritative title', sessionStatus: 'idle' }), + ]); + }); + + it('keeps optimistic cached titles when authoritative and cached timestamps match', () => { + const authoritative = makeStoredSession('ses_existing', '2026-01-02T00:00:00.000Z', { + worktreeId, + prompt: 'Previous title', + }); + const renamed = makeStoredSession('ses_existing', '2026-01-02T00:00:00.000Z', { + worktreeId, + prompt: 'Optimistically renamed', + }); + + expect(mergeWorktreeChatSessions(worktreeId, [authoritative], [renamed])).toEqual([ + expect.objectContaining({ prompt: 'Optimistically renamed' }), + ]); + }); + + it('sorts chats by creation time and uses session identity for deterministic ties', () => { + const latest = makeStoredSession('ses_latest', '2026-01-01T00:00:00.000Z', { + worktreeId, + createdAt: '2026-01-03T00:00:00.000Z', + }); + const firstTie = makeStoredSession('ses_a', '2026-01-03T00:00:00.000Z', { + worktreeId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + const secondTie = makeStoredSession('ses_b', '2026-01-02T00:00:00.000Z', { + worktreeId, + createdAt: '2026-01-01T00:00:00.000Z', + }); + + const result = mergeWorktreeChatSessions(worktreeId, [latest, secondTie], [firstTie]); + + expect(result.map(session => session.sessionId)).toEqual(['ses_a', 'ses_b', 'ses_latest']); + }); + }); + + describe('sidebar worktree grouping', () => { + it('groups siblings before date bucketing so a worktree appears only once', () => { + const sessions = [ + makeStoredSession('ses_today', '2026-01-03T12:00:00.000Z', { + worktreeId: 'worktree_shared', + }), + makeStoredSession('ses_yesterday', '2026-01-02T12:00:00.000Z', { + worktreeId: 'worktree_shared', + }), + makeStoredSession('ses_ungrouped', '2026-01-02T11:00:00.000Z'), + ]; + + const result = groupSidebarSessionsByDate(sessions, new Date('2026-01-03T18:00:00.000Z')); + + expect(result.map(group => ({ label: group.label, size: group.items.length }))).toEqual([ + { label: 'Today', size: 1 }, + { label: 'Yesterday', size: 1 }, + ]); + expect(result[0]?.items[0]).toMatchObject({ + type: 'worktree', + worktreeId: 'worktree_shared', + sessions: [{ sessionId: 'ses_today' }, { sessionId: 'ses_yesterday' }], + }); + expect(result[1]?.items[0]).toMatchObject({ + type: 'session', + session: { sessionId: 'ses_ungrouped' }, + }); + }); + + it('orders groups and their children by the newest sibling activity', () => { + const result = groupSidebarSessions([ + makeStoredSession('ses_older_group', '2026-01-01T10:00:00.000Z', { + worktreeId: 'worktree_latest', + }), + makeStoredSession('ses_ungrouped', '2026-01-02T10:00:00.000Z'), + makeStoredSession('ses_newest_group', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_latest', + }), + ]); + + expect(result[0]).toMatchObject({ + type: 'worktree', + latestSession: { sessionId: 'ses_newest_group' }, + sessions: [{ sessionId: 'ses_newest_group' }, { sessionId: 'ses_older_group' }], + }); + expect(result[1]).toMatchObject({ type: 'session', session: { sessionId: 'ses_ungrouped' } }); + }); + + it('derives stable group labels from repository and branch metadata', () => { + const [group] = groupSidebarSessions([ + makeStoredSession('ses_grouped', '2026-01-03T10:00:00.000Z', { + repository: 'kilo/repo', + branch: 'feature/shared-chat', + worktreeId: 'worktree_shared', + }), + ]); + if (group?.type !== 'worktree') throw new Error('Expected worktree group'); + + expect(getSidebarWorktreeLabel(group)).toBe('kilo/repo · feature/shared-chat'); + }); + + it.each([ + { + name: 'Custom worktree name', + defaultTitle: 'Authoritative first chat title', + expected: 'Custom worktree name', + }, + { + name: null, + defaultTitle: 'Authoritative first chat title', + expected: 'Authoritative first chat title', + }, + { name: null, defaultTitle: null, expected: 'kilo/repo · main' }, + ])('keeps $expected independent of visible chat titles and activity', details => { + const first = makeStoredSession('ses_first', '2026-01-01T10:00:00.000Z', { + worktreeId: 'worktree_shared', + prompt: 'Visible older chat title', + }); + const latest = makeStoredSession('ses_latest', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + prompt: 'Visible latest chat title', + }); + const worktreeDetails = { + worktree_shared: { + name: details.name, + defaultTitle: details.defaultTitle, + prSession: null, + }, + } satisfies Record; + + for (const sessions of [[first, latest], [latest], [{ ...first, prompt: 'Renamed chat' }]]) { + const [group] = groupSidebarSessions(sessions, worktreeDetails); + if (group?.type !== 'worktree') throw new Error('Expected worktree group'); + + expect(getSidebarWorktreeLabel(group)).toBe(details.expected); + expect(group.details).toBe(worktreeDetails.worktree_shared); + } + }); + + it('carries authoritative metadata through date grouping without adding invisible worktrees', () => { + const visible = makeStoredSession('ses_visible', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + }); + const details: SidebarWorktreeDetails = { + name: 'Named worktree', + defaultTitle: 'First chat outside the filtered list', + prSession: null, + }; + const result = groupSidebarSessionsByDate([visible], new Date('2026-01-03T18:00:00.000Z'), { + worktree_shared: details, + worktree_hidden: details, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.items).toHaveLength(1); + expect(result[0]?.items[0]).toMatchObject({ + type: 'worktree', + worktreeId: 'worktree_shared', + details, + latestSession: visible, + }); + }); + + it('uses repository and branch instead of a visible chat title while metadata is absent', () => { + const [group] = groupSidebarSessions([ + makeStoredSession('ses_filtered', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + prompt: 'This filtered chat must not become the worktree name', + repository: '', + branch: null, + }), + ]); + if (group?.type !== 'worktree') throw new Error('Expected worktree group'); + + expect(getSidebarWorktreeLabel(group)).toBe('Repository'); + }); + + it('uses the most recently active PR-bearing sibling rather than the latest chat', () => { + const olderPr = makeStoredSession('ses_older_pr', '2026-01-01T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr, + }); + const latestPr = makeStoredSession('ses_latest_pr', '2026-01-02T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: { ...associatedPr, number: 43 }, + }); + const latestChat = makeStoredSession('ses_latest_chat', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + }); + const pendingChat = makeStoredSession('ses_pending_chat', '2026-01-04T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: undefined, + }); + const [group] = groupSidebarSessions([olderPr, latestChat, latestPr, pendingChat]); + if (group?.type !== 'worktree') throw new Error('Expected worktree group'); + + expect(group.latestSession).toBe(pendingChat); + expect(getSidebarWorktreePrSession(group)).toBe(latestPr); + }); + + it('uses the existing deterministic sibling order for equally active PR sources', () => { + const first = makeStoredSession('ses_a', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr, + }); + const second = { ...first, sessionId: 'ses_b' }; + + for (const sessions of [ + [first, second], + [second, first], + ]) { + const [group] = groupSidebarSessions(sessions); + if (group?.type !== 'worktree') throw new Error('Expected worktree group'); + + expect(getSidebarWorktreePrSession(group)).toBe(second); + } + }); + + it('prefers an authoritative PR source outside the visible group and honors confirmed absence', () => { + const visible = makeStoredSession('ses_visible', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr, + }); + const hidden = makeStoredSession('ses_hidden', '2026-01-04T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: { ...associatedPr, number: 43 }, + }); + + for (const prSession of [hidden, { ...hidden, associatedPr: undefined }, null]) { + const [group] = groupSidebarSessions([visible], { + worktree_shared: { name: null, defaultTitle: null, prSession }, + }); + if (group?.type !== 'worktree') throw new Error('Expected worktree group'); + + expect(getSidebarWorktreePrSession(group)).toBe(prSession); + } + }); + + it('preserves a real pending PR source only when a branch can show the loading indicator', () => { + const confirmed = makeStoredSession('ses_confirmed', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + }); + const pending = makeStoredSession('ses_pending', '2026-01-02T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: undefined, + }); + const withoutBranch = makeStoredSession('ses_unbranched', '2026-01-04T10:00:00.000Z', { + worktreeId: 'worktree_shared', + branch: null, + associatedPr: undefined, + }); + const [group] = groupSidebarSessions([confirmed, pending, withoutBranch]); + const [loadedGroup] = groupSidebarSessions([confirmed, withoutBranch]); + if (group?.type !== 'worktree' || loadedGroup?.type !== 'worktree') { + throw new Error('Expected worktree groups'); + } + + expect(getSidebarWorktreePrSession(group)).toBe(pending); + expect(getSidebarWorktreePrSession(loadedGroup)).toBeNull(); + }); + + it('prioritizes attention from any child over another running sibling', () => { + const sessions = [ + makeStoredSession('ses_running', '2026-01-03T12:00:00.000Z', { + sessionStatus: 'busy', + sessionStatusUpdatedAt: '2026-01-03T12:00:00.000Z', + }), + makeStoredSession('ses_waiting', '2026-01-03T11:00:00.000Z', { + sessionStatus: 'question', + sessionStatusUpdatedAt: '2026-01-03T11:00:00.000Z', + }), + ]; + + expect(getSidebarWorktreeActivity(sessions, new Map())).toEqual({ + status: 'question', + statusUpdatedAt: '2026-01-03T11:00:00.000Z', + isLive: false, + }); + }); + + it('reflects live running status even before a stored status update arrives', () => { + const sessions = [makeStoredSession('ses_live', '2026-01-03T12:00:00.000Z')]; + + expect(getSidebarWorktreeActivity(sessions, new Map([['ses_live', 'retry']]))).toEqual({ + status: 'retry', + statusUpdatedAt: null, + isLive: true, + }); + }); + }); + describe('dbSessionMatchesSearch', () => { it('matches by session_id substring', () => { const session = makeDbSession('abc-123-def', '2026-01-01T00:00:00.000Z'); @@ -219,3 +850,282 @@ describe('useSidebarSessions live update helpers', () => { }); }); }); + +type ChatSidebarProps = ComponentProps; + +function renderChatSidebar(overrides: Partial = {}, openMenus = false): string { + mockRenderOpenMenus = openMenus; + jest.mocked(DropdownMenuItem).mockClear(); + jest.mocked(SessionPrIndicator).mockClear(); + return renderToStaticMarkup( + createElement(ChatSidebar, { + sessions: [ + makeStoredSession('ses_latest', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + }), + ], + ...overrides, + }) + ); +} + +function getSidebarMenuItemProps(label: string): ComponentProps { + const props = jest + .mocked(DropdownMenuItem) + .mock.calls.find(([props]) => React.Children.toArray(props.children).includes(label))?.[0]; + if (!props) throw new Error(`Menu item not rendered: ${label}`); + return props; +} + +function getSidebarButtonMarkup(html: string, label: string): string { + const buttons = html.match(/]*>[\s\S]*?<\/button>/g) ?? []; + const button = buttons.find(markup => markup.includes(`aria-label="${label}"`)); + if (!button) throw new Error(`Button not rendered: ${label}`); + return button; +} + +describe('ChatSidebar worktree controls', () => { + it.each([ + { create: false, rename: false, remove: false }, + { create: true, rename: false, remove: false }, + { create: false, rename: true, remove: false }, + { create: false, rename: false, remove: true }, + { create: true, rename: true, remove: false }, + { create: true, rename: false, remove: true }, + { create: false, rename: true, remove: true }, + { create: true, rename: true, remove: true }, + ])('independently gates worktree capabilities: %j', capabilities => { + const html = renderChatSidebar( + { + onCreateWorktreeChat: capabilities.create ? async () => true : undefined, + onRenameWorktree: capabilities.rename ? async () => undefined : undefined, + onDeleteWorktree: capabilities.remove ? () => undefined : undefined, + onRenameSession: async () => undefined, + onDeleteSession: () => undefined, + }, + true + ); + + expect(html.includes('Worktree actions for')).toBe( + capabilities.create || capabilities.rename || capabilities.remove + ); + expect(html.includes('New chat')).toBe(capabilities.create); + expect(html.includes('Rename worktree')).toBe(capabilities.rename); + expect(html.includes('Delete worktree')).toBe(capabilities.remove); + expect(html).not.toContain('Delete session'); + expect(html).not.toContain('Session actions for'); + }); + + it('creates from the latest chat but delegates deletion using only the worktree ID', () => { + const createdFrom: string[] = []; + const deletedWorktrees: string[] = []; + const deletedSessions: string[] = []; + const openedSessions: string[] = []; + renderChatSidebar( + { + sessions: [ + makeStoredSession('ses_older', '2026-01-01T10:00:00.000Z', { + worktreeId: 'worktree_shared', + }), + makeStoredSession('ses_latest', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + }), + ], + onCreateWorktreeChat: async sessionId => { + createdFrom.push(sessionId); + return true; + }, + onDeleteWorktree: worktreeId => deletedWorktrees.push(worktreeId), + onDeleteSession: sessionId => deletedSessions.push(sessionId), + onOpenSession: sessionId => openedSessions.push(sessionId), + }, + true + ); + + getSidebarMenuItemProps('New chat').onSelect?.(new Event('select')); + getSidebarMenuItemProps('Delete worktree').onSelect?.(new Event('select')); + + expect(createdFrom).toEqual(['ses_latest']); + expect(deletedWorktrees).toEqual(['worktree_shared']); + expect(deletedSessions).toEqual([]); + expect(openedSessions).toEqual([]); + }); + + it.each([null, 'busy'])('shares the session action slot for status=%s', sessionStatus => { + const worktreeSession = makeStoredSession('ses_grouped', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + sessionStatus, + }); + const standalone = { ...worktreeSession, sessionId: 'ses_standalone', worktreeId: null }; + const html = renderChatSidebar({ + sessions: [worktreeSession, standalone], + onDeleteWorktree: () => undefined, + onDeleteSession: () => undefined, + }); + const slots = html.match(/class="group\/session-actions[^"]*"/g) ?? []; + const worktreeButton = getSidebarButtonMarkup(html, 'Worktree actions for kilo/repo · main'); + const sessionButton = getSidebarButtonMarkup(html, `Session actions for ${standalone.prompt}`); + + expect(slots).toHaveLength(2); + expect(slots[0]).toBe(slots[1]); + expect(worktreeButton.match(/class="([^"]*)"/)?.[1]).toBe( + sessionButton.match(/class="([^"]*)"/)?.[1] + ); + expect(html.match(/group-focus-within\/session-actions:invisible/g)).toHaveLength(2); + expect(html.match(/focus-within:opacity-100/g)).toHaveLength(2); + expect(html.match(/\[@media\(any-pointer:coarse\)\]:opacity-100/g)).toHaveLength(2); + expect(html.match(/\[@media\(hover:none\)\]:opacity-100/g)).toHaveLength(2); + expect(worktreeButton).not.toContain('tabindex="-1"'); + }); + + it('renders sibling PR information outside the worktree navigation button with a real chat ID', () => { + const source = makeStoredSession('ses_pr_source', '2026-01-02T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr, + }); + const latest = makeStoredSession('ses_latest', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + }); + const html = renderChatSidebar({ + sessions: [source, latest], + onDeleteWorktree: () => undefined, + }); + + expect(html).toContain('aria-label="open pull request #42"'); + expect(getSidebarButtonMarkup(html, 'Open worktree kilo/repo · main')).not.toContain( + 'open pull request' + ); + expect(html).not.toMatch(/]*>(?:(?!<\/button>)[\s\S])* props.session)).toEqual([ + source, + ]); + }); + + it('uses parent metadata for a filtered worktree and honors an authoritative missing PR', () => { + const source = makeStoredSession('ses_hidden_pr', '2026-01-04T10:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr, + }); + const details: SidebarWorktreeDetails = { + name: 'Custom worktree', + defaultTitle: 'Authoritative first chat', + prSession: source, + }; + const html = renderChatSidebar({ worktreeDetails: { worktree_shared: details } }); + + expect(html).toContain('aria-label="Open worktree Custom worktree"'); + expect(jest.mocked(SessionPrIndicator).mock.calls.map(([props]) => props.session)).toEqual([ + source, + ]); + + const withoutPr = renderChatSidebar({ + sessions: [source], + worktreeDetails: { worktree_shared: { ...details, prSession: null } }, + }); + + expect(withoutPr).toContain('aria-label="Open worktree Custom worktree"'); + expect(withoutPr).not.toContain('open pull request'); + expect(jest.mocked(SessionPrIndicator).mock.calls).toHaveLength(0); + }); + + it.each(['ses_latest', 'ses_other'])( + 'keeps unrelated actions available during creation from %s', + sourceId => { + const html = renderChatSidebar( + { + onCreateWorktreeChat: async () => true, + onRenameWorktree: async () => undefined, + onDeleteWorktree: () => undefined, + creatingWorktreeSourceSessionId: sourceId, + }, + true + ); + + expect(getSidebarButtonMarkup(html, 'Worktree actions for kilo/repo · main')).not.toContain( + 'disabled=""' + ); + expect(getSidebarMenuItemProps('New chat').disabled).toBe(true); + expect(getSidebarMenuItemProps('Rename worktree').disabled).not.toBe(true); + expect(getSidebarMenuItemProps('Delete worktree').disabled).toBe(sourceId === 'ses_latest'); + expect(html.includes('aria-label="Creating chat"')).toBe(sourceId === 'ses_latest'); + } + ); + + it('disables navigation and mutation controls only for the deleting worktree', () => { + const deleting = makeStoredSession('ses_deleting', '2026-01-03T10:00:00.000Z', { + worktreeId: 'worktree_deleting', + branch: 'deleting', + associatedPr: null, + }); + const available = { + ...deleting, + sessionId: 'ses_available', + worktreeId: 'worktree_available', + branch: 'available', + }; + const html = renderChatSidebar( + { + sessions: [deleting, available], + deletingWorktreeId: deleting.worktreeId ?? undefined, + onCreateWorktreeChat: async () => true, + onRenameWorktree: async () => undefined, + onDeleteWorktree: () => undefined, + }, + true + ); + + expect(getSidebarButtonMarkup(html, 'Open worktree kilo/repo · deleting')).toContain( + 'disabled=""' + ); + expect(html).toContain('aria-label="Deleting worktree"'); + expect(html).not.toContain('Worktree actions for kilo/repo · deleting'); + expect(getSidebarButtonMarkup(html, 'Open worktree kilo/repo · available')).not.toContain( + 'disabled=""' + ); + expect( + getSidebarButtonMarkup(html, 'Worktree actions for kilo/repo · available') + ).not.toContain('disabled=""'); + expect(getSidebarMenuItemProps('New chat').disabled).toBe(false); + expect(getSidebarMenuItemProps('Rename worktree').disabled).not.toBe(true); + expect(getSidebarMenuItemProps('Delete worktree').disabled).toBe(false); + }); + + it('preserves standalone session permissions and deletion callbacks', () => { + const standalone = makeStoredSession('ses_standalone', '2026-01-03T10:00:00.000Z', { + prompt: 'Standalone chat', + associatedPr: null, + }); + const deletedSessions: string[] = []; + const worktreeCallbacks: Partial = { + onCreateWorktreeChat: async () => true, + onRenameWorktree: async () => undefined, + onDeleteWorktree: () => undefined, + }; + const readOnlyHtml = renderChatSidebar({ sessions: [standalone], ...worktreeCallbacks }, true); + + expect(readOnlyHtml).not.toContain('Session actions for'); + expect(readOnlyHtml).not.toContain('Worktree actions for'); + + const html = renderChatSidebar( + { + sessions: [standalone], + ...worktreeCallbacks, + onRenameSession: async () => undefined, + onDeleteSession: sessionId => deletedSessions.push(sessionId), + }, + true + ); + getSidebarMenuItemProps('Delete session').onClick?.({ + stopPropagation: () => undefined, + } as React.MouseEvent); + + expect(html).toContain('Session actions for Standalone chat'); + expect(html).toContain('>Rename<'); + expect(html).not.toContain('Rename worktree'); + expect(html).not.toContain('Delete worktree'); + expect(deletedSessions).toEqual([standalone.sessionId]); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts index 9722049ceb..346fc1fe4a 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts @@ -7,10 +7,20 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; +import { + cloudAgentWorktreeIdSchema, + type CloudAgentWorktreeId, +} from '@kilocode/session-ingest-contracts'; import { useUserWebConnection } from '../CloudAgentProvider'; -import type { UserWebSessionEventData } from '@kilocode/cloud-agent-sdk'; +import type { + CloudStatus, + MessageDeliveryState, + ResolvedSession, + SessionActivity, + UserWebSessionEventData, +} from '@kilocode/cloud-agent-sdk'; import { apiSessionToDbSession, dbSessionsAtom, @@ -18,7 +28,7 @@ import { type DbSession, type DbSessionV2, } from '../store/db-session-atoms'; -import { startOfDay, subDays } from 'date-fns'; +import { differenceInCalendarDays, format, isSameDay, startOfDay, subDays } from 'date-fns'; import { extractRepoFromGitUrl } from '../utils/git-utils'; import type { StoredSession } from '../types'; @@ -39,6 +49,7 @@ export function dbSessionToStoredSession(session: DbSession | DbSessionV2): Stor sessionId: session.session_id, repository: extractRepoDisplay(dbSession.git_url), branch: dbSession.git_branch ?? null, + worktreeId: dbSession.cloud_agent_worktree_id ?? null, prompt: title, mode: 'last_mode' in dbSession ? (dbSession.last_mode ?? 'code') : 'code', model: 'last_model' in dbSession ? (dbSession.last_model ?? '') : '', @@ -54,6 +65,296 @@ export function dbSessionToStoredSession(session: DbSession | DbSessionV2): Stor }; } +type ForegroundSessionStatusInput = { + currentSessionId: string | null; + organizationId: string | null; + activeSessionType: ResolvedSession['type'] | null; + fetchedSessionData: { kiloSessionId: string; organizationId: string | null } | null; + activity: SessionActivity; + isStreaming: boolean; + activeQuestion: { requestId: string } | null; + activePermission: { requestId: string } | null; + cloudStatus: CloudStatus | null; + pendingMessages: ReadonlyMap; +}; + +type ForegroundSessionStatus = 'permission' | 'question' | 'retry' | 'busy' | 'idle'; + +export function deriveForegroundSessionStatus({ + currentSessionId, + organizationId, + activeSessionType, + fetchedSessionData, + activity, + isStreaming, + activeQuestion, + activePermission, + cloudStatus, + pendingMessages, +}: ForegroundSessionStatusInput): ForegroundSessionStatus | null { + if ( + !currentSessionId || + activeSessionType !== 'cloud-agent' || + !fetchedSessionData || + fetchedSessionData.kiloSessionId !== currentSessionId || + fetchedSessionData.organizationId !== organizationId + ) { + return null; + } + + if (activePermission) return 'permission'; + if (activeQuestion) return 'question'; + if (activity.type === 'retrying') return 'retry'; + + if ( + activity.type === 'busy' || + isStreaming || + cloudStatus?.type === 'preparing' || + cloudStatus?.type === 'finalizing' + ) { + return 'busy'; + } + + for (const message of pendingMessages.values()) { + if (message.status === 'queued') return 'busy'; + } + + return 'idle'; +} + +export function mergeWorktreeChatSessions( + worktreeId: string, + authoritativeSessions: StoredSession[], + cachedSessions: StoredSession[] +): StoredSession[] { + const sessionsById = new Map(); + + for (const session of authoritativeSessions) { + if (session.worktreeId === worktreeId) { + sessionsById.set(session.sessionId, session); + } + } + + for (const cachedSession of cachedSessions) { + if (cachedSession.worktreeId !== worktreeId) continue; + + const authoritativeSession = sessionsById.get(cachedSession.sessionId); + if (!authoritativeSession) { + sessionsById.set(cachedSession.sessionId, cachedSession); + continue; + } + + const cachedSessionIsCurrent = + new Date(cachedSession.updatedAt).getTime() >= + new Date(authoritativeSession.updatedAt).getTime(); + if (!cachedSessionIsCurrent) continue; + + const cloudAgentSessionId = + cachedSession.cloudAgentSessionId ?? authoritativeSession.cloudAgentSessionId; + sessionsById.set(cachedSession.sessionId, { + ...authoritativeSession, + ...cachedSession, + cloudAgentSessionId, + status: + cloudAgentSessionId && !cachedSession.cloudAgentSessionId + ? authoritativeSession.status + : cachedSession.status, + }); + } + + return [...sessionsById.values()].sort((a, b) => { + const createdAtDifference = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + return createdAtDifference === 0 ? a.sessionId.localeCompare(b.sessionId) : createdAtDifference; + }); +} + +export type SidebarWorktreeDetails = { + name: string | null; + defaultTitle: string | null; + prSession: StoredSession | null; +}; + +export type SidebarWorktreeGroup = { + type: 'worktree'; + worktreeId: string; + sessions: StoredSession[]; + latestSession: StoredSession; + details?: SidebarWorktreeDetails; +}; + +export type SidebarSessionItem = { type: 'session'; session: StoredSession } | SidebarWorktreeGroup; + +export type SidebarSessionDateGroup = { + label: string; + items: SidebarSessionItem[]; +}; + +export type SidebarWorktreeActivity = { + status: string | null; + statusUpdatedAt: string | null; + isLive: boolean; +}; + +function getSidebarSessionItemUpdatedAt(item: SidebarSessionItem): string { + return item.type === 'worktree' ? item.latestSession.updatedAt : item.session.updatedAt; +} + +function compareStoredSessionsByUpdatedAtDesc(a: StoredSession, b: StoredSession): number { + const difference = new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + if (difference !== 0) return difference; + return b.sessionId.localeCompare(a.sessionId); +} + +export function groupSidebarSessions( + sessions: StoredSession[], + worktreeDetails: Record = {} +): SidebarSessionItem[] { + const worktreeGroups = new Map(); + const items: SidebarSessionItem[] = []; + + for (const session of sessions) { + if (!session.worktreeId) { + items.push({ type: 'session', session }); + continue; + } + + const groupedSessions = worktreeGroups.get(session.worktreeId); + if (groupedSessions) { + groupedSessions.push(session); + continue; + } + + const nextGroupedSessions = [session]; + worktreeGroups.set(session.worktreeId, nextGroupedSessions); + items.push({ + type: 'worktree', + worktreeId: session.worktreeId, + sessions: nextGroupedSessions, + latestSession: session, + details: worktreeDetails[session.worktreeId], + }); + } + + for (const item of items) { + if (item.type !== 'worktree') continue; + item.sessions.sort(compareStoredSessionsByUpdatedAtDesc); + item.latestSession = item.sessions[0] ?? item.latestSession; + } + + return items.sort( + (a, b) => + new Date(getSidebarSessionItemUpdatedAt(b)).getTime() - + new Date(getSidebarSessionItemUpdatedAt(a)).getTime() + ); +} + +export function groupSidebarSessionsByDate( + sessions: StoredSession[], + now = new Date(), + worktreeDetails: Record = {} +): SidebarSessionDateGroup[] { + const today: SidebarSessionItem[] = []; + const yesterday: SidebarSessionItem[] = []; + const namedDayBuckets = new Map(); + const older: SidebarSessionItem[] = []; + const todayStart = startOfDay(now); + + for (const item of groupSidebarSessions(sessions, worktreeDetails)) { + const date = new Date(getSidebarSessionItemUpdatedAt(item)); + if (isSameDay(date, now)) { + today.push(item); + } else if (isSameDay(date, subDays(now, 1))) { + yesterday.push(item); + } else { + const daysAgo = differenceInCalendarDays(todayStart, startOfDay(date)); + if (daysAgo <= 7) { + const dayName = format(date, 'EEEE'); + const bucket = namedDayBuckets.get(dayName); + if (bucket) bucket.items.push(item); + else namedDayBuckets.set(dayName, { daysAgo, items: [item] }); + } else { + older.push(item); + } + } + } + + const groups: SidebarSessionDateGroup[] = []; + if (today.length > 0) groups.push({ label: 'Today', items: today }); + if (yesterday.length > 0) groups.push({ label: 'Yesterday', items: yesterday }); + + const sortedNamedDays = [...namedDayBuckets.entries()].sort( + (a, b) => a[1].daysAgo - b[1].daysAgo + ); + for (const [index, [dayName, bucket]] of sortedNamedDays.entries()) { + if (index < 3) groups.push({ label: dayName, items: bucket.items }); + else older.push(...bucket.items); + } + + if (older.length > 0) { + older.sort( + (a, b) => + new Date(getSidebarSessionItemUpdatedAt(b)).getTime() - + new Date(getSidebarSessionItemUpdatedAt(a)).getTime() + ); + groups.push({ label: 'Older', items: older }); + } + + return groups; +} + +export function getSidebarWorktreePrSession(group: SidebarWorktreeGroup): StoredSession | null { + if (group.details) return group.details.prSession; + + return ( + group.sessions.find(session => session.associatedPr != null) ?? + group.sessions.find(session => session.associatedPr === undefined && session.branch) ?? + null + ); +} + +export function getSidebarWorktreeLabel(group: SidebarWorktreeGroup): string { + const name = group.details?.name || group.details?.defaultTitle; + if (name) return name; + + const repository = + group.latestSession.repository || + group.sessions.find(session => session.repository)?.repository; + const branch = + group.latestSession.branch ?? group.sessions.find(session => session.branch)?.branch ?? null; + return branch ? `${repository || 'Repository'} · ${branch}` : repository || 'Repository'; +} + +export function getSidebarWorktreeActivity( + sessions: StoredSession[], + activeSessionStatuses: ReadonlyMap +): SidebarWorktreeActivity { + let selectedStatus: string | null = null; + let selectedStatusUpdatedAt: string | null = null; + let selectedPriority = 0; + let isLive = false; + + for (const session of sessions) { + const activeStatus = activeSessionStatuses.get(session.sessionId); + if (activeStatus !== undefined) isLive = true; + + for (const status of [session.sessionStatus ?? null, activeStatus ?? null]) { + const priority = + status === 'question' || status === 'permission' + ? 2 + : status === 'busy' || status === 'retry' + ? 1 + : 0; + if (priority <= selectedPriority) continue; + selectedPriority = priority; + selectedStatus = status; + selectedStatusUpdatedAt = + status === session.sessionStatus ? (session.sessionStatusUpdatedAt ?? null) : null; + } + } + + return { status: selectedStatus, statusUpdatedAt: selectedStatusUpdatedAt, isLive }; +} + const SIDEBAR_LIST_LIMIT = 200; /** @@ -61,11 +362,12 @@ const SIDEBAR_LIST_LIMIT = 200; * Used to detect changes that should trigger a Jotai atom update, including * PR state changes that arrive via webhook without modifying the session row. */ -function sessionCacheKey(s: { +export function sessionCacheKey(s: { session_id: string; updated_at: string; status: string | null; status_updated_at: string | null; + cloud_agent_worktree_id?: string | null; associatedPr?: { state: string; lastSyncedAt: string; @@ -73,7 +375,7 @@ function sessionCacheKey(s: { reviewDecisionPending: boolean; } | null; }): string { - return `${s.session_id}-${s.updated_at}-${s.status ?? ''}-${s.status_updated_at ?? ''}-${s.associatedPr?.state ?? ''}-${s.associatedPr?.lastSyncedAt ?? ''}-${s.associatedPr?.reviewDecision ?? ''}-${s.associatedPr?.reviewDecisionPending ?? false}`; + return `${s.session_id}-${s.updated_at}-${s.status ?? ''}-${s.status_updated_at ?? ''}-${s.cloud_agent_worktree_id ?? ''}-${s.associatedPr?.state ?? ''}-${s.associatedPr?.lastSyncedAt ?? ''}-${s.associatedPr?.reviewDecision ?? ''}-${s.associatedPr?.reviewDecisionPending ?? false}`; } /** @@ -90,13 +392,14 @@ type SidebarSessionFilters = { gitUrl?: string | string[]; }; -function eventRowToDbSession( +export function eventRowToDbSession( row: UserWebSessionEventData<'session.created'>['session'] ): DbSessionV2 { return { session_id: row.sessionId, title: row.title, cloud_agent_session_id: null, + cloud_agent_worktree_id: row.worktreeId, created_on_platform: row.createdOnPlatform, organization_id: row.organizationId, git_url: row.gitUrl, @@ -134,6 +437,8 @@ function mergeSidebarDbSession( const merged = { ...next, cloud_agent_session_id: existing.cloud_agent_session_id ?? next.cloud_agent_session_id, + cloud_agent_worktree_id: + next.cloud_agent_worktree_id ?? existing.cloud_agent_worktree_id ?? null, }; if ('associatedPr' in existing) return { ...merged, associatedPr: existing.associatedPr }; return merged; @@ -242,6 +547,8 @@ type UseSidebarSessionsOptions = { type UseSidebarSessionsReturn = { sessions: StoredSession[]; + cachedSessions: StoredSession[]; + worktreeDetails: Record; isLoading: boolean; refetchSessions: () => void; renameSessionLocally: (sessionId: string, newTitle: string) => void; @@ -256,6 +563,7 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide void queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()); void queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()); void queryClient.invalidateQueries(trpc.cliSessionsV2.recentRepositories.pathFilter()); + void queryClient.invalidateQueries(trpc.cliSessionsV2.worktreeDetails.pathFilter()); }, [queryClient, trpc]); const queryReconciler = useMemo( () => createSidebarQueryReconciler(reconcileSidebarQueries), @@ -339,9 +647,11 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide }, [listData?.cliSessions, setDbSessions, isSearchActive]); // Atom-derived sessions for list mode - const listSessions = useMemo(() => { - return recentSessions.map(dbSessionToStoredSession); - }, [recentSessions]); + const cachedSessions = useMemo(() => { + return recentSessions + .filter(session => organizationId === undefined || session.organization_id === organizationId) + .map(dbSessionToStoredSession); + }, [organizationId, recentSessions]); // Convert search results directly to StoredSession[] (no Jotai atoms) const searchSessions = useMemo(() => { @@ -350,6 +660,7 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide sessionId: row.session_id, repository: extractRepoDisplay(row.git_url), branch: row.git_branch, + worktreeId: row.cloud_agent_worktree_id ?? null, prompt: row.title || `Session ${row.session_id.substring(0, 8)}`, mode: 'code', model: '', @@ -373,6 +684,7 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide session_id: session.session_id, title: session.title, cloud_agent_session_id: session.cloud_agent_session_id, + cloud_agent_worktree_id: session.cloud_agent_worktree_id ?? null, created_at: session.created_at.toISOString(), updated_at: session.updated_at.toISOString(), version: session.version, @@ -411,10 +723,14 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide ...session, cloud_agent_session_id: existing.cloud_agent_session_id ?? session.cloud_agent_session_id, + cloud_agent_worktree_id: + session.cloud_agent_worktree_id ?? existing.cloud_agent_worktree_id ?? null, associatedPr: existing.associatedPr ?? session.associatedPr, } : session; - const shouldKeep = filterResult === true && dbSessionMatchesSearch(session, searchQuery); + const shouldKeep = + filterResult === true && + (existing !== undefined || dbSessionMatchesSearch(session, searchQuery)); if (!shouldKeep) { if (!existing) return current; return { ...current, results: withoutSession }; @@ -534,13 +850,60 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide queryReconciler, ]); - const sessions = isSearchActive ? searchSessions : listSessions; + const sessions = isSearchActive ? searchSessions : cachedSessions; const isLoading = isSearchActive ? isSearchLoading : isListLoading; + const worktreeIdBatches = useMemo(() => { + const ids = [ + ...new Set( + sessions.flatMap(session => { + const parsed = cloudAgentWorktreeIdSchema.safeParse(session.worktreeId); + return parsed.success ? [parsed.data] : []; + }) + ), + ].sort(); + const batches: CloudAgentWorktreeId[][] = []; + for (let index = 0; index < ids.length; index += 200) { + batches.push(ids.slice(index, index + 200)); + } + return batches; + }, [sessions]); + const worktreeDetails = useQueries({ + queries: worktreeIdBatches.map(worktreeIds => + trpc.cliSessionsV2.worktreeDetails.queryOptions( + { worktreeIds, organizationId: organizationId ?? null }, + { + staleTime: 5000, + refetchInterval: query => + Object.values(query.state.data?.worktrees ?? {}).some( + details => details.prSession?.associatedPr?.reviewDecisionPending === true + ) + ? REVIEW_DECISION_POLL_INTERVAL_MS + : false, + } + ) + ), + combine: queries => { + const details: Record = {}; + for (const query of queries) { + for (const [worktreeId, worktree] of Object.entries(query.data?.worktrees ?? {})) { + details[worktreeId] = { + name: worktree.name, + defaultTitle: worktree.defaultTitle, + prSession: worktree.prSession + ? dbSessionToStoredSession(apiSessionToDbSession(worktree.prSession)) + : null, + }; + } + } + return details; + }, + }); // Refetch sessions by invalidating the query cache const refetchSessions = useCallback(() => { void queryClient.invalidateQueries({ queryKey: listQueryKey }); - }, [queryClient, listQueryKey]); + void queryClient.invalidateQueries(trpc.cliSessionsV2.worktreeDetails.pathFilter()); + }, [queryClient, listQueryKey, trpc]); // Optimistically update a session's title in the Jotai atom so the UI // reflects the change immediately (before the server refetch completes). @@ -553,5 +916,12 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide [setDbSessions] ); - return { sessions, isLoading, refetchSessions, renameSessionLocally }; + return { + sessions, + cachedSessions, + worktreeDetails, + isLoading, + refetchSessions, + renameSessionLocally, + }; } diff --git a/apps/web/src/components/cloud-agent-next/session-deletion.test.ts b/apps/web/src/components/cloud-agent-next/session-deletion.test.ts index 300462e8d7..b4e94438be 100644 --- a/apps/web/src/components/cloud-agent-next/session-deletion.test.ts +++ b/apps/web/src/components/cloud-agent-next/session-deletion.test.ts @@ -15,6 +15,7 @@ function makeSession(session_id: string): ApiSession { session_id, title: session_id, cloud_agent_session_id: null, + cloud_agent_worktree_id: null, created_on_platform: 'cloud-agent', organization_id: null, git_url: null, @@ -50,6 +51,10 @@ function createFixture() { const recentKey = trpc.cliSessionsV2.recentRepositories.queryKey({ updatedSince: '2026-08-01T00:00:00.000Z', }); + const worktreeDetailsKey = trpc.cliSessionsV2.worktreeDetails.queryKey({ + worktreeIds: ['worktree_12345678-1234-4234-9234-123456789abc'], + organizationId: null, + }); const sessions = ['ses_a', 'ses_b', 'ses_c'].map(makeSession); const persisted = new Set(sessions.map(session => session.session_id)); const seedSessions = (rows: ApiSession[]) => { @@ -71,6 +76,15 @@ function createFixture() { }; seedSessions(sessions); queryClient.setQueryData(recentKey, { repositories: [] }); + queryClient.setQueryData(worktreeDetailsKey, { + worktrees: { + 'worktree_12345678-1234-4234-9234-123456789abc': { + name: null, + defaultTitle: sessions[0].title, + prSession: null, + }, + }, + }); queryClient.setQueryData(['unrelated'], { cliSessions: sessions }); const context = { @@ -90,6 +104,7 @@ function createFixture() { searchKeys, activeKey, recentKey, + worktreeDetailsKey, seedSessions, }; } @@ -206,14 +221,23 @@ describe('session deletion', () => { }); it('reconciles queries without rolling back current data or another successful deletion', async () => { - const { context, sessions, seedSessions, listKeys, searchKeys, activeKey, recentKey } = fixture; + const { + context, + sessions, + seedSessions, + listKeys, + searchKeys, + activeKey, + recentKey, + worktreeDetailsKey, + } = fixture; await removeDeletedSession({ ...context, sessionId: 'ses_b' }); seedSessions([ { ...sessions[0], title: 'New live title' }, sessions[2], makeSession('ses_new'), ]); - const keys: QueryKey[] = [...listKeys, ...searchKeys, activeKey, recentKey]; + const keys: QueryKey[] = [...listKeys, ...searchKeys, activeKey, recentKey, worktreeDetailsKey]; const currentData = keys.map(key => context.queryClient.getQueryData(key)); await invalidateSessionQueries(context); diff --git a/apps/web/src/components/cloud-agent-next/session-deletion.ts b/apps/web/src/components/cloud-agent-next/session-deletion.ts index 28a81c00be..60c1446236 100644 --- a/apps/web/src/components/cloud-agent-next/session-deletion.ts +++ b/apps/web/src/components/cloud-agent-next/session-deletion.ts @@ -21,6 +21,7 @@ export function invalidateSessionQueries({ queryClient, trpc }: SessionQueryCont queryClient.invalidateQueries(trpc.cliSessionsV2.list.pathFilter()), queryClient.invalidateQueries(trpc.cliSessionsV2.search.pathFilter()), queryClient.invalidateQueries(trpc.cliSessionsV2.recentRepositories.pathFilter()), + queryClient.invalidateQueries(trpc.cliSessionsV2.worktreeDetails.pathFilter()), queryClient.invalidateQueries(trpc.activeSessions.list.pathFilter()), ]); } diff --git a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts index d84816d4eb..e3171cd05b 100644 --- a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts +++ b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from '@jest/globals'; import { + apiSessionToDbSession, extractRepoFromGitUrl, formatSessionDate, getSessionDisplayTitle, @@ -15,6 +16,30 @@ import { // extractRepoFromGitUrl Tests // ============================================================================ +describe('apiSessionToDbSession', () => { + test('preserves worktree identity while converting API timestamps', () => { + const result = apiSessionToDbSession({ + session_id: 'ses_grouped', + title: 'Grouped session', + git_url: 'https://github.com/owner/repo', + git_branch: 'main', + cloud_agent_session_id: 'workspace_grouped', + cloud_agent_worktree_id: 'worktree_grouped', + created_on_platform: 'cloud-agent-web', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:01.000Z', + version: 2, + organization_id: null, + status: 'idle', + status_updated_at: null, + parent_session_id: null, + }); + + expect(result.cloud_agent_worktree_id).toBe('worktree_grouped'); + expect(result.updated_at).toEqual(new Date('2026-01-01T00:00:01.000Z')); + }); +}); + describe('extractRepoFromGitUrl', () => { test('should extract owner/repo from HTTPS URL', () => { expect(extractRepoFromGitUrl('https://github.com/owner/repo')).toBe('owner/repo'); diff --git a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts index 2453c1ad2b..bf243ab420 100644 --- a/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts +++ b/apps/web/src/components/cloud-agent-next/store/db-session-atoms.ts @@ -158,6 +158,7 @@ type ApiSession = { git_url: string | null; git_branch: string | null; cloud_agent_session_id: string | null; + cloud_agent_worktree_id?: string | null; created_on_platform: string | null; created_at: string; updated_at: string; @@ -180,6 +181,7 @@ export type DbSession = { git_url: string | null; git_branch: string | null; cloud_agent_session_id: string | null; + cloud_agent_worktree_id?: string | null; created_on_platform: string | null; created_at: Date; updated_at: Date; @@ -200,6 +202,7 @@ export type DbSessionV2 = { session_id: string; title: string | null; cloud_agent_session_id: string | null; + cloud_agent_worktree_id?: string | null; created_on_platform: string | null; organization_id: string | null; git_url: string | null; diff --git a/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts b/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts index 394d4d8052..7b2866e762 100644 --- a/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts @@ -3,6 +3,7 @@ import { addTerminalTab, closeTerminalTab, createWorkspaceTabsState, + getWorkspaceTabScope, resetWorkspaceTabs, selectWorkspaceTab, terminalTabId, @@ -17,22 +18,22 @@ describe('cloud agent workspace terminal tabs', () => { }); }); - it('adds terminal tabs with stable ids and human labels', () => { - const first = addTerminalTab(createWorkspaceTabsState(), 'tab-a'); - const second = addTerminalTab(first, 'tab-b'); + it('adds terminal tabs with stable ids, human labels, and their original session owners', () => { + const first = addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'); + const second = addTerminalTab(first, 'tab-b', 'cloud-agent-session-b'); expect(second).toEqual({ activeTabId: terminalTabId('tab-b'), nextTerminalNumber: 3, terminals: [ - { id: 'tab-a', title: 'Terminal 1' }, - { id: 'tab-b', title: 'Terminal 2' }, + { id: 'tab-a', title: 'Terminal 1', cloudAgentSessionId: 'cloud-agent-session-a' }, + { id: 'tab-b', title: 'Terminal 2', cloudAgentSessionId: 'cloud-agent-session-b' }, ], }); }); it('selects chat and existing terminal tabs only', () => { - const state = addTerminalTab(createWorkspaceTabsState(), 'tab-a'); + const state = addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'); expect(selectWorkspaceTab(state, CHAT_TAB_ID).activeTabId).toBe(CHAT_TAB_ID); expect(selectWorkspaceTab(state, terminalTabId('tab-a')).activeTabId).toBe( @@ -43,31 +44,110 @@ describe('cloud agent workspace terminal tabs', () => { it('keeps the current tab when a background terminal closes', () => { const state = selectWorkspaceTab( - addTerminalTab(addTerminalTab(createWorkspaceTabsState(), 'tab-a'), 'tab-b'), + addTerminalTab( + addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'), + 'tab-b', + 'cloud-agent-session-b' + ), terminalTabId('tab-a') ); expect(closeTerminalTab(state, 'tab-b')).toEqual({ activeTabId: terminalTabId('tab-a'), nextTerminalNumber: 3, - terminals: [{ id: 'tab-a', title: 'Terminal 1' }], + terminals: [ + { id: 'tab-a', title: 'Terminal 1', cloudAgentSessionId: 'cloud-agent-session-a' }, + ], }); }); it('activates the left neighbor when the active terminal closes', () => { - const state = addTerminalTab(addTerminalTab(createWorkspaceTabsState(), 'tab-a'), 'tab-b'); + const state = addTerminalTab( + addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'), + 'tab-b', + 'cloud-agent-session-b' + ); expect(closeTerminalTab(state, 'tab-b')).toEqual({ activeTabId: terminalTabId('tab-a'), nextTerminalNumber: 3, - terminals: [{ id: 'tab-a', title: 'Terminal 1' }], + terminals: [ + { id: 'tab-a', title: 'Terminal 1', cloudAgentSessionId: 'cloud-agent-session-a' }, + ], }); }); + it('activates the right neighbor when the first active terminal closes', () => { + const state = selectWorkspaceTab( + addTerminalTab( + addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'), + 'tab-b', + 'cloud-agent-session-b' + ), + terminalTabId('tab-a') + ); + + expect(closeTerminalTab(state, 'tab-a')).toEqual({ + activeTabId: terminalTabId('tab-b'), + nextTerminalNumber: 3, + terminals: [ + { id: 'tab-b', title: 'Terminal 2', cloudAgentSessionId: 'cloud-agent-session-b' }, + ], + }); + }); + + it('preserves chat selection and numbering when its background terminal closes', () => { + const state = selectWorkspaceTab( + addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'), + CHAT_TAB_ID + ); + + expect(closeTerminalTab(state, 'tab-a')).toEqual({ + activeTabId: CHAT_TAB_ID, + terminals: [], + nextTerminalNumber: 2, + }); + }); + + it('ignores attempts to close a terminal that does not exist', () => { + const state = addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'); + + expect(closeTerminalTab(state, 'missing')).toBe(state); + }); + it('returns to chat when the last terminal closes or the session resets', () => { - const state = addTerminalTab(createWorkspaceTabsState(), 'tab-a'); + const state = addTerminalTab(createWorkspaceTabsState(), 'tab-a', 'cloud-agent-session-a'); expect(closeTerminalTab(state, 'tab-a')).toEqual(createWorkspaceTabsState()); expect(resetWorkspaceTabs(state)).toEqual(createWorkspaceTabsState()); }); }); + +describe('getWorkspaceTabScope', () => { + it('keeps sibling chats in the same worktree scope', () => { + const first = getWorkspaceTabScope('worktree-a', 'session-a'); + const second = getWorkspaceTabScope('worktree-a', 'session-b'); + + expect(first).toBe('worktree:worktree-a'); + expect(second).toBe(first); + }); + + it('isolates distinct worktrees even when their session identity matches', () => { + expect(getWorkspaceTabScope('worktree-a', 'shared-session')).toBe('worktree:worktree-a'); + expect(getWorkspaceTabScope('worktree-b', 'shared-session')).toBe('worktree:worktree-b'); + }); + + it('isolates standalone sessions from each other and grouped identities', () => { + expect(getWorkspaceTabScope(null, 'session-a')).toBe('session:session-a'); + expect(getWorkspaceTabScope(undefined, 'session-b')).toBe('session:session-b'); + expect(getWorkspaceTabScope('shared-id', 'shared-id')).toBe('worktree:shared-id'); + expect(getWorkspaceTabScope(null, 'shared-id')).toBe('session:shared-id'); + }); + + it('returns null when no worktree or standalone session is available', () => { + expect(getWorkspaceTabScope(null, null)).toBeNull(); + expect(getWorkspaceTabScope(undefined, undefined)).toBeNull(); + expect(getWorkspaceTabScope(null, undefined)).toBeNull(); + expect(getWorkspaceTabScope(undefined, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/terminal-tabs.ts b/apps/web/src/components/cloud-agent-next/terminal-tabs.ts index 89342be65c..53d91cac78 100644 --- a/apps/web/src/components/cloud-agent-next/terminal-tabs.ts +++ b/apps/web/src/components/cloud-agent-next/terminal-tabs.ts @@ -6,6 +6,7 @@ export type WorkspaceTabId = typeof CHAT_TAB_ID | TerminalTabId; export type TerminalWorkspaceTab = { id: string; title: string; + cloudAgentSessionId: string; }; export type WorkspaceTabsState = { @@ -23,6 +24,15 @@ export function terminalIdFromTabId(tabId: WorkspaceTabId): string | null { return tabId.slice('terminal:'.length); } +export function getWorkspaceTabScope( + worktreeId: string | null | undefined, + kiloSessionId: string | null | undefined +): string | null { + if (worktreeId) return `worktree:${worktreeId}`; + if (kiloSessionId) return `session:${kiloSessionId}`; + return null; +} + export function createWorkspaceTabsState(): WorkspaceTabsState { return { activeTabId: CHAT_TAB_ID, @@ -35,12 +45,16 @@ export function resetWorkspaceTabs(_state: WorkspaceTabsState): WorkspaceTabsSta return createWorkspaceTabsState(); } -export function addTerminalTab(state: WorkspaceTabsState, terminalId: string): WorkspaceTabsState { +export function addTerminalTab( + state: WorkspaceTabsState, + terminalId: string, + cloudAgentSessionId: string +): WorkspaceTabsState { const title = `Terminal ${state.nextTerminalNumber}`; return { activeTabId: terminalTabId(terminalId), - terminals: [...state.terminals, { id: terminalId, title }], + terminals: [...state.terminals, { id: terminalId, title, cloudAgentSessionId }], nextTerminalNumber: state.nextTerminalNumber + 1, }; } diff --git a/apps/web/src/components/cloud-agent-next/types.test.ts b/apps/web/src/components/cloud-agent-next/types.test.ts index a3a705f641..569c6f71a6 100644 --- a/apps/web/src/components/cloud-agent-next/types.test.ts +++ b/apps/web/src/components/cloud-agent-next/types.test.ts @@ -1,5 +1,10 @@ import type { ReasoningPart, TextPart } from './types'; -import { isPartStreaming, shouldRenderReasoningPart } from './types'; +import { + getCloudSessionCreationOperation, + isAmbiguousCloudSessionCreationError, + isPartStreaming, + shouldRenderReasoningPart, +} from './types'; function makeReasoningPart(text: string, ended = true): ReasoningPart { return { @@ -54,3 +59,63 @@ describe('shouldRenderReasoningPart', () => { expect(shouldRenderReasoningPart(makeTextPart('hello'))).toBe(false); }); }); + +describe('getCloudSessionCreationOperation', () => { + it('retains one operation key when the immutable creation intent is retried', () => { + const createOperationKey = jest.fn(() => 'operation-first'); + const original = { + ...getCloudSessionCreationOperation(null, 'same intent', createOperationKey), + initialMessageId: 'message-first', + }; + + expect(getCloudSessionCreationOperation(original, 'same intent', createOperationKey)).toBe( + original + ); + expect(createOperationKey).toHaveBeenCalledTimes(1); + }); + + it('creates a new operation key when the immutable intent changes', () => { + const createOperationKey = jest + .fn() + .mockReturnValueOnce('operation-first') + .mockReturnValueOnce('operation-second'); + const original = getCloudSessionCreationOperation(null, 'first intent', createOperationKey); + + expect( + getCloudSessionCreationOperation(original, 'changed intent', createOperationKey) + ).toEqual({ intent: 'changed intent', operationKey: 'operation-second' }); + }); +}); + +describe('isAmbiguousCloudSessionCreationError', () => { + it('retains the operation for transport and server failures with an unknown outcome', () => { + expect(isAmbiguousCloudSessionCreationError(new Error('Network unavailable'))).toBe(true); + expect(isAmbiguousCloudSessionCreationError({ data: { code: 'TIMEOUT' } })).toBe(true); + expect(isAmbiguousCloudSessionCreationError({ data: { code: 'INTERNAL_SERVER_ERROR' } })).toBe( + true + ); + }); + + it.each(['creation_in_progress', 'operation_in_progress'])( + 'retains the operation for the retryable %s conflict', + message => { + expect( + isAmbiguousCloudSessionCreationError({ + data: { code: 'CONFLICT' }, + message, + }) + ).toBe(true); + } + ); + + it('clears the operation after definitive authorization, validation, and intent failures', () => { + expect(isAmbiguousCloudSessionCreationError({ data: { code: 'FORBIDDEN' } })).toBe(false); + expect(isAmbiguousCloudSessionCreationError({ data: { code: 'BAD_REQUEST' } })).toBe(false); + expect( + isAmbiguousCloudSessionCreationError({ + data: { code: 'CONFLICT' }, + message: 'operation_key_reuse_mismatch', + }) + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/types.ts b/apps/web/src/components/cloud-agent-next/types.ts index 745ee7f2c7..f81a045c34 100644 --- a/apps/web/src/components/cloud-agent-next/types.ts +++ b/apps/web/src/components/cloud-agent-next/types.ts @@ -361,6 +361,41 @@ type LegacySessionMessage = { toolExecutions?: ToolExecution[]; }; +export type CloudSessionCreationOperation = { + intent: string; + operationKey: string; +}; + +export function getCloudSessionCreationOperation( + current: CloudSessionCreationOperation | null | undefined, + intent: string, + createOperationKey: () => string +): CloudSessionCreationOperation { + if (current?.intent === intent) return current; + return { intent, operationKey: createOperationKey() }; +} + +export function isAmbiguousCloudSessionCreationError(error: unknown): boolean { + if (typeof error !== 'object' || error === null || !('data' in error)) return true; + if (typeof error.data !== 'object' || error.data === null || !('code' in error.data)) return true; + if (typeof error.data.code !== 'string') return true; + + if (error.data.code === 'CONFLICT') { + return ( + 'message' in error && + (error.message === 'creation_in_progress' || error.message === 'operation_in_progress') + ); + } + + return [ + 'TIMEOUT', + 'INTERNAL_SERVER_ERROR', + 'BAD_GATEWAY', + 'SERVICE_UNAVAILABLE', + 'GATEWAY_TIMEOUT', + ].includes(error.data.code); +} + export type StoredSession = { sessionId: string; repository: string; @@ -380,6 +415,7 @@ export type StoredSession = { createdOnPlatform?: string | null; /** Git branch name, shown separately from repository in the sidebar */ branch?: string | null; + worktreeId?: string | null; sessionStatus?: string | null; sessionStatusUpdatedAt?: string | null; /** diff --git a/apps/web/src/components/cloud-agent-next/worktree-chat-tabs.test.ts b/apps/web/src/components/cloud-agent-next/worktree-chat-tabs.test.ts new file mode 100644 index 0000000000..462609489a --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/worktree-chat-tabs.test.ts @@ -0,0 +1,421 @@ +import { describe, expect, it } from '@jest/globals'; +import { + getClosedWorktreeChatTabsStorageKey, + getClosedWorktreeChatSessionIds, + getNextOpenChatSessionId, + getOpenWorktreeChatSessionIds, + parseWorktreeChatTabs, + reduceWorktreeChatTabs, + type WorktreeChatTabsAction, + type WorktreeChatTabsState, +} from './worktree-chat-tabs'; + +const openSessionIds = Object.freeze(['session-a', 'session-b', 'session-c']); +const worktreeId = 'worktree-a'; + +function createState(closedSessionIds: string[] = []): WorktreeChatTabsState { + return { closedSessionIds, sessionOrderByWorktree: {} }; +} + +const replaceAction = { + type: 'replace', + worktreeId, + oldSessionId: 'session-b', + newSessionId: 'new-session', + openSessionIds, +} satisfies WorktreeChatTabsAction; + +function visibleSessionIds(state: WorktreeChatTabsState, sessionIds: readonly string[]) { + return getOpenWorktreeChatSessionIds( + sessionIds, + state.closedSessionIds, + state.sessionOrderByWorktree[worktreeId] + ); +} + +describe('worktree chat tabs', () => { + it('closes a chat and reopens it without changing other closed tabs', () => { + const closed = reduceWorktreeChatTabs(createState(['other-session']), { + type: 'close', + sessionId: 'session-a', + }); + expect(closed.closedSessionIds).toEqual(['other-session', 'session-a']); + expect( + reduceWorktreeChatTabs(closed, { type: 'open', sessionId: 'session-a' }).closedSessionIds + ).toEqual(['other-session']); + }); + + it('leaves the active chat open when a background chat closes', () => { + const closed = reduceWorktreeChatTabs(createState(), { + type: 'close', + sessionId: 'session-b', + }); + + expect(visibleSessionIds(closed, openSessionIds)).toEqual(['session-a', 'session-c']); + }); + + it('replaces a chat by closing the old tab and reopening the new tab together', () => { + const replaced = reduceWorktreeChatTabs( + createState(['new-session', 'other-session']), + replaceAction + ); + + expect(replaced.closedSessionIds).toEqual(['other-session', 'session-b']); + expect(visibleSessionIds(replaced, [...openSessionIds, 'new-session'])).toEqual([ + 'session-a', + 'new-session', + 'session-c', + ]); + }); + + it.each<[string, string[]]>([ + ['session-a', ['new-session', 'session-b', 'session-c']], + ['session-b', ['session-a', 'new-session', 'session-c']], + ['session-c', ['session-a', 'session-b', 'new-session']], + ])('keeps the replacement in the position of %s', (oldSessionId, expected) => { + const replaced = reduceWorktreeChatTabs(createState(), { ...replaceAction, oldSessionId }); + + expect(visibleSessionIds(replaced, [...openSessionIds, 'new-session'])).toEqual(expected); + expect(replaced.closedSessionIds).toEqual([oldSessionId]); + }); + + it('keeps repeated replacements in the same position', () => { + const replaced = reduceWorktreeChatTabs(createState(), replaceAction); + const sessions = [...openSessionIds, 'new-session']; + const replacedAgain = reduceWorktreeChatTabs(replaced, { + ...replaceAction, + oldSessionId: 'new-session', + newSessionId: 'newer-session', + openSessionIds: visibleSessionIds(replaced, sessions), + }); + + expect(visibleSessionIds(replacedAgain, [...sessions, 'newer-session'])).toEqual([ + 'session-a', + 'newer-session', + 'session-c', + ]); + expect(replacedAgain.closedSessionIds).toEqual(['session-b', 'new-session']); + }); + + it('appends new chats without moving an earlier replacement', () => { + const replaced = reduceWorktreeChatTabs(createState(), replaceAction); + + expect( + visibleSessionIds(replaced, [...openSessionIds, 'new-session', 'appended-session']) + ).toEqual(['session-a', 'new-session', 'session-c', 'appended-session']); + }); + + it('preserves the replacement order across refresh', () => { + const replaced = reduceWorktreeChatTabs(createState(['other-session']), replaceAction); + const restored = parseWorktreeChatTabs(JSON.stringify(replaced)); + + expect(restored).toEqual(replaced); + expect(visibleSessionIds(restored, [...openSessionIds, 'new-session'])).toEqual([ + 'session-a', + 'new-session', + 'session-c', + ]); + }); + + it('does not duplicate a replacement already received from session events', () => { + const replaced = reduceWorktreeChatTabs(createState(), { + ...replaceAction, + openSessionIds: [...openSessionIds, 'new-session'], + }); + + expect(visibleSessionIds(replaced, [...openSessionIds, 'new-session'])).toEqual([ + 'session-a', + 'new-session', + 'session-c', + ]); + }); + + it('preserves tabs closed while a replacement is being created', () => { + const closed = reduceWorktreeChatTabs(createState(), { + type: 'close', + sessionId: 'session-c', + }); + const replaced = reduceWorktreeChatTabs(closed, replaceAction); + + expect(visibleSessionIds(replaced, [...openSessionIds, 'new-session'])).toEqual([ + 'session-a', + 'new-session', + ]); + }); + + it('keeps another worktree order unchanged', () => { + const otherOrder = ['other-b', 'other-a']; + const replaced = reduceWorktreeChatTabs( + { ...createState(), sessionOrderByWorktree: { 'worktree-other': otherOrder } }, + replaceAction + ); + + expect(replaced.sessionOrderByWorktree['worktree-other']).toBe(otherOrder); + expect( + getOpenWorktreeChatSessionIds( + ['other-a', 'other-b'], + replaced.closedSessionIds, + replaced.sessionOrderByWorktree['worktree-other'] + ) + ).toEqual(otherOrder); + }); + + it('forgets a deleted worktree without changing other worktree preferences', () => { + const initial = { + closedSessionIds: ['session-a', 'other-session'], + sessionOrderByWorktree: { + 'worktree-a': ['session-a', 'session-b'], + 'worktree-other': ['other-session'], + }, + }; + const forgotten = reduceWorktreeChatTabs(initial, { + type: 'forgetWorktree', + worktreeId: 'worktree-a', + sessionIds: ['session-a', 'session-b'], + }); + + expect(forgotten).toEqual({ + closedSessionIds: ['other-session'], + sessionOrderByWorktree: { 'worktree-other': ['other-session'] }, + }); + expect(initial.sessionOrderByWorktree['worktree-a']).toEqual(['session-a', 'session-b']); + expect(initial.closedSessionIds).toEqual(['session-a', 'other-session']); + }); + + it('allows reopening a replaced chat without moving the replacement', () => { + const replaced = reduceWorktreeChatTabs(createState(), replaceAction); + const reopened = reduceWorktreeChatTabs(replaced, { type: 'open', sessionId: 'session-b' }); + + expect(visibleSessionIds(reopened, [...openSessionIds, 'new-session'])).toEqual([ + 'session-a', + 'new-session', + 'session-c', + 'session-b', + ]); + }); + + it('keeps a same-id replacement open even if it was closed', () => { + const replaced = reduceWorktreeChatTabs(createState(['session-a', 'other-session']), { + ...replaceAction, + oldSessionId: 'session-a', + newSessionId: 'session-a', + }); + + expect(replaced.closedSessionIds).toEqual(['other-session']); + expect(replaced.sessionOrderByWorktree).toEqual({}); + }); + + it('opens the replacement if the original tab is no longer in the list', () => { + const replaced = reduceWorktreeChatTabs(createState(['session-b']), { + ...replaceAction, + openSessionIds: ['session-a', 'session-c'], + }); + + expect(visibleSessionIds(replaced, [...openSessionIds, 'new-session'])).toEqual([ + 'session-a', + 'session-c', + 'new-session', + ]); + }); + + it('treats repeated close, reopen, and replace actions as no-ops', () => { + const closed = createState(['session-a']); + const closeAction = { type: 'close', sessionId: 'session-a' } satisfies WorktreeChatTabsAction; + const openAction = { type: 'open', sessionId: 'session-a' } satisfies WorktreeChatTabsAction; + const opened = reduceWorktreeChatTabs(closed, openAction); + const replaced = reduceWorktreeChatTabs(createState(), replaceAction); + + expect(reduceWorktreeChatTabs(closed, closeAction)).toBe(closed); + expect(reduceWorktreeChatTabs(opened, openAction)).toBe(opened); + expect(reduceWorktreeChatTabs(replaced, replaceAction)).toBe(replaced); + expect( + reduceWorktreeChatTabs(replaced, { + ...replaceAction, + openSessionIds: ['session-a', 'new-session', 'session-c'], + }) + ).toBe(replaced); + expect( + reduceWorktreeChatTabs(opened, { + ...replaceAction, + oldSessionId: 'session-a', + newSessionId: 'session-a', + }) + ).toBe(opened); + }); + + it('ignores reopening a chat that is already open', () => { + const closed = createState(['other-session']); + expect(reduceWorktreeChatTabs(closed, { type: 'open', sessionId: 'session-a' })).toBe(closed); + }); + + it.each([ + { type: 'open', sessionId: 'session-a' }, + { type: 'close', sessionId: 'session-b' }, + replaceAction, + ])('does not mutate the input for $type', action => { + const state = createState(['session-a', 'other-session']); + Object.freeze(state.closedSessionIds); + Object.freeze(state.sessionOrderByWorktree); + Object.freeze(state); + + reduceWorktreeChatTabs(state, action); + + expect(state).toEqual(createState(['session-a', 'other-session'])); + }); +}); + +describe('getOpenWorktreeChatSessionIds', () => { + it('keeps the session order when no replacement order is stored', () => { + expect(getOpenWorktreeChatSessionIds(openSessionIds, ['session-b'])).toEqual([ + 'session-a', + 'session-c', + ]); + }); + + it('ignores deleted, unknown, and closed sessions and duplicate order entries', () => { + expect( + getOpenWorktreeChatSessionIds( + openSessionIds, + ['session-b'], + ['foreign-session', 'session-c', 'session-b', 'session-c'] + ) + ).toEqual(['session-c', 'session-a']); + }); +}); + +describe('getClosedWorktreeChatSessionIds', () => { + it('orders by the latest close rather than session creation or activity', () => { + expect( + getClosedWorktreeChatSessionIds(openSessionIds, ['session-c', 'foreign-session', 'session-a']) + ).toEqual(['session-a', 'session-c']); + }); + + it('moves a reopened and reclosed session to the top and preserves the order on refresh', () => { + const reopened = reduceWorktreeChatTabs(createState(['session-b', 'session-c']), { + type: 'open', + sessionId: 'session-b', + }); + const reclosed = reduceWorktreeChatTabs(reopened, { type: 'close', sessionId: 'session-b' }); + const restored = parseWorktreeChatTabs(JSON.stringify(reclosed)); + + expect(getClosedWorktreeChatSessionIds(openSessionIds, restored.closedSessionIds)).toEqual([ + 'session-b', + 'session-c', + ]); + }); +}); + +describe('getNextOpenChatSessionId', () => { + it('selects the first remaining chat when closing the first tab', () => { + expect(getNextOpenChatSessionId(openSessionIds, 'session-a')).toBe('session-b'); + }); + + it('selects the adjacent previous chat when closing a middle tab', () => { + expect(getNextOpenChatSessionId(openSessionIds, 'session-b')).toBe('session-a'); + }); + + it('selects the adjacent previous chat when closing the last tab', () => { + expect(getNextOpenChatSessionId(openSessionIds, 'session-c')).toBe('session-b'); + }); + + it('uses the displayed neighbor after a replacement', () => { + const replaced = reduceWorktreeChatTabs(createState(), replaceAction); + const visibleIds = visibleSessionIds(replaced, [...openSessionIds, 'new-session']); + + expect(getNextOpenChatSessionId(visibleIds, 'session-c')).toBe('new-session'); + }); + + it('returns null when the final open chat closes', () => { + expect(getNextOpenChatSessionId(['session-a'], 'session-a')).toBeNull(); + }); + + it('ignores a chat outside the open tabs and an empty tab list', () => { + expect(getNextOpenChatSessionId(openSessionIds, 'unrelated-session')).toBeNull(); + expect(getNextOpenChatSessionId([], 'session-a')).toBeNull(); + }); + + it('does not mutate or reorder the open tab list', () => { + getNextOpenChatSessionId(openSessionIds, 'session-b'); + + expect(openSessionIds).toEqual(['session-a', 'session-b', 'session-c']); + }); +}); + +describe('parseWorktreeChatTabs', () => { + it('migrates existing closed-tab preferences without reopening sessions', () => { + expect(parseWorktreeChatTabs('["session-b","session-a","session-b","session-a"]')).toEqual( + createState(['session-b', 'session-a']) + ); + }); + + it('validates and deduplicates closed ids and worktree orders', () => { + expect( + parseWorktreeChatTabs( + JSON.stringify({ + closedSessionIds: ['session-a', 'session-a'], + sessionOrderByWorktree: { 'worktree-a': ['session-c', 'session-b', 'session-c'] }, + }) + ) + ).toEqual({ + closedSessionIds: ['session-a'], + sessionOrderByWorktree: { 'worktree-a': ['session-c', 'session-b'] }, + }); + }); + + it.each([ + null, + '', + '[]', + 'not json', + '[', + 'null', + 'true', + '42', + '"session-a"', + '{}', + '{"closedSessionIds":["session-a"]}', + '["session-a",42]', + '["session-a",null]', + '["session-a",false]', + '["session-a",{}]', + '["session-a",["session-b"]]', + '{"closedSessionIds":[42],"sessionOrderByWorktree":{}}', + '{"closedSessionIds":[],"sessionOrderByWorktree":[]}', + '{"closedSessionIds":[],"sessionOrderByWorktree":{"worktree-a":[42]}}', + '{"closedSessionIds":[],"sessionOrderByWorktree":{"worktree-a":"session-a"}}', + ])('returns empty preferences for missing, empty, or invalid persisted data: %s', raw => { + expect(parseWorktreeChatTabs(raw)).toEqual(createState()); + }); +}); + +describe('getClosedWorktreeChatTabsStorageKey', () => { + it.each([null, undefined, ''])('does not persist without a user id: %s', userId => { + expect(getClosedWorktreeChatTabsStorageKey(userId)).toBeNull(); + expect(getClosedWorktreeChatTabsStorageKey(userId, 'org-a')).toBeNull(); + }); + + it('uses distinct stable keys for each user and organization scope', () => { + const keys = [ + getClosedWorktreeChatTabsStorageKey('user-a'), + getClosedWorktreeChatTabsStorageKey('user-b'), + getClosedWorktreeChatTabsStorageKey('user-a', 'org-a'), + getClosedWorktreeChatTabsStorageKey('user-a', 'org-b'), + getClosedWorktreeChatTabsStorageKey('user-b', 'org-a'), + getClosedWorktreeChatTabsStorageKey('user-a', 'personal'), + ]; + + expect(keys).not.toContain(null); + expect(new Set(keys).size).toBe(keys.length); + expect(getClosedWorktreeChatTabsStorageKey('user-a')).toBe(keys[0]); + expect(getClosedWorktreeChatTabsStorageKey('user-a', 'org-a')).toBe(keys[2]); + }); + + it('prevents delimiters inside ids from colliding across scopes', () => { + expect(getClosedWorktreeChatTabsStorageKey('user-a:organization:org-a', 'org-b')).not.toBe( + getClosedWorktreeChatTabsStorageKey('user-a', 'org-a:organization:org-b') + ); + expect(getClosedWorktreeChatTabsStorageKey('user:a', 'org-a')).not.toBe( + getClosedWorktreeChatTabsStorageKey('user%3Aa', 'org-a') + ); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/worktree-chat-tabs.ts b/apps/web/src/components/cloud-agent-next/worktree-chat-tabs.ts new file mode 100644 index 0000000000..6b729797ea --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/worktree-chat-tabs.ts @@ -0,0 +1,150 @@ +import { z } from 'zod'; + +const CLOSED_WORKTREE_CHAT_TABS_STORAGE_KEY_PREFIX = 'cloud-agent:closed-worktree-chat-tabs'; + +const sessionIdsSchema = z.array(z.string()).transform(sessionIds => [...new Set(sessionIds)]); +const worktreeChatTabsSchema = z.object({ + closedSessionIds: sessionIdsSchema, + sessionOrderByWorktree: z.record(z.string(), sessionIdsSchema), +}); + +export type WorktreeChatTabsState = z.infer; + +export type WorktreeChatTabsAction = + | { type: 'open'; sessionId: string } + | { type: 'close'; sessionId: string } + | { type: 'forgetWorktree'; worktreeId: string; sessionIds: readonly string[] } + | { + type: 'replace'; + worktreeId: string; + oldSessionId: string; + newSessionId: string; + openSessionIds: readonly string[]; + }; + +export function getClosedWorktreeChatTabsStorageKey( + userId: string | null | undefined, + organizationId?: string +): string | null { + if (!userId) return null; + + const scope = organizationId ? `organization:${encodeURIComponent(organizationId)}` : 'personal'; + return `${CLOSED_WORKTREE_CHAT_TABS_STORAGE_KEY_PREFIX}:user:${encodeURIComponent(userId)}:${scope}`; +} + +export function parseWorktreeChatTabs(rawValue: string | null): WorktreeChatTabsState { + const emptyState: WorktreeChatTabsState = { closedSessionIds: [], sessionOrderByWorktree: {} }; + if (!rawValue) return emptyState; + + try { + const parsed: unknown = JSON.parse(rawValue); + const result = worktreeChatTabsSchema.safeParse( + Array.isArray(parsed) ? { closedSessionIds: parsed, sessionOrderByWorktree: {} } : parsed + ); + return result.success ? result.data : emptyState; + } catch { + return emptyState; + } +} + +export function reduceWorktreeChatTabs( + state: WorktreeChatTabsState, + action: WorktreeChatTabsAction +): WorktreeChatTabsState { + switch (action.type) { + case 'open': + return state.closedSessionIds.includes(action.sessionId) + ? { + ...state, + closedSessionIds: state.closedSessionIds.filter( + sessionId => sessionId !== action.sessionId + ), + } + : state; + case 'close': + return state.closedSessionIds.includes(action.sessionId) + ? state + : { ...state, closedSessionIds: [...state.closedSessionIds, action.sessionId] }; + case 'forgetWorktree': { + const deletedIds = new Set(action.sessionIds); + const sessionOrderByWorktree = { ...state.sessionOrderByWorktree }; + delete sessionOrderByWorktree[action.worktreeId]; + return { + closedSessionIds: state.closedSessionIds.filter(sessionId => !deletedIds.has(sessionId)), + sessionOrderByWorktree, + }; + } + case 'replace': { + if (action.oldSessionId === action.newSessionId) { + return reduceWorktreeChatTabs(state, { type: 'open', sessionId: action.newSessionId }); + } + + const closedSessionIds = state.closedSessionIds.filter( + sessionId => sessionId !== action.newSessionId + ); + if (!closedSessionIds.includes(action.oldSessionId)) + closedSessionIds.push(action.oldSessionId); + + const sessionOrder = action.openSessionIds.includes(action.oldSessionId) + ? action.openSessionIds.flatMap(sessionId => + sessionId === action.oldSessionId + ? [action.newSessionId] + : sessionId === action.newSessionId + ? [] + : [sessionId] + ) + : [...new Set([...action.openSessionIds, action.newSessionId])]; + const previousOrder = state.sessionOrderByWorktree[action.worktreeId] ?? []; + if ( + closedSessionIds.length === state.closedSessionIds.length && + closedSessionIds.every((sessionId, index) => sessionId === state.closedSessionIds[index]) && + sessionOrder.length === previousOrder.length && + sessionOrder.every((sessionId, index) => sessionId === previousOrder[index]) + ) { + return state; + } + + return { + closedSessionIds, + sessionOrderByWorktree: { + ...state.sessionOrderByWorktree, + [action.worktreeId]: sessionOrder, + }, + }; + } + } +} + +export function getOpenWorktreeChatSessionIds( + sessionIds: readonly string[], + closedSessionIds: readonly string[], + sessionOrder: readonly string[] = [] +): string[] { + const knownIds = new Set(sessionIds); + const closedIds = new Set(closedSessionIds); + return [...new Set([...sessionOrder, ...sessionIds])].filter( + sessionId => knownIds.has(sessionId) && !closedIds.has(sessionId) + ); +} + +export function getClosedWorktreeChatSessionIds( + sessionIds: readonly string[], + closedSessionIds: readonly string[] +): string[] { + const knownIds = new Set(sessionIds); + return closedSessionIds.toReversed().filter(sessionId => knownIds.has(sessionId)); +} + +export function getNextOpenChatSessionId( + openSessionIds: readonly string[], + closingSessionId: string +): string | null { + const closingIndex = openSessionIds.indexOf(closingSessionId); + if (closingIndex === -1) return null; + + return ( + openSessionIds[closingIndex - 1] ?? + openSessionIds.find(sessionId => sessionId !== closingSessionId) ?? + null + ); +} diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts index 7a48ea8447..581d5e4288 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.test.ts @@ -1,4 +1,19 @@ -import { describe, expect, it, jest, beforeEach } from '@jest/globals'; +import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type * as TrpcClientModule from '@trpc/client'; +import type { + CloudAgentNextClient as CloudAgentNextClientType, + CreateWorktreeChatInput, + CreateWorktreeChatOutput, + DeleteWorktreeInput, + DeleteWorktreeOutput, + PrepareSessionInput, + SendMessageInput, +} from './cloud-agent-client'; + +const mockCreateTRPCClient = jest.fn(() => ({})); +const mockHttpLink = + jest.fn<(options: { url: string; headers: () => Record }) => undefined>(); +const mockCaptureException = jest.fn(); jest.mock('@/lib/dotenvx', () => ({ getEnvVariable: jest.fn(() => 'http://cloud-agent-next'), @@ -9,13 +24,13 @@ jest.mock('@/lib/config.server', () => ({ })); jest.mock('@trpc/client', () => ({ - createTRPCClient: jest.fn(() => ({})), - httpLink: jest.fn(), - TRPCClientError: class TRPCClientError extends Error {}, + ...jest.requireActual('@trpc/client'), + createTRPCClient: mockCreateTRPCClient, + httpLink: mockHttpLink, })); jest.mock('@sentry/nextjs', () => ({ - captureException: jest.fn(), + captureException: mockCaptureException, })); jest.mock('./cloud-agent-client', () => { @@ -54,12 +69,18 @@ const { beforeEach(() => { mockCreateCloudAgentNextClient.mockClear(); mockCreateAppBuilderCloudAgentNextClient.mockClear(); + mockCreateTRPCClient.mockClear(); + mockHttpLink.mockClear(); + mockCaptureException.mockClear(); }); // Load the real `closeCloudAgentOrgStreams` (the module mock above does not // expose it) so the test exercises the actual fetch call, not a stub. -const { closeCloudAgentOrgStreams } = jest.requireActual('./cloud-agent-client') as { +const { closeCloudAgentOrgStreams, CloudAgentNextClient } = jest.requireActual( + './cloud-agent-client' +) as { closeCloudAgentOrgStreams: (userId: string, organizationId: string) => Promise; + CloudAgentNextClient: typeof CloudAgentNextClientType; }; describe('createCloudAgentNextClientForModel', () => { @@ -94,6 +115,311 @@ describe('createCloudAgentNextClientForModel', () => { }); }); +describe('CloudAgentNextClient sensitive error reporting', () => { + const error = new Error('Worker unavailable'); + + it('captures only safe prepare metadata without prompts, credentials, environment, or callback headers', async () => { + const input = { + prompt: 'private customer prompt', + mode: 'code', + model: 'kilo/test-model', + githubRepo: 'owner/repository', + githubToken: 'github-token-secret', + gitToken: 'git-token-secret', + envVars: { API_KEY: 'environment-secret' }, + setupCommands: ['export PASSWORD=setup-secret'], + mcpServers: { + tools: { + type: 'local', + command: ['tool'], + environment: { MCP_TOKEN: 'mcp-secret' }, + }, + }, + callbackTarget: { + url: 'https://callback.example.test', + headers: { Authorization: 'Bearer callback-secret' }, + }, + kilocodeOrganizationId: '9a283301-b75d-4375-a1ba-e319a02e18b7', + operationKey: '12345678-1234-4234-9234-123456789abc', + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + } satisfies PrepareSessionInput; + const mutate = jest.fn(async () => { + throw error; + }); + mockCreateTRPCClient.mockReturnValueOnce({ prepareSession: { mutate } }); + + await expect(new CloudAgentNextClient('auth-token').prepareSession(input)).rejects.toBe(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { source: 'cloud-agent-next-client', endpoint: 'prepareSession' }, + extra: { + kilocodeOrganizationId: input.kilocodeOrganizationId, + cloneFromKiloSessionId: undefined, + operationKey: input.operationKey, + createdOnPlatform: input.createdOnPlatform, + clientProvenance: input.clientProvenance, + model: input.model, + mode: input.mode, + }, + }); + const captured = JSON.stringify(mockCaptureException.mock.calls); + for (const sensitive of [ + 'private customer prompt', + 'github-token-secret', + 'git-token-secret', + 'environment-secret', + 'setup-secret', + 'mcp-secret', + 'callback-secret', + 'auth-token', + ]) { + expect(captured).not.toContain(sensitive); + } + }); + + it('captures only the session identity when prepared-session initiation fails', async () => { + const mutate = jest.fn(async () => { + throw error; + }); + mockCreateTRPCClient.mockReturnValueOnce({ initiateFromKilocodeSessionV2: { mutate } }); + + await expect( + new CloudAgentNextClient('auth-token').initiateFromPreparedSession({ + cloudAgentSessionId: 'workspace_12345678-1234-4234-9234-123456789abc', + }) + ).rejects.toBe(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { + source: 'cloud-agent-next-client', + endpoint: 'initiateFromPreparedSession', + }, + extra: { cloudAgentSessionId: 'workspace_12345678-1234-4234-9234-123456789abc' }, + }); + }); + + it('captures only message identities and payload type without prompt or Git credentials', async () => { + const input = { + cloudAgentSessionId: 'workspace_12345678-1234-4234-9234-123456789abc', + messageId: 'msg_12345678901212345678901234', + payload: { + type: 'prompt', + prompt: 'private follow-up prompt', + mode: 'code', + model: 'kilo/test-model', + }, + githubToken: 'follow-up-github-secret', + gitToken: 'follow-up-git-secret', + } satisfies SendMessageInput; + const mutate = jest.fn(async () => { + throw error; + }); + mockCreateTRPCClient.mockReturnValueOnce({ sendMessageV2: { mutate } }); + + await expect(new CloudAgentNextClient('auth-token').sendMessage(input)).rejects.toBe(error); + + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { source: 'cloud-agent-next-client', endpoint: 'sendMessage' }, + extra: { + cloudAgentSessionId: input.cloudAgentSessionId, + messageId: input.messageId, + payloadType: 'prompt', + }, + }); + const captured = JSON.stringify(mockCaptureException.mock.calls); + expect(captured).not.toContain('private follow-up prompt'); + expect(captured).not.toContain('follow-up-github-secret'); + expect(captured).not.toContain('follow-up-git-secret'); + expect(captured).not.toContain('auth-token'); + }); +}); + +describe('CloudAgentNextClient.deleteSession', () => { + const sessionId = 'workspace_12345678-1234-4234-9234-123456789abc'; + + it('preserves typed Worker NOT_FOUND so ownership deletion can remain idempotent', async () => { + const { TRPCClientError } = jest.requireActual('@trpc/client'); + const error = new TRPCClientError('Runtime session not found', { + result: { + error: { + code: -32004, + message: 'Runtime session not found', + data: { code: 'NOT_FOUND', httpStatus: 404 }, + }, + }, + }); + const mutate = jest.fn(async () => { + throw error; + }); + mockCreateTRPCClient.mockReturnValueOnce({ deleteSession: { mutate } }); + + await expect(new CloudAgentNextClient('auth-token').deleteSession(sessionId)).rejects.toBe( + error + ); + expect(mockCaptureException).not.toHaveBeenCalled(); + }); + + it('continues returning failed runtime deletion for genuine Worker failures', async () => { + const error = new Error('Worker unavailable'); + const mutate = jest.fn(async () => { + throw error; + }); + mockCreateTRPCClient.mockReturnValueOnce({ deleteSession: { mutate } }); + + await expect(new CloudAgentNextClient('auth-token').deleteSession(sessionId)).resolves.toEqual({ + success: false, + }); + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { source: 'cloud-agent-next-client', endpoint: 'deleteSession' }, + extra: { sessionId }, + }); + }); + + it('preserves an explicit unsuccessful Worker deletion response', async () => { + const mutate = jest.fn(async () => ({ success: false })); + mockCreateTRPCClient.mockReturnValueOnce({ deleteSession: { mutate } }); + + await expect(new CloudAgentNextClient('auth-token').deleteSession(sessionId)).resolves.toEqual({ + success: false, + }); + }); +}); + +describe('CloudAgentNextClient.deleteWorktree', () => { + const worktreeId = 'worktree_12345678-1234-4234-9234-123456789abc'; + const deletedSessionIds = ['ses_12345678901234567890123456', 'ses_abcdefghijklmnopqrstuvwxyz']; + + it.each([undefined, '9a283301-b75d-4375-a1ba-e319a02e18b7'])( + 'forwards the exact scope and authenticates the Worker request: %p', + async kilocodeOrganizationId => { + const input: DeleteWorktreeInput = { + worktreeId, + ...(kilocodeOrganizationId ? { kilocodeOrganizationId } : {}), + }; + const mutate = jest + .fn<(request: DeleteWorktreeInput) => Promise>() + .mockResolvedValue({ success: true, deletedSessionIds }); + mockCreateTRPCClient.mockReturnValueOnce({ deleteWorktree: { mutate } }); + const client = new CloudAgentNextClient('auth-token'); + + await expect(client.deleteWorktree(input)).resolves.toEqual({ + success: true, + deletedSessionIds, + }); + expect(mutate).toHaveBeenCalledWith(input); + expect(mockHttpLink.mock.calls[0]?.[0].headers()).toEqual({ + Authorization: 'Bearer auth-token', + 'x-internal-api-key': 'test-secret', + }); + } + ); + + it.each([ + ['CONFLICT', 409], + ['SERVICE_UNAVAILABLE', 503], + ['NOT_FOUND', 404], + ['FORBIDDEN', 403], + ])('preserves the original typed Worker %s error', async (code, httpStatus) => { + const { TRPCClientError } = jest.requireActual('@trpc/client'); + const error = new TRPCClientError('worktree_deletion_pending', { + result: { + error: { + code: -32000, + message: 'worktree_deletion_pending', + data: { code, httpStatus }, + }, + }, + }); + const mutate = jest.fn(async () => { + throw error; + }); + mockCreateTRPCClient.mockReturnValueOnce({ deleteWorktree: { mutate } }); + + await expect( + new CloudAgentNextClient('auth-token').deleteWorktree({ worktreeId }) + ).rejects.toBe(error); + expect(mockCaptureException).toHaveBeenCalledWith(error, { + tags: { source: 'cloud-agent-next-client', endpoint: 'deleteWorktree' }, + extra: { worktreeId }, + }); + expect(JSON.stringify(mockCaptureException.mock.calls)).not.toContain('auth-token'); + }); + + it('propagates transport failures rather than returning a false success', async () => { + const error = new Error('Worker unavailable'); + mockCreateTRPCClient.mockReturnValueOnce({ + deleteWorktree: { + mutate: jest.fn(async () => { + throw error; + }), + }, + }); + await expect( + new CloudAgentNextClient('auth-token').deleteWorktree({ worktreeId }) + ).rejects.toBe(error); + }); + + it.each([ + { success: false, deletedSessionIds: [] }, + { success: true }, + { success: true, deletedSessionIds: [42] }, + ])('rejects an unconfirmed or malformed cleanup result: %p', async output => { + mockCreateTRPCClient.mockReturnValueOnce({ + deleteWorktree: { mutate: jest.fn(async () => output) }, + }); + await expect( + new CloudAgentNextClient('auth-token').deleteWorktree({ worktreeId }) + ).rejects.toThrow(); + }); + + it('returns only the public cleanup result and preserves empty idempotent responses', async () => { + mockCreateTRPCClient.mockReturnValueOnce({ + deleteWorktree: { + mutate: jest.fn(async () => ({ + success: true, + deletedSessionIds: [], + runtimeLocations: [{ workspacePath: '/private/worktree' }], + })), + }, + }); + await expect( + new CloudAgentNextClient('auth-token').deleteWorktree({ worktreeId }) + ).resolves.toEqual({ + success: true, + deletedSessionIds: [], + }); + }); +}); + +describe('CloudAgentNextClient.createWorktreeChat', () => { + it('forwards the exact authenticated Worker operation and preserves replay identity', async () => { + const uuid = '12345678-1234-4234-9234-123456789abc'; + const input = { + sourceKiloSessionId: 'ses_12345678901234567890123456', + sourceCloudAgentSessionId: `workspace_${uuid}`, + operationKey: uuid, + kilocodeOrganizationId: '9a283301-b75d-4375-a1ba-e319a02e18b7', + clientProvenance: 'browser', + } satisfies CreateWorktreeChatInput; + const output = { + kiloSessionId: 'ses_abcdefghijklmnopqrstuvwxyz', + cloudAgentSessionId: `workspace_${uuid}`, + worktreeId: `worktree_${uuid}`, + replayed: true, + } satisfies CreateWorktreeChatOutput; + const mutate = jest + .fn<(request: CreateWorktreeChatInput) => Promise>() + .mockResolvedValue(output); + mockCreateTRPCClient.mockReturnValueOnce({ createWorktreeChat: { mutate } }); + + await expect(new CloudAgentNextClient('token').createWorktreeChat(input)).resolves.toEqual( + output + ); + expect(mutate).toHaveBeenCalledWith(input); + }); +}); + describe('closeCloudAgentOrgStreams', () => { const originalFetch = global.fetch; diff --git a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts index 976ae89146..7ccd741260 100644 --- a/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts +++ b/apps/web/src/lib/cloud-agent-next/cloud-agent-client.ts @@ -1,6 +1,7 @@ import 'server-only'; import { createTRPCClient, httpLink, TRPCClientError } from '@trpc/client'; import { TRPCError } from '@trpc/server'; +import * as z from 'zod'; import type { AgentConfig } from '@kilocode/db/schema-types'; import type { EncryptedEnvelope } from '@/lib/encryption'; import type { CloudAgentAttachments } from '@/lib/cloud-agent/constants'; @@ -9,6 +10,7 @@ import { getEnvVariable } from '@/lib/dotenvx'; import { captureException } from '@sentry/nextjs'; import { INTERNAL_API_SECRET } from '@/lib/config.server'; import { parseCustomerBillingFailure } from '@kilocode/cloud-agent-sdk'; +import type { CloudAgentWorktreeId } from '@kilocode/session-ingest-contracts'; import type { SendMessagePayload } from './types.js'; export type { SendMessagePayload } from './types.js'; @@ -137,6 +139,7 @@ type PrepareSessionSharedFields = { callbackTarget?: CallbackTarget; /** Platform that created this session (e.g. 'security-agent', 'slack', 'app-builder') */ createdOnPlatform?: string; + clientProvenance?: 'browser' | 'mobile'; /** PR gate threshold — when not 'off', the agent reports gateResult in its callback */ gateThreshold?: 'off' | 'all' | 'warning' | 'critical'; /** When true, route the session to a Docker-in-Docker sandbox that supports devcontainer runtimes */ @@ -174,6 +177,33 @@ export type PrepareSessionOutput = { replayed?: boolean; }; +export type CreateWorktreeChatInput = { + sourceKiloSessionId: string; + sourceCloudAgentSessionId: string; + operationKey: string; + kilocodeOrganizationId?: string; + clientProvenance: 'browser'; +}; + +export type CreateWorktreeChatOutput = { + kiloSessionId: string; + cloudAgentSessionId: `workspace_${string}`; + worktreeId: CloudAgentWorktreeId; + replayed?: boolean; +}; + +export type DeleteWorktreeInput = { + worktreeId: CloudAgentWorktreeId; + kilocodeOrganizationId?: string; +}; + +const deleteWorktreeOutputSchema = z.object({ + success: z.literal(true), + deletedSessionIds: z.array(z.string()), +}); + +export type DeleteWorktreeOutput = z.infer; + /** Input for initiating from a prepared session */ export type InitiateFromPreparedSessionInput = { cloudAgentSessionId: string; @@ -521,6 +551,9 @@ type CloudAgentNextTRPCClient = { deleteSession: { mutate: (input: { sessionId: string }) => Promise<{ success: boolean; message?: string }>; }; + deleteWorktree: { + mutate: (input: DeleteWorktreeInput) => Promise; + }; cleanupSession: { mutate: (input: { sessionId: string }) => Promise<{ success: boolean; message?: string }>; }; @@ -539,6 +572,9 @@ type CloudAgentNextTRPCClient = { prepareSession: { mutate: (input: PrepareSessionInput) => Promise; }; + createWorktreeChat: { + mutate: (input: CreateWorktreeChatInput) => Promise; + }; updateSession: { mutate: (input: UpdateSessionInput) => Promise; }; @@ -646,6 +682,16 @@ export class CloudAgentNextClient { const result = await this.client.deleteSession.mutate({ sessionId }); return { success: result.success }; } catch (error) { + if ( + error instanceof TRPCClientError && + (error.data?.code === 'NOT_FOUND' || + error.shape?.data?.code === 'NOT_FOUND' || + error.data?.httpStatus === 404 || + error.shape?.data?.httpStatus === 404) + ) { + throw error; + } + console.error(`Error deleting session ${sessionId}:`, error); captureException(error, { tags: { source: 'cloud-agent-next-client', endpoint: 'deleteSession' }, @@ -655,6 +701,18 @@ export class CloudAgentNextClient { } } + async deleteWorktree(input: DeleteWorktreeInput): Promise { + try { + return deleteWorktreeOutputSchema.parse(await this.client.deleteWorktree.mutate(input)); + } catch (error) { + captureException(error, { + tags: { source: 'cloud-agent-next-client', endpoint: 'deleteWorktree' }, + extra: { worktreeId: input.worktreeId }, + }); + throw error; + } + } + /** * Clean up a caller-created session and classify its runtime deletion as caller cleanup. */ @@ -731,6 +789,21 @@ export class CloudAgentNextClient { return await this.client.getComputeBillingStatus.query({ cloudAgentSessionId }); } + async createWorktreeChat(input: CreateWorktreeChatInput): Promise { + try { + return await this.client.createWorktreeChat.mutate(input); + } catch (error) { + captureException(error, { + tags: { source: 'cloud-agent-next-client', endpoint: 'createWorktreeChat' }, + extra: { + sourceKiloSessionId: input.sourceKiloSessionId, + sourceCloudAgentSessionId: input.sourceCloudAgentSessionId, + }, + }); + throw error; + } + } + /** * Prepare a new cloud agent session. */ @@ -767,7 +840,15 @@ export class CloudAgentNextClient { captureException(normalizedError, { tags: { source: 'cloud-agent-next-client', endpoint: 'prepareSession' }, - extra: { input }, + extra: { + kilocodeOrganizationId: input.kilocodeOrganizationId, + cloneFromKiloSessionId: input.cloneFromKiloSessionId, + operationKey: input.operationKey, + createdOnPlatform: input.createdOnPlatform, + clientProvenance: input.clientProvenance, + model: input.model, + mode: input.mode, + }, }); throw normalizedError; } @@ -818,7 +899,7 @@ export class CloudAgentNextClient { source: 'cloud-agent-next-client', endpoint: 'initiateFromPreparedSession', }, - extra: { input }, + extra: { cloudAgentSessionId: input.cloudAgentSessionId }, }); throw normalizedError; } @@ -844,7 +925,11 @@ export class CloudAgentNextClient { captureException(normalizedError, { tags: { source: 'cloud-agent-next-client', endpoint: 'sendMessage' }, - extra: { input }, + extra: { + cloudAgentSessionId: input.cloudAgentSessionId, + messageId: input.messageId, + payloadType: input.payload.type, + }, }); throw normalizedError; } diff --git a/apps/web/src/lib/cloud-agent-next/worktree-chat.test.ts b/apps/web/src/lib/cloud-agent-next/worktree-chat.test.ts new file mode 100644 index 0000000000..fefa9edca5 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-next/worktree-chat.test.ts @@ -0,0 +1,274 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import type { User } from '@kilocode/db/schema'; +import { TRPCClientError } from '@trpc/client'; +import type { SQL } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import type { CreateWorktreeChatInput, CreateWorktreeChatOutput } from './cloud-agent-client'; +import type { createWorktreeChat as CreateWorktreeChat } from './worktree-chat'; + +const UUID = '12345678-1234-4234-9234-123456789abc'; +const ORGANIZATION_ID = '9a283301-b75d-4375-a1ba-e319a02e18b7'; +const SOURCE_KILO_SESSION_ID = 'ses_12345678901234567890123456'; +const SOURCE_WORKSPACE_ID = `workspace_${UUID}` as const; +const WORKTREE_ID = `worktree_${UUID}` as const; + +type SourceSession = { + cloudAgentSessionId: string | null; + worktreeId: string | null; +}; + +const mockLimit = jest.fn<(limit: number) => Promise>(); +const mockWhere = jest.fn((condition: SQL | undefined) => ({ limit: mockLimit, condition })); +const mockLeftJoin = jest.fn((_table: unknown, condition: SQL | undefined) => ({ + where: mockWhere, + condition, +})); +const mockFrom = jest.fn(() => ({ leftJoin: mockLeftJoin })); +const mockSelect = jest.fn(() => ({ from: mockFrom })); +const mockWorkerCreateWorktreeChat = + jest.fn<(input: CreateWorktreeChatInput) => Promise>(); +const mockCreateCloudAgentNextClient = jest.fn(() => ({ + createWorktreeChat: mockWorkerCreateWorktreeChat, +})); +const mockGenerateCloudAgentToken = jest.fn(() => 'cloud-agent-token'); + +jest.mock('@/lib/drizzle', () => ({ + db: { select: mockSelect }, +})); + +jest.mock('@/lib/tokens', () => ({ + generateCloudAgentToken: mockGenerateCloudAgentToken, +})); + +jest.mock('./cloud-agent-client', () => ({ + createCloudAgentNextClient: mockCreateCloudAgentNextClient, +})); + +let createWorktreeChat: typeof CreateWorktreeChat; + +function compileCondition(condition: SQL | undefined) { + if (!condition) { + throw new Error('Expected a SQL condition'); + } + + return new PgDialect().sqlToQuery(condition); +} + +beforeAll(async () => { + ({ createWorktreeChat } = await import('./worktree-chat')); +}); + +describe('createWorktreeChat', () => { + const user = { id: 'oauth/github|owner', is_admin: false } as User; + const result = { + kiloSessionId: 'ses_abcdefghijklmnopqrstuvwxyz', + cloudAgentSessionId: SOURCE_WORKSPACE_ID, + worktreeId: WORKTREE_ID, + replayed: false, + } satisfies CreateWorktreeChatOutput; + + beforeEach(() => { + jest.clearAllMocks(); + mockLimit.mockResolvedValue([ + { cloudAgentSessionId: SOURCE_WORKSPACE_ID, worktreeId: WORKTREE_ID }, + ]); + mockWorkerCreateWorktreeChat.mockResolvedValue(result); + }); + + it('authorizes exact personal ownership, preserves arbitrary user IDs, and sends trusted browser provenance', async () => { + await expect( + createWorktreeChat({ + user, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + }) + ).resolves.toEqual(result); + + const where = compileCondition(mockWhere.mock.calls[0]?.[0]); + expect(where.sql).toContain('"organization_id" is null'); + expect(where.params).toEqual([SOURCE_KILO_SESSION_ID, user.id]); + expect(mockWorkerCreateWorktreeChat).toHaveBeenCalledWith({ + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + sourceCloudAgentSessionId: SOURCE_WORKSPACE_ID, + operationKey: UUID, + clientProvenance: 'browser', + }); + }); + + it('requires the owner and current membership in the exact organization', async () => { + await expect( + createWorktreeChat({ + user, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + organizationId: ORGANIZATION_ID, + }) + ).resolves.toEqual(result); + + const membership = compileCondition(mockLeftJoin.mock.calls[0]?.[1]); + const where = compileCondition(mockWhere.mock.calls[0]?.[0]); + expect(membership.sql).toContain('"organization_memberships"."organization_id"'); + expect(membership.params).toEqual([user.id]); + expect(where.sql).toContain('"organization_memberships"."id" is not null'); + expect(where.params).toEqual([SOURCE_KILO_SESSION_ID, user.id, ORGANIZATION_ID]); + expect(mockWorkerCreateWorktreeChat).toHaveBeenCalledWith({ + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + sourceCloudAgentSessionId: SOURCE_WORKSPACE_ID, + operationKey: UUID, + kilocodeOrganizationId: ORGANIZATION_ID, + clientProvenance: 'browser', + }); + }); + + it.each([ + { + description: 'another personal owner', + userId: 'oauth/github|attacker', + organizationId: undefined, + }, + { + description: 'a same-organization nonowner', + userId: 'oauth/github|coworker', + organizationId: ORGANIZATION_ID, + }, + { + description: 'the wrong organization context', + userId: user.id, + organizationId: '22222222-2222-4222-8222-222222222222', + }, + { + description: 'a revoked organization member', + userId: user.id, + organizationId: ORGANIZATION_ID, + }, + ])('denies $description without contacting the Worker', async ({ userId, organizationId }) => { + mockLimit.mockResolvedValueOnce([]); + const requestUser = { ...user, id: userId }; + + await expect( + createWorktreeChat({ + user: requestUser, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + ...(organizationId ? { organizationId } : {}), + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + const where = compileCondition(mockWhere.mock.calls[0]?.[0]); + expect(where.params).toContain(userId); + if (organizationId) { + expect(where.params).toContain(organizationId); + expect(where.sql).toContain('"organization_memberships"."id" is not null'); + } + expect(mockGenerateCloudAgentToken).not.toHaveBeenCalled(); + expect(mockWorkerCreateWorktreeChat).not.toHaveBeenCalled(); + }); + + it.each([ + { cloudAgentSessionId: null, worktreeId: WORKTREE_ID }, + { cloudAgentSessionId: `agent_${UUID}`, worktreeId: WORKTREE_ID }, + { cloudAgentSessionId: 'workspace_invalid', worktreeId: WORKTREE_ID }, + { cloudAgentSessionId: SOURCE_WORKSPACE_ID, worktreeId: null }, + { cloudAgentSessionId: SOURCE_WORKSPACE_ID, worktreeId: 'worktree_invalid' }, + ])('rejects an ineligible source %j before contacting the Worker', async source => { + mockLimit.mockResolvedValueOnce([source]); + + await expect( + createWorktreeChat({ + user, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + + expect(mockWorkerCreateWorktreeChat).not.toHaveBeenCalled(); + }); + + it('rejects native mobile before querying the source or trusting browser provenance', async () => { + await expect( + createWorktreeChat({ + user, + headersList: new Headers({ 'x-kilo-client': 'mobile' }), + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + expect(mockSelect).not.toHaveBeenCalled(); + expect(mockWorkerCreateWorktreeChat).not.toHaveBeenCalled(); + }); + + it.each([ + ['BAD_REQUEST', 'worktree_source_not_eligible', 'worktree_source_not_eligible'], + ['FORBIDDEN', 'Session access denied', 'Worktree chat request failed'], + ['NOT_FOUND', 'Private checkout /srv/secret', 'Worktree chat request failed'], + ['CONFLICT', 'creation_in_progress', 'creation_in_progress'], + ['CONFLICT', 'operation_key_reuse_mismatch', 'operation_key_reuse_mismatch'], + ['SERVICE_UNAVAILABLE', 'Bearer sensitive-worker-token', 'Worktree chat request failed'], + [ + 'INTERNAL_SERVER_ERROR', + 'worktree_chat_registration_failed', + 'worktree_chat_registration_failed', + ], + ] as const)( + 'preserves safe upstream %s semantics without leaking details', + async (code, message, expectedMessage) => { + const error = new TRPCClientError(message, { + result: { + error: { + code: -32000, + message, + data: { code }, + }, + }, + }); + mockWorkerCreateWorktreeChat.mockRejectedValueOnce(error); + + await expect( + createWorktreeChat({ + user, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + }) + ).rejects.toMatchObject({ code, message: expectedMessage }); + } + ); + + it('maps a known upstream HTTP status when no tRPC code is available', async () => { + const error = new TRPCClientError('internal detail token=private', { + result: { + error: { + code: -32000, + message: 'internal detail token=private', + data: { httpStatus: 409 }, + }, + }, + }); + mockWorkerCreateWorktreeChat.mockRejectedValueOnce(error); + + await expect( + createWorktreeChat({ + user, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + }) + ).rejects.toMatchObject({ code: 'CONFLICT', message: 'Worktree chat request failed' }); + }); + + it('converts ambiguous transport failures into sanitized retryable service errors', async () => { + mockWorkerCreateWorktreeChat.mockRejectedValueOnce( + new Error('Worker unavailable Authorization: Bearer private-token') + ); + + await expect( + createWorktreeChat({ + user, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + operationKey: UUID, + }) + ).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + message: 'Worktree chat service is unavailable', + }); + }); +}); diff --git a/apps/web/src/lib/cloud-agent-next/worktree-chat.ts b/apps/web/src/lib/cloud-agent-next/worktree-chat.ts new file mode 100644 index 0000000000..f4e33862d2 --- /dev/null +++ b/apps/web/src/lib/cloud-agent-next/worktree-chat.ts @@ -0,0 +1,180 @@ +import 'server-only'; +import type { User } from '@kilocode/db/schema'; +import { cli_sessions_v2, organization_memberships } from '@kilocode/db/schema'; +import { cloudAgentWorktreeIdSchema } from '@kilocode/session-ingest-contracts'; +import { TRPCClientError } from '@trpc/client'; +import { TRPCError } from '@trpc/server'; +import { and, eq, isNotNull, isNull } from 'drizzle-orm'; +import * as z from 'zod'; +import { db } from '@/lib/drizzle'; +import { generateCloudAgentToken } from '@/lib/tokens'; +import { isMobileClient } from '@/lib/trpc/min-version'; +import { createCloudAgentNextClient, type CreateWorktreeChatOutput } from './cloud-agent-client'; + +type CreateWorktreeChatOptions = { + user: User; + headersList?: Headers; + sourceKiloSessionId: string; + operationKey: string; + organizationId?: string; +}; + +type WorktreeUpstreamErrorShape = { + data?: { code?: unknown; httpStatus?: unknown }; + shape?: { data?: { code?: unknown; httpStatus?: unknown } } | null; +}; + +type WorktreeErrorCode = + | 'BAD_REQUEST' + | 'UNAUTHORIZED' + | 'PAYMENT_REQUIRED' + | 'FORBIDDEN' + | 'NOT_FOUND' + | 'TIMEOUT' + | 'CONFLICT' + | 'PRECONDITION_FAILED' + | 'UNPROCESSABLE_CONTENT' + | 'TOO_MANY_REQUESTS' + | 'INTERNAL_SERVER_ERROR' + | 'BAD_GATEWAY' + | 'SERVICE_UNAVAILABLE' + | 'GATEWAY_TIMEOUT'; + +const worktreeErrorCodesByHttpStatus = new Map([ + [400, 'BAD_REQUEST'], + [401, 'UNAUTHORIZED'], + [402, 'PAYMENT_REQUIRED'], + [403, 'FORBIDDEN'], + [404, 'NOT_FOUND'], + [408, 'TIMEOUT'], + [409, 'CONFLICT'], + [412, 'PRECONDITION_FAILED'], + [422, 'UNPROCESSABLE_CONTENT'], + [429, 'TOO_MANY_REQUESTS'], + [500, 'INTERNAL_SERVER_ERROR'], + [502, 'BAD_GATEWAY'], + [503, 'SERVICE_UNAVAILABLE'], + [504, 'GATEWAY_TIMEOUT'], +]); + +const safeWorktreeErrorMessages = new Set([ + 'creation_in_progress', + 'operation_key_reuse_mismatch', + 'worktree_source_not_eligible', + 'worktree_chat_creation_failed', + 'worktree_chat_registration_failed', +]); + +function getWorktreeErrorCode(error: WorktreeUpstreamErrorShape): WorktreeErrorCode | undefined { + const code = error.data?.code ?? error.shape?.data?.code; + if ( + code === 'BAD_REQUEST' || + code === 'UNAUTHORIZED' || + code === 'PAYMENT_REQUIRED' || + code === 'FORBIDDEN' || + code === 'NOT_FOUND' || + code === 'TIMEOUT' || + code === 'CONFLICT' || + code === 'PRECONDITION_FAILED' || + code === 'UNPROCESSABLE_CONTENT' || + code === 'TOO_MANY_REQUESTS' || + code === 'INTERNAL_SERVER_ERROR' || + code === 'BAD_GATEWAY' || + code === 'SERVICE_UNAVAILABLE' || + code === 'GATEWAY_TIMEOUT' + ) { + return code; + } + + const status = error.data?.httpStatus ?? error.shape?.data?.httpStatus; + return typeof status === 'number' ? worktreeErrorCodesByHttpStatus.get(status) : undefined; +} + +export async function createWorktreeChat({ + user, + headersList, + sourceKiloSessionId, + operationKey, + organizationId, +}: CreateWorktreeChatOptions): Promise { + if (isMobileClient(headersList)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Worktree chats are only available in the browser', + }); + } + + const organizationCondition = organizationId + ? and( + eq(cli_sessions_v2.organization_id, organizationId), + isNotNull(organization_memberships.id) + ) + : isNull(cli_sessions_v2.organization_id); + + const [source] = await db + .select({ + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + }) + .from(cli_sessions_v2) + .leftJoin( + organization_memberships, + and( + eq(organization_memberships.organization_id, cli_sessions_v2.organization_id), + eq(organization_memberships.kilo_user_id, user.id) + ) + ) + .where( + and( + eq(cli_sessions_v2.session_id, sourceKiloSessionId), + eq(cli_sessions_v2.kilo_user_id, user.id), + organizationCondition + ) + ) + .limit(1); + + if (!source) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Source session not found or access denied', + }); + } + + if ( + !source.cloudAgentSessionId?.startsWith('workspace_') || + !z.uuid().safeParse(source.cloudAgentSessionId.slice('workspace_'.length)).success || + !cloudAgentWorktreeIdSchema.safeParse(source.worktreeId).success + ) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Source session is not part of a worktree', + }); + } + + try { + return await createCloudAgentNextClient(generateCloudAgentToken(user)).createWorktreeChat({ + sourceKiloSessionId, + sourceCloudAgentSessionId: source.cloudAgentSessionId, + operationKey, + ...(organizationId ? { kilocodeOrganizationId: organizationId } : {}), + clientProvenance: 'browser', + }); + } catch (error) { + if (error instanceof TRPCClientError) { + const code = getWorktreeErrorCode(error); + if (code) { + throw new TRPCError({ + code, + message: safeWorktreeErrorMessages.has(error.message) + ? error.message + : 'Worktree chat request failed', + }); + } + } + + throw new TRPCError({ + code: 'SERVICE_UNAVAILABLE', + message: 'Worktree chat service is unavailable', + }); + } +} diff --git a/apps/web/src/lib/user/index.test.ts b/apps/web/src/lib/user/index.test.ts index 19588d38d6..035171c8e4 100644 --- a/apps/web/src/lib/user/index.test.ts +++ b/apps/web/src/lib/user/index.test.ts @@ -131,6 +131,7 @@ import { user_deletion_requests, user_deletion_steps, cloud_agent_pending_uploads, + cloud_agent_worktrees, } from '@kilocode/db/schema'; import { eq, count, inArray, sql } from 'drizzle-orm'; @@ -201,6 +202,7 @@ const mockRecordAffiliateAttributionAndQueueParentEvent = jest.mocked( describe('User', () => { // Shared cleanup for all tests in this suite to prevent data pollution afterEach(async () => { + await db.delete(cloud_agent_worktrees); await db.delete(user_deletion_steps); await db.delete(user_deletion_requests); await db.delete(deployments_ephemeral); @@ -1502,6 +1504,42 @@ describe('User', () => { }); describe('softDeleteUser', () => { + it('scrubs worktree names without removing deletion fences or another owner’s names', async () => { + const user = await insertTestUser(); + const otherUser = await insertTestUser(); + const worktreeId = `worktree_${randomUUID()}`; + const deletionStartedAt = '2026-08-27T08:00:00.000Z'; + await db.insert(cloud_agent_worktrees).values([ + { + worktree_id: worktreeId, + kilo_user_id: user.id, + name: 'Private customer incident', + deletion_started_at: deletionStartedAt, + }, + { + worktree_id: `worktree_${randomUUID()}`, + kilo_user_id: otherUser.id, + name: 'Keep this name', + }, + ]); + + await softDeleteUser(user.id); + + const [deletedUserWorktree] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, worktreeId)); + expect(deletedUserWorktree.name).toBeNull(); + expect(new Date(deletedUserWorktree.deletion_started_at ?? '').toISOString()).toBe( + deletionStartedAt + ); + const [otherWorktree] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.kilo_user_id, otherUser.id)); + expect(otherWorktree.name).toBe('Keep this name'); + }); + it('deletes operation ledger rows by user id and analytics outbox rows by either identity', async () => { const user = await insertTestUser({ google_user_email: 'ledger-user@example.com' }); const otherUser = await insertTestUser(); diff --git a/apps/web/src/lib/user/index.ts b/apps/web/src/lib/user/index.ts index 773c39d076..763039fef5 100644 --- a/apps/web/src/lib/user/index.ts +++ b/apps/web/src/lib/user/index.ts @@ -23,6 +23,7 @@ import { kilo_pass_store_purchases, kilo_pass_subscriptions, cloud_agent_webhook_triggers, + cloud_agent_worktrees, enrichment_data, source_embeddings, code_indexing_search, @@ -1176,6 +1177,10 @@ export async function anonymizeCloudUserData( ), updated_at = now() `); await tx.delete(user_data_exports).where(eq(user_data_exports.kilo_user_id, userId)); + await tx + .update(cloud_agent_worktrees) + .set({ name: null }) + .where(eq(cloud_agent_worktrees.kilo_user_id, userId)); await tx.delete(enrichment_data).where(eq(enrichment_data.user_id, userId)); await tx.delete(user_admin_notes).where(eq(user_admin_notes.kilo_user_id, userId)); await tx diff --git a/apps/web/src/routers/cli-sessions-v2-router.test.ts b/apps/web/src/routers/cli-sessions-v2-router.test.ts index d999dfb4c5..3d99ee41ec 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.test.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.test.ts @@ -12,7 +12,9 @@ import { } from '@kilocode/db/schema'; import { eq, and, inArray } from 'drizzle-orm'; import type { User, Organization } from '@kilocode/db/schema'; +import type { KiloSdkStoredMessage } from '@kilocode/session-ingest-contracts'; import * as githubAdapter from '@/lib/integrations/platforms/github/adapter'; +import { TRPCClientError } from '@trpc/client'; import { TRPCError } from '@trpc/server'; import { parseGitHubOwnerRepo, parseGitHubPrUrl } from '@/routers/cli-sessions-v2-router'; import type { fetchSessionMessagesPage as FetchSessionMessagesPageType } from '@/lib/session-ingest-client'; @@ -21,10 +23,12 @@ import { captureException } from '@sentry/nextjs'; // Mock the cloud agent client for watermark and runtime-state tests. const mockGetSession = jest.fn().mockRejectedValue(new Error('not mocked')); +const mockDeleteCloudAgentSession = jest.fn().mockResolvedValue({ success: true }); jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ createCloudAgentNextClient: jest.fn(() => ({ getSession: mockGetSession, + deleteSession: mockDeleteCloudAgentSession, })), rethrowAsPaymentRequired: jest.fn(), InsufficientCreditsError: class InsufficientCreditsError extends Error { @@ -124,6 +128,7 @@ describe('cli-sessions-v2-router', () => { beforeEach(() => { afterCallbacks.length = 0; mockedNotifyCliSessionRenamed.mockReset().mockResolvedValue({ delivered: true }); + mockDeleteCloudAgentSession.mockReset().mockResolvedValue({ success: true }); mockedCaptureException.mockClear(); }); @@ -209,6 +214,154 @@ describe('cli-sessions-v2-router', () => { }); }); + it('projects every grouped snapshot path into the distinct public chat directory', async () => { + const privateDirectory = '/srv/runtime/private/worktrees/worktree-secret'; + const publicDirectory = `/cloud-agent/sessions/${sessionId}`; + await db + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) + .where(eq(cli_sessions_v2.session_id, sessionId)); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + info: { + id: sessionId, + directory: privateDirectory, + path: `${privateDirectory}/session.json`, + summary: { diffs: [{ file: `${privateDirectory}/src/app.ts` }] }, + permission: [{ pattern: `${privateDirectory}/src` }], + }, + messages: [ + { + info: { + id: 'msg_assistant', + role: 'assistant', + path: { cwd: privateDirectory, root: privateDirectory }, + }, + parts: [ + { + id: 'prt_file', + type: 'file', + url: `file://${privateDirectory}/attachment.txt`, + source: { type: 'file', path: `${privateDirectory}/attachment.txt` }, + }, + { + id: 'prt_tool', + type: 'tool', + state: { + status: 'completed', + attachments: [ + { + id: 'prt_nested', + type: 'file', + url: `file://${privateDirectory}/nested.txt`, + source: { type: 'symbol', path: `${privateDirectory}/nested.ts` }, + }, + ], + }, + }, + { + id: 'prt_patch', + type: 'patch', + files: [`${privateDirectory}/src/app.ts`, 'relative.ts'], + }, + ], + }, + { + info: { + id: 'msg_user', + role: 'user', + editorContext: { + visibleFiles: [`${privateDirectory}/visible.ts`], + openTabs: [`${privateDirectory}/open.ts`], + activeFile: `${privateDirectory}/active.ts`, + }, + summary: { diffs: [{ file: `${privateDirectory}/summary.ts` }] }, + }, + parts: [], + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + const caller = await createCallerForUser(regularUser.id); + + const result = await caller.cliSessionsV2.getSessionMessages({ session_id: sessionId }); + + expect(result.info).toMatchObject({ + directory: publicDirectory, + summary: { diffs: [{ file: publicDirectory }] }, + permission: [{ pattern: publicDirectory }], + }); + expect(result.messages[0]).toMatchObject({ + info: { path: { cwd: publicDirectory, root: publicDirectory } }, + parts: [ + { url: '', source: { path: publicDirectory } }, + { state: { attachments: [{ url: '', source: { path: publicDirectory } }] } }, + { files: [publicDirectory, 'relative.ts'] }, + ], + }); + expect(result.messages[1]).toMatchObject({ + info: { + editorContext: { + visibleFiles: [publicDirectory], + openTabs: [publicDirectory], + activeFile: publicDirectory, + }, + summary: { diffs: [{ file: publicDirectory }] }, + }, + }); + expect(JSON.stringify(result)).not.toContain(privateDirectory); + }); + + it('preserves private-path fields unchanged for legacy ungrouped snapshots', async () => { + const privateDirectory = '/legacy/unchanged/workspace'; + const snapshot = { + info: { id: sessionId, directory: privateDirectory }, + messages: [ + { + info: { + id: 'msg_legacy', + role: 'assistant', + path: { cwd: privateDirectory, root: privateDirectory }, + }, + parts: [ + { + id: 'prt_legacy', + type: 'file', + url: `file://${privateDirectory}/legacy.txt`, + }, + ], + }, + ], + }; + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify(snapshot), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessages({ session_id: sessionId }) + ).resolves.toEqual(snapshot); + }); + + it('keeps a grouped sibling without persisted history blank and writable', async () => { + await db + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) + .where(eq(cli_sessions_v2.session_id, sessionId)); + fetchSpy.mockResolvedValueOnce(new Response(null, { status: 404 })); + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessages({ session_id: sessionId }) + ).resolves.toEqual({ info: {}, messages: [] }); + }); + it('does not fetch a snapshot for a session owned by another user', async () => { const caller = await createCallerForUser(otherUser.id); @@ -221,8 +374,56 @@ describe('cli-sessions-v2-router', () => { describe('getSessionMessagesPage', () => { const sessionId = 'ses_messages_page_test_1234'; + const privateDirectory = '/srv/runtime/private/worktrees/grouped-history'; let fetchSessionMessagesPage: jest.MockedFunction; + function privateAssistantMessage(): KiloSdkStoredMessage { + return { + info: { + id: 'msg_assistant_01', + sessionID: sessionId, + role: 'assistant', + time: { created: 1761000000100 }, + parentID: 'msg_user_01', + modelID: 'kilo/test-model', + providerID: 'openrouter', + mode: 'code', + agent: 'code', + path: { cwd: privateDirectory, root: privateDirectory }, + cost: 0, + tokens: { + input: 1, + output: 1, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + }, + parts: [ + { + id: 'prt_file_01', + sessionID: sessionId, + messageID: 'msg_assistant_01', + type: 'file', + mime: 'text/plain', + url: `file://${privateDirectory}/attachment.txt`, + source: { + type: 'file', + text: { value: 'attachment', start: 0, end: 10 }, + path: `${privateDirectory}/attachment.txt`, + }, + }, + { + id: 'prt_patch_01', + sessionID: sessionId, + messageID: 'msg_assistant_01', + type: 'patch', + hash: 'patch', + files: [`${privateDirectory}/src/app.ts`, 'relative.ts'], + }, + ], + }; + } + beforeEach(async () => { const { fetchSessionMessagesPage: imported } = jest.requireMock( '@/lib/session-ingest-client' @@ -292,6 +493,72 @@ describe('cli-sessions-v2-router', () => { }); }); + it('projects grouped paginated assistant and attachment paths into their own public directory', async () => { + await db + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) + .where(eq(cli_sessions_v2.session_id, sessionId)); + fetchSessionMessagesPage.mockResolvedValueOnce({ + kiloSessionId: sessionId, + history: { + messages: [privateAssistantMessage()], + nextCursor: null, + omittedItemCount: 0, + }, + }); + const caller = await createCallerForUser(regularUser.id); + const publicDirectory = `/cloud-agent/sessions/${sessionId}`; + + const result = await caller.cliSessionsV2.getSessionMessagesPage({ + session_id: sessionId, + limit: 50, + }); + + expect(result).toMatchObject({ + kiloSessionId: sessionId, + history: { + messages: [ + { + info: { path: { cwd: publicDirectory, root: publicDirectory } }, + parts: [ + { url: '', source: { path: publicDirectory } }, + { files: [publicDirectory, 'relative.ts'] }, + ], + }, + ], + nextCursor: null, + omittedItemCount: 0, + }, + watermarkEventId: null, + }); + expect(JSON.stringify(result)).not.toContain(privateDirectory); + }); + + it('preserves private-path fields unchanged for ungrouped paginated history', async () => { + const message = privateAssistantMessage(); + const history = { messages: [message], nextCursor: null, omittedItemCount: 0 }; + fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 50 }) + ).resolves.toEqual({ kiloSessionId: sessionId, history, watermarkEventId: null }); + }); + + it('preserves grouped typed history failures without fabricating a transcript', async () => { + await db + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) + .where(eq(cli_sessions_v2.session_id, sessionId)); + const history = { kind: 'retryable_failure' as const, phase: 'page_parts' as const }; + fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); + const caller = await createCallerForUser(regularUser.id); + + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 50 }) + ).resolves.toEqual({ kiloSessionId: sessionId, history, watermarkEventId: null }); + }); + it('forwards the continuation cursor to the client', async () => { fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, @@ -753,6 +1020,79 @@ describe('cli-sessions-v2-router', () => { }); }); + describe('runtime session deletion', () => { + const sessionId = 'ses_runtime_delete_grouped_1234'; + const cloudAgentSessionId = 'workspace_12345678-1234-4234-9234-123456789abc'; + let fetchSpy: jest.SpyInstance; + + beforeEach(async () => { + await db.insert(cli_sessions_v2).values({ + session_id: sessionId, + kilo_user_id: regularUser.id, + cloud_agent_session_id: cloudAgentSessionId, + cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc', + created_on_platform: 'cloud-agent-web', + }); + fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue(new Response(null, { status: 204 })); + }); + + afterEach(async () => { + fetchSpy.mockRestore(); + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, sessionId)); + }); + + it.each([ + [ + 'reports unsuccessful deletion', + () => mockDeleteCloudAgentSession.mockResolvedValueOnce({ success: false }), + ], + [ + 'throws an operational failure', + () => mockDeleteCloudAgentSession.mockRejectedValueOnce(new Error('Worker unavailable')), + ], + ])('preserves the real ownership row when the Worker %s', async (_, failDeletion) => { + failDeletion(); + const caller = await createCallerForUser(regularUser.id); + + await expect(caller.cliSessionsV2.delete({ session_id: sessionId })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to clean up cloud-agent session', + }); + + const [persisted] = await db + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.session_id, sessionId)); + expect(persisted?.sessionId).toBe(sessionId); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('continues idempotent ownership cleanup after a typed runtime NOT_FOUND', async () => { + const error = new TRPCClientError('Runtime session not found', { + result: { + error: { + code: -32004, + message: 'Runtime session not found', + data: { code: 'NOT_FOUND', httpStatus: 404 }, + }, + }, + }); + mockDeleteCloudAgentSession.mockRejectedValueOnce(error); + const caller = await createCallerForUser(regularUser.id); + + await expect(caller.cliSessionsV2.delete({ session_id: sessionId })).resolves.toEqual({ + success: true, + session_id: sessionId, + }); + + expect(mockDeleteCloudAgentSession).toHaveBeenCalledWith(cloudAgentSessionId); + expect(fetchSpy).toHaveBeenCalledWith( + `https://test-ingest.example.com/api/session/${encodeURIComponent(sessionId)}`, + expect.objectContaining({ method: 'DELETE' }) + ); + }); + }); + describe('shareForWebhookTrigger', () => { let triggerId: string; let profileId: string; diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 680a08e7a2..7b6fbc409e 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -6,6 +6,7 @@ import { eq, and, or, + asc, desc, lt, isNull, @@ -17,15 +18,18 @@ import { type SQL, } from 'drizzle-orm'; import { TRPCError } from '@trpc/server'; +import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc'; import { captureException } from '@sentry/nextjs'; import { TRPCClientError } from '@trpc/client'; import { cli_sessions_v2, + cloud_agent_worktrees, github_branch_pull_requests, organization_memberships, + type CliSessionV2, } from '@kilocode/db/schema'; import { createCloudAgentNextClient } from '@/lib/cloud-agent-next/cloud-agent-client'; -import { generateApiToken } from '@/lib/tokens'; +import { generateApiToken, generateCloudAgentToken } from '@/lib/tokens'; import { fetchSessionSnapshot, fetchSessionMessagesPage, @@ -36,6 +40,8 @@ import { import { DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, + cloudAgentWorktreeIdSchema, + isDefaultSessionTitle, validateKiloSdkMessagesCursor, } from '@kilocode/session-ingest-contracts'; import { baseGetSessionNextOutputSchema } from './cloud-agent-next-schemas'; @@ -77,6 +83,70 @@ function isSessionNotFoundError(err: unknown): boolean { return false; } +const privateTranscriptPathFields = new Set([ + 'path', + 'file', + 'activeFile', + 'pattern', + 'absolutePath', + 'filePath', + 'filepath', +]); +const privateTranscriptPathArrays = new Set(['files', 'visibleFiles', 'openTabs']); + +function isAbsoluteTranscriptPath(value: string): boolean { + return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value); +} + +function projectGroupedTranscriptPaths(value: unknown, directory: string): void { + if (Array.isArray(value)) { + for (const item of value) { + projectGroupedTranscriptPaths(item, directory); + } + return; + } + + if (typeof value !== 'object' || value === null) { + return; + } + + for (const [key, current] of Object.entries(value)) { + if (typeof current === 'string') { + if ( + key === 'directory' || + key === 'cwd' || + key === 'root' || + (privateTranscriptPathFields.has(key) && isAbsoluteTranscriptPath(current)) + ) { + Reflect.set(value, key, directory); + } else if ((key === 'url' || key === 'uri') && /^file:/i.test(current)) { + Reflect.set(value, key, ''); + } + continue; + } + + if (Array.isArray(current) && privateTranscriptPathArrays.has(key)) { + for (let index = 0; index < current.length; index += 1) { + const path = current[index]; + if (typeof path === 'string' && isAbsoluteTranscriptPath(path)) { + current[index] = directory; + } else { + projectGroupedTranscriptPaths(path, directory); + } + } + continue; + } + + projectGroupedTranscriptPaths(current, directory); + } +} + +function projectGroupedSessionTranscript(value: T, kiloSessionId: string): T { + const projected = structuredClone(value); + projectGroupedTranscriptPaths(projected, `/cloud-agent/sessions/${kiloSessionId}`); + return projected; +} + const PAGE_SIZE = 10; const RECENT_DAYS_LIMIT = 200; /** Upper bound of PostgreSQL `integer`; a larger needle cannot be a `pr_number`. */ @@ -344,6 +414,7 @@ const commonSessionFields = { session_id: cli_sessions_v2.session_id, title: cli_sessions_v2.title, cloud_agent_session_id: cli_sessions_v2.cloud_agent_session_id, + cloud_agent_worktree_id: cli_sessions_v2.cloud_agent_worktree_id, parent_session_id: cli_sessions_v2.parent_session_id, organization_id: cli_sessions_v2.organization_id, created_on_platform: cli_sessions_v2.created_on_platform, @@ -455,6 +526,39 @@ function projectAssociatedPr< }; } +type WorktreeDetail = { + name: string | null; + defaultTitle: string | null; + prSession: + | (Pick & { + associatedPr: z.infer | null; + }) + | null; +}; + +const WorktreeInputSchema = z.object({ + worktreeId: cloudAgentWorktreeIdSchema, + organizationId: z.uuid().nullable(), +}); + +const WorktreeDetailsInputSchema = z.object({ + worktreeIds: z + .array(cloudAgentWorktreeIdSchema) + .max(200) + .transform(ids => [...new Set(ids)]), + organizationId: z.uuid().nullable(), +}); + +function worktreeMembershipCondition(userId: string, organizationId: string | null) { + return organizationId === null + ? undefined + : sql`EXISTS ( + SELECT 1 FROM ${organization_memberships} + WHERE ${organization_memberships.kilo_user_id} = ${userId} + AND ${organization_memberships.organization_id} = ${organizationId} + )`; +} + const sessionIdField = z.string().min(1); const cloudAgentSessionIdField = z.string().min(1).max(255); @@ -526,6 +630,7 @@ const ListSessionsInputSchema = z.object({ .union([createdOnPlatformField, z.array(createdOnPlatformField).min(1)]) .optional(), organizationId: z.uuid().nullable().optional(), + worktreeId: cloudAgentWorktreeIdSchema.optional(), gitUrl: z.union([z.string(), z.array(z.string()).min(1)]).optional(), updatedSince: z.iso.datetime().optional(), version: z.number().optional(), @@ -547,6 +652,7 @@ const SearchInputSchema = z.object({ .union([createdOnPlatformField, z.array(createdOnPlatformField).min(1)]) .optional(), organizationId: z.uuid().nullable().optional(), + worktreeId: cloudAgentWorktreeIdSchema.optional(), includeChildren: z.boolean().optional().default(false), sharedOnly: z.boolean().optional().default(false), gitUrl: z.union([z.string(), z.array(z.string()).min(1)]).optional(), @@ -714,6 +820,204 @@ function joinWithAnd(fragments: SQL[]): SQL { * This router only queries the data. */ export const cliSessionsV2Router = createTRPCRouter({ + worktreeDetails: baseProcedure.input(WorktreeDetailsInputSchema).query(async ({ ctx, input }) => { + const worktrees: Record = {}; + if (input.worktreeIds.length === 0) { + return { worktrees }; + } + + const scopeCondition = and( + eq(cli_sessions_v2.kilo_user_id, ctx.user.id), + input.organizationId === null + ? isNull(cli_sessions_v2.organization_id) + : eq(cli_sessions_v2.organization_id, input.organizationId), + worktreeMembershipCondition(ctx.user.id, input.organizationId), + inArray(cli_sessions_v2.cloud_agent_worktree_id, input.worktreeIds), + isNull(cli_sessions_v2.parent_session_id), + or( + isNull(cloud_agent_worktrees.worktree_id), + and( + eq(cloud_agent_worktrees.kilo_user_id, ctx.user.id), + sql`${cloud_agent_worktrees.organization_id} IS NOT DISTINCT FROM ${cli_sessions_v2.organization_id}` + ) + ), + isNull(cloud_agent_worktrees.deletion_started_at), + isNull(cloud_agent_worktrees.deletion_completed_at) + ); + const metadataJoin = eq( + cloud_agent_worktrees.worktree_id, + cli_sessions_v2.cloud_agent_worktree_id + ); + + const [firstSessions, prRows] = await Promise.all([ + db + .selectDistinctOn([cli_sessions_v2.cloud_agent_worktree_id], { + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + title: cli_sessions_v2.title, + name: cloud_agent_worktrees.name, + }) + .from(cli_sessions_v2) + .leftJoin(cloud_agent_worktrees, metadataJoin) + .where(scopeCondition) + .orderBy( + asc(cli_sessions_v2.cloud_agent_worktree_id), + asc(cli_sessions_v2.created_at), + asc(cli_sessions_v2.session_id) + ), + db + .selectDistinctOn([cli_sessions_v2.cloud_agent_worktree_id], commonSessionFieldsWithPr) + .from(cli_sessions_v2) + .leftJoin(cloud_agent_worktrees, metadataJoin) + .leftJoin(github_branch_pull_requests, sessionPrJoinPredicate) + .where( + and( + scopeCondition, + or( + sql`COALESCE(${cli_sessions_v2.pr_url}, '') <> ''`, + and( + isNotNull(github_branch_pull_requests.pr_url), + isNotNull(github_branch_pull_requests.pr_number), + isNotNull(github_branch_pull_requests.pr_state), + isNotNull(github_branch_pull_requests.pr_last_synced_at) + ) + ) + ) + ) + .orderBy( + asc(cli_sessions_v2.cloud_agent_worktree_id), + desc(cli_sessions_v2.updated_at), + asc(cli_sessions_v2.session_id) + ), + ]); + + const prSessions = new Map>(); + for (const row of prRows) { + const session = projectAssociatedPr(row); + if (session.cloud_agent_worktree_id === null || session.associatedPr === null) { + continue; + } + prSessions.set(session.cloud_agent_worktree_id, { + ...session, + created_at: new Date(session.created_at).toISOString(), + updated_at: new Date(session.updated_at).toISOString(), + status_updated_at: session.status_updated_at + ? new Date(session.status_updated_at).toISOString() + : null, + associatedPr: { + ...session.associatedPr, + lastSyncedAt: new Date(session.associatedPr.lastSyncedAt).toISOString(), + }, + }); + } + + for (const session of firstSessions) { + if (session.worktreeId === null) { + continue; + } + worktrees[session.worktreeId] = { + name: session.name, + defaultTitle: + session.title?.trim() && !isDefaultSessionTitle(session.title) ? session.title : null, + prSession: prSessions.get(session.worktreeId) ?? null, + }; + } + return { worktrees }; + }), + + renameWorktree: baseProcedure + .input(WorktreeInputSchema.extend({ name: z.string().trim().min(1).max(200) })) + .mutation(async ({ ctx, input }) => + db.transaction(async tx => { + const membershipCondition = worktreeMembershipCondition(ctx.user.id, input.organizationId); + const [session] = await tx + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.cloud_agent_worktree_id, input.worktreeId), + eq(cli_sessions_v2.kilo_user_id, ctx.user.id), + input.organizationId === null + ? isNull(cli_sessions_v2.organization_id) + : eq(cli_sessions_v2.organization_id, input.organizationId), + membershipCondition + ) + ) + .limit(1); + + if (session) { + await tx + .insert(cloud_agent_worktrees) + .values({ + worktree_id: input.worktreeId, + kilo_user_id: ctx.user.id, + organization_id: input.organizationId, + }) + .onConflictDoNothing({ target: cloud_agent_worktrees.worktree_id }); + } + + const scopeCondition = and( + eq(cloud_agent_worktrees.worktree_id, input.worktreeId), + eq(cloud_agent_worktrees.kilo_user_id, ctx.user.id), + input.organizationId === null + ? isNull(cloud_agent_worktrees.organization_id) + : eq(cloud_agent_worktrees.organization_id, input.organizationId), + membershipCondition + ); + const [worktree] = await tx + .select({ + deletionStartedAt: cloud_agent_worktrees.deletion_started_at, + deletionCompletedAt: cloud_agent_worktrees.deletion_completed_at, + }) + .from(cloud_agent_worktrees) + .where(scopeCondition) + .for('update'); + + if (!worktree) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Worktree not found' }); + } + if (worktree.deletionStartedAt !== null || worktree.deletionCompletedAt !== null) { + throw new TRPCError({ code: 'CONFLICT', message: 'Worktree is being deleted' }); + } + + const [updated] = await tx + .update(cloud_agent_worktrees) + .set({ name: input.name, updated_at: sql`now()` }) + .where(scopeCondition) + .returning({ worktreeId: cloud_agent_worktrees.worktree_id }); + if (!updated) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Worktree not found' }); + } + return { name: input.name }; + }) + ), + + deleteWorktree: baseProcedure + .input(WorktreeInputSchema) + .output(z.object({ success: z.literal(true), deletedSessionIds: z.array(z.string()) })) + .mutation(async ({ ctx, input }) => { + const client = createCloudAgentNextClient(generateCloudAgentToken(ctx.user)); + try { + return await client.deleteWorktree({ + worktreeId: input.worktreeId, + ...(input.organizationId === null + ? {} + : { kilocodeOrganizationId: input.organizationId }), + }); + } catch (error) { + if (error instanceof TRPCClientError) { + const code: unknown = error.data?.code ?? error.shape?.data?.code; + if (typeof code === 'string' && Object.hasOwn(TRPC_ERROR_CODES_BY_KEY, code)) { + throw new TRPCError({ + code: code as keyof typeof TRPC_ERROR_CODES_BY_KEY, + message: error.message, + cause: error, + }); + } + } + throw error; + } + }), + /** * List sessions for the current user with cursor-based pagination. */ @@ -726,6 +1030,7 @@ export const cliSessionsV2Router = createTRPCRouter({ sharedOnly, createdOnPlatform, organizationId, + worktreeId, gitUrl, updatedSince, version, @@ -737,6 +1042,9 @@ export const cliSessionsV2Router = createTRPCRouter({ const whereConditions: SQL[] = [eq(cli_sessions_v2.kilo_user_id, ctx.user.id)]; await addOrganizationCondition(whereConditions, ctx, organizationId); + if (worktreeId) { + whereConditions.push(eq(cli_sessions_v2.cloud_agent_worktree_id, worktreeId)); + } addCreatedOnPlatformConditions(whereConditions, createdOnPlatform); addGitUrlConditions(whereConditions, gitUrl); addHideUningestedPlaceholderCondition(whereConditions); @@ -813,6 +1121,7 @@ export const cliSessionsV2Router = createTRPCRouter({ orderBy, createdOnPlatform, organizationId, + worktreeId, includeChildren, sharedOnly, gitUrl, @@ -826,6 +1135,9 @@ export const cliSessionsV2Router = createTRPCRouter({ const whereConditions: SQL[] = [eq(cli_sessions_v2.kilo_user_id, ctx.user.id)]; await addOrganizationCondition(whereConditions, ctx, organizationId); + if (worktreeId) { + whereConditions.push(eq(cli_sessions_v2.cloud_agent_worktree_id, worktreeId)); + } addCreatedOnPlatformConditions(whereConditions, createdOnPlatform); addGitUrlConditions(whereConditions, gitUrl); addHideUningestedPlaceholderCondition(whereConditions); @@ -856,6 +1168,15 @@ export const cliSessionsV2Router = createTRPCRouter({ sql`position(${needle} in lower(COALESCE(${cli_sessions_v2.git_url}, ''))) > 0`, sql`position(${needle} in lower(COALESCE(${cli_sessions_v2.git_branch}, ''))) > 0`, sql`position(${needle} in lower(COALESCE(${github_branch_pull_requests.pr_title}, ''))) > 0`, + sql`EXISTS ( + SELECT 1 FROM ${cloud_agent_worktrees} + WHERE ${cloud_agent_worktrees.worktree_id} = ${cli_sessions_v2.cloud_agent_worktree_id} + AND ${cloud_agent_worktrees.kilo_user_id} = ${cli_sessions_v2.kilo_user_id} + AND ${cloud_agent_worktrees.organization_id} IS NOT DISTINCT FROM ${cli_sessions_v2.organization_id} + AND ${cloud_agent_worktrees.deletion_started_at} IS NULL + AND ${cloud_agent_worktrees.deletion_completed_at} IS NULL + AND position(${needle} in lower(COALESCE(${cloud_agent_worktrees.name}, ''))) > 0 + )`, ]; // `#1234` and a bare `1234` name one pull request; they are not a // substring search over every stored number. An equality test on the @@ -1005,11 +1326,16 @@ export const cliSessionsV2Router = createTRPCRouter({ getSessionMessages: baseProcedure .input(z.object({ session_id: sessionIdField })) .query(async ({ ctx, input }) => { - await getSessionWithAccessCheck(input.session_id, ctx); + const session = await getSessionWithAccessCheck(input.session_id, ctx); try { const snapshot = await fetchSessionSnapshot(input.session_id, ctx.user.id); - return snapshot ?? { info: {}, messages: [] }; + if (!snapshot) { + return { info: {}, messages: [] }; + } + return session.cloud_agent_worktree_id + ? projectGroupedSessionTranscript(snapshot, input.session_id) + : snapshot; } catch (error) { console.error( `Failed to fetch messages for session ${input.session_id}:`, @@ -1092,7 +1418,12 @@ export const cliSessionsV2Router = createTRPCRouter({ }); } - return { ...result, watermarkEventId }; + return { + ...(session.cloud_agent_worktree_id + ? projectGroupedSessionTranscript(result, input.session_id) + : result), + watermarkEventId, + }; }), /** @@ -1113,6 +1444,7 @@ export const cliSessionsV2Router = createTRPCRouter({ session_id: z.string(), title: z.string().nullable(), cloud_agent_session_id: z.string().nullable(), + cloud_agent_worktree_id: cloudAgentWorktreeIdSchema.nullable(), organization_id: z.string().nullable(), git_url: z.string().nullable(), git_branch: z.string().nullable(), @@ -1228,6 +1560,9 @@ export const cliSessionsV2Router = createTRPCRouter({ session_id: session.session_id, title: session.title, cloud_agent_session_id: session.cloud_agent_session_id, + cloud_agent_worktree_id: session.cloud_agent_worktree_id + ? cloudAgentWorktreeIdSchema.parse(session.cloud_agent_worktree_id) + : null, organization_id: session.organization_id ?? null, git_url: session.git_url ?? null, git_branch: session.git_branch ?? null, @@ -1585,7 +1920,10 @@ export const cliSessionsV2Router = createTRPCRouter({ const authToken = generateApiToken(ctx.user); const client = createCloudAgentNextClient(authToken); try { - await client.deleteSession(session.cloud_agent_session_id); + const result = await client.deleteSession(session.cloud_agent_session_id); + if (!result.success) { + throw new Error('Cloud-agent session deletion was not confirmed'); + } } catch (err) { if (!isSessionNotFoundError(err)) { captureException(err, { diff --git a/apps/web/src/routers/cli-sessions-v2-worktree.test.ts b/apps/web/src/routers/cli-sessions-v2-worktree.test.ts new file mode 100644 index 0000000000..d9231494f9 --- /dev/null +++ b/apps/web/src/routers/cli-sessions-v2-worktree.test.ts @@ -0,0 +1,1288 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from '@jest/globals'; +import { db } from '@/lib/drizzle'; +import { insertTestUser } from '@/tests/helpers/user.helper'; +import { + cli_sessions_v2, + cloud_agent_worktrees, + github_branch_pull_requests, + organization_memberships, + organizations, + type CliSessionV2, + type NewCliSessionV2, + type User, +} from '@kilocode/db/schema'; +import type { CloudAgentWorktreeId } from '@kilocode/session-ingest-contracts'; +import { TRPCClientError } from '@trpc/client'; +import type * as TrpcServerModule from '@trpc/server'; +import { and, eq, inArray, isNotNull, or } from 'drizzle-orm'; +import type { TRPCContext } from '@/lib/trpc/init'; +import type { cliSessionsV2Router } from './cli-sessions-v2-router'; +import type { + DeleteWorktreeInput, + DeleteWorktreeOutput, +} from '@/lib/cloud-agent-next/cloud-agent-client'; + +const UUID = '12345678-1234-4234-9234-123456789abc'; +const ORGANIZATION_ID = '9a283301-b75d-4375-a1ba-e319a02e18b7'; +const OTHER_ORGANIZATION_ID = '4a283301-b75d-4375-a1ba-e319a02e18b7'; +const SESSION_ID = 'ses_12345678901234567890123456'; +const WORKSPACE_ID = `workspace_${UUID}`; +const WORKTREE_ID = `worktree_${UUID}` as const; +const USER_ID = 'oauth/github|worktree-owner'; +const OTHER_USER_ID = 'oauth/github|other-worktree-owner'; +const INITIAL_TIME = '2026-08-26T00:00:00.000Z'; +const LATER_TIME = '2026-08-26T01:00:00.000Z'; +const GIT_URL = 'https://github.com/worktree-tests/repository'; +const BRANCH = 'feature/worktree'; +const PR_URL = `${GIT_URL}/pull/42`; + +const mockRuntimeDelete = jest.fn<(sessionId: string) => Promise<{ success: boolean }>>(); +const mockWorktreeDelete = jest.fn<(input: DeleteWorktreeInput) => Promise>(); +const mockGetRuntimeSession = jest.fn<(sessionId: string) => Promise>>(); +const mockDeleteSessionIngest = jest.fn<(sessionId: string, userId: string) => Promise>(); +const mockFetchSessionSnapshot = jest.fn<() => Promise>(); +const mockCaptureException = jest.fn(); +const mockGenerateCloudAgentToken = jest.fn((_user: User) => 'cloud-agent-token'); +const mockCreateCloudAgentNextClient = jest.fn((_token: string) => ({ + deleteSession: mockRuntimeDelete, + deleteWorktree: mockWorktreeDelete, + getSession: mockGetRuntimeSession, +})); + +jest.mock('@/lib/trpc/init', () => { + const { initTRPC } = jest.requireActual('@trpc/server'); + const trpc = initTRPC.context().create(); + + return { + baseProcedure: trpc.procedure, + createTRPCRouter: trpc.router, + createCallerFactory: trpc.createCallerFactory, + }; +}); + +jest.mock('@sentry/nextjs', () => ({ + captureException: mockCaptureException, +})); + +jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ + createCloudAgentNextClient: mockCreateCloudAgentNextClient, +})); + +jest.mock('@/lib/tokens', () => ({ + generateApiToken: jest.fn(() => 'cloud-agent-token'), + generateCloudAgentToken: mockGenerateCloudAgentToken, +})); + +jest.mock('@/lib/session-ingest-client', () => ({ + fetchSessionSnapshot: mockFetchSessionSnapshot, + fetchSessionMessagesPage: jest.fn(), + deleteSession: mockDeleteSessionIngest, + shareSession: jest.fn(), + unshareSession: jest.fn(), +})); + +jest.mock('@/lib/admin/admin-access-log', () => ({ + recordKiloAdminElevation: jest.fn(), + UNSCOPED_TARGET: 'unscoped', +})); + +jest.mock('@/lib/webhook-trigger-ownership', () => ({ + verifyWebhookTriggerAccess: jest.fn(), +})); + +jest.mock('@/lib/integrations/platforms/github/adapter', () => ({ + fetchPullRequestByNumber: jest.fn(), + fetchPullRequestReviewDecision: jest.fn(), + GitHubRateLimitError: class GitHubRateLimitError extends Error {}, +})); + +jest.mock('@/lib/integrations/db/platform-integrations', () => ({ + getIntegrationForOwner: jest.fn(), +})); + +jest.mock('@/lib/integrations/platforms/github/batch-review-decisions', () => ({ + triggerBatchReviewDecisionFetchIfNeeded: jest.fn(), +})); + +jest.mock('@/lib/cloud-agent/session-events', () => ({ + notifyCliSessionRenamed: jest.fn(), +})); + +jest.mock('next/server', () => ({ + after: jest.fn(), +})); + +type WorktreeCaller = ReturnType; +let createCaller: (ctx: TRPCContext) => WorktreeCaller; +let user: User; +let otherUser: User; + +const ownershipRow = { + session_id: SESSION_ID, + kilo_user_id: USER_ID, + version: 0, + title: 'Grouped worktree session', + public_id: null, + parent_session_id: null, + organization_id: null, + cloud_agent_session_id: WORKSPACE_ID, + cloud_agent_session_scope_id: null, + cloud_agent_worktree_id: WORKTREE_ID, + created_on_platform: 'cloud-agent-web', + git_url: null, + git_branch: null, + platform: null, + pr_url: null, + pr_number: null, + status: null, + status_updated_at: null, + last_activity_at: null, + total_cost_microdollars: null, + created_at: INITIAL_TIME, + updated_at: INITIAL_TIME, +} satisfies CliSessionV2; + +function makeSession(overrides: Partial = {}): NewCliSessionV2 { + return { + ...ownershipRow, + session_id: `ses_${crypto.randomUUID().replaceAll('-', '').slice(0, 26)}`, + cloud_agent_session_id: `workspace_${crypto.randomUUID()}`, + created_at: LATER_TIME, + updated_at: LATER_TIME, + ...overrides, + }; +} + +function newWorktreeId(): CloudAgentWorktreeId { + return `worktree_${crypto.randomUUID()}`; +} + +async function insertSession(overrides: Partial = {}) { + const [session] = await db.insert(cli_sessions_v2).values(makeSession(overrides)).returning(); + return session; +} + +async function readWorktree() { + const [worktree] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, WORKTREE_ID)); + return worktree; +} + +async function readSessions() { + return db + .select() + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.cloud_agent_worktree_id, WORKTREE_ID)) + .orderBy(cli_sessions_v2.session_id); +} + +beforeAll(async () => { + user = await insertTestUser({ id: USER_ID, is_admin: false }); + otherUser = await insertTestUser({ id: OTHER_USER_ID, is_admin: false }); + await db.insert(organizations).values([ + { id: ORGANIZATION_ID, name: 'Worktree organization', created_by_kilo_user_id: USER_ID }, + { + id: OTHER_ORGANIZATION_ID, + name: 'Other worktree organization', + created_by_kilo_user_id: USER_ID, + }, + ]); + const { createCallerFactory } = await import('@/lib/trpc/init'); + const { cliSessionsV2Router } = await import('./cli-sessions-v2-router'); + createCaller = createCallerFactory(cliSessionsV2Router); +}); + +beforeEach(async () => { + jest.clearAllMocks(); + const userIds = [USER_ID, OTHER_USER_ID]; + await db + .delete(cli_sessions_v2) + .where( + and( + inArray(cli_sessions_v2.kilo_user_id, userIds), + isNotNull(cli_sessions_v2.parent_session_id) + ) + ); + await db.delete(cli_sessions_v2).where(inArray(cli_sessions_v2.kilo_user_id, userIds)); + await db + .delete(cloud_agent_worktrees) + .where(inArray(cloud_agent_worktrees.kilo_user_id, userIds)); + await db + .delete(github_branch_pull_requests) + .where( + or( + inArray(github_branch_pull_requests.owned_by_user_id, userIds), + inArray(github_branch_pull_requests.owned_by_organization_id, [ + ORGANIZATION_ID, + OTHER_ORGANIZATION_ID, + ]) + ) + ); + await db + .delete(organization_memberships) + .where(inArray(organization_memberships.kilo_user_id, userIds)); + await db.insert(organization_memberships).values([ + { kilo_user_id: USER_ID, organization_id: ORGANIZATION_ID, role: 'member' }, + { kilo_user_id: USER_ID, organization_id: OTHER_ORGANIZATION_ID, role: 'member' }, + { kilo_user_id: OTHER_USER_ID, organization_id: ORGANIZATION_ID, role: 'member' }, + ]); + await db.insert(cli_sessions_v2).values(ownershipRow); + mockRuntimeDelete.mockReset().mockResolvedValue({ success: true }); + mockWorktreeDelete + .mockReset() + .mockResolvedValue({ success: true, deletedSessionIds: [SESSION_ID] }); + mockGetRuntimeSession.mockReset().mockRejectedValue(new Error('Runtime not configured')); + mockDeleteSessionIngest.mockReset().mockImplementation(async (sessionId, userId) => { + await db + .delete(cli_sessions_v2) + .where( + and(eq(cli_sessions_v2.session_id, sessionId), eq(cli_sessions_v2.kilo_user_id, userId)) + ); + }); + mockFetchSessionSnapshot.mockReset().mockResolvedValue(null); +}); + +describe('cliSessionsV2 worktree projections and session deletion', () => { + it('projects personal worktree rows without including other owners or organization scopes', async () => { + await insertSession({ kilo_user_id: OTHER_USER_ID }); + await insertSession({ organization_id: ORGANIZATION_ID }); + const result = await createCaller({ user }).list({ + organizationId: null, + worktreeId: WORKTREE_ID, + }); + + expect(result.cliSessions).toEqual([ + expect.objectContaining({ session_id: SESSION_ID, cloud_agent_worktree_id: WORKTREE_ID }), + ]); + expect(result.cliSessions[0]).not.toHaveProperty('workspacePath'); + }); + + it('retains exact organization authorization when filtering sibling lists', async () => { + const orgSession = await insertSession({ organization_id: ORGANIZATION_ID }); + await insertSession({ organization_id: OTHER_ORGANIZATION_ID }); + const result = await createCaller({ user }).list({ + organizationId: ORGANIZATION_ID, + worktreeId: WORKTREE_ID, + }); + + expect(result.cliSessions.map(session => session.session_id)).toEqual([orgSession.session_id]); + }); + + it('projects and filters the canonical worktree during search', async () => { + await insertSession({ cloud_agent_worktree_id: newWorktreeId() }); + const result = await createCaller({ user }).search({ + search_string: 'Grouped', + organizationId: null, + worktreeId: WORKTREE_ID, + }); + + expect(result.results).toEqual([ + expect.objectContaining({ session_id: SESSION_ID, cloud_agent_worktree_id: WORKTREE_ID }), + ]); + expect(result.nextCursor).toBeNull(); + }); + + it('returns the ownership worktree in runtime state without exposing the private checkout path', async () => { + mockGetRuntimeSession.mockResolvedValueOnce({ + sessionId: WORKSPACE_ID, + userId: USER_ID, + execution: null, + timestamp: 1, + version: 1, + workspacePath: '/private/shared-checkout', + }); + const result = await createCaller({ user }).getWithRuntimeState({ session_id: SESSION_ID }); + + expect(result.cloud_agent_worktree_id).toBe(WORKTREE_ID); + expect(result).not.toHaveProperty('workspacePath'); + expect(result.runtimeState).not.toHaveProperty('workspacePath'); + }); + + it('preserves the empty-history fallback for a lazily created sibling', async () => { + await expect( + createCaller({ user }).getSessionMessages({ session_id: SESSION_ID }) + ).resolves.toEqual({ + info: {}, + messages: [], + }); + expect(mockGetRuntimeSession).not.toHaveBeenCalled(); + }); + + it.each([ + ['reports success false', () => mockRuntimeDelete.mockResolvedValueOnce({ success: false })], + ['throws', () => mockRuntimeDelete.mockRejectedValueOnce(new Error('Worker unavailable'))], + ])('preserves ownership when runtime deletion %s', async (_, failDeletion) => { + failDeletion(); + await expect(createCaller({ user }).delete({ session_id: SESSION_ID })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to clean up cloud-agent session', + }); + + expect(await readSessions()).toHaveLength(1); + expect(mockDeleteSessionIngest).not.toHaveBeenCalled(); + expect(mockCaptureException).toHaveBeenCalled(); + }); + + it('removes ownership only after runtime deletion confirms success', async () => { + await expect(createCaller({ user }).delete({ session_id: SESSION_ID })).resolves.toEqual({ + success: true, + session_id: SESSION_ID, + }); + + expect(await readSessions()).toHaveLength(0); + expect(mockRuntimeDelete.mock.invocationCallOrder[0]).toBeLessThan( + mockDeleteSessionIngest.mock.invocationCallOrder[0] + ); + }); +}); + +describe('cliSessionsV2 worktreeDetails and persistent names', () => { + it('returns defaults for existing worktrees without metadata and omits unknown or foreign groups', async () => { + const foreignId = newWorktreeId(); + await insertSession({ kilo_user_id: OTHER_USER_ID, cloud_agent_worktree_id: foreignId }); + const result = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID, WORKTREE_ID, newWorktreeId(), foreignId], + organizationId: null, + }); + + expect(result).toEqual({ + worktrees: { + [WORKTREE_ID]: { name: null, defaultTitle: ownershipRow.title, prSession: null }, + }, + }); + expect(await readWorktree()).toBeUndefined(); + }); + + it('derives the first root from full history rather than recent, searched, filtered, or capped rows', async () => { + await db + .update(cli_sessions_v2) + .set({ + title: 'Original worktree purpose', + created_on_platform: 'cli', + git_url: 'https://github.com/worktree-tests/original', + created_at: '2020-01-01T00:00:00.000Z', + updated_at: '2020-01-01T00:00:00.000Z', + }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(cli_sessions_v2).values( + Array.from({ length: 205 }, () => + makeSession({ + title: 'Visible sibling', + git_url: GIT_URL, + }) + ) + ); + const caller = createCaller({ user }); + const recent = await caller.list({ + organizationId: null, + worktreeId: WORKTREE_ID, + limit: 200, + createdOnPlatform: 'cloud-agent-web', + gitUrl: GIT_URL, + updatedSince: INITIAL_TIME, + }); + const search = await caller.search({ + search_string: 'Visible', + organizationId: null, + limit: 50, + }); + const details = await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + + expect(recent.cliSessions).toHaveLength(200); + expect(recent.cliSessions.some(session => session.session_id === SESSION_ID)).toBe(false); + expect(search.results.some(session => session.session_id === SESSION_ID)).toBe(false); + expect(details.worktrees[WORKTREE_ID].defaultTitle).toBe('Original worktree purpose'); + }); + + it.each([null, '', ' \t\n', `New session - ${INITIAL_TIME}`, `Child session - ${INITIAL_TIME}`])( + 'does not use a later real title when the first title is %p', + async title => { + await db + .update(cli_sessions_v2) + .set({ title }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await insertSession({ title: 'Later real title' }); + + const details = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + + expect(details.worktrees[WORKTREE_ID]).toEqual({ + name: null, + defaultTitle: null, + prSession: null, + }); + } + ); + + it.each(['New session - implementation plan', `New session - ${INITIAL_TIME} notes`])( + 'does not suppress a real title that only resembles a CLI placeholder: %s', + async title => { + await db + .update(cli_sessions_v2) + .set({ title }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + const details = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + expect(details.worktrees[WORKTREE_ID].defaultTitle).toBe(title); + } + ); + + it('keeps a hidden first placeholder authoritative and observes its later real title without caching', async () => { + await db + .update(cli_sessions_v2) + .set({ title: null, created_on_platform: 'unknown' }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + const sibling = await insertSession({ title: 'Visible sibling' }); + const caller = createCaller({ user }); + const list = await caller.list({ organizationId: null, worktreeId: WORKTREE_ID }); + expect(list.cliSessions.map(session => session.session_id)).toEqual([sibling.session_id]); + expect( + (await caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null })) + .worktrees[WORKTREE_ID].defaultTitle + ).toBeNull(); + + await db + .update(cli_sessions_v2) + .set({ title: 'Generated first title' }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + expect( + ( + await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }) + ).worktrees[WORKTREE_ID].defaultTitle + ).toBe('Generated first title'); + }); + + it('breaks creation ties by session ID, ignores child titles, and advances only when the first root is removed', async () => { + const tied = await insertSession({ + session_id: 'ses_00000000000000000000000000', + title: 'First tied root', + created_at: INITIAL_TIME, + }); + await insertSession({ + parent_session_id: SESSION_ID, + title: 'Child must not name the worktree', + created_at: '2020-01-01T00:00:00.000Z', + }); + const caller = createCaller({ user }); + expect( + (await caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null })) + .worktrees[WORKTREE_ID].defaultTitle + ).toBe(tied.title); + + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, tied.session_id)); + expect( + (await caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null })) + .worktrees[WORKTREE_ID].defaultTitle + ).toBe(ownershipRow.title); + }); + + it('persists trimmed worktree names for OAuth owners independently of chat titles, timestamps, and later title promotion', async () => { + await insertSession({ title: 'Independent sibling' }); + const before = await readSessions(); + const caller = createCaller({ user }); + await expect( + caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: ' Custom worktree name ', + }) + ).resolves.toEqual({ name: 'Custom worktree name' }); + + const persisted = await readWorktree(); + expect(persisted).toMatchObject({ + worktree_id: WORKTREE_ID, + kilo_user_id: USER_ID, + organization_id: null, + name: 'Custom worktree name', + }); + expect(await readSessions()).toEqual(before); + expect(mockCreateCloudAgentNextClient).not.toHaveBeenCalled(); + expect(user.total_microdollars_acquired).toBe(0); + + await db + .update(cli_sessions_v2) + .set({ title: 'New automatic first title' }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + const reloaded = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + expect(reloaded.worktrees[WORKTREE_ID]).toEqual({ + name: 'Custom worktree name', + defaultTitle: 'New automatic first title', + prSession: null, + }); + + await db + .update(cloud_agent_worktrees) + .set({ updated_at: INITIAL_TIME }) + .where(eq(cloud_agent_worktrees.worktree_id, WORKTREE_ID)); + await caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: 'Second name', + }); + const renamed = await readWorktree(); + expect(renamed.created_at).toBe(persisted.created_at); + expect(new Date(renamed.updated_at).getTime()).toBeGreaterThan( + new Date(INITIAL_TIME).getTime() + ); + expect(renamed.name).toBe('Second name'); + }); + + it('safely serializes concurrent first renames without duplicating metadata or changing ownership', async () => { + const caller = createCaller({ user }); + const results = await Promise.all( + ['First name', 'Second name'].map(name => + caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name, + }) + ) + ); + + expect(results).toEqual([{ name: 'First name' }, { name: 'Second name' }]); + const worktree = await readWorktree(); + expect(['First name', 'Second name']).toContain(worktree.name); + expect(worktree).toMatchObject({ kilo_user_id: USER_ID, organization_id: null }); + expect((await readSessions())[0].title).toBe(ownershipRow.title); + }); + + it('requires an existing owned worktree and never inserts metadata for a foreign or missing group', async () => { + const missingId = newWorktreeId(); + await expect( + createCaller({ user: otherUser }).renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: 'Stolen name', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect( + createCaller({ user }).renameWorktree({ + worktreeId: missingId, + organizationId: null, + name: 'Missing name', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + expect( + await db + .select() + .from(cloud_agent_worktrees) + .where(inArray(cloud_agent_worktrees.worktree_id, [WORKTREE_ID, missingId])) + ).toEqual([]); + }); + + it.each([ + { kilo_user_id: OTHER_USER_ID, organization_id: null }, + { kilo_user_id: USER_ID, organization_id: ORGANIZATION_ID }, + ])('never claims or exposes conflicting immutable metadata: %p', async owner => { + await db + .insert(cloud_agent_worktrees) + .values({ worktree_id: WORKTREE_ID, ...owner, name: 'Private name' }); + const before = await readWorktree(); + const caller = createCaller({ user }); + + await expect( + caller.renameWorktree({ worktreeId: WORKTREE_ID, organizationId: null, name: 'Wrong name' }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect( + caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null }) + ).resolves.toEqual({ worktrees: {} }); + expect(await readWorktree()).toEqual(before); + }); + + it('enforces owner and exact personal/organization scope for worktree reads and renames', async () => { + const orgWorktreeId = newWorktreeId(); + await insertSession({ + cloud_agent_worktree_id: orgWorktreeId, + organization_id: ORGANIZATION_ID, + title: 'Organization first chat', + }); + const caller = createCaller({ user }); + await caller.renameWorktree({ + worktreeId: orgWorktreeId, + organizationId: ORGANIZATION_ID, + name: 'Organization name', + }); + + expect( + ( + await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID, orgWorktreeId], + organizationId: null, + }) + ).worktrees + ).toEqual({ [WORKTREE_ID]: { name: null, defaultTitle: ownershipRow.title, prSession: null } }); + expect( + ( + await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID, orgWorktreeId], + organizationId: ORGANIZATION_ID, + }) + ).worktrees + ).toEqual({ + [orgWorktreeId]: { + name: 'Organization name', + defaultTitle: 'Organization first chat', + prSession: null, + }, + }); + for (const organizationId of [null, OTHER_ORGANIZATION_ID]) { + await expect( + caller.renameWorktree({ worktreeId: orgWorktreeId, organizationId, name: 'Wrong scope' }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + } + await expect( + caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: ORGANIZATION_ID, + name: 'Wrong scope', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect( + createCaller({ user: otherUser }).worktreeDetails({ + worktreeIds: [orgWorktreeId], + organizationId: ORGANIZATION_ID, + }) + ).resolves.toEqual({ worktrees: {} }); + await expect( + createCaller({ user: otherUser }).renameWorktree({ + worktreeId: orgWorktreeId, + organizationId: ORGANIZATION_ID, + name: 'Other member', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('requires current organization membership even for an owner marked as a Kilo admin', async () => { + await db + .update(cli_sessions_v2) + .set({ organization_id: ORGANIZATION_ID }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await createCaller({ user }).renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: ORGANIZATION_ID, + name: 'Owned org group', + }); + await db + .delete(organization_memberships) + .where( + and( + eq(organization_memberships.kilo_user_id, USER_ID), + eq(organization_memberships.organization_id, ORGANIZATION_ID) + ) + ); + + for (const currentUser of [user, { ...user, is_admin: true }]) { + const caller = createCaller({ user: currentUser }); + await expect( + caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: ORGANIZATION_ID }) + ).resolves.toEqual({ worktrees: {} }); + await expect( + caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: ORGANIZATION_ID, + name: 'No longer authorized', + }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + } + expect((await readWorktree()).name).toBe('Owned org group'); + }); + + it.each([false, true])( + 'refuses renames and hides deleting metadata (completed=%s)', + async completed => { + await db.insert(cloud_agent_worktrees).values({ + worktree_id: WORKTREE_ID, + kilo_user_id: USER_ID, + name: completed ? null : 'Preserved pending name', + deletion_started_at: INITIAL_TIME, + deletion_completed_at: completed ? LATER_TIME : null, + }); + const before = await readWorktree(); + const caller = createCaller({ user }); + + await expect( + caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: 'Resurrected name', + }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + await expect( + caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null }) + ).resolves.toEqual({ worktrees: {} }); + expect(await readWorktree()).toEqual(before); + } + ); +}); + +describe('cliSessionsV2 worktree PR projection', () => { + it('selects an older real PR source beyond the 200-session sidebar cap', async () => { + await db + .update(cli_sessions_v2) + .set({ + pr_url: PR_URL, + pr_number: 42, + platform: 'github', + updated_at: INITIAL_TIME, + total_cost_microdollars: 123, + }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db + .insert(cli_sessions_v2) + .values(Array.from({ length: 205 }, () => makeSession({ title: 'Newer chat without PR' }))); + const caller = createCaller({ user }); + expect( + ( + await caller.list({ organizationId: null, worktreeId: WORKTREE_ID, limit: 200 }) + ).cliSessions.some(session => session.session_id === SESSION_ID) + ).toBe(false); + + const details = await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + const prSession = details.worktrees[WORKTREE_ID].prSession; + expect(prSession).toMatchObject({ + session_id: SESSION_ID, + title: ownershipRow.title, + total_cost_microdollars: 123, + associatedPr: { + url: PR_URL, + number: 42, + state: 'unknown', + title: null, + reviewDecisionPending: false, + platform: 'github', + }, + }); + expect(Object.keys(prSession ?? {}).sort()).toEqual( + [ + 'session_id', + 'title', + 'cloud_agent_session_id', + 'cloud_agent_worktree_id', + 'parent_session_id', + 'organization_id', + 'created_on_platform', + 'git_url', + 'git_branch', + 'status', + 'status_updated_at', + 'created_at', + 'updated_at', + 'version', + 'total_cost_microdollars', + 'associatedPr', + ].sort() + ); + expect(mockGetRuntimeSession).not.toHaveBeenCalled(); + }); + + it('selects the most recently updated PR-bearing root with a stable session-ID tie-break', async () => { + await db + .update(cli_sessions_v2) + .set({ pr_url: `${GIT_URL}/pull/1`, updated_at: INITIAL_TIME }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + const winner = await insertSession({ + session_id: 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', + pr_url: PR_URL, + }); + await insertSession({ + session_id: 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbb', + pr_url: `${GIT_URL}/pull/43`, + }); + await insertSession({ updated_at: '2026-08-26T02:00:00.000Z' }); + await insertSession({ + parent_session_id: SESSION_ID, + pr_url: `${GIT_URL}/pull/99`, + updated_at: '2026-08-26T03:00:00.000Z', + }); + + const details = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + expect(details.worktrees[WORKTREE_ID].prSession?.session_id).toBe(winner.session_id); + expect(details.worktrees[WORKTREE_ID].prSession?.associatedPr?.number).toBe(42); + }); + + it('uses the existing branch-cache fallback and preserves pending review flags with normalized DB timestamps', async () => { + const timestamp = '2026-04-29 01:16:12.945+00'; + const isoTimestamp = '2026-04-29T01:16:12.945Z'; + await db + .update(cli_sessions_v2) + .set({ + git_url: GIT_URL, + git_branch: BRANCH, + created_at: timestamp, + updated_at: timestamp, + status_updated_at: timestamp, + }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(github_branch_pull_requests).values({ + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_user_id: USER_ID, + pr_url: PR_URL, + pr_number: 42, + pr_state: 'open', + pr_title: 'Cached pull request', + pr_head_sha: 'abc123', + pr_review_decision: 'approved', + review_decision_pending: true, + pr_last_synced_at: timestamp, + }); + const details = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + + expect(details.worktrees[WORKTREE_ID].prSession).toMatchObject({ + session_id: SESSION_ID, + created_at: isoTimestamp, + updated_at: isoTimestamp, + status_updated_at: isoTimestamp, + associatedPr: { + url: PR_URL, + number: 42, + state: 'open', + title: 'Cached pull request', + headSha: 'abc123', + reviewDecision: 'approved', + reviewDecisionPending: true, + lastSyncedAt: isoTimestamp, + platform: 'github', + }, + }); + }); + + it('prefers a stored PR link to a different branch-cache PR instead of copying the wrong PR fields', async () => { + await db + .update(cli_sessions_v2) + .set({ git_url: GIT_URL, git_branch: BRANCH, pr_url: PR_URL, updated_at: INITIAL_TIME }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(github_branch_pull_requests).values({ + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_user_id: USER_ID, + pr_url: `${GIT_URL}/pull/99`, + pr_number: 99, + pr_state: 'closed', + pr_title: 'Wrong PR', + review_decision_pending: true, + }); + const caller = createCaller({ user }); + const details = await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + const list = await caller.list({ worktreeId: WORKTREE_ID, organizationId: null }); + + expect(details.worktrees[WORKTREE_ID].prSession?.associatedPr).toEqual( + list.cliSessions[0].associatedPr + ); + expect(details.worktrees[WORKTREE_ID].prSession?.associatedPr).toMatchObject({ + url: PR_URL, + number: 42, + state: 'unknown', + title: null, + reviewDecisionPending: false, + }); + }); + + it('uses live cache fields for the same stored PR even with URL subpaths and query strings', async () => { + await db + .update(cli_sessions_v2) + .set({ + git_url: GIT_URL, + git_branch: BRANCH, + pr_url: `${PR_URL}/files?view=all`, + updated_at: INITIAL_TIME, + }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(github_branch_pull_requests).values({ + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_user_id: USER_ID, + pr_url: PR_URL, + pr_number: 42, + pr_state: 'merged', + pr_title: 'Same linked PR', + pr_review_decision: 'approved', + }); + + const details = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId: null, + }); + expect(details.worktrees[WORKTREE_ID].prSession?.associatedPr).toMatchObject({ + url: PR_URL, + state: 'merged', + title: 'Same linked PR', + reviewDecision: 'approved', + }); + }); + + it('correlates branch-cache PRs with the exact personal or organization tenant', async () => { + const orgWorktreeId = newWorktreeId(); + await db + .update(cli_sessions_v2) + .set({ git_url: GIT_URL, git_branch: BRANCH, updated_at: INITIAL_TIME }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + const orgSession = await insertSession({ + organization_id: ORGANIZATION_ID, + cloud_agent_worktree_id: orgWorktreeId, + git_url: GIT_URL, + git_branch: BRANCH, + }); + await db.insert(github_branch_pull_requests).values([ + { + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_user_id: OTHER_USER_ID, + pr_url: `${GIT_URL}/pull/99`, + pr_number: 99, + pr_state: 'open', + }, + { + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_organization_id: ORGANIZATION_ID, + pr_url: PR_URL, + pr_number: 42, + pr_state: 'open', + }, + ]); + const caller = createCaller({ user }); + expect( + (await caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null })) + .worktrees[WORKTREE_ID].prSession + ).toBeNull(); + const orgDetails = await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID, orgWorktreeId], + organizationId: ORGANIZATION_ID, + }); + expect(Object.keys(orgDetails.worktrees)).toEqual([orgWorktreeId]); + expect(orgDetails.worktrees[orgWorktreeId].prSession).toMatchObject({ + session_id: orgSession.session_id, + associatedPr: { number: 42 }, + }); + }); +}); + +describe('cliSessionsV2 worktree name search', () => { + it('matches persistent names case-insensitively and treats %, underscore, and backslash literally', async () => { + const sibling = await insertSession({ title: 'Independent sibling' }); + const otherWorktreeId = newWorktreeId(); + await insertSession({ cloud_agent_worktree_id: otherWorktreeId }); + const caller = createCaller({ user }); + await caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: 'Release 100%_Ready\\now', + }); + await caller.renameWorktree({ + worktreeId: otherWorktreeId, + organizationId: null, + name: 'Release 100XXReadyXnow', + }); + + const result = await caller.search({ search_string: '%_READY\\', organizationId: null }); + expect(result.results.map(session => session.session_id).sort()).toEqual( + [SESSION_ID, sibling.session_id].sort() + ); + expect(result.results.every(session => session.cloud_agent_worktree_id === WORKTREE_ID)).toBe( + true + ); + await caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: 'Renamed release', + }); + expect( + (await caller.search({ search_string: '%_READY\\', organizationId: null })).results + ).toEqual([]); + expect( + (await caller.search({ search_string: 'RENAMED RELEASE', organizationId: null })).results + ).toHaveLength(2); + }); + + it.each([ + { kilo_user_id: OTHER_USER_ID, organization_id: null }, + { kilo_user_id: USER_ID, organization_id: ORGANIZATION_ID }, + ])('does not match a worktree name from another owner or scope: %p', async owner => { + await db + .insert(cloud_agent_worktrees) + .values({ worktree_id: WORKTREE_ID, ...owner, name: 'Private worktree name' }); + expect( + ( + await createCaller({ user }).search({ + search_string: 'Private worktree name', + organizationId: null, + }) + ).results + ).toEqual([]); + }); + + it('keeps exact organization and worktree filters for names and hides names after membership removal', async () => { + const orgWorktreeId = newWorktreeId(); + const orgSession = await insertSession({ + cloud_agent_worktree_id: orgWorktreeId, + organization_id: ORGANIZATION_ID, + }); + const caller = createCaller({ user }); + await caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: 'Shared search name', + }); + await caller.renameWorktree({ + worktreeId: orgWorktreeId, + organizationId: ORGANIZATION_ID, + name: 'Shared search name', + }); + + expect( + ( + await caller.search({ search_string: 'Shared search name', organizationId: null }) + ).results.map(session => session.session_id) + ).toEqual([SESSION_ID]); + expect( + ( + await caller.search({ + search_string: 'Shared search name', + organizationId: ORGANIZATION_ID, + worktreeId: orgWorktreeId, + }) + ).results.map(session => session.session_id) + ).toEqual([orgSession.session_id]); + await db + .delete(organization_memberships) + .where( + and( + eq(organization_memberships.kilo_user_id, USER_ID), + eq(organization_memberships.organization_id, ORGANIZATION_ID) + ) + ); + expect( + (await caller.search({ search_string: 'Shared search name' })).results.map( + session => session.session_id + ) + ).toEqual([SESSION_ID]); + }); + + it('does not use a deleting worktree name for matching', async () => { + await db.insert(cloud_agent_worktrees).values({ + worktree_id: WORKTREE_ID, + kilo_user_id: USER_ID, + name: 'Deleting name', + deletion_started_at: INITIAL_TIME, + }); + expect( + ( + await createCaller({ user }).search({ + search_string: 'Deleting name', + organizationId: null, + }) + ).results + ).toEqual([]); + }); +}); + +describe('cliSessionsV2 worktree API validation', () => { + it.each(['worktree_invalid', 'worktree_../../private', `workspace_${UUID}`])( + 'rejects noncanonical worktree IDs across filters and operations: %s', + async value => { + const worktreeId = value as CloudAgentWorktreeId; + const caller = createCaller({ user }); + await expect(caller.list({ worktreeId })).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect(caller.search({ search_string: 'Grouped', worktreeId })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect( + caller.worktreeDetails({ worktreeIds: [worktreeId], organizationId: null }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.renameWorktree({ worktreeId, organizationId: null, name: 'Valid name' }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.deleteWorktree({ worktreeId, organizationId: null }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(await readWorktree()).toBeUndefined(); + expect(mockWorktreeDelete).not.toHaveBeenCalled(); + } + ); + + it.each(['', ' \n\t', 'x'.repeat(201)])('rejects an invalid worktree name: %p', async name => { + await expect( + createCaller({ user }).renameWorktree({ worktreeId: WORKTREE_ID, organizationId: null, name }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(await readWorktree()).toBeUndefined(); + }); + + it('validates name length after trimming', async () => { + await expect( + createCaller({ user }).renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId: null, + name: ` ${'x'.repeat(200)} `, + }) + ).resolves.toEqual({ name: 'x'.repeat(200) }); + }); + + it('bounds the batch before deduplication and permits empty requests', async () => { + const caller = createCaller({ user }); + await expect( + caller.worktreeDetails({ + worktreeIds: Array.from({ length: 201 }, () => WORKTREE_ID), + organizationId: null, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.worktreeDetails({ worktreeIds: [], organizationId: null }) + ).resolves.toEqual({ worktrees: {} }); + expect( + Object.keys( + ( + await caller.worktreeDetails({ + worktreeIds: Array.from({ length: 200 }, () => WORKTREE_ID), + organizationId: null, + }) + ).worktrees + ) + ).toEqual([WORKTREE_ID]); + }); + + it.each([undefined, 'not-an-organization-id'])( + 'requires an explicit valid organization scope: %p', + async organizationId => { + const caller = createCaller({ user }); + await expect( + caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId } as Parameters< + WorktreeCaller['worktreeDetails'] + >[0]) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId, + name: 'Valid name', + } as Parameters[0]) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + await expect( + caller.deleteWorktree({ worktreeId: WORKTREE_ID, organizationId } as Parameters< + WorktreeCaller['deleteWorktree'] + >[0]) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockWorktreeDelete).not.toHaveBeenCalled(); + } + ); +}); + +describe('cliSessionsV2 deleteWorktree adapter', () => { + it.each([null, ORGANIZATION_ID])( + 'forwards the authenticated exact scope without per-session deletion: %p', + async organizationId => { + const deletedSessionIds = Array.from({ length: 205 }, (_, index) => `ses_deleted_${index}`); + mockWorktreeDelete.mockResolvedValueOnce({ success: true, deletedSessionIds }); + const caller = createCaller({ + user, + headersList: new Headers({ 'x-client-source': 'browser' }), + }); + + await expect( + caller.deleteWorktree({ worktreeId: WORKTREE_ID, organizationId }) + ).resolves.toEqual({ success: true, deletedSessionIds }); + expect(mockGenerateCloudAgentToken).toHaveBeenCalledWith(user); + expect(mockCreateCloudAgentNextClient).toHaveBeenCalledWith('cloud-agent-token'); + expect(mockWorktreeDelete).toHaveBeenCalledTimes(1); + expect(mockWorktreeDelete).toHaveBeenCalledWith({ + worktreeId: WORKTREE_ID, + ...(organizationId === null ? {} : { kilocodeOrganizationId: organizationId }), + }); + expect(mockRuntimeDelete).not.toHaveBeenCalled(); + expect(mockDeleteSessionIngest).not.toHaveBeenCalled(); + expect(await readSessions()).toHaveLength(1); + } + ); + + it.each([ + ['CONFLICT', 409, 'worktree_deletion_pending'], + ['SERVICE_UNAVAILABLE', 503, 'worktree_cleanup_failed'], + ['NOT_FOUND', 404, 'Worktree not found'], + ['FORBIDDEN', 403, 'Worktree access denied'], + ])( + 'preserves Worker %s failures rather than returning success', + async (code, httpStatus, message) => { + const error = new TRPCClientError(message, { + result: { error: { code: -32000, message, data: { code, httpStatus } } }, + }); + mockWorktreeDelete.mockRejectedValueOnce(error); + const before = await readSessions(); + + await expect( + createCaller({ user }).deleteWorktree({ worktreeId: WORKTREE_ID, organizationId: null }) + ).rejects.toMatchObject({ code, message }); + expect(await readSessions()).toEqual(before); + expect(mockDeleteSessionIngest).not.toHaveBeenCalled(); + expect(mockRuntimeDelete).not.toHaveBeenCalled(); + } + ); + + it('rejects transport failures and unconfirmed Worker output', async () => { + const caller = createCaller({ user }); + mockWorktreeDelete.mockRejectedValueOnce(new Error('Worker unavailable')); + await expect( + caller.deleteWorktree({ worktreeId: WORKTREE_ID, organizationId: null }) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + mockWorktreeDelete.mockResolvedValueOnce({ + success: false, + deletedSessionIds: [], + } as unknown as DeleteWorktreeOutput); + await expect( + caller.deleteWorktree({ worktreeId: WORKTREE_ID, organizationId: null }) + ).rejects.toMatchObject({ code: 'INTERNAL_SERVER_ERROR' }); + expect(await readSessions()).toHaveLength(1); + }); + + it.each([null, ORGANIZATION_ID])( + 'allows an authenticated same-owner retry after sessions are gone and only a tombstone remains: %p', + async organizationId => { + await db.delete(cli_sessions_v2).where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(cloud_agent_worktrees).values({ + worktree_id: WORKTREE_ID, + kilo_user_id: USER_ID, + organization_id: organizationId, + deletion_started_at: INITIAL_TIME, + deletion_completed_at: LATER_TIME, + deleted_session_ids: [SESSION_ID], + }); + const before = await readWorktree(); + const caller = createCaller({ user }); + await expect( + caller.deleteWorktree({ worktreeId: WORKTREE_ID, organizationId }) + ).resolves.toEqual({ success: true, deletedSessionIds: [SESSION_ID] }); + await expect( + createCaller({ user }).deleteWorktree({ worktreeId: WORKTREE_ID, organizationId }) + ).resolves.toEqual({ success: true, deletedSessionIds: [SESSION_ID] }); + expect(mockWorktreeDelete).toHaveBeenCalledTimes(2); + expect(await readWorktree()).toEqual(before); + expect(await readSessions()).toEqual([]); + } + ); +}); diff --git a/apps/web/src/routers/cloud-agent-next-router.test.ts b/apps/web/src/routers/cloud-agent-next-router.test.ts index cece37ede7..e1802d4520 100644 --- a/apps/web/src/routers/cloud-agent-next-router.test.ts +++ b/apps/web/src/routers/cloud-agent-next-router.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest, beforeAll, beforeEach } from '@jest/globals'; -import { createCallerFactory } from '@/lib/trpc/init'; import type { User } from '@kilocode/db/schema'; +import type { createWorktreeChat as CreateWorktreeChat } from '@/lib/cloud-agent-next/worktree-chat'; +import type * as MinimumVersionModule from '@/lib/trpc/min-version'; import type { z } from 'zod'; import type { personalPrepareSessionNextSchema } from '@/routers/cloud-agent-next-schemas'; @@ -53,6 +54,7 @@ const mockGenerateCloudAgentAttachmentDownloadUrl = jest.fn< >(() => Promise.resolve({ signedUrl: 'signed', key: 'key', expiresAt: 'expires' })); const mockGetSession = jest.fn<(cloudAgentSessionId: string) => Promise<{ model?: string }>>(); +const mockCreateWorktreeChat = jest.fn(); const mockCancelQueuedMessage = jest.fn<(input: { sessionId: string; messageId: string }) => Promise<{ dropped: boolean }>>(); @@ -125,6 +127,16 @@ jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ rethrowAsPaymentRequired: jest.fn(), })); +jest.mock('@/lib/cloud-agent-next/worktree-chat', () => ({ + createWorktreeChat: mockCreateWorktreeChat, +})); + +jest.mock('@/lib/trpc/min-version', () => ({ + ...jest.requireActual('@/lib/trpc/min-version'), + getMinimumVersions: jest.fn(async () => ({ ios: '0.0.0', android: '0.0.0' })), + enforceMinimumVersion: jest.fn(() => ({ pass: true })), +})); + jest.mock('@/lib/cloud-agent-next/balance-check-eligibility', () => ({ computeCloudAgentNextBalanceCheckEligibility: mockComputeCloudAgentNextBalanceCheckEligibility, })); @@ -161,11 +173,17 @@ jest.mock('@/lib/cloud-agent/session-ownership', () => ({ verifyUserOwnsSessionV2ByCloudAgentId: mockVerifyUserOwnsSessionV2ByCloudAgentId, })); -let createCaller: (ctx: { user: User }) => { +let createCaller: (ctx: { user: User; headersList?: Headers }) => { prepareSession: (input: z.infer) => Promise<{ cloudAgentSessionId: string; kiloSessionId: string; }>; + createWorktreeChat: (input: { sourceKiloSessionId: string; operationKey: string }) => Promise<{ + kiloSessionId: string; + cloudAgentSessionId: string; + worktreeId: string; + replayed?: boolean; + }>; sendMessage: (input: { cloudAgentSessionId: string; payload: @@ -193,6 +211,7 @@ let createCaller: (ctx: { user: User }) => { }; beforeAll(async () => { + const { createCallerFactory } = await import('@/lib/trpc/init'); const mod = await import('./cloud-agent-next-router'); createCaller = createCallerFactory(mod.cloudAgentNextRouter); }); @@ -552,6 +571,49 @@ describe('cloudAgentNextRouter.prepareSession', () => { }); }); + it('derives browser provenance when the authenticated request is not mobile', async () => { + const caller = createCaller({ + user: { id: 'user-browser', is_admin: false } as User, + headersList: new Headers({ 'x-kilo-client': 'web' }), + }); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'kilo/test-model', + githubRepo: 'acme/repo', + autoInitiate: true, + }); + + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ createdOnPlatform: 'cloud-agent-web', clientProvenance: 'browser' }) + ); + }); + + it('derives mobile provenance from authenticated headers even when public input attempts to forge browser provenance', async () => { + const caller = createCaller({ + user: { id: 'user-mobile', is_admin: false } as User, + headersList: new Headers({ + 'x-kilo-client': 'mobile', + 'x-kilo-app-platform': 'ios', + 'x-kilo-app-version': '1.0.0', + }), + }); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'kilo/test-model', + githubRepo: 'acme/repo', + autoInitiate: true, + clientProvenance: 'browser', + } as z.infer); + + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ createdOnPlatform: 'cloud-agent-web', clientProvenance: 'mobile' }) + ); + }); + it('rejects devcontainer sessions when the feature flag is disabled', async () => { mockIsFeatureFlagEnabledOrDevelopment.mockResolvedValue(false); const caller = createCaller({ @@ -748,3 +810,47 @@ describe('cloudAgentNextRouter.prepareSession', () => { ); }); }); + +describe('cloudAgentNextRouter.createWorktreeChat', () => { + const uuid = '12345678-1234-4234-9234-123456789abc'; + const sourceKiloSessionId = 'ses_12345678901234567890123456'; + const result = { + kiloSessionId: 'ses_abcdefghijklmnopqrstuvwxyz', + cloudAgentSessionId: `workspace_${uuid}` as const, + worktreeId: `worktree_${uuid}` as const, + replayed: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockCreateWorktreeChat.mockResolvedValue(result); + }); + + it('forwards only the authenticated owner and canonical sibling action to the shared operation', async () => { + const user = { id: 'oauth/github|owner', is_admin: false } as User; + const headersList = new Headers({ 'x-kilo-client': 'web' }); + const caller = createCaller({ user, headersList }); + + await expect( + caller.createWorktreeChat({ sourceKiloSessionId, operationKey: uuid }) + ).resolves.toEqual(result); + + expect(mockCreateWorktreeChat).toHaveBeenCalledWith({ + user, + headersList, + sourceKiloSessionId, + operationKey: uuid, + }); + }); + + it.each([ + { sourceKiloSessionId: 'ses_invalid', operationKey: uuid }, + { sourceKiloSessionId, operationKey: 'invalid-uuid' }, + { sourceKiloSessionId, operationKey: uuid, clientProvenance: 'browser' }, + ])('rejects malformed or untrusted public input %j', async input => { + const caller = createCaller({ user: { id: 'owner', is_admin: false } as User }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(mockCreateWorktreeChat).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/routers/cloud-agent-next-router.ts b/apps/web/src/routers/cloud-agent-next-router.ts index a3e7ea328d..bb2f2274da 100644 --- a/apps/web/src/routers/cloud-agent-next-router.ts +++ b/apps/web/src/routers/cloud-agent-next-router.ts @@ -7,6 +7,7 @@ import { } from '@/lib/cloud-agent-next/cloud-agent-client'; import { computeCloudAgentNextBalanceCheckEligibility } from '@/lib/cloud-agent-next/balance-check-eligibility'; import { rethrowAsTerminalError } from '@/lib/cloud-agent-next/terminal-errors'; +import { createWorktreeChat } from '@/lib/cloud-agent-next/worktree-chat'; import { generateCloudAgentToken } from '@/lib/tokens'; import { isFeatureFlagEnabledOrDevelopment } from '@/lib/posthog-feature-flags'; import { fetchGitHubRepositoriesForUser } from '@/lib/cloud-agent/github-integration-helpers'; @@ -19,6 +20,8 @@ import { orderRepositoriesByUsage } from '@/lib/cloud-agent/order-repositories'; import { personalPrepareSessionNextSchema, basePrepareSessionNextOutputSchema, + baseCreateWorktreeChatNextSchema, + baseCreateWorktreeChatNextOutputSchema, baseInitiateFromPreparedSessionNextSchema, baseInitiateSessionNextOutputSchema, baseSendMessageNextSchema, @@ -57,6 +60,7 @@ import { verifyUserOwnsSessionV2ByCloudAgentId } from '@/lib/cloud-agent/session import { TRPCError } from '@trpc/server'; import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; import { getBalanceForUser } from '@/lib/user/balance'; +import { isMobileClient } from '@/lib/trpc/min-version'; import { buildCloudAgentNextEligibility } from './cloud-agent-next-eligibility'; function buildTerminalUrl(params: { @@ -178,6 +182,7 @@ export const cloudAgentNextRouter = createTRPCRouter({ ...gitParams, attachments: attachments ?? images, createdOnPlatform: 'cloud-agent-web', + clientProvenance: isMobileClient(ctx.headersList) ? 'mobile' : 'browser', }); // New-session flows (mobile, continue-cloud-create) call prepareSession @@ -200,6 +205,18 @@ export const cloudAgentNextRouter = createTRPCRouter({ } }), + createWorktreeChat: baseProcedure + .input(baseCreateWorktreeChatNextSchema) + .output(baseCreateWorktreeChatNextOutputSchema) + .mutation(({ ctx, input }) => + createWorktreeChat({ + user: ctx.user, + headersList: ctx.headersList, + sourceKiloSessionId: input.sourceKiloSessionId, + operationKey: input.operationKey, + }) + ), + /** * Initiate a prepared session (V2 - WebSocket-based). * diff --git a/apps/web/src/routers/cloud-agent-next-schemas.test.ts b/apps/web/src/routers/cloud-agent-next-schemas.test.ts index 59e76a1a68..05765aefa2 100644 --- a/apps/web/src/routers/cloud-agent-next-schemas.test.ts +++ b/apps/web/src/routers/cloud-agent-next-schemas.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from '@jest/globals'; import { + baseCreateWorktreeChatNextOutputSchema, + baseCreateWorktreeChatNextSchema, basePrepareSessionNextSchema, baseCancelQueuedMessageNextSchema, cloudAgentGetAttachmentDownloadUrlSchema, @@ -264,6 +266,65 @@ describe('basePrepareSessionNextSchema cloneFromKiloSessionId union', () => { }); expect(result.success).toBe(true); }); + + it('does not accept client provenance from public session preparation input', () => { + const result = basePrepareSessionNextSchema.parse({ + githubRepo: 'acme/repo', + prompt: 'Create a session', + mode: 'code', + model: 'kilo/test-model', + clientProvenance: 'browser', + }); + + expect(result).not.toHaveProperty('clientProvenance'); + }); +}); + +describe('createWorktreeChat schemas', () => { + const operationKey = '12345678-1234-4234-9234-123456789abc'; + const workspaceId = `workspace_${operationKey}`; + const worktreeId = `worktree_${operationKey}`; + + it('accepts only a canonical source session and operation UUID', () => { + expect( + baseCreateWorktreeChatNextSchema.parse({ + sourceKiloSessionId: KILO_SESSION_ID, + operationKey, + }) + ).toEqual({ sourceKiloSessionId: KILO_SESSION_ID, operationKey }); + + for (const input of [ + { sourceKiloSessionId: 'agent_not_a_kilo_session', operationKey }, + { sourceKiloSessionId: KILO_SESSION_ID, operationKey: 'not-a-uuid' }, + { sourceKiloSessionId: KILO_SESSION_ID, operationKey, clientProvenance: 'browser' }, + { + sourceKiloSessionId: KILO_SESSION_ID, + operationKey, + sourceCloudAgentSessionId: workspaceId, + }, + ]) { + expect(baseCreateWorktreeChatNextSchema.safeParse(input).success).toBe(false); + } + }); + + it('requires canonical workspace/worktree output and rejects private runtime paths', () => { + const output = { + kiloSessionId: KILO_SESSION_ID, + cloudAgentSessionId: workspaceId, + worktreeId, + replayed: true, + }; + + expect(baseCreateWorktreeChatNextOutputSchema.parse(output)).toEqual(output); + + for (const invalidOutput of [ + { ...output, cloudAgentSessionId: `agent_${operationKey}` }, + { ...output, worktreeId: 'worktree_../../private' }, + { ...output, workspacePath: '/private/shared-checkout' }, + ]) { + expect(baseCreateWorktreeChatNextOutputSchema.safeParse(invalidOutput).success).toBe(false); + } + }); }); describe('baseCancelQueuedMessageNextSchema', () => { diff --git a/apps/web/src/routers/cloud-agent-next-schemas.ts b/apps/web/src/routers/cloud-agent-next-schemas.ts index a1ec679fb9..2368658584 100644 --- a/apps/web/src/routers/cloud-agent-next-schemas.ts +++ b/apps/web/src/routers/cloud-agent-next-schemas.ts @@ -1,4 +1,8 @@ import * as z from 'zod'; +import { + cloudAgentWorktreeIdSchema, + sessionIdSchema as kiloSessionIdSchema, +} from '@kilocode/session-ingest-contracts'; import { CLOUD_AGENT_ATTACHMENT_ALLOWED_TYPES, CLOUD_AGENT_ATTACHMENT_DENIED_EXTENSIONS, @@ -477,6 +481,22 @@ export const basePrepareSessionNextOutputSchema = z.object({ replayed: z.boolean().optional(), }); +export const baseCreateWorktreeChatNextSchema = z + .object({ + sourceKiloSessionId: kiloSessionIdSchema, + operationKey: z.uuid(), + }) + .strict(); + +export const baseCreateWorktreeChatNextOutputSchema = z + .object({ + kiloSessionId: kiloSessionIdSchema, + cloudAgentSessionId: z.templateLiteral(['workspace_', z.uuid()]), + worktreeId: cloudAgentWorktreeIdSchema, + replayed: z.boolean().optional(), + }) + .strict(); + // Schema for initiating from a prepared session export const baseInitiateFromPreparedSessionNextSchema = z .object({ diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts index d0f13f8c3d..95e4a9835b 100644 --- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, jest, beforeAll, beforeEach } from '@jest/globals'; -import { createCallerFactory } from '@/lib/trpc/init'; import type * as TrpcInitModule from '@/lib/trpc/init'; +import type { createWorktreeChat as CreateWorktreeChat } from '@/lib/cloud-agent-next/worktree-chat'; +import type * as MinimumVersionModule from '@/lib/trpc/min-version'; import type * as ZodModule from 'zod'; import type { z } from 'zod'; import type { User } from '@kilocode/db/schema'; @@ -57,6 +58,7 @@ const mockGenerateCloudAgentAttachmentUploadUrl = jest.fn< >(() => Promise.resolve({ signedUrl: 'signed', key: 'key', expiresAt: 'expires' })); const mockGetSession = jest.fn<(cloudAgentSessionId: string) => Promise<{ model?: string }>>(); +const mockCreateWorktreeChat = jest.fn(); const mockCancelQueuedMessage = jest.fn<(input: { sessionId: string; messageId: string }) => Promise<{ dropped: boolean }>>(); @@ -140,6 +142,16 @@ jest.mock('@/lib/cloud-agent-next/cloud-agent-client', () => ({ rethrowAsPaymentRequired: jest.fn(), })); +jest.mock('@/lib/cloud-agent-next/worktree-chat', () => ({ + createWorktreeChat: mockCreateWorktreeChat, +})); + +jest.mock('@/lib/trpc/min-version', () => ({ + ...jest.requireActual('@/lib/trpc/min-version'), + getMinimumVersions: jest.fn(async () => ({ ios: '0.0.0', android: '0.0.0' })), + enforceMinimumVersion: jest.fn(() => ({ pass: true })), +})); + jest.mock('@/lib/cloud-agent-next/balance-check-eligibility', () => ({ computeCloudAgentNextBalanceCheckEligibility: mockComputeCloudAgentNextBalanceCheckEligibility, })); @@ -199,13 +211,23 @@ jest.mock('@/routers/organizations/utils', () => { }; }); -let createCaller: (ctx: { user: User }) => { +let createCaller: (ctx: { user: User; headersList?: Headers }) => { prepareSession: ( input: z.infer & { organizationId: string } ) => Promise<{ cloudAgentSessionId: string; kiloSessionId: string; }>; + createWorktreeChat: (input: { + organizationId: string; + sourceKiloSessionId: string; + operationKey: string; + }) => Promise<{ + kiloSessionId: string; + cloudAgentSessionId: string; + worktreeId: string; + replayed?: boolean; + }>; sendMessage: (input: { organizationId: string; cloudAgentSessionId: string; @@ -269,6 +291,7 @@ let createCaller: (ctx: { user: User }) => { }; beforeAll(async () => { + const { createCallerFactory } = await import('@/lib/trpc/init'); const mod = await import('./organization-cloud-agent-next-router'); createCaller = createCallerFactory(mod.organizationCloudAgentNextRouter); }); @@ -728,6 +751,59 @@ describe('organizationCloudAgentNextRouter.prepareSession', () => { }); }); + it('derives browser provenance from the authenticated organization request', async () => { + const caller = createCaller({ + user: { id: 'organization-browser', is_admin: false } as User, + headersList: new Headers({ 'x-kilo-client': 'web' }), + }); + + await caller.prepareSession({ + organizationId: ORGANIZATION_ID, + prompt: 'Test prompt', + mode: 'code', + model: 'kilo/test-model', + githubRepo: 'acme/repo', + autoInitiate: true, + }); + + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ + kilocodeOrganizationId: ORGANIZATION_ID, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }) + ); + }); + + it('derives mobile provenance even when organization input attempts to forge browser provenance', async () => { + const caller = createCaller({ + user: { id: 'organization-mobile', is_admin: false } as User, + headersList: new Headers({ + 'x-kilo-client': 'mobile', + 'x-kilo-app-platform': 'android', + 'x-kilo-app-version': '1.0.0', + }), + }); + + await caller.prepareSession({ + organizationId: ORGANIZATION_ID, + prompt: 'Test prompt', + mode: 'code', + model: 'kilo/test-model', + githubRepo: 'acme/repo', + autoInitiate: true, + clientProvenance: 'browser', + } as z.infer & { organizationId: string }); + + expect(mockPrepareSession).toHaveBeenCalledWith( + expect.objectContaining({ + kilocodeOrganizationId: ORGANIZATION_ID, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'mobile', + }) + ); + }); + it('rejects devcontainer sessions when the feature flag is disabled', async () => { mockIsFeatureFlagEnabledOrDevelopment.mockResolvedValue(false); const caller = createCaller({ @@ -937,6 +1013,83 @@ describe('organizationCloudAgentNextRouter.prepareSession', () => { }); }); +describe('organizationCloudAgentNextRouter.createWorktreeChat', () => { + const uuid = '12345678-1234-4234-9234-123456789abc'; + const sourceKiloSessionId = 'ses_12345678901234567890123456'; + const result = { + kiloSessionId: 'ses_abcdefghijklmnopqrstuvwxyz', + cloudAgentSessionId: `workspace_${uuid}` as const, + worktreeId: `worktree_${uuid}` as const, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockEnsureOrganizationAccess.mockImplementation(() => undefined); + mockCreateWorktreeChat.mockResolvedValue(result); + }); + + it('requires current membership and forwards the exact organization and authenticated owner', async () => { + const user = { id: 'organization-owner', is_admin: false } as User; + const headersList = new Headers({ 'x-kilo-client': 'web' }); + const caller = createCaller({ user, headersList }); + + await expect( + caller.createWorktreeChat({ + organizationId: ORGANIZATION_ID, + sourceKiloSessionId, + operationKey: uuid, + }) + ).resolves.toEqual(result); + + expect(mockEnsureOrganizationAccess).toHaveBeenCalledWith(user.id, ORGANIZATION_ID); + expect(mockCreateWorktreeChat).toHaveBeenCalledWith({ + user, + headersList, + sourceKiloSessionId, + operationKey: uuid, + organizationId: ORGANIZATION_ID, + }); + }); + + it('rejects a revoked organization member before resolving the source session', async () => { + mockEnsureOrganizationAccess.mockImplementation(() => { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'You do not have access to this organization', + }); + }); + const caller = createCaller({ user: { id: 'revoked-member', is_admin: false } as User }); + + await expect( + caller.createWorktreeChat({ + organizationId: ORGANIZATION_ID, + sourceKiloSessionId, + operationKey: uuid, + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + + expect(mockCreateWorktreeChat).not.toHaveBeenCalled(); + }); + + it('rejects untrusted provenance and organization identifiers before invoking the operation', async () => { + const caller = createCaller({ user: { id: 'organization-owner', is_admin: false } as User }); + + for (const input of [ + { organizationId: 'invalid-organization', sourceKiloSessionId, operationKey: uuid }, + { + organizationId: ORGANIZATION_ID, + sourceKiloSessionId, + operationKey: uuid, + clientProvenance: 'browser', + }, + ]) { + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + } + + expect(mockCreateWorktreeChat).not.toHaveBeenCalled(); + }); +}); + describe('organizationCloudAgentNextRouter Bitbucket repository listing', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts index fb351c3e1b..3b68e46d33 100644 --- a/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts +++ b/apps/web/src/routers/organizations/organization-cloud-agent-next-router.ts @@ -7,6 +7,7 @@ import { } from '@/lib/cloud-agent-next/cloud-agent-client'; import { computeCloudAgentNextBalanceCheckEligibility } from '@/lib/cloud-agent-next/balance-check-eligibility'; import { rethrowAsTerminalError } from '@/lib/cloud-agent-next/terminal-errors'; +import { createWorktreeChat } from '@/lib/cloud-agent-next/worktree-chat'; import { generateCloudAgentToken } from '@/lib/tokens'; import { isFeatureFlagEnabledOrDevelopment } from '@/lib/posthog-feature-flags'; import { @@ -27,6 +28,8 @@ import { orderRepositoriesByUsage } from '@/lib/cloud-agent/order-repositories'; import { basePrepareSessionNextSchema, basePrepareSessionNextOutputSchema, + baseCreateWorktreeChatNextSchema, + baseCreateWorktreeChatNextOutputSchema, baseInitiateFromPreparedSessionNextSchema, baseInitiateSessionNextOutputSchema, baseSendMessageNextSchema, @@ -63,6 +66,7 @@ import { verifyOrgOwnsSessionV2ByCloudAgentId } from '@/lib/cloud-agent/session- import { TRPCError } from '@trpc/server'; import { generateMessageId } from '@kilocode/cloud-agent-sdk/message-id'; import { getBalanceForOrganizationUser } from '@/lib/organizations/organization-usage'; +import { isMobileClient } from '@/lib/trpc/min-version'; import { buildCloudAgentNextEligibility } from '../cloud-agent-next-eligibility'; function buildTerminalUrl(params: { @@ -130,6 +134,10 @@ const PrepareSessionInput = basePrepareSessionNextSchema.and( }) ); +const CreateWorktreeChatInput = baseCreateWorktreeChatNextSchema.extend({ + organizationId: z.uuid(), +}); + const InitiateFromPreparedSessionInput = baseInitiateFromPreparedSessionNextSchema.extend({ organizationId: z.uuid(), }); @@ -296,6 +304,7 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ attachments: attachments ?? images, createdOnPlatform: 'cloud-agent-web', kilocodeOrganizationId: organizationId, + clientProvenance: isMobileClient(ctx.headersList) ? 'mobile' : 'browser', }); // New-session flows call prepareSession without a follow-up @@ -318,6 +327,19 @@ export const organizationCloudAgentNextRouter = createTRPCRouter({ } }), + createWorktreeChat: organizationMemberMutationProcedure + .input(CreateWorktreeChatInput) + .output(baseCreateWorktreeChatNextOutputSchema) + .mutation(({ ctx, input }) => + createWorktreeChat({ + user: ctx.user, + headersList: ctx.headersList, + sourceKiloSessionId: input.sourceKiloSessionId, + operationKey: input.operationKey, + organizationId: input.organizationId, + }) + ), + /** * Initiate a prepared session (V2 - WebSocket-based, organization context). * diff --git a/packages/cloud-agent-sdk/src/schemas.test.ts b/packages/cloud-agent-sdk/src/schemas.test.ts index 56a35ab0c4..28af0c7bac 100644 --- a/packages/cloud-agent-sdk/src/schemas.test.ts +++ b/packages/cloud-agent-sdk/src/schemas.test.ts @@ -1,4 +1,9 @@ -import { activeSessionSchema, parseCustomerBillingFailure } from './schemas'; +import { + activeSessionSchema, + parseCustomerBillingFailure, + sessionEventPayloadSchema, + sessionEventV2RowSchema, +} from './schemas'; describe('parseCustomerBillingFailure', () => { const failure = { @@ -35,6 +40,49 @@ describe('parseCustomerBillingFailure', () => { }); }); +describe('session worktree event schemas', () => { + const session = { + source: 'v2' as const, + sessionId: 'ses_12345678901234567890123456', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:01.000Z', + title: 'Test', + createdOnPlatform: 'cloud-agent-web', + organizationId: null, + gitUrl: null, + gitBranch: null, + parentSessionId: null, + status: 'idle' as const, + statusUpdatedAt: null, + }; + + it.each([null, undefined])('accepts legacy worktree ID %s', worktreeId => { + const parsed = sessionEventV2RowSchema.parse({ + ...session, + ...(worktreeId === undefined ? {} : { worktreeId }), + }); + + expect(parsed.worktreeId).toBe(worktreeId); + }); + + it.each(['session.created', 'session.updated'] as const)( + 'retains worktree IDs in %s payloads', + type => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const event = sessionEventPayloadSchema.parse({ + type, + data: { + source: 'v2', + session: { ...session, worktreeId }, + changedAt: session.updatedAt, + }, + }); + + expect(event.data).toHaveProperty('session.worktreeId', worktreeId); + } + ); +}); + describe('activeSessionSchema capabilities', () => { it('parses a session whose `capabilities` is absent', () => { const parsed = activeSessionSchema.parse({ diff --git a/packages/cloud-agent-sdk/src/schemas.ts b/packages/cloud-agent-sdk/src/schemas.ts index c4b9ced3f1..54f5cf3807 100644 --- a/packages/cloud-agent-sdk/src/schemas.ts +++ b/packages/cloud-agent-sdk/src/schemas.ts @@ -568,6 +568,7 @@ export const sessionEventV2RowSchema = z.object({ gitUrl: z.string().nullable(), gitBranch: z.string().nullable(), parentSessionId: z.string().nullable(), + worktreeId: z.string().nullable().optional(), status: sessionStatusValueSchema.nullable(), statusUpdatedAt: z.string().nullable(), }); diff --git a/packages/cloud-agent-sdk/src/session-manager.test.ts b/packages/cloud-agent-sdk/src/session-manager.test.ts index 6cacfc1746..a528fc6b1d 100644 --- a/packages/cloud-agent-sdk/src/session-manager.test.ts +++ b/packages/cloud-agent-sdk/src/session-manager.test.ts @@ -503,6 +503,66 @@ describe('createSessionManager', () => { expect(atomValue(config.store, mgr.atoms.sessionId)).toBe('agent-1'); }); + it('preserves an optional worktree identity without replacing chat identities', async () => { + const worktreeId = 'worktree_12345678-1234-4234-8234-123456789abc'; + const config = createMockConfig({ + fetchSession: jest.fn().mockResolvedValue({ ...defaultFetchedSession, worktreeId }), + }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + + expect( + atomValue(config.store, mgr.atoms.fetchedSessionData) + ).toMatchObject({ + kiloSessionId: 'ses-1', + cloudAgentSessionId: 'agent-1', + worktreeId, + }); + expect(atomValue(config.store, mgr.atoms.sessionId)).toBe('agent-1'); + }); + + it('does not carry a worktree identity into an ungrouped session', async () => { + const config = createMockConfig({ + fetchSession: jest + .fn() + .mockResolvedValueOnce({ + ...defaultFetchedSession, + worktreeId: 'worktree_12345678-1234-4234-8234-123456789abc', + }) + .mockResolvedValueOnce(defaultFetchedSession), + }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-1')); + await mgr.switchSession(kiloId('ses-2')); + + expect( + atomValue(config.store, mgr.atoms.fetchedSessionData).worktreeId + ).toBeUndefined(); + }); + + it('opens a blank grouped sibling without preparing or initiating another turn', async () => { + const config = createMockConfig({ + fetchSession: jest.fn().mockResolvedValue({ + ...defaultFetchedSession, + kiloSessionId: kiloId('ses-sibling'), + worktreeId: 'worktree_12345678-1234-4234-8234-123456789abc', + isInitiated: false, + prompt: null, + initialMessageId: null, + }), + }); + const mgr = createSessionManager(config); + + await mgr.switchSession(kiloId('ses-sibling')); + + expect(atomValue(config.store, mgr.atoms.messagesList)).toEqual([]); + expect(atomValue(config.store, mgr.atoms.canSend)).toBe(true); + expect(config.prepare).not.toHaveBeenCalled(); + expect(config.initiate).not.toHaveBeenCalled(); + }); + it('clears error on start', async () => { const config = createMockConfig(); const mgr = createSessionManager(config); diff --git a/packages/cloud-agent-sdk/src/session-manager.ts b/packages/cloud-agent-sdk/src/session-manager.ts index d06dd170f3..b473f5fed4 100644 --- a/packages/cloud-agent-sdk/src/session-manager.ts +++ b/packages/cloud-agent-sdk/src/session-manager.ts @@ -229,6 +229,7 @@ type FetchedSessionData = { organizationId: string | null; gitUrl: string | null; gitBranch: string | null; + worktreeId?: string | null; mode: string | null; model: string | null; variant: string | null; diff --git a/packages/cloud-agent-sdk/src/user-web-connection.test.ts b/packages/cloud-agent-sdk/src/user-web-connection.test.ts index 4cd1b84ea2..f86cb4dae8 100644 --- a/packages/cloud-agent-sdk/src/user-web-connection.test.ts +++ b/packages/cloud-agent-sdk/src/user-web-connection.test.ts @@ -1318,6 +1318,7 @@ describe('createUserWebConnection', () => { gitUrl: null, gitBranch: null, parentSessionId: null, + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', status: null, statusUpdatedAt: null, }, @@ -1340,6 +1341,7 @@ describe('createUserWebConnection', () => { gitUrl: null, gitBranch: null, parentSessionId: null, + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', status: null, statusUpdatedAt: null, }, @@ -1373,6 +1375,7 @@ describe('createUserWebConnection', () => { gitUrl: null, gitBranch: null, parentSessionId: null, + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', status: 'idle', statusUpdatedAt: 'now', }, @@ -1398,8 +1401,30 @@ describe('createUserWebConnection', () => { }); expect(created).toHaveBeenCalledTimes(1); + expect(created).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', + }), + }) + ); expect(updated).toHaveBeenCalledTimes(1); + expect(updated).toHaveBeenCalledWith( + expect.objectContaining({ + session: expect.objectContaining({ + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', + }), + }) + ); expect(status).toHaveBeenCalledTimes(2); + expect(status).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + session: expect.objectContaining({ + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', + }), + }) + ); expect(deleted).toHaveBeenCalledTimes(1); client.destroy(); }); diff --git a/packages/db/src/migrations/0234_amused_sasquatch.sql b/packages/db/src/migrations/0234_amused_sasquatch.sql new file mode 100644 index 0000000000..648dbea68d --- /dev/null +++ b/packages/db/src/migrations/0234_amused_sasquatch.sql @@ -0,0 +1,31 @@ +CREATE TABLE "cloud_agent_worktrees" ( + "worktree_id" text PRIMARY KEY NOT NULL, + "kilo_user_id" text NOT NULL, + "organization_id" uuid, + "name" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "deletion_started_at" timestamp with time zone, + "deletion_completed_at" timestamp with time zone, + "runtime_locations" jsonb DEFAULT '[]'::jsonb NOT NULL, + "deletion_manifest" jsonb, + "deleted_session_ids" text[] DEFAULT '{}'::text[] NOT NULL, + CONSTRAINT "cloud_agent_worktrees_deletion_check" CHECK ("cloud_agent_worktrees"."deletion_completed_at" IS NULL OR ("cloud_agent_worktrees"."deletion_started_at" IS NOT NULL AND "cloud_agent_worktrees"."name" IS NULL AND "cloud_agent_worktrees"."deletion_manifest" IS NULL AND "cloud_agent_worktrees"."runtime_locations" = '[]'::jsonb)) +); +--> statement-breakpoint +ALTER TABLE "cli_sessions_v2" ADD COLUMN "cloud_agent_worktree_id" text;--> statement-breakpoint +ALTER TABLE "cloud_agent_worktrees" ADD CONSTRAINT "cloud_agent_worktrees_kilo_user_id_kilocode_users_id_fk" FOREIGN KEY ("kilo_user_id") REFERENCES "public"."kilocode_users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "cloud_agent_worktrees" ADD CONSTRAINT "cloud_agent_worktrees_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "IDX_cloud_agent_worktrees_owner_scope" ON "cloud_agent_worktrees" USING btree ("kilo_user_id","organization_id");--> statement-breakpoint +COMMIT;--> statement-breakpoint +CREATE INDEX CONCURRENTLY "IDX_cli_sessions_v2_user_worktree_updated" ON "cli_sessions_v2" USING btree ("kilo_user_id","cloud_agent_worktree_id","updated_at") WHERE "cli_sessions_v2"."cloud_agent_worktree_id" is not null;--> statement-breakpoint +BEGIN; +--> statement-breakpoint +INSERT INTO "cloud_agent_worktrees" ("worktree_id", "kilo_user_id", "organization_id", "created_at", "updated_at") +SELECT "cloud_agent_worktree_id", min("kilo_user_id"), (array_agg("organization_id"))[1], min("created_at"), max("updated_at") +FROM "cli_sessions_v2" +WHERE "cloud_agent_worktree_id" IS NOT NULL +GROUP BY "cloud_agent_worktree_id" +HAVING count(DISTINCT "kilo_user_id") = 1 + AND count(DISTINCT coalesce("organization_id"::text, 'personal')) = 1 +ON CONFLICT ("worktree_id") DO NOTHING; diff --git a/packages/db/src/migrations/meta/0234_snapshot.json b/packages/db/src/migrations/meta/0234_snapshot.json new file mode 100644 index 0000000000..1d0d150288 --- /dev/null +++ b/packages/db/src/migrations/meta/0234_snapshot.json @@ -0,0 +1,39945 @@ +{ + "id": "4ce43cc2-f500-4a55-97f1-2810d5c177f5", + "prevId": "e0607648-c887-430b-805e-ecbf3342f762", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_configs": { + "name": "agent_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "runtime_state": { + "name": "runtime_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "config_revision": { + "name": "config_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + } + }, + "indexes": { + "IDX_agent_configs_org_id": { + "name": "IDX_agent_configs_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_owned_by_user_id": { + "name": "IDX_agent_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_agent_type": { + "name": "IDX_agent_configs_agent_type", + "columns": [ + { + "expression": "agent_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_configs_platform": { + "name": "IDX_agent_configs_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_configs_owned_by_organization_id_organizations_id_fk": { + "name": "agent_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_configs_org_agent_platform": { + "name": "UQ_agent_configs_org_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_organization_id", + "agent_type", + "platform" + ] + }, + "UQ_agent_configs_user_agent_platform": { + "name": "UQ_agent_configs_user_agent_platform", + "nullsNotDistinct": false, + "columns": [ + "owned_by_user_id", + "agent_type", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": { + "agent_configs_owner_check": { + "name": "agent_configs_owner_check", + "value": "(\n (\"agent_configs\".\"owned_by_user_id\" IS NOT NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_configs\".\"owned_by_user_id\" IS NULL AND \"agent_configs\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "agent_configs_agent_type_check": { + "name": "agent_configs_agent_type_check", + "value": "\"agent_configs\".\"agent_type\" IN ('code_review', 'auto_triage', 'auto_fix', 'security_scan')" + }, + "agent_configs_config_revision_check": { + "name": "agent_configs_config_revision_check", + "value": "\"agent_configs\".\"config_revision\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_agents": { + "name": "agent_environment_profile_agents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_agents_profile_id": { + "name": "IDX_agent_env_profile_agents_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_agents_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_agents", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_agents_profile_slug": { + "name": "UQ_agent_env_profile_agents_profile_slug", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_commands": { + "name": "agent_environment_profile_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "command": { + "name": "command", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_commands_profile_id": { + "name": "IDX_agent_env_profile_commands_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_commands_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_commands", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_commands_profile_sequence": { + "name": "UQ_agent_env_profile_commands_profile_sequence", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "sequence" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_kilo_commands": { + "name": "agent_environment_profile_kilo_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subtask": { + "name": "subtask", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_kilo_cmds_profile_id": { + "name": "IDX_agent_env_profile_kilo_cmds_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_kilo_commands_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_kilo_commands_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_kilo_commands", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_kilo_cmds_profile_name": { + "name": "UQ_agent_env_profile_kilo_cmds_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_mcp_servers": { + "name": "agent_environment_profile_mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_mcp_servers_profile_id": { + "name": "IDX_agent_env_profile_mcp_servers_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_mcp_servers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_mcp_servers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_mcp_servers_profile_name": { + "name": "UQ_agent_env_profile_mcp_servers_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_repo_bindings": { + "name": "agent_environment_profile_repo_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profile_repo_bindings_user": { + "name": "UQ_agent_env_profile_repo_bindings_user", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profile_repo_bindings_org": { + "name": "UQ_agent_env_profile_repo_bindings_org", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_repo_bindings_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profile_repo_bindings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profile_repo_bindings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profile_repo_bindings_owner_check": { + "name": "agent_env_profile_repo_bindings_owner_check", + "value": "(\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profile_repo_bindings\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profile_repo_bindings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.agent_environment_profile_skills": { + "name": "agent_environment_profile_skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_markdown": { + "name": "raw_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_skills_profile_id": { + "name": "IDX_agent_env_profile_skills_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_skills_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_skills", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_skills_profile_name": { + "name": "UQ_agent_env_profile_skills_profile_name", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profile_vars": { + "name": "agent_environment_profile_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_agent_env_profile_vars_profile_id": { + "name": "IDX_agent_env_profile_vars_profile_id", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk": { + "name": "agent_environment_profile_vars_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "agent_environment_profile_vars", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_agent_env_profile_vars_profile_key": { + "name": "UQ_agent_env_profile_vars_profile_key", + "nullsNotDistinct": false, + "columns": [ + "profile_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_profiles": { + "name": "agent_environment_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_agent_env_profiles_org_name": { + "name": "UQ_agent_env_profiles_org_name", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_name": { + "name": "UQ_agent_env_profiles_user_name", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_org_default": { + "name": "UQ_agent_env_profiles_org_default", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_agent_env_profiles_user_default": { + "name": "UQ_agent_env_profiles_user_default", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"agent_environment_profiles\".\"is_default\" = true AND \"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_org_id": { + "name": "IDX_agent_env_profiles_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_user_id": { + "name": "IDX_agent_env_profiles_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_agent_env_profiles_created_by_user_id": { + "name": "IDX_agent_env_profiles_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_profiles_owned_by_organization_id_organizations_id_fk": { + "name": "agent_environment_profiles_owned_by_organization_id_organizations_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk": { + "name": "agent_environment_profiles_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "agent_environment_profiles", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_env_profiles_owner_check": { + "name": "agent_env_profiles_owner_check", + "value": "(\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NOT NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NULL) OR\n (\"agent_environment_profiles\".\"owned_by_user_id\" IS NULL AND \"agent_environment_profiles\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.analytics_event_outbox": { + "name": "analytics_event_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "event_uuid": { + "name": "event_uuid", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "distinct_id": { + "name": "distinct_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_analytics_event_outbox_event_uuid": { + "name": "UQ_analytics_event_outbox_event_uuid", + "columns": [ + { + "expression": "event_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_analytics_event_outbox_status_next_attempt_at": { + "name": "IDX_analytics_event_outbox_status_next_attempt_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_kind": { + "name": "api_kind", + "schema": "", + "columns": { + "api_kind_id": { + "name": "api_kind_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_api_kind": { + "name": "UQ_api_kind", + "columns": [ + { + "expression": "api_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_request_log": { + "name": "api_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vercel_request_id": { + "name": "vercel_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_api_request_log_created_at": { + "name": "idx_api_request_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_feedback": { + "name": "app_builder_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_status": { + "name": "preview_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_feedback_created_at": { + "name": "IDX_app_builder_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_kilo_user_id": { + "name": "IDX_app_builder_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_feedback_project_id": { + "name": "IDX_app_builder_feedback_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "app_builder_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "app_builder_feedback_project_id_app_builder_projects_id_fk": { + "name": "app_builder_feedback_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_feedback", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_project_sessions": { + "name": "app_builder_project_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "worker_version": { + "name": "worker_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v2'" + } + }, + "indexes": { + "IDX_app_builder_project_sessions_project_id": { + "name": "IDX_app_builder_project_sessions_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_project_sessions_project_id_app_builder_projects_id_fk": { + "name": "app_builder_project_sessions_project_id_app_builder_projects_id_fk", + "tableFrom": "app_builder_project_sessions", + "tableTo": "app_builder_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_app_builder_project_sessions_cloud_agent_session_id": { + "name": "UQ_app_builder_project_sessions_cloud_agent_session_id", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_builder_projects": { + "name": "app_builder_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "git_repo_full_name": { + "name": "git_repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_platform_integration_id": { + "name": "git_platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "migrated_at": { + "name": "migrated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_app_builder_projects_created_by_user_id": { + "name": "IDX_app_builder_projects_created_by_user_id", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_user_id": { + "name": "IDX_app_builder_projects_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_owned_by_organization_id": { + "name": "IDX_app_builder_projects_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_created_at": { + "name": "IDX_app_builder_projects_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_last_message_at": { + "name": "IDX_app_builder_projects_last_message_at", + "columns": [ + { + "expression": "last_message_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_app_builder_projects_git_repo_integration": { + "name": "IDX_app_builder_projects_git_repo_integration", + "columns": [ + { + "expression": "git_repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"app_builder_projects\".\"git_repo_full_name\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "app_builder_projects_owned_by_user_id_kilocode_users_id_fk": { + "name": "app_builder_projects_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_owned_by_organization_id_organizations_id_fk": { + "name": "app_builder_projects_owned_by_organization_id_organizations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "app_builder_projects_deployment_id_deployments_id_fk": { + "name": "app_builder_projects_deployment_id_deployments_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk": { + "name": "app_builder_projects_git_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "app_builder_projects", + "tableTo": "platform_integrations", + "columnsFrom": [ + "git_platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "app_builder_projects_owner_check": { + "name": "app_builder_projects_owner_check", + "value": "(\n (\"app_builder_projects\".\"owned_by_user_id\" IS NOT NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NULL) OR\n (\"app_builder_projects\".\"owned_by_user_id\" IS NULL AND \"app_builder_projects\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.app_min_versions": { + "name": "app_min_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ios_min_version": { + "name": "ios_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "android_min_version": { + "name": "android_min_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.app_reported_messages": { + "name": "app_reported_messages", + "schema": "", + "columns": { + "report_id": { + "name": "report_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "report_type": { + "name": "report_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "app_reported_messages_cli_session_id_cli_sessions_session_id_fk": { + "name": "app_reported_messages_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "app_reported_messages", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_fix_tickets": { + "name": "auto_fix_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "triage_ticket_id": { + "name": "triage_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'label'" + }, + "review_comment_id": { + "name": "review_comment_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "review_comment_body": { + "name": "review_comment_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "line_number": { + "name": "line_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "diff_hunk": { + "name": "diff_hunk", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_head_ref": { + "name": "pr_head_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_branch": { + "name": "pr_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_fix_tickets_repo_issue": { + "name": "UQ_auto_fix_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"trigger_source\" = 'label'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_fix_tickets_repo_review_comment": { + "name": "UQ_auto_fix_tickets_repo_review_comment", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "review_comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_fix_tickets\".\"review_comment_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_org": { + "name": "IDX_auto_fix_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_owned_by_user": { + "name": "IDX_auto_fix_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_status": { + "name": "IDX_auto_fix_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_created_at": { + "name": "IDX_auto_fix_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_triage_ticket_id": { + "name": "IDX_auto_fix_tickets_triage_ticket_id", + "columns": [ + { + "expression": "triage_ticket_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_fix_tickets_session_id": { + "name": "IDX_auto_fix_tickets_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_fix_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_fix_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_fix_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_fix_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_fix_tickets_triage_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "triage_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk": { + "name": "auto_fix_tickets_cli_session_id_cli_sessions_session_id_fk", + "tableFrom": "auto_fix_tickets", + "tableTo": "cli_sessions", + "columnsFrom": [ + "cli_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_fix_tickets_owner_check": { + "name": "auto_fix_tickets_owner_check", + "value": "(\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_fix_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_fix_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_fix_tickets_status_check": { + "name": "auto_fix_tickets_status_check", + "value": "\"auto_fix_tickets\".\"status\" IN ('pending', 'running', 'completed', 'failed', 'cancelled')" + }, + "auto_fix_tickets_classification_check": { + "name": "auto_fix_tickets_classification_check", + "value": "\"auto_fix_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'unclear')" + }, + "auto_fix_tickets_confidence_check": { + "name": "auto_fix_tickets_confidence_check", + "value": "\"auto_fix_tickets\".\"confidence\" >= 0 AND \"auto_fix_tickets\".\"confidence\" <= 1" + }, + "auto_fix_tickets_trigger_source_check": { + "name": "auto_fix_tickets_trigger_source_check", + "value": "\"auto_fix_tickets\".\"trigger_source\" IN ('label', 'review_comment')" + } + }, + "isRLSEnabled": false + }, + "public.auto_model": { + "name": "auto_model", + "schema": "", + "columns": { + "auto_model_id": { + "name": "auto_model_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_auto_model": { + "name": "UQ_auto_model", + "columns": [ + { + "expression": "auto_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auto_top_up_configs": { + "name": "auto_top_up_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_method_id": { + "name": "stripe_payment_method_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5000 + }, + "last_auto_top_up_at": { + "name": "last_auto_top_up_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_started_at": { + "name": "attempt_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "disabled_reason": { + "name": "disabled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_top_up_configs_owned_by_user_id": { + "name": "UQ_auto_top_up_configs_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_auto_top_up_configs_owned_by_organization_id": { + "name": "UQ_auto_top_up_configs_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_top_up_configs_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "auto_top_up_configs_owned_by_organization_id_organizations_id_fk": { + "name": "auto_top_up_configs_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_top_up_configs", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_top_up_configs_exactly_one_owner": { + "name": "auto_top_up_configs_exactly_one_owner", + "value": "(\"auto_top_up_configs\".\"owned_by_user_id\" IS NOT NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NULL) OR (\"auto_top_up_configs\".\"owned_by_user_id\" IS NULL AND \"auto_top_up_configs\".\"owned_by_organization_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.auto_triage_tickets": { + "name": "auto_triage_tickets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_number": { + "name": "issue_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "issue_url": { + "name": "issue_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_title": { + "name": "issue_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_body": { + "name": "issue_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "issue_author": { + "name": "issue_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_type": { + "name": "issue_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issue_labels": { + "name": "issue_labels", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "classification": { + "name": "classification", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "intent_summary": { + "name": "intent_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_files": { + "name": "related_files", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "is_duplicate": { + "name": "is_duplicate", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "duplicate_of_ticket_id": { + "name": "duplicate_of_ticket_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "similarity_score": { + "name": "similarity_score", + "type": "numeric(3, 2)", + "primaryKey": false, + "notNull": false + }, + "qdrant_point_id": { + "name": "qdrant_point_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "should_auto_fix": { + "name": "should_auto_fix", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "action_taken": { + "name": "action_taken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action_metadata": { + "name": "action_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_auto_triage_tickets_repo_issue": { + "name": "UQ_auto_triage_tickets_repo_issue", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "issue_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_org": { + "name": "IDX_auto_triage_tickets_owned_by_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owned_by_user": { + "name": "IDX_auto_triage_tickets_owned_by_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_status": { + "name": "IDX_auto_triage_tickets_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_created_at": { + "name": "IDX_auto_triage_tickets_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_qdrant_point_id": { + "name": "IDX_auto_triage_tickets_qdrant_point_id", + "columns": [ + { + "expression": "qdrant_point_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_owner_status_created": { + "name": "IDX_auto_triage_tickets_owner_status_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_user_status_created": { + "name": "IDX_auto_triage_tickets_user_status_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_auto_triage_tickets_repo_classification": { + "name": "IDX_auto_triage_tickets_repo_classification", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "classification", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auto_triage_tickets_owned_by_organization_id_organizations_id_fk": { + "name": "auto_triage_tickets_owned_by_organization_id_organizations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk": { + "name": "auto_triage_tickets_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk": { + "name": "auto_triage_tickets_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk": { + "name": "auto_triage_tickets_duplicate_of_ticket_id_auto_triage_tickets_id_fk", + "tableFrom": "auto_triage_tickets", + "tableTo": "auto_triage_tickets", + "columnsFrom": [ + "duplicate_of_ticket_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "auto_triage_tickets_owner_check": { + "name": "auto_triage_tickets_owner_check", + "value": "(\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NOT NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NULL) OR\n (\"auto_triage_tickets\".\"owned_by_user_id\" IS NULL AND \"auto_triage_tickets\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "auto_triage_tickets_issue_type_check": { + "name": "auto_triage_tickets_issue_type_check", + "value": "\"auto_triage_tickets\".\"issue_type\" IN ('issue', 'pull_request')" + }, + "auto_triage_tickets_classification_check": { + "name": "auto_triage_tickets_classification_check", + "value": "\"auto_triage_tickets\".\"classification\" IN ('bug', 'feature', 'question', 'duplicate', 'unclear')" + }, + "auto_triage_tickets_confidence_check": { + "name": "auto_triage_tickets_confidence_check", + "value": "\"auto_triage_tickets\".\"confidence\" >= 0 AND \"auto_triage_tickets\".\"confidence\" <= 1" + }, + "auto_triage_tickets_similarity_score_check": { + "name": "auto_triage_tickets_similarity_score_check", + "value": "\"auto_triage_tickets\".\"similarity_score\" >= 0 AND \"auto_triage_tickets\".\"similarity_score\" <= 1" + }, + "auto_triage_tickets_status_check": { + "name": "auto_triage_tickets_status_check", + "value": "\"auto_triage_tickets\".\"status\" IN ('pending', 'analyzing', 'actioned', 'failed', 'skipped')" + }, + "auto_triage_tickets_action_taken_check": { + "name": "auto_triage_tickets_action_taken_check", + "value": "\"auto_triage_tickets\".\"action_taken\" IN ('pr_created', 'comment_posted', 'closed_duplicate', 'needs_clarification')" + } + }, + "isRLSEnabled": false + }, + "public.bot_request_cloud_agent_sessions": { + "name": "bot_request_cloud_agent_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "bot_request_id": { + "name": "bot_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "spawn_group_id": { + "name": "spawn_group_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gitlab_project": { + "name": "gitlab_project", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_step": { + "name": "callback_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message": { + "name": "final_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "final_message_fetched_at": { + "name": "final_message_fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "final_message_error": { + "name": "final_message_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "continuation_started_at": { + "name": "continuation_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_bot_request_cas_cloud_agent_session_id": { + "name": "UQ_bot_request_cas_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id": { + "name": "IDX_bot_request_cas_bot_request_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_request_cas_bot_request_id_spawn_group_id_status": { + "name": "IDX_bot_request_cas_bot_request_id_spawn_group_id_status", + "columns": [ + { + "expression": "bot_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "spawn_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk": { + "name": "bot_request_cloud_agent_sessions_bot_request_id_bot_requests_id_fk", + "tableFrom": "bot_request_cloud_agent_sessions", + "tableTo": "bot_requests", + "columnsFrom": [ + "bot_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_requests": { + "name": "bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_thread_id": { + "name": "platform_thread_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_message_id": { + "name": "platform_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steps": { + "name": "steps", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_bot_requests_created_at": { + "name": "IDX_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_created_by": { + "name": "IDX_bot_requests_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_organization_id": { + "name": "IDX_bot_requests_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_platform_integration_id": { + "name": "IDX_bot_requests_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_bot_requests_status": { + "name": "IDX_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bot_requests_created_by_kilocode_users_id_fk": { + "name": "bot_requests_created_by_kilocode_users_id_fk", + "tableFrom": "bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_organization_id_organizations_id_fk": { + "name": "bot_requests_organization_id_organizations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.byok_api_keys": { + "name": "byok_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "management_source": { + "name": "management_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_enabled": { + "name": "is_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_byok_api_keys_organization_id": { + "name": "IDX_byok_api_keys_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_kilo_user_id": { + "name": "IDX_byok_api_keys_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_byok_api_keys_provider_id": { + "name": "IDX_byok_api_keys_provider_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "byok_api_keys_organization_id_organizations_id_fk": { + "name": "byok_api_keys_organization_id_organizations_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "byok_api_keys_kilo_user_id_kilocode_users_id_fk": { + "name": "byok_api_keys_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "byok_api_keys", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_byok_api_keys_org_provider": { + "name": "UQ_byok_api_keys_org_provider", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider_id" + ] + }, + "UQ_byok_api_keys_user_provider": { + "name": "UQ_byok_api_keys_user_provider", + "nullsNotDistinct": false, + "columns": [ + "kilo_user_id", + "provider_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "byok_api_keys_management_source_check": { + "name": "byok_api_keys_management_source_check", + "value": "\"byok_api_keys\".\"management_source\" IN ('user', 'coding_plan')" + }, + "byok_api_keys_owner_check": { + "name": "byok_api_keys_owner_check", + "value": "(\n (\"byok_api_keys\".\"kilo_user_id\" IS NOT NULL AND \"byok_api_keys\".\"organization_id\" IS NULL) OR\n (\"byok_api_keys\".\"kilo_user_id\" IS NULL AND \"byok_api_keys\".\"organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cli_sessions": { + "name": "cli_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from": { + "name": "forked_from", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_mode": { + "name": "last_mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_model": { + "name": "last_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cli_sessions_kilo_user_id": { + "name": "IDX_cli_sessions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_created_at": { + "name": "IDX_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_updated_at": { + "name": "IDX_cli_sessions_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_organization_id": { + "name": "IDX_cli_sessions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_user_updated": { + "name": "IDX_cli_sessions_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_forked_from_cli_sessions_session_id_fk": { + "name": "cli_sessions_forked_from_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "forked_from" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_parent_session_id_cli_sessions_session_id_fk": { + "name": "cli_sessions_parent_session_id_cli_sessions_session_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "parent_session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_organization_id_organizations_id_fk": { + "name": "cli_sessions_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cli_sessions_cloud_agent_session_id_unique": { + "name": "cli_sessions_cloud_agent_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "cloud_agent_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cli_sessions_v2": { + "name": "cli_sessions_v2", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_session_id": { + "name": "parent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_scope_id": { + "name": "cloud_agent_session_scope_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_worktree_id": { + "name": "cloud_agent_worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_on_platform": { + "name": "created_on_platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_updated_at": { + "name": "status_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cli_sessions_v2_parent_session_id_kilo_user_id": { + "name": "IDX_cli_sessions_v2_parent_session_id_kilo_user_id", + "columns": [ + { + "expression": "parent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_public_id": { + "name": "UQ_cli_sessions_v2_public_id", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"public_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cli_sessions_v2_cloud_agent_session_id": { + "name": "UQ_cli_sessions_v2_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cli_sessions_v2\".\"cloud_agent_session_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_organization_id": { + "name": "IDX_cli_sessions_v2_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_user_updated": { + "name": "IDX_cli_sessions_v2_user_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_user_created": { + "name": "IDX_cli_sessions_v2_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + }, + "IDX_cli_sessions_v2_user_worktree_updated": { + "name": "IDX_cli_sessions_v2_user_worktree_updated", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cloud_agent_worktree_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cli_sessions_v2\".\"cloud_agent_worktree_id\" is not null", + "concurrently": true, + "method": "btree", + "with": {} + }, + "cli_sessions_v2_git_url_branch_idx": { + "name": "cli_sessions_v2_git_url_branch_idx", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk": { + "name": "cli_sessions_v2_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cli_sessions_v2_organization_id_organizations_id_fk": { + "name": "cli_sessions_v2_organization_id_organizations_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "cli_sessions_v2_parent_session_id_kilo_user_id_fk": { + "name": "cli_sessions_v2_parent_session_id_kilo_user_id_fk", + "tableFrom": "cli_sessions_v2", + "tableTo": "cli_sessions_v2", + "columnsFrom": [ + "parent_session_id", + "kilo_user_id" + ], + "columnsTo": [ + "session_id", + "kilo_user_id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cli_sessions_v2_session_id_kilo_user_id_pk": { + "name": "cli_sessions_v2_session_id_kilo_user_id_pk", + "columns": [ + "session_id", + "kilo_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_code_review_attempts": { + "name": "cloud_agent_code_review_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code_review_id": { + "name": "code_review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retry_of_attempt_id": { + "name": "retry_of_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "retry_reason": { + "name": "retry_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analytics_enabled_at_dispatch": { + "name": "analytics_enabled_at_dispatch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_reason": { + "name": "terminal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_code_review_attempts_review_attempt_number": { + "name": "UQ_cloud_agent_code_review_attempts_review_attempt_number", + "columns": [ + { + "expression": "code_review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_code_review_id": { + "name": "idx_cloud_agent_code_review_attempts_code_review_id", + "columns": [ + { + "expression": "code_review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_session_id": { + "name": "idx_cloud_agent_code_review_attempts_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_cli_session_id": { + "name": "idx_cloud_agent_code_review_attempts_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_status": { + "name": "idx_cloud_agent_code_review_attempts_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_review_attempts_retry_reason": { + "name": "idx_cloud_agent_code_review_attempts_retry_reason", + "columns": [ + { + "expression": "retry_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_code_review_attempts_code_review_id_cloud_agent_code_reviews_id_fk": { + "name": "cloud_agent_code_review_attempts_code_review_id_cloud_agent_code_reviews_id_fk", + "tableFrom": "cloud_agent_code_review_attempts", + "tableTo": "cloud_agent_code_reviews", + "columnsFrom": [ + "code_review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_review_attempts_retry_of_attempt_id_cloud_agent_code_review_attempts_id_fk": { + "name": "cloud_agent_code_review_attempts_retry_of_attempt_id_cloud_agent_code_review_attempts_id_fk", + "tableFrom": "cloud_agent_code_review_attempts", + "tableTo": "cloud_agent_code_review_attempts", + "columnsFrom": [ + "retry_of_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_code_review_attempts_attempt_number_check": { + "name": "cloud_agent_code_review_attempts_attempt_number_check", + "value": "\"cloud_agent_code_review_attempts\".\"attempt_number\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_code_reviews": { + "name": "cloud_agent_code_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "manual_config": { + "name": "manual_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "review_type": { + "name": "review_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "trigger_source": { + "name": "trigger_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "council_result": { + "name": "council_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author": { + "name": "pr_author", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_author_github_id": { + "name": "pr_author_github_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_ref": { + "name": "base_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_ref": { + "name": "head_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "platform_project_id": { + "name": "platform_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "dispatch_reservation_id": { + "name": "dispatch_reservation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "terminal_reason": { + "name": "terminal_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_version": { + "name": "agent_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'v1'" + }, + "check_run_id": { + "name": "check_run_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "repository_review_instructions_used": { + "name": "repository_review_instructions_used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "repository_review_instructions_ref": { + "name": "repository_review_instructions_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository_review_instructions_truncated": { + "name": "repository_review_instructions_truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "previous_summary_body": { + "name": "previous_summary_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_summary_head_sha": { + "name": "previous_summary_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_tokens_in": { + "name": "total_tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_tokens_out": { + "name": "total_tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cost_musd": { + "name": "total_cost_musd", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_code_reviews_webhook_integration_repo_pr_sha": { + "name": "UQ_cloud_agent_code_reviews_webhook_integration_repo_pr_sha", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "head_sha", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_code_reviews\".\"manual_config\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_code_reviews_active_provider_publisher": { + "name": "UQ_cloud_agent_code_reviews_active_provider_publisher", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_code_reviews\".\"platform_integration_id\" IS NOT NULL\n AND \"cloud_agent_code_reviews\".\"status\" IN ('pending', 'queued', 'running')\n AND (\"cloud_agent_code_reviews\".\"manual_config\" IS NULL OR \"cloud_agent_code_reviews\".\"manual_config\"->>'outputMode' = 'provider')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_org_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_owned_by_user_id": { + "name": "idx_cloud_agent_code_reviews_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_session_id": { + "name": "idx_cloud_agent_code_reviews_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_cli_session_id": { + "name": "idx_cloud_agent_code_reviews_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_status": { + "name": "idx_cloud_agent_code_reviews_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_repo": { + "name": "idx_cloud_agent_code_reviews_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_number": { + "name": "idx_cloud_agent_code_reviews_pr_number", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pr_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_created_at": { + "name": "idx_cloud_agent_code_reviews_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_cloud_agent_code_reviews_pr_author_github_id": { + "name": "idx_cloud_agent_code_reviews_pr_author_github_id", + "columns": [ + { + "expression": "pr_author_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_code_reviews_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk": { + "name": "cloud_agent_code_reviews_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "cloud_agent_code_reviews", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_code_reviews_owner_check": { + "name": "cloud_agent_code_reviews_owner_check", + "value": "(\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NOT NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NULL) OR\n (\"cloud_agent_code_reviews\".\"owned_by_user_id\" IS NULL AND \"cloud_agent_code_reviews\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_feedback": { + "name": "cloud_agent_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_type": { + "name": "session_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repository": { + "name": "repository", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_streaming": { + "name": "is_streaming", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recent_messages": { + "name": "recent_messages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_cloud_agent_feedback_created_at": { + "name": "IDX_cloud_agent_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_kilo_user_id": { + "name": "IDX_cloud_agent_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_feedback_cloud_agent_session_id": { + "name": "IDX_cloud_agent_feedback_cloud_agent_session_id", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "cloud_agent_feedback_organization_id_organizations_id_fk": { + "name": "cloud_agent_feedback_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_feedback", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cloud_agent_pending_uploads": { + "name": "cloud_agent_pending_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_uuid": { + "name": "message_uuid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attachment_id": { + "name": "attachment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_cloud_agent_pending_uploads_user_message_status": { + "name": "IDX_cloud_agent_pending_uploads_user_message_status", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_uuid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_pending_uploads_expired": { + "name": "IDX_cloud_agent_pending_uploads_expired", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_pending_uploads\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cloud_agent_pending_uploads_object_key_unique": { + "name": "cloud_agent_pending_uploads_object_key_unique", + "nullsNotDistinct": false, + "columns": [ + "object_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "cloud_agent_pending_uploads_status_check": { + "name": "cloud_agent_pending_uploads_status_check", + "value": "\"cloud_agent_pending_uploads\".\"status\" IN ('pending', 'linked', 'reaped')" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_session_runs": { + "name": "cloud_agent_session_runs", + "schema": "", + "columns": { + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "wrapper_run_id": { + "name": "wrapper_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dispatch_accepted_at": { + "name": "dispatch_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "agent_activity_observed_at": { + "name": "agent_activity_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_responsibility": { + "name": "failure_responsibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message_redacted": { + "name": "error_message_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_expires_at": { + "name": "error_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_cloud_agent_session_runs_wrapper_run_id": { + "name": "IDX_cloud_agent_session_runs_wrapper_run_id", + "columns": [ + { + "expression": "wrapper_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_session_runs\".\"wrapper_run_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_session_queued": { + "name": "IDX_cloud_agent_session_runs_session_queued", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_queued_at": { + "name": "IDX_cloud_agent_session_runs_queued_at", + "columns": [ + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_terminal_at": { + "name": "IDX_cloud_agent_session_runs_terminal_at", + "columns": [ + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_status_terminal": { + "name": "IDX_cloud_agent_session_runs_status_terminal", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_failure_terminal": { + "name": "IDX_cloud_agent_session_runs_failure_terminal", + "columns": [ + { + "expression": "failure_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_responsibility_reason_terminal": { + "name": "IDX_cloud_agent_session_runs_responsibility_reason_terminal", + "columns": [ + { + "expression": "failure_responsibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_session_runs\".\"status\" = 'failed'", + "concurrently": true, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_session_runs_error_expires_at": { + "name": "IDX_cloud_agent_session_runs_error_expires_at", + "columns": [ + { + "expression": "error_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_session_runs\".\"error_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_session_runs_cloud_agent_session_id_cloud_agent_sessions_cloud_agent_session_id_fk": { + "name": "cloud_agent_session_runs_cloud_agent_session_id_cloud_agent_sessions_cloud_agent_session_id_fk", + "tableFrom": "cloud_agent_session_runs", + "tableTo": "cloud_agent_sessions", + "columnsFrom": [ + "cloud_agent_session_id" + ], + "columnsTo": [ + "cloud_agent_session_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cloud_agent_session_runs_cloud_agent_session_id_message_id_pk": { + "name": "cloud_agent_session_runs_cloud_agent_session_id_message_id_pk", + "columns": [ + "cloud_agent_session_id", + "message_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_session_runs_status_check": { + "name": "cloud_agent_session_runs_status_check", + "value": "\"cloud_agent_session_runs\".\"status\" IN ('queued', 'accepted', 'completed', 'failed', 'interrupted')" + }, + "cloud_agent_session_runs_error_message_bounded_check": { + "name": "cloud_agent_session_runs_error_message_bounded_check", + "value": "\"cloud_agent_session_runs\".\"error_message_redacted\" IS NULL OR char_length(\"cloud_agent_session_runs\".\"error_message_redacted\") <= 4096" + }, + "cloud_agent_session_runs_error_expiry_check": { + "name": "cloud_agent_session_runs_error_expiry_check", + "value": "(\"cloud_agent_session_runs\".\"error_message_redacted\" IS NULL AND \"cloud_agent_session_runs\".\"error_expires_at\" IS NULL) OR\n (\"cloud_agent_session_runs\".\"error_message_redacted\" IS NOT NULL AND \"cloud_agent_session_runs\".\"error_expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_sessions": { + "name": "cloud_agent_sessions", + "schema": "", + "columns": { + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "initial_message_id": { + "name": "initial_message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "failure_at": { + "name": "failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_stage": { + "name": "failure_stage", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_responsibility": { + "name": "failure_responsibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message_redacted": { + "name": "error_message_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_expires_at": { + "name": "error_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_cloud_agent_sessions_kilo_session_id": { + "name": "UQ_cloud_agent_sessions_kilo_session_id", + "columns": [ + { + "expression": "kilo_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_sessions_initial_message_id": { + "name": "UQ_cloud_agent_sessions_initial_message_id", + "columns": [ + { + "expression": "initial_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_sandbox_id": { + "name": "IDX_cloud_agent_sessions_sandbox_id", + "columns": [ + { + "expression": "sandbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"sandbox_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_created_at": { + "name": "IDX_cloud_agent_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_failure_created": { + "name": "IDX_cloud_agent_sessions_failure_created", + "columns": [ + { + "expression": "failure_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_failure_at": { + "name": "IDX_cloud_agent_sessions_failure_at", + "columns": [ + { + "expression": "failure_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"failure_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_failure_classification_at": { + "name": "IDX_cloud_agent_sessions_failure_classification_at", + "columns": [ + { + "expression": "failure_stage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "failure_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"failure_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_sessions_error_expires_at": { + "name": "IDX_cloud_agent_sessions_error_expires_at", + "columns": [ + { + "expression": "error_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cloud_agent_sessions\".\"error_expires_at\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_sessions_failure_classification_check": { + "name": "cloud_agent_sessions_failure_classification_check", + "value": "(\"cloud_agent_sessions\".\"failure_at\" IS NULL AND \"cloud_agent_sessions\".\"failure_stage\" IS NULL AND \"cloud_agent_sessions\".\"failure_code\" IS NULL) OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'sandbox_identity' AND \"cloud_agent_sessions\".\"failure_code\" = 'sandbox_id_derivation_failed') OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'registration' AND \"cloud_agent_sessions\".\"failure_code\" = 'do_registration_rejected') OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'initial_admission' AND \"cloud_agent_sessions\".\"failure_code\" IN ('initial_admission_rejected', 'initial_queue_full', 'invalid_initial_intent')) OR\n (\"cloud_agent_sessions\".\"failure_at\" IS NOT NULL AND \"cloud_agent_sessions\".\"failure_stage\" = 'transport' AND \"cloud_agent_sessions\".\"failure_code\" = 'do_rpc_outcome_unknown')" + }, + "cloud_agent_sessions_error_message_bounded_check": { + "name": "cloud_agent_sessions_error_message_bounded_check", + "value": "\"cloud_agent_sessions\".\"error_message_redacted\" IS NULL OR char_length(\"cloud_agent_sessions\".\"error_message_redacted\") <= 4096" + }, + "cloud_agent_sessions_error_expiry_check": { + "name": "cloud_agent_sessions_error_expiry_check", + "value": "(\"cloud_agent_sessions\".\"error_message_redacted\" IS NULL AND \"cloud_agent_sessions\".\"error_expires_at\" IS NULL) OR\n (\"cloud_agent_sessions\".\"error_message_redacted\" IS NOT NULL AND \"cloud_agent_sessions\".\"error_expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_webhook_triggers": { + "name": "cloud_agent_webhook_triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "trigger_id": { + "name": "trigger_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'cloud_agent'" + }, + "kiloclaw_instance_id": { + "name": "kiloclaw_instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "activation_mode": { + "name": "activation_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'webhook'" + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_timezone": { + "name": "cron_timezone", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'UTC'" + }, + "github_repo": { + "name": "github_repo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "profile_id": { + "name": "profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_cloud_agent_webhook_triggers_user_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_user_trigger", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_cloud_agent_webhook_triggers_org_trigger": { + "name": "UQ_cloud_agent_webhook_triggers_org_trigger", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "trigger_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"cloud_agent_webhook_triggers\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_user": { + "name": "IDX_cloud_agent_webhook_triggers_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_org": { + "name": "IDX_cloud_agent_webhook_triggers_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_active": { + "name": "IDX_cloud_agent_webhook_triggers_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_cloud_agent_webhook_triggers_profile": { + "name": "IDX_cloud_agent_webhook_triggers_profile", + "columns": [ + { + "expression": "profile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_webhook_triggers_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_organization_id_organizations_id_fk": { + "name": "cloud_agent_webhook_triggers_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk": { + "name": "cloud_agent_webhook_triggers_kiloclaw_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "kiloclaw_instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk": { + "name": "cloud_agent_webhook_triggers_profile_id_agent_environment_profiles_id_fk", + "tableFrom": "cloud_agent_webhook_triggers", + "tableTo": "agent_environment_profiles", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_cloud_agent_webhook_triggers_owner": { + "name": "CHK_cloud_agent_webhook_triggers_owner", + "value": "(\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NULL) OR\n (\"cloud_agent_webhook_triggers\".\"user_id\" IS NULL AND \"cloud_agent_webhook_triggers\".\"organization_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_cloud_agent_fields": { + "name": "CHK_cloud_agent_webhook_triggers_cloud_agent_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'cloud_agent' OR\n (\"cloud_agent_webhook_triggers\".\"github_repo\" IS NOT NULL AND \"cloud_agent_webhook_triggers\".\"profile_id\" IS NOT NULL)\n )" + }, + "CHK_cloud_agent_webhook_triggers_kiloclaw_fields": { + "name": "CHK_cloud_agent_webhook_triggers_kiloclaw_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"target_type\" != 'kiloclaw_chat' OR\n \"cloud_agent_webhook_triggers\".\"kiloclaw_instance_id\" IS NOT NULL\n )" + }, + "CHK_cloud_agent_webhook_triggers_scheduled_fields": { + "name": "CHK_cloud_agent_webhook_triggers_scheduled_fields", + "value": "(\n \"cloud_agent_webhook_triggers\".\"activation_mode\" != 'scheduled' OR\n \"cloud_agent_webhook_triggers\".\"cron_expression\" IS NOT NULL\n )" + } + }, + "isRLSEnabled": false + }, + "public.cloud_agent_worktrees": { + "name": "cloud_agent_worktrees", + "schema": "", + "columns": { + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deletion_started_at": { + "name": "deletion_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deletion_completed_at": { + "name": "deletion_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "runtime_locations": { + "name": "runtime_locations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "deletion_manifest": { + "name": "deletion_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_session_ids": { + "name": "deleted_session_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + } + }, + "indexes": { + "IDX_cloud_agent_worktrees_owner_scope": { + "name": "IDX_cloud_agent_worktrees_owner_scope", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cloud_agent_worktrees_kilo_user_id_kilocode_users_id_fk": { + "name": "cloud_agent_worktrees_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_agent_worktrees", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "cloud_agent_worktrees_organization_id_organizations_id_fk": { + "name": "cloud_agent_worktrees_organization_id_organizations_id_fk", + "tableFrom": "cloud_agent_worktrees", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_agent_worktrees_deletion_check": { + "name": "cloud_agent_worktrees_deletion_check", + "value": "\"cloud_agent_worktrees\".\"deletion_completed_at\" IS NULL OR (\"cloud_agent_worktrees\".\"deletion_started_at\" IS NOT NULL AND \"cloud_agent_worktrees\".\"name\" IS NULL AND \"cloud_agent_worktrees\".\"deletion_manifest\" IS NULL AND \"cloud_agent_worktrees\".\"runtime_locations\" = '[]'::jsonb)" + } + }, + "isRLSEnabled": false + }, + "public.cloud_billing_sku": { + "name": "cloud_billing_sku", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rate_cents_per_unit": { + "name": "rate_cents_per_unit", + "type": "numeric(24, 12)", + "primaryKey": false, + "notNull": true + }, + "accepts_new_usage": { + "name": "accepts_new_usage", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cloud_billing_sku_created_by_user_id_kilocode_users_id_fk": { + "name": "cloud_billing_sku_created_by_user_id_kilocode_users_id_fk", + "tableFrom": "cloud_billing_sku", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "cloud_billing_sku_id_format": { + "name": "cloud_billing_sku_id_format", + "value": "\"cloud_billing_sku\".\"id\" ~ '^[a-z0-9][a-z0-9-]{2,79}$'" + }, + "cloud_billing_sku_name_nonempty": { + "name": "cloud_billing_sku_name_nonempty", + "value": "length(btrim(\"cloud_billing_sku\".\"name\")) > 0" + }, + "cloud_billing_sku_rate_positive": { + "name": "cloud_billing_sku_rate_positive", + "value": "\"cloud_billing_sku\".\"rate_cents_per_unit\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.code_indexing_manifest": { + "name": "code_indexing_manifest", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_lines": { + "name": "total_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_ai_lines": { + "name": "total_ai_lines", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_manifest_organization_id": { + "name": "IDX_code_indexing_manifest_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_kilo_user_id": { + "name": "IDX_code_indexing_manifest_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_project_id": { + "name": "IDX_code_indexing_manifest_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_git_branch": { + "name": "IDX_code_indexing_manifest_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_manifest_created_at": { + "name": "IDX_code_indexing_manifest_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_manifest_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_manifest", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_indexing_manifest_org_user_project_hash_branch": { + "name": "UQ_code_indexing_manifest_org_user_project_hash_branch", + "nullsNotDistinct": true, + "columns": [ + "organization_id", + "kilo_user_id", + "project_id", + "file_path", + "git_branch" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.code_indexing_search": { + "name": "code_indexing_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "query": { + "name": "query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_code_indexing_search_organization_id": { + "name": "IDX_code_indexing_search_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_kilo_user_id": { + "name": "IDX_code_indexing_search_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_project_id": { + "name": "IDX_code_indexing_search_project_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_code_indexing_search_created_at": { + "name": "IDX_code_indexing_search_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_indexing_search_kilo_user_id_kilocode_users_id_fk": { + "name": "code_indexing_search_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "code_indexing_search", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.code_review_analytics_findings": { + "name": "code_review_analytics_findings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "analytics_result_id": { + "name": "analytics_result_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ordinal": { + "name": "ordinal", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "security_class": { + "name": "security_class", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "code_review_analytics_findings_analytics_result_id_code_review_analytics_results_id_fk": { + "name": "code_review_analytics_findings_analytics_result_id_code_review_analytics_results_id_fk", + "tableFrom": "code_review_analytics_findings", + "tableTo": "code_review_analytics_results", + "columnsFrom": [ + "analytics_result_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_review_analytics_findings_result_ordinal": { + "name": "UQ_code_review_analytics_findings_result_ordinal", + "nullsNotDistinct": false, + "columns": [ + "analytics_result_id", + "ordinal" + ] + } + }, + "policies": {}, + "checkConstraints": { + "code_review_analytics_findings_severity_check": { + "name": "code_review_analytics_findings_severity_check", + "value": "\"code_review_analytics_findings\".\"severity\" IN ('critical', 'warning', 'suggestion')" + }, + "code_review_analytics_findings_category_check": { + "name": "code_review_analytics_findings_category_check", + "value": "\"code_review_analytics_findings\".\"category\" IN ('security', 'correctness', 'reliability', 'data_integrity', 'performance', 'compatibility', 'maintainability', 'test_quality', 'documentation', 'accessibility', 'other')" + }, + "code_review_analytics_findings_security_class_check": { + "name": "code_review_analytics_findings_security_class_check", + "value": "\"code_review_analytics_findings\".\"security_class\" IN ('auth_access', 'injection', 'data_protection', 'request_resource_boundary', 'deserialization_object_integrity', 'dependency_supply_chain', 'memory_safety', 'availability', 'concurrency', 'security_configuration', 'other')" + }, + "code_review_analytics_findings_ordinal_check": { + "name": "code_review_analytics_findings_ordinal_check", + "value": "\"code_review_analytics_findings\".\"ordinal\" >= 0" + }, + "code_review_analytics_findings_security_class_presence_check": { + "name": "code_review_analytics_findings_security_class_presence_check", + "value": "(\n (\"code_review_analytics_findings\".\"category\" = 'security' AND \"code_review_analytics_findings\".\"security_class\" IS NOT NULL) OR\n (\"code_review_analytics_findings\".\"category\" <> 'security' AND \"code_review_analytics_findings\".\"security_class\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_review_analytics_results": { + "name": "code_review_analytics_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code_review_id": { + "name": "code_review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_attempt_id": { + "name": "source_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "capture_status": { + "name": "capture_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "taxonomy_version": { + "name": "taxonomy_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "change_type": { + "name": "change_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_level": { + "name": "impact_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "complexity_level": { + "name": "complexity_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "classification_confidence": { + "name": "classification_confidence", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_code_review_analytics_results_source_attempt_id": { + "name": "idx_code_review_analytics_results_source_attempt_id", + "columns": [ + { + "expression": "source_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_analytics_results_finalized_at": { + "name": "idx_code_review_analytics_results_finalized_at", + "columns": [ + { + "expression": "finalized_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_review_analytics_results_code_review_id_cloud_agent_code_reviews_id_fk": { + "name": "code_review_analytics_results_code_review_id_cloud_agent_code_reviews_id_fk", + "tableFrom": "code_review_analytics_results", + "tableTo": "cloud_agent_code_reviews", + "columnsFrom": [ + "code_review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_review_analytics_results_source_attempt_id_cloud_agent_code_review_attempts_id_fk": { + "name": "code_review_analytics_results_source_attempt_id_cloud_agent_code_review_attempts_id_fk", + "tableFrom": "code_review_analytics_results", + "tableTo": "cloud_agent_code_review_attempts", + "columnsFrom": [ + "source_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_review_analytics_results_code_review_id": { + "name": "UQ_code_review_analytics_results_code_review_id", + "nullsNotDistinct": false, + "columns": [ + "code_review_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "code_review_analytics_results_capture_status_check": { + "name": "code_review_analytics_results_capture_status_check", + "value": "\"code_review_analytics_results\".\"capture_status\" IN ('captured', 'missing', 'invalid', 'omitted')" + }, + "code_review_analytics_results_change_type_check": { + "name": "code_review_analytics_results_change_type_check", + "value": "\"code_review_analytics_results\".\"change_type\" IN ('bug_fix', 'feature', 'refactor', 'maintenance', 'dependency', 'test', 'documentation', 'mixed', 'other')" + }, + "code_review_analytics_results_impact_level_check": { + "name": "code_review_analytics_results_impact_level_check", + "value": "\"code_review_analytics_results\".\"impact_level\" IN ('low', 'medium', 'high')" + }, + "code_review_analytics_results_complexity_level_check": { + "name": "code_review_analytics_results_complexity_level_check", + "value": "\"code_review_analytics_results\".\"complexity_level\" IN ('low', 'medium', 'high')" + }, + "code_review_analytics_results_classification_confidence_check": { + "name": "code_review_analytics_results_classification_confidence_check", + "value": "\"code_review_analytics_results\".\"classification_confidence\" IN ('low', 'medium', 'high')" + }, + "code_review_analytics_results_classification_presence_check": { + "name": "code_review_analytics_results_classification_presence_check", + "value": "(\n (\n \"code_review_analytics_results\".\"capture_status\" = 'captured'\n AND \"code_review_analytics_results\".\"change_type\" IS NOT NULL\n AND \"code_review_analytics_results\".\"impact_level\" IS NOT NULL\n AND \"code_review_analytics_results\".\"complexity_level\" IS NOT NULL\n AND \"code_review_analytics_results\".\"classification_confidence\" IS NOT NULL\n ) OR (\n \"code_review_analytics_results\".\"capture_status\" <> 'captured'\n AND \"code_review_analytics_results\".\"change_type\" IS NULL\n AND \"code_review_analytics_results\".\"impact_level\" IS NULL\n AND \"code_review_analytics_results\".\"complexity_level\" IS NULL\n AND \"code_review_analytics_results\".\"classification_confidence\" IS NULL\n )\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_review_feedback_events": { + "name": "code_review_feedback_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "kilo_comment_id": { + "name": "kilo_comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reply_excerpt": { + "name": "reply_excerpt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_comment_excerpt": { + "name": "kilo_comment_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dedupe_hash": { + "name": "dedupe_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_code_review_feedback_events_owned_by_org_id": { + "name": "idx_code_review_feedback_events_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_feedback_events_owned_by_user_id": { + "name": "idx_code_review_feedback_events_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_feedback_events_platform_repo": { + "name": "idx_code_review_feedback_events_platform_repo", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_feedback_events_created_at": { + "name": "idx_code_review_feedback_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_review_feedback_events_owned_by_organization_id_organizations_id_fk": { + "name": "code_review_feedback_events_owned_by_organization_id_organizations_id_fk", + "tableFrom": "code_review_feedback_events", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_review_feedback_events_owned_by_user_id_kilocode_users_id_fk": { + "name": "code_review_feedback_events_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "code_review_feedback_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_code_review_feedback_events_dedupe_hash": { + "name": "UQ_code_review_feedback_events_dedupe_hash", + "nullsNotDistinct": false, + "columns": [ + "dedupe_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "code_review_feedback_events_owner_check": { + "name": "code_review_feedback_events_owner_check", + "value": "(\n (\"code_review_feedback_events\".\"owned_by_user_id\" IS NOT NULL AND \"code_review_feedback_events\".\"owned_by_organization_id\" IS NULL) OR\n (\"code_review_feedback_events\".\"owned_by_user_id\" IS NULL AND \"code_review_feedback_events\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.code_review_memory_proposals": { + "name": "code_review_memory_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rationale": { + "name": "rationale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposed_markdown": { + "name": "proposed_markdown", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "evidence": { + "name": "evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "positive_count": { + "name": "positive_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "negative_count": { + "name": "negative_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "neutral_count": { + "name": "neutral_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "change_request_url": { + "name": "change_request_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_code_review_memory_proposals_owned_by_org_id": { + "name": "idx_code_review_memory_proposals_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_memory_proposals_owned_by_user_id": { + "name": "idx_code_review_memory_proposals_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_memory_proposals_platform_repo_status": { + "name": "idx_code_review_memory_proposals_platform_repo_status", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_code_review_memory_proposals_updated_at": { + "name": "idx_code_review_memory_proposals_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_code_review_memory_proposals_org_active_scope": { + "name": "UQ_code_review_memory_proposals_org_active_scope", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"code_review_memory_proposals\".\"owned_by_organization_id\" IS NOT NULL AND \"code_review_memory_proposals\".\"status\" IN ('open', 'edited', 'opening_change_request')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_code_review_memory_proposals_user_active_scope": { + "name": "UQ_code_review_memory_proposals_user_active_scope", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"code_review_memory_proposals\".\"owned_by_user_id\" IS NOT NULL AND \"code_review_memory_proposals\".\"status\" IN ('open', 'edited', 'opening_change_request')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "code_review_memory_proposals_owned_by_organization_id_organizations_id_fk": { + "name": "code_review_memory_proposals_owned_by_organization_id_organizations_id_fk", + "tableFrom": "code_review_memory_proposals", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "code_review_memory_proposals_owned_by_user_id_kilocode_users_id_fk": { + "name": "code_review_memory_proposals_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "code_review_memory_proposals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "code_review_memory_proposals_owner_check": { + "name": "code_review_memory_proposals_owner_check", + "value": "(\n (\"code_review_memory_proposals\".\"owned_by_user_id\" IS NOT NULL AND \"code_review_memory_proposals\".\"owned_by_organization_id\" IS NULL) OR\n (\"code_review_memory_proposals\".\"owned_by_user_id\" IS NULL AND \"code_review_memory_proposals\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.coding_plan_availability_intents": { + "name": "coding_plan_availability_intents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_availability_intents_user_plan": { + "name": "UQ_coding_plan_availability_intents_user_plan", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_availability_intents_plan": { + "name": "IDX_coding_plan_availability_intents_plan", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_availability_intents_user_id_kilocode_users_id_fk": { + "name": "coding_plan_availability_intents_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_availability_intents", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.coding_plan_key_inventory": { + "name": "coding_plan_key_inventory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_plan_id": { + "name": "upstream_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_usage_id": { + "name": "upstream_usage_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "credential_fingerprint": { + "name": "credential_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "assigned_to_user_id": { + "name": "assigned_to_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_requested_at": { + "name": "revocation_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_attempt_count": { + "name": "revocation_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_revocation_error": { + "name": "last_revocation_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_key_inv_fingerprint": { + "name": "UQ_coding_plan_key_inv_fingerprint", + "columns": [ + { + "expression": "credential_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_coding_plan_key_inv_provider_usage_id": { + "name": "UQ_coding_plan_key_inv_provider_usage_id", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upstream_usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"coding_plan_key_inventory\".\"upstream_usage_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_key_inv_plan_status": { + "name": "IDX_coding_plan_key_inv_plan_status", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_key_inv_available": { + "name": "IDX_coding_plan_key_inv_available", + "columns": [ + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"coding_plan_key_inventory\".\"status\" = 'available'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_key_inventory_assigned_to_user_id_kilocode_users_id_fk": { + "name": "coding_plan_key_inventory_assigned_to_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_key_inventory", + "tableTo": "kilocode_users", + "columnsFrom": [ + "assigned_to_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "coding_plan_key_inventory_status_check": { + "name": "coding_plan_key_inventory_status_check", + "value": "\"coding_plan_key_inventory\".\"status\" IN ('available', 'assigned', 'revocation_pending', 'revoked', 'revocation_failed')" + } + }, + "isRLSEnabled": false + }, + "public.coding_plan_subscriptions": { + "name": "coding_plan_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_inventory_id": { + "name": "key_inventory_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "installed_byok_key_id": { + "name": "installed_byok_key_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "billing_period_days": { + "name": "billing_period_days", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "credit_renewal_at": { + "name": "credit_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "past_due_started_at": { + "name": "past_due_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payment_grace_expires_at": { + "name": "payment_grace_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_attempted_for_due": { + "name": "auto_top_up_attempted_for_due", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancellation_reason": { + "name": "cancellation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_sub_live_user_plan": { + "name": "UQ_coding_plan_sub_live_user_plan", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"coding_plan_subscriptions\".\"status\" IN ('active', 'past_due')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_coding_plan_sub_live_user_provider": { + "name": "UQ_coding_plan_sub_live_user_provider", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"coding_plan_subscriptions\".\"status\" IN ('active', 'past_due')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_sub_status": { + "name": "IDX_coding_plan_sub_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_sub_renewal": { + "name": "IDX_coding_plan_sub_renewal", + "columns": [ + { + "expression": "credit_renewal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_sub_inventory": { + "name": "IDX_coding_plan_sub_inventory", + "columns": [ + { + "expression": "key_inventory_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_subscriptions_user_id_kilocode_users_id_fk": { + "name": "coding_plan_subscriptions_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coding_plan_subscriptions_key_inventory_id_coding_plan_key_inventory_id_fk": { + "name": "coding_plan_subscriptions_key_inventory_id_coding_plan_key_inventory_id_fk", + "tableFrom": "coding_plan_subscriptions", + "tableTo": "coding_plan_key_inventory", + "columnsFrom": [ + "key_inventory_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "coding_plan_subscriptions_installed_byok_key_id_byok_api_keys_id_fk": { + "name": "coding_plan_subscriptions_installed_byok_key_id_byok_api_keys_id_fk", + "tableFrom": "coding_plan_subscriptions", + "tableTo": "byok_api_keys", + "columnsFrom": [ + "installed_byok_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "coding_plan_subscriptions_status_check": { + "name": "coding_plan_subscriptions_status_check", + "value": "\"coding_plan_subscriptions\".\"status\" IN ('active', 'past_due', 'canceled')" + }, + "coding_plan_subscriptions_live_access_check": { + "name": "coding_plan_subscriptions_live_access_check", + "value": "\"coding_plan_subscriptions\".\"status\" = 'canceled' OR \"coding_plan_subscriptions\".\"key_inventory_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.coding_plan_terms": { + "name": "coding_plan_terms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan_id": { + "name": "plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_coding_plan_terms_request": { + "name": "UQ_coding_plan_terms_request", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_coding_plan_terms_subscription": { + "name": "IDX_coding_plan_terms_subscription", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "coding_plan_terms_subscription_id_coding_plan_subscriptions_id_fk": { + "name": "coding_plan_terms_subscription_id_coding_plan_subscriptions_id_fk", + "tableFrom": "coding_plan_terms", + "tableTo": "coding_plan_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coding_plan_terms_user_id_kilocode_users_id_fk": { + "name": "coding_plan_terms_user_id_kilocode_users_id_fk", + "tableFrom": "coding_plan_terms", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "coding_plan_terms_credit_transaction_id_credit_transactions_id_fk": { + "name": "coding_plan_terms_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "coding_plan_terms", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "coding_plan_terms_kind_check": { + "name": "coding_plan_terms_kind_check", + "value": "\"coding_plan_terms\".\"kind\" IN ('activation', 'extension', 'renewal')" + } + }, + "isRLSEnabled": false + }, + "public.compute_usage_charge": { + "name": "compute_usage_charge", + "schema": "", + "columns": { + "usage_source": { + "name": "usage_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_source_id": { + "name": "usage_source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cloud_billing_sku_id": { + "name": "cloud_billing_sku_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(24, 12)", + "primaryKey": false, + "notNull": true + }, + "settled_quantity_after": { + "name": "settled_quantity_after", + "type": "numeric(24, 12)", + "primaryKey": false, + "notNull": false + }, + "rate_cents_per_unit": { + "name": "rate_cents_per_unit", + "type": "numeric(24, 12)", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_compute_usage_charge_user_created": { + "name": "IDX_compute_usage_charge_user_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_compute_usage_charge_organization_created": { + "name": "IDX_compute_usage_charge_organization_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compute_usage_charge_user_id_kilocode_users_id_fk": { + "name": "compute_usage_charge_user_id_kilocode_users_id_fk", + "tableFrom": "compute_usage_charge", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "compute_usage_charge_organization_id_organizations_id_fk": { + "name": "compute_usage_charge_organization_id_organizations_id_fk", + "tableFrom": "compute_usage_charge", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "compute_usage_charge_cloud_billing_sku_id_cloud_billing_sku_id_fk": { + "name": "compute_usage_charge_cloud_billing_sku_id_cloud_billing_sku_id_fk", + "tableFrom": "compute_usage_charge", + "tableTo": "cloud_billing_sku", + "columnsFrom": [ + "cloud_billing_sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "compute_usage_charge_usage_source_usage_source_id_created_at_pk": { + "name": "compute_usage_charge_usage_source_usage_source_id_created_at_pk", + "columns": [ + "usage_source", + "usage_source_id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "compute_usage_charge_exactly_one_payer": { + "name": "compute_usage_charge_exactly_one_payer", + "value": "(\"compute_usage_charge\".\"user_id\" IS NULL) <> (\"compute_usage_charge\".\"organization_id\" IS NULL)" + }, + "compute_usage_charge_quantity_positive": { + "name": "compute_usage_charge_quantity_positive", + "value": "\"compute_usage_charge\".\"quantity\" > 0" + }, + "compute_usage_charge_settled_quantity_positive": { + "name": "compute_usage_charge_settled_quantity_positive", + "value": "\"compute_usage_charge\".\"settled_quantity_after\" IS NULL OR \"compute_usage_charge\".\"settled_quantity_after\" > 0" + }, + "compute_usage_charge_rate_positive": { + "name": "compute_usage_charge_rate_positive", + "value": "\"compute_usage_charge\".\"rate_cents_per_unit\" > 0" + }, + "compute_usage_charge_amount_positive": { + "name": "compute_usage_charge_amount_positive", + "value": "\"compute_usage_charge\".\"amount_microdollars\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.container_usage_interval": { + "name": "container_usage_interval", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "service": { + "name": "service", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_epoch_ms": { + "name": "start_epoch_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cloud_billing_sku_id": { + "name": "cloud_billing_sku_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_fingerprint": { + "name": "context_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_heartbeat_seq": { + "name": "last_heartbeat_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "confirmed_seconds": { + "name": "confirmed_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "billing_mode": { + "name": "billing_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'shadow'" + }, + "rate_cents_per_unit": { + "name": "rate_cents_per_unit", + "type": "numeric(24, 12)", + "primaryKey": false, + "notNull": false + }, + "settled_billable_seconds": { + "name": "settled_billable_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "final_stop_seq": { + "name": "final_stop_seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_container_usage_interval_sweep": { + "name": "IDX_container_usage_interval_sweep", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_container_usage_interval_subject_started": { + "name": "IDX_container_usage_interval_subject_started", + "columns": [ + { + "expression": "subject_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_container_usage_interval_single_open": { + "name": "UQ_container_usage_interval_single_open", + "columns": [ + { + "expression": "service", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"container_usage_interval\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "container_usage_interval_cloud_billing_sku_id_cloud_billing_sku_id_fk": { + "name": "container_usage_interval_cloud_billing_sku_id_cloud_billing_sku_id_fk", + "tableFrom": "container_usage_interval", + "tableTo": "cloud_billing_sku", + "columnsFrom": [ + "cloud_billing_sku_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "container_usage_interval_subject_type": { + "name": "container_usage_interval_subject_type", + "value": "\"container_usage_interval\".\"subject_type\" IN ('user', 'org')" + }, + "container_usage_interval_actor_type": { + "name": "container_usage_interval_actor_type", + "value": "\"container_usage_interval\".\"actor_type\" IN ('user', 'bot')" + }, + "container_usage_interval_context_fingerprint": { + "name": "container_usage_interval_context_fingerprint", + "value": "\"container_usage_interval\".\"context_fingerprint\" ~ '^[a-f0-9]{64}$'" + }, + "container_usage_interval_attribution": { + "name": "container_usage_interval_attribution", + "value": "\"container_usage_interval\".\"actor_type\" = 'bot' OR (\"container_usage_interval\".\"actor_type\" = 'user' AND (\"container_usage_interval\".\"subject_type\" <> 'user' OR \"container_usage_interval\".\"actor_id\" = \"container_usage_interval\".\"subject_id\"))" + }, + "container_usage_interval_status": { + "name": "container_usage_interval_status", + "value": "\"container_usage_interval\".\"status\" IN ('open', 'closed')" + }, + "container_usage_interval_billing_mode": { + "name": "container_usage_interval_billing_mode", + "value": "\"container_usage_interval\".\"billing_mode\" IN ('shadow', 'paid')" + }, + "container_usage_interval_paid_rate": { + "name": "container_usage_interval_paid_rate", + "value": "(\"container_usage_interval\".\"billing_mode\" = 'shadow' AND \"container_usage_interval\".\"rate_cents_per_unit\" IS NULL) OR (\"container_usage_interval\".\"billing_mode\" = 'paid' AND \"container_usage_interval\".\"rate_cents_per_unit\" > 0)" + }, + "container_usage_interval_open_closed_shape": { + "name": "container_usage_interval_open_closed_shape", + "value": "(\"container_usage_interval\".\"status\" = 'open' AND \"container_usage_interval\".\"stopped_at\" IS NULL AND \"container_usage_interval\".\"close_reason\" IS NULL) OR (\"container_usage_interval\".\"status\" = 'closed' AND \"container_usage_interval\".\"stopped_at\" IS NOT NULL AND \"container_usage_interval\".\"close_reason\" IS NOT NULL)" + }, + "container_usage_interval_time_order": { + "name": "container_usage_interval_time_order", + "value": "\"container_usage_interval\".\"last_seen_at\" >= \"container_usage_interval\".\"started_at\" AND (\"container_usage_interval\".\"stopped_at\" IS NULL OR (\"container_usage_interval\".\"stopped_at\" >= \"container_usage_interval\".\"started_at\" AND \"container_usage_interval\".\"stopped_at\" <= \"container_usage_interval\".\"last_seen_at\"))" + }, + "container_usage_interval_last_heartbeat_seq_nonnegative": { + "name": "container_usage_interval_last_heartbeat_seq_nonnegative", + "value": "\"container_usage_interval\".\"last_heartbeat_seq\" >= 0" + }, + "container_usage_interval_confirmed_seconds_nonnegative": { + "name": "container_usage_interval_confirmed_seconds_nonnegative", + "value": "\"container_usage_interval\".\"confirmed_seconds\" >= 0" + }, + "container_usage_interval_settled_billable_seconds_nonnegative": { + "name": "container_usage_interval_settled_billable_seconds_nonnegative", + "value": "\"container_usage_interval\".\"settled_billable_seconds\" >= 0 AND \"container_usage_interval\".\"settled_billable_seconds\" <= \"container_usage_interval\".\"confirmed_seconds\"" + }, + "container_usage_interval_final_stop_seq_positive": { + "name": "container_usage_interval_final_stop_seq_positive", + "value": "\"container_usage_interval\".\"final_stop_seq\" IS NULL OR \"container_usage_interval\".\"final_stop_seq\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.container_usage_segment": { + "name": "container_usage_segment", + "schema": "", + "columns": { + "interval_id": { + "name": "interval_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reported_seconds": { + "name": "reported_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "usage_seconds": { + "name": "usage_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_container_usage_segment_received": { + "name": "IDX_container_usage_segment_received", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "container_usage_segment_interval_id_container_usage_interval_id_fk": { + "name": "container_usage_segment_interval_id_container_usage_interval_id_fk", + "tableFrom": "container_usage_segment", + "tableTo": "container_usage_interval", + "columnsFrom": [ + "interval_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "container_usage_segment_interval_id_seq_pk": { + "name": "container_usage_segment_interval_id_seq_pk", + "columns": [ + "interval_id", + "seq" + ] + } + }, + "uniqueConstraints": { + "container_usage_segment_idempotency_key_unique": { + "name": "container_usage_segment_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "container_usage_segment_seq_positive": { + "name": "container_usage_segment_seq_positive", + "value": "\"container_usage_segment\".\"seq\" > 0" + }, + "container_usage_segment_reported_seconds_nonnegative": { + "name": "container_usage_segment_reported_seconds_nonnegative", + "value": "\"container_usage_segment\".\"reported_seconds\" >= 0" + }, + "container_usage_segment_usage_seconds_nonnegative": { + "name": "container_usage_segment_usage_seconds_nonnegative", + "value": "\"container_usage_segment\".\"usage_seconds\" >= 0" + }, + "container_usage_segment_usage_within_reported": { + "name": "container_usage_segment_usage_within_reported", + "value": "\"container_usage_segment\".\"usage_seconds\" <= \"container_usage_segment\".\"reported_seconds\"" + } + }, + "isRLSEnabled": false + }, + "public.content_moderation_reports": { + "name": "content_moderation_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "surface": { + "name": "surface", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "triage_status": { + "name": "triage_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "appeal_status": { + "name": "appeal_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_content_moderation_reports_user_created": { + "name": "IDX_content_moderation_reports_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_content_moderation_reports_target": { + "name": "IDX_content_moderation_reports_target", + "columns": [ + { + "expression": "target_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "content_moderation_reports_receipt_id_unique": { + "name": "content_moderation_reports_receipt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "receipt_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_contributors": { + "name": "contributor_champion_contributors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_profile_url": { + "name": "github_profile_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_user_id": { + "name": "github_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "first_contribution_at": { + "name": "first_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_contribution_at": { + "name": "last_contribution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "all_time_contributions": { + "name": "all_time_contributions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "manual_email": { + "name": "manual_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_contributors_last_contribution_at": { + "name": "IDX_contributor_champion_contributors_last_contribution_at", + "columns": [ + { + "expression": "last_contribution_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_contributors_manual_email": { + "name": "IDX_contributor_champion_contributors_manual_email", + "columns": [ + { + "expression": "manual_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_contributors_github_login": { + "name": "UQ_contributor_champion_contributors_github_login", + "nullsNotDistinct": false, + "columns": [ + "github_login" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_events": { + "name": "contributor_champion_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_number": { + "name": "github_pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "github_pr_url": { + "name": "github_pr_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_pr_title": { + "name": "github_pr_title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_login": { + "name": "github_author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_author_email": { + "name": "github_author_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_events_contributor_id": { + "name": "IDX_contributor_champion_events_contributor_id", + "columns": [ + { + "expression": "contributor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_merged_at": { + "name": "IDX_contributor_champion_events_merged_at", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_events_author_email": { + "name": "IDX_contributor_champion_events_author_email", + "columns": [ + { + "expression": "github_author_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_events_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_events", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_events_repo_pr": { + "name": "UQ_contributor_champion_events_repo_pr", + "nullsNotDistinct": false, + "columns": [ + "repo_full_name", + "github_pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contributor_champion_memberships": { + "name": "contributor_champion_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_tier": { + "name": "selected_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_tier": { + "name": "enrolled_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enrolled_at": { + "name": "enrolled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_amount_microdollars": { + "name": "credit_amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "credits_last_granted_at": { + "name": "credits_last_granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "linked_kilo_user_id": { + "name": "linked_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_contributor_champion_memberships_credits_due": { + "name": "IDX_contributor_champion_memberships_credits_due", + "columns": [ + { + "expression": "credits_last_granted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NOT NULL AND \"contributor_champion_memberships\".\"credit_amount_microdollars\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_contributor_champion_memberships_linked_kilo_user_id": { + "name": "IDX_contributor_champion_memberships_linked_kilo_user_id", + "columns": [ + { + "expression": "linked_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk": { + "name": "contributor_champion_memberships_contributor_id_contributor_champion_contributors_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "contributor_champion_contributors", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk": { + "name": "contributor_champion_memberships_linked_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "contributor_champion_memberships", + "tableTo": "kilocode_users", + "columnsFrom": [ + "linked_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_contributor_champion_memberships_contributor_id": { + "name": "UQ_contributor_champion_memberships_contributor_id", + "nullsNotDistinct": false, + "columns": [ + "contributor_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "contributor_champion_memberships_selected_tier_check": { + "name": "contributor_champion_memberships_selected_tier_check", + "value": "\"contributor_champion_memberships\".\"selected_tier\" IS NULL OR \"contributor_champion_memberships\".\"selected_tier\" IN ('contributor', 'ambassador', 'champion')" + }, + "contributor_champion_memberships_enrolled_tier_check": { + "name": "contributor_champion_memberships_enrolled_tier_check", + "value": "\"contributor_champion_memberships\".\"enrolled_tier\" IS NULL OR \"contributor_champion_memberships\".\"enrolled_tier\" IN ('contributor', 'ambassador', 'champion')" + } + }, + "isRLSEnabled": false + }, + "public.contributor_champion_sync_state": { + "name": "contributor_champion_sync_state", + "schema": "", + "columns": { + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_merged_at": { + "name": "last_merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credit_campaigns": { + "name": "credit_campaigns", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "credit_expiry_hours": { + "name": "credit_expiry_hours", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "campaign_ends_at": { + "name": "campaign_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_redemptions_allowed": { + "name": "total_redemptions_allowed", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_credit_campaigns_slug": { + "name": "UQ_credit_campaigns_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_credit_campaigns_credit_category": { + "name": "UQ_credit_campaigns_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credit_campaigns_slug_format_check": { + "name": "credit_campaigns_slug_format_check", + "value": "\"credit_campaigns\".\"slug\" ~ '^[a-z0-9-]{5,40}$'" + }, + "credit_campaigns_amount_positive_check": { + "name": "credit_campaigns_amount_positive_check", + "value": "\"credit_campaigns\".\"amount_microdollars\" > 0" + }, + "credit_campaigns_credit_expiry_hours_positive_check": { + "name": "credit_campaigns_credit_expiry_hours_positive_check", + "value": "\"credit_campaigns\".\"credit_expiry_hours\" IS NULL OR \"credit_campaigns\".\"credit_expiry_hours\" > 0" + }, + "credit_campaigns_total_redemptions_allowed_positive_check": { + "name": "credit_campaigns_total_redemptions_allowed_positive_check", + "value": "\"credit_campaigns\".\"total_redemptions_allowed\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.credit_transactions": { + "name": "credit_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_microdollars": { + "name": "amount_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "expiration_baseline_microdollars_used": { + "name": "expiration_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "original_baseline_microdollars_used": { + "name": "original_baseline_microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "original_transaction_id": { + "name": "original_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_id": { + "name": "stripe_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coinbase_credit_block_id": { + "name": "coinbase_credit_block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credit_category": { + "name": "credit_category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiry_date": { + "name": "expiry_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "check_category_uniqueness": { + "name": "check_category_uniqueness", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_credit_transactions_created_at": { + "name": "IDX_credit_transactions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_is_free": { + "name": "IDX_credit_transactions_is_free", + "columns": [ + { + "expression": "is_free", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_kilo_user_id": { + "name": "IDX_credit_transactions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_credit_category": { + "name": "IDX_credit_transactions_credit_category", + "columns": [ + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_stripe_payment_id": { + "name": "IDX_credit_transactions_stripe_payment_id", + "columns": [ + { + "expression": "stripe_payment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_original_transaction_id": { + "name": "IDX_credit_transactions_original_transaction_id", + "columns": [ + { + "expression": "original_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_coinbase_credit_block_id": { + "name": "IDX_credit_transactions_coinbase_credit_block_id", + "columns": [ + { + "expression": "coinbase_credit_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_organization_id": { + "name": "IDX_credit_transactions_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_credit_transactions_unique_category": { + "name": "IDX_credit_transactions_unique_category", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "credit_category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"credit_transactions\".\"check_category_uniqueness\" = TRUE", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credit_transactions_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "credit_transactions_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "credit_transactions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_llm2": { + "name": "custom_llm2", + "schema": "", + "columns": { + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deleted_user_email_tombstones": { + "name": "deleted_user_email_tombstones", + "schema": "", + "columns": { + "normalized_email_hash": { + "name": "normalized_email_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_builds": { + "name": "deployment_builds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_builds_deployment_id": { + "name": "idx_deployment_builds_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_builds_status": { + "name": "idx_deployment_builds_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_builds_deployment_id_deployments_id_fk": { + "name": "deployment_builds_deployment_id_deployments_id_fk", + "tableFrom": "deployment_builds", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_env_vars": { + "name": "deployment_env_vars", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_env_vars_deployment_id": { + "name": "idx_deployment_env_vars_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_env_vars_deployment_id_deployments_id_fk": { + "name": "deployment_env_vars_deployment_id_deployments_id_fk", + "tableFrom": "deployment_env_vars", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployment_env_vars_deployment_key": { + "name": "UQ_deployment_env_vars_deployment_key", + "nullsNotDistinct": false, + "columns": [ + "deployment_id", + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_events": { + "name": "deployment_events", + "schema": "", + "columns": { + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'log'" + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_deployment_events_build_id": { + "name": "idx_deployment_events_build_id", + "columns": [ + { + "expression": "build_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_timestamp": { + "name": "idx_deployment_events_timestamp", + "columns": [ + { + "expression": "timestamp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_events_type": { + "name": "idx_deployment_events_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_events_build_id_deployment_builds_id_fk": { + "name": "deployment_events_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_events", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "deployment_events_build_id_event_id_pk": { + "name": "deployment_events_build_id_event_id_pk", + "columns": [ + "build_id", + "event_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_threat_detections": { + "name": "deployment_threat_detections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "deployment_id": { + "name": "deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "threat_type": { + "name": "threat_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployment_threat_detections_deployment_id": { + "name": "idx_deployment_threat_detections_deployment_id", + "columns": [ + { + "expression": "deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployment_threat_detections_created_at": { + "name": "idx_deployment_threat_detections_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployment_threat_detections_deployment_id_deployments_id_fk": { + "name": "deployment_threat_detections_deployment_id_deployments_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployments", + "columnsFrom": [ + "deployment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "deployment_threat_detections_build_id_deployment_builds_id_fk": { + "name": "deployment_threat_detections_build_id_deployment_builds_id_fk", + "tableFrom": "deployment_threat_detections", + "tableTo": "deployment_builds", + "columnsFrom": [ + "build_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployments": { + "name": "deployments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "deployment_slug": { + "name": "deployment_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_worker_name": { + "name": "internal_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_source": { + "name": "repository_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_url": { + "name": "deployment_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "git_auth_token": { + "name": "git_auth_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_deployed_at": { + "name": "last_deployed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_build_id": { + "name": "last_build_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "threat_status": { + "name": "threat_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_deployments_owned_by_user_id": { + "name": "idx_deployments_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_owned_by_organization_id": { + "name": "idx_deployments_owned_by_organization_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_platform_integration_id": { + "name": "idx_deployments_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_repository_source_branch": { + "name": "idx_deployments_repository_source_branch", + "columns": [ + { + "expression": "repository_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_threat_status_pending": { + "name": "idx_deployments_threat_status_pending", + "columns": [ + { + "expression": "threat_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"deployments\".\"threat_status\" = 'pending_scan'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_owned_by_user_id_kilocode_users_id_fk": { + "name": "deployments_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "deployments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "deployments_owned_by_organization_id_organizations_id_fk": { + "name": "deployments_owned_by_organization_id_organizations_id_fk", + "tableFrom": "deployments", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployments_deployment_slug": { + "name": "UQ_deployments_deployment_slug", + "nullsNotDistinct": false, + "columns": [ + "deployment_slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "deployments_owner_check": { + "name": "deployments_owner_check", + "value": "(\n (\"deployments\".\"owned_by_user_id\" IS NOT NULL AND \"deployments\".\"owned_by_organization_id\" IS NULL) OR\n (\"deployments\".\"owned_by_user_id\" IS NULL AND \"deployments\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "deployments_source_type_check": { + "name": "deployments_source_type_check", + "value": "\"deployments\".\"source_type\" IN ('github', 'git', 'app-builder')" + } + }, + "isRLSEnabled": false + }, + "public.deployments_ephemeral": { + "name": "deployments_ephemeral", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "internal_worker_name": { + "name": "internal_worker_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_slug": { + "name": "deployment_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_cleanup_at": { + "name": "next_cleanup_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "cleanup_claim_token": { + "name": "cleanup_claim_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cleanup_claimed_until": { + "name": "cleanup_claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_deployments_ephemeral_owned_by_user_id": { + "name": "idx_deployments_ephemeral_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_deployments_ephemeral_next_cleanup_at": { + "name": "idx_deployments_ephemeral_next_cleanup_at", + "columns": [ + { + "expression": "next_cleanup_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "deployments_ephemeral_owned_by_user_id_kilocode_users_id_fk": { + "name": "deployments_ephemeral_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "deployments_ephemeral", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_deployments_ephemeral_internal_worker_name": { + "name": "UQ_deployments_ephemeral_internal_worker_name", + "nullsNotDistinct": false, + "columns": [ + "internal_worker_name" + ] + }, + "UQ_deployments_ephemeral_deployment_slug": { + "name": "UQ_deployments_ephemeral_deployment_slug", + "nullsNotDistinct": false, + "columns": [ + "deployment_slug" + ] + } + }, + "policies": {}, + "checkConstraints": { + "deployments_ephemeral_source_type_check": { + "name": "deployments_ephemeral_source_type_check", + "value": "\"deployments_ephemeral\".\"source_type\" IN ('html')" + }, + "deployments_ephemeral_status_check": { + "name": "deployments_ephemeral_status_check", + "value": "\"deployments_ephemeral\".\"status\" IN ('pending', 'active', 'cleanup_retry')" + }, + "deployments_ephemeral_claim_fields_check": { + "name": "deployments_ephemeral_claim_fields_check", + "value": "(\"deployments_ephemeral\".\"cleanup_claim_token\" IS NULL) = (\"deployments_ephemeral\".\"cleanup_claimed_until\" IS NULL)" + }, + "deployments_ephemeral_active_fields_check": { + "name": "deployments_ephemeral_active_fields_check", + "value": "\"deployments_ephemeral\".\"status\" <> 'active' OR (\"deployments_ephemeral\".\"deployment_slug\" IS NOT NULL AND \"deployments_ephemeral\".\"expires_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.device_auth_requests": { + "name": "device_auth_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_device_auth_requests_code": { + "name": "UQ_device_auth_requests_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_status": { + "name": "IDX_device_auth_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_expires_at": { + "name": "IDX_device_auth_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_kilo_user_id": { + "name": "IDX_device_auth_requests_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_device_auth_requests_device_code_hash": { + "name": "UQ_device_auth_requests_device_code_hash", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"device_auth_requests\".\"device_code_hash\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "IDX_device_auth_requests_user_code": { + "name": "IDX_device_auth_requests_user_code", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"device_auth_requests\".\"user_code\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_auth_requests_kilo_user_id_kilocode_users_id_fk": { + "name": "device_auth_requests_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "device_auth_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_refresh_tokens": { + "name": "device_refresh_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_session_id": { + "name": "device_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_device_refresh_tokens_device_session_id": { + "name": "IDX_device_refresh_tokens_device_session_id", + "columns": [ + { + "expression": "device_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_refresh_tokens_expires_at": { + "name": "IDX_device_refresh_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_refresh_tokens_device_session_id_device_sessions_id_fk": { + "name": "device_refresh_tokens_device_session_id_device_sessions_id_fk", + "tableFrom": "device_refresh_tokens", + "tableTo": "device_sessions", + "columnsFrom": [ + "device_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_sessions": { + "name": "device_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_auth_request_id": { + "name": "device_auth_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_reason": { + "name": "revoked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_device_sessions_kilo_user_id": { + "name": "IDX_device_sessions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_device_sessions_revoked_at": { + "name": "IDX_device_sessions_revoked_at", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "device_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "device_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_gateway_listener": { + "name": "discord_gateway_listener", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "listener_id": { + "name": "listener_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.editor_name": { + "name": "editor_name", + "schema": "", + "columns": { + "editor_name_id": { + "name": "editor_name_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_editor_name": { + "name": "UQ_editor_name", + "columns": [ + { + "expression": "editor_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.enrichment_data": { + "name": "enrichment_data", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_enrichment_data": { + "name": "github_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "linkedin_enrichment_data": { + "name": "linkedin_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "clay_enrichment_data": { + "name": "clay_enrichment_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_enrichment_data_user_id": { + "name": "IDX_enrichment_data_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "enrichment_data_user_id_kilocode_users_id_fk": { + "name": "enrichment_data_user_id_kilocode_users_id_fk", + "tableFrom": "enrichment_data", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_enrichment_data_user_id": { + "name": "UQ_enrichment_data_user_id", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_monthly_usage": { + "name": "exa_monthly_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "month": { + "name": "month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_charged_microdollars": { + "name": "total_charged_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "free_allowance_microdollars": { + "name": "free_allowance_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 10000000 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_monthly_usage_personal": { + "name": "idx_exa_monthly_usage_personal", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_exa_monthly_usage_org": { + "name": "idx_exa_monthly_usage_org", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"exa_monthly_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.exa_usage_log": { + "name": "exa_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_microdollars": { + "name": "cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "charged_to_balance": { + "name": "charged_to_balance", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "feature_id": { + "name": "feature_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_exa_usage_log_user_created": { + "name": "idx_exa_usage_log_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "exa_usage_log_id_created_at_pk": { + "name": "exa_usage_log_id_created_at_pk", + "columns": [ + "id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_side_effect_outbox": { + "name": "external_side_effect_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'send_org_invite_email'" + }, + "invitation_id": { + "name": "invitation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_external_side_effect_outbox_invitation_id": { + "name": "UQ_external_side_effect_outbox_invitation_id", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_external_side_effect_outbox_status_next_attempt_at": { + "name": "IDX_external_side_effect_outbox_status_next_attempt_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feature": { + "name": "feature", + "schema": "", + "columns": { + "feature_id": { + "name": "feature_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_feature": { + "name": "UQ_feature", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.finish_reason": { + "name": "finish_reason", + "schema": "", + "columns": { + "finish_reason_id": { + "name": "finish_reason_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_finish_reason": { + "name": "UQ_finish_reason", + "columns": [ + { + "expression": "finish_reason", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.free_model_usage": { + "name": "free_model_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_free_model_usage_ip_created_at": { + "name": "idx_free_model_usage_ip_created_at", + "columns": [ + { + "expression": "ip_address", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_free_model_usage_created_at": { + "name": "idx_free_model_usage_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_branch_pull_requests": { + "name": "github_branch_pull_requests", + "schema": "", + "columns": { + "git_url": { + "name": "git_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_state": { + "name": "pr_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_title": { + "name": "pr_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_head_sha": { + "name": "pr_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_review_decision": { + "name": "pr_review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_decision_pending": { + "name": "review_decision_pending", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_decision_fetching_at": { + "name": "review_decision_fetching_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pr_last_synced_at": { + "name": "pr_last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_github_branch_prs_org": { + "name": "UQ_github_branch_prs_org", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"github_branch_pull_requests\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_github_branch_prs_user": { + "name": "UQ_github_branch_prs_user", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"github_branch_pull_requests\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_github_branch_prs_url_branch": { + "name": "IDX_github_branch_prs_url_branch", + "columns": [ + { + "expression": "git_url", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_branch_pull_requests_owned_by_organization_id_organizations_id_fk": { + "name": "github_branch_pull_requests_owned_by_organization_id_organizations_id_fk", + "tableFrom": "github_branch_pull_requests", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_branch_pull_requests_owned_by_user_id_kilocode_users_id_fk": { + "name": "github_branch_pull_requests_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "github_branch_pull_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_branch_pull_requests_owner_check": { + "name": "github_branch_pull_requests_owner_check", + "value": "(\n (\"github_branch_pull_requests\".\"owned_by_organization_id\" IS NOT NULL AND \"github_branch_pull_requests\".\"owned_by_user_id\" IS NULL) OR\n (\"github_branch_pull_requests\".\"owned_by_organization_id\" IS NULL AND \"github_branch_pull_requests\".\"owned_by_user_id\" IS NOT NULL)\n )" + }, + "github_branch_pull_requests_review_decision_check": { + "name": "github_branch_pull_requests_review_decision_check", + "value": "\"github_branch_pull_requests\".\"pr_review_decision\" IS NULL OR \"github_branch_pull_requests\".\"pr_review_decision\" IN ('approved', 'changes_requested', 'review_required')" + } + }, + "isRLSEnabled": false + }, + "public.github_install_states": { + "name": "github_install_states", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "return_to": { + "name": "return_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_github_install_states_expires_at": { + "name": "IDX_github_install_states_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_install_states_kilo_user_id_kilocode_users_id_fk": { + "name": "github_install_states_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "github_install_states", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "github_install_states_owner_type_check": { + "name": "github_install_states_owner_type_check", + "value": "\"github_install_states\".\"owner_type\" IN ('org', 'user')" + } + }, + "isRLSEnabled": false + }, + "public.http_ip": { + "name": "http_ip", + "schema": "", + "columns": { + "http_ip_id": { + "name": "http_ip_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_ip": { + "name": "http_ip", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_ip": { + "name": "UQ_http_ip", + "columns": [ + { + "expression": "http_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.http_user_agent": { + "name": "http_user_agent", + "schema": "", + "columns": { + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_http_user_agent": { + "name": "UQ_http_user_agent", + "columns": [ + { + "expression": "http_user_agent", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.impact_advocate_participants": { + "name": "impact_advocate_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "program_key": { + "name": "program_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_id": { + "name": "advocate_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "advocate_account_id": { + "name": "advocate_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_referral_identifier": { + "name": "opaque_referral_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_email": { + "name": "contact_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_state": { + "name": "registration_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "registered_at": { + "name": "registered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_registration_attempt_at": { + "name": "last_registration_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_impact_advocate_participants_program_referral_identifier": { + "name": "UQ_impact_advocate_participants_program_referral_identifier", + "columns": [ + { + "expression": "program_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "opaque_referral_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"impact_advocate_participants\".\"opaque_referral_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_participants_registration_state": { + "name": "IDX_impact_advocate_participants_registration_state", + "columns": [ + { + "expression": "registration_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_participants_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_participants_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_participants", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_participants_program_user": { + "name": "UQ_impact_advocate_participants_program_user", + "nullsNotDistinct": false, + "columns": [ + "program_key", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_participants_program_key_check": { + "name": "impact_advocate_participants_program_key_check", + "value": "\"impact_advocate_participants\".\"program_key\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_advocate_participants_registration_state_check": { + "name": "impact_advocate_participants_registration_state_check", + "value": "\"impact_advocate_participants\".\"registration_state\" IN ('pending', 'retrying', 'registered', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_registration_attempts": { + "name": "impact_advocate_registration_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "program_key": { + "name": "program_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "participant_id": { + "name": "participant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_cookie_value": { + "name": "opaque_cookie_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookie_value_length": { + "name": "cookie_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_registration_attempts_participant_id": { + "name": "IDX_impact_advocate_registration_attempts_participant_id", + "columns": [ + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_registration_attempts_delivery_state": { + "name": "IDX_impact_advocate_registration_attempts_delivery_state", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk": { + "name": "impact_advocate_registration_attempts_participant_id_impact_advocate_participants_id_fk", + "tableFrom": "impact_advocate_registration_attempts", + "tableTo": "impact_advocate_participants", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_registration_attempts_dedupe_key": { + "name": "UQ_impact_advocate_registration_attempts_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_registration_attempts_program_key_check": { + "name": "impact_advocate_registration_attempts_program_key_check", + "value": "\"impact_advocate_registration_attempts\".\"program_key\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_advocate_registration_attempts_delivery_state_check": { + "name": "impact_advocate_registration_attempts_delivery_state_check", + "value": "\"impact_advocate_registration_attempts\".\"delivery_state\" IN ('queued', 'sending', 'succeeded', 'failed')" + }, + "impact_advocate_registration_attempts_cookie_value_length_non_negative_check": { + "name": "impact_advocate_registration_attempts_cookie_value_length_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"cookie_value_length\" >= 0" + }, + "impact_advocate_registration_attempts_attempt_count_non_negative_check": { + "name": "impact_advocate_registration_attempts_attempt_count_non_negative_check", + "value": "\"impact_advocate_registration_attempts\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_advocate_reward_redemptions": { + "name": "impact_advocate_reward_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "impact_reward_id": { + "name": "impact_reward_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "lookup_response_payload": { + "name": "lookup_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "redeem_response_payload": { + "name": "redeem_response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_advocate_reward_redemptions_beneficiary_user_id": { + "name": "IDX_impact_advocate_reward_redemptions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_advocate_reward_redemptions_state": { + "name": "IDX_impact_advocate_reward_redemptions_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_advocate_reward_redemptions_reward_id_impact_referral_rewards_id_fk": { + "name": "impact_advocate_reward_redemptions_reward_id_impact_referral_rewards_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "impact_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_advocate_reward_redemptions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_advocate_reward_redemptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_advocate_reward_redemptions_reward_id": { + "name": "UQ_impact_advocate_reward_redemptions_reward_id", + "nullsNotDistinct": false, + "columns": [ + "reward_id" + ] + }, + "UQ_impact_advocate_reward_redemptions_dedupe_key": { + "name": "UQ_impact_advocate_reward_redemptions_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_advocate_reward_redemptions_state_check": { + "name": "impact_advocate_reward_redemptions_state_check", + "value": "\"impact_advocate_reward_redemptions\".\"state\" IN ('queued', 'retrying', 'redeemed', 'failed')" + }, + "impact_advocate_reward_redemptions_attempt_count_non_negative_check": { + "name": "impact_advocate_reward_redemptions_attempt_count_non_negative_check", + "value": "\"impact_advocate_reward_redemptions\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_attribution_touches": { + "name": "impact_attribution_touches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "program_key": { + "name": "program_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'kiloclaw'" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touch_type": { + "name": "touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opaque_tracking_value": { + "name": "opaque_tracking_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_value_length": { + "name": "tracking_value_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_tracking_value_accepted": { + "name": "is_tracking_value_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rs_code": { + "name": "rs_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_share_medium": { + "name": "rs_share_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rs_engagement_medium": { + "name": "rs_engagement_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "im_ref": { + "name": "im_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "landing_path": { + "name": "landing_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_source": { + "name": "utm_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_medium": { + "name": "utm_medium", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_campaign": { + "name": "utm_campaign", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_term": { + "name": "utm_term", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "utm_content": { + "name": "utm_content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "touched_at": { + "name": "touched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "sale_attributed_at": { + "name": "sale_attributed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_attribution_touches_product_user_id": { + "name": "IDX_impact_attribution_touches_product_user_id", + "columns": [ + { + "expression": "product", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_user_id": { + "name": "IDX_impact_attribution_touches_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_anonymous_id": { + "name": "IDX_impact_attribution_touches_anonymous_id", + "columns": [ + { + "expression": "anonymous_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_expires_at": { + "name": "IDX_impact_attribution_touches_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_attribution_touches_sale_attributed_at": { + "name": "IDX_impact_attribution_touches_sale_attributed_at", + "columns": [ + { + "expression": "sale_attributed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_attribution_touches_user_id_kilocode_users_id_fk": { + "name": "impact_attribution_touches_user_id_kilocode_users_id_fk", + "tableFrom": "impact_attribution_touches", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_attribution_touches_dedupe_key": { + "name": "UQ_impact_attribution_touches_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_attribution_touches_product_check": { + "name": "impact_attribution_touches_product_check", + "value": "\"impact_attribution_touches\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_attribution_touches_program_key_check": { + "name": "impact_attribution_touches_program_key_check", + "value": "\"impact_attribution_touches\".\"program_key\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_attribution_touches_touch_type_check": { + "name": "impact_attribution_touches_touch_type_check", + "value": "\"impact_attribution_touches\".\"touch_type\" IN ('affiliate', 'referral')" + }, + "impact_attribution_touches_provider_check": { + "name": "impact_attribution_touches_provider_check", + "value": "\"impact_attribution_touches\".\"provider\" IN ('impact_performance', 'impact_advocate')" + }, + "impact_attribution_touches_tracking_value_length_non_negative_check": { + "name": "impact_attribution_touches_tracking_value_length_non_negative_check", + "value": "\"impact_attribution_touches\".\"tracking_value_length\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_conversion_reports": { + "name": "impact_conversion_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action_tracker_id": { + "name": "action_tracker_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "request_payload": { + "name": "request_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_payload": { + "name": "response_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "response_status_code": { + "name": "response_status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_conversion_reports_conversion_id": { + "name": "IDX_impact_conversion_reports_conversion_id", + "columns": [ + { + "expression": "conversion_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_conversion_reports_state": { + "name": "IDX_impact_conversion_reports_state", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_conversion_reports_conversion_id_impact_referral_conversions_id_fk": { + "name": "impact_conversion_reports_conversion_id_impact_referral_conversions_id_fk", + "tableFrom": "impact_conversion_reports", + "tableTo": "impact_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_conversion_reports_dedupe_key": { + "name": "UQ_impact_conversion_reports_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_conversion_reports_state_check": { + "name": "impact_conversion_reports_state_check", + "value": "\"impact_conversion_reports\".\"state\" IN ('queued', 'retrying', 'delivered', 'failed')" + }, + "impact_conversion_reports_attempt_count_non_negative_check": { + "name": "impact_conversion_reports_attempt_count_non_negative_check", + "value": "\"impact_conversion_reports\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_conversions": { + "name": "impact_referral_conversions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "winning_touch_type": { + "name": "winning_touch_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credits'" + }, + "source_payment_id": { + "name": "source_payment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "qualified": { + "name": "qualified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "disqualification_reason": { + "name": "disqualification_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "converted_at": { + "name": "converted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_conversions_referee_user_id": { + "name": "IDX_impact_referral_conversions_referee_user_id", + "columns": [ + { + "expression": "referee_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referral_conversions_referrer_user_id": { + "name": "IDX_impact_referral_conversions_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_conversions_referee_user_id_kilocode_users_id_fk": { + "name": "impact_referral_conversions_referee_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_conversions_referrer_user_id_kilocode_users_id_fk": { + "name": "impact_referral_conversions_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_conversions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "impact_referral_conversions_source_touch_id_impact_attribution_touches_id_fk": { + "name": "impact_referral_conversions_source_touch_id_impact_attribution_touches_id_fk", + "tableFrom": "impact_referral_conversions", + "tableTo": "impact_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referral_conversions_product_payment_source": { + "name": "UQ_impact_referral_conversions_product_payment_source", + "nullsNotDistinct": false, + "columns": [ + "product", + "payment_provider", + "source_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referral_conversions_product_check": { + "name": "impact_referral_conversions_product_check", + "value": "\"impact_referral_conversions\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_referral_conversions_winning_touch_type_check": { + "name": "impact_referral_conversions_winning_touch_type_check", + "value": "\"impact_referral_conversions\".\"winning_touch_type\" IN ('referral', 'affiliate', 'none')" + }, + "impact_referral_conversions_payment_provider_check": { + "name": "impact_referral_conversions_payment_provider_check", + "value": "\"impact_referral_conversions\".\"payment_provider\" IN ('stripe', 'credits', 'app_store', 'google_play')" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_reward_applications": { + "name": "impact_referral_reward_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "reward_id": { + "name": "reward_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "previous_renewal_boundary": { + "name": "previous_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "new_renewal_boundary": { + "name": "new_renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "local_operation_id": { + "name": "local_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_operation_id": { + "name": "stripe_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_idempotency_key": { + "name": "stripe_idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_reward_applications_reward_id": { + "name": "IDX_impact_referral_reward_applications_reward_id", + "columns": [ + { + "expression": "reward_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referral_reward_applications_beneficiary_user_id": { + "name": "IDX_impact_referral_reward_applications_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_reward_applications_reward_id_impact_referral_rewards_id_fk": { + "name": "impact_referral_reward_applications_reward_id_impact_referral_rewards_id_fk", + "tableFrom": "impact_referral_reward_applications", + "tableTo": "impact_referral_rewards", + "columnsFrom": [ + "reward_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_referral_reward_applications_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_reward_applications", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "impact_referral_reward_applications_product_check": { + "name": "impact_referral_reward_applications_product_check", + "value": "\"impact_referral_reward_applications\".\"product\" IN ('kiloclaw', 'kilo_pass')" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_reward_decisions": { + "name": "impact_referral_reward_decisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_kind": { + "name": "reward_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw_free_month'" + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reward_percent": { + "name": "reward_percent", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "source_tier": { + "name": "source_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount_usd": { + "name": "reward_amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_reward_decisions_beneficiary_user_id": { + "name": "IDX_impact_referral_reward_decisions_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_reward_decisions_conversion_id_impact_referral_conversions_id_fk": { + "name": "impact_referral_reward_decisions_conversion_id_impact_referral_conversions_id_fk", + "tableFrom": "impact_referral_reward_decisions", + "tableTo": "impact_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_referral_reward_decisions_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_reward_decisions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referral_reward_decisions_conversion_role": { + "name": "UQ_impact_referral_reward_decisions_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referral_reward_decisions_product_check": { + "name": "impact_referral_reward_decisions_product_check", + "value": "\"impact_referral_reward_decisions\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_referral_reward_decisions_beneficiary_role_check": { + "name": "impact_referral_reward_decisions_beneficiary_role_check", + "value": "\"impact_referral_reward_decisions\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "impact_referral_reward_decisions_outcome_check": { + "name": "impact_referral_reward_decisions_outcome_check", + "value": "\"impact_referral_reward_decisions\".\"outcome\" IN ('granted', 'cap_limited', 'disqualified')" + }, + "impact_referral_reward_decisions_reward_kind_check": { + "name": "impact_referral_reward_decisions_reward_kind_check", + "value": "\"impact_referral_reward_decisions\".\"reward_kind\" IN ('kiloclaw_free_month', 'kilo_pass_bonus')" + }, + "impact_referral_reward_decisions_months_granted_non_negative_check": { + "name": "impact_referral_reward_decisions_months_granted_non_negative_check", + "value": "\"impact_referral_reward_decisions\".\"months_granted\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_referral_rewards": { + "name": "impact_referral_rewards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "conversion_id": { + "name": "conversion_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "decision_id": { + "name": "decision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "beneficiary_user_id": { + "name": "beneficiary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "beneficiary_role": { + "name": "beneficiary_role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reward_kind": { + "name": "reward_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw_free_month'" + }, + "months_granted": { + "name": "months_granted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "reward_percent": { + "name": "reward_percent", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "source_tier": { + "name": "source_tier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reward_amount_usd": { + "name": "reward_amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "applies_to_subscription_id": { + "name": "applies_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "applies_to_kilo_pass_subscription_id": { + "name": "applies_to_kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "consumed_kilo_pass_issuance_id": { + "name": "consumed_kilo_pass_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "consumed_kilo_pass_issuance_item_id": { + "name": "consumed_kilo_pass_issuance_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "earned_at": { + "name": "earned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "reversed_at": { + "name": "reversed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_reason": { + "name": "review_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referral_rewards_beneficiary_user_id": { + "name": "IDX_impact_referral_rewards_beneficiary_user_id", + "columns": [ + { + "expression": "beneficiary_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referral_rewards_status": { + "name": "IDX_impact_referral_rewards_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referral_rewards_conversion_id_impact_referral_conversions_id_fk": { + "name": "impact_referral_rewards_conversion_id_impact_referral_conversions_id_fk", + "tableFrom": "impact_referral_rewards", + "tableTo": "impact_referral_conversions", + "columnsFrom": [ + "conversion_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_rewards_decision_id_impact_referral_reward_decisions_id_fk": { + "name": "impact_referral_rewards_decision_id_impact_referral_reward_decisions_id_fk", + "tableFrom": "impact_referral_rewards", + "tableTo": "impact_referral_reward_decisions", + "columnsFrom": [ + "decision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referral_rewards_beneficiary_user_id_kilocode_users_id_fk": { + "name": "impact_referral_rewards_beneficiary_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilocode_users", + "columnsFrom": [ + "beneficiary_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "FK_impact_referral_rewards_kilo_pass_subscription": { + "name": "FK_impact_referral_rewards_kilo_pass_subscription", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "applies_to_kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "FK_impact_referral_rewards_kilo_pass_issuance": { + "name": "FK_impact_referral_rewards_kilo_pass_issuance", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "consumed_kilo_pass_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "FK_impact_referral_rewards_kilo_pass_issuance_item": { + "name": "FK_impact_referral_rewards_kilo_pass_issuance_item", + "tableFrom": "impact_referral_rewards", + "tableTo": "kilo_pass_issuance_items", + "columnsFrom": [ + "consumed_kilo_pass_issuance_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referral_rewards_conversion_role": { + "name": "UQ_impact_referral_rewards_conversion_role", + "nullsNotDistinct": false, + "columns": [ + "conversion_id", + "beneficiary_role" + ] + }, + "UQ_impact_referral_rewards_decision_id": { + "name": "UQ_impact_referral_rewards_decision_id", + "nullsNotDistinct": false, + "columns": [ + "decision_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referral_rewards_product_check": { + "name": "impact_referral_rewards_product_check", + "value": "\"impact_referral_rewards\".\"product\" IN ('kiloclaw', 'kilo_pass')" + }, + "impact_referral_rewards_beneficiary_role_check": { + "name": "impact_referral_rewards_beneficiary_role_check", + "value": "\"impact_referral_rewards\".\"beneficiary_role\" IN ('referrer', 'referee')" + }, + "impact_referral_rewards_reward_kind_check": { + "name": "impact_referral_rewards_reward_kind_check", + "value": "\"impact_referral_rewards\".\"reward_kind\" IN ('kiloclaw_free_month', 'kilo_pass_bonus')" + }, + "impact_referral_rewards_status_check": { + "name": "impact_referral_rewards_status_check", + "value": "\"impact_referral_rewards\".\"status\" IN ('pending', 'earned', 'applied', 'reversed', 'expired', 'canceled', 'review_required')" + }, + "impact_referral_rewards_months_granted_non_negative_check": { + "name": "impact_referral_rewards_months_granted_non_negative_check", + "value": "\"impact_referral_rewards\".\"months_granted\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.impact_referrals": { + "name": "impact_referrals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "product": { + "name": "product", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kiloclaw'" + }, + "referee_user_id": { + "name": "referee_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_touch_id": { + "name": "source_touch_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "impact_referral_id": { + "name": "impact_referral_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_impact_referrals_referrer_user_id": { + "name": "IDX_impact_referrals_referrer_user_id", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_impact_referrals_source_touch_id": { + "name": "IDX_impact_referrals_source_touch_id", + "columns": [ + { + "expression": "source_touch_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "impact_referrals_referee_user_id_kilocode_users_id_fk": { + "name": "impact_referrals_referee_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referee_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "impact_referrals_referrer_user_id_kilocode_users_id_fk": { + "name": "impact_referrals_referrer_user_id_kilocode_users_id_fk", + "tableFrom": "impact_referrals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "impact_referrals_source_touch_id_impact_attribution_touches_id_fk": { + "name": "impact_referrals_source_touch_id_impact_attribution_touches_id_fk", + "tableFrom": "impact_referrals", + "tableTo": "impact_attribution_touches", + "columnsFrom": [ + "source_touch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_impact_referrals_product_referee_user_id": { + "name": "UQ_impact_referrals_product_referee_user_id", + "nullsNotDistinct": false, + "columns": [ + "product", + "referee_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "impact_referrals_product_check": { + "name": "impact_referrals_product_check", + "value": "\"impact_referrals\".\"product\" IN ('kiloclaw', 'kilo_pass')" + } + }, + "isRLSEnabled": false + }, + "public.ja4_digest": { + "name": "ja4_digest", + "schema": "", + "columns": { + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "ja4_digest": { + "name": "ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_ja4_digest": { + "name": "UQ_ja4_digest", + "columns": [ + { + "expression": "ja4_digest", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilo_pass_audit_log": { + "name": "kilo_pass_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "related_credit_transaction_id": { + "name": "related_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "related_monthly_issuance_id": { + "name": "related_monthly_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_kilo_pass_audit_log_created_at": { + "name": "IDX_kilo_pass_audit_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_user_id": { + "name": "IDX_kilo_pass_audit_log_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_kilo_pass_subscription_id": { + "name": "IDX_kilo_pass_audit_log_kilo_pass_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_action": { + "name": "IDX_kilo_pass_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_result": { + "name": "IDX_kilo_pass_audit_log_result", + "columns": [ + { + "expression": "result", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_idempotency_key": { + "name": "IDX_kilo_pass_audit_log_idempotency_key", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_event_id": { + "name": "IDX_kilo_pass_audit_log_stripe_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_invoice_id": { + "name": "IDX_kilo_pass_audit_log_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_stripe_subscription_id": { + "name": "IDX_kilo_pass_audit_log_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_credit_transaction_id": { + "name": "IDX_kilo_pass_audit_log_related_credit_transaction_id", + "columns": [ + { + "expression": "related_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_audit_log_related_monthly_issuance_id": { + "name": "IDX_kilo_pass_audit_log_related_monthly_issuance_id", + "columns": [ + { + "expression": "related_monthly_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_audit_log_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_audit_log_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_audit_log_related_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "credit_transactions", + "columnsFrom": [ + "related_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_audit_log_related_monthly_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_audit_log", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "related_monthly_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_audit_log_action_check": { + "name": "kilo_pass_audit_log_action_check", + "value": "\"kilo_pass_audit_log\".\"action\" IN ('stripe_webhook_received', 'kilo_pass_invoice_paid_handled', 'store_purchase_completed', 'store_notification_received', 'store_subscription_renewed', 'store_subscription_canceled', 'store_subscription_expired', 'store_subscription_refunded', 'base_credits_issued', 'bonus_credits_issued', 'bonus_credits_skipped_idempotent', 'first_month_50pct_promo_issued', 'yearly_monthly_base_cron_started', 'yearly_monthly_base_cron_completed', 'issue_yearly_remaining_credits', 'duplicate_card_subscription_canceled', 'yearly_monthly_bonus_cron_started', 'yearly_monthly_bonus_cron_completed')" + }, + "kilo_pass_audit_log_result_check": { + "name": "kilo_pass_audit_log_result_check", + "value": "\"kilo_pass_audit_log\".\"result\" IN ('success', 'skipped_idempotent', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuance_items": { + "name": "kilo_pass_issuance_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_issuance_id": { + "name": "kilo_pass_issuance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric(12, 2)", + "primaryKey": false, + "notNull": true + }, + "bonus_percent_applied": { + "name": "bonus_percent_applied", + "type": "numeric(6, 4)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_issuance_items_issuance_id": { + "name": "IDX_kilo_pass_issuance_items_issuance_id", + "columns": [ + { + "expression": "kilo_pass_issuance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuance_items_credit_transaction_id": { + "name": "IDX_kilo_pass_issuance_items_credit_transaction_id", + "columns": [ + { + "expression": "credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk": { + "name": "kilo_pass_issuance_items_kilo_pass_issuance_id_kilo_pass_issuances_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "kilo_pass_issuances", + "columnsFrom": [ + "kilo_pass_issuance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_issuance_items_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_issuance_items", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_issuance_items_credit_transaction_id_unique": { + "name": "kilo_pass_issuance_items_credit_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "credit_transaction_id" + ] + }, + "UQ_kilo_pass_issuance_items_issuance_kind": { + "name": "UQ_kilo_pass_issuance_items_issuance_kind", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_issuance_id", + "kind" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuance_items_bonus_percent_applied_range_check": { + "name": "kilo_pass_issuance_items_bonus_percent_applied_range_check", + "value": "\"kilo_pass_issuance_items\".\"bonus_percent_applied\" IS NULL OR (\"kilo_pass_issuance_items\".\"bonus_percent_applied\" >= 0 AND \"kilo_pass_issuance_items\".\"bonus_percent_applied\" <= 1)" + }, + "kilo_pass_issuance_items_amount_usd_non_negative_check": { + "name": "kilo_pass_issuance_items_amount_usd_non_negative_check", + "value": "\"kilo_pass_issuance_items\".\"amount_usd\" >= 0" + }, + "kilo_pass_issuance_items_kind_check": { + "name": "kilo_pass_issuance_items_kind_check", + "value": "\"kilo_pass_issuance_items\".\"kind\" IN ('base', 'bonus', 'promo_first_month_50pct', 'referral_bonus')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_issuances": { + "name": "kilo_pass_issuances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "issue_month": { + "name": "issue_month", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "initial_welcome_promo_eligibility_reason": { + "name": "initial_welcome_promo_eligibility_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_issuances_stripe_invoice_id": { + "name": "UQ_kilo_pass_issuances_stripe_invoice_id", + "columns": [ + { + "expression": "stripe_invoice_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_issuances\".\"stripe_invoice_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_subscription_id": { + "name": "IDX_kilo_pass_issuances_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_issuances_issue_month": { + "name": "IDX_kilo_pass_issuances_issue_month", + "columns": [ + { + "expression": "issue_month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_issuances_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_issuances", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_issuances_subscription_issue_month": { + "name": "UQ_kilo_pass_issuances_subscription_issue_month", + "nullsNotDistinct": false, + "columns": [ + "kilo_pass_subscription_id", + "issue_month" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_issuances_issue_month_day_one_check": { + "name": "kilo_pass_issuances_issue_month_day_one_check", + "value": "EXTRACT(DAY FROM \"kilo_pass_issuances\".\"issue_month\") = 1" + }, + "kilo_pass_issuances_source_check": { + "name": "kilo_pass_issuances_source_check", + "value": "\"kilo_pass_issuances\".\"source\" IN ('stripe_invoice', 'app_store_transaction', 'google_play_transaction', 'cron')" + }, + "kilo_pass_issuances_initial_welcome_promo_reason_check": { + "name": "kilo_pass_issuances_initial_welcome_promo_reason_check", + "value": "\"kilo_pass_issuances\".\"initial_welcome_promo_eligibility_reason\" IN ('first_payment_fingerprint_claim', 'fingerprint_previously_claimed', 'missing_fingerprint', 'no_supported_fingerprint', 'no_positive_settlement', 'settlement_unresolved')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_agreements": { + "name": "kilo_pass_org_agreements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "parent_organization_id": { + "name": "parent_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "term_version_id": { + "name": "term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processing_condition": { + "name": "processing_condition", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "purchase_channel": { + "name": "purchase_channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchased_pass_capacity": { + "name": "purchased_pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "next_purchased_pass_capacity": { + "name": "next_purchased_pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_capacity_effective_at": { + "name": "next_capacity_effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_from": { + "name": "paid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_until": { + "name": "paid_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "issuance_anchor_at": { + "name": "issuance_anchor_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_seat_add_on_item_id": { + "name": "provider_seat_add_on_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activation_provider_event_id": { + "name": "activation_provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_contract_id": { + "name": "external_contract_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_review_required_at": { + "name": "payment_review_required_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancellation_effective_at": { + "name": "cancellation_effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "manually_issued_through": { + "name": "manually_issued_through", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_org_agreements_one_non_ended_parent": { + "name": "UQ_kilo_pass_org_agreements_one_non_ended_parent", + "columns": [ + { + "expression": "parent_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"state\" <> 'ended'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_provider_subscription": { + "name": "UQ_kilo_pass_org_agreements_provider_subscription", + "columns": [ + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"provider_subscription_id\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"state\" <> 'ended'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_provider_seat_add_on_item": { + "name": "UQ_kilo_pass_org_agreements_provider_seat_add_on_item", + "columns": [ + { + "expression": "provider_seat_add_on_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"provider_seat_add_on_item_id\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"state\" <> 'ended'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_external_contract": { + "name": "UQ_kilo_pass_org_agreements_external_contract", + "columns": [ + { + "expression": "external_contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"external_contract_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_agreements_activation_provider_event": { + "name": "UQ_kilo_pass_org_agreements_activation_provider_event", + "columns": [ + { + "expression": "activation_provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_agreements\".\"activation_provider_event_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_org_agreements_processing": { + "name": "IDX_kilo_pass_org_agreements_processing", + "columns": [ + { + "expression": "processing_condition", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_agreements_parent_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_agreements_parent_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_agreements", + "tableTo": "organizations", + "columnsFrom": [ + "parent_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_agreements_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_agreements_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_agreements", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_agreements_purchased_capacity_non_negative_check": { + "name": "kilo_pass_org_agreements_purchased_capacity_non_negative_check", + "value": "\"kilo_pass_org_agreements\".\"purchased_pass_capacity\" >= 0" + }, + "kilo_pass_org_agreements_next_capacity_check": { + "name": "kilo_pass_org_agreements_next_capacity_check", + "value": "(\"kilo_pass_org_agreements\".\"next_purchased_pass_capacity\" IS NULL AND \"kilo_pass_org_agreements\".\"next_capacity_effective_at\" IS NULL) OR (\"kilo_pass_org_agreements\".\"next_purchased_pass_capacity\" >= 0 AND \"kilo_pass_org_agreements\".\"next_capacity_effective_at\" IS NOT NULL)" + }, + "kilo_pass_org_agreements_paid_interval_check": { + "name": "kilo_pass_org_agreements_paid_interval_check", + "value": "(\"kilo_pass_org_agreements\".\"paid_from\" IS NULL AND \"kilo_pass_org_agreements\".\"paid_until\" IS NULL) OR (\"kilo_pass_org_agreements\".\"paid_from\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"paid_until\" IS NOT NULL AND \"kilo_pass_org_agreements\".\"paid_from\" < \"kilo_pass_org_agreements\".\"paid_until\")" + }, + "kilo_pass_org_agreements_state_check": { + "name": "kilo_pass_org_agreements_state_check", + "value": "\"kilo_pass_org_agreements\".\"state\" IN ('pending_payment', 'active', 'cancel_at_period_end', 'ended')" + }, + "kilo_pass_org_agreements_processing_condition_check": { + "name": "kilo_pass_org_agreements_processing_condition_check", + "value": "\"kilo_pass_org_agreements\".\"processing_condition\" IN ('ready', 'manual', 'blocked', 'overallocated', 'failed', 'suspended_for_review')" + }, + "kilo_pass_org_agreements_purchase_channel_check": { + "name": "kilo_pass_org_agreements_purchase_channel_check", + "value": "\"kilo_pass_org_agreements\".\"purchase_channel\" IN ('self_serve', 'manual')" + }, + "kilo_pass_org_agreements_cadence_check": { + "name": "kilo_pass_org_agreements_cadence_check", + "value": "\"kilo_pass_org_agreements\".\"cadence\" IN ('monthly', 'yearly')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_allocation_plan_rows": { + "name": "kilo_pass_org_allocation_plan_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "allocation_plan_id": { + "name": "allocation_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_container_organization_id": { + "name": "allocation_container_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pass_capacity": { + "name": "pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_allocation_plan_rows_positive_container": { + "name": "IDX_kilo_pass_org_allocation_plan_rows_positive_container", + "columns": [ + { + "expression": "allocation_container_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kilo_pass_org_allocation_plan_rows\".\"pass_capacity\" > 0", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_allocation_plan_rows_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk": { + "name": "kilo_pass_org_allocation_plan_rows_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk", + "tableFrom": "kilo_pass_org_allocation_plan_rows", + "tableTo": "kilo_pass_org_allocation_plans", + "columnsFrom": [ + "allocation_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_allocation_plan_rows_allocation_container_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_allocation_plan_rows_allocation_container_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_allocation_plan_rows", + "tableTo": "organizations", + "columnsFrom": [ + "allocation_container_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_allocation_plan_rows_plan_container": { + "name": "UQ_kilo_pass_org_allocation_plan_rows_plan_container", + "nullsNotDistinct": false, + "columns": [ + "allocation_plan_id", + "allocation_container_organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_allocation_plan_rows_capacity_non_negative_check": { + "name": "kilo_pass_org_allocation_plan_rows_capacity_non_negative_check", + "value": "\"kilo_pass_org_allocation_plan_rows\".\"pass_capacity\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_allocation_plans": { + "name": "kilo_pass_org_allocation_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_window_start": { + "name": "effective_window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_allocation_plans_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_allocation_plans_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_allocation_plans", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_allocation_plans_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_allocation_plans_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_allocation_plans", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_allocation_plans_agreement_window": { + "name": "UQ_kilo_pass_org_allocation_plans_agreement_window", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "effective_window_start" + ] + }, + "UQ_kilo_pass_org_allocation_plans_agreement_version": { + "name": "UQ_kilo_pass_org_allocation_plans_agreement_version", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_allocation_plans_version_positive_check": { + "name": "kilo_pass_org_allocation_plans_version_positive_check", + "value": "\"kilo_pass_org_allocation_plans\".\"version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_audit_records": { + "name": "kilo_pass_org_audit_records", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_kilo_user_id": { + "name": "actor_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_json": { + "name": "before_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_json": { + "name": "after_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_org_audit_records_idempotency": { + "name": "UQ_kilo_pass_org_audit_records_idempotency", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_audit_records\".\"idempotency_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_org_audit_records_agreement_created": { + "name": "IDX_kilo_pass_org_audit_records_agreement_created", + "columns": [ + { + "expression": "agreement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_audit_records_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_audit_records_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_audit_records", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_org_audit_records_actor_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_audit_records_actor_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_audit_records", + "tableTo": "kilocode_users", + "columnsFrom": [ + "actor_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilo_pass_org_issuance_snapshots": { + "name": "kilo_pass_org_issuance_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "processing_run_id": { + "name": "processing_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "allocation_plan_id": { + "name": "allocation_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "term_version_id": { + "name": "term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_container_organization_id": { + "name": "allocation_container_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "qualifying_spend_starts_at": { + "name": "qualifying_spend_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tranche_key": { + "name": "tranche_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allocated_pass_capacity": { + "name": "allocated_pass_capacity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "base_credit_microdollars": { + "name": "base_credit_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bonus_credit_microdollars": { + "name": "bonus_credit_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "unlock_spend_microdollars": { + "name": "unlock_spend_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "qualifying_spend_microdollars": { + "name": "qualifying_spend_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bonus_mode": { + "name": "bonus_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bonus_unlocked_at": { + "name": "bonus_unlocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "repair_completed_at": { + "name": "repair_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "bonus_credit_transaction_id": { + "name": "bonus_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "base_credit_transaction_id": { + "name": "base_credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_org_issuance_snapshots_base_credit_transaction": { + "name": "UQ_kilo_pass_org_issuance_snapshots_base_credit_transaction", + "columns": [ + { + "expression": "base_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_issuance_snapshots\".\"base_credit_transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_org_issuance_snapshots_bonus_credit_transaction": { + "name": "UQ_kilo_pass_org_issuance_snapshots_bonus_credit_transaction", + "columns": [ + { + "expression": "bonus_credit_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_org_issuance_snapshots\".\"bonus_credit_transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_org_issuance_snapshots_window": { + "name": "IDX_kilo_pass_org_issuance_snapshots_window", + "columns": [ + { + "expression": "agreement_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_issuance_snapshots_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_processing_run_id_kilo_pass_org_processing_runs_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_processing_run_id_kilo_pass_org_processing_runs_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_processing_runs", + "columnsFrom": [ + "processing_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_allocation_plan_id_kilo_pass_org_allocation_plans_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_allocation_plans", + "columnsFrom": [ + "allocation_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_allocation_container_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_allocation_container_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "organizations", + "columnsFrom": [ + "allocation_container_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_bonus_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_bonus_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "credit_transactions", + "columnsFrom": [ + "bonus_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_issuance_snapshots_base_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_org_issuance_snapshots_base_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_org_issuance_snapshots", + "tableTo": "credit_transactions", + "columnsFrom": [ + "base_credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_issuance_snapshots_container_window_tranche": { + "name": "UQ_kilo_pass_org_issuance_snapshots_container_window_tranche", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "allocation_container_organization_id", + "window_start", + "tranche_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_issuance_snapshots_window_check": { + "name": "kilo_pass_org_issuance_snapshots_window_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"window_start\" < \"kilo_pass_org_issuance_snapshots\".\"window_end\"" + }, + "kilo_pass_org_issuance_snapshots_qualifying_spend_window_check": { + "name": "kilo_pass_org_issuance_snapshots_qualifying_spend_window_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"window_start\" <= \"kilo_pass_org_issuance_snapshots\".\"qualifying_spend_starts_at\" AND \"kilo_pass_org_issuance_snapshots\".\"qualifying_spend_starts_at\" < \"kilo_pass_org_issuance_snapshots\".\"window_end\"" + }, + "kilo_pass_org_issuance_snapshots_values_non_negative_check": { + "name": "kilo_pass_org_issuance_snapshots_values_non_negative_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"allocated_pass_capacity\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"base_credit_microdollars\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"bonus_credit_microdollars\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"unlock_spend_microdollars\" >= 0 AND \"kilo_pass_org_issuance_snapshots\".\"qualifying_spend_microdollars\" >= 0" + }, + "kilo_pass_org_issuance_snapshots_kind_check": { + "name": "kilo_pass_org_issuance_snapshots_kind_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"kind\" IN ('regular', 'bridge', 'supplement')" + }, + "kilo_pass_org_issuance_snapshots_bonus_mode_check": { + "name": "kilo_pass_org_issuance_snapshots_bonus_mode_check", + "value": "\"kilo_pass_org_issuance_snapshots\".\"bonus_mode\" IN ('after_base', 'upfront')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_notification_deliveries": { + "name": "kilo_pass_org_notification_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "processing_run_id": { + "name": "processing_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_kilo_user_id": { + "name": "recipient_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_notification_deliveries_status": { + "name": "IDX_kilo_pass_org_notification_deliveries_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_notification_deliveries_processing_run_id_kilo_pass_org_processing_runs_id_fk": { + "name": "kilo_pass_org_notification_deliveries_processing_run_id_kilo_pass_org_processing_runs_id_fk", + "tableFrom": "kilo_pass_org_notification_deliveries", + "tableTo": "kilo_pass_org_processing_runs", + "columnsFrom": [ + "processing_run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_notification_deliveries_recipient_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_notification_deliveries_recipient_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_notification_deliveries", + "tableTo": "kilocode_users", + "columnsFrom": [ + "recipient_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_notification_deliveries_run_recipient": { + "name": "UQ_kilo_pass_org_notification_deliveries_run_recipient", + "nullsNotDistinct": false, + "columns": [ + "processing_run_id", + "recipient_kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_notification_deliveries_status_check": { + "name": "kilo_pass_org_notification_deliveries_status_check", + "value": "\"kilo_pass_org_notification_deliveries\".\"status\" IN ('pending', 'sending', 'sent', 'failed')" + }, + "kilo_pass_org_notification_deliveries_attempt_count_check": { + "name": "kilo_pass_org_notification_deliveries_attempt_count_check", + "value": "\"kilo_pass_org_notification_deliveries\".\"attempt_count\" >= 0" + }, + "kilo_pass_org_notification_deliveries_sent_check": { + "name": "kilo_pass_org_notification_deliveries_sent_check", + "value": "(\"kilo_pass_org_notification_deliveries\".\"status\" = 'sent' AND \"kilo_pass_org_notification_deliveries\".\"sent_at\" IS NOT NULL AND \"kilo_pass_org_notification_deliveries\".\"lease_expires_at\" IS NULL) OR (\"kilo_pass_org_notification_deliveries\".\"status\" = 'sending' AND \"kilo_pass_org_notification_deliveries\".\"sent_at\" IS NULL AND \"kilo_pass_org_notification_deliveries\".\"lease_expires_at\" IS NOT NULL) OR (\"kilo_pass_org_notification_deliveries\".\"status\" IN ('pending', 'failed') AND \"kilo_pass_org_notification_deliveries\".\"sent_at\" IS NULL AND \"kilo_pass_org_notification_deliveries\".\"lease_expires_at\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_processing_runs": { + "name": "kilo_pass_org_processing_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "window_end": { + "name": "window_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_processing_runs_state_lease": { + "name": "IDX_kilo_pass_org_processing_runs_state_lease", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_processing_runs_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_processing_runs_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_processing_runs", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_processing_runs_agreement_window": { + "name": "UQ_kilo_pass_org_processing_runs_agreement_window", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "window_start" + ] + }, + "UQ_kilo_pass_org_processing_runs_idempotency": { + "name": "UQ_kilo_pass_org_processing_runs_idempotency", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_processing_runs_window_check": { + "name": "kilo_pass_org_processing_runs_window_check", + "value": "\"kilo_pass_org_processing_runs\".\"window_start\" < \"kilo_pass_org_processing_runs\".\"window_end\"" + }, + "kilo_pass_org_processing_runs_attempt_count_non_negative_check": { + "name": "kilo_pass_org_processing_runs_attempt_count_non_negative_check", + "value": "\"kilo_pass_org_processing_runs\".\"attempt_count\" >= 0" + }, + "kilo_pass_org_processing_runs_state_check": { + "name": "kilo_pass_org_processing_runs_state_check", + "value": "\"kilo_pass_org_processing_runs\".\"state\" IN ('pending', 'running', 'succeeded', 'blocked', 'failed')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_qualifying_spend_events": { + "name": "kilo_pass_org_qualifying_spend_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "issuance_snapshot_id": { + "name": "issuance_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_container_organization_id": { + "name": "allocation_container_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "credit_transaction_id": { + "name": "credit_transaction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "spent_microdollars": { + "name": "spent_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_org_qualifying_spend_events_snapshot_occurred": { + "name": "IDX_kilo_pass_org_qualifying_spend_events_snapshot_occurred", + "columns": [ + { + "expression": "issuance_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_org_qualifying_spend_events_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk": { + "name": "kilo_pass_org_qualifying_spend_events_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk", + "tableFrom": "kilo_pass_org_qualifying_spend_events", + "tableTo": "kilo_pass_org_issuance_snapshots", + "columnsFrom": [ + "issuance_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_qualifying_spend_events_allocation_container_organization_id_organizations_id_fk": { + "name": "kilo_pass_org_qualifying_spend_events_allocation_container_organization_id_organizations_id_fk", + "tableFrom": "kilo_pass_org_qualifying_spend_events", + "tableTo": "organizations", + "columnsFrom": [ + "allocation_container_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_qualifying_spend_events_credit_transaction_id_credit_transactions_id_fk": { + "name": "kilo_pass_org_qualifying_spend_events_credit_transaction_id_credit_transactions_id_fk", + "tableFrom": "kilo_pass_org_qualifying_spend_events", + "tableTo": "credit_transactions", + "columnsFrom": [ + "credit_transaction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_qualifying_spend_events_snapshot_credit_transaction": { + "name": "UQ_kilo_pass_org_qualifying_spend_events_snapshot_credit_transaction", + "nullsNotDistinct": false, + "columns": [ + "issuance_snapshot_id", + "credit_transaction_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_qualifying_spend_events_amount_positive_check": { + "name": "kilo_pass_org_qualifying_spend_events_amount_positive_check", + "value": "\"kilo_pass_org_qualifying_spend_events\".\"spent_microdollars\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_supplements": { + "name": "kilo_pass_org_supplements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "issuance_snapshot_id": { + "name": "issuance_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider_invoice_line_id": { + "name": "provider_invoice_line_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remaining_service_numerator": { + "name": "remaining_service_numerator", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "remaining_service_denominator": { + "name": "remaining_service_denominator", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_supplements_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk": { + "name": "kilo_pass_org_supplements_issuance_snapshot_id_kilo_pass_org_issuance_snapshots_id_fk", + "tableFrom": "kilo_pass_org_supplements", + "tableTo": "kilo_pass_org_issuance_snapshots", + "columnsFrom": [ + "issuance_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_supplements_provider_invoice_line": { + "name": "UQ_kilo_pass_org_supplements_provider_invoice_line", + "nullsNotDistinct": false, + "columns": [ + "provider_invoice_line_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_supplements_ratio_check": { + "name": "kilo_pass_org_supplements_ratio_check", + "value": "\"kilo_pass_org_supplements\".\"remaining_service_numerator\" > 0 AND \"kilo_pass_org_supplements\".\"remaining_service_denominator\" > 0 AND \"kilo_pass_org_supplements\".\"remaining_service_numerator\" <= \"kilo_pass_org_supplements\".\"remaining_service_denominator\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_term_transitions": { + "name": "kilo_pass_org_term_transitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "agreement_id": { + "name": "agreement_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_term_version_id": { + "name": "from_term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "to_term_version_id": { + "name": "to_term_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_term_transitions_agreement_id_kilo_pass_org_agreements_id_fk": { + "name": "kilo_pass_org_term_transitions_agreement_id_kilo_pass_org_agreements_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilo_pass_org_agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_org_term_transitions_from_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_term_transitions_from_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "from_term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_term_transitions_to_term_version_id_kilo_pass_org_term_versions_id_fk": { + "name": "kilo_pass_org_term_transitions_to_term_version_id_kilo_pass_org_term_versions_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilo_pass_org_term_versions", + "columnsFrom": [ + "to_term_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "kilo_pass_org_term_transitions_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_term_transitions_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_term_transitions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_term_transitions_agreement_effective": { + "name": "UQ_kilo_pass_org_term_transitions_agreement_effective", + "nullsNotDistinct": false, + "columns": [ + "agreement_id", + "effective_at" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_term_transitions_changes_version_check": { + "name": "kilo_pass_org_term_transitions_changes_version_check", + "value": "\"kilo_pass_org_term_transitions\".\"from_term_version_id\" <> \"kilo_pass_org_term_transitions\".\"to_term_version_id\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_org_term_versions": { + "name": "kilo_pass_org_term_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "version_key": { + "name": "version_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_microdollars_per_pass": { + "name": "billing_price_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "base_credit_microdollars_per_pass": { + "name": "base_credit_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bonus_credit_microdollars_per_pass": { + "name": "bonus_credit_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "unlock_spend_microdollars_per_pass": { + "name": "unlock_spend_microdollars_per_pass", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "bonus_mode": { + "name": "bonus_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kilo_pass_org_term_versions_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_org_term_versions_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_org_term_versions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_kilo_pass_org_term_versions_version_key": { + "name": "UQ_kilo_pass_org_term_versions_version_key", + "nullsNotDistinct": false, + "columns": [ + "version_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_org_term_versions_amounts_non_negative_check": { + "name": "kilo_pass_org_term_versions_amounts_non_negative_check", + "value": "\"kilo_pass_org_term_versions\".\"billing_price_microdollars_per_pass\" >= 0 AND \"kilo_pass_org_term_versions\".\"base_credit_microdollars_per_pass\" >= 0 AND \"kilo_pass_org_term_versions\".\"bonus_credit_microdollars_per_pass\" >= 0 AND \"kilo_pass_org_term_versions\".\"unlock_spend_microdollars_per_pass\" >= 0" + }, + "kilo_pass_org_term_versions_tier_check": { + "name": "kilo_pass_org_term_versions_tier_check", + "value": "\"kilo_pass_org_term_versions\".\"tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_org_term_versions_cadence_check": { + "name": "kilo_pass_org_term_versions_cadence_check", + "value": "\"kilo_pass_org_term_versions\".\"cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_org_term_versions_bonus_mode_check": { + "name": "kilo_pass_org_term_versions_bonus_mode_check", + "value": "\"kilo_pass_org_term_versions\".\"bonus_mode\" IN ('after_base', 'upfront')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_pause_events": { + "name": "kilo_pass_pause_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "resumes_at": { + "name": "resumes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resumed_at": { + "name": "resumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_pause_events_subscription_id": { + "name": "IDX_kilo_pass_pause_events_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_pause_events_one_open_per_sub": { + "name": "UQ_kilo_pass_pause_events_one_open_per_sub", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_pause_events_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_pause_events", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_pause_events_resumed_at_after_paused_at_check": { + "name": "kilo_pass_pause_events_resumed_at_after_paused_at_check", + "value": "\"kilo_pass_pause_events\".\"resumed_at\" IS NULL OR \"kilo_pass_pause_events\".\"resumed_at\" >= \"kilo_pass_pause_events\".\"paused_at\"" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_scheduled_changes": { + "name": "kilo_pass_scheduled_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_tier": { + "name": "from_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_cadence": { + "name": "from_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_tier": { + "name": "to_tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_cadence": { + "name": "to_cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_scheduled_changes_kilo_user_id": { + "name": "IDX_kilo_pass_scheduled_changes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_status": { + "name": "IDX_kilo_pass_scheduled_changes_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_stripe_subscription_id": { + "name": "IDX_kilo_pass_scheduled_changes_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id": { + "name": "UQ_kilo_pass_scheduled_changes_active_stripe_subscription_id", + "columns": [ + { + "expression": "stripe_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_scheduled_changes\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_effective_at": { + "name": "IDX_kilo_pass_scheduled_changes_effective_at", + "columns": [ + { + "expression": "effective_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_scheduled_changes_deleted_at": { + "name": "IDX_kilo_pass_scheduled_changes_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_scheduled_changes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk": { + "name": "kilo_pass_scheduled_changes_stripe_subscription_id_kilo_pass_subscriptions_stripe_subscription_id_fk", + "tableFrom": "kilo_pass_scheduled_changes", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "stripe_subscription_id" + ], + "columnsTo": [ + "stripe_subscription_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_scheduled_changes_from_tier_check": { + "name": "kilo_pass_scheduled_changes_from_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_from_cadence_check": { + "name": "kilo_pass_scheduled_changes_from_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"from_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_to_tier_check": { + "name": "kilo_pass_scheduled_changes_to_tier_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_scheduled_changes_to_cadence_check": { + "name": "kilo_pass_scheduled_changes_to_cadence_check", + "value": "\"kilo_pass_scheduled_changes\".\"to_cadence\" IN ('monthly', 'yearly')" + }, + "kilo_pass_scheduled_changes_status_check": { + "name": "kilo_pass_scheduled_changes_status_check", + "value": "\"kilo_pass_scheduled_changes\".\"status\" IN ('not_started', 'active', 'completed', 'released', 'canceled')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_store_events": { + "name": "kilo_pass_store_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_transaction_id": { + "name": "provider_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_account_token": { + "name": "app_account_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_store_events_provider_event": { + "name": "UQ_kilo_pass_store_events_provider_event", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_events_provider_subscription": { + "name": "IDX_kilo_pass_store_events_provider_subscription", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_events_app_account_token": { + "name": "IDX_kilo_pass_store_events_app_account_token", + "columns": [ + { + "expression": "app_account_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_store_events_payment_provider_check": { + "name": "kilo_pass_store_events_payment_provider_check", + "value": "\"kilo_pass_store_events\".\"payment_provider\" IN ('stripe', 'app_store', 'google_play')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_store_purchases": { + "name": "kilo_pass_store_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_pass_subscription_id": { + "name": "kilo_pass_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_transaction_id": { + "name": "provider_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_original_transaction_id": { + "name": "provider_original_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "app_account_token": { + "name": "app_account_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "purchase_token": { + "name": "purchase_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "environment": { + "name": "environment", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_payload_json": { + "name": "raw_payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kilo_pass_store_purchases_provider_transaction": { + "name": "UQ_kilo_pass_store_purchases_provider_transaction", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_subscription_id": { + "name": "IDX_kilo_pass_store_purchases_subscription_id", + "columns": [ + { + "expression": "kilo_pass_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_user_id": { + "name": "IDX_kilo_pass_store_purchases_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_app_account_token": { + "name": "IDX_kilo_pass_store_purchases_app_account_token", + "columns": [ + { + "expression": "app_account_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_store_purchases_latest_subscription_purchase": { + "name": "IDX_kilo_pass_store_purchases_latest_subscription_purchase", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchased_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_store_purchases_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk": { + "name": "kilo_pass_store_purchases_kilo_pass_subscription_id_kilo_pass_subscriptions_id_fk", + "tableFrom": "kilo_pass_store_purchases", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "kilo_pass_store_purchases_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_store_purchases_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_store_purchases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "FK_kilo_pass_store_purchases_subscription_owner_provider": { + "name": "FK_kilo_pass_store_purchases_subscription_owner_provider", + "tableFrom": "kilo_pass_store_purchases", + "tableTo": "kilo_pass_subscriptions", + "columnsFrom": [ + "kilo_pass_subscription_id", + "kilo_user_id", + "payment_provider", + "provider_subscription_id" + ], + "columnsTo": [ + "id", + "kilo_user_id", + "payment_provider", + "provider_subscription_id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kilo_pass_store_purchases_store_provider_check": { + "name": "kilo_pass_store_purchases_store_provider_check", + "value": "\"kilo_pass_store_purchases\".\"payment_provider\" IN ('app_store', 'google_play')" + }, + "kilo_pass_store_purchases_payment_provider_check": { + "name": "kilo_pass_store_purchases_payment_provider_check", + "value": "\"kilo_pass_store_purchases\".\"payment_provider\" IN ('stripe', 'app_store', 'google_play')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_subscriptions": { + "name": "kilo_pass_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cadence": { + "name": "cadence", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_streak_months": { + "name": "current_streak_months", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_yearly_issue_at": { + "name": "next_yearly_issue_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kilo_pass_subscriptions_kilo_user_id": { + "name": "IDX_kilo_pass_subscriptions_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_payment_provider": { + "name": "IDX_kilo_pass_subscriptions_payment_provider", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_status": { + "name": "IDX_kilo_pass_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilo_pass_subscriptions_cadence": { + "name": "IDX_kilo_pass_subscriptions_cadence", + "columns": [ + { + "expression": "cadence", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_subscriptions_provider_subscription": { + "name": "UQ_kilo_pass_subscriptions_provider_subscription", + "columns": [ + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilo_pass_subscriptions\".\"provider_subscription_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilo_pass_subscriptions_store_purchase_reference": { + "name": "UQ_kilo_pass_subscriptions_store_purchase_reference", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payment_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk": { + "name": "kilo_pass_subscriptions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kilo_pass_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilo_pass_subscriptions_stripe_subscription_id_unique": { + "name": "kilo_pass_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_subscriptions_current_streak_months_non_negative_check": { + "name": "kilo_pass_subscriptions_current_streak_months_non_negative_check", + "value": "\"kilo_pass_subscriptions\".\"current_streak_months\" >= 0" + }, + "kilo_pass_subscriptions_provider_ids_check": { + "name": "kilo_pass_subscriptions_provider_ids_check", + "value": "(\n \"kilo_pass_subscriptions\".\"payment_provider\" = 'stripe'\n AND \"kilo_pass_subscriptions\".\"provider_subscription_id\" IS NOT NULL\n AND \"kilo_pass_subscriptions\".\"stripe_subscription_id\" IS NOT NULL\n AND \"kilo_pass_subscriptions\".\"provider_subscription_id\" = \"kilo_pass_subscriptions\".\"stripe_subscription_id\"\n ) OR (\n \"kilo_pass_subscriptions\".\"payment_provider\" IN ('app_store', 'google_play')\n AND \"kilo_pass_subscriptions\".\"provider_subscription_id\" IS NOT NULL\n AND \"kilo_pass_subscriptions\".\"stripe_subscription_id\" IS NULL\n )" + }, + "kilo_pass_subscriptions_payment_provider_check": { + "name": "kilo_pass_subscriptions_payment_provider_check", + "value": "\"kilo_pass_subscriptions\".\"payment_provider\" IN ('stripe', 'app_store', 'google_play')" + }, + "kilo_pass_subscriptions_tier_check": { + "name": "kilo_pass_subscriptions_tier_check", + "value": "\"kilo_pass_subscriptions\".\"tier\" IN ('tier_19', 'tier_49', 'tier_199')" + }, + "kilo_pass_subscriptions_cadence_check": { + "name": "kilo_pass_subscriptions_cadence_check", + "value": "\"kilo_pass_subscriptions\".\"cadence\" IN ('monthly', 'yearly')" + } + }, + "isRLSEnabled": false + }, + "public.kilo_pass_welcome_promo_payment_fingerprint_claims": { + "name": "kilo_pass_welcome_promo_payment_fingerprint_claims", + "schema": "", + "columns": { + "stripe_payment_method_type": { + "name": "stripe_payment_method_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_fingerprint": { + "name": "stripe_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_stripe_invoice_id": { + "name": "source_stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "kilo_pass_welcome_promo_payment_fingerprint_claims_stripe_payment_method_type_stripe_fingerprint_pk": { + "name": "kilo_pass_welcome_promo_payment_fingerprint_claims_stripe_payment_method_type_stripe_fingerprint_pk", + "columns": [ + "stripe_payment_method_type", + "stripe_fingerprint" + ] + } + }, + "uniqueConstraints": { + "UQ_kilo_pass_welcome_promo_payment_fingerprint_claims_source_invoice_id": { + "name": "UQ_kilo_pass_welcome_promo_payment_fingerprint_claims_source_invoice_id", + "nullsNotDistinct": false, + "columns": [ + "source_stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kilo_pass_welcome_promo_payment_fingerprint_claims_type_check": { + "name": "kilo_pass_welcome_promo_payment_fingerprint_claims_type_check", + "value": "\"kilo_pass_welcome_promo_payment_fingerprint_claims\".\"stripe_payment_method_type\" IN ('card', 'sepa_debit', 'us_bank_account', 'bacs_debit', 'au_becs_debit')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_access_codes": { + "name": "kiloclaw_access_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_access_codes_code": { + "name": "UQ_kiloclaw_access_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_access_codes_user_status": { + "name": "IDX_kiloclaw_access_codes_user_status", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_access_codes_one_active_per_user": { + "name": "UQ_kiloclaw_access_codes_one_active_per_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "status = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_access_codes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_access_codes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_admin_audit_logs": { + "name": "kiloclaw_admin_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_admin_audit_logs_target_user_id": { + "name": "IDX_kiloclaw_admin_audit_logs_target_user_id", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_action": { + "name": "IDX_kiloclaw_admin_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_admin_audit_logs_created_at": { + "name": "IDX_kiloclaw_admin_audit_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_cli_runs": { + "name": "kiloclaw_cli_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "initiated_by_admin_id": { + "name": "initiated_by_admin_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "exit_code": { + "name": "exit_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_cli_runs_user_id": { + "name": "IDX_kiloclaw_cli_runs_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_started_at": { + "name": "IDX_kiloclaw_cli_runs_started_at", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_cli_runs_instance_id": { + "name": "IDX_kiloclaw_cli_runs_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_cli_runs_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_cli_runs_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk": { + "name": "kiloclaw_cli_runs_initiated_by_admin_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_cli_runs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "initiated_by_admin_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_earlybird_purchases": { + "name": "kiloclaw_earlybird_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manual_payment_id": { + "name": "manual_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_earlybird_purchases_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_earlybird_purchases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_earlybird_purchases_user_id_unique": { + "name": "kiloclaw_earlybird_purchases_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "kiloclaw_earlybird_purchases_stripe_charge_id_unique": { + "name": "kiloclaw_earlybird_purchases_stripe_charge_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_charge_id" + ] + }, + "kiloclaw_earlybird_purchases_manual_payment_id_unique": { + "name": "kiloclaw_earlybird_purchases_manual_payment_id_unique", + "nullsNotDistinct": false, + "columns": [ + "manual_payment_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_email_log": { + "name": "kiloclaw_email_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "'epoch'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_email_log_user_type_global": { + "name": "UQ_kiloclaw_email_log_user_type_global", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_email_log_user_instance_type_period": { + "name": "UQ_kiloclaw_email_log_user_instance_type_period", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_email_log_type_sent_instance": { + "name": "IDX_kiloclaw_email_log_type_sent_instance", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_email_log\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_email_log_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_email_log_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_email_log", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_google_oauth_connections": { + "name": "kiloclaw_google_oauth_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'google'" + }, + "account_email": { + "name": "account_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_subject": { + "name": "account_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_secret_encrypted": { + "name": "oauth_client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_profile": { + "name": "credential_profile", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'kilo_owned'" + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "grants_by_source": { + "name": "grants_by_source", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "capabilities": { + "name": "capabilities", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_at": { + "name": "last_error_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_google_oauth_connections_instance": { + "name": "UQ_kiloclaw_google_oauth_connections_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_status": { + "name": "IDX_kiloclaw_google_oauth_connections_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_google_oauth_connections_provider": { + "name": "IDX_kiloclaw_google_oauth_connections_provider", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_google_oauth_connections_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_google_oauth_connections", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_google_oauth_connections_status_check": { + "name": "kiloclaw_google_oauth_connections_status_check", + "value": "\"kiloclaw_google_oauth_connections\".\"status\" IN ('active', 'action_required', 'disconnected')" + }, + "kiloclaw_google_oauth_connections_credential_profile_check": { + "name": "kiloclaw_google_oauth_connections_credential_profile_check", + "value": "\"kiloclaw_google_oauth_connections\".\"credential_profile\" IN ('legacy', 'kilo_owned')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_image_catalog": { + "name": "kiloclaw_image_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_digest": { + "name": "image_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "rollout_percent": { + "name": "rollout_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_latest": { + "name": "is_latest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_kiloclaw_image_catalog_status": { + "name": "IDX_kiloclaw_image_catalog_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_image_catalog_variant": { + "name": "IDX_kiloclaw_image_catalog_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_latest_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_latest_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_image_catalog_one_candidate_per_variant": { + "name": "UQ_kiloclaw_image_catalog_one_candidate_per_variant", + "columns": [ + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_image_catalog\".\"is_latest\" = false AND \"kiloclaw_image_catalog\".\"rollout_percent\" > 0 AND \"kiloclaw_image_catalog\".\"status\" = 'available'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_image_catalog_image_tag_unique": { + "name": "kiloclaw_image_catalog_image_tag_unique", + "nullsNotDistinct": false, + "columns": [ + "image_tag" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_aliases": { + "name": "kiloclaw_inbound_email_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_inbound_email_aliases_instance_id": { + "name": "IDX_kiloclaw_inbound_email_aliases_instance_id", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_inbound_email_aliases_active_instance": { + "name": "UQ_kiloclaw_inbound_email_aliases_active_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_inbound_email_aliases\".\"retired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_inbound_email_aliases_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_inbound_email_aliases", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_inbound_email_reserved_aliases": { + "name": "kiloclaw_inbound_email_reserved_aliases", + "schema": "", + "columns": { + "alias": { + "name": "alias", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_instances": { + "name": "kiloclaw_instances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sandbox_id": { + "name": "sandbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'fly'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbound_email_enabled": { + "name": "inbound_email_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inactive_trial_stopped_at": { + "name": "inactive_trial_stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tracked_image_tag": { + "name": "tracked_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_type": { + "name": "instance_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "admin_size_override": { + "name": "admin_size_override", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_instances_active": { + "name": "UQ_kiloclaw_instances_active", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sandbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_personal_by_user": { + "name": "IDX_kiloclaw_instances_active_personal_by_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_org_by_user_org": { + "name": "IDX_kiloclaw_instances_active_org_by_user_org", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_active_org_by_org_created": { + "name": "IDX_kiloclaw_instances_active_org_by_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"organization_id\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_user_id_created_at": { + "name": "IDX_kiloclaw_instances_user_id_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_tracked_image_tag": { + "name": "IDX_kiloclaw_instances_tracked_image_tag", + "columns": [ + { + "expression": "tracked_image_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_instance_type": { + "name": "IDX_kiloclaw_instances_instance_type", + "columns": [ + { + "expression": "instance_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"destroyed_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_instances_admin_size_override": { + "name": "IDX_kiloclaw_instances_admin_size_override", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_instances\".\"admin_size_override\" IS NOT NULL AND \"kiloclaw_instances\".\"destroyed_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_instances_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_instances_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_instances_organization_id_organizations_id_fk": { + "name": "kiloclaw_instances_organization_id_organizations_id_fk", + "tableFrom": "kiloclaw_instances", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_kiloclaw_instances_instance_type": { + "name": "CHK_kiloclaw_instances_instance_type", + "value": "\"kiloclaw_instances\".\"instance_type\" IS NULL OR \"kiloclaw_instances\".\"instance_type\" IN ('perf-1-3', 'perf-4-8', 'perf-4-16', 'shared-2-3', 'shared-2-4', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_morning_briefing_configs": { + "name": "kiloclaw_morning_briefing_configs", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cron": { + "name": "cron", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0 7 * * *'" + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "interest_topics": { + "name": "interest_topics", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_morning_briefing_configs_enabled": { + "name": "IDX_kiloclaw_morning_briefing_configs_enabled", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_morning_briefing_configs\".\"enabled\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_morning_briefing_configs_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_morning_briefing_configs_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_morning_briefing_configs", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_notifications": { + "name": "kiloclaw_scheduled_action_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'notice'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel": { + "name": "UQ_kiloclaw_scheduled_action_notifications_target_kind_channel", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "channel", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_notifications_pending": { + "name": "IDX_kiloclaw_scheduled_action_notifications_pending", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_notifications\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk": { + "name": "kiloclaw_scheduled_action_notifications_target_id_kiloclaw_scheduled_action_targets_id_fk", + "tableFrom": "kiloclaw_scheduled_action_notifications", + "tableTo": "kiloclaw_scheduled_action_targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_stages": { + "name": "kiloclaw_scheduled_action_stages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_index": { + "name": "stage_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notice_sent_at": { + "name": "notice_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_stages_parent_index": { + "name": "UQ_kiloclaw_scheduled_action_stages_parent_index", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stage_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_stages_notice_due": { + "name": "IDX_kiloclaw_scheduled_action_stages_notice_due", + "columns": [ + { + "expression": "scheduled_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_stages\".\"notice_sent_at\" IS NULL AND \"kiloclaw_scheduled_action_stages\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_stages_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_stages", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_action_targets": { + "name": "kiloclaw_scheduled_action_targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scheduled_action_id": { + "name": "scheduled_action_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stage_id": { + "name": "stage_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_image_tag": { + "name": "source_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "skip_reason": { + "name": "skip_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_kiloclaw_scheduled_action_targets_parent_instance": { + "name": "UQ_kiloclaw_scheduled_action_targets_parent_instance", + "columns": [ + { + "expression": "scheduled_action_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_stage": { + "name": "IDX_kiloclaw_scheduled_action_targets_stage", + "columns": [ + { + "expression": "stage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_action_targets_pending_by_instance": { + "name": "IDX_kiloclaw_scheduled_action_targets_pending_by_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_scheduled_action_targets\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk": { + "name": "kiloclaw_scheduled_action_targets_scheduled_action_id_kiloclaw_scheduled_actions_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_actions", + "columnsFrom": [ + "scheduled_action_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk": { + "name": "kiloclaw_scheduled_action_targets_stage_id_kiloclaw_scheduled_action_stages_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_scheduled_action_stages", + "columnsFrom": [ + "stage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_scheduled_action_targets_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_action_targets_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_action_targets", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_scheduled_actions": { + "name": "kiloclaw_scheduled_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_image_tag": { + "name": "target_image_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "override_pins": { + "name": "override_pins", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notice_lead_hours": { + "name": "notice_lead_hours", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 24 + }, + "notice_subject": { + "name": "notice_subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "notice_body": { + "name": "notice_body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "applied_count": { + "name": "applied_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "skipped_count": { + "name": "skipped_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "IDX_kiloclaw_scheduled_actions_status": { + "name": "IDX_kiloclaw_scheduled_actions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_action_type": { + "name": "IDX_kiloclaw_scheduled_actions_action_type", + "columns": [ + { + "expression": "action_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_scheduled_actions_created_by": { + "name": "IDX_kiloclaw_scheduled_actions_created_by", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_scheduled_actions_target_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "target_image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk": { + "name": "kiloclaw_scheduled_actions_created_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_scheduled_actions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kiloclaw_subscription_change_log": { + "name": "kiloclaw_subscription_change_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_kiloclaw_subscription_change_log_subscription_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_subscription_created_at", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscription_change_log_created_at": { + "name": "IDX_kiloclaw_subscription_change_log_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscription_change_log_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscription_change_log", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscription_change_log_actor_type_check": { + "name": "kiloclaw_subscription_change_log_actor_type_check", + "value": "\"kiloclaw_subscription_change_log\".\"actor_type\" IN ('user', 'system')" + }, + "kiloclaw_subscription_change_log_action_check": { + "name": "kiloclaw_subscription_change_log_action_check", + "value": "\"kiloclaw_subscription_change_log\".\"action\" IN ('created', 'status_changed', 'plan_switched', 'period_advanced', 'canceled', 'reactivated', 'suspended', 'destruction_scheduled', 'reassigned', 'backfilled', 'payment_source_changed', 'schedule_changed', 'admin_override')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_subscriptions": { + "name": "kiloclaw_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transferred_to_subscription_id": { + "name": "transferred_to_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "access_origin": { + "name": "access_origin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_source": { + "name": "payment_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kiloclaw_price_version": { + "name": "kiloclaw_price_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scheduled_plan": { + "name": "scheduled_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_by": { + "name": "scheduled_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pending_conversion": { + "name": "pending_conversion", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trial_started_at": { + "name": "trial_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credit_renewal_at": { + "name": "credit_renewal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "commit_ends_at": { + "name": "commit_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "past_due_since": { + "name": "past_due_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "destruction_deadline": { + "name": "destruction_deadline", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_requested_at": { + "name": "auto_resume_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_retry_after": { + "name": "auto_resume_retry_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auto_resume_attempt_count": { + "name": "auto_resume_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "auto_top_up_triggered_for_period": { + "name": "auto_top_up_triggered_for_period", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_kiloclaw_subscriptions_status": { + "name": "IDX_kiloclaw_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_id": { + "name": "IDX_kiloclaw_subscriptions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_user_status": { + "name": "IDX_kiloclaw_subscriptions_user_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_price_version": { + "name": "IDX_kiloclaw_subscriptions_price_version", + "columns": [ + { + "expression": "kiloclaw_price_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_transferred_to": { + "name": "IDX_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_stripe_schedule_id": { + "name": "IDX_kiloclaw_subscriptions_stripe_schedule_id", + "columns": [ + { + "expression": "stripe_schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_auto_resume_retry_after": { + "name": "IDX_kiloclaw_subscriptions_auto_resume_retry_after", + "columns": [ + { + "expression": "auto_resume_retry_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_instance": { + "name": "UQ_kiloclaw_subscriptions_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"instance_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kiloclaw_subscriptions_transferred_to": { + "name": "UQ_kiloclaw_subscriptions_transferred_to", + "columns": [ + { + "expression": "transferred_to_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kiloclaw_subscriptions\".\"transferred_to_subscription_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_subscriptions_earlybird_origin": { + "name": "IDX_kiloclaw_subscriptions_earlybird_origin", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "access_origin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_subscriptions\".\"access_origin\" = 'earlybird'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_subscriptions_user_id_kilocode_users_id_fk": { + "name": "kiloclaw_subscriptions_user_id_kilocode_users_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_subscriptions_transferred_to_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "transferred_to_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_subscriptions_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_subscriptions", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_subscriptions_stripe_subscription_id_unique": { + "name": "kiloclaw_subscriptions_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "kiloclaw_subscriptions_price_version_check": { + "name": "kiloclaw_subscriptions_price_version_check", + "value": "\"kiloclaw_subscriptions\".\"kiloclaw_price_version\" IN ('2026-03-19', '2026-05-10')" + }, + "kiloclaw_subscriptions_plan_check": { + "name": "kiloclaw_subscriptions_plan_check", + "value": "\"kiloclaw_subscriptions\".\"plan\" IN ('trial', 'commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_plan_check": { + "name": "kiloclaw_subscriptions_scheduled_plan_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_plan\" IN ('commit', 'standard')" + }, + "kiloclaw_subscriptions_scheduled_by_check": { + "name": "kiloclaw_subscriptions_scheduled_by_check", + "value": "\"kiloclaw_subscriptions\".\"scheduled_by\" IN ('auto', 'user')" + }, + "kiloclaw_subscriptions_status_check": { + "name": "kiloclaw_subscriptions_status_check", + "value": "\"kiloclaw_subscriptions\".\"status\" IN ('trialing', 'active', 'past_due', 'canceled', 'unpaid')" + }, + "kiloclaw_subscriptions_access_origin_check": { + "name": "kiloclaw_subscriptions_access_origin_check", + "value": "\"kiloclaw_subscriptions\".\"access_origin\" IN ('earlybird')" + }, + "kiloclaw_subscriptions_payment_source_check": { + "name": "kiloclaw_subscriptions_payment_source_check", + "value": "\"kiloclaw_subscriptions\".\"payment_source\" IN ('stripe', 'credits')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_terminal_renewal_failures": { + "name": "kiloclaw_terminal_renewal_failures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "renewal_boundary": { + "name": "renewal_boundary", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unresolved'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_failure_at": { + "name": "first_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_failure_at": { + "name": "last_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_failure_code": { + "name": "last_failure_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_failure_message": { + "name": "last_failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_actor_type": { + "name": "resolution_actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_actor_id": { + "name": "resolution_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resolution_at": { + "name": "resolution_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "resolution_reason": { + "name": "resolution_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_kiloclaw_terminal_renewal_failures_subscription_boundary": { + "name": "UQ_kiloclaw_terminal_renewal_failures_subscription_boundary", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "renewal_boundary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_terminal_renewal_failures_unresolved": { + "name": "IDX_kiloclaw_terminal_renewal_failures_unresolved", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "renewal_boundary", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"kiloclaw_terminal_renewal_failures\".\"status\" = 'unresolved'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kiloclaw_terminal_renewal_failures_status_last_failure_at": { + "name": "IDX_kiloclaw_terminal_renewal_failures_status_last_failure_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_failure_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "kiloclaw_terminal_renewal_failures_subscription_id_kiloclaw_subscriptions_id_fk": { + "name": "kiloclaw_terminal_renewal_failures_subscription_id_kiloclaw_subscriptions_id_fk", + "tableFrom": "kiloclaw_terminal_renewal_failures", + "tableTo": "kiloclaw_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "kiloclaw_terminal_renewal_failures_status_check": { + "name": "kiloclaw_terminal_renewal_failures_status_check", + "value": "\"kiloclaw_terminal_renewal_failures\".\"status\" IN ('unresolved', 'resolved', 'waived', 'superseded')" + }, + "kiloclaw_terminal_renewal_failures_last_failure_code_check": { + "name": "kiloclaw_terminal_renewal_failures_last_failure_code_check", + "value": "\"kiloclaw_terminal_renewal_failures\".\"last_failure_code\" IN ('credit_balance_read_failed', 'renewal_transaction_failed', 'auto_top_up_marker_write_failed', 'worker_timeout', 'poison_payload', 'queue_delivery_exhausted')" + }, + "kiloclaw_terminal_renewal_failures_resolution_actor_type_check": { + "name": "kiloclaw_terminal_renewal_failures_resolution_actor_type_check", + "value": "\"kiloclaw_terminal_renewal_failures\".\"resolution_actor_type\" IN ('operator', 'system')" + } + }, + "isRLSEnabled": false + }, + "public.kiloclaw_version_pins": { + "name": "kiloclaw_version_pins", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image_tag": { + "name": "image_tag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pinned_by": { + "name": "pinned_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk": { + "name": "kiloclaw_version_pins_instance_id_kiloclaw_instances_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk": { + "name": "kiloclaw_version_pins_image_tag_kiloclaw_image_catalog_image_tag_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kiloclaw_image_catalog", + "columnsFrom": [ + "image_tag" + ], + "columnsTo": [ + "image_tag" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk": { + "name": "kiloclaw_version_pins_pinned_by_kilocode_users_id_fk", + "tableFrom": "kiloclaw_version_pins", + "tableTo": "kilocode_users", + "columnsFrom": [ + "pinned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kiloclaw_version_pins_instance_id_unique": { + "name": "kiloclaw_version_pins_instance_id_unique", + "nullsNotDistinct": false, + "columns": [ + "instance_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.kilocode_users": { + "name": "kilocode_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "google_user_email": { + "name": "google_user_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_name": { + "name": "google_user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "google_user_image_url": { + "name": "google_user_image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "kilo_pass_threshold": { + "name": "kilo_pass_threshold", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_store_account_token": { + "name": "app_store_account_token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_super_admin": { + "name": "is_super_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_view_sessions": { + "name": "can_view_sessions", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "can_manage_credits": { + "name": "can_manage_credits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "has_validation_stytch": { + "name": "has_validation_stytch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_validation_novel_card_with_hold": { + "name": "has_validation_novel_card_with_hold", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_at": { + "name": "blocked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_by_kilo_user_id": { + "name": "blocked_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_token_pepper": { + "name": "api_token_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "web_session_pepper": { + "name": "web_session_pepper", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_bot": { + "name": "is_bot", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "kiloclaw_early_access": { + "name": "kiloclaw_early_access", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cohorts": { + "name": "cohorts", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "completed_welcome_form": { + "name": "completed_welcome_form", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "linkedin_url": { + "name": "linkedin_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_url": { + "name": "github_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discord_server_membership_verified_at": { + "name": "discord_server_membership_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "openrouter_upstream_safety_identifier": { + "name": "openrouter_upstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "openrouter_downstream_safety_identifier": { + "name": "openrouter_downstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vercel_downstream_safety_identifier": { + "name": "vercel_downstream_safety_identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customer_source": { + "name": "customer_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signup_ip": { + "name": "signup_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_deletion_requested_at": { + "name": "account_deletion_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_domain": { + "name": "email_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_account_disabled": { + "name": "personal_account_disabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "IDX_kilocode_users_signup_ip_created_at": { + "name": "IDX_kilocode_users_signup_ip_created_at", + "columns": [ + { + "expression": "signup_ip", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_at": { + "name": "IDX_kilocode_users_blocked_at", + "columns": [ + { + "expression": "blocked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_blocked_by_kilo_user_id": { + "name": "IDX_kilocode_users_blocked_by_kilo_user_id", + "columns": [ + { + "expression": "blocked_by_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_openrouter_upstream_safety_identifier": { + "name": "UQ_kilocode_users_openrouter_upstream_safety_identifier", + "columns": [ + { + "expression": "openrouter_upstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"openrouter_upstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_openrouter_downstream_safety_identifier": { + "name": "UQ_kilocode_users_openrouter_downstream_safety_identifier", + "columns": [ + { + "expression": "openrouter_downstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"openrouter_downstream_safety_identifier\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "UQ_kilocode_users_vercel_downstream_safety_identifier": { + "name": "UQ_kilocode_users_vercel_downstream_safety_identifier", + "columns": [ + { + "expression": "vercel_downstream_safety_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"kilocode_users\".\"vercel_downstream_safety_identifier\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_normalized_email": { + "name": "IDX_kilocode_users_normalized_email", + "columns": [ + { + "expression": "normalized_email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_kilocode_users_email_domain": { + "name": "IDX_kilocode_users_email_domain", + "columns": [ + { + "expression": "email_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "kilocode_users_app_store_account_token_unique": { + "name": "kilocode_users_app_store_account_token_unique", + "nullsNotDistinct": false, + "columns": [ + "app_store_account_token" + ] + }, + "UQ_b1afacbcf43f2c7c4cb9f7e7faa": { + "name": "UQ_b1afacbcf43f2c7c4cb9f7e7faa", + "nullsNotDistinct": false, + "columns": [ + "google_user_email" + ] + } + }, + "policies": {}, + "checkConstraints": { + "blocked_reason_not_empty": { + "name": "blocked_reason_not_empty", + "value": "length(blocked_reason) > 0" + }, + "kilocode_users_is_super_admin_requires_admin_check": { + "name": "kilocode_users_is_super_admin_requires_admin_check", + "value": "NOT \"kilocode_users\".\"is_super_admin\" OR \"kilocode_users\".\"is_admin\"" + }, + "kilocode_users_can_view_sessions_requires_admin_check": { + "name": "kilocode_users_can_view_sessions_requires_admin_check", + "value": "NOT \"kilocode_users\".\"can_view_sessions\" OR \"kilocode_users\".\"is_admin\"" + }, + "kilocode_users_can_manage_credits_requires_admin_check": { + "name": "kilocode_users_can_manage_credits_requires_admin_check", + "value": "NOT \"kilocode_users\".\"can_manage_credits\" OR \"kilocode_users\".\"is_admin\"" + } + }, + "isRLSEnabled": false + }, + "public.magic_link_tokens": { + "name": "magic_link_tokens", + "schema": "", + "columns": { + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reserved_until": { + "name": "reserved_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'magic_link'" + }, + "challenge_id": { + "name": "challenge_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_magic_link_tokens_email": { + "name": "idx_magic_link_tokens_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_magic_link_tokens_expires_at": { + "name": "idx_magic_link_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_magic_link_tokens_challenge_id": { + "name": "UQ_magic_link_tokens_challenge_id", + "columns": [ + { + "expression": "challenge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"magic_link_tokens\".\"challenge_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_expires_at_future": { + "name": "check_expires_at_future", + "value": "\"magic_link_tokens\".\"expires_at\" > \"magic_link_tokens\".\"created_at\"" + }, + "check_magic_link_tokens_purpose": { + "name": "check_magic_link_tokens_purpose", + "value": "\"magic_link_tokens\".\"purpose\" IN ('magic_link', 'sign_in_code', 'data_export_download')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_assignments": { + "name": "mcp_gateway_assignments", + "schema": "", + "columns": { + "assignment_id": { + "name": "assignment_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_kilo_user_id": { + "name": "assigned_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "single_user_slot": { + "name": "single_user_slot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_assignments_active": { + "name": "UQ_mcp_gateway_assignments_active", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_assignments\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_mcp_gateway_assignments_single_user_slot": { + "name": "UQ_mcp_gateway_assignments_single_user_slot", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "single_user_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_assignments\".\"revoked_at\" is null and \"mcp_gateway_assignments\".\"single_user_slot\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_assignments_config": { + "name": "IDX_mcp_gateway_assignments_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_assignments_user": { + "name": "IDX_mcp_gateway_assignments_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_assignments_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_assignments_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_assignments", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_assignments_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_assignments_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_assignments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_assignments_assigned_by_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_assignments_assigned_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_assignments", + "tableTo": "kilocode_users", + "columnsFrom": [ + "assigned_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_gateway_audit_events": { + "name": "mcp_gateway_audit_events", + "schema": "", + "columns": { + "audit_event_id": { + "name": "audit_event_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "actor_kilo_user_id": { + "name": "actor_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "connect_resource_id": { + "name": "connect_resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "outcome": { + "name": "outcome", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "correlation_metadata": { + "name": "correlation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_mcp_gateway_audit_events_config": { + "name": "IDX_mcp_gateway_audit_events_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_audit_events_grant": { + "name": "IDX_mcp_gateway_audit_events_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_audit_events\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_audit_events_owner": { + "name": "IDX_mcp_gateway_audit_events_owner", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_audit_events_created_at": { + "name": "IDX_mcp_gateway_audit_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_audit_events_actor_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_audit_events_actor_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "actor_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_audit_events_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk": { + "name": "mcp_gateway_audit_events_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_connect_resources", + "columnsFrom": [ + "connect_resource_id" + ], + "columnsTo": [ + "connect_resource_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_audit_events_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_gateway_audit_events_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_audit_events_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_audit_events", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_audit_events_owner_scope": { + "name": "mcp_gateway_audit_events_owner_scope", + "value": "\"mcp_gateway_audit_events\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_audit_events_outcome": { + "name": "mcp_gateway_audit_events_outcome", + "value": "\"mcp_gateway_audit_events\".\"outcome\" IN ('success', 'failure', 'blocked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_authorization_codes": { + "name": "mcp_gateway_authorization_codes", + "schema": "", + "columns": { + "authorization_code_id": { + "name": "authorization_code_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorization_request_id": { + "name": "authorization_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_authorization_codes_code_hash": { + "name": "UQ_mcp_gateway_authorization_codes_code_hash", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_codes_expires_at": { + "name": "IDX_mcp_gateway_authorization_codes_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_codes_client": { + "name": "IDX_mcp_gateway_authorization_codes_client", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_codes_grant": { + "name": "IDX_mcp_gateway_authorization_codes_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_authorization_codes\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_authorization_codes_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk": { + "name": "mcp_gateway_authorization_codes_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_authorization_requests", + "columnsFrom": [ + "authorization_request_id" + ], + "columnsTo": [ + "authorization_request_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_authorization_codes_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_authorization_codes_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_authorization_codes_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_authorization_codes_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_codes_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_authorization_codes_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_authorization_codes", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_authorization_codes_owner_scope": { + "name": "mcp_gateway_authorization_codes_owner_scope", + "value": "\"mcp_gateway_authorization_codes\".\"owner_scope\" IN ('personal', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_authorization_requests": { + "name": "mcp_gateway_authorization_requests", + "schema": "", + "columns": { + "authorization_request_id": { + "name": "authorization_request_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "request_state_hash": { + "name": "request_state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_scopes": { + "name": "requested_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "oauth_state": { + "name": "oauth_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'S256'" + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "request_status": { + "name": "request_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_authorization_requests_state_hash": { + "name": "UQ_mcp_gateway_authorization_requests_state_hash", + "columns": [ + { + "expression": "request_state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_config": { + "name": "IDX_mcp_gateway_authorization_requests_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_grant": { + "name": "IDX_mcp_gateway_authorization_requests_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_authorization_requests\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_user": { + "name": "IDX_mcp_gateway_authorization_requests_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_authorization_requests_expires_at": { + "name": "IDX_mcp_gateway_authorization_requests_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_authorization_requests_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_authorization_requests_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_authorization_requests_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_authorization_requests_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_authorization_requests_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_authorization_requests_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_authorization_requests_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_authorization_requests", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_authorization_requests_owner_scope": { + "name": "mcp_gateway_authorization_requests_owner_scope", + "value": "\"mcp_gateway_authorization_requests\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_authorization_requests_status": { + "name": "mcp_gateway_authorization_requests_status", + "value": "\"mcp_gateway_authorization_requests\".\"request_status\" IN ('pending', 'completed', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_config_secrets": { + "name": "mcp_gateway_config_secrets", + "schema": "", + "columns": { + "config_secret_id": { + "name": "config_secret_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "secret_kind": { + "name": "secret_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_secret": { + "name": "encrypted_secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_version": { + "name": "secret_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_config_secrets_active_kind": { + "name": "UQ_mcp_gateway_config_secrets_active_kind", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "secret_kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_config_secrets\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_config_secrets_config": { + "name": "IDX_mcp_gateway_config_secrets_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_config_secrets_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_config_secrets_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_config_secrets", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_config_secrets_version_positive": { + "name": "mcp_gateway_config_secrets_version_positive", + "value": "\"mcp_gateway_config_secrets\".\"secret_version\" > 0" + }, + "mcp_gateway_config_secrets_kind": { + "name": "mcp_gateway_config_secrets_kind", + "value": "\"mcp_gateway_config_secrets\".\"secret_kind\" IN ('static_provider_credentials', 'dynamic_registration', 'static_headers')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_configs": { + "name": "mcp_gateway_configs", + "schema": "", + "columns": { + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_mode": { + "name": "auth_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sharing_mode": { + "name": "sharing_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_scopes": { + "name": "provider_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_scope_source": { + "name": "provider_scope_source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "provider_resource": { + "name": "provider_resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "path_passthrough": { + "name": "path_passthrough", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "discovered_provider_metadata": { + "name": "discovered_provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "registry_metadata": { + "name": "registry_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "auxiliary_headers": { + "name": "auxiliary_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_mcp_gateway_configs_owner": { + "name": "IDX_mcp_gateway_configs_owner", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_configs_enabled": { + "name": "IDX_mcp_gateway_configs_enabled", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_configs_remote_url": { + "name": "IDX_mcp_gateway_configs_remote_url", + "columns": [ + { + "expression": "remote_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_configs_created_by_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_configs_created_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_configs", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_configs_name_not_empty": { + "name": "mcp_gateway_configs_name_not_empty", + "value": "length(trim(\"mcp_gateway_configs\".\"name\")) > 0" + }, + "mcp_gateway_configs_config_version_positive": { + "name": "mcp_gateway_configs_config_version_positive", + "value": "\"mcp_gateway_configs\".\"config_version\" > 0" + }, + "mcp_gateway_configs_personal_single_user": { + "name": "mcp_gateway_configs_personal_single_user", + "value": "\"mcp_gateway_configs\".\"owner_scope\" <> 'personal' OR \"mcp_gateway_configs\".\"sharing_mode\" = 'single_user'" + }, + "mcp_gateway_configs_owner_scope": { + "name": "mcp_gateway_configs_owner_scope", + "value": "\"mcp_gateway_configs\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_configs_auth_mode": { + "name": "mcp_gateway_configs_auth_mode", + "value": "\"mcp_gateway_configs\".\"auth_mode\" IN ('none', 'static_headers', 'oauth_dynamic', 'oauth_static')" + }, + "mcp_gateway_configs_sharing_mode": { + "name": "mcp_gateway_configs_sharing_mode", + "value": "\"mcp_gateway_configs\".\"sharing_mode\" IN ('single_user', 'multi_user')" + }, + "mcp_gateway_configs_provider_scope_source": { + "name": "mcp_gateway_configs_provider_scope_source", + "value": "\"mcp_gateway_configs\".\"provider_scope_source\" IN ('none', 'discovered', 'override')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_connect_resources": { + "name": "mcp_gateway_connect_resources", + "schema": "", + "columns": { + "connect_resource_id": { + "name": "connect_resource_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_status": { + "name": "route_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "route_version": { + "name": "route_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_connect_resources_route_key": { + "name": "UQ_mcp_gateway_connect_resources_route_key", + "columns": [ + { + "expression": "route_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_mcp_gateway_connect_resources_active_config": { + "name": "UQ_mcp_gateway_connect_resources_active_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_connect_resources\".\"route_status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connect_resources_config": { + "name": "IDX_mcp_gateway_connect_resources_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connect_resources_canonical_url": { + "name": "IDX_mcp_gateway_connect_resources_canonical_url", + "columns": [ + { + "expression": "canonical_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_connect_resources_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_connect_resources_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_connect_resources", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_connect_resources_route_key_format": { + "name": "mcp_gateway_connect_resources_route_key_format", + "value": "\"mcp_gateway_connect_resources\".\"route_key\" ~ '^[A-Za-z0-9_-]{32,}$'" + }, + "mcp_gateway_connect_resources_route_version_positive": { + "name": "mcp_gateway_connect_resources_route_version_positive", + "value": "\"mcp_gateway_connect_resources\".\"route_version\" > 0" + }, + "mcp_gateway_connect_resources_owner_scope": { + "name": "mcp_gateway_connect_resources_owner_scope", + "value": "\"mcp_gateway_connect_resources\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_connect_resources_route_status": { + "name": "mcp_gateway_connect_resources_route_status", + "value": "\"mcp_gateway_connect_resources\".\"route_status\" IN ('active', 'rotated', 'revoked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_connection_instances": { + "name": "mcp_gateway_connection_instances", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_status": { + "name": "instance_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "instance_version": { + "name": "instance_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_connection_instances_non_terminal": { + "name": "UQ_mcp_gateway_connection_instances_non_terminal", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_connection_instances\".\"instance_status\" IN ('active', 'needs_reauth')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connection_instances_config": { + "name": "IDX_mcp_gateway_connection_instances_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_connection_instances_user": { + "name": "IDX_mcp_gateway_connection_instances_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_connection_instances_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_connection_instances_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_connection_instances", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_connection_instances_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_connection_instances_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_connection_instances", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_connection_instances_version_positive": { + "name": "mcp_gateway_connection_instances_version_positive", + "value": "\"mcp_gateway_connection_instances\".\"instance_version\" > 0" + }, + "mcp_gateway_connection_instances_owner_scope": { + "name": "mcp_gateway_connection_instances_owner_scope", + "value": "\"mcp_gateway_connection_instances\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_connection_instances_status": { + "name": "mcp_gateway_connection_instances_status", + "value": "\"mcp_gateway_connection_instances\".\"instance_status\" IN ('active', 'needs_reauth', 'revoked', 'removed')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_oauth_clients": { + "name": "mcp_gateway_oauth_clients", + "schema": "", + "columns": { + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_token_hash": { + "name": "registration_token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_hash": { + "name": "client_secret_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "declared_scopes": { + "name": "declared_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "registration_access_token_expires_at": { + "name": "registration_access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_oauth_clients_client_id": { + "name": "UQ_mcp_gateway_oauth_clients_client_id", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_mcp_gateway_oauth_clients_registration_token_hash": { + "name": "UQ_mcp_gateway_oauth_clients_registration_token_hash", + "columns": [ + { + "expression": "registration_token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_clients_deleted_at": { + "name": "IDX_mcp_gateway_oauth_clients_deleted_at", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_oauth_clients_client_id_format": { + "name": "mcp_gateway_oauth_clients_client_id_format", + "value": "\"mcp_gateway_oauth_clients\".\"client_id\" ~ '^[A-Za-z0-9._-]+:[A-Za-z0-9._-]+$'" + }, + "mcp_gateway_oauth_clients_auth_method": { + "name": "mcp_gateway_oauth_clients_auth_method", + "value": "\"mcp_gateway_oauth_clients\".\"token_endpoint_auth_method\" IN ('none', 'client_secret_post', 'client_secret_basic')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_oauth_grants": { + "name": "mcp_gateway_oauth_grants", + "schema": "", + "columns": { + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connect_resource_id": { + "name": "connect_resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grant_status": { + "name": "grant_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_oauth_grants_active_binding": { + "name": "UQ_mcp_gateway_oauth_grants_active_binding", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connect_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "redirect_uri", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_oauth_grants\".\"revoked_at\" is null and \"mcp_gateway_oauth_grants\".\"grant_status\" in ('pending', 'active')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_client": { + "name": "IDX_mcp_gateway_oauth_grants_client", + "columns": [ + { + "expression": "oauth_client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_user": { + "name": "IDX_mcp_gateway_oauth_grants_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_config": { + "name": "IDX_mcp_gateway_oauth_grants_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_owner": { + "name": "IDX_mcp_gateway_oauth_grants_owner", + "columns": [ + { + "expression": "owner_scope", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_resource": { + "name": "IDX_mcp_gateway_oauth_grants_resource", + "columns": [ + { + "expression": "connect_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_instance": { + "name": "IDX_mcp_gateway_oauth_grants_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_oauth_grants_revoked_at": { + "name": "IDX_mcp_gateway_oauth_grants_revoked_at", + "columns": [ + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_oauth_grants_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_oauth_grants_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_oauth_grants_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_oauth_grants_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk": { + "name": "mcp_gateway_oauth_grants_connect_resource_id_mcp_gateway_connect_resources_connect_resource_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_connect_resources", + "columnsFrom": [ + "connect_resource_id" + ], + "columnsTo": [ + "connect_resource_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_oauth_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_oauth_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_oauth_grants", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_oauth_grants_config_version_positive": { + "name": "mcp_gateway_oauth_grants_config_version_positive", + "value": "\"mcp_gateway_oauth_grants\".\"config_version\" > 0" + }, + "mcp_gateway_oauth_grants_owner_scope": { + "name": "mcp_gateway_oauth_grants_owner_scope", + "value": "\"mcp_gateway_oauth_grants\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_oauth_grants_status": { + "name": "mcp_gateway_oauth_grants_status", + "value": "\"mcp_gateway_oauth_grants\".\"grant_status\" IN ('pending', 'active', 'revoked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_pending_provider_authorizations": { + "name": "mcp_gateway_pending_provider_authorizations", + "schema": "", + "columns": { + "pending_provider_authorization_id": { + "name": "pending_provider_authorization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authorization_request_id": { + "name": "authorization_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth_mode": { + "name": "auth_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_authorization_endpoint": { + "name": "provider_authorization_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_token_endpoint": { + "name": "provider_token_endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_state": { + "name": "encrypted_state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "config_version": { + "name": "config_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "pending_status": { + "name": "pending_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_pending_provider_authorizations_state_hash": { + "name": "UQ_mcp_gateway_pending_provider_authorizations_state_hash", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_pending_provider_authorizations_config": { + "name": "IDX_mcp_gateway_pending_provider_authorizations_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_pending_provider_authorizations_grant": { + "name": "IDX_mcp_gateway_pending_provider_authorizations_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_pending_provider_authorizations\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_pending_provider_authorizations_expires_at": { + "name": "IDX_mcp_gateway_pending_provider_authorizations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_pending_provider_authorizations_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_authorization_request_id_mcp_gateway_authorization_requests_authorization_request_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_authorization_requests", + "columnsFrom": [ + "authorization_request_id" + ], + "columnsTo": [ + "authorization_request_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_pending_provider_authorizations_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_pending_provider_authorizations_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_pending_provider_authorizations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_pending_provider_authorizations_config_version_positive": { + "name": "mcp_gateway_pending_provider_authorizations_config_version_positive", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"config_version\" > 0" + }, + "mcp_gateway_pending_provider_authorizations_owner_scope": { + "name": "mcp_gateway_pending_provider_authorizations_owner_scope", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"owner_scope\" IN ('personal', 'organization')" + }, + "mcp_gateway_pending_provider_authorizations_auth_mode": { + "name": "mcp_gateway_pending_provider_authorizations_auth_mode", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"auth_mode\" IN ('none', 'static_headers', 'oauth_dynamic', 'oauth_static')" + }, + "mcp_gateway_pending_provider_authorizations_status": { + "name": "mcp_gateway_pending_provider_authorizations_status", + "value": "\"mcp_gateway_pending_provider_authorizations\".\"pending_status\" IN ('pending', 'completed', 'error')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_provider_grants": { + "name": "mcp_gateway_provider_grants", + "schema": "", + "columns": { + "provider_grant_id": { + "name": "provider_grant_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_grant": { + "name": "encrypted_grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subject": { + "name": "provider_subject", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_scope": { + "name": "grant_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grant_status": { + "name": "grant_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "grant_version": { + "name": "grant_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_provider_grants_active_instance": { + "name": "UQ_mcp_gateway_provider_grants_active_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"mcp_gateway_provider_grants\".\"grant_status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_provider_grants_instance": { + "name": "IDX_mcp_gateway_provider_grants_instance", + "columns": [ + { + "expression": "instance_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_provider_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_provider_grants_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_provider_grants", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_provider_grants_version_positive": { + "name": "mcp_gateway_provider_grants_version_positive", + "value": "\"mcp_gateway_provider_grants\".\"grant_version\" > 0" + }, + "mcp_gateway_provider_grants_status": { + "name": "mcp_gateway_provider_grants_status", + "value": "\"mcp_gateway_provider_grants\".\"grant_status\" IN ('active', 'revoked')" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_rate_limit_windows": { + "name": "mcp_gateway_rate_limit_windows", + "schema": "", + "columns": { + "rate_limit_window_id": { + "name": "rate_limit_window_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "ip_hash": { + "name": "ip_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_rate_limit_windows_ip_window": { + "name": "UQ_mcp_gateway_rate_limit_windows_ip_window", + "columns": [ + { + "expression": "ip_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "window_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_rate_limit_windows_window": { + "name": "IDX_mcp_gateway_rate_limit_windows_window", + "columns": [ + { + "expression": "window_started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_rate_limit_windows_attempt_count_non_negative": { + "name": "mcp_gateway_rate_limit_windows_attempt_count_non_negative", + "value": "\"mcp_gateway_rate_limit_windows\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.mcp_gateway_refresh_tokens": { + "name": "mcp_gateway_refresh_tokens", + "schema": "", + "columns": { + "refresh_token_id": { + "name": "refresh_token_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotated_from_refresh_token_id": { + "name": "rotated_from_refresh_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "oauth_grant_id": { + "name": "oauth_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_scope": { + "name": "owner_scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_key": { + "name": "route_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "canonical_resource_url": { + "name": "canonical_resource_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "execution_context": { + "name": "execution_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_id": { + "name": "instance_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_mcp_gateway_refresh_tokens_token_hash": { + "name": "UQ_mcp_gateway_refresh_tokens_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_user": { + "name": "IDX_mcp_gateway_refresh_tokens_user", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_grant": { + "name": "IDX_mcp_gateway_refresh_tokens_grant", + "columns": [ + { + "expression": "oauth_grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_gateway_refresh_tokens\".\"oauth_grant_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_config": { + "name": "IDX_mcp_gateway_refresh_tokens_config", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_mcp_gateway_refresh_tokens_consumed_at": { + "name": "IDX_mcp_gateway_refresh_tokens_consumed_at", + "columns": [ + { + "expression": "consumed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_gateway_refresh_tokens_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk": { + "name": "mcp_gateway_refresh_tokens_oauth_client_id_mcp_gateway_oauth_clients_oauth_client_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_oauth_clients", + "columnsFrom": [ + "oauth_client_id" + ], + "columnsTo": [ + "oauth_client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk": { + "name": "mcp_gateway_refresh_tokens_oauth_grant_id_mcp_gateway_oauth_grants_oauth_grant_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_oauth_grants", + "columnsFrom": [ + "oauth_grant_id" + ], + "columnsTo": [ + "oauth_grant_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_config_id_mcp_gateway_configs_config_id_fk": { + "name": "mcp_gateway_refresh_tokens_config_id_mcp_gateway_configs_config_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_configs", + "columnsFrom": [ + "config_id" + ], + "columnsTo": [ + "config_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_kilo_user_id_kilocode_users_id_fk": { + "name": "mcp_gateway_refresh_tokens_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_gateway_refresh_tokens_instance_id_mcp_gateway_connection_instances_instance_id_fk": { + "name": "mcp_gateway_refresh_tokens_instance_id_mcp_gateway_connection_instances_instance_id_fk", + "tableFrom": "mcp_gateway_refresh_tokens", + "tableTo": "mcp_gateway_connection_instances", + "columnsFrom": [ + "instance_id" + ], + "columnsTo": [ + "instance_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "mcp_gateway_refresh_tokens_owner_scope": { + "name": "mcp_gateway_refresh_tokens_owner_scope", + "value": "\"mcp_gateway_refresh_tokens\".\"owner_scope\" IN ('personal', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.microdollar_usage": { + "name": "microdollar_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_created_at": { + "name": "idx_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_abuse_classification": { + "name": "idx_abuse_classification", + "columns": [ + { + "expression": "abuse_classification", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id_created_at2": { + "name": "idx_kilo_user_id_created_at2", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_organization_id": { + "name": "idx_microdollar_usage_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"microdollar_usage\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microdollar_usage_daily": { + "name": "microdollar_usage_daily", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_microdollar_usage_daily_personal": { + "name": "idx_microdollar_usage_daily_personal", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"microdollar_usage_daily\".\"organization_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_daily_org": { + "name": "idx_microdollar_usage_daily_org", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"microdollar_usage_daily\".\"organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.microdollar_usage_daily_repairs": { + "name": "microdollar_usage_daily_repairs", + "schema": "", + "columns": { + "usage_id": { + "name": "usage_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_microdollar_usage_daily_repairs_claim": { + "name": "IDX_microdollar_usage_daily_repairs_claim", + "columns": [ + { + "expression": "attempt_count", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "usage_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microdollar_usage_daily_repairs_usage_id_microdollar_usage_id_fk": { + "name": "microdollar_usage_daily_repairs_usage_id_microdollar_usage_id_fk", + "tableFrom": "microdollar_usage_daily_repairs", + "tableTo": "microdollar_usage", + "columnsFrom": [ + "usage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "microdollar_usage_daily_repairs_attempt_count_check": { + "name": "microdollar_usage_daily_repairs_attempt_count_check", + "value": "\"microdollar_usage_daily_repairs\".\"attempt_count\" >= 0" + }, + "microdollar_usage_daily_repairs_claim_token_check": { + "name": "microdollar_usage_daily_repairs_claim_token_check", + "value": "(\"microdollar_usage_daily_repairs\".\"claimed_at\" IS NULL AND \"microdollar_usage_daily_repairs\".\"claim_token\" IS NULL) OR (\"microdollar_usage_daily_repairs\".\"claimed_at\" IS NOT NULL AND \"microdollar_usage_daily_repairs\".\"claim_token\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.microdollar_usage_metadata": { + "name": "microdollar_usage_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "http_user_agent_id": { + "name": "http_user_agent_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_ip_id": { + "name": "http_ip_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_latitude": { + "name": "vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "vercel_ip_longitude": { + "name": "vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "ja4_digest_id": { + "name": "ja4_digest_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason_id": { + "name": "finish_reason_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name_id": { + "name": "editor_name_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "api_kind_id": { + "name": "api_kind_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature_id": { + "name": "feature_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode_id": { + "name": "mode_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "auto_model_id": { + "name": "auto_model_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "abuse_delay": { + "name": "abuse_delay", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "abuse_downgraded_from": { + "name": "abuse_downgraded_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_microdollar_usage_metadata_created_at": { + "name": "idx_microdollar_usage_metadata_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_microdollar_usage_metadata_session_id": { + "name": "idx_microdollar_usage_metadata_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"microdollar_usage_metadata\".\"session_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk": { + "name": "microdollar_usage_metadata_http_user_agent_id_http_user_agent_http_user_agent_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_user_agent", + "columnsFrom": [ + "http_user_agent_id" + ], + "columnsTo": [ + "http_user_agent_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk": { + "name": "microdollar_usage_metadata_http_ip_id_http_ip_http_ip_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "http_ip", + "columnsFrom": [ + "http_ip_id" + ], + "columnsTo": [ + "http_ip_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_city_id_vercel_ip_city_vercel_ip_city_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_city", + "columnsFrom": [ + "vercel_ip_city_id" + ], + "columnsTo": [ + "vercel_ip_city_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk": { + "name": "microdollar_usage_metadata_vercel_ip_country_id_vercel_ip_country_vercel_ip_country_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "vercel_ip_country", + "columnsFrom": [ + "vercel_ip_country_id" + ], + "columnsTo": [ + "vercel_ip_country_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk": { + "name": "microdollar_usage_metadata_ja4_digest_id_ja4_digest_ja4_digest_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "ja4_digest", + "columnsFrom": [ + "ja4_digest_id" + ], + "columnsTo": [ + "ja4_digest_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk": { + "name": "microdollar_usage_metadata_system_prompt_prefix_id_system_prompt_prefix_system_prompt_prefix_id_fk", + "tableFrom": "microdollar_usage_metadata", + "tableTo": "system_prompt_prefix", + "columnsFrom": [ + "system_prompt_prefix_id" + ], + "columnsTo": [ + "system_prompt_prefix_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mode": { + "name": "mode", + "schema": "", + "columns": { + "mode_id": { + "name": "mode_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_mode": { + "name": "UQ_mode", + "columns": [ + { + "expression": "mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_stats": { + "name": "model_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_stealth": { + "name": "is_stealth", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_recommended": { + "name": "is_recommended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "openrouter_id": { + "name": "openrouter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aa_slug": { + "name": "aa_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_creator": { + "name": "model_creator", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_slug": { + "name": "creator_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "release_date": { + "name": "release_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "price_input": { + "name": "price_input", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "price_output": { + "name": "price_output", + "type": "numeric(10, 6)", + "primaryKey": false, + "notNull": false + }, + "coding_index": { + "name": "coding_index", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": false + }, + "speed_tokens_per_sec": { + "name": "speed_tokens_per_sec", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "context_length": { + "name": "context_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_modalities": { + "name": "input_modalities", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "openrouter_data": { + "name": "openrouter_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "benchmarks": { + "name": "benchmarks", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "chart_data": { + "name": "chart_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_stats_openrouter_id": { + "name": "IDX_model_stats_openrouter_id", + "columns": [ + { + "expression": "openrouter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_slug": { + "name": "IDX_model_stats_slug", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_is_active": { + "name": "IDX_model_stats_is_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_creator_slug": { + "name": "IDX_model_stats_creator_slug", + "columns": [ + { + "expression": "creator_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_price_input": { + "name": "IDX_model_stats_price_input", + "columns": [ + { + "expression": "price_input", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_coding_index": { + "name": "IDX_model_stats_coding_index", + "columns": [ + { + "expression": "coding_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_stats_context_length": { + "name": "IDX_model_stats_context_length", + "columns": [ + { + "expression": "context_length", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_stats_openrouter_id_unique": { + "name": "model_stats_openrouter_id_unique", + "nullsNotDistinct": false, + "columns": [ + "openrouter_id" + ] + }, + "model_stats_slug_unique": { + "name": "model_stats_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_eval_ingestions": { + "name": "model_eval_ingestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bench_eval_name": { + "name": "bench_eval_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bench_eval_url": { + "name": "bench_eval_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_stats_id": { + "name": "model_stats_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_source": { + "name": "task_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "n_total_trials": { + "name": "n_total_trials", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "n_attempts": { + "name": "n_attempts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_score": { + "name": "total_score", + "type": "numeric(14, 6)", + "primaryKey": false, + "notNull": true + }, + "overall_score": { + "name": "overall_score", + "type": "numeric(12, 8)", + "primaryKey": false, + "notNull": true + }, + "n_errored": { + "name": "n_errored", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "avg_cost_microdollars": { + "name": "avg_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "total_cost_microdollars": { + "name": "total_cost_microdollars", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_input_tokens": { + "name": "avg_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_input_tokens": { + "name": "total_input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_output_tokens": { + "name": "avg_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_output_tokens": { + "name": "total_output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_cache_read_tokens": { + "name": "avg_cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_cache_read_tokens": { + "name": "total_cache_read_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "avg_execution_ms": { + "name": "avg_execution_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "promoted_at": { + "name": "promoted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "promoted_by_email": { + "name": "promoted_by_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "promotion_note": { + "name": "promotion_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_eval_ingestions_lookup": { + "name": "IDX_model_eval_ingestions_lookup", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "variant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "promoted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_eval_ingestions_model_stats": { + "name": "IDX_model_eval_ingestions_model_stats", + "columns": [ + { + "expression": "model_stats_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_eval_ingestions_promoted_by_email_lower": { + "name": "IDX_model_eval_ingestions_promoted_by_email_lower", + "columns": [ + { + "expression": "LOWER(\"promoted_by_email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_eval_ingestions_model_stats_id_model_stats_id_fk": { + "name": "model_eval_ingestions_model_stats_id_model_stats_id_fk", + "tableFrom": "model_eval_ingestions", + "tableTo": "model_stats", + "columnsFrom": [ + "model_stats_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "model_eval_ingestions_bench_eval_name_unique": { + "name": "model_eval_ingestions_bench_eval_name_unique", + "nullsNotDistinct": false, + "columns": [ + "bench_eval_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.model_experiment": { + "name": "model_experiment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "public_model_id": { + "name": "public_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_model_experiment_public_model_id_routing": { + "name": "UQ_model_experiment_public_model_id_routing", + "columns": [ + { + "expression": "public_model_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"model_experiment\".\"status\" IN ('active', 'paused')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_experiment_status": { + "name": "IDX_model_experiment_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_created_by_user_id_kilocode_users_id_fk": { + "name": "model_experiment_created_by_user_id_kilocode_users_id_fk", + "tableFrom": "model_experiment", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_experiment_status_valid": { + "name": "model_experiment_status_valid", + "value": "\"model_experiment\".\"status\" IN ('draft', 'active', 'paused', 'completed')" + }, + "model_experiment_active_not_archived": { + "name": "model_experiment_active_not_archived", + "value": "\"model_experiment\".\"status\" <> 'active' OR \"model_experiment\".\"is_archived\" = false" + } + }, + "isRLSEnabled": false + }, + "public.model_experiment_request": { + "name": "model_experiment_request", + "schema": "", + "columns": { + "usage_id": { + "name": "usage_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "variant_version_id": { + "name": "variant_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "allocation_subject": { + "name": "allocation_subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_request_id": { + "name": "client_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "request_kind": { + "name": "request_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_body_sha256": { + "name": "request_body_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "was_truncated": { + "name": "was_truncated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_experiment_request_variant_version_created_at": { + "name": "IDX_model_experiment_request_variant_version_created_at", + "columns": [ + { + "expression": "variant_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_model_experiment_request_client_request_id": { + "name": "IDX_model_experiment_request_client_request_id", + "columns": [ + { + "expression": "client_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"model_experiment_request\".\"client_request_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_request_usage_id_microdollar_usage_id_fk": { + "name": "model_experiment_request_usage_id_microdollar_usage_id_fk", + "tableFrom": "model_experiment_request", + "tableTo": "microdollar_usage", + "columnsFrom": [ + "usage_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_experiment_request_variant_version_id_model_experiment_variant_version_id_fk": { + "name": "model_experiment_request_variant_version_id_model_experiment_variant_version_id_fk", + "tableFrom": "model_experiment_request", + "tableTo": "model_experiment_variant_version", + "columnsFrom": [ + "variant_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "model_experiment_request_usage_id_created_at_pk": { + "name": "model_experiment_request_usage_id_created_at_pk", + "columns": [ + "usage_id", + "created_at" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "model_experiment_request_allocation_subject_valid": { + "name": "model_experiment_request_allocation_subject_valid", + "value": "\"model_experiment_request\".\"allocation_subject\" IN ('user', 'machine', 'ip')" + }, + "model_experiment_request_request_kind_valid": { + "name": "model_experiment_request_request_kind_valid", + "value": "\"model_experiment_request\".\"request_kind\" IN ('chat_completions', 'messages', 'responses')" + }, + "model_experiment_request_request_body_sha256_format": { + "name": "model_experiment_request_request_body_sha256_format", + "value": "\"model_experiment_request\".\"request_body_sha256\" ~ '^[0-9a-f]{64}$' OR \"model_experiment_request\".\"request_body_sha256\" IN ('__failed__', '__deleted__')" + } + }, + "isRLSEnabled": false + }, + "public.model_experiment_variant": { + "name": "model_experiment_variant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "experiment_id": { + "name": "experiment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weight": { + "name": "weight", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_experiment_variant_experiment_id": { + "name": "IDX_model_experiment_variant_experiment_id", + "columns": [ + { + "expression": "experiment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_variant_experiment_id_model_experiment_id_fk": { + "name": "model_experiment_variant_experiment_id_model_experiment_id_fk", + "tableFrom": "model_experiment_variant", + "tableTo": "model_experiment", + "columnsFrom": [ + "experiment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_model_experiment_variant_experiment_label": { + "name": "UQ_model_experiment_variant_experiment_label", + "nullsNotDistinct": false, + "columns": [ + "experiment_id", + "label" + ] + } + }, + "policies": {}, + "checkConstraints": { + "model_experiment_variant_weight_positive": { + "name": "model_experiment_variant_weight_positive", + "value": "\"model_experiment_variant\".\"weight\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.model_experiment_variant_version": { + "name": "model_experiment_variant_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "variant_id": { + "name": "variant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "upstream": { + "name": "upstream", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_model_experiment_variant_version_variant_effective": { + "name": "IDX_model_experiment_variant_version_variant_effective", + "columns": [ + { + "expression": "variant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "effective_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "model_experiment_variant_version_variant_id_model_experiment_variant_id_fk": { + "name": "model_experiment_variant_version_variant_id_model_experiment_variant_id_fk", + "tableFrom": "model_experiment_variant_version", + "tableTo": "model_experiment_variant", + "columnsFrom": [ + "variant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "model_experiment_variant_version_created_by_kilocode_users_id_fk": { + "name": "model_experiment_variant_version_created_by_kilocode_users_id_fk", + "tableFrom": "model_experiment_variant_version", + "tableTo": "kilocode_users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.models_by_provider": { + "name": "models_by_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "openrouter": { + "name": "openrouter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "vercel": { + "name": "vercel", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.native_admission_challenges": { + "name": "native_admission_challenges", + "schema": "", + "columns": { + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_native_admission_challenges_expires_at": { + "name": "IDX_native_admission_challenges_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.native_attested_keys": { + "name": "native_attested_keys", + "schema": "", + "columns": { + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sign_count": { + "name": "sign_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attested_at": { + "name": "attested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_native_attested_keys_kilo_user_id": { + "name": "IDX_native_attested_keys_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "native_attested_keys_kilo_user_id_kilocode_users_id_fk": { + "name": "native_attested_keys_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "native_attested_keys", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "native_attested_keys_platform_check": { + "name": "native_attested_keys_platform_check", + "value": "\"native_attested_keys\".\"platform\" IN ('ios', 'android')" + } + }, + "isRLSEnabled": false + }, + "public.operation_ledgers": { + "name": "operation_ledgers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "operation_key": { + "name": "operation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "intent": { + "name": "intent", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_key": { + "name": "resource_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "taxonomy": { + "name": "taxonomy", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admitted'" + }, + "outcome_code": { + "name": "outcome_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_result": { + "name": "canonical_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "admitted_at": { + "name": "admitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "settled_at": { + "name": "settled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_operation_ledgers_kilo_user_id_domain_operation_key": { + "name": "UQ_operation_ledgers_kilo_user_id_domain_operation_key", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_operation_ledgers_status_expires_at": { + "name": "IDX_operation_ledgers_status_expires_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_operation_ledgers_provider_ref": { + "name": "IDX_operation_ledgers_provider_ref", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"operation_ledgers\".\"provider_ref\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_audit_logs": { + "name": "organization_audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_audit_logs_organization_id": { + "name": "IDX_organization_audit_logs_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_action": { + "name": "IDX_organization_audit_logs_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_actor_id": { + "name": "IDX_organization_audit_logs_actor_id", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_audit_logs_created_at": { + "name": "IDX_organization_audit_logs_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_domain_claims": { + "name": "organization_domain_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_domain_id": { + "name": "workos_domain_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_domain_claims_verified_domain": { + "name": "UQ_organization_domain_claims_verified_domain", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_domain_claims\".\"status\" = 'verified'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_organization_domain_claims_workos_domain_id": { + "name": "UQ_organization_domain_claims_workos_domain_id", + "columns": [ + { + "expression": "workos_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization_domain_claims\".\"workos_domain_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_domain_claims_organization_id": { + "name": "IDX_organization_domain_claims_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_domain_claims_organization_id_organizations_id_fk": { + "name": "organization_domain_claims_organization_id_organizations_id_fk", + "tableFrom": "organization_domain_claims", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_domain_claims_organization_domain": { + "name": "UQ_organization_domain_claims_organization_domain", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "domain" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_domain_claims_canonical_domain_check": { + "name": "organization_domain_claims_canonical_domain_check", + "value": "length(\"organization_domain_claims\".\"domain\") BETWEEN 1 AND 253 AND \"organization_domain_claims\".\"domain\" = lower(btrim(\"organization_domain_claims\".\"domain\"))" + }, + "organization_domain_claims_status_check": { + "name": "organization_domain_claims_status_check", + "value": "\"organization_domain_claims\".\"status\" IN ('pending', 'verified')" + }, + "organization_domain_claims_verification_shape_check": { + "name": "organization_domain_claims_verification_shape_check", + "value": "(\"organization_domain_claims\".\"status\" = 'pending' AND \"organization_domain_claims\".\"verified_at\" IS NULL)\n OR (\"organization_domain_claims\".\"status\" = 'verified' AND \"organization_domain_claims\".\"verified_at\" IS NOT NULL AND \"organization_domain_claims\".\"workos_organization_id\" IS NOT NULL AND \"organization_domain_claims\".\"workos_domain_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.organization_group_memberships": { + "name": "organization_group_memberships", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by_kilo_user_id": { + "name": "assigned_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_group_memberships_organization_user": { + "name": "IDX_organization_group_memberships_organization_user", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "FK_organization_group_memberships_group": { + "name": "FK_organization_group_memberships_group", + "tableFrom": "organization_group_memberships", + "tableTo": "organization_groups", + "columnsFrom": [ + "organization_id", + "group_id" + ], + "columnsTo": [ + "organization_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_organization_group_memberships_member": { + "name": "FK_organization_group_memberships_member", + "tableFrom": "organization_group_memberships", + "tableTo": "organization_memberships", + "columnsFrom": [ + "organization_id", + "kilo_user_id" + ], + "columnsTo": [ + "organization_id", + "kilo_user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "PK_organization_group_memberships": { + "name": "PK_organization_group_memberships", + "columns": [ + "organization_id", + "group_id", + "kilo_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_group_policy_settings": { + "name": "organization_group_policy_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "default_policies": { + "name": "default_policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[{\"type\":\"model_access\",\"data\":{\"mode\":\"all\"}}]'::jsonb" + }, + "policy_revision": { + "name": "policy_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_by_kilo_user_id": { + "name": "updated_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_group_policy_settings_organization_id_organizations_id_fk": { + "name": "organization_group_policy_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_group_policy_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organization_group_policy_settings_revision_check": { + "name": "organization_group_policy_settings_revision_check", + "value": "\"organization_group_policy_settings\".\"policy_revision\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.organization_groups": { + "name": "organization_groups", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policies": { + "name": "policies", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_groups_organization_id_canonical_name": { + "name": "UQ_organization_groups_organization_id_canonical_name", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lower(btrim(\"name\"))", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_groups_organization_id": { + "name": "IDX_organization_groups_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_groups_organization_id_organizations_id_fk": { + "name": "organization_groups_organization_id_organizations_id_fk", + "tableFrom": "organization_groups", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_groups_organization_id_id": { + "name": "UQ_organization_groups_organization_id_id", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "organization_groups_name_check": { + "name": "organization_groups_name_check", + "value": "char_length(btrim(\"organization_groups\".\"name\")) BETWEEN 1 AND 80" + }, + "organization_groups_description_check": { + "name": "organization_groups_description_check", + "value": "\"organization_groups\".\"description\" IS NULL OR char_length(\"organization_groups\".\"description\") <= 500" + } + }, + "isRLSEnabled": false + }, + "public.organization_invitations": { + "name": "organization_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "authentication_requirement": { + "name": "authentication_requirement", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "sso_source_organization_id": { + "name": "sso_source_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_organization_invitations_token": { + "name": "UQ_organization_invitations_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_org_id": { + "name": "IDX_organization_invitations_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_email": { + "name": "IDX_organization_invitations_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_invitations_expires_at": { + "name": "IDX_organization_invitations_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_invitations_sso_source_organization_id_organizations_id_fk": { + "name": "organization_invitations_sso_source_organization_id_organizations_id_fk", + "tableFrom": "organization_invitations", + "tableTo": "organizations", + "columnsFrom": [ + "sso_source_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_membership_removals": { + "name": "organization_membership_removals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "removed_at": { + "name": "removed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "removed_by": { + "name": "removed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "previous_role": { + "name": "previous_role", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "IDX_org_membership_removals_org_id": { + "name": "IDX_org_membership_removals_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_org_membership_removals_user_id": { + "name": "IDX_org_membership_removals_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_org_membership_removals_org_user": { + "name": "UQ_org_membership_removals_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_memberships": { + "name": "organization_memberships", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_memberships_org_id": { + "name": "IDX_organization_memberships_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_memberships_user_id": { + "name": "IDX_organization_memberships_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_memberships_org_user": { + "name": "UQ_organization_memberships_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_recommendation_dismissals": { + "name": "organization_recommendation_dismissals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recommendation_key": { + "name": "recommendation_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dismissed_by_user_id": { + "name": "dismissed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_recommendation_dismissals_owned_by_organization_id_organizations_id_fk": { + "name": "organization_recommendation_dismissals_owned_by_organization_id_organizations_id_fk", + "tableFrom": "organization_recommendation_dismissals", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_recommendation_dismissals_dismissed_by_user_id_kilocode_users_id_fk": { + "name": "organization_recommendation_dismissals_dismissed_by_user_id_kilocode_users_id_fk", + "tableFrom": "organization_recommendation_dismissals", + "tableTo": "kilocode_users", + "columnsFrom": [ + "dismissed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_org_recommendation_dismissals_org_key": { + "name": "UQ_org_recommendation_dismissals_org_key", + "nullsNotDistinct": false, + "columns": [ + "owned_by_organization_id", + "recommendation_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_seats_purchases": { + "name": "organization_seats_purchases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_stripe_id": { + "name": "subscription_stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "billing_cycle": { + "name": "billing_cycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monthly'" + } + }, + "indexes": { + "IDX_organization_seats_org_id": { + "name": "IDX_organization_seats_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_expires_at": { + "name": "IDX_organization_seats_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_created_at": { + "name": "IDX_organization_seats_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_updated_at": { + "name": "IDX_organization_seats_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_seats_starts_at": { + "name": "IDX_organization_seats_starts_at", + "columns": [ + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_seats_idempotency_key": { + "name": "UQ_organization_seats_idempotency_key", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_limits": { + "name": "organization_user_limits", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_limit": { + "name": "microdollar_limit", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_user_limits_org_id": { + "name": "IDX_organization_user_limits_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_limits_user_id": { + "name": "IDX_organization_user_limits_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_limits_org_user": { + "name": "UQ_organization_user_limits_org_user", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_user_usage": { + "name": "organization_user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "limit_type": { + "name": "limit_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "microdollar_usage": { + "name": "microdollar_usage", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_organization_user_daily_usage_org_id": { + "name": "IDX_organization_user_daily_usage_org_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organization_user_daily_usage_user_id": { + "name": "IDX_organization_user_daily_usage_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_user_daily_usage_org_user_date": { + "name": "UQ_organization_user_daily_usage_org_user_date", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "kilo_user_id", + "limit_type", + "usage_date" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "microdollars_balance": { + "name": "microdollars_balance", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_microdollars_acquired": { + "name": "total_microdollars_acquired", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "next_credit_expiration_at": { + "name": "next_credit_expiration_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_top_up_enabled": { + "name": "auto_top_up_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "seat_count": { + "name": "seat_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "require_seats": { + "name": "require_seats", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_kilo_user_id": { + "name": "created_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sso_domain": { + "name": "sso_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_organization_id": { + "name": "parent_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'teams'" + }, + "free_trial_end_at": { + "name": "free_trial_end_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "company_domain": { + "name": "company_domain", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_organizations_sso_domain": { + "name": "IDX_organizations_sso_domain", + "columns": [ + { + "expression": "sso_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_organizations_parent_organization_id": { + "name": "IDX_organizations_parent_organization_id", + "columns": [ + { + "expression": "parent_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_organizations_live_sales_demo_per_owner": { + "name": "UQ_organizations_live_sales_demo_per_owner", + "columns": [ + { + "expression": "created_by_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "(\"organizations\".\"settings\"->>'is_sales_demo')::boolean = true AND \"organizations\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organizations_parent_organization_id_organizations_id_fk": { + "name": "organizations_parent_organization_id_organizations_id_fk", + "tableFrom": "organizations", + "tableTo": "organizations", + "columnsFrom": [ + "parent_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "organizations_name_not_empty_check": { + "name": "organizations_name_not_empty_check", + "value": "length(trim(\"organizations\".\"name\")) > 0" + }, + "organizations_not_parented_by_self_check": { + "name": "organizations_not_parented_by_self_check", + "value": "\"organizations\".\"parent_organization_id\" IS NULL OR \"organizations\".\"parent_organization_id\" <> \"organizations\".\"id\"" + } + }, + "isRLSEnabled": false + }, + "public.organization_modes": { + "name": "organization_modes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "IDX_organization_modes_organization_id": { + "name": "IDX_organization_modes_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_organization_modes_org_id_slug": { + "name": "UQ_organization_modes_org_id_slug", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_fingerprint": { + "name": "stripe_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_id": { + "name": "stripe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last4": { + "name": "last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1": { + "name": "address_line1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line2": { + "name": "address_line2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_country": { + "name": "address_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "three_d_secure_supported": { + "name": "three_d_secure_supported", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "funding": { + "name": "funding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "regulated_status": { + "name": "regulated_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_line1_check_status": { + "name": "address_line1_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "postal_code_check_status": { + "name": "postal_code_check_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "eligible_for_free_credits": { + "name": "eligible_for_free_credits", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_data": { + "name": "stripe_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_d7d7fb15569674aaadcfbc0428": { + "name": "IDX_d7d7fb15569674aaadcfbc0428", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_e1feb919d0ab8a36381d5d5138": { + "name": "IDX_e1feb919d0ab8a36381d5d5138", + "columns": [ + { + "expression": "stripe_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_payment_methods_organization_id": { + "name": "IDX_payment_methods_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_29df1b0403df5792c96bbbfdbe6": { + "name": "UQ_29df1b0403df5792c96bbbfdbe6", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "stripe_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_impact_sale_reversals": { + "name": "pending_impact_sale_reversals", + "schema": "", + "columns": { + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "dispute_id": { + "name": "dispute_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_date": { + "name": "event_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pending_impact_sale_reversals_attempt_count_non_negative_check": { + "name": "pending_impact_sale_reversals_attempt_count_non_negative_check", + "value": "\"pending_impact_sale_reversals\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.platform_access_token_credentials": { + "name": "platform_access_token_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_encrypted": { + "name": "token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_credential_type": { + "name": "provider_credential_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_resource_id": { + "name": "provider_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_base_url": { + "name": "provider_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_metadata": { + "name": "provider_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "provider_scopes": { + "name": "provider_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "provider_verified_at": { + "name": "provider_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "last_validated_at": { + "name": "last_validated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_access_token_credentials_integration_level": { + "name": "UQ_platform_access_token_credentials_integration_level", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_access_token_credentials\".\"provider_resource_id\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_access_token_credentials_resource": { + "name": "UQ_platform_access_token_credentials_resource", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_credential_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_access_token_credentials\".\"provider_resource_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_access_token_credentials_authorized_by_user_id": { + "name": "IDX_platform_access_token_credentials_authorized_by_user_id", + "columns": [ + { + "expression": "authorized_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_access_token_credentials_authorized_by_user_id_kilocode_users_id_fk": { + "name": "platform_access_token_credentials_authorized_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_access_token_credentials", + "tableTo": "kilocode_users", + "columnsFrom": [ + "authorized_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "FK_platform_access_token_credentials_parent": { + "name": "FK_platform_access_token_credentials_parent", + "tableFrom": "platform_access_token_credentials", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_access_token_credentials_credential_version_check": { + "name": "platform_access_token_credentials_credential_version_check", + "value": "\"platform_access_token_credentials\".\"credential_version\" > 0" + }, + "platform_access_token_credentials_resource_id_check": { + "name": "platform_access_token_credentials_resource_id_check", + "value": "\"platform_access_token_credentials\".\"provider_resource_id\" IS NULL OR \"platform_access_token_credentials\".\"provider_resource_id\" <> ''" + } + }, + "isRLSEnabled": false + }, + "public.platform_integrations": { + "name": "platform_integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration_type": { + "name": "integration_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_installation_id": { + "name": "platform_installation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_id": { + "name": "platform_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_account_login": { + "name": "platform_account_login", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "repository_access": { + "name": "repository_access", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repositories": { + "name": "repositories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "repositories_synced_at": { + "name": "repositories_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_invalid_at": { + "name": "auth_invalid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_invalid_reason": { + "name": "auth_invalid_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "kilo_requester_user_id": { + "name": "kilo_requester_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_requester_account_id": { + "name": "platform_requester_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "integration_status": { + "name": "integration_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_by": { + "name": "suspended_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'standard'" + }, + "installed_at": { + "name": "installed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_integrations_owned_by_org_platform_inst": { + "name": "UQ_platform_integrations_owned_by_org_platform_inst", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_owned_by_user_platform_inst": { + "name": "UQ_platform_integrations_owned_by_user_platform_inst", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_slack_platform_inst": { + "name": "UQ_platform_integrations_slack_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'slack' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_linear_platform_inst": { + "name": "UQ_platform_integrations_linear_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'linear' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_github_platform_inst": { + "name": "UQ_platform_integrations_github_platform_inst", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'github' AND \"platform_integrations\".\"platform_installation_id\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_github_pending_target": { + "name": "UQ_platform_integrations_github_pending_target", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'github' AND \"platform_integrations\".\"integration_status\" = 'pending' AND \"platform_integrations\".\"platform_installation_id\" IS NULL AND \"platform_integrations\".\"platform_account_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_user_bitbucket": { + "name": "UQ_platform_integrations_user_bitbucket", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'bitbucket' AND \"platform_integrations\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_platform_integrations_org_bitbucket": { + "name": "UQ_platform_integrations_org_bitbucket", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"platform_integrations\".\"platform\" = 'bitbucket' AND \"platform_integrations\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_id": { + "name": "IDX_platform_integrations_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_id": { + "name": "IDX_platform_integrations_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_inst_id": { + "name": "IDX_platform_integrations_platform_inst_id", + "columns": [ + { + "expression": "platform_installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform": { + "name": "IDX_platform_integrations_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_org_platform": { + "name": "IDX_platform_integrations_owned_by_org_platform", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_owned_by_user_platform": { + "name": "IDX_platform_integrations_owned_by_user_platform", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_integration_status": { + "name": "IDX_platform_integrations_integration_status", + "columns": [ + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_kilo_requester": { + "name": "IDX_platform_integrations_kilo_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kilo_requester_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_integrations_platform_requester": { + "name": "IDX_platform_integrations_platform_requester", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform_requester_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_integrations_owned_by_organization_id_organizations_id_fk": { + "name": "platform_integrations_owned_by_organization_id_organizations_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_integrations_owned_by_user_id_kilocode_users_id_fk": { + "name": "platform_integrations_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_integrations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_integrations_owner_check": { + "name": "platform_integrations_owner_check", + "value": "(\n (\"platform_integrations\".\"owned_by_user_id\" IS NOT NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NULL) OR\n (\"platform_integrations\".\"owned_by_user_id\" IS NULL AND \"platform_integrations\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.platform_oauth_credentials": { + "name": "platform_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorized_by_user_id": { + "name": "authorized_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subject_login": { + "name": "provider_subject_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_base_url": { + "name": "provider_base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret_encrypted": { + "name": "oauth_client_secret_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_platform_oauth_credentials_platform_integration_id": { + "name": "UQ_platform_oauth_credentials_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_platform_oauth_credentials_authorized_by_user_id": { + "name": "IDX_platform_oauth_credentials_authorized_by_user_id", + "columns": [ + { + "expression": "authorized_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "platform_oauth_credentials_platform_integration_id_platform_integrations_id_fk": { + "name": "platform_oauth_credentials_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "platform_oauth_credentials", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "platform_oauth_credentials_authorized_by_user_id_kilocode_users_id_fk": { + "name": "platform_oauth_credentials_authorized_by_user_id_kilocode_users_id_fk", + "tableFrom": "platform_oauth_credentials", + "tableTo": "kilocode_users", + "columnsFrom": [ + "authorized_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "platform_oauth_credentials_credential_version_check": { + "name": "platform_oauth_credentials_credential_version_check", + "value": "\"platform_oauth_credentials\".\"credential_version\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.quick_chat_messages": { + "name": "quick_chat_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "thread_id": { + "name": "thread_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_quick_chat_messages_thread_created_at": { + "name": "IDX_quick_chat_messages_thread_created_at", + "columns": [ + { + "expression": "thread_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quick_chat_messages_thread_id_quick_chat_threads_id_fk": { + "name": "quick_chat_messages_thread_id_quick_chat_threads_id_fk", + "tableFrom": "quick_chat_messages", + "tableTo": "quick_chat_threads", + "columnsFrom": [ + "thread_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quick_chat_threads": { + "name": "quick_chat_threads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quick_chat_threads_user_personal_uidx": { + "name": "quick_chat_threads_user_personal_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quick_chat_threads\".\"organization_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "quick_chat_threads_user_org_uidx": { + "name": "quick_chat_threads_user_org_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"quick_chat_threads\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quick_chat_threads_user_id_kilocode_users_id_fk": { + "name": "quick_chat_threads_user_id_kilocode_users_id_fk", + "tableFrom": "quick_chat_threads", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "quick_chat_threads_organization_id_organizations_id_fk": { + "name": "quick_chat_threads_organization_id_organizations_id_fk", + "tableFrom": "quick_chat_threads", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_code_usages": { + "name": "referral_code_usages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "referring_kilo_user_id": { + "name": "referring_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeeming_kilo_user_id": { + "name": "redeeming_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_usd": { + "name": "amount_usd", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_referral_code_usages_redeeming_kilo_user_id": { + "name": "IDX_referral_code_usages_redeeming_kilo_user_id", + "columns": [ + { + "expression": "redeeming_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_referral_code_usages_redeeming_user_id_code": { + "name": "UQ_referral_code_usages_redeeming_user_id_code", + "nullsNotDistinct": false, + "columns": [ + "redeeming_kilo_user_id", + "referring_kilo_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_codes": { + "name": "referral_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_redemptions": { + "name": "max_redemptions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_referral_codes_kilo_user_id": { + "name": "UQ_referral_codes_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_referral_codes_code": { + "name": "IDX_referral_codes_code", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sales_demo_spend_ledger": { + "name": "sales_demo_spend_ledger", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_kilo_user_id": { + "name": "owner_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "microdollars_used": { + "name": "microdollars_used", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sales_demo_spend_ledger_organization_id_organizations_id_fk": { + "name": "sales_demo_spend_ledger_organization_id_organizations_id_fk", + "tableFrom": "sales_demo_spend_ledger", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "sales_demo_spend_ledger_spend_positive": { + "name": "sales_demo_spend_ledger_spend_positive", + "value": "\"sales_demo_spend_ledger\".\"microdollars_used\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.security_advisor_check_catalog": { + "name": "security_advisor_check_catalog", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "check_id": { + "name": "check_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "risk": { + "name": "risk", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_check_catalog_check_id_unique": { + "name": "security_advisor_check_catalog_check_id_unique", + "nullsNotDistinct": false, + "columns": [ + "check_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "security_advisor_check_catalog_severity_check": { + "name": "security_advisor_check_catalog_severity_check", + "value": "\"security_advisor_check_catalog\".\"severity\" in ('critical', 'warn', 'info')" + } + }, + "isRLSEnabled": false + }, + "public.security_advisor_content": { + "name": "security_advisor_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_content_key_unique": { + "name": "security_advisor_content_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_kiloclaw_coverage": { + "name": "security_advisor_kiloclaw_coverage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "match_check_ids": { + "name": "match_check_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "security_advisor_kiloclaw_coverage_area_unique": { + "name": "security_advisor_kiloclaw_coverage_area_unique", + "nullsNotDistinct": false, + "columns": [ + "area" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_advisor_scans": { + "name": "security_advisor_scans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_platform": { + "name": "source_platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_method": { + "name": "source_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_version": { + "name": "plugin_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "openclaw_version": { + "name": "openclaw_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_ip": { + "name": "public_ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "findings_critical": { + "name": "findings_critical", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_warn": { + "name": "findings_warn", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "findings_info": { + "name": "findings_info", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_advisor_scans_user_created_at": { + "name": "idx_security_advisor_scans_user_created_at", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_created_at": { + "name": "idx_security_advisor_scans_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_advisor_scans_platform": { + "name": "idx_security_advisor_scans_platform", + "columns": [ + { + "expression": "source_platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.security_agent_commands": { + "name": "security_agent_commands", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "operation_key": { + "name": "operation_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'accepted'" + }, + "result_code": { + "name": "result_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_metadata": { + "name": "result_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_security_agent_commands_org_created": { + "name": "idx_security_agent_commands_org_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_agent_commands_user_created": { + "name": "idx_security_agent_commands_user_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_agent_commands_status_updated": { + "name": "idx_security_agent_commands_status_updated", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_agent_commands_finding_created": { + "name": "idx_security_agent_commands_finding_created", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_agent_commands_org_operation_key": { + "name": "UQ_security_agent_commands_org_operation_key", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_agent_commands\".\"owned_by_organization_id\" IS NOT NULL AND \"security_agent_commands\".\"operation_key\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + }, + "UQ_security_agent_commands_user_operation_key": { + "name": "UQ_security_agent_commands_user_operation_key", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "operation_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_agent_commands\".\"owned_by_user_id\" IS NOT NULL AND \"security_agent_commands\".\"operation_key\" IS NOT NULL", + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_agent_commands_owned_by_organization_id_organizations_id_fk": { + "name": "security_agent_commands_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_agent_commands", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_agent_commands_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_agent_commands_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_agent_commands", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_agent_commands_finding_id_security_findings_id_fk": { + "name": "security_agent_commands_finding_id_security_findings_id_fk", + "tableFrom": "security_agent_commands", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_agent_commands_owner_check": { + "name": "security_agent_commands_owner_check", + "value": "(\n (\"security_agent_commands\".\"owned_by_user_id\" IS NOT NULL AND \"security_agent_commands\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_agent_commands\".\"owned_by_user_id\" IS NULL AND \"security_agent_commands\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_agent_commands_type_check": { + "name": "security_agent_commands_type_check", + "value": "\"security_agent_commands\".\"command_type\" IN ('sync', 'dismiss_finding', 'start_analysis', 'apply_auto_remediation')" + }, + "security_agent_commands_origin_check": { + "name": "security_agent_commands_origin_check", + "value": "\"security_agent_commands\".\"origin\" IN ('manual', 'dashboard_refresh', 'enable_initial_sync', 'settings_include_existing')" + }, + "security_agent_commands_status_check": { + "name": "security_agent_commands_status_check", + "value": "\"security_agent_commands\".\"status\" IN ('accepted', 'running', 'succeeded', 'failed', 'no_op')" + } + }, + "isRLSEnabled": false + }, + "public.security_agent_repository_sync_state": { + "name": "security_agent_repository_sync_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_attempted_at": { + "name": "last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_succeeded_at": { + "name": "last_succeeded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_failure_code": { + "name": "last_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_agent_repository_sync_state_org_repo": { + "name": "UQ_security_agent_repository_sync_state_org_repo", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_agent_repository_sync_state\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_agent_repository_sync_state_user_repo": { + "name": "UQ_security_agent_repository_sync_state_user_repo", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_agent_repository_sync_state\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_agent_repository_sync_state_owned_by_organization_id_organizations_id_fk": { + "name": "security_agent_repository_sync_state_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_agent_repository_sync_state", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_agent_repository_sync_state_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_agent_repository_sync_state_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_agent_repository_sync_state", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_agent_repository_sync_state_owner_check": { + "name": "security_agent_repository_sync_state_owner_check", + "value": "(\n (\"security_agent_repository_sync_state\".\"owned_by_user_id\" IS NOT NULL AND \"security_agent_repository_sync_state\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_agent_repository_sync_state\".\"owned_by_user_id\" IS NULL AND \"security_agent_repository_sync_state\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_analysis_owner_state": { + "name": "security_analysis_owner_state", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_analysis_enabled_at": { + "name": "auto_analysis_enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "blocked_until": { + "name": "blocked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "block_reason": { + "name": "block_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consecutive_actor_resolution_failures": { + "name": "consecutive_actor_resolution_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_actor_resolution_failure_at": { + "name": "last_actor_resolution_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_analysis_owner_state_org_owner": { + "name": "UQ_security_analysis_owner_state_org_owner", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_organization_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_analysis_owner_state_user_owner": { + "name": "UQ_security_analysis_owner_state_user_owner", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_analysis_owner_state\".\"owned_by_user_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk": { + "name": "security_analysis_owner_state_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_analysis_owner_state", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_analysis_owner_state_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_analysis_owner_state", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_analysis_owner_state_owner_check": { + "name": "security_analysis_owner_state_owner_check", + "value": "(\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_owner_state\".\"owned_by_user_id\" IS NULL AND \"security_analysis_owner_state\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_analysis_owner_state_block_reason_check": { + "name": "security_analysis_owner_state_block_reason_check", + "value": "\"security_analysis_owner_state\".\"block_reason\" IS NULL OR \"security_analysis_owner_state\".\"block_reason\" IN ('INSUFFICIENT_CREDITS', 'ACTOR_RESOLUTION_FAILED', 'OPERATOR_PAUSE')" + } + }, + "isRLSEnabled": false + }, + "public.security_analysis_queue": { + "name": "security_analysis_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queue_status": { + "name": "queue_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity_rank": { + "name": "severity_rank", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "admitted_config_revision": { + "name": "admitted_config_revision", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by_job_id": { + "name": "claimed_by_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "reopen_requeue_count": { + "name": "reopen_requeue_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_analysis_queue_finding_id": { + "name": "UQ_security_analysis_queue_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_claim_path_org": { + "name": "idx_security_analysis_queue_claim_path_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "severity_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_claim_path_user": { + "name": "idx_security_analysis_queue_claim_path_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "severity_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_in_flight_org": { + "name": "idx_security_analysis_queue_in_flight_org", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queue_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_in_flight_user": { + "name": "idx_security_analysis_queue_in_flight_user", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queue_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_lag_dashboards": { + "name": "idx_security_analysis_queue_lag_dashboards", + "columns": [ + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_pending_reconciliation": { + "name": "idx_security_analysis_queue_pending_reconciliation", + "columns": [ + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_running_reconciliation": { + "name": "idx_security_analysis_queue_running_reconciliation", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"queue_status\" = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_analysis_queue_failure_trend": { + "name": "idx_security_analysis_queue_failure_trend", + "columns": [ + { + "expression": "failure_code", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_analysis_queue\".\"failure_code\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_analysis_queue_finding_id_security_findings_id_fk": { + "name": "security_analysis_queue_finding_id_security_findings_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_queue_owned_by_organization_id_organizations_id_fk": { + "name": "security_analysis_queue_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_analysis_queue_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_analysis_queue", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_analysis_queue_owner_check": { + "name": "security_analysis_queue_owner_check", + "value": "(\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NOT NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_analysis_queue\".\"owned_by_user_id\" IS NULL AND \"security_analysis_queue\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_analysis_queue_status_check": { + "name": "security_analysis_queue_status_check", + "value": "\"security_analysis_queue\".\"queue_status\" IN ('queued', 'pending', 'running', 'failed', 'completed')" + }, + "security_analysis_queue_claim_token_required_check": { + "name": "security_analysis_queue_claim_token_required_check", + "value": "\"security_analysis_queue\".\"queue_status\" NOT IN ('pending', 'running') OR \"security_analysis_queue\".\"claim_token\" IS NOT NULL" + }, + "security_analysis_queue_attempt_count_non_negative_check": { + "name": "security_analysis_queue_attempt_count_non_negative_check", + "value": "\"security_analysis_queue\".\"attempt_count\" >= 0" + }, + "security_analysis_queue_reopen_requeue_count_non_negative_check": { + "name": "security_analysis_queue_reopen_requeue_count_non_negative_check", + "value": "\"security_analysis_queue\".\"reopen_requeue_count\" >= 0" + }, + "security_analysis_queue_severity_rank_check": { + "name": "security_analysis_queue_severity_rank_check", + "value": "\"security_analysis_queue\".\"severity_rank\" IN (0, 1, 2, 3)" + }, + "security_analysis_queue_failure_code_check": { + "name": "security_analysis_queue_failure_code_check", + "value": "\"security_analysis_queue\".\"failure_code\" IS NULL OR \"security_analysis_queue\".\"failure_code\" IN (\n 'NETWORK_TIMEOUT',\n 'UPSTREAM_5XX',\n 'TEMP_TOKEN_FAILURE',\n 'START_CALL_AMBIGUOUS',\n 'REQUEUE_TEMPORARY_PRECONDITION',\n 'ACTOR_RESOLUTION_FAILED',\n 'GITHUB_TOKEN_UNAVAILABLE',\n 'INVALID_CONFIG',\n 'MISSING_OWNERSHIP',\n 'PERMISSION_DENIED_PERMANENT',\n 'UNSUPPORTED_SEVERITY',\n 'INSUFFICIENT_CREDITS',\n 'STATE_GUARD_REJECTED',\n 'SKIPPED_ALREADY_IN_PROGRESS',\n 'SKIPPED_NO_LONGER_ELIGIBLE',\n 'REOPEN_LOOP_GUARD',\n 'RUN_LOST'\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_audit_log": { + "name": "security_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before_state": { + "name": "before_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after_state": { + "name": "after_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "source_occurred_at": { + "name": "source_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema_version": { + "name": "schema_version", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "finding_snapshot": { + "name": "finding_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_context": { + "name": "source_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_security_audit_log_org_created": { + "name": "IDX_security_audit_log_org_created", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_user_created": { + "name": "IDX_security_audit_log_user_created", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_resource": { + "name": "IDX_security_audit_log_resource", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_actor": { + "name": "IDX_security_audit_log_actor", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_action": { + "name": "IDX_security_audit_log_action", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_audit_log_org_event_key": { + "name": "UQ_security_audit_log_org_event_key", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL AND \"security_audit_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_audit_log_user_event_key": { + "name": "UQ_security_audit_log_user_event_key", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_org_occurred": { + "name": "IDX_security_audit_log_org_occurred", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL AND \"security_audit_log\".\"occurred_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_security_audit_log_user_occurred": { + "name": "IDX_security_audit_log_user_occurred", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"occurred_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_audit_log_owned_by_organization_id_organizations_id_fk": { + "name": "security_audit_log_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_audit_log_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_audit_log_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_audit_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_audit_log_owner_check": { + "name": "security_audit_log_owner_check", + "value": "(\"security_audit_log\".\"owned_by_user_id\" IS NOT NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NULL) OR (\"security_audit_log\".\"owned_by_user_id\" IS NULL AND \"security_audit_log\".\"owned_by_organization_id\" IS NOT NULL)" + }, + "security_audit_log_action_check": { + "name": "security_audit_log_action_check", + "value": "\"security_audit_log\".\"action\" IN ('security.finding.created', 'security.finding.severity_changed', 'security.finding.status_change', 'security.finding.dismissed', 'security.finding.auto_dismissed', 'security.finding.superseded', 'security.finding.analysis_started', 'security.finding.analysis_completed', 'security.finding.analysis_failed', 'security.remediation.queued', 'security.remediation.started', 'security.remediation.pr_opened', 'security.remediation.failed', 'security.remediation.blocked', 'security.remediation.no_changes_needed', 'security.remediation.cancelled', 'security.remediation.retried', 'security.finding.deleted', 'security.config.enabled', 'security.config.disabled', 'security.config.updated', 'security.sync.triggered', 'security.sync.completed', 'security.audit_log.exported', 'security.audit_report.generated')" + }, + "security_audit_log_actor_type_check": { + "name": "security_audit_log_actor_type_check", + "value": "\"security_audit_log\".\"actor_type\" IN ('customer_user', 'kilo_admin', 'system')" + }, + "security_audit_log_source_context_check": { + "name": "security_audit_log_source_context_check", + "value": "\"security_audit_log\".\"source_context\" IN ('security_sync', 'web', 'analysis_worker', 'remediation_callback', 'rollout_baseline')" + } + }, + "isRLSEnabled": false + }, + "public.security_finding_notifications": { + "name": "security_finding_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'staged'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_security_finding_notifications_finding_recipient_kind": { + "name": "uq_security_finding_notifications_finding_recipient_kind", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_pending": { + "name": "idx_security_finding_notifications_pending", + "columns": [ + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_finding_notifications\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_staged": { + "name": "idx_security_finding_notifications_staged", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_finding_notifications\".\"status\" = 'staged'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_finding_id": { + "name": "idx_security_finding_notifications_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_finding_notifications_recipient_user_id": { + "name": "idx_security_finding_notifications_recipient_user_id", + "columns": [ + { + "expression": "recipient_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_finding_notifications_finding_fk": { + "name": "security_finding_notifications_finding_fk", + "tableFrom": "security_finding_notifications", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_finding_notifications_recipient_fk": { + "name": "security_finding_notifications_recipient_fk", + "tableFrom": "security_finding_notifications", + "tableTo": "kilocode_users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_finding_notifications_kind_check": { + "name": "security_finding_notifications_kind_check", + "value": "\"security_finding_notifications\".\"kind\" IN ('new_finding', 'sla_warning', 'sla_breach')" + }, + "security_finding_notifications_status_check": { + "name": "security_finding_notifications_status_check", + "value": "\"security_finding_notifications\".\"status\" IN ('staged', 'pending', 'sending', 'sent', 'failed', 'cancelled')" + }, + "security_finding_notifications_attempt_count_check": { + "name": "security_finding_notifications_attempt_count_check", + "value": "\"security_finding_notifications\".\"attempt_count\" >= 0" + }, + "security_finding_notifications_claimed_at_check": { + "name": "security_finding_notifications_claimed_at_check", + "value": "(\n (\"security_finding_notifications\".\"status\" = 'sending' AND \"security_finding_notifications\".\"claimed_at\" IS NOT NULL) OR\n (\"security_finding_notifications\".\"status\" <> 'sending' AND \"security_finding_notifications\".\"claimed_at\" IS NULL)\n )" + }, + "security_finding_notifications_sent_at_check": { + "name": "security_finding_notifications_sent_at_check", + "value": "(\n (\"security_finding_notifications\".\"status\" = 'sent' AND \"security_finding_notifications\".\"sent_at\" IS NOT NULL) OR\n (\"security_finding_notifications\".\"status\" <> 'sent' AND \"security_finding_notifications\".\"sent_at\" IS NULL)\n )" + }, + "security_finding_notifications_error_message_length_check": { + "name": "security_finding_notifications_error_message_length_check", + "value": "\"security_finding_notifications\".\"error_message\" IS NULL OR length(\"security_finding_notifications\".\"error_message\") <= 500" + } + }, + "isRLSEnabled": false + }, + "public.security_findings": { + "name": "security_findings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ghsa_id": { + "name": "ghsa_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cve_id": { + "name": "cve_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "package_name": { + "name": "package_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "package_ecosystem": { + "name": "package_ecosystem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vulnerable_version_range": { + "name": "vulnerable_version_range", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "patched_version": { + "name": "patched_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "manifest_path": { + "name": "manifest_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "ignored_reason": { + "name": "ignored_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ignored_by": { + "name": "ignored_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fixed_at": { + "name": "fixed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sla_due_at": { + "name": "sla_due_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dependabot_html_url": { + "name": "dependabot_html_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cwe_ids": { + "name": "cwe_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cvss_score": { + "name": "cvss_score", + "type": "numeric(3, 1)", + "primaryKey": false, + "notNull": false + }, + "dependency_scope": { + "name": "dependency_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cli_session_id": { + "name": "cli_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_status": { + "name": "analysis_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_started_at": { + "name": "analysis_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_completed_at": { + "name": "analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "analysis_error": { + "name": "analysis_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis": { + "name": "analysis", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "first_detected_at": { + "name": "first_detected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_security_findings_user_source": { + "name": "uq_security_findings_user_source", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_findings\".\"owned_by_user_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_security_findings_org_source": { + "name": "uq_security_findings_org_source", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_findings\".\"owned_by_organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_org_id": { + "name": "idx_security_findings_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_id": { + "name": "idx_security_findings_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_repo": { + "name": "idx_security_findings_repo", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_severity": { + "name": "idx_security_findings_severity", + "columns": [ + { + "expression": "severity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_status": { + "name": "idx_security_findings_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_package": { + "name": "idx_security_findings_package", + "columns": [ + { + "expression": "package_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_sla_due_at": { + "name": "idx_security_findings_sla_due_at", + "columns": [ + { + "expression": "sla_due_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_session_id": { + "name": "idx_security_findings_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_cli_session_id": { + "name": "idx_security_findings_cli_session_id", + "columns": [ + { + "expression": "cli_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_analysis_status": { + "name": "idx_security_findings_analysis_status", + "columns": [ + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_org_analysis_in_flight": { + "name": "idx_security_findings_org_analysis_in_flight", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_findings_user_analysis_in_flight": { + "name": "idx_security_findings_user_analysis_in_flight", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_findings\".\"analysis_status\" IN ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_findings_owned_by_organization_id_organizations_id_fk": { + "name": "security_findings_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_findings", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_findings_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_findings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_findings_platform_integration_id_platform_integrations_id_fk": { + "name": "security_findings_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "security_findings", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_findings_owner_check": { + "name": "security_findings_owner_check", + "value": "(\n (\"security_findings\".\"owned_by_user_id\" IS NOT NULL AND \"security_findings\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_findings\".\"owned_by_user_id\" IS NULL AND \"security_findings\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.security_remediation_attempts": { + "name": "security_remediation_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "remediation_id": { + "name": "remediation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "retry_of_attempt_id": { + "name": "retry_of_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "requested_by_user_id": { + "name": "requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "analysis_fingerprint": { + "name": "analysis_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "analysis_completed_at": { + "name": "analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "remediation_model_slug": { + "name": "remediation_model_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kilo_session_id": { + "name": "kilo_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 50 + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by_job_id": { + "name": "claimed_by_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_attempt_count": { + "name": "launch_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "callback_attempt_token_hash": { + "name": "callback_attempt_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "structured_result": { + "name": "structured_result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "final_assistant_message": { + "name": "final_assistant_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "validation_evidence": { + "name": "validation_evidence", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "risk_notes": { + "name": "risk_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "draft_reason": { + "name": "draft_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_draft": { + "name": "pr_draft", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pr_head_branch": { + "name": "pr_head_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_branch": { + "name": "pr_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancellation_requested_at": { + "name": "cancellation_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancellation_requested_by_user_id": { + "name": "cancellation_requested_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "launched_at": { + "name": "launched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_remediation_attempts_number": { + "name": "UQ_security_remediation_attempts_number", + "columns": [ + { + "expression": "remediation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_remediation_attempts_active_finding": { + "name": "UQ_security_remediation_attempts_active_finding", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_remediation_attempts_active_remediation": { + "name": "UQ_security_remediation_attempts_active_remediation", + "columns": [ + { + "expression": "remediation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_security_remediation_attempts_finding_fingerprint_terminal": { + "name": "UQ_security_remediation_attempts_finding_fingerprint_terminal", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running', 'pr_opened')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_org_claim": { + "name": "idx_security_remediation_attempts_org_claim", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_user_claim": { + "name": "idx_security_remediation_attempts_user_claim", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_repo_claim": { + "name": "idx_security_remediation_attempts_repo_claim", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "priority", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" = 'queued'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_org_inflight": { + "name": "idx_security_remediation_attempts_org_inflight", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" IN ('launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_user_inflight": { + "name": "idx_security_remediation_attempts_user_inflight", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" IN ('launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_repo_inflight": { + "name": "idx_security_remediation_attempts_repo_inflight", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "claimed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"security_remediation_attempts\".\"status\" IN ('launching', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_cloud_agent_session": { + "name": "idx_security_remediation_attempts_cloud_agent_session", + "columns": [ + { + "expression": "cloud_agent_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediation_attempts_finding_fingerprint": { + "name": "idx_security_remediation_attempts_finding_fingerprint", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "analysis_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_remediation_attempts_remediation_id_security_remediations_id_fk": { + "name": "security_remediation_attempts_remediation_id_security_remediations_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "security_remediations", + "columnsFrom": [ + "remediation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_finding_id_security_findings_id_fk": { + "name": "security_remediation_attempts_finding_id_security_findings_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_owned_by_organization_id_organizations_id_fk": { + "name": "security_remediation_attempts_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_remediation_attempts_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediation_attempts_requested_by_user_id_kilocode_users_id_fk": { + "name": "security_remediation_attempts_requested_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "security_remediation_attempts_cancellation_requested_by_user_id_kilocode_users_id_fk": { + "name": "security_remediation_attempts_cancellation_requested_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediation_attempts", + "tableTo": "kilocode_users", + "columnsFrom": [ + "cancellation_requested_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_remediation_attempts_owner_check": { + "name": "security_remediation_attempts_owner_check", + "value": "(\n (\"security_remediation_attempts\".\"owned_by_user_id\" IS NOT NULL AND \"security_remediation_attempts\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_remediation_attempts\".\"owned_by_user_id\" IS NULL AND \"security_remediation_attempts\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_remediation_attempts_status_check": { + "name": "security_remediation_attempts_status_check", + "value": "\"security_remediation_attempts\".\"status\" IN ('queued', 'launching', 'running', 'pr_opened', 'failed', 'blocked', 'no_changes_needed', 'cancelled')" + }, + "security_remediation_attempts_origin_check": { + "name": "security_remediation_attempts_origin_check", + "value": "\"security_remediation_attempts\".\"origin\" IN ('auto_policy', 'bulk_existing', 'manual')" + }, + "security_remediation_attempts_attempt_number_check": { + "name": "security_remediation_attempts_attempt_number_check", + "value": "\"security_remediation_attempts\".\"attempt_number\" >= 1" + }, + "security_remediation_attempts_launch_attempt_count_check": { + "name": "security_remediation_attempts_launch_attempt_count_check", + "value": "\"security_remediation_attempts\".\"launch_attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.security_remediations": { + "name": "security_remediations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finding_id": { + "name": "finding_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_full_name": { + "name": "repo_full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "latest_attempt_id": { + "name": "latest_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "latest_analysis_fingerprint": { + "name": "latest_analysis_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latest_analysis_completed_at": { + "name": "latest_analysis_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pr_draft": { + "name": "pr_draft", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "pr_head_branch": { + "name": "pr_head_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_base_branch": { + "name": "pr_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "blocked_reason": { + "name": "blocked_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "outcome_summary": { + "name": "outcome_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_security_remediations_finding_id": { + "name": "UQ_security_remediations_finding_id", + "columns": [ + { + "expression": "finding_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_org_status": { + "name": "idx_security_remediations_org_status", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_user_status": { + "name": "idx_security_remediations_user_status", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_repo_status": { + "name": "idx_security_remediations_repo_status", + "columns": [ + { + "expression": "repo_full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_security_remediations_latest_attempt": { + "name": "idx_security_remediations_latest_attempt", + "columns": [ + { + "expression": "latest_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "security_remediations_owned_by_organization_id_organizations_id_fk": { + "name": "security_remediations_owned_by_organization_id_organizations_id_fk", + "tableFrom": "security_remediations", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediations_owned_by_user_id_kilocode_users_id_fk": { + "name": "security_remediations_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "security_remediations", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "security_remediations_finding_id_security_findings_id_fk": { + "name": "security_remediations_finding_id_security_findings_id_fk", + "tableFrom": "security_remediations", + "tableTo": "security_findings", + "columnsFrom": [ + "finding_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "security_remediations_owner_check": { + "name": "security_remediations_owner_check", + "value": "(\n (\"security_remediations\".\"owned_by_user_id\" IS NOT NULL AND \"security_remediations\".\"owned_by_organization_id\" IS NULL) OR\n (\"security_remediations\".\"owned_by_user_id\" IS NULL AND \"security_remediations\".\"owned_by_organization_id\" IS NOT NULL)\n )" + }, + "security_remediations_status_check": { + "name": "security_remediations_status_check", + "value": "\"security_remediations\".\"status\" IN ('queued', 'running', 'pr_opened', 'failed', 'blocked', 'no_changes_needed', 'cancelled')" + } + }, + "isRLSEnabled": false + }, + "public.shared_cli_sessions": { + "name": "shared_cli_sessions", + "schema": "", + "columns": { + "share_id": { + "name": "share_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "session_id": { + "name": "session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shared_state": { + "name": "shared_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "api_conversation_history_blob_url": { + "name": "api_conversation_history_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "task_metadata_blob_url": { + "name": "task_metadata_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ui_messages_blob_url": { + "name": "ui_messages_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_state_blob_url": { + "name": "git_state_blob_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_shared_cli_sessions_session_id": { + "name": "IDX_shared_cli_sessions_session_id", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_shared_cli_sessions_created_at": { + "name": "IDX_shared_cli_sessions_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_cli_sessions_session_id_cli_sessions_session_id_fk": { + "name": "shared_cli_sessions_session_id_cli_sessions_session_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "cli_sessions", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "session_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk": { + "name": "shared_cli_sessions_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "shared_cli_sessions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shared_cli_sessions_shared_state_check": { + "name": "shared_cli_sessions_shared_state_check", + "value": "\"shared_cli_sessions\".\"shared_state\" IN ('public', 'organization')" + } + }, + "isRLSEnabled": false + }, + "public.slack_bot_requests": { + "name": "slack_bot_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_team_name": { + "name": "slack_team_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_thread_ts": { + "name": "slack_thread_ts", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_message_truncated": { + "name": "user_message_truncated", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "model_used": { + "name": "model_used", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool_calls_made": { + "name": "tool_calls_made", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "cloud_agent_session_id": { + "name": "cloud_agent_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_slack_bot_requests_created_at": { + "name": "idx_slack_bot_requests_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_slack_team_id": { + "name": "idx_slack_bot_requests_slack_team_id", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_org_id": { + "name": "idx_slack_bot_requests_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_owned_by_user_id": { + "name": "idx_slack_bot_requests_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_status": { + "name": "idx_slack_bot_requests_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_event_type": { + "name": "idx_slack_bot_requests_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_slack_bot_requests_team_created": { + "name": "idx_slack_bot_requests_team_created", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_bot_requests_owned_by_organization_id_organizations_id_fk": { + "name": "slack_bot_requests_owned_by_organization_id_organizations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk": { + "name": "slack_bot_requests_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slack_bot_requests_platform_integration_id_platform_integrations_id_fk": { + "name": "slack_bot_requests_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "slack_bot_requests", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_bot_requests_owner_check": { + "name": "slack_bot_requests_owner_check", + "value": "(\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NOT NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NOT NULL) OR\n (\"slack_bot_requests\".\"owned_by_user_id\" IS NULL AND \"slack_bot_requests\".\"owned_by_organization_id\" IS NULL)\n )" + } + }, + "isRLSEnabled": false + }, + "public.slack_oauth_credentials": { + "name": "slack_oauth_credentials", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "platform_integration_id": { + "name": "platform_integration_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slack_team_id": { + "name": "slack_team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_enterprise_id": { + "name": "slack_enterprise_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_enterprise_install": { + "name": "is_enterprise_install", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "bot_user_id": { + "name": "bot_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_scopes": { + "name": "granted_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "refresh_claimed_at": { + "name": "refresh_claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_attempt_count": { + "name": "refresh_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_refresh_attempt_at": { + "name": "next_refresh_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_slack_oauth_credentials_platform_integration_id": { + "name": "UQ_slack_oauth_credentials_platform_integration_id", + "columns": [ + { + "expression": "platform_integration_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_slack_oauth_credentials_slack_team_id": { + "name": "IDX_slack_oauth_credentials_slack_team_id", + "columns": [ + { + "expression": "slack_team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_slack_oauth_credentials_refresh_due": { + "name": "IDX_slack_oauth_credentials_refresh_due", + "columns": [ + { + "expression": "access_token_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"slack_oauth_credentials\".\"revoked_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slack_oauth_credentials_platform_integration_id_platform_integrations_id_fk": { + "name": "slack_oauth_credentials_platform_integration_id_platform_integrations_id_fk", + "tableFrom": "slack_oauth_credentials", + "tableTo": "platform_integrations", + "columnsFrom": [ + "platform_integration_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "slack_oauth_credentials_credential_version_check": { + "name": "slack_oauth_credentials_credential_version_check", + "value": "\"slack_oauth_credentials\".\"credential_version\" > 0" + }, + "slack_oauth_credentials_refresh_attempt_count_check": { + "name": "slack_oauth_credentials_refresh_attempt_count_check", + "value": "\"slack_oauth_credentials\".\"refresh_attempt_count\" >= 0" + }, + "slack_oauth_credentials_slack_team_id_check": { + "name": "slack_oauth_credentials_slack_team_id_check", + "value": "\"slack_oauth_credentials\".\"slack_team_id\" <> ''" + } + }, + "isRLSEnabled": false + }, + "public.source_embeddings": { + "name": "source_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_hash": { + "name": "file_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start_line": { + "name": "start_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_line": { + "name": "end_line", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "git_branch": { + "name": "git_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_base_branch": { + "name": "is_base_branch", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_source_embeddings_organization_id": { + "name": "IDX_source_embeddings_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_kilo_user_id": { + "name": "IDX_source_embeddings_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_project_id": { + "name": "IDX_source_embeddings_project_id", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_created_at": { + "name": "IDX_source_embeddings_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_updated_at": { + "name": "IDX_source_embeddings_updated_at", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_file_path_lower": { + "name": "IDX_source_embeddings_file_path_lower", + "columns": [ + { + "expression": "LOWER(\"file_path\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_git_branch": { + "name": "IDX_source_embeddings_git_branch", + "columns": [ + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_source_embeddings_org_project_branch": { + "name": "IDX_source_embeddings_org_project_branch", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "git_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "source_embeddings_organization_id_organizations_id_fk": { + "name": "source_embeddings_organization_id_organizations_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "source_embeddings_kilo_user_id_kilocode_users_id_fk": { + "name": "source_embeddings_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "source_embeddings", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_source_embeddings_org_project_branch_file_lines": { + "name": "UQ_source_embeddings_org_project_branch_file_lines", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "project_id", + "git_branch", + "file_path", + "start_line", + "end_line" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_dispute_actions": { + "name": "stripe_dispute_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result_code": { + "name": "result_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_reference_id": { + "name": "result_reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_dispute_actions_case_id": { + "name": "IDX_stripe_dispute_actions_case_id", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_actions_claim_path": { + "name": "IDX_stripe_dispute_actions_claim_path", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_dispute_actions_case_id_stripe_dispute_cases_id_fk": { + "name": "stripe_dispute_actions_case_id_stripe_dispute_cases_id_fk", + "tableFrom": "stripe_dispute_actions", + "tableTo": "stripe_dispute_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_dispute_actions_case_type_target": { + "name": "UQ_stripe_dispute_actions_case_type_target", + "nullsNotDistinct": false, + "columns": [ + "case_id", + "action_type", + "target_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_dispute_actions_action_type_check": { + "name": "stripe_dispute_actions_action_type_check", + "value": "\"stripe_dispute_actions\".\"action_type\" IN ('stripe_acceptance', 'user_block', 'auto_top_up_disable', 'credit_balance_reset', 'subscription_cancellation', 'access_termination', 'kiloclaw_suspension')" + }, + "stripe_dispute_actions_status_check": { + "name": "stripe_dispute_actions_status_check", + "value": "\"stripe_dispute_actions\".\"status\" IN ('queued', 'processing', 'completed', 'failed', 'skipped')" + }, + "stripe_dispute_actions_attempt_count_non_negative_check": { + "name": "stripe_dispute_actions_attempt_count_non_negative_check", + "value": "\"stripe_dispute_actions\".\"attempt_count\" >= 0" + }, + "stripe_dispute_actions_target_key_not_empty_check": { + "name": "stripe_dispute_actions_target_key_not_empty_check", + "value": "length(\"stripe_dispute_actions\".\"target_key\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_dispute_cases": { + "name": "stripe_dispute_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_dispute_id": { + "name": "stripe_dispute_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_event_created_at": { + "name": "stripe_event_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor_units": { + "name": "amount_minor_units", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dispute_reason": { + "name": "dispute_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_status": { + "name": "stripe_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_classification": { + "name": "owner_classification", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'needs_action'" + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_created_at": { + "name": "stripe_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "evidence_due_by": { + "name": "evidence_due_by", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_by_kilo_user_id": { + "name": "accepted_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "acceptance_started_at": { + "name": "acceptance_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enforcement_completed_at": { + "name": "enforcement_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_required_at": { + "name": "review_required_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_dispute_cases_event_id": { + "name": "IDX_stripe_dispute_cases_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_charge_id": { + "name": "IDX_stripe_dispute_cases_charge_id", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_payment_intent_id": { + "name": "IDX_stripe_dispute_cases_payment_intent_id", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_customer_id": { + "name": "IDX_stripe_dispute_cases_customer_id", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_kilo_user_id": { + "name": "IDX_stripe_dispute_cases_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_organization_id": { + "name": "IDX_stripe_dispute_cases_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_dispute_cases_status_due_by": { + "name": "IDX_stripe_dispute_cases_status_due_by", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "evidence_due_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stripe_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_dispute_cases_kilo_user_id_kilocode_users_id_fk": { + "name": "stripe_dispute_cases_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "stripe_dispute_cases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "stripe_dispute_cases_organization_id_organizations_id_fk": { + "name": "stripe_dispute_cases_organization_id_organizations_id_fk", + "tableFrom": "stripe_dispute_cases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "stripe_dispute_cases_accepted_by_kilo_user_id_kilocode_users_id_fk": { + "name": "stripe_dispute_cases_accepted_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "stripe_dispute_cases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "accepted_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_dispute_cases_dispute_id": { + "name": "UQ_stripe_dispute_cases_dispute_id", + "nullsNotDistinct": false, + "columns": [ + "stripe_dispute_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_dispute_cases_owner_classification_check": { + "name": "stripe_dispute_cases_owner_classification_check", + "value": "\"stripe_dispute_cases\".\"owner_classification\" IN ('personal', 'organization', 'ambiguous', 'unmatched')" + }, + "stripe_dispute_cases_status_check": { + "name": "stripe_dispute_cases_status_check", + "value": "\"stripe_dispute_cases\".\"status\" IN ('needs_action', 'processing', 'accepted', 'acceptance_failed', 'enforcement_failed', 'review_required', 'closed')" + }, + "stripe_dispute_cases_amount_minor_units_non_negative_check": { + "name": "stripe_dispute_cases_amount_minor_units_non_negative_check", + "value": "\"stripe_dispute_cases\".\"amount_minor_units\" IS NULL OR \"stripe_dispute_cases\".\"amount_minor_units\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_early_fraud_warning_actions": { + "name": "stripe_early_fraud_warning_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "case_id": { + "name": "case_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "action_type": { + "name": "action_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_key": { + "name": "target_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "result_code": { + "name": "result_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_reference_id": { + "name": "result_reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_early_fraud_warning_actions_case_id": { + "name": "IDX_stripe_early_fraud_warning_actions_case_id", + "columns": [ + { + "expression": "case_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_actions_claim_path": { + "name": "IDX_stripe_early_fraud_warning_actions_claim_path", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_early_fraud_warning_actions_case_id_stripe_early_fraud_warning_cases_id_fk": { + "name": "stripe_early_fraud_warning_actions_case_id_stripe_early_fraud_warning_cases_id_fk", + "tableFrom": "stripe_early_fraud_warning_actions", + "tableTo": "stripe_early_fraud_warning_cases", + "columnsFrom": [ + "case_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_early_fraud_warning_actions_case_type_target": { + "name": "UQ_stripe_early_fraud_warning_actions_case_type_target", + "nullsNotDistinct": false, + "columns": [ + "case_id", + "action_type", + "target_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_early_fraud_warning_actions_action_type_check": { + "name": "stripe_early_fraud_warning_actions_action_type_check", + "value": "\"stripe_early_fraud_warning_actions\".\"action_type\" IN ('containment', 'refund', 'payment_value_clawback', 'subscription_termination', 'access_termination', 'kiloclaw_suspension', 'affiliate_payout_reversal', 'referral_reward_reversal', 'user_notice')" + }, + "stripe_early_fraud_warning_actions_status_check": { + "name": "stripe_early_fraud_warning_actions_status_check", + "value": "\"stripe_early_fraud_warning_actions\".\"status\" IN ('queued', 'processing', 'completed', 'failed', 'review_required', 'dismissed')" + }, + "stripe_early_fraud_warning_actions_attempt_count_non_negative_check": { + "name": "stripe_early_fraud_warning_actions_attempt_count_non_negative_check", + "value": "\"stripe_early_fraud_warning_actions\".\"attempt_count\" >= 0" + }, + "stripe_early_fraud_warning_actions_target_key_not_empty_check": { + "name": "stripe_early_fraud_warning_actions_target_key_not_empty_check", + "value": "length(\"stripe_early_fraud_warning_actions\".\"target_key\") > 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_early_fraud_warning_cases": { + "name": "stripe_early_fraud_warning_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "stripe_early_fraud_warning_id": { + "name": "stripe_early_fraud_warning_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_event_id": { + "name": "stripe_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor_units": { + "name": "amount_minor_units", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_classification": { + "name": "owner_classification", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_context": { + "name": "failure_context", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "warning_created_at": { + "name": "warning_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contained_at": { + "name": "contained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "review_required_at": { + "name": "review_required_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "remediated_at": { + "name": "remediated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_stripe_early_fraud_warning_cases_event_id": { + "name": "IDX_stripe_early_fraud_warning_cases_event_id", + "columns": [ + { + "expression": "stripe_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_charge_id": { + "name": "IDX_stripe_early_fraud_warning_cases_charge_id", + "columns": [ + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_payment_intent_id": { + "name": "IDX_stripe_early_fraud_warning_cases_payment_intent_id", + "columns": [ + { + "expression": "stripe_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_customer_id": { + "name": "IDX_stripe_early_fraud_warning_cases_customer_id", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_kilo_user_id": { + "name": "IDX_stripe_early_fraud_warning_cases_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_organization_id": { + "name": "IDX_stripe_early_fraud_warning_cases_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_stripe_early_fraud_warning_cases_status_created_at": { + "name": "IDX_stripe_early_fraud_warning_cases_status_created_at", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_early_fraud_warning_cases_kilo_user_id_kilocode_users_id_fk": { + "name": "stripe_early_fraud_warning_cases_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "stripe_early_fraud_warning_cases", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + }, + "stripe_early_fraud_warning_cases_organization_id_organizations_id_fk": { + "name": "stripe_early_fraud_warning_cases_organization_id_organizations_id_fk", + "tableFrom": "stripe_early_fraud_warning_cases", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_stripe_early_fraud_warning_cases_warning_id": { + "name": "UQ_stripe_early_fraud_warning_cases_warning_id", + "nullsNotDistinct": false, + "columns": [ + "stripe_early_fraud_warning_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "stripe_early_fraud_warning_cases_owner_classification_check": { + "name": "stripe_early_fraud_warning_cases_owner_classification_check", + "value": "\"stripe_early_fraud_warning_cases\".\"owner_classification\" IN ('personal', 'organization', 'ambiguous', 'unmatched')" + }, + "stripe_early_fraud_warning_cases_status_check": { + "name": "stripe_early_fraud_warning_cases_status_check", + "value": "\"stripe_early_fraud_warning_cases\".\"status\" IN ('queued', 'contained', 'processing', 'completed', 'review_required', 'failed', 'remediated', 'dismissed')" + }, + "stripe_early_fraud_warning_cases_amount_minor_units_non_negative_check": { + "name": "stripe_early_fraud_warning_cases_amount_minor_units_non_negative_check", + "value": "\"stripe_early_fraud_warning_cases\".\"amount_minor_units\" IS NULL OR \"stripe_early_fraud_warning_cases\".\"amount_minor_units\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stytch_fingerprints": { + "name": "stytch_fingerprints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_fingerprint": { + "name": "visitor_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_fingerprint": { + "name": "browser_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "browser_id": { + "name": "browser_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hardware_fingerprint": { + "name": "hardware_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "network_fingerprint": { + "name": "network_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visitor_id": { + "name": "visitor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verdict_action": { + "name": "verdict_action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detected_device_type": { + "name": "detected_device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_authentic_device": { + "name": "is_authentic_device", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "reasons": { + "name": "reasons", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"\"}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "fingerprint_data": { + "name": "fingerprint_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "kilo_free_tier_allowed": { + "name": "kilo_free_tier_allowed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_hardware_fingerprint": { + "name": "idx_hardware_fingerprint", + "columns": [ + { + "expression": "hardware_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_kilo_user_id": { + "name": "idx_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_stytch_fingerprints_reasons_gin": { + "name": "idx_stytch_fingerprints_reasons_gin", + "columns": [ + { + "expression": "reasons", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_verdict_action": { + "name": "idx_verdict_action", + "columns": [ + { + "expression": "verdict_action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_visitor_fingerprint": { + "name": "idx_visitor_fingerprint", + "columns": [ + { + "expression": "visitor_fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompt_prefix": { + "name": "system_prompt_prefix", + "schema": "", + "columns": { + "system_prompt_prefix_id": { + "name": "system_prompt_prefix_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_system_prompt_prefix": { + "name": "UQ_system_prompt_prefix", + "columns": [ + { + "expression": "system_prompt_prefix", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactional_email_log": { + "name": "transactional_email_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_type": { + "name": "email_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_transactional_email_log_type_idempotency_key": { + "name": "UQ_transactional_email_log_type_idempotency_key", + "columns": [ + { + "expression": "email_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_transactional_email_log_user_id": { + "name": "IDX_transactional_email_log_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_transactional_email_log_organization_id": { + "name": "IDX_transactional_email_log_organization_id", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactional_email_log_user_id_kilocode_users_id_fk": { + "name": "transactional_email_log_user_id_kilocode_users_id_fk", + "tableFrom": "transactional_email_log", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactional_email_log_organization_id_organizations_id_fk": { + "name": "transactional_email_log_organization_id_organizations_id_fk", + "tableFrom": "transactional_email_log", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "CHK_transactional_email_log_owner": { + "name": "CHK_transactional_email_log_owner", + "value": "\"transactional_email_log\".\"user_id\" IS NOT NULL OR \"transactional_email_log\".\"organization_id\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.user_admin_notes": { + "name": "user_admin_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note_content": { + "name": "note_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "admin_kilo_user_id": { + "name": "admin_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_34517df0b385234babc38fe81b": { + "name": "IDX_34517df0b385234babc38fe81b", + "columns": [ + { + "expression": "admin_kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_ccbde98c4c14046daa5682ec4f": { + "name": "IDX_ccbde98c4c14046daa5682ec4f", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_d0270eb24ef6442d65a0b7853c": { + "name": "IDX_d0270eb24ef6442d65a0b7853c", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_affiliate_attributions": { + "name": "user_affiliate_attributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tracking_id": { + "name": "tracking_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_attributions_user_id": { + "name": "IDX_user_affiliate_attributions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_attributions_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_attributions_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_attributions", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_attributions_user_provider": { + "name": "UQ_user_affiliate_attributions_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_attributions_provider_check": { + "name": "user_affiliate_attributions_provider_check", + "value": "\"user_affiliate_attributions\".\"provider\" IN ('impact')" + } + }, + "isRLSEnabled": false + }, + "public.user_affiliate_events": { + "name": "user_affiliate_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_event_id": { + "name": "parent_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_state": { + "name": "delivery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "payload_json": { + "name": "payload_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stripe_charge_id": { + "name": "stripe_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_action_id": { + "name": "impact_action_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impact_submission_uri": { + "name": "impact_submission_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_retry_at": { + "name": "next_retry_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_affiliate_events_claim_path": { + "name": "IDX_user_affiliate_events_claim_path", + "columns": [ + { + "expression": "delivery_state", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"next_retry_at\", '-infinity'::timestamptz)", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_parent_event_id": { + "name": "IDX_user_affiliate_events_parent_event_id", + "columns": [ + { + "expression": "parent_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_affiliate_events_provider_event_type_charge": { + "name": "IDX_user_affiliate_events_provider_event_type_charge", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stripe_charge_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_affiliate_events_user_id_kilocode_users_id_fk": { + "name": "user_affiliate_events_user_id_kilocode_users_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "user_affiliate_events_parent_event_id_fk": { + "name": "user_affiliate_events_parent_event_id_fk", + "tableFrom": "user_affiliate_events", + "tableTo": "user_affiliate_events", + "columnsFrom": [ + "parent_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_affiliate_events_dedupe_key": { + "name": "UQ_user_affiliate_events_dedupe_key", + "nullsNotDistinct": false, + "columns": [ + "dedupe_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_affiliate_events_provider_check": { + "name": "user_affiliate_events_provider_check", + "value": "\"user_affiliate_events\".\"provider\" IN ('impact')" + }, + "user_affiliate_events_event_type_check": { + "name": "user_affiliate_events_event_type_check", + "value": "\"user_affiliate_events\".\"event_type\" IN ('signup', 'trial_start', 'trial_end', 'sale', 'sale_reversal')" + }, + "user_affiliate_events_delivery_state_check": { + "name": "user_affiliate_events_delivery_state_check", + "value": "\"user_affiliate_events\".\"delivery_state\" IN ('queued', 'blocked', 'sending', 'delivered', 'failed')" + }, + "user_affiliate_events_attempt_count_non_negative_check": { + "name": "user_affiliate_events_attempt_count_non_negative_check", + "value": "\"user_affiliate_events\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_auth_provider": { + "name": "user_auth_provider", + "schema": "", + "columns": { + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hosted_domain": { + "name": "hosted_domain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_auth_provider_kilo_user_id": { + "name": "IDX_user_auth_provider_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_auth_provider_hosted_domain": { + "name": "IDX_user_auth_provider_hosted_domain", + "columns": [ + { + "expression": "hosted_domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_auth_provider_lower_email": { + "name": "IDX_user_auth_provider_lower_email", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": true, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_auth_provider_provider_provider_account_id_pk": { + "name": "user_auth_provider_provider_provider_account_id_pk", + "columns": [ + "provider", + "provider_account_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_data_export_object_deletions": { + "name": "user_data_export_object_deletions", + "schema": "", + "columns": { + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'account_deletion'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_data_export_object_deletions_ready": { + "name": "IDX_user_data_export_object_deletions_ready", + "columns": [ + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_data_export_object_deletions_reason_check": { + "name": "user_data_export_object_deletions_reason_check", + "value": "\"user_data_export_object_deletions\".\"reason\" IN ('account_deletion', 'admin_cancel', 'admin_replace')" + }, + "user_data_export_object_deletions_attempt_count_nonnegative": { + "name": "user_data_export_object_deletions_attempt_count_nonnegative", + "value": "\"user_data_export_object_deletions\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_data_export_outbox": { + "name": "user_data_export_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generate'" + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_data_export_outbox_pending": { + "name": "IDX_user_data_export_outbox_pending", + "columns": [ + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_data_export_outbox\".\"sent_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_data_export_outbox_export_id_user_data_exports_id_fk": { + "name": "user_data_export_outbox_export_id_user_data_exports_id_fk", + "tableFrom": "user_data_export_outbox", + "tableTo": "user_data_exports", + "columnsFrom": [ + "export_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_data_export_outbox_generation_operation": { + "name": "UQ_user_data_export_outbox_generation_operation", + "nullsNotDistinct": false, + "columns": [ + "export_id", + "generation", + "operation" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_data_export_outbox_operation_check": { + "name": "user_data_export_outbox_operation_check", + "value": "\"user_data_export_outbox\".\"operation\" = 'generate'" + }, + "user_data_export_outbox_generation_nonnegative": { + "name": "user_data_export_outbox_generation_nonnegative", + "value": "\"user_data_export_outbox\".\"generation\" >= 0" + }, + "user_data_export_outbox_attempt_count_nonnegative": { + "name": "user_data_export_outbox_attempt_count_nonnegative", + "value": "\"user_data_export_outbox\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_data_export_parts": { + "name": "user_data_export_parts", + "schema": "", + "columns": { + "export_id": { + "name": "export_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "part_number": { + "name": "part_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_data_export_parts_export_id_user_data_exports_id_fk": { + "name": "user_data_export_parts_export_id_user_data_exports_id_fk", + "tableFrom": "user_data_export_parts", + "tableTo": "user_data_exports", + "columnsFrom": [ + "export_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_data_export_parts_export_id_part_number_pk": { + "name": "user_data_export_parts_export_id_part_number_pk", + "columns": [ + "export_id", + "part_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_data_export_parts_part_number_positive": { + "name": "user_data_export_parts_part_number_positive", + "value": "\"user_data_export_parts\".\"part_number\" > 0" + }, + "user_data_export_parts_size_bytes_nonnegative": { + "name": "user_data_export_parts_size_bytes_nonnegative", + "value": "\"user_data_export_parts\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.user_data_exports": { + "name": "user_data_exports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "snapshot_at": { + "name": "snapshot_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "current_source": { + "name": "current_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_cursor": { + "name": "source_cursor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "multipart_upload_id": { + "name": "multipart_upload_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_part_number": { + "name": "next_part_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "dispatch_generation": { + "name": "dispatch_generation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lease_token": { + "name": "lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "row_count": { + "name": "row_count", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "r2_etag": { + "name": "r2_etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_redacted": { + "name": "last_error_redacted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email_status": { + "name": "email_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "email_attempt_count": { + "name": "email_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "email_lease_token": { + "name": "email_lease_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "email_lease_expires_at": { + "name": "email_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email_sent_at": { + "name": "email_sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_data_exports_single_active": { + "name": "UQ_user_data_exports_single_active", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_data_exports\".\"status\" IN ('queued', 'processing', 'finalizing') AND \"user_data_exports\".\"subject_type\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_data_exports_single_active_org": { + "name": "UQ_user_data_exports_single_active_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_data_exports\".\"status\" IN ('queued', 'processing', 'finalizing') AND \"user_data_exports\".\"subject_type\" = 'organization'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_data_exports_user_created": { + "name": "IDX_user_data_exports_user_created", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_data_exports_org_created": { + "name": "IDX_user_data_exports_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_data_exports\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_data_exports_lease_expiry": { + "name": "IDX_user_data_exports_lease_expiry", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_data_exports\".\"status\" IN ('processing', 'finalizing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_data_exports_ready_expiry": { + "name": "IDX_user_data_exports_ready_expiry", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_data_exports\".\"status\" = 'ready'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_data_exports_failed_multipart": { + "name": "IDX_user_data_exports_failed_multipart", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_data_exports\".\"status\" = 'failed' AND \"user_data_exports\".\"multipart_upload_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_data_exports_email_lease_expiry": { + "name": "IDX_user_data_exports_email_lease_expiry", + "columns": [ + { + "expression": "email_lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_data_exports\".\"email_status\" = 'sending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_data_exports_kilo_user_id_kilocode_users_id_fk": { + "name": "user_data_exports_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_data_exports", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "user_data_exports_organization_id_organizations_id_fk": { + "name": "user_data_exports_organization_id_organizations_id_fk", + "tableFrom": "user_data_exports", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_data_exports_status_check": { + "name": "user_data_exports_status_check", + "value": "\"user_data_exports\".\"status\" IN ('queued', 'processing', 'finalizing', 'ready', 'failed', 'expired')" + }, + "user_data_exports_subject_type_check": { + "name": "user_data_exports_subject_type_check", + "value": "\"user_data_exports\".\"subject_type\" IN ('user', 'organization')" + }, + "user_data_exports_subject_shape": { + "name": "user_data_exports_subject_shape", + "value": "(\"user_data_exports\".\"subject_type\" = 'user' AND \"user_data_exports\".\"organization_id\" IS NULL)\n OR (\"user_data_exports\".\"subject_type\" = 'organization' AND \"user_data_exports\".\"organization_id\" IS NOT NULL)" + }, + "user_data_exports_schema_version_positive": { + "name": "user_data_exports_schema_version_positive", + "value": "\"user_data_exports\".\"schema_version\" > 0" + }, + "user_data_exports_next_part_number_positive": { + "name": "user_data_exports_next_part_number_positive", + "value": "\"user_data_exports\".\"next_part_number\" > 0" + }, + "user_data_exports_dispatch_generation_nonnegative": { + "name": "user_data_exports_dispatch_generation_nonnegative", + "value": "\"user_data_exports\".\"dispatch_generation\" >= 0" + }, + "user_data_exports_attempt_count_nonnegative": { + "name": "user_data_exports_attempt_count_nonnegative", + "value": "\"user_data_exports\".\"attempt_count\" >= 0" + }, + "user_data_exports_row_count_nonnegative": { + "name": "user_data_exports_row_count_nonnegative", + "value": "\"user_data_exports\".\"row_count\" >= 0" + }, + "user_data_exports_size_bytes_nonnegative": { + "name": "user_data_exports_size_bytes_nonnegative", + "value": "\"user_data_exports\".\"size_bytes\" IS NULL OR \"user_data_exports\".\"size_bytes\" >= 0" + }, + "user_data_exports_lease_shape": { + "name": "user_data_exports_lease_shape", + "value": "(\"user_data_exports\".\"lease_token\" IS NULL) = (\"user_data_exports\".\"lease_expires_at\" IS NULL)" + }, + "user_data_exports_ready_shape": { + "name": "user_data_exports_ready_shape", + "value": "\"user_data_exports\".\"status\" <> 'ready' OR (\"user_data_exports\".\"r2_object_key\" IS NOT NULL AND \"user_data_exports\".\"size_bytes\" IS NOT NULL AND \"user_data_exports\".\"completed_at\" IS NOT NULL AND \"user_data_exports\".\"expires_at\" IS NOT NULL)" + }, + "user_data_exports_last_error_redacted_length": { + "name": "user_data_exports_last_error_redacted_length", + "value": "\"user_data_exports\".\"last_error_redacted\" IS NULL OR length(\"user_data_exports\".\"last_error_redacted\") <= 500" + }, + "user_data_exports_email_attempt_count_nonnegative": { + "name": "user_data_exports_email_attempt_count_nonnegative", + "value": "\"user_data_exports\".\"email_attempt_count\" >= 0" + }, + "user_data_exports_email_status_check": { + "name": "user_data_exports_email_status_check", + "value": "\"user_data_exports\".\"email_status\" IN ('pending', 'sending', 'sent', 'failed')" + }, + "user_data_exports_email_lease_shape": { + "name": "user_data_exports_email_lease_shape", + "value": "(\"user_data_exports\".\"email_status\" = 'sending') = (\"user_data_exports\".\"email_lease_token\" IS NOT NULL AND \"user_data_exports\".\"email_lease_expires_at\" IS NOT NULL)" + }, + "user_data_exports_email_sent_shape": { + "name": "user_data_exports_email_sent_shape", + "value": "(\"user_data_exports\".\"email_status\" = 'sent') = (\"user_data_exports\".\"email_sent_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.user_deletion_activity": { + "name": "user_deletion_activity", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details_json": { + "name": "details_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_deletion_activity_request_created": { + "name": "IDX_user_deletion_activity_request_created", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_deletion_activity_request_id_user_deletion_requests_id_fk": { + "name": "user_deletion_activity_request_id_user_deletion_requests_id_fk", + "tableFrom": "user_deletion_activity", + "tableTo": "user_deletion_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_deletion_audit_events": { + "name": "user_deletion_audit_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_kilo_user_id": { + "name": "actor_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_email_hmac": { + "name": "target_email_hmac", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_key": { + "name": "subject_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "details_json": { + "name": "details_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_deletion_audit_events_idempotent": { + "name": "UQ_user_deletion_audit_events_idempotent", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_deletion_audit_events\".\"request_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_deletion_audit_events_request_id": { + "name": "IDX_user_deletion_audit_events_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_deletion_audit_events_hmac": { + "name": "IDX_user_deletion_audit_events_hmac", + "columns": [ + { + "expression": "target_email_hmac", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_deletion_audit_events_request_id_user_deletion_requests_id_fk": { + "name": "user_deletion_audit_events_request_id_user_deletion_requests_id_fk", + "tableFrom": "user_deletion_audit_events", + "tableTo": "user_deletion_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_deletion_audit_events_event_type_check": { + "name": "user_deletion_audit_events_event_type_check", + "value": "\"user_deletion_audit_events\".\"event_type\" IN ('request_created', 'intake_refused', 'access_disabled', 'access_absent', 'preflight_disposition', 'task_disposition', 'manual_retry', 'manual_action', 'anonymized', 'deletion_ready_for_customer_reply', 'cancelled', 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.user_deletion_provider_credentials": { + "name": "user_deletion_provider_credentials", + "schema": "", + "columns": { + "provider_scope": { + "name": "provider_scope", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "encrypted_material": { + "name": "encrypted_material", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_kilo_user_id": { + "name": "updated_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_deletion_provider_credentials_updated_by_kilo_user_id_kilocode_users_id_fk": { + "name": "user_deletion_provider_credentials_updated_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_deletion_provider_credentials", + "tableTo": "kilocode_users", + "columnsFrom": [ + "updated_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_deletion_provider_credentials_scope_check": { + "name": "user_deletion_provider_credentials_scope_check", + "value": "\"user_deletion_provider_credentials\".\"provider_scope\" IN ('kiloclaw', 'customerio', 'cloud_storage', 'session_ingest', 'posthog', 'substack', 'pylon', 'csa')" + } + }, + "isRLSEnabled": false + }, + "public.user_deletion_requests": { + "name": "user_deletion_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "catalog_version": { + "name": "catalog_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "requested_by_kilo_user_id": { + "name": "requested_by_kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_by_email": { + "name": "requested_by_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_email": { + "name": "target_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_email_hmac": { + "name": "target_email_hmac", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pylon_ticket_ref": { + "name": "pylon_ticket_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cloud_subject_resolution": { + "name": "cloud_subject_resolution", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cloud_subject_proof_ref": { + "name": "cloud_subject_proof_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preflight_attention_code": { + "name": "preflight_attention_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_progress_at": { + "name": "last_progress_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "anonymized_at": { + "name": "anonymized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "UQ_user_deletion_requests_active_email_hmac": { + "name": "UQ_user_deletion_requests_active_email_hmac", + "columns": [ + { + "expression": "target_email_hmac", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_deletion_requests\".\"target_email_hmac\" IS NOT NULL AND \"user_deletion_requests\".\"status\" IN ('pending', 'in_progress', 'finalizing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_deletion_requests_active_user_id": { + "name": "UQ_user_deletion_requests_active_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_deletion_requests\".\"user_id\" IS NOT NULL AND \"user_deletion_requests\".\"status\" IN ('pending', 'in_progress', 'finalizing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_deletion_requests_active_pylon_ticket": { + "name": "UQ_user_deletion_requests_active_pylon_ticket", + "columns": [ + { + "expression": "regexp_replace(\"pylon_ticket_ref\", '^#', '')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_deletion_requests\".\"pylon_ticket_ref\" IS NOT NULL AND \"user_deletion_requests\".\"status\" IN ('pending', 'in_progress', 'finalizing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_deletion_requests_fairness": { + "name": "IDX_user_deletion_requests_fairness", + "columns": [ + { + "expression": "last_progress_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_deletion_requests\".\"status\" IN ('pending', 'in_progress', 'finalizing')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_deletion_requests_email_hmac": { + "name": "IDX_user_deletion_requests_email_hmac", + "columns": [ + { + "expression": "target_email_hmac", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_deletion_requests_user_id": { + "name": "IDX_user_deletion_requests_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_deletion_requests_user_id_kilocode_users_id_fk": { + "name": "user_deletion_requests_user_id_kilocode_users_id_fk", + "tableFrom": "user_deletion_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_deletion_requests_requested_by_kilo_user_id_kilocode_users_id_fk": { + "name": "user_deletion_requests_requested_by_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_deletion_requests", + "tableTo": "kilocode_users", + "columnsFrom": [ + "requested_by_kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_deletion_requests_status_check": { + "name": "user_deletion_requests_status_check", + "value": "\"user_deletion_requests\".\"status\" IN ('pending', 'in_progress', 'finalizing', 'completed', 'cancelled')" + }, + "user_deletion_requests_cloud_subject_resolution_check": { + "name": "user_deletion_requests_cloud_subject_resolution_check", + "value": "\"user_deletion_requests\".\"cloud_subject_resolution\" IN ('current_user', 'authoritative_absence', 'prior_queue_cleanup', 'legacy_identity_unresolved', 'unresolved')" + }, + "user_deletion_requests_catalog_version_positive": { + "name": "user_deletion_requests_catalog_version_positive", + "value": "\"user_deletion_requests\".\"catalog_version\" >= 1" + }, + "user_deletion_requests_completed_at_check": { + "name": "user_deletion_requests_completed_at_check", + "value": "(\"user_deletion_requests\".\"status\" = 'completed') = (\"user_deletion_requests\".\"completed_at\" IS NOT NULL)" + }, + "user_deletion_requests_cancelled_at_check": { + "name": "user_deletion_requests_cancelled_at_check", + "value": "(\"user_deletion_requests\".\"status\" = 'cancelled') = (\"user_deletion_requests\".\"cancelled_at\" IS NOT NULL)" + }, + "user_deletion_requests_active_email_check": { + "name": "user_deletion_requests_active_email_check", + "value": "(\"user_deletion_requests\".\"status\" NOT IN ('in_progress', 'finalizing') OR \"user_deletion_requests\".\"target_email\" IS NOT NULL) AND (\"user_deletion_requests\".\"status\" NOT IN ('completed', 'cancelled') OR \"user_deletion_requests\".\"target_email\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.user_deletion_steps": { + "name": "user_deletion_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_key": { + "name": "step_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claim_token": { + "name": "claim_token", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claimed_until": { + "name": "claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "window_attempt_count": { + "name": "window_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_attempt_count": { + "name": "lifetime_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "progress_json": { + "name": "progress_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rate_limited_since": { + "name": "rate_limited_since", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "manual_evidence_json": { + "name": "manual_evidence_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_deletion_steps_due": { + "name": "IDX_user_deletion_steps_due", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_deletion_steps\".\"status\" IN ('pending', 'retry_wait', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_deletion_steps_request_id": { + "name": "IDX_user_deletion_steps_request_id", + "columns": [ + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_deletion_steps_request_id_user_deletion_requests_id_fk": { + "name": "user_deletion_steps_request_id_user_deletion_requests_id_fk", + "tableFrom": "user_deletion_steps", + "tableTo": "user_deletion_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_user_deletion_steps_request_step": { + "name": "UQ_user_deletion_steps_request_step", + "nullsNotDistinct": false, + "columns": [ + "request_id", + "step_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_deletion_steps_step_key_check": { + "name": "user_deletion_steps_step_key_check", + "value": "\"user_deletion_steps\".\"step_key\" IN ('kiloclaw_destroy', 'customerio', 'cli_v1_blobs', 'cli_v2_sessions', 'usage_prompt_prefixes', 'posthog', 'substack', 'anonymize', 'pylon_reply', 'pylon_finalize', 'completion_email', 'pylon_contact', 'csa_support_db')" + }, + "user_deletion_steps_status_check": { + "name": "user_deletion_steps_status_check", + "value": "\"user_deletion_steps\".\"status\" IN ('pending', 'running', 'retry_wait', 'needs_attention', 'manual_action_required', 'succeeded', 'not_applicable', 'manually_verified')" + }, + "user_deletion_steps_window_attempt_count_nonnegative": { + "name": "user_deletion_steps_window_attempt_count_nonnegative", + "value": "\"user_deletion_steps\".\"window_attempt_count\" >= 0" + }, + "user_deletion_steps_lifetime_attempt_count_nonnegative": { + "name": "user_deletion_steps_lifetime_attempt_count_nonnegative", + "value": "\"user_deletion_steps\".\"lifetime_attempt_count\" >= 0" + }, + "user_deletion_steps_claim_fields_check": { + "name": "user_deletion_steps_claim_fields_check", + "value": "(\"user_deletion_steps\".\"claim_token\" IS NULL) = (\"user_deletion_steps\".\"claimed_until\" IS NULL)" + }, + "user_deletion_steps_manual_evidence_check": { + "name": "user_deletion_steps_manual_evidence_check", + "value": "(\"user_deletion_steps\".\"status\" = 'manually_verified') = (\"user_deletion_steps\".\"manual_evidence_json\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.user_feedback": { + "name": "user_feedback", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feedback_text": { + "name": "feedback_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feedback_for": { + "name": "feedback_for", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "feedback_batch": { + "name": "feedback_batch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "context_json": { + "name": "context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_user_feedback_created_at": { + "name": "IDX_user_feedback_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_kilo_user_id": { + "name": "IDX_user_feedback_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_for": { + "name": "IDX_user_feedback_feedback_for", + "columns": [ + { + "expression": "feedback_for", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_feedback_batch": { + "name": "IDX_user_feedback_feedback_batch", + "columns": [ + { + "expression": "feedback_batch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_feedback_source": { + "name": "IDX_user_feedback_source", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_feedback_kilo_user_id_kilocode_users_id_fk": { + "name": "user_feedback_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_feedback", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_github_app_tokens": { + "name": "user_github_app_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_app_type": { + "name": "github_app_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "github_user_id": { + "name": "github_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "github_login": { + "name": "github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "credential_version": { + "name": "credential_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revocation_reason": { + "name": "revocation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_github_app_tokens_user_app": { + "name": "UQ_user_github_app_tokens_user_app", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_github_app_tokens_github_user_app": { + "name": "UQ_user_github_app_tokens_github_user_app", + "columns": [ + { + "expression": "github_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "github_app_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_github_app_tokens_kilo_user_id_kilocode_users_id_fk": { + "name": "user_github_app_tokens_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_github_app_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_github_app_tokens_app_type_check": { + "name": "user_github_app_tokens_app_type_check", + "value": "\"user_github_app_tokens\".\"github_app_type\" IN ('standard', 'lite')" + } + }, + "isRLSEnabled": false + }, + "public.user_model_preferences": { + "name": "user_model_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "favorites": { + "name": "favorites", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_selected": { + "name": "last_selected", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_model_preferences_user_id": { + "name": "UQ_user_model_preferences_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_model_preferences_user_id_kilocode_users_id_fk": { + "name": "user_model_preferences_user_id_kilocode_users_id_fk", + "tableFrom": "user_model_preferences", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_moderation_blocks": { + "name": "user_moderation_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "blocked_github_login": { + "name": "blocked_github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_moderation_blocks_blocker_login": { + "name": "UQ_user_moderation_blocks_blocker_login", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "blocked_github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_moderation_mutes": { + "name": "user_moderation_mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "blocker_user_id": { + "name": "blocker_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "muted_github_login": { + "name": "muted_github_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_moderation_mutes_blocker_login": { + "name": "UQ_user_moderation_mutes_blocker_login", + "columns": [ + { + "expression": "blocker_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "muted_github_login", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notification_preferences": { + "name": "user_notification_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_push_enabled": { + "name": "agent_push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "chat_messages_enabled": { + "name": "chat_messages_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "agent_attention_enabled": { + "name": "agent_attention_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "session_status_enabled": { + "name": "session_status_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "kiloclaw_activity_enabled": { + "name": "kiloclaw_activity_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "balance_alerts_enabled": { + "name": "balance_alerts_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "security_findings_enabled": { + "name": "security_findings_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "notification_previews": { + "name": "notification_previews", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'generic'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_notification_preferences_user_id_kilocode_users_id_fk": { + "name": "user_notification_preferences_user_id_kilocode_users_id_fk", + "tableFrom": "user_notification_preferences", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_period_cache": { + "name": "user_period_cache", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cache_type": { + "name": "cache_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_type": { + "name": "period_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "computed_at": { + "name": "computed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "shared_url_token": { + "name": "shared_url_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shared_at": { + "name": "shared_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "IDX_user_period_cache_kilo_user_id": { + "name": "IDX_user_period_cache_kilo_user_id", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache": { + "name": "UQ_user_period_cache", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_period_cache_lookup": { + "name": "IDX_user_period_cache_lookup", + "columns": [ + { + "expression": "cache_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "UQ_user_period_cache_share_token": { + "name": "UQ_user_period_cache_share_token", + "columns": [ + { + "expression": "shared_url_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_period_cache\".\"shared_url_token\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_period_cache_kilo_user_id_kilocode_users_id_fk": { + "name": "user_period_cache_kilo_user_id_kilocode_users_id_fk", + "tableFrom": "user_period_cache", + "tableTo": "kilocode_users", + "columnsFrom": [ + "kilo_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "user_period_cache_period_type_check": { + "name": "user_period_cache_period_type_check", + "value": "\"user_period_cache\".\"period_type\" IN ('year', 'quarter', 'month', 'week', 'custom')" + } + }, + "isRLSEnabled": false + }, + "public.user_push_tokens": { + "name": "user_push_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_push_tokens_token": { + "name": "UQ_user_push_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_user_push_tokens_user_id": { + "name": "IDX_user_push_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_push_tokens_user_id_kilocode_users_id_fk": { + "name": "user_push_tokens_user_id_kilocode_users_id_fk", + "tableFrom": "user_push_tokens", + "tableTo": "kilocode_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_terms_acceptances": { + "name": "user_terms_acceptances", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "age_posture": { + "name": "age_posture", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'13_plus'" + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "UQ_user_terms_acceptances_user_version": { + "name": "UQ_user_terms_acceptances_user_version", + "columns": [ + { + "expression": "kilo_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terms_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_city": { + "name": "vercel_ip_city", + "schema": "", + "columns": { + "vercel_ip_city_id": { + "name": "vercel_ip_city_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_city": { + "name": "vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_city": { + "name": "UQ_vercel_ip_city", + "columns": [ + { + "expression": "vercel_ip_city", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vercel_ip_country": { + "name": "vercel_ip_country", + "schema": "", + "columns": { + "vercel_ip_country_id": { + "name": "vercel_ip_country_id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "vercel_ip_country": { + "name": "vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "UQ_vercel_ip_country": { + "name": "UQ_vercel_ip_country", + "columns": [ + { + "expression": "vercel_ip_country", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "pg_catalog.gen_random_uuid()" + }, + "owned_by_organization_id": { + "name": "owned_by_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "owned_by_user_id": { + "name": "owned_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_action": { + "name": "event_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "handlers_triggered": { + "name": "handlers_triggered", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "errors": { + "name": "errors", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "event_signature": { + "name": "event_signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "IDX_webhook_events_owned_by_org_id": { + "name": "IDX_webhook_events_owned_by_org_id", + "columns": [ + { + "expression": "owned_by_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_owned_by_user_id": { + "name": "IDX_webhook_events_owned_by_user_id", + "columns": [ + { + "expression": "owned_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_platform": { + "name": "IDX_webhook_events_platform", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_event_type": { + "name": "IDX_webhook_events_event_type", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "IDX_webhook_events_created_at": { + "name": "IDX_webhook_events_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_owned_by_organization_id_organizations_id_fk": { + "name": "webhook_events_owned_by_organization_id_organizations_id_fk", + "tableFrom": "webhook_events", + "tableTo": "organizations", + "columnsFrom": [ + "owned_by_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_events_owned_by_user_id_kilocode_users_id_fk": { + "name": "webhook_events_owned_by_user_id_kilocode_users_id_fk", + "tableFrom": "webhook_events", + "tableTo": "kilocode_users", + "columnsFrom": [ + "owned_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "UQ_webhook_events_signature": { + "name": "UQ_webhook_events_signature", + "nullsNotDistinct": false, + "columns": [ + "event_signature" + ] + } + }, + "policies": {}, + "checkConstraints": { + "webhook_events_owner_check": { + "name": "webhook_events_owner_check", + "value": "(\n (\"webhook_events\".\"owned_by_user_id\" IS NOT NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NULL) OR\n (\"webhook_events\".\"owned_by_user_id\" IS NULL AND \"webhook_events\".\"owned_by_organization_id\" IS NOT NULL)\n )" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.microdollar_usage_view": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kilo_user_id": { + "name": "kilo_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "input_tokens": { + "name": "input_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "output_tokens": { + "name": "output_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "cache_hit_tokens": { + "name": "cache_hit_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "http_x_forwarded_for": { + "name": "http_x_forwarded_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_city": { + "name": "http_x_vercel_ip_city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_country": { + "name": "http_x_vercel_ip_country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_latitude": { + "name": "http_x_vercel_ip_latitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ip_longitude": { + "name": "http_x_vercel_ip_longitude", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "http_x_vercel_ja4_digest": { + "name": "http_x_vercel_ja4_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_model": { + "name": "requested_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_prompt_prefix": { + "name": "user_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_prefix": { + "name": "system_prompt_prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "system_prompt_length": { + "name": "system_prompt_length", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "http_user_agent": { + "name": "http_user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cache_discount": { + "name": "cache_discount", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "max_tokens": { + "name": "max_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "has_middle_out_transform": { + "name": "has_middle_out_transform", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_error": { + "name": "has_error", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "abuse_classification": { + "name": "abuse_classification", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "inference_provider": { + "name": "inference_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "upstream_id": { + "name": "upstream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "finish_reason": { + "name": "finish_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency": { + "name": "latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "moderation_latency": { + "name": "moderation_latency", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "generation_time": { + "name": "generation_time", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "is_byok": { + "name": "is_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_user_byok": { + "name": "is_user_byok", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "streamed": { + "name": "streamed", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "editor_name": { + "name": "editor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_kind": { + "name": "api_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_tools": { + "name": "has_tools", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "machine_id": { + "name": "machine_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auto_model": { + "name": "auto_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "market_cost": { + "name": "market_cost", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "abuse_delay": { + "name": "abuse_delay", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "abuse_downgraded_from": { + "name": "abuse_downgraded_from", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "\n SELECT\n mu.id,\n mu.kilo_user_id,\n meta.message_id,\n mu.cost,\n mu.input_tokens,\n mu.output_tokens,\n mu.cache_write_tokens,\n mu.cache_hit_tokens,\n mu.created_at,\n ip.http_ip AS http_x_forwarded_for,\n city.vercel_ip_city AS http_x_vercel_ip_city,\n country.vercel_ip_country AS http_x_vercel_ip_country,\n meta.vercel_ip_latitude AS http_x_vercel_ip_latitude,\n meta.vercel_ip_longitude AS http_x_vercel_ip_longitude,\n ja4.ja4_digest AS http_x_vercel_ja4_digest,\n mu.provider,\n mu.model,\n mu.requested_model,\n meta.user_prompt_prefix,\n spp.system_prompt_prefix,\n meta.system_prompt_length,\n ua.http_user_agent,\n mu.cache_discount,\n meta.max_tokens,\n meta.has_middle_out_transform,\n mu.has_error,\n mu.abuse_classification,\n mu.organization_id,\n mu.inference_provider,\n mu.project_id,\n meta.status_code,\n meta.upstream_id,\n frfr.finish_reason,\n meta.latency,\n meta.moderation_latency,\n meta.generation_time,\n meta.is_byok,\n meta.is_user_byok,\n meta.streamed,\n meta.cancelled,\n edit.editor_name,\n ak.api_kind,\n meta.has_tools,\n meta.machine_id,\n feat.feature,\n meta.session_id,\n md.mode,\n am.auto_model,\n meta.market_cost,\n meta.is_free,\n meta.abuse_delay,\n meta.abuse_downgraded_from\n FROM \"microdollar_usage\" mu\n LEFT JOIN \"microdollar_usage_metadata\" meta ON mu.id = meta.id\n LEFT JOIN \"http_ip\" ip ON meta.http_ip_id = ip.http_ip_id\n LEFT JOIN \"vercel_ip_city\" city ON meta.vercel_ip_city_id = city.vercel_ip_city_id\n LEFT JOIN \"vercel_ip_country\" country ON meta.vercel_ip_country_id = country.vercel_ip_country_id\n LEFT JOIN \"ja4_digest\" ja4 ON meta.ja4_digest_id = ja4.ja4_digest_id\n LEFT JOIN \"system_prompt_prefix\" spp ON meta.system_prompt_prefix_id = spp.system_prompt_prefix_id\n LEFT JOIN \"http_user_agent\" ua ON meta.http_user_agent_id = ua.http_user_agent_id\n LEFT JOIN \"finish_reason\" frfr ON meta.finish_reason_id = frfr.finish_reason_id\n LEFT JOIN \"editor_name\" edit ON meta.editor_name_id = edit.editor_name_id\n LEFT JOIN \"api_kind\" ak ON meta.api_kind_id = ak.api_kind_id\n LEFT JOIN \"feature\" feat ON meta.feature_id = feat.feature_id\n LEFT JOIN \"mode\" md ON meta.mode_id = md.mode_id\n LEFT JOIN \"auto_model\" am ON meta.auto_model_id = am.auto_model_id\n", + "name": "microdollar_usage_view", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index c68d7043bd..df10bb33f3 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -1639,6 +1639,13 @@ "when": 1787799247206, "tag": "0233_big_abomination", "breakpoints": true + }, + { + "idx": 234, + "version": "7", + "when": 1788031699722, + "tag": "0234_amused_sasquatch", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 935d86276a..88461290d9 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -5868,6 +5868,43 @@ export const sharedCliSessions = pgTable( export type SharedCliSession = typeof sharedCliSessions.$inferSelect; +export const cloud_agent_worktrees = pgTable( + 'cloud_agent_worktrees', + { + worktree_id: text().primaryKey().notNull(), + kilo_user_id: text() + .notNull() + .references(() => kilocode_users.id, { onDelete: 'restrict' }), + organization_id: uuid().references(() => organizations.id, { onDelete: 'restrict' }), + name: text(), + created_at: timestamp({ withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updated_at: timestamp({ withTimezone: true, mode: 'string' }) + .defaultNow() + .notNull() + .$onUpdateFn(() => sql`now()`), + deletion_started_at: timestamp({ withTimezone: true, mode: 'string' }), + deletion_completed_at: timestamp({ withTimezone: true, mode: 'string' }), + runtime_locations: jsonb() + .$type() + .notNull() + .default(sql`'[]'::jsonb`), + deletion_manifest: jsonb().$type(), + deleted_session_ids: text() + .array() + .notNull() + .default(sql`'{}'::text[]`), + }, + table => [ + index('IDX_cloud_agent_worktrees_owner_scope').on(table.kilo_user_id, table.organization_id), + check( + 'cloud_agent_worktrees_deletion_check', + sql`${table.deletion_completed_at} IS NULL OR (${table.deletion_started_at} IS NOT NULL AND ${table.name} IS NULL AND ${table.deletion_manifest} IS NULL AND ${table.runtime_locations} = '[]'::jsonb)` + ), + ] +); + +export type CloudAgentWorktree = typeof cloud_agent_worktrees.$inferSelect; + export const cli_sessions_v2 = pgTable( 'cli_sessions_v2', { @@ -5884,6 +5921,7 @@ export const cli_sessions_v2 = pgTable( }), cloud_agent_session_id: text(), cloud_agent_session_scope_id: text(), + cloud_agent_worktree_id: text(), created_on_platform: text().notNull().default('unknown'), git_url: text(), git_branch: text(), @@ -5927,6 +5965,10 @@ export const cli_sessions_v2 = pgTable( index('IDX_cli_sessions_v2_user_created') .on(table.kilo_user_id, table.created_at) .concurrently(), + index('IDX_cli_sessions_v2_user_worktree_updated') + .on(table.kilo_user_id, table.cloud_agent_worktree_id, table.updated_at) + .concurrently() + .where(isNotNull(table.cloud_agent_worktree_id)), // Supports joins from github_branch_pull_requests on (git_url, git_branch). index('cli_sessions_v2_git_url_branch_idx').on(table.git_url, table.git_branch), ] diff --git a/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts b/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts index e515101845..5e60745d92 100644 --- a/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts +++ b/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts @@ -17,3 +17,11 @@ export const cloudAgentSessionScopeAssertionSchema = z.object({ }); export type CloudAgentSessionScopeAssertion = z.infer; + +export const cloudAgentChildSessionLineageSchema = z + .object({ + sessionId: containedKiloSessionIdSchema, + parentSessionId: containedKiloSessionIdSchema, + }) + .strict(); +export type CloudAgentChildSessionLineage = z.infer; diff --git a/packages/session-ingest-contracts/src/index.ts b/packages/session-ingest-contracts/src/index.ts index 18cecc4a3a..1eb71fce92 100644 --- a/packages/session-ingest-contracts/src/index.ts +++ b/packages/session-ingest-contracts/src/index.ts @@ -1,2 +1,9 @@ export * from './rpc-contract'; export * from './cloud-agent-session-scope'; + +export const DEFAULT_SESSION_TITLE_PATTERN = + /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + +export function isDefaultSessionTitle(title: string | null | undefined): boolean { + return title == null || DEFAULT_SESSION_TITLE_PATTERN.test(title); +} diff --git a/packages/session-ingest-contracts/src/rpc-contract.ts b/packages/session-ingest-contracts/src/rpc-contract.ts index ef56b03519..164ea06c41 100644 --- a/packages/session-ingest-contracts/src/rpc-contract.ts +++ b/packages/session-ingest-contracts/src/rpc-contract.ts @@ -1,6 +1,117 @@ import { z } from 'zod'; +import { cloudAgentChildSessionLineageSchema } from './cloud-agent-session-scope'; export const sessionIdSchema = z.string().startsWith('ses_').length(30); +export const cloudAgentWorktreeIdSchema = z.templateLiteral(['worktree_', z.uuid()]); +export type CloudAgentWorktreeId = z.infer; + +export const cloudAgentWorktreeLocationSchema = z + .object({ + sandboxId: z.string().regex(/^[A-Za-z0-9._:-]{1,256}$/), + provider: z.enum(['cloudflare', 'vercel']), + }) + .strict(); +export type CloudAgentWorktreeLocation = z.infer; +export const WORKTREE_RUNTIME_HISTORY_UNAVAILABLE = 'worktree_runtime_history_unavailable'; + +export const cloudAgentWorktreeDeletionManifestSchema = z + .object({ + version: z.literal(1), + sessions: z.array( + z + .object({ + sessionId: sessionIdSchema, + cloudAgentSessionId: z.templateLiteral(['workspace_', z.uuid()]).nullable(), + }) + .strict() + ), + }) + .strict(); +export type CloudAgentWorktreeDeletionManifest = z.infer< + typeof cloudAgentWorktreeDeletionManifestSchema +>; + +export const cloudAgentWorktreeDeletionSchema = z + .object({ + worktreeId: cloudAgentWorktreeIdSchema, + kiloUserId: z.string().min(1), + organizationId: z.uuid().optional(), + }) + .strict(); +export type CloudAgentWorktreeDeletionParams = z.infer; + +export const cloudAgentWorktreeDeletionStateSchema = z + .object({ + completed: z.boolean(), + manifest: cloudAgentWorktreeDeletionManifestSchema, + runtimeLocations: z.array(cloudAgentWorktreeLocationSchema), + }) + .strict(); +export type CloudAgentWorktreeDeletionState = z.infer; + +export const recordCloudAgentWorktreeCleanupSchema = cloudAgentWorktreeDeletionSchema + .extend({ + runtimeLocations: z.array(cloudAgentWorktreeLocationSchema).optional(), + directory: z.string().min(1).max(4096).optional(), + sessionIds: z.array(sessionIdSchema).optional(), + childSessions: z + .array( + cloudAgentChildSessionLineageSchema.extend({ + cloudAgentSessionId: z.templateLiteral(['workspace_', z.uuid()]), + }) + ) + .optional(), + }) + .strict(); +export type RecordCloudAgentWorktreeCleanupParams = z.infer< + typeof recordCloudAgentWorktreeCleanupSchema +>; + +export const canDestroyCloudAgentWorktreeSandboxSchema = cloudAgentWorktreeDeletionSchema + .extend({ + location: cloudAgentWorktreeLocationSchema, + releasedWorktreeIds: z.array(cloudAgentWorktreeIdSchema).optional(), + }) + .strict(); +export type CanDestroyCloudAgentWorktreeSandboxParams = z.infer< + typeof canDestroyCloudAgentWorktreeSandboxSchema +>; + +export const unresolvedCloudAgentSandboxOwnerSchema = z + .object({ + worktreeId: cloudAgentWorktreeIdSchema.nullable(), + organizationId: z.uuid().nullable(), + allocationLocation: cloudAgentWorktreeLocationSchema.optional(), + sessions: z + .array( + z + .object({ + sessionId: sessionIdSchema.nullable(), + cloudAgentSessionId: z.string().min(1), + }) + .strict() + ) + .min(1), + }) + .strict(); +export type UnresolvedCloudAgentSandboxOwner = z.infer< + typeof unresolvedCloudAgentSandboxOwnerSchema +>; + +export const canDestroyCloudAgentWorktreeSandboxResultSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('exclusive') }).strict(), + z.object({ kind: z.literal('shared') }).strict(), + z + .object({ + kind: z.literal('unresolved'), + owners: z.array(unresolvedCloudAgentSandboxOwnerSchema).min(1), + }) + .strict(), +]); +export type CanDestroyCloudAgentWorktreeSandboxResult = z.infer< + typeof canDestroyCloudAgentWorktreeSandboxResultSchema +>; + export const messageIdSchema = z .string() .startsWith('msg') @@ -19,6 +130,8 @@ export const createSessionForCloudAgentSchema = z.object({ sessionId: sessionIdSchema, kiloUserId: z.string().min(1), cloudAgentSessionId: z.string().min(1), + cloudAgentWorktreeId: cloudAgentWorktreeIdSchema.optional(), + cloudAgentWorktreeLocation: cloudAgentWorktreeLocationSchema.optional(), organizationId: z.string().optional(), createdOnPlatform: z.string().min(1), title: z.string().optional(), @@ -733,6 +846,18 @@ export type AuthorizedSessionMessages = { export type GetSessionMessagesResult = AuthorizedSessionMessages | null; export type SessionIngestRpcMethods = { + beginCloudAgentWorktreeDeletion: ( + params: CloudAgentWorktreeDeletionParams + ) => Promise; + recordCloudAgentWorktreeCleanup: ( + params: RecordCloudAgentWorktreeCleanupParams + ) => Promise; + completeCloudAgentWorktreeDeletion: ( + params: CloudAgentWorktreeDeletionParams + ) => Promise<{ success: true; deletedSessionIds: string[] }>; + canDestroyCloudAgentWorktreeSandbox: ( + params: CanDestroyCloudAgentWorktreeSandboxParams + ) => Promise; createSessionForCloudAgent: ( params: CreateSessionForCloudAgentParams ) => Promise; diff --git a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts index 3223056068..91988a5bec 100644 --- a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts +++ b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts @@ -71,6 +71,74 @@ describe('projectPublicStoredMessage', () => { ]); }); + it('rewrites grouped private checkout paths in tool outputs and nested metadata', () => { + const privateDirectory = + '/workspace/private/worktrees/worktree_12345678-1234-4234-8234-123456789abc'; + const publicDirectory = `/cloud-agent/sessions/${kiloSessionId}`; + const projected = projectPublicStoredMessage( + { + info: { + id: 'msg_grouped', + sessionID: kiloSessionId, + role: 'assistant', + time: { created: 100, completed: 101 }, + parentID: 'msg_user', + modelID: 'fake', + providerID: 'kilo', + mode: 'code', + agent: 'code', + path: { cwd: privateDirectory, root: privateDirectory }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + id: 'prt_grouped_tool', + sessionID: kiloSessionId, + messageID: 'msg_grouped', + type: 'tool', + callID: 'call_grouped', + tool: 'edit', + state: { + status: 'completed', + input: { filePath: `${privateDirectory}/shared.txt`, unrelated: '/owner/project' }, + output: `Read ${privateDirectory}/shared.txt without losing file contents`, + title: 'edit', + metadata: { + filepath: `${privateDirectory}/shared.txt`, + display: { path: `${privateDirectory}/shared.txt` }, + filediff: { + file: `${privateDirectory}/shared.txt`, + patch: `--- ${privateDirectory}/shared.txt\n+file contents`, + }, + }, + time: { start: 100, end: 101 }, + }, + }, + ], + }, + kiloSessionId + ); + + expect(JSON.stringify(projected)).not.toContain(privateDirectory); + expect(projected.parts).toMatchObject([ + { + state: { + input: { filePath: `${publicDirectory}/shared.txt`, unrelated: '/owner/project' }, + output: `Read ${publicDirectory}/shared.txt without losing file contents`, + metadata: { + filepath: `${publicDirectory}/shared.txt`, + display: { path: `${publicDirectory}/shared.txt` }, + filediff: { + file: `${publicDirectory}/shared.txt`, + patch: `--- ${publicDirectory}/shared.txt\n+file contents`, + }, + }, + }, + }, + ]); + }); + it('preserves owner-visible typed resource file URIs', () => { const projected = projectPublicStoredMessage( { diff --git a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts index 294099f1f2..a9e15a5d5b 100644 --- a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts +++ b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts @@ -167,13 +167,63 @@ export function projectPublicPart(part: KiloSdkPart, kiloSessionId: string): Kil return part; } +function privateWorktreeDirectory(message: KiloSdkStoredMessage): string | undefined { + if (message.info.role !== 'assistant') return undefined; + return [message.info.path.cwd, message.info.path.root].find(directory => + /^\/.*\/worktrees\/worktree_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + directory + ) + ); +} + +function projectPrivateWorktreeValue( + value: unknown, + privateDirectory: string, + publicDirectory: string +): unknown { + if (typeof value === 'string') return value.replaceAll(privateDirectory, publicDirectory); + if (Array.isArray(value)) { + return value.map(item => projectPrivateWorktreeValue(item, privateDirectory, publicDirectory)); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + projectPrivateWorktreeValue(item, privateDirectory, publicDirectory), + ]) + ); + } + return value; +} + +function projectPrivateWorktreeMessage( + message: KiloSdkStoredMessage, + privateDirectory: string, + kiloSessionId: string +): KiloSdkStoredMessage { + const projected = { ...message }; + const publicDirectory = publicCloudAgentDirectory(kiloSessionId); + for (const [key, value] of Object.entries(message)) { + Reflect.set( + projected, + key, + projectPrivateWorktreeValue(value, privateDirectory, publicDirectory) + ); + } + return projected; +} + export function projectPublicStoredMessage( message: KiloSdkStoredMessage, kiloSessionId: string ): KiloSdkStoredMessage { + const privateDirectory = privateWorktreeDirectory(message); + const sanitized = privateDirectory + ? projectPrivateWorktreeMessage(message, privateDirectory, kiloSessionId) + : message; return { - info: projectPublicMessageInfo(message.info, kiloSessionId), - parts: message.parts.map(part => projectPublicPart(part, kiloSessionId)), + info: projectPublicMessageInfo(sanitized.info, kiloSessionId), + parts: sanitized.parts.map(part => projectPublicPart(part, kiloSessionId)), }; } diff --git a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.test.ts b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.test.ts index 6e5d86fe6b..e76d0e4241 100644 --- a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.test.ts +++ b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.test.ts @@ -5,10 +5,15 @@ import { import { TRPCError } from '@trpc/server'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -const { preflightExistingPromptModelMock } = vi.hoisted(() => ({ +const { getPgDbMock, preflightExistingPromptModelMock } = vi.hoisted(() => ({ + getPgDbMock: vi.fn(), preflightExistingPromptModelMock: vi.fn(), })); +vi.mock('../db/pg.js', () => ({ + getPgDb: getPgDbMock, +})); + vi.mock('../session/model-preflight.js', () => ({ preflightExistingPromptModel: preflightExistingPromptModelMock, })); @@ -43,7 +48,56 @@ import { import type { LiveWrapperResolution, SessionKiloFacadeDecision } from './session-proxy'; const kiloSessionId = 'ses_12345678901234567890123456'; +const siblingKiloSessionId = 'ses_abcdefghijklmnopqrstuvwxyz'; +const groupedOwnerId = 'oauth/google:1234'; +const groupedOrganizationId = '11111111-1111-4111-8111-111111111111'; +const groupedWorktreeId = 'worktree_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const firstWorkspaceSessionId = 'workspace_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const siblingWorkspaceSessionId = 'workspace_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; + type ContainerFetch = (request: Request, port: number) => Promise; +type PendingOwnershipFixture = { + kiloSessionId: string; + cloudAgentSessionId: string; + userId: string; + organizationId: string | null; + organizationMembershipId: string | null; + worktreeId: string | null; + cloudAgentSessionScopeId: string | null; + title: string | null; + repositoryUrl: string | null; + createdAt: string; + updatedAt: string; +}; + +function pendingOwnershipFixture( + overrides: Partial = {} +): PendingOwnershipFixture { + return { + kiloSessionId, + cloudAgentSessionId: firstWorkspaceSessionId, + userId: groupedOwnerId, + organizationId: groupedOrganizationId, + organizationMembershipId: 'membership_grouped_owner', + worktreeId: groupedWorktreeId, + cloudAgentSessionScopeId: firstWorkspaceSessionId, + title: 'Grouped session', + repositoryUrl: 'https://github.com/acme/shared.git', + createdAt: '2026-04-29 01:16:12.945+00', + updatedAt: '2026-04-29 01:17:13.123+00', + ...overrides, + }; +} + +function mockPendingOwnershipRows(results: PendingOwnershipFixture[][]) { + const limit = vi.fn(async () => results.shift() ?? []); + const where = vi.fn(() => ({ limit })); + const leftJoin = vi.fn(() => ({ where })); + const from = vi.fn(() => ({ leftJoin })); + const select = vi.fn(() => ({ from })); + getPgDbMock.mockReturnValue({ select }); + return { select, from, leftJoin, where, limit }; +} function envStub(): Env { return { @@ -915,6 +969,233 @@ describe('handleKiloFacadeRequest', () => { }); }); + it('projects distinct lazy grouped roots from authorized ownership without touching the sandbox', async () => { + const env = envStub(); + const firstOwnership = pendingOwnershipFixture({ title: 'First grouped chat' }); + const siblingOwnership = pendingOwnershipFixture({ + kiloSessionId: siblingKiloSessionId, + cloudAgentSessionId: siblingWorkspaceSessionId, + cloudAgentSessionScopeId: siblingWorkspaceSessionId, + title: null, + updatedAt: '2026-04-29 01:18:14.456+00', + }); + const ownershipQuery = mockPendingOwnershipRows([[firstOwnership], [siblingOwnership]]); + vi.mocked(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).mockImplementation( + async ({ kiloSessionId: requestedKiloSessionId }) => ({ + kiloSessionId: requestedKiloSessionId, + cloudAgentSessionId: + requestedKiloSessionId === kiloSessionId + ? firstWorkspaceSessionId + : siblingWorkspaceSessionId, + snapshot: { kind: 'pending' }, + }) + ); + const resolveRootSessionForKiloSession = vi.fn( + async ({ kiloSessionId: requestedKiloSessionId }: { kiloSessionId: string }) => ({ + cloudAgentSessionId: + requestedKiloSessionId === kiloSessionId + ? firstWorkspaceSessionId + : siblingWorkspaceSessionId, + }) + ); + const resolveLiveWrapper = vi.fn(); + + const firstResponse = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${kiloSessionId}`), + env, + userId: groupedOwnerId, + deps: { resolveRootSessionForKiloSession, resolveLiveWrapper }, + }); + const siblingResponse = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${siblingKiloSessionId}`), + env, + userId: groupedOwnerId, + deps: { resolveRootSessionForKiloSession, resolveLiveWrapper }, + }); + + expect(firstResponse.status).toBe(200); + expect(siblingResponse.status).toBe(200); + const firstSession = await firstResponse.json(); + const siblingSession = await siblingResponse.json(); + expect(firstSession).toEqual({ + id: kiloSessionId, + slug: kiloSessionId, + projectID: 'cloud-agent', + directory: publicCloudAgentDirectory(kiloSessionId), + title: 'First grouped chat', + version: 'cloud-agent', + time: { + created: new Date(firstOwnership.createdAt).getTime(), + updated: new Date(firstOwnership.updatedAt).getTime(), + }, + }); + expect(siblingSession).toEqual({ + id: siblingKiloSessionId, + slug: siblingKiloSessionId, + projectID: 'cloud-agent', + directory: publicCloudAgentDirectory(siblingKiloSessionId), + title: 'acme/shared', + version: 'cloud-agent', + time: { + created: new Date(siblingOwnership.createdAt).getTime(), + updated: new Date(siblingOwnership.updatedAt).getTime(), + }, + }); + expect(firstSession).not.toMatchObject({ + directory: publicCloudAgentDirectory(siblingKiloSessionId), + }); + expect(JSON.stringify([firstSession, siblingSession])).not.toContain('/workspace/'); + expect(JSON.stringify([firstSession, siblingSession])).not.toContain(groupedOrganizationId); + expect(ownershipQuery.leftJoin).toHaveBeenCalledTimes(2); + expect(resolveLiveWrapper).not.toHaveBeenCalled(); + }); + + it.each([ + ['ungrouped workspace sessions', { worktreeId: null }], + ['invalid worktree identities', { worktreeId: 'worktree_invalid' }], + ['mismatched root ownership scopes', { cloudAgentSessionScopeId: siblingWorkspaceSessionId }], + ])('preserves the pending snapshot response for %s', async (_description, overrides) => { + const env = envStub(); + mockPendingOwnershipRows([[pendingOwnershipFixture(overrides)]]); + vi.mocked(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).mockResolvedValue({ + kiloSessionId, + cloudAgentSessionId: firstWorkspaceSessionId, + snapshot: { kind: 'pending' }, + }); + const resolveLiveWrapper = vi.fn(async () => null); + + const response = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${kiloSessionId}`), + env, + userId: groupedOwnerId, + deps: { + resolveRootSessionForKiloSession: vi.fn(async () => ({ + cloudAgentSessionId: firstWorkspaceSessionId, + })), + resolveLiveWrapper, + }, + }); + + expect(response.status).toBe(503); + expect(response.headers.get('retry-after')).toBe('1'); + await expect(response.json()).resolves.toMatchObject({ + error: 'KILO_SESSION_SNAPSHOT_PENDING', + }); + expect(resolveLiveWrapper).toHaveBeenCalledOnce(); + }); + + it.each([ + ['another owner', { userId: 'oauth/google:another-owner' }], + ['a revoked organization membership', { organizationMembershipId: null }], + ])('denies a pending grouped session belonging to %s', async (_description, overrides) => { + const env = envStub(); + mockPendingOwnershipRows([[pendingOwnershipFixture(overrides)]]); + vi.mocked(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).mockResolvedValue({ + kiloSessionId, + cloudAgentSessionId: firstWorkspaceSessionId, + snapshot: { kind: 'pending' }, + }); + const resolveLiveWrapper = vi.fn(); + + const response = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${kiloSessionId}`), + env, + userId: groupedOwnerId, + deps: { + resolveRootSessionForKiloSession: vi.fn(async () => ({ + cloudAgentSessionId: firstWorkspaceSessionId, + })), + resolveLiveWrapper, + }, + }); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ error: 'KILO_SESSION_NOT_FOUND' }); + expect(resolveLiveWrapper).not.toHaveBeenCalled(); + }); + + it('denies a foreign grouped root before reading its snapshot or ownership', async () => { + const env = envStub(); + vi.mocked(env.SESSION_INGEST.resolveCloudAgentRootSessionForKiloSession).mockResolvedValue( + null + ); + const resolveLiveWrapper = vi.fn(); + + const response = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${kiloSessionId}`), + env, + userId: 'oauth/google:foreign-owner', + deps: { resolveLiveWrapper }, + }); + + expect(response.status).toBe(404); + expect(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).not.toHaveBeenCalled(); + expect(getPgDbMock).not.toHaveBeenCalled(); + expect(resolveLiveWrapper).not.toHaveBeenCalled(); + }); + + it.each([ + 'file:///workspace/private/shared', + 'https://username:password@github.com/acme/shared.git', + ])('does not expose unsafe repository metadata from %s', async repositoryUrl => { + const env = envStub(); + mockPendingOwnershipRows([[pendingOwnershipFixture({ title: null, repositoryUrl })]]); + vi.mocked(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).mockResolvedValue({ + kiloSessionId, + cloudAgentSessionId: firstWorkspaceSessionId, + snapshot: { kind: 'pending' }, + }); + + const response = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${kiloSessionId}`), + env, + userId: groupedOwnerId, + deps: { + resolveRootSessionForKiloSession: vi.fn(async () => ({ + cloudAgentSessionId: firstWorkspaceSessionId, + })), + resolveLiveWrapper: vi.fn(), + }, + }); + + expect(response.status).toBe(200); + const session = await response.json(); + expect(session).toMatchObject({ title: '' }); + expect(JSON.stringify(session)).not.toContain('/workspace/private'); + expect(JSON.stringify(session)).not.toContain('password'); + }); + + it('rejects invalid grouped ownership timestamps without touching the sandbox', async () => { + const env = envStub(); + mockPendingOwnershipRows([ + [pendingOwnershipFixture({ createdAt: 'not-a-postgresql-timestamp' })], + ]); + vi.mocked(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).mockResolvedValue({ + kiloSessionId, + cloudAgentSessionId: firstWorkspaceSessionId, + snapshot: { kind: 'pending' }, + }); + const resolveLiveWrapper = vi.fn(); + + const response = await handleKiloFacadeRequest({ + request: new Request(`http://worker.test/kilo/session/${kiloSessionId}`), + env, + userId: groupedOwnerId, + deps: { + resolveRootSessionForKiloSession: vi.fn(async () => ({ + cloudAgentSessionId: firstWorkspaceSessionId, + })), + resolveLiveWrapper, + }, + }); + + expect(response.status).toBe(502); + await expect(response.json()).resolves.toMatchObject({ + error: 'KILO_UPSTREAM_RESPONSE_INVALID', + }); + expect(resolveLiveWrapper).not.toHaveBeenCalled(); + }); + it('returns a stable pending response for a cold detail read without a materialized snapshot', async () => { const env = envStub(); vi.mocked(env.SESSION_INGEST.getCloudAgentRootSessionSnapshot).mockResolvedValue({ @@ -940,6 +1221,7 @@ describe('handleKiloFacadeRequest', () => { await expect(response.json()).resolves.toMatchObject({ error: 'KILO_SESSION_SNAPSHOT_PENDING', }); + expect(getPgDbMock).not.toHaveBeenCalled(); }); it('maps an oversized cold session snapshot to stable 413', async () => { diff --git a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts index 8941b57c03..b2a502755c 100644 --- a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts +++ b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts @@ -1,9 +1,12 @@ +import { cli_sessions_v2, organization_memberships } from '@kilocode/db/schema'; import { + cloudAgentWorktreeIdSchema, MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, validateKiloSdkMessagesCursor, } from '@kilocode/session-ingest-contracts'; import { DurableObject } from 'cloudflare:workers'; import { TRPCError } from '@trpc/server'; +import { and, eq, isNotNull, isNull, or } from 'drizzle-orm'; import type { GetCloudAgentRootSessionMessagesParams, KiloSdkSessionInfo, @@ -20,6 +23,7 @@ import type { SubmittedSessionMessageRequest, SessionMessageAdmissionResult, } from '../execution/types.js'; +import { getPgDb } from '../db/pg.js'; import type { CloudAgentSession } from '../persistence/CloudAgentSession.js'; import type { UserId } from '../types/ids.js'; import type { Env } from '../types.js'; @@ -70,6 +74,7 @@ const MAX_KILO_SESSION_JSON_BYTES = 8 * 1024 * 1024; const MAX_KILO_ERROR_JSON_BYTES = 64 * 1024; const MAX_KILO_PROMPT_JSON_BYTES = 256 * 1024; const MAX_TIMESTAMP_MILLISECONDS = 8_640_000_000_000_000; +const MAX_PUBLIC_SESSION_TITLE_CHARACTERS = 512; const PUBLIC_VIRTUAL_SERVER_DIRECTORY = '/cloud-agent'; type KiloEventPayload = Record & { @@ -946,6 +951,107 @@ async function persistedSessionDetailResponse(params: { } } +function publicRepositoryTitle(repositoryUrl: string | null): string | null { + if (!repositoryUrl) return null; + try { + const repository = new URL(repositoryUrl); + if (repository.protocol !== 'https:' || repository.username || repository.password) return null; + const title = repository.pathname.split('/').filter(Boolean).slice(-2).join('/'); + return title.replace(/\.git$/, '') || null; + } catch { + return null; + } +} + +async function groupedPendingSessionDetailResponse(params: { + env: Env; + userId: string; + kiloSessionId: string; + cloudAgentSessionId: string; +}): Promise { + const snapshot = await params.env.SESSION_INGEST.getCloudAgentRootSessionSnapshot({ + kiloUserId: params.userId, + kiloSessionId: params.kiloSessionId, + }); + if ( + !snapshot || + snapshot.kiloSessionId !== params.kiloSessionId || + snapshot.cloudAgentSessionId !== params.cloudAgentSessionId + ) { + return missingRootKiloSessionResponse(); + } + if (snapshot.snapshot.kind !== 'pending') return null; + + const [ownership] = await getPgDb(params.env) + .select({ + kiloSessionId: cli_sessions_v2.session_id, + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + userId: cli_sessions_v2.kilo_user_id, + organizationId: cli_sessions_v2.organization_id, + organizationMembershipId: organization_memberships.id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + title: cli_sessions_v2.title, + repositoryUrl: cli_sessions_v2.git_url, + createdAt: cli_sessions_v2.created_at, + updatedAt: cli_sessions_v2.updated_at, + }) + .from(cli_sessions_v2) + .leftJoin( + organization_memberships, + and( + eq(organization_memberships.organization_id, cli_sessions_v2.organization_id), + eq(organization_memberships.kilo_user_id, params.userId) + ) + ) + .where( + and( + eq(cli_sessions_v2.session_id, params.kiloSessionId), + eq(cli_sessions_v2.kilo_user_id, params.userId), + eq(cli_sessions_v2.cloud_agent_session_id, params.cloudAgentSessionId), + isNull(cli_sessions_v2.parent_session_id), + or(isNull(cli_sessions_v2.organization_id), isNotNull(organization_memberships.id)) + ) + ) + .limit(1); + + if ( + !ownership || + ownership.userId !== params.userId || + ownership.kiloSessionId !== params.kiloSessionId || + ownership.cloudAgentSessionId !== params.cloudAgentSessionId || + (ownership.organizationId !== null && ownership.organizationMembershipId === null) + ) { + return missingRootKiloSessionResponse(); + } + if ( + ownership.cloudAgentSessionScopeId !== params.cloudAgentSessionId || + !cloudAgentWorktreeIdSchema.safeParse(ownership.worktreeId).success + ) { + return null; + } + + const created = new Date(ownership.createdAt).getTime(); + const updated = new Date(ownership.updatedAt).getTime(); + if (!Number.isFinite(created) || !Number.isFinite(updated)) { + return invalidPersistedSessionDataResponse('session'); + } + + return Response.json( + projectPublicListedSession({ + kiloSessionId: ownership.kiloSessionId, + cloudAgentSessionId: params.cloudAgentSessionId, + title: + (ownership.title ?? publicRepositoryTitle(ownership.repositoryUrl))?.slice( + 0, + MAX_PUBLIC_SESSION_TITLE_CHARACTERS + ) ?? null, + created, + updated, + }) + ); +} + async function persistedSessionMessagesResponse(params: { env: Env; userId: string; @@ -1136,6 +1242,16 @@ async function proxyOwnedKiloSessionRequest(params: { persistedRead: PersistedSessionRead | null; }): Promise { const { request, env, userId, deps, url, kiloPath, kiloSessionId, cloudAgentSessionId } = params; + if (params.persistedRead?.kind === 'detail' && cloudAgentSessionId.startsWith('workspace_')) { + const pendingResponse = await groupedPendingSessionDetailResponse({ + env, + userId, + kiloSessionId, + cloudAgentSessionId, + }); + if (pendingResponse) return pendingResponse; + } + const persistedFallback = () => params.persistedRead ? persistedSessionReadResponse({ diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index 6fa7c36d5f..c6a37a3352 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -13,6 +13,10 @@ import { serializeSessionMetadata, type SessionMetadata, } from './session-metadata.js'; +import { + sessionRuntimeLocator, + type SessionRuntimeLocator, +} from '../sandbox-control/worktree-ownership.js'; import { readProfileBundle, type SessionProfileBundle } from '../session-profile.js'; import { fitCallbackJobToQueueLimit } from '../callbacks/queue-payload.js'; import type { CallbackJob, CallbackTarget } from '../callbacks/index.js'; @@ -1663,6 +1667,11 @@ export class CloudAgentSession extends DurableObject { return this.getStoredMetadata(); } + async getRuntimeLocation(): Promise { + const metadata = await this.getStoredMetadata(); + return metadata ? sessionRuntimeLocator(metadata) : null; + } + async validateKiloGlobalFeedProducer(params: { kiloSessionId: string; wrapperRunId: string; diff --git a/services/cloud-agent-next/src/persistence/SandboxControl.ts b/services/cloud-agent-next/src/persistence/SandboxControl.ts index ba145e76bb..28cf28a614 100644 --- a/services/cloud-agent-next/src/persistence/SandboxControl.ts +++ b/services/cloud-agent-next/src/persistence/SandboxControl.ts @@ -1,4 +1,21 @@ import { DurableObject } from 'cloudflare:workers'; +import { + cloudAgentWorktreeIdSchema, + WORKTREE_RUNTIME_HISTORY_UNAVAILABLE, +} from '@kilocode/session-ingest-contracts'; +import { resolveSandboxExclusivity } from '../sandbox-control/worktree-ownership.js'; +import { getWorktreeWorkspacePath } from '../workspace.js'; +import { + cleanWorktreeRuntime, + isUnallocatedControlRuntime, + loadWorktreeDeletionJournal, + loadWorktreeDeletionJournals, + sandboxWorktreeCleanupInputSchema, + WORKTREE_DELETION_PREFIX, + EXCLUSIVE_DELETION_KEY, + RUNTIME_DELETED_KEY, + type SandboxWorktreeCleanupInput, +} from '../sandbox-control/worktree-deletion.js'; import { getSandbox } from '@cloudflare/sandbox'; import { withTimeout } from '@kilocode/worker-utils'; import { z } from 'zod'; @@ -121,6 +138,8 @@ import { import { createVercelProviderAdapter, decodeVercelProviderRef, + vercelProviderLocatorSchema, + type VercelProviderLocator, } from '../sandbox-control/vercel-provider.js'; import type { VercelSandboxNetworkPolicy } from '../agent-sandbox/vercel/vercel-sandbox-rest-client.js'; import { @@ -153,6 +172,7 @@ const WRAPPER_READY_AT_KEY = 'wrapper_ready_at'; const ACTIVE_WRAPPER_RUNTIME_KEY = 'active_wrapper_runtime'; const DIAGNOSTIC_BUNDLE_KEY = 'diagnostic_bundle'; const PROVIDER_KIND_KEY = 'provider_kind'; +const PROVIDER_LOCATOR_KEY = 'provider_locator'; const BILLING_INPUT_KEY = 'billing_input'; const ACQUISITION_RECEIPTS_KEY = 'acquisition_receipts'; const CREDENTIAL_POLICY_DIRTY_KEY = 'credential_policy_dirty'; @@ -232,6 +252,13 @@ export class SandboxControl extends DurableObject { physical: PhysicalRecord; promise: Promise; } | null = null; + private vercelLocator: VercelProviderLocator | undefined; + private readonly deletingWorktrees = new Set(); + private exclusiveDeletionWorktreeId: string | undefined; + private runtimeDeleted = false; + private readonly readinessOperations = new Set>(); + private readonly lifecycleOperations = new Set>(); + private worktreeDeletionChain: Promise = Promise.resolve(); constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -259,8 +286,20 @@ export class SandboxControl extends DurableObject { ctx.storage.get(PROVIDER_KIND_KEY), loadPhysicalRecord(ctx.storage), ]); + this.vercelLocator = vercelProviderLocatorSchema + .optional() + .parse(await ctx.storage.get(PROVIDER_LOCATOR_KEY)); this.providerKind = kind ?? 'cloudflare'; this.provider = this.createProviderAdapter(this.providerKind, physical); + this.runtimeDeleted = (await ctx.storage.get(RUNTIME_DELETED_KEY)) === true; + this.exclusiveDeletionWorktreeId = cloudAgentWorktreeIdSchema + .optional() + .parse(await ctx.storage.get(EXCLUSIVE_DELETION_KEY)); + for (const key of (await ctx.storage.list({ prefix: WORKTREE_DELETION_PREFIX })).keys()) { + this.deletingWorktrees.add( + cloudAgentWorktreeIdSchema.parse(key.slice(WORKTREE_DELETION_PREFIX.length)) + ); + } await this.repairLifecycleScheduling(physical); if ( physical.stopTombstone || @@ -304,18 +343,32 @@ export class SandboxControl extends DurableObject { } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { - await this.socketHandler.handleMessage(ws, message); + if (this.runtimeDeleted) return; + await this.trackLifecycleOperation( + Promise.resolve(this.socketHandler.handleMessage(ws, message)) + ); } async webSocketClose(ws: WebSocket): Promise { - await this.socketHandler.handleClose(ws); + if (this.runtimeDeleted) return; + await this.trackLifecycleOperation(Promise.resolve(this.socketHandler.handleClose(ws))); } async webSocketError(ws: WebSocket): Promise { - await this.socketHandler.handleClose(ws); + await this.webSocketClose(ws); } async alarm(): Promise { + if (this.runtimeDeleted) return; + await this.trackLifecycleOperation(this.runAlarm()); + } + + private trackLifecycleOperation(operation: Promise): Promise { + this.lifecycleOperations.add(operation); + return operation.finally(() => this.lifecycleOperations.delete(operation)); + } + + private async runAlarm(): Promise { const now = Date.now(); const deadlines = await loadDeadlines(this.ctx.storage); for (const id of dueDeadlines(deadlines, now)) { @@ -391,6 +444,11 @@ export class SandboxControl extends DurableObject { } async request(input: SandboxControlOutboundRequest): Promise { + if (input.session) + this.assertWorktreeAdmission(this.worktreeIdFromDirectory(input.session.directory)); + if (input.operation === 'worktree.delete' || input.operation === 'worktree.prepareDeletion') { + throw new Error('Worktree cleanup requires the deletion coordinator'); + } const expectedWrapperInstanceId = input.expectedWrapperInstanceId === undefined ? undefined @@ -423,7 +481,8 @@ export class SandboxControl extends DurableObject { if (!identity.success) throw new Error('session identity is required'); await this.ctx.storage.transaction(async () => { const current = await loadPhysicalRecord(this.ctx.storage); - const route = (await loadRouteTable(this.ctx.storage)).get(identity.data.sessionId); + const table = await loadRouteTable(this.ctx.storage); + const route = table.get(identity.data.sessionId); if ( current.state !== 'running' || current.stopTombstone || @@ -440,21 +499,40 @@ export class SandboxControl extends DurableObject { ) { throw new Error('Session is not attached to this sandbox runtime'); } + this.assertWorktreeAdmission(route.worktreeId); + const now = Date.now(); + if (input.operation === 'session.prompt') { + const previous = route.lastState; + applyReportedSessionState( + table, + route.kiloSessionId, + { state: 'active', idleForMs: 0, waitingOn: 'model' }, + now + ); + await saveRouteTable(this.ctx.storage, table); + if (previous !== 'active') { + await this.appendLog( + sessionStateTransition(now, route.kiloSessionId, previous, 'active') + ); + } + } const deadlines = await loadDeadlines(this.ctx.storage); const next = armDeadline( deadlines, 'idleStop', - Math.max(deadlines.idleStop ?? 0, Date.now() + DEADLINE_MS.idleStop) + Math.max(deadlines.idleStop ?? 0, now + DEADLINE_MS.idleStop) ); await saveDeadlines(this.ctx.storage, next); if (deadlines.idleStop === undefined) { - await this.appendLog(deadlineTransition(Date.now(), 'idleStop', 'armed')); + await this.appendLog(deadlineTransition(now, 'idleStop', 'armed')); } await this.scheduleAlarm(next); if (!isCurrent()) throw new Error('Sandbox wrapper runtime changed'); }); } if (!isCurrent()) throw new Error('Sandbox wrapper runtime changed'); + if (input.session) + this.assertWorktreeAdmission(this.worktreeIdFromDirectory(input.session.directory)); return this.socketHandler.sendRequest(input); } @@ -580,6 +658,7 @@ export class SandboxControl extends DurableObject { throw new Error('Terminal runtime changed during credential preparation'); } } + this.assertWorktreeAdmission(metadata.workspace?.worktreeId); const updated = [...grants.filter(grant => grant.scopeId !== scopeId), prepared.grant]; if (provider === 'vercel') await this.ctx.storage.put(CREDENTIAL_POLICY_DIRTY_KEY, true); await saveSessionCredentialGrants(this.ctx.storage, updated); @@ -628,6 +707,7 @@ export class SandboxControl extends DurableObject { const expected = alias.purpose === 'kilo' ? grant.kilo.alias : grant.scm?.alias; if ( grant.userId !== ownerId || + this.deletingWorktrees.has(grant.scopeId) || !expected || !(await sandboxCredentialMatchesHash( input.credential, @@ -644,7 +724,8 @@ export class SandboxControl extends DurableObject { current.providerRef !== physical.providerRef || !sameAllocation(current, physical) || !this.matchesContainment(current, WORKTREE_CREDENTIAL_CONTAINMENT) || - Date.now() >= resolved.grant.expiresAt + Date.now() >= resolved.grant.expiresAt || + this.deletingWorktrees.has(grant.scopeId) ) { return null; } @@ -667,37 +748,64 @@ export class SandboxControl extends DurableObject { }); } - async ensureReady(input: { + ensureReady(input: { ownerId: string; sessionId: string; provider?: AgentSandboxProvider; allowCreate?: boolean; acquisition?: SandboxAcquisition; billing?: SandboxBillingInput; + worktreeId?: string; }): Promise { + const operation = this.runEnsureReady(input); + this.readinessOperations.add(operation); + return operation.finally(() => this.readinessOperations.delete(operation)); + } + + private async runEnsureReady( + input: Parameters[0] + ): Promise { + this.assertWorktreeAdmission(input.worktreeId); const acquisition = input.acquisition === undefined ? undefined : sandboxAcquisitionSchema.parse(input.acquisition); if (acquisition) assertAcquisitionDeadline(acquisition); const { ownerId } = await this.initializeOwner(input.ownerId); + const metadata = await withTimeout( + this.readCredentialMetadata(input), + Math.max( + 1, + Math.min(DEADLINE_MS.startup, (acquisition?.deadlineAt ?? Infinity) - Date.now()) + ), + 'Sandbox credential metadata timed out' + ); + const worktreeId = metadata.workspace?.worktreeId; + if (input.worktreeId !== undefined && input.worktreeId !== worktreeId) { + throw new Error('Worktree identity conflict'); + } + this.assertWorktreeAdmission(worktreeId); + if (this.runtimeDeleted) { + await this.ctx.storage.delete(RUNTIME_DELETED_KEY); + this.runtimeDeleted = false; + } await this.pinProvider(input.provider); if (acquisition && this.providerKind !== 'cloudflare') { throw new Error('Sandbox acquisition is only supported for Cloudflare'); } - const billing = await this.billingInput(ownerId, input.billing); + const billing = await this.billingInput(ownerId, input.billing, worktreeId); let physical: PhysicalRecord; let creating = false; if (acquisition) { - let selected = await this.acquirePhysical(acquisition); + let selected = await this.acquirePhysical(acquisition, worktreeId); if (selected.action === 'wait') { const step = nextEnsureReadyStep(selected.physical.state, true); if (step === 'release-failed') { await this.releaseIfAuthoritativelyDead(selected.physical); - selected = await this.acquirePhysical(acquisition); + selected = await this.acquirePhysical(acquisition, worktreeId); } else if (step === 'observe-unknown') { await this.observeCurrentProvider(selected.physical); - selected = await this.acquirePhysical(acquisition); + selected = await this.acquirePhysical(acquisition, worktreeId); } } if (selected.action === 'wait') return this.statusForPhysical(selected.physical); @@ -718,6 +826,7 @@ export class SandboxControl extends DurableObject { if (nextEnsureReadyStep(current.state, allowCreate) !== 'create') return current; const intentId = crypto.randomUUID(); const allocationName = await deriveSandboxAllocationId(this.sandboxId, intentId); + this.assertWorktreeAdmission(worktreeId); const claimed = await this.claimCreate( intentId, this.provider.resumable, @@ -813,6 +922,7 @@ export class SandboxControl extends DurableObject { await this.appendLog(credentialTransition(Date.now(), 'issued')); const provider = this.provider; try { + this.assertWorktreeAdmission(worktreeId); if (acquisition) assertAcquisitionDeadline(acquisition); const created = await withTimeout( provider.create({ @@ -838,6 +948,7 @@ export class SandboxControl extends DurableObject { if (!sameAllocation(allocated, physical) || allocated.stopTombstone) { return currentStatus(); } + this.assertWorktreeAdmission(worktreeId); if (acquisition) assertAcquisitionDeadline(acquisition); await withTimeout( provider.launch(created.providerRef, this.wrapperLaunchEnv(credential)), @@ -880,7 +991,10 @@ export class SandboxControl extends DurableObject { return { ...status, attachment }; } - private async acquirePhysical(acquisition: SandboxAcquisition): Promise<{ + private async acquirePhysical( + acquisition: SandboxAcquisition, + worktreeId?: string + ): Promise<{ physical: PhysicalRecord; action: 'create' | 'reuse' | 'wait'; }> { @@ -888,6 +1002,7 @@ export class SandboxControl extends DurableObject { const allocationName = await deriveSandboxAllocationId(this.sandboxId, intentId); return this.ctx.storage.transaction(async () => { const physical = await loadPhysicalRecord(this.ctx.storage, this.provider.resumable); + this.assertWorktreeAdmission(worktreeId); if (await this.bindAcquisition(acquisition, physical)) return { physical, action: 'reuse' }; if (physical.state !== 'stopped' || physical.stopTombstone) { return { physical, action: 'wait' }; @@ -1024,9 +1139,11 @@ export class SandboxControl extends DurableObject { if (!grant || worktreeId !== input.worktreeId) { throw new Error('Session has no matching worktree credential grant'); } - const table = await loadRouteTable(this.ctx.storage); - const result = attachRoute(table, input, ownerId); - await saveRouteTable(this.ctx.storage, result.table); + const result = await this.mutateRoutes(table => { + this.assertWorktreeAdmission(worktreeId); + const attached = attachRoute(table, input, ownerId); + return { value: attached, changed: attached.changed }; + }); if (result.changed) { await this.appendLog( routeTransition(Date.now(), 'attach', input.sessionId, input.kiloSessionId) @@ -1037,41 +1154,300 @@ export class SandboxControl extends DurableObject { } async detachSession(sessionId: string): Promise<{ existed: boolean }> { - const result = await this.withCredentialUpdate(async () => { - const table = await loadRouteTable(this.ctx.storage); - const detached = detachRoute(table, sessionId); - await saveRouteTable(this.ctx.storage, detached.table); - const grants = await loadSessionCredentialGrants(this.ctx.storage); - const credentialsChanged = grants.some(grant => - grant.members.some(member => member.sessionId === sessionId) + const route = (await loadRouteTable(this.ctx.storage)).get(sessionId); + let runtimeDetached = false; + let existed = false; + try { + if (route && this.socketHandler.hasHandshakenSocket()) { + const response = await this.socketHandler.sendRequest({ + operation: 'session.detach', + session: { + sessionId: route.sessionId, + kiloSessionId: route.kiloSessionId, + directory: route.directory, + }, + payload: {}, + }); + if (!response.ok) throw new Error(response.error?.message ?? 'session.detach failed'); + } + runtimeDetached = true; + } finally { + const result = await this.withCredentialUpdate(() => + this.ctx.storage.transaction(async () => { + const table = await loadRouteTable(this.ctx.storage); + const detached = runtimeDetached + ? detachRoute(table, sessionId) + : { table, existed: false }; + await saveRouteTable(this.ctx.storage, detached.table); + const grants = await loadSessionCredentialGrants(this.ctx.storage); + if (grants.some(grant => grant.members.some(member => member.sessionId === sessionId))) { + if (this.providerKind === 'vercel') { + await this.ctx.storage.put(CREDENTIAL_POLICY_DIRTY_KEY, true); + } + await saveSessionCredentialGrants( + this.ctx.storage, + removeSessionCredentialMembership(grants, sessionId) + ); + } + return detached; + }) ); - if (credentialsChanged) { + if ( + this.providerKind === 'vercel' && + (await this.ctx.storage.get(CREDENTIAL_POLICY_DIRTY_KEY)) + ) { + await this.enforceWorktreeNetworkPolicy(await this.requireOwner()); + } + existed = result.existed; + if (existed) { + this.sessionForwardChains.delete(sessionId); + await this.appendLog(routeTransition(Date.now(), 'detach', sessionId)); + } + if (!hasActiveWork(result.table)) { + const physical = await loadPhysicalRecord(this.ctx.storage); + if (physical.state === 'running' && !physical.stopTombstone) { + await this.armDeadlineIfAbsent('idleStop', Date.now() + DEADLINE_MS.idleStop); + } + } + } + return { existed }; + } + + deleteWorktreeResources( + raw: SandboxWorktreeCleanupInput + ): Promise<{ deleted: true; sessionIds: string[] }> { + const input = sandboxWorktreeCleanupInputSchema.parse(raw); + const operation = this.worktreeDeletionChain + .catch(() => undefined) + .then(() => this.runWorktreeDeletion(input)); + this.worktreeDeletionChain = operation; + return operation; + } + + private async runWorktreeDeletion( + input: SandboxWorktreeCleanupInput + ): Promise<{ deleted: true; sessionIds: string[] }> { + if (input.location.sandboxId !== this.sandboxId) throw new Error('Sandbox identity conflict'); + await this.initializeOwner(input.kiloUserId); + const previous = await loadWorktreeDeletionJournal(this.ctx.storage, input.worktreeId); + if (previous?.completed && previous.destroyed) { + await this.releaseWorktreeAdmission(input.worktreeId); + return { + deleted: true, + sessionIds: [...new Set([...previous.sessionIds, ...input.sessionIds])], + }; + } + if (this.exclusiveDeletionWorktreeId && this.exclusiveDeletionWorktreeId !== input.worktreeId) { + throw new Error('worktree_teardown_in_progress'); + } + if (previous?.exclusiveTeardown && this.exclusiveDeletionWorktreeId !== input.worktreeId) { + throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + } + const getProvider = async () => { + await this.pinProvider(input.location.provider); + return this.provider; + }; + this.deletingWorktrees.add(input.worktreeId); + await this.ctx.storage.put( + `${WORKTREE_DELETION_PREFIX}${input.worktreeId}`, + previous ?? { + sessionIds: input.sessionIds, + resourcesCleaned: false, + destroyed: false, + } + ); + const directory = getWorktreeWorkspacePath( + input.organizationId, + input.kiloUserId, + input.worktreeId + ); + let journal: Awaited>; + try { + const admittedTeardown = + previous?.exclusiveTeardown === true && + this.exclusiveDeletionWorktreeId === input.worktreeId; + const exclusive = + previous?.destroyed === true || + admittedTeardown || + (await this.fenceAndCheckWorktreeExclusivity(input, directory)); + if (!exclusive) { + if ((await loadPhysicalRecord(this.ctx.storage)).state !== 'stopped') await getProvider(); + await this.revokeWorktreeCredentials(input.worktreeId); + } + journal = await cleanWorktreeRuntime({ + request: input, + directory, + storage: this.ctx.storage, + getProvider, + stopRuntime: () => this.stopDeletedWorktreeRuntime(), + hasConnection: () => this.socketHandler.hasHandshakenSocket(), + sendRequest: request => this.socketHandler.sendRequest(request), + exclusive, + }); + } finally { + await this.revokeWorktreeCredentials(input.worktreeId); + } + const deletedIds = new Set(journal.sessionIds); + const detached = await this.mutateRoutes(table => { + const sessionIds: string[] = []; + for (const [sessionId, route] of table) { + if ( + route.worktreeId === input.worktreeId || + (route.directory === directory && deletedIds.has(route.kiloSessionId)) + ) { + table.delete(sessionId); + sessionIds.push(sessionId); + } + } + return { value: sessionIds, changed: sessionIds.length > 0 }; + }); + await Promise.allSettled(detached.flatMap(id => this.sessionForwardChains.get(id) ?? [])); + for (const id of detached) this.sessionForwardChains.delete(id); + if ( + !journal.destroyed && + (await this.fenceAndCheckWorktreeExclusivity( + { ...input, sessionIds: journal.sessionIds }, + directory + )) + ) { + journal = await cleanWorktreeRuntime({ + request: { ...input, sessionIds: journal.sessionIds }, + directory, + storage: this.ctx.storage, + getProvider, + stopRuntime: () => this.stopDeletedWorktreeRuntime(), + hasConnection: () => this.socketHandler.hasHandshakenSocket(), + sendRequest: request => this.socketHandler.sendRequest(request), + exclusive: true, + }); + } + if (journal.destroyed) { + this.runtimeDeleted = true; + this.kiloReady = false; + await this.ctx.storage.put(RUNTIME_DELETED_KEY, true); + this.socketHandler.closeAll('Worktree deleted'); + await Promise.allSettled([...this.lifecycleOperations]); + await this.eraseRecord(); + await this.ctx.storage.deleteAlarm(); + for (const [worktreeId, receipt] of await loadWorktreeDeletionJournals(this.ctx.storage)) { + if (receipt.resourcesCleaned) { + await this.ctx.storage.put(`${WORKTREE_DELETION_PREFIX}${worktreeId}`, { + ...receipt, + completed: true, + destroyed: true, + }); + } + } + } + await this.ctx.storage.put(`${WORKTREE_DELETION_PREFIX}${input.worktreeId}`, { + ...journal, + completed: true, + }); + await this.releaseWorktreeAdmission(input.worktreeId); + return { deleted: true, sessionIds: journal.sessionIds }; + } + + private async stopDeletedWorktreeRuntime(): Promise { + const physical = await this.beginStop('worktree_deleted'); + if (physical.state === 'stopped') return physical; + if ((physical.stopTombstone?.attempts ?? 0) >= DEADLINE_MS.stopAttemptLadder.length) { + return this.observeCurrentProvider(physical); + } + return this.recordStopAttempt(); + } + + private async revokeWorktreeCredentials(worktreeId: string): Promise { + await this.withCredentialUpdate(() => + this.ctx.storage.transaction(async () => { + const grants = await loadSessionCredentialGrants(this.ctx.storage); + if (!grants.some(grant => grant.scopeId === worktreeId)) return; if (this.providerKind === 'vercel') { await this.ctx.storage.put(CREDENTIAL_POLICY_DIRTY_KEY, true); } await saveSessionCredentialGrants( this.ctx.storage, - removeSessionCredentialMembership(grants, sessionId) + grants.filter(grant => grant.scopeId !== worktreeId) ); - } - return detached; - }); + }) + ); if ( this.providerKind === 'vercel' && (await this.ctx.storage.get(CREDENTIAL_POLICY_DIRTY_KEY)) ) { await this.enforceWorktreeNetworkPolicy(await this.requireOwner()); } - if (result.existed) { - await this.appendLog(routeTransition(Date.now(), 'detach', sessionId)); + } + + private async fenceAndCheckWorktreeExclusivity( + input: SandboxWorktreeCleanupInput, + directory: string + ): Promise { + this.exclusiveDeletionWorktreeId = input.worktreeId; + await this.ctx.storage.put(EXCLUSIVE_DELETION_KEY, input.worktreeId); + try { + await Promise.allSettled([...this.readinessOperations, ...this.lifecycleOperations]); + if ( + await isUnallocatedControlRuntime(this.ctx.storage, () => + this.socketHandler.hasHandshakenSocket() + ) + ) + return true; + const receipts = await loadWorktreeDeletionJournals(this.ctx.storage); + const releasedWorktreeIds = [...receipts] + .filter(([, receipt]) => receipt.resourcesCleaned) + .map(([id]) => id); + const released = new Set(releasedWorktreeIds); + const requestedIds = new Set(input.sessionIds); + const otherRoutes = [...(await loadRouteTable(this.ctx.storage)).values()].some(route => { + const worktreeId = route.worktreeId ?? this.worktreeIdFromDirectory(route.directory); + if (worktreeId && released.has(worktreeId)) return false; + return ( + route.directory !== directory || + !requestedIds.has(route.kiloSessionId) || + (worktreeId !== undefined && worktreeId !== input.worktreeId) + ); + }); + const exclusive = + !otherRoutes && + (await withTimeout( + resolveSandboxExclusivity(this.env, { + worktreeId: input.worktreeId, + kiloUserId: input.kiloUserId, + organizationId: input.organizationId, + location: input.location, + releasedWorktreeIds, + }), + DEADLINE_MS.stopAttempt, + 'Worktree ownership lookup timed out' + )); + if (exclusive) return true; + } catch (error) { + await this.releaseWorktreeAdmission(input.worktreeId); + throw error; } - if (!hasActiveWork(result.table)) { - const physical = await loadPhysicalRecord(this.ctx.storage); - if (physical.state === 'running') { - await this.armDeadlineIfAbsent('idleStop', Date.now() + DEADLINE_MS.idleStop); - } + await this.releaseWorktreeAdmission(input.worktreeId); + return false; + } + + private async releaseWorktreeAdmission(worktreeId: string): Promise { + if (this.exclusiveDeletionWorktreeId !== worktreeId) return; + await this.ctx.storage.delete(EXCLUSIVE_DELETION_KEY); + this.exclusiveDeletionWorktreeId = undefined; + if (!this.runtimeDeleted) await this.scheduleAlarm(await loadDeadlines(this.ctx.storage)); + } + + private worktreeIdFromDirectory(directory: string): string | undefined { + const parsed = cloudAgentWorktreeIdSchema.safeParse(directory.split('/').at(-1)); + return parsed.success ? parsed.data : undefined; + } + + private assertWorktreeAdmission(worktreeId?: string): void { + if ( + this.exclusiveDeletionWorktreeId || + (worktreeId && this.deletingWorktrees.has(worktreeId)) + ) { + throw new Error('worktree_deleting'); } - return { existed: result.existed }; } async listRoutes(): Promise { @@ -1103,7 +1479,12 @@ export class SandboxControl extends DurableObject { }); const sandbox = getSandbox(namespace, allocationId); billing = validateTerminalBillingRuntime({ - access: input, + access: runtime.route.worktreeId + ? { + ...input, + sessionId: `workspace_${runtime.route.worktreeId.slice('worktree_'.length)}`, + } + : input, sandboxId: allocationId, providerInstanceId: runtime.connection.providerInstanceId, sandboxDurableObjectId: namespace.idFromName(allocationId).toString(), @@ -1232,6 +1613,7 @@ export class SandboxControl extends DurableObject { ): Promise { return this.ctx.storage.transaction(async () => { const current = await loadPhysicalRecord(this.ctx.storage, resumable); + this.assertWorktreeAdmission(); const next = claimCreate(current, intentId, Date.now(), allocationName, containment); const vercel = this.providerKind === 'vercel' ? parseVercelSandboxRuntimeConfig(this.env) : undefined; @@ -1241,6 +1623,14 @@ export class SandboxControl extends DurableObject { ...next.createIntent, vercel: { projectId, snapshotId, runtimeBuildId, runtime }, }; + this.vercelLocator = vercelProviderLocatorSchema.parse({ + teamId: vercel.teamId, + projectId, + snapshotId, + runtimeBuildId, + runtime, + }); + await this.ctx.storage.put(PROVIDER_LOCATOR_KEY, this.vercelLocator); } await this.persistPhysicalState(current, next, 'demand'); return next; @@ -1437,9 +1827,13 @@ export class SandboxControl extends DurableObject { DIAGNOSTIC_BUNDLE_KEY, PROVIDER_KIND_KEY, BILLING_INPUT_KEY, - ACQUISITION_RECEIPTS_KEY, CREDENTIAL_POLICY_DIRTY_KEY, + PROVIDER_LOCATOR_KEY, ]); + this.vercelLocator = undefined; + this.activeConnection = null; + this.readyConnectionId = null; + this.kiloReady = false; } private withCredentialUpdate(operation: () => Promise): Promise { @@ -1474,6 +1868,7 @@ export class SandboxControl extends DurableObject { ) { throw new Error('Session credential ownership mismatch'); } + this.assertWorktreeAdmission(metadata.workspace?.worktreeId); return metadata; } @@ -1552,9 +1947,14 @@ export class SandboxControl extends DurableObject { ): ProviderAdapter { const allocationName = physical?.createIntent?.allocationName ?? this.sandboxId; if (kind === 'vercel') { + const locator = physical?.state === 'stopped' ? undefined : this.vercelLocator; + const config = resolveVercelSandboxRuntimeConfig( + this.env, + physical?.createIntent?.vercel ?? locator + ); return createVercelProviderAdapter({ sandboxName: allocationName, - config: resolveVercelSandboxRuntimeConfig(this.env, physical?.createIntent?.vercel), + config: config && locator ? { ...config, teamId: locator.teamId } : config, }); } return createCloudflareProviderAdapter({ @@ -1591,11 +1991,15 @@ export class SandboxControl extends DurableObject { private async billingInput( ownerId: string, - supplied?: SandboxBillingInput + supplied?: SandboxBillingInput, + worktreeId?: string ): Promise { const raw = await this.ctx.storage.get(BILLING_INPUT_KEY); const stored = raw === undefined ? undefined : parseSandboxBillingInput(raw); - const input = supplied === undefined ? stored : parseSandboxBillingInput(supplied); + let input = supplied === undefined ? stored : parseSandboxBillingInput(supplied); + if (input?.sessionId !== undefined && worktreeId) { + input = { ...input, sessionId: `workspace_${worktreeId.slice('worktree_'.length)}` }; + } const enforced = isCloudAgentContainerBillingEnabled(this.env, { userId: ownerId, ...(input?.subject.type === 'org' ? { orgId: input.subject.id } : {}), @@ -2172,6 +2576,14 @@ export class SandboxControl extends DurableObject { if (!route || route.ownerId !== input.ownerId) { return { allowed: false, reason: 'session_not_attached' }; } + const worktreeId = route.worktreeId ?? this.worktreeIdFromDirectory(route.directory); + if ( + this.runtimeDeleted || + this.exclusiveDeletionWorktreeId || + (worktreeId && this.deletingWorktrees.has(worktreeId)) + ) { + return { allowed: false, reason: 'worktree_deleting' }; + } if (physical.state !== 'running' || physical.stopTombstone || physical.providerRef === null) { return { allowed: false, reason: 'runtime_not_running' }; } @@ -2420,6 +2832,17 @@ export class SandboxControl extends DurableObject { ); } + private mutateRoutes( + mutation: (table: Map) => { value: T; changed: boolean } + ): Promise { + return this.ctx.storage.transaction(async () => { + const table = await loadRouteTable(this.ctx.storage); + const updated = mutation(table); + if (updated.changed) await saveRouteTable(this.ctx.storage, table); + return updated.value; + }); + } + private async armDeadlineIfAbsent(id: DeadlineId, at: number): Promise { await this.armDeadlineAndAlarm(id, at, true); } diff --git a/services/cloud-agent-next/src/persistence/session-metadata.test.ts b/services/cloud-agent-next/src/persistence/session-metadata.test.ts index 364e9c61d3..3a85ff4fe6 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.test.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.test.ts @@ -313,6 +313,42 @@ describe('session metadata boundary', () => { expect(parseSessionMetadata(current).repository).not.toHaveProperty('githubIntegrationId'); }); + it('preserves a validated worktree ID and its canonical workspace path', () => { + const worktreeId = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; + const current = { + metadataSchemaVersion: 2 as const, + identity: { + sessionId: 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e', + userId: 'oauth/google:1234', + }, + auth: {}, + workspace: { + worktreeId, + workspacePath: `/workspace/oauth-google-1234/worktrees/${worktreeId}`, + }, + lifecycle: { version: 1, timestamp: 1 }, + }; + + expect(parseSessionMetadata(current)).toEqual(current); + expect(serializeSessionMetadata(parseSessionMetadata(current))).toEqual(current); + }); + + it.each([ + 'worktree_invalid', + 'worktree_../outside', + 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e', + ])('rejects malformed persisted worktree ID %s', worktreeId => { + expect(() => + parseSessionMetadata({ + metadataSchemaVersion: 2, + identity: { sessionId: 'workspace_test', userId: 'user_test' }, + auth: {}, + workspace: { worktreeId }, + lifecycle: { version: 1, timestamp: 1 }, + }) + ).toThrow('Invalid current session metadata'); + }); + it.each([undefined, '2026-08-29T10:00:00.000Z'])( 'round-trips clone metadata without inventing a reporting creation time (%s)', reportingCreatedAt => { diff --git a/services/cloud-agent-next/src/persistence/session-metadata.ts b/services/cloud-agent-next/src/persistence/session-metadata.ts index fcb7769edd..3f4abdd07d 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.ts @@ -1,5 +1,8 @@ import * as z from 'zod'; -import { sessionIdSchema as kiloSessionIdSchema } from '@kilocode/session-ingest-contracts'; +import { + cloudAgentWorktreeIdSchema, + sessionIdSchema as kiloSessionIdSchema, +} from '@kilocode/session-ingest-contracts'; import { PROVIDER_CAPABILITIES } from '../agent-sandbox/capabilities.js'; import { isGeneratedSharedSandboxId, isValidSandboxId } from '../sandbox-id.js'; @@ -236,11 +239,8 @@ const MetadataWorkspaceSchema = z sandboxRoute: MetadataSharedSandboxRouteSchema.optional(), sandboxProvider: SandboxProviderSchema.optional(), providerRuntime: ProviderRuntimeSchema.optional(), + worktreeId: cloudAgentWorktreeIdSchema.optional(), workspacePath: z.string().optional(), - worktreeId: z - .string() - .regex(/^worktree_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) - .optional(), sessionHome: z.string().optional(), branchName: z.string().optional(), shallow: z.boolean().optional(), diff --git a/services/cloud-agent-next/src/router.ts b/services/cloud-agent-next/src/router.ts index cfead3eecc..3036e0a29f 100644 --- a/services/cloud-agent-next/src/router.ts +++ b/services/cloud-agent-next/src/router.ts @@ -12,8 +12,11 @@ import { createSessionQuestionHandlers } from './router/handlers/session-questio import { createSessionTerminalHandlers } from './router/handlers/session-terminal.js'; import { createSessionStartHandlers } from './router/handlers/session-start.js'; import { createSessionSendHandlers } from './router/handlers/session-send.js'; +import { createSessionWorktreeHandlers } from './router/handlers/session-worktree.js'; +import { deleteWorktree } from './router/handlers/worktree-deletion.js'; export const appRouter = router({ + deleteWorktree, ...createSessionManagementHandlers(), ...createSessionPrepareHandlers(), ...createSessionExecutionV2Handlers(), @@ -21,6 +24,7 @@ export const appRouter = router({ ...createSessionTerminalHandlers(), ...createSessionStartHandlers(), ...createSessionSendHandlers(), + ...createSessionWorktreeHandlers(), }); export type AppRouter = typeof appRouter; diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts index f43feae3e2..d852a445cb 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.test.ts @@ -167,6 +167,7 @@ describe('prepareSession operation-ledger admission gate', () => { autoInitiate: true, operationKey: OPERATION_KEY, createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', }); expect(createSessionWithLedgerMock).toHaveBeenCalledTimes(1); @@ -175,6 +176,7 @@ describe('prepareSession operation-ledger admission gate', () => { options: expect.objectContaining({ operationKey: OPERATION_KEY, createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', }), }), expect.objectContaining({ userId: 'test-user-123', authToken: 'test-auth-token' }), @@ -210,6 +212,29 @@ describe('prepareSession operation-ledger admission gate', () => { ); }); + it('preserves trusted mobile provenance on internally authenticated preparation', async () => { + const caller = router.createCaller(createContext()); + + await caller.prepareSession({ + prompt: 'Test prompt', + mode: 'code', + model: 'claude-3', + githubRepo: 'acme/repo', + autoInitiate: true, + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'mobile', + }); + + expect(createSessionWithLedgerMock).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ clientProvenance: 'mobile' }), + }), + expect.anything(), + expect.anything() + ); + }); + it('ignores operationKey when autoInitiate is false and retains legacy registration', async () => { const caller = router.createCaller(createContext()); diff --git a/services/cloud-agent-next/src/router/handlers/session-prepare.ts b/services/cloud-agent-next/src/router/handlers/session-prepare.ts index a363dfa838..007ab40850 100644 --- a/services/cloud-agent-next/src/router/handlers/session-prepare.ts +++ b/services/cloud-agent-next/src/router/handlers/session-prepare.ts @@ -305,6 +305,7 @@ export function prepareInputToSessionCreateRequest(input: PrepareInput): Session callbackTarget: input.callbackTarget, kilocodeOrganizationId: input.kilocodeOrganizationId, createdOnPlatform: input.createdOnPlatform, + clientProvenance: input.clientProvenance, shallow: input.shallow, operationKey: input.operationKey, }, diff --git a/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts new file mode 100644 index 0000000000..0f3e25f7c7 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts @@ -0,0 +1,937 @@ +import { TRPCError } from '@trpc/server'; +import type { WorkerDb } from '@kilocode/db/client'; +import type { OperationLedgerRow } from '@kilocode/db/schema'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { t } from '../auth.js'; +import type { SessionMetadata } from '../../persistence/session-metadata.js'; +import type * as SessionPlane from '../../session-plane.js'; +import type { TRPCContext } from '../../types.js'; +import { sha256Hex } from '../../utils/sha256.js'; +import { getWorktreeWorkspacePath } from '../../workspace.js'; +import { CreateWorktreeChatInput, createSessionWorktreeHandlers } from './session-worktree.js'; + +const { + admitOperationMock, + markReconcilePendingMock, + recordOperationProgressMock, + settleOperationMock, + getPgDbMock, + assertOrganizationMembershipMock, + generateSessionIdMock, + generateKiloSessionIdMock, + withDORetryMock, + createSessionForCloudAgentMock, + deleteSessionForCloudAgentMock, +} = vi.hoisted(() => ({ + admitOperationMock: vi.fn(), + markReconcilePendingMock: vi.fn(), + recordOperationProgressMock: vi.fn(), + settleOperationMock: vi.fn(), + getPgDbMock: vi.fn(), + assertOrganizationMembershipMock: vi.fn(), + generateSessionIdMock: vi.fn(), + generateKiloSessionIdMock: vi.fn(), + withDORetryMock: vi.fn(), + createSessionForCloudAgentMock: vi.fn(), + deleteSessionForCloudAgentMock: vi.fn(), +})); + +vi.mock('@kilocode/db/operation-ledger', () => ({ + admitOperation: admitOperationMock, + markReconcilePending: markReconcilePendingMock, + recordOperationProgress: recordOperationProgressMock, + settleOperation: settleOperationMock, +})); + +vi.mock('../../db/pg.js', () => ({ getPgDb: getPgDbMock })); + +vi.mock('./organization-membership.js', () => ({ + assertOrganizationMembership: assertOrganizationMembershipMock, +})); + +vi.mock('../../session-plane.js', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + generateSessionId: (plane: string) => generateSessionIdMock(plane), + }; +}); + +vi.mock('../../utils/kilo-session-id.js', () => ({ + generateKiloSessionId: () => generateKiloSessionIdMock(), +})); + +vi.mock('../../utils/do-retry.js', () => ({ + withDORetry: ( + getStub: () => unknown, + operation: (stub: unknown) => Promise, + operationName: string + ) => withDORetryMock(getStub, operation, operationName), +})); + +const USER_ID = 'oauth/google:1234'; +const ORGANIZATION_ID = '11111111-1111-4111-8111-111111111111'; +const OTHER_ORGANIZATION_ID = '22222222-2222-4222-8222-222222222222'; +const SOURCE_WORKSPACE_ID = 'workspace_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const DESTINATION_WORKSPACE_ID = 'workspace_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const ALTERNATE_SOURCE_WORKSPACE_ID = 'workspace_dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const SOURCE_KILO_SESSION_ID = 'ses_12345678901234567890123456'; +const DESTINATION_KILO_SESSION_ID = 'ses_abcdefghijklmnopqrstuvwxyz'; +const ALTERNATE_SOURCE_KILO_SESSION_ID = 'ses_ZYXWVUTSRQPONMLKJIHGFEDCBA'; +const WORKTREE_ID = 'worktree_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const OTHER_WORKTREE_ID = 'worktree_dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const OPERATION_KEY = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const LEDGER_ROW_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const SHARED_SANDBOX_ID = 'usr-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const INTERNAL_SECRET = 'test-internal-secret'; +const CURRENT_AUTH_TOKEN = 'current-customer-token'; + +const router = t.router(createSessionWorktreeHandlers()); + +type OwnershipFixture = { + kiloSessionId: string; + cloudAgentSessionId: string; + userId: string; + organizationId: string | null; + worktreeId: string | null; + createdOnPlatform: string; + parentSessionId: string | null; + cloudAgentSessionScopeId: string | null; + gitUrl: string | null; +}; + +function sourceMetadata(options?: { + organizationId?: string; + userId?: string; + cloudAgentSessionId?: string; + kiloSessionId?: string; +}): SessionMetadata { + const userId = options?.userId ?? USER_ID; + const organizationId = options?.organizationId; + return { + metadataSchemaVersion: 2, + identity: { + sessionId: options?.cloudAgentSessionId ?? SOURCE_WORKSPACE_ID, + userId, + ...(organizationId ? { orgId: organizationId } : {}), + createdOnPlatform: 'cloud-agent-web', + billingOrigin: 'cloud-agent-web', + }, + auth: { + kiloSessionId: options?.kiloSessionId ?? SOURCE_KILO_SESSION_ID, + kilocodeToken: 'previous-customer-token', + }, + repository: { + type: 'github', + repo: 'Acme/Repo', + upstreamBranch: 'feature/shared', + token: 'previous-repository-token', + githubInstallationId: '12345', + }, + agent: { mode: 'code', model: 'test-model', appendSystemPrompt: 'Stay focused' }, + profile: { envVars: { PROFILE_SETTING: 'preserved' }, setupCommands: ['pnpm install'] }, + finalization: { autoCommit: false, condenseOnComplete: true }, + workspace: { + sandboxId: SHARED_SANDBOX_ID, + sandboxProvider: 'cloudflare', + sandboxRoute: { kind: 'shared', routeKey: SHARED_SANDBOX_ID }, + worktreeId: WORKTREE_ID, + workspacePath: getWorktreeWorkspacePath(organizationId, userId, WORKTREE_ID), + branchName: 'feature/shared', + shallow: true, + credentialContainment: { github: true, gitlab: false, kilocode: true }, + }, + lifecycle: { version: 1, timestamp: 1 }, + }; +} + +function ownershipRow(overrides: Partial = {}): OwnershipFixture { + return { + kiloSessionId: SOURCE_KILO_SESSION_ID, + cloudAgentSessionId: SOURCE_WORKSPACE_ID, + userId: USER_ID, + organizationId: null, + worktreeId: WORKTREE_ID, + createdOnPlatform: 'cloud-agent-web', + parentSessionId: null, + cloudAgentSessionScopeId: SOURCE_WORKSPACE_ID, + gitUrl: 'https://github.com/acme/repo', + ...overrides, + }; +} + +function destinationOwnershipRow(overrides: Partial = {}): OwnershipFixture { + return ownershipRow({ + kiloSessionId: DESTINATION_KILO_SESSION_ID, + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + cloudAgentSessionScopeId: DESTINATION_WORKSPACE_ID, + ...overrides, + }); +} + +function destinationMetadata(source: SessionMetadata): SessionMetadata { + const workspace = { ...source.workspace }; + delete workspace.providerRuntime; + const repository = source.repository ? { ...source.repository } : undefined; + if (repository && 'token' in repository) delete repository.token; + + return { + ...source, + identity: { ...source.identity, sessionId: DESTINATION_WORKSPACE_ID }, + auth: { kiloSessionId: DESTINATION_KILO_SESSION_ID, kilocodeToken: CURRENT_AUTH_TOKEN }, + ...(repository ? { repository } : {}), + workspace, + finalization: { ...source.finalization, autoCommit: false }, + }; +} + +function ledgerRow(overrides: Partial = {}): OperationLedgerRow { + return { + id: LEDGER_ROW_ID, + operation_key: OPERATION_KEY, + domain: 'session', + intent: 'create_worktree_chat', + kilo_user_id: USER_ID, + organization_id: null, + resource_key: WORKTREE_ID, + provider_ref: null, + taxonomy: 'safe-retry', + status: 'admitted', + outcome_code: null, + canonical_result: null, + admitted_at: new Date().toISOString(), + settled_at: null, + lease_expires_at: new Date(Date.now() + 120_000).toISOString(), + expires_at: new Date(Date.now() + 30 * 86_400_000).toISOString(), + ...overrides, + }; +} + +function makeDb(results: OwnershipFixture[][]): WorkerDb { + const limit = vi.fn(async () => results.shift() ?? []); + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit })), + })), + })), + } as unknown as WorkerDb; +} + +function fixture(options?: { + organizationId?: string; + userId?: string; + metadata?: SessionMetadata; + ownershipResults?: OwnershipFixture[][]; + internalSecret?: string; + controlPlaneIds?: string; + botId?: string; +}) { + const userId = options?.userId ?? USER_ID; + const metadata = + options?.metadata ?? sourceMetadata({ userId, organizationId: options?.organizationId }); + const source = ownershipRow({ + userId, + organizationId: options?.organizationId ?? null, + cloudAgentSessionId: metadata.identity.sessionId, + cloudAgentSessionScopeId: metadata.identity.sessionId, + kiloSessionId: metadata.auth.kiloSessionId ?? SOURCE_KILO_SESSION_ID, + }); + const db = makeDb(options?.ownershipResults ?? [[source]]); + getPgDbMock.mockReturnValue(db); + + const sourceStub = { getMetadata: vi.fn().mockResolvedValue(metadata) }; + const destinationStub = { + getMetadata: vi.fn().mockResolvedValue(null), + registerSession: vi.fn().mockResolvedValue({ success: true }), + createSessionWithInitialAdmission: vi.fn(), + }; + const sandboxSessionNamespace = { + idFromName: vi.fn((name: string) => name), + get: vi.fn((name: string) => + name === `${userId}:${metadata.identity.sessionId}` ? sourceStub : destinationStub + ), + }; + const legacySessionNamespace = { idFromName: vi.fn(), get: vi.fn() }; + const sandboxControlNamespace = { idFromName: vi.fn(), get: vi.fn() }; + const headers = new Headers({ + 'x-internal-api-key': options?.internalSecret ?? INTERNAL_SECRET, + }); + const context = { + userId, + authToken: CURRENT_AUTH_TOKEN, + ...(options?.botId ? { botId: options.botId } : {}), + request: { headers } as Request, + env: { + INTERNAL_API_SECRET: INTERNAL_SECRET, + CONTROL_PLANE_IDS: options?.controlPlaneIds ?? '*', + HYPERDRIVE: { connectionString: 'postgres://worktree-handler-test' }, + SANDBOX_SESSION: sandboxSessionNamespace, + CLOUD_AGENT_SESSION: legacySessionNamespace, + SANDBOX_CONTROL: sandboxControlNamespace, + SESSION_INGEST: { + createSessionForCloudAgent: createSessionForCloudAgentMock, + deleteSessionForCloudAgent: deleteSessionForCloudAgentMock, + }, + }, + } as unknown as TRPCContext; + const input = { + sourceKiloSessionId: source.kiloSessionId, + sourceCloudAgentSessionId: source.cloudAgentSessionId as `workspace_${string}`, + operationKey: OPERATION_KEY, + ...(options?.organizationId ? { kilocodeOrganizationId: options.organizationId } : {}), + clientProvenance: 'browser' as const, + }; + + return { + caller: router.createCaller(context), + context, + db, + input, + metadata, + source, + sourceStub, + destinationStub, + sandboxSessionNamespace, + legacySessionNamespace, + sandboxControlNamespace, + }; +} + +async function progressFor( + input: ReturnType['input'], + overrides: Record = {} +) { + const fingerprint = await sha256Hex( + JSON.stringify({ + sourceKiloSessionId: input.sourceKiloSessionId, + sourceCloudAgentSessionId: input.sourceCloudAgentSessionId, + organizationId: input.kilocodeOrganizationId ?? null, + worktreeId: WORKTREE_ID, + clientProvenance: input.clientProvenance, + }) + ); + return { + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + createIntentFingerprint: fingerprint, + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + generateSessionIdMock.mockReturnValue(DESTINATION_WORKSPACE_ID); + generateKiloSessionIdMock.mockReturnValue(DESTINATION_KILO_SESSION_ID); + assertOrganizationMembershipMock.mockResolvedValue(undefined); + markReconcilePendingMock.mockResolvedValue(null); + recordOperationProgressMock.mockImplementation( + async (_db: WorkerDb, _rowId: string, progress: Record) => + ledgerRow({ canonical_result: progress }) + ); + settleOperationMock.mockResolvedValue({ settled: true }); + deleteSessionForCloudAgentMock.mockResolvedValue(undefined); + createSessionForCloudAgentMock.mockResolvedValue({ + status: 'ready', + clone: { sessionId: DESTINATION_KILO_SESSION_ID, copiedItemCount: 0 }, + }); + admitOperationMock.mockImplementation( + async ( + _db: WorkerDb, + input: { userId: string; orgId?: string; intent: string; resourceKey?: string } + ) => ({ + admission: 'admitted', + row: ledgerRow({ + kilo_user_id: input.userId, + organization_id: input.orgId ?? null, + intent: input.intent, + resource_key: input.resourceKey ?? null, + }), + }) + ); + withDORetryMock.mockImplementation( + async (getStub: () => unknown, operation: (stub: unknown) => Promise) => { + try { + return await operation(getStub()); + } catch (error) { + if (error instanceof Error && 'retryable' in error && error.retryable === true) { + return operation(getStub()); + } + throw error; + } + } + ); +}); + +describe('createWorktreeChat request validation and authorization', () => { + it('rejects malformed identities, non-browser provenance, and unknown input fields', () => { + const { input } = fixture(); + + expect(CreateWorktreeChatInput.safeParse(input).success).toBe(true); + expect( + CreateWorktreeChatInput.safeParse({ ...input, sourceKiloSessionId: 'ses_invalid' }).success + ).toBe(false); + expect( + CreateWorktreeChatInput.safeParse({ + ...input, + sourceCloudAgentSessionId: 'agent_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + }).success + ).toBe(false); + expect( + CreateWorktreeChatInput.safeParse({ ...input, operationKey: 'not-a-uuid' }).success + ).toBe(false); + expect( + CreateWorktreeChatInput.safeParse({ ...input, clientProvenance: 'mobile' }).success + ).toBe(false); + expect(CreateWorktreeChatInput.safeParse({ ...input, unexpected: true }).success).toBe(false); + }); + + it('requires internal authentication before loading source ownership', async () => { + const { caller, input } = fixture({ internalSecret: 'wrong-secret' }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(getPgDbMock).not.toHaveBeenCalled(); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects another owner or a same-organization member without source ownership', async () => { + const { caller, input } = fixture({ + organizationId: ORGANIZATION_ID, + ownershipResults: [[]], + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(assertOrganizationMembershipMock).toHaveBeenCalledWith( + expect.anything(), + USER_ID, + ORGANIZATION_ID + ); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects revoked organization membership before reading source metadata', async () => { + assertOrganizationMembershipMock.mockRejectedValueOnce( + new TRPCError({ code: 'FORBIDDEN', message: 'membership revoked' }) + ); + const { caller, input, sourceStub } = fixture({ organizationId: ORGANIZATION_ID }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(sourceStub.getMetadata).not.toHaveBeenCalled(); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects a source from another organization context', async () => { + const source = ownershipRow({ organizationId: OTHER_ORGANIZATION_ID }); + const { caller, input } = fixture({ + organizationId: ORGANIZATION_ID, + ownershipResults: [[source]], + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects bot-authenticated callers', async () => { + const { caller, input, sourceStub } = fixture({ botId: 'bot-user' }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(sourceStub.getMetadata).not.toHaveBeenCalled(); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it.each([ + ['missing group', (row: OwnershipFixture) => ({ ...row, worktreeId: null })], + ['malformed group', (row: OwnershipFixture) => ({ ...row, worktreeId: 'worktree_../../bad' })], + [ + 'child session', + (row: OwnershipFixture) => ({ ...row, parentSessionId: SOURCE_KILO_SESSION_ID }), + ], + [ + 'different scope', + (row: OwnershipFixture) => ({ ...row, cloudAgentSessionScopeId: DESTINATION_WORKSPACE_ID }), + ], + ['automation origin', (row: OwnershipFixture) => ({ ...row, createdOnPlatform: 'slack' })], + ])('rejects an ineligible ownership row: %s', async (_label, mutate) => { + const { caller, input } = fixture({ ownershipResults: [[mutate(ownershipRow())]] }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'owner mismatch', + (metadata: SessionMetadata) => ({ + ...metadata, + identity: { ...metadata.identity, userId: 'different-owner' }, + }), + ], + [ + 'source Kilo mismatch', + (metadata: SessionMetadata) => ({ + ...metadata, + auth: { ...metadata.auth, kiloSessionId: DESTINATION_KILO_SESSION_ID }, + }), + ], + [ + 'worktree mismatch', + (metadata: SessionMetadata) => ({ + ...metadata, + workspace: { ...metadata.workspace, worktreeId: OTHER_WORKTREE_ID }, + }), + ], + [ + 'noncanonical path', + (metadata: SessionMetadata) => ({ + ...metadata, + workspace: { ...metadata.workspace, workspacePath: '/workspace/another-owner/worktree' }, + }), + ], + [ + 'devcontainer session', + (metadata: SessionMetadata) => ({ + ...metadata, + workspace: { ...metadata.workspace, devcontainerRequested: true }, + }), + ], + [ + 'enabled auto-commit', + (metadata: SessionMetadata) => ({ + ...metadata, + finalization: { ...metadata.finalization, autoCommit: true }, + }), + ], + [ + 'repository mismatch', + (metadata: SessionMetadata) => ({ + ...metadata, + repository: { type: 'github' as const, repo: 'different/repository' }, + }), + ], + ])('rejects unsafe source metadata: %s', async (_label, mutate) => { + const original = sourceMetadata(); + const { caller, input, sourceStub } = fixture({ metadata: original }); + sourceStub.getMetadata.mockResolvedValueOnce(mutate(original)); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects a grouped owner that is no longer enrolled in the control plane', async () => { + const { caller, input } = fixture({ controlPlaneIds: 'another-owner' }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(admitOperationMock).not.toHaveBeenCalled(); + }); +}); + +describe('createWorktreeChat ownership, metadata, and control-plane routing', () => { + it('records canonical IDs first, preserves the source route, and registers an empty control-plane chat', async () => { + const { + caller, + input, + metadata, + destinationStub, + sandboxSessionNamespace, + legacySessionNamespace, + sandboxControlNamespace, + } = fixture(); + const steps: string[] = []; + recordOperationProgressMock.mockImplementation( + async (_db: WorkerDb, _rowId: string, progress: Record) => { + steps.push('progress'); + return ledgerRow({ canonical_result: progress }); + } + ); + createSessionForCloudAgentMock.mockImplementation(async () => { + steps.push('ownership'); + return { + status: 'ready', + clone: { sessionId: DESTINATION_KILO_SESSION_ID, copiedItemCount: 0 }, + }; + }); + destinationStub.registerSession.mockImplementation(async () => { + steps.push('register'); + return { success: true }; + }); + settleOperationMock.mockImplementation(async () => { + steps.push('complete'); + return { settled: true }; + }); + + await expect(caller.createWorktreeChat(input)).resolves.toEqual({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + }); + + expect(steps).toEqual(['progress', 'ownership', 'register', 'complete']); + expect(admitOperationMock).toHaveBeenCalledWith(expect.anything(), { + userId: USER_ID, + orgId: undefined, + domain: 'session', + intent: 'create_worktree_chat', + operationKey: OPERATION_KEY, + resourceKey: WORKTREE_ID, + taxonomy: 'safe-retry', + leaseSeconds: 120, + }); + expect(recordOperationProgressMock).toHaveBeenCalledWith( + expect.anything(), + LEDGER_ROW_ID, + expect.objectContaining({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + createIntentFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/), + }) + ); + expect(createSessionForCloudAgentMock).toHaveBeenCalledWith({ + sessionId: DESTINATION_KILO_SESSION_ID, + kiloUserId: USER_ID, + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + cloudAgentWorktreeId: WORKTREE_ID, + cloudAgentWorktreeLocation: { + sandboxId: metadata.workspace?.sandboxId, + provider: 'cloudflare', + }, + organizationId: undefined, + createdOnPlatform: 'cloud-agent-web', + title: expect.stringMatching(/^New session - /), + gitUrl: 'https://github.com/acme/repo', + }); + expect(destinationStub.registerSession).toHaveBeenCalledWith({ + identity: { ...metadata.identity, sessionId: DESTINATION_WORKSPACE_ID }, + auth: { kiloSessionId: DESTINATION_KILO_SESSION_ID, kilocodeToken: CURRENT_AUTH_TOKEN }, + agent: metadata.agent, + repository: { + type: 'github', + repo: 'Acme/Repo', + upstreamBranch: 'feature/shared', + githubInstallationId: '12345', + }, + workspace: metadata.workspace, + profile: metadata.profile, + finalization: { autoCommit: false, condenseOnComplete: true }, + }); + expect(sandboxSessionNamespace.idFromName).toHaveBeenCalledWith( + `${USER_ID}:${DESTINATION_WORKSPACE_ID}` + ); + expect(legacySessionNamespace.idFromName).not.toHaveBeenCalled(); + expect(legacySessionNamespace.get).not.toHaveBeenCalled(); + expect(sandboxControlNamespace.get).not.toHaveBeenCalled(); + expect(destinationStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(generateSessionIdMock).toHaveBeenCalledWith('control'); + }); + + it('does not create ownership when canonical operation progress cannot be recorded', async () => { + recordOperationProgressMock.mockResolvedValueOnce(null); + const { caller, input, destinationStub } = fixture(); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + expect(destinationStub.registerSession).not.toHaveBeenCalled(); + }); + + it('preserves an organization-scoped isolated Vercel route without copying provider runtime', async () => { + const metadata = sourceMetadata({ organizationId: ORGANIZATION_ID }); + metadata.workspace = { + ...metadata.workspace, + sandboxId: 'ses-0123456789abcdef', + sandboxProvider: 'vercel', + sandboxRoute: undefined, + providerRuntime: { provider: 'vercel', sessionId: 'vercel-instance' }, + }; + const { caller, input, destinationStub } = fixture({ + organizationId: ORGANIZATION_ID, + metadata, + }); + + await caller.createWorktreeChat(input); + + expect(assertOrganizationMembershipMock).toHaveBeenCalledWith( + expect.anything(), + USER_ID, + ORGANIZATION_ID + ); + expect(destinationStub.registerSession).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ orgId: ORGANIZATION_ID }), + workspace: expect.objectContaining({ + sandboxId: 'ses-0123456789abcdef', + sandboxProvider: 'vercel', + worktreeId: WORKTREE_ID, + workspacePath: getWorktreeWorkspacePath(ORGANIZATION_ID, USER_ID, WORKTREE_ID), + }), + }) + ); + const registration = destinationStub.registerSession.mock.calls[0]?.[0]; + expect(registration?.workspace).not.toHaveProperty('providerRuntime'); + expect(registration?.repository).not.toHaveProperty('token'); + expect(registration?.auth.kilocodeToken).toBe(CURRENT_AUTH_TOKEN); + }); +}); + +describe('createWorktreeChat operation-ledger replay and conflict handling', () => { + it('replays only canonical sanitized IDs for an identical completed operation', async () => { + const { caller, input, destinationStub } = fixture({ + ownershipResults: [[ownershipRow()], [ownershipRow()]], + }); + await caller.createWorktreeChat(input); + const progress = recordOperationProgressMock.mock.calls[0]?.[2] as Record; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ status: 'completed', canonical_result: progress }), + }); + + await expect(caller.createWorktreeChat(input)).resolves.toEqual({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + replayed: true, + }); + expect(createSessionForCloudAgentMock).toHaveBeenCalledTimes(1); + expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['first-chat intent', { intent: 'create_cloud', resource_key: null }], + ['different domain', { domain: 'security' }], + ['different user', { kilo_user_id: 'different-user' }], + ['different organization', { organization_id: OTHER_ORGANIZATION_ID }], + ['different worktree', { resource_key: OTHER_WORKTREE_ID }], + ] as const)( + 'rejects an admitted identity collision before handling %s', + async (_label, change) => { + const { caller, input, destinationStub } = fixture(); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ + ...change, + status: 'completed', + canonical_result: await progressFor(input), + }), + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + expect(destinationStub.getMetadata).not.toHaveBeenCalled(); + expect(destinationStub.registerSession).not.toHaveBeenCalled(); + } + ); + + it('rejects a same-worktree retry whose source chat changed before replay or reconciliation', async () => { + const alternateSource = sourceMetadata({ + cloudAgentSessionId: ALTERNATE_SOURCE_WORKSPACE_ID, + kiloSessionId: ALTERNATE_SOURCE_KILO_SESSION_ID, + }); + const { caller, input, destinationStub } = fixture({ metadata: alternateSource }); + const progress = await progressFor({ + ...input, + sourceKiloSessionId: SOURCE_KILO_SESSION_ID, + sourceCloudAgentSessionId: SOURCE_WORKSPACE_ID, + }); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ status: 'reconcile_pending', canonical_result: progress }), + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(destinationStub.getMetadata).not.toHaveBeenCalled(); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + }); + + it.each(['duplicate_in_flight', 'duplicate_reconcile_in_progress'] as const)( + 'returns an in-progress conflict for %s without side effects', + async admission => { + const { caller, input, destinationStub } = fixture(); + admitOperationMock.mockResolvedValueOnce({ admission, row: ledgerRow() }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'creation_in_progress', + }); + expect(destinationStub.registerSession).not.toHaveBeenCalled(); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + } + ); + + it('does not replay IDs from a failed settled operation', async () => { + const { caller, input } = fixture(); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: ledgerRow({ status: 'failed', canonical_result: await progressFor(input) }), + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + }); +}); + +describe('createWorktreeChat registration rollback and unknown-outcome reconciliation', () => { + it('rolls back only the empty ownership row after an explicit registration rejection', async () => { + const { caller, input, destinationStub } = fixture(); + destinationStub.registerSession.mockResolvedValueOnce({ + success: false, + error: 'sensitive internal registration detail', + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'worktree_chat_registration_failed', + }); + expect(deleteSessionForCloudAgentMock).toHaveBeenCalledWith({ + sessionId: DESTINATION_KILO_SESSION_ID, + kiloUserId: USER_ID, + onlyIfEmpty: true, + }); + expect(settleOperationMock).toHaveBeenCalledWith(expect.anything(), { + rowId: LEDGER_ROW_ID, + status: 'failed', + outcomeCode: 'registration_rejected', + }); + expect(markReconcilePendingMock).not.toHaveBeenCalled(); + }); + + it('keeps ambiguous registration ownership and reconciles existing metadata on the same-key retry', async () => { + const { caller, input, metadata, destinationStub } = fixture({ + ownershipResults: [[ownershipRow()], [ownershipRow()], [destinationOwnershipRow()]], + }); + destinationStub.registerSession.mockRejectedValueOnce( + new Error('registration outcome unknown') + ); + + await expect(caller.createWorktreeChat(input)).rejects.toThrow('registration outcome unknown'); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.anything(), { + rowId: LEDGER_ROW_ID, + }); + expect(deleteSessionForCloudAgentMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + + const progress = recordOperationProgressMock.mock.calls[0]?.[2] as Record; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ status: 'reconcile_pending', canonical_result: progress }), + }); + destinationStub.getMetadata.mockResolvedValueOnce(destinationMetadata(metadata)); + + await expect(caller.createWorktreeChat(input)).resolves.toEqual({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + replayed: true, + }); + expect(createSessionForCloudAgentMock).toHaveBeenCalledTimes(1); + expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status: 'completed' }) + ); + }); + + it('reads committed metadata before retrying a retryable registration transport failure', async () => { + const { caller, input, metadata, destinationStub } = fixture(); + const transportError = Object.assign(new Error('retryable transport failure'), { + retryable: true, + }); + destinationStub.registerSession.mockRejectedValueOnce(transportError); + destinationStub.getMetadata.mockResolvedValueOnce(destinationMetadata(metadata)); + + await expect(caller.createWorktreeChat(input)).resolves.toEqual({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + }); + expect(destinationStub.getMetadata).toHaveBeenCalledTimes(1); + expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); + expect(deleteSessionForCloudAgentMock).not.toHaveBeenCalled(); + expect(markReconcilePendingMock).not.toHaveBeenCalled(); + }); + + it('resumes the recorded IDs when ownership exists but destination metadata is absent', async () => { + const { caller, input, destinationStub } = fixture({ + ownershipResults: [[ownershipRow()], [destinationOwnershipRow()]], + }); + admitOperationMock.mockResolvedValueOnce({ + admission: 'takeover', + row: ledgerRow({ canonical_result: await progressFor(input) }), + }); + + await expect(caller.createWorktreeChat(input)).resolves.toEqual({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + replayed: true, + }); + expect(destinationStub.getMetadata).toHaveBeenCalledTimes(1); + expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + expect(generateSessionIdMock).not.toHaveBeenCalled(); + }); + + it('recreates missing ownership without registering already-committed destination metadata twice', async () => { + const { caller, input, metadata, destinationStub } = fixture({ + ownershipResults: [[ownershipRow()], []], + }); + destinationStub.getMetadata.mockResolvedValueOnce(destinationMetadata(metadata)); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + canonical_result: await progressFor(input), + }), + }); + + await caller.createWorktreeChat(input); + + expect(createSessionForCloudAgentMock).toHaveBeenCalledTimes(1); + expect(destinationStub.registerSession).not.toHaveBeenCalled(); + }); + + it('rejects destination metadata that points at a different physical route', async () => { + const { caller, input, metadata, destinationStub } = fixture(); + const destination = destinationMetadata(metadata); + destination.workspace = { + ...destination.workspace, + sandboxId: 'usr-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + sandboxRoute: undefined, + }; + destinationStub.getMetadata.mockResolvedValueOnce(destination); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ + status: 'reconcile_pending', + canonical_result: await progressFor(input), + }), + }); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(destinationStub.registerSession).not.toHaveBeenCalled(); + expect(createSessionForCloudAgentMock).not.toHaveBeenCalled(); + }); + + it('does not report success when the completed ledger settlement is not recorded', async () => { + settleOperationMock.mockResolvedValueOnce({ settled: false, row: null }); + const { caller, input, destinationStub } = fixture(); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.anything(), { + rowId: LEDGER_ROW_ID, + }); + }); + + it('marks a missing ownership-create acknowledgement for reconciliation without registering', async () => { + createSessionForCloudAgentMock.mockResolvedValueOnce(undefined); + const { caller, input, destinationStub } = fixture(); + + await expect(caller.createWorktreeChat(input)).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.anything(), { + rowId: LEDGER_ROW_ID, + }); + expect(destinationStub.registerSession).not.toHaveBeenCalled(); + }); +}); diff --git a/services/cloud-agent-next/src/router/handlers/session-worktree.ts b/services/cloud-agent-next/src/router/handlers/session-worktree.ts new file mode 100644 index 0000000000..0eeda93745 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.ts @@ -0,0 +1,600 @@ +import { TRPCError } from '@trpc/server'; +import type { WorkerDb } from '@kilocode/db/client'; +import { + admitOperation, + markReconcilePending, + recordOperationProgress, + settleOperation, +} from '@kilocode/db/operation-ledger'; +import { cli_sessions_v2, type OperationLedgerRow } from '@kilocode/db/schema'; +import { + cloudAgentWorktreeIdSchema, + sessionIdSchema as kiloSessionIdSchema, + type CloudAgentWorktreeId, +} from '@kilocode/session-ingest-contracts'; +import { normalizeGitUrl } from '@kilocode/worker-utils'; +import { and, eq } from 'drizzle-orm'; +import { z } from 'zod'; + +import { getPgDb } from '../../db/pg.js'; +import { + CurrentSessionMetadataSchema, + type SessionMetadata, +} from '../../persistence/session-metadata.js'; +import { getSandboxSessionStub } from '../../sandbox-session/session-stub.js'; +import { generateSessionId, isControlPlaneOwner } from '../../session-plane.js'; +import { + assertSessionOperationIdentity, + SESSION_CREATE_INTENT_FINGERPRINT_KEY, +} from '../../session/session-registration.js'; +import type { TRPCContext } from '../../types.js'; +import { withDORetry } from '../../utils/do-retry.js'; +import { generateKiloSessionId } from '../../utils/kilo-session-id.js'; +import { sha256Hex } from '../../utils/sha256.js'; +import { getWorktreeWorkspacePath } from '../../workspace.js'; +import { internalApiProtectedProcedure } from '../auth.js'; +import { assertOrganizationMembership } from './organization-membership.js'; + +const workspaceSessionIdSchema = z.templateLiteral(['workspace_', z.uuid()]); + +export const CreateWorktreeChatInput = z + .object({ + sourceKiloSessionId: kiloSessionIdSchema, + sourceCloudAgentSessionId: workspaceSessionIdSchema, + operationKey: z.uuid(), + kilocodeOrganizationId: z.uuid().optional(), + clientProvenance: z.literal('browser'), + }) + .strict(); + +export const CreateWorktreeChatOutput = z + .object({ + cloudAgentSessionId: workspaceSessionIdSchema, + kiloSessionId: kiloSessionIdSchema, + worktreeId: cloudAgentWorktreeIdSchema, + replayed: z.boolean().optional(), + }) + .strict(); + +const ownershipRowSchema = z + .object({ + kiloSessionId: kiloSessionIdSchema, + cloudAgentSessionId: workspaceSessionIdSchema, + userId: z.string().min(1), + organizationId: z.uuid().nullable(), + worktreeId: cloudAgentWorktreeIdSchema.nullable(), + createdOnPlatform: z.string().min(1), + parentSessionId: z.string().nullable(), + cloudAgentSessionScopeId: workspaceSessionIdSchema.nullable(), + gitUrl: z.string().nullable(), + }) + .strict(); + +const operationProgressSchema = CreateWorktreeChatOutput.omit({ replayed: true }) + .extend({ + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: z.string().regex(/^[a-f0-9]{64}$/), + }) + .strict(); + +const ingestResultSchema = z.discriminatedUnion('status', [ + z + .object({ + status: z.literal('ready'), + clone: z + .object({ + sessionId: kiloSessionIdSchema, + copiedItemCount: z.number().int().nonnegative(), + }) + .strict(), + }) + .strict(), + z.object({ status: z.literal('in_progress') }).strict(), + z.object({ status: z.literal('rejected'), code: z.string().min(1) }).strict(), +]); + +const registrationResultSchema = z.discriminatedUnion('success', [ + z.object({ success: z.literal(true) }).strip(), + z.object({ success: z.literal(false), error: z.string().optional() }).strip(), +]); + +type WorktreeInput = z.infer; +type WorktreeResult = z.infer; +type OwnershipRow = z.infer; +type OperationProgress = z.infer; +type WorktreeSource = { + ownership: OwnershipRow; + metadata: SessionMetadata; + workspace: NonNullable & { + sandboxId: NonNullable['sandboxId']>; + sandboxProvider: NonNullable['sandboxProvider']>; + }; + repository: NonNullable; + worktreeId: CloudAgentWorktreeId; +}; + +const WORKTREE_CREATE_LEDGER_LEASE_SECONDS = 120; + +function sourceRejected(): TRPCError { + return new TRPCError({ code: 'BAD_REQUEST', message: 'worktree_source_not_eligible' }); +} + +function operationConflict(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: 'operation_key_reuse_mismatch' }); +} + +function creationInProgress(): TRPCError { + return new TRPCError({ code: 'CONFLICT', message: 'creation_in_progress' }); +} + +function creationFailed(): TRPCError { + return new TRPCError({ code: 'BAD_REQUEST', message: 'worktree_chat_creation_failed' }); +} + +async function findOwnershipRow( + db: WorkerDb, + userId: string, + kiloSessionId: string, + cloudAgentSessionId: string +): Promise { + const [row] = await db + .select({ + kiloSessionId: cli_sessions_v2.session_id, + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + userId: cli_sessions_v2.kilo_user_id, + organizationId: cli_sessions_v2.organization_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + createdOnPlatform: cli_sessions_v2.created_on_platform, + parentSessionId: cli_sessions_v2.parent_session_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + gitUrl: cli_sessions_v2.git_url, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, userId), + eq(cli_sessions_v2.session_id, kiloSessionId), + eq(cli_sessions_v2.cloud_agent_session_id, cloudAgentSessionId) + ) + ) + .limit(1); + + if (!row) return null; + const parsed = ownershipRowSchema.safeParse(row); + if (!parsed.success) throw sourceRejected(); + return parsed.data; +} + +function canonicalRepositoryUrl(repository: WorktreeSource['repository']): string { + return normalizeGitUrl( + repository.type === 'github' ? `https://github.com/${repository.repo}` : repository.url + ); +} + +async function loadWorktreeSource( + db: WorkerDb, + ctx: TRPCContext, + input: WorktreeInput +): Promise { + if (ctx.botId !== undefined) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Session access denied' }); + } + + if (input.kilocodeOrganizationId) { + await assertOrganizationMembership(db, ctx.userId, input.kilocodeOrganizationId); + } + + const ownership = await findOwnershipRow( + db, + ctx.userId, + input.sourceKiloSessionId, + input.sourceCloudAgentSessionId + ); + if (!ownership || ownership.organizationId !== (input.kilocodeOrganizationId ?? null)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Session access denied' }); + } + + const worktreeId = ownership.worktreeId; + if ( + !worktreeId || + ownership.parentSessionId !== null || + ownership.cloudAgentSessionScopeId !== input.sourceCloudAgentSessionId || + ownership.createdOnPlatform !== 'cloud-agent-web' || + !isControlPlaneOwner(ctx.env, { + userId: ctx.userId, + orgId: input.kilocodeOrganizationId, + }) + ) { + throw sourceRejected(); + } + + const rawMetadata = await withDORetry( + () => getSandboxSessionStub(ctx.env, ctx.userId, input.sourceCloudAgentSessionId), + stub => stub.getMetadata(), + 'getMetadata' + ); + const parsedMetadata = CurrentSessionMetadataSchema.safeParse(rawMetadata); + if (!parsedMetadata.success) throw sourceRejected(); + + const metadata = parsedMetadata.data; + const workspace = metadata.workspace; + const repository = metadata.repository; + if ( + !workspace || + !repository || + !workspace.sandboxId || + !workspace.sandboxProvider || + !metadata.agent?.mode || + !metadata.agent.model || + metadata.identity.userId !== ctx.userId || + metadata.identity.orgId !== input.kilocodeOrganizationId || + metadata.identity.sessionId !== input.sourceCloudAgentSessionId || + metadata.auth.kiloSessionId !== input.sourceKiloSessionId || + metadata.identity.createdOnPlatform !== 'cloud-agent-web' || + metadata.identity.botId !== undefined || + metadata.devcontainer !== undefined || + workspace.devcontainerRequested === true || + workspace.worktreeId !== worktreeId || + workspace.workspacePath !== + getWorktreeWorkspacePath(input.kilocodeOrganizationId, ctx.userId, worktreeId) || + metadata.finalization?.autoCommit !== false || + (ownership.gitUrl !== null && + normalizeGitUrl(ownership.gitUrl) !== canonicalRepositoryUrl(repository)) + ) { + throw sourceRejected(); + } + + return { + ownership, + metadata, + workspace: { + ...workspace, + sandboxId: workspace.sandboxId, + sandboxProvider: workspace.sandboxProvider, + }, + repository, + worktreeId, + }; +} + +async function worktreeIntentFingerprint( + input: WorktreeInput, + worktreeId: CloudAgentWorktreeId +): Promise { + return sha256Hex( + JSON.stringify({ + sourceKiloSessionId: input.sourceKiloSessionId, + sourceCloudAgentSessionId: input.sourceCloudAgentSessionId, + organizationId: input.kilocodeOrganizationId ?? null, + worktreeId, + clientProvenance: input.clientProvenance, + }) + ); +} + +function readOperationProgress( + row: OperationLedgerRow, + source: WorktreeSource, + fingerprint: string +): OperationProgress | undefined { + if (row.canonical_result === null) return undefined; + const progress = operationProgressSchema.safeParse(row.canonical_result); + if ( + !progress.success || + progress.data.worktreeId !== source.worktreeId || + progress.data[SESSION_CREATE_INTENT_FINGERPRINT_KEY] !== fingerprint + ) { + throw operationConflict(); + } + return progress.data; +} + +function resultFromProgress(progress: OperationProgress, replayed = false): WorktreeResult { + const result = { + cloudAgentSessionId: progress.cloudAgentSessionId, + kiloSessionId: progress.kiloSessionId, + worktreeId: progress.worktreeId, + }; + return replayed ? { ...result, replayed: true } : result; +} + +function buildRegistrationInput( + source: WorktreeSource, + ctx: TRPCContext, + progress: OperationProgress +): Parameters['registerSession']>[0] { + const repository = { ...source.repository }; + if ('token' in repository) delete repository.token; + + const workspace = { ...source.workspace }; + delete workspace.providerRuntime; + + return { + identity: { ...source.metadata.identity, sessionId: progress.cloudAgentSessionId }, + auth: { kiloSessionId: progress.kiloSessionId, kilocodeToken: ctx.authToken }, + agent: source.metadata.agent, + repository, + workspace, + ...(source.metadata.profile ? { profile: source.metadata.profile } : {}), + finalization: { ...source.metadata.finalization, autoCommit: false }, + }; +} + +function assertRegisteredMetadata( + rawMetadata: unknown, + source: WorktreeSource, + progress: OperationProgress +): void { + const parsed = CurrentSessionMetadataSchema.safeParse(rawMetadata); + if (!parsed.success) throw operationConflict(); + + const metadata = parsed.data; + const workspace = metadata.workspace; + if ( + metadata.identity.sessionId !== progress.cloudAgentSessionId || + metadata.identity.userId !== source.metadata.identity.userId || + metadata.identity.orgId !== source.metadata.identity.orgId || + metadata.identity.createdOnPlatform !== source.metadata.identity.createdOnPlatform || + metadata.auth.kiloSessionId !== progress.kiloSessionId || + metadata.finalization?.autoCommit !== false || + !workspace || + workspace.worktreeId !== source.worktreeId || + workspace.workspacePath !== source.workspace.workspacePath || + workspace.sandboxId !== source.workspace.sandboxId || + workspace.sandboxProvider !== source.workspace.sandboxProvider || + workspace.branchName !== source.workspace.branchName || + JSON.stringify(workspace.sandboxRoute) !== JSON.stringify(source.workspace.sandboxRoute) || + !metadata.repository || + canonicalRepositoryUrl(metadata.repository) !== canonicalRepositoryUrl(source.repository) || + metadata.repository.upstreamBranch !== source.repository.upstreamBranch + ) { + throw operationConflict(); + } +} + +async function markPending(db: WorkerDb, rowId: string): Promise { + try { + await markReconcilePending(db, { rowId }); + } catch { + return; + } +} + +async function createOwnershipRow( + db: WorkerDb, + rowId: string, + source: WorktreeSource, + ctx: TRPCContext, + progress: OperationProgress +): Promise { + let response: unknown; + try { + response = await ctx.env.SESSION_INGEST.createSessionForCloudAgent({ + sessionId: progress.kiloSessionId, + kiloUserId: ctx.userId, + cloudAgentSessionId: progress.cloudAgentSessionId, + cloudAgentWorktreeId: source.worktreeId, + cloudAgentWorktreeLocation: { + sandboxId: source.workspace.sandboxId, + provider: source.workspace.sandboxProvider, + }, + organizationId: source.ownership.organizationId ?? undefined, + createdOnPlatform: source.ownership.createdOnPlatform, + title: `New session - ${new Date().toISOString()}`, + gitUrl: source.ownership.gitUrl ?? canonicalRepositoryUrl(source.repository), + }); + } catch (error) { + await markPending(db, rowId); + throw error; + } + + const parsed = ingestResultSchema.safeParse(response); + if (!parsed.success || parsed.data.status === 'in_progress') { + await markPending(db, rowId); + throw creationInProgress(); + } + if (parsed.data.status === 'rejected') { + await settleOperation(db, { rowId, status: 'failed', outcomeCode: 'ownership_row_rejected' }); + throw creationFailed(); + } + if ( + parsed.data.clone.sessionId !== progress.kiloSessionId || + parsed.data.clone.copiedItemCount !== 0 + ) { + await markPending(db, rowId); + throw creationInProgress(); + } +} + +async function completeOperation( + db: WorkerDb, + rowId: string, + progress: OperationProgress +): Promise { + try { + const settlement = await settleOperation(db, { + rowId, + status: 'completed', + outcomeCode: 'ok', + canonicalResult: resultFromProgress(progress), + }); + if (!settlement.settled) throw creationInProgress(); + } catch (error) { + await markPending(db, rowId); + throw error; + } +} + +async function registerWorktreeSession( + db: WorkerDb, + rowId: string, + source: WorktreeSource, + ctx: TRPCContext, + progress: OperationProgress +): Promise { + const registrationInput = buildRegistrationInput(source, ctx, progress); + let registrationAttempted = false; + let response: unknown; + + try { + response = await withDORetry( + () => getSandboxSessionStub(ctx.env, ctx.userId, progress.cloudAgentSessionId), + async stub => { + if (registrationAttempted) { + const existing = await stub.getMetadata(); + if (existing) { + assertRegisteredMetadata(existing, source, progress); + return { success: true }; + } + } + registrationAttempted = true; + return stub.registerSession(registrationInput); + }, + 'registerSession' + ); + } catch (error) { + await markPending(db, rowId); + throw error; + } + + const result = registrationResultSchema.safeParse(response); + if (!result.success) { + await markPending(db, rowId); + throw creationInProgress(); + } + if (!result.data.success) { + try { + await ctx.env.SESSION_INGEST.deleteSessionForCloudAgent({ + sessionId: progress.kiloSessionId, + kiloUserId: ctx.userId, + onlyIfEmpty: true, + }); + } catch (error) { + await markPending(db, rowId); + throw error; + } + await settleOperation(db, { + rowId, + status: 'failed', + outcomeCode: 'registration_rejected', + }); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'worktree_chat_registration_failed', + }); + } + + await completeOperation(db, rowId, progress); +} + +async function executeWorktreeCreate( + db: WorkerDb, + row: OperationLedgerRow, + source: WorktreeSource, + ctx: TRPCContext, + fingerprint: string +): Promise { + const progress = operationProgressSchema.parse({ + cloudAgentSessionId: generateSessionId('control'), + kiloSessionId: generateKiloSessionId(), + worktreeId: source.worktreeId, + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: fingerprint, + }); + const recorded = await recordOperationProgress(db, row.id, progress); + if (!recorded) throw creationInProgress(); + await createOwnershipRow(db, row.id, source, ctx, progress); + await registerWorktreeSession(db, row.id, source, ctx, progress); + return resultFromProgress(progress); +} + +async function reconcileWorktreeCreate( + db: WorkerDb, + row: OperationLedgerRow, + source: WorktreeSource, + ctx: TRPCContext, + progress: OperationProgress | undefined, + fingerprint: string +): Promise { + if (!progress) return executeWorktreeCreate(db, row, source, ctx, fingerprint); + + const existingMetadata = await withDORetry( + () => getSandboxSessionStub(ctx.env, ctx.userId, progress.cloudAgentSessionId), + stub => stub.getMetadata(), + 'getMetadata' + ); + if (existingMetadata) assertRegisteredMetadata(existingMetadata, source, progress); + + const ownership = await findOwnershipRow( + db, + ctx.userId, + progress.kiloSessionId, + progress.cloudAgentSessionId + ); + if (ownership) { + if ( + ownership.organizationId !== source.ownership.organizationId || + ownership.worktreeId !== source.worktreeId || + ownership.parentSessionId !== null || + ownership.cloudAgentSessionScopeId !== progress.cloudAgentSessionId + ) { + throw operationConflict(); + } + } else { + await createOwnershipRow(db, row.id, source, ctx, progress); + } + + if (existingMetadata) { + await completeOperation(db, row.id, progress); + } else { + await registerWorktreeSession(db, row.id, source, ctx, progress); + } + + return resultFromProgress(progress, true); +} + +const createWorktreeChatHandler = internalApiProtectedProcedure + .input(CreateWorktreeChatInput) + .output(CreateWorktreeChatOutput) + .mutation(async ({ input, ctx }) => { + const db = getPgDb(ctx.env); + const source = await loadWorktreeSource(db, ctx, input); + const fingerprint = await worktreeIntentFingerprint(input, source.worktreeId); + const admission = await admitOperation(db, { + userId: ctx.userId, + orgId: input.kilocodeOrganizationId, + domain: 'session', + intent: 'create_worktree_chat', + operationKey: input.operationKey, + resourceKey: source.worktreeId, + taxonomy: 'safe-retry', + leaseSeconds: WORKTREE_CREATE_LEDGER_LEASE_SECONDS, + }); + + assertSessionOperationIdentity(admission.row, { + userId: ctx.userId, + intent: 'create_worktree_chat', + organizationId: input.kilocodeOrganizationId, + resourceKey: source.worktreeId, + }); + const progress = readOperationProgress(admission.row, source, fingerprint); + + switch (admission.admission) { + case 'admitted': + if (progress) throw operationConflict(); + return executeWorktreeCreate(db, admission.row, source, ctx, fingerprint); + case 'duplicate_settled': + if (admission.row.status !== 'completed' || !progress) throw creationFailed(); + return resultFromProgress(progress, true); + case 'duplicate_in_flight': + case 'duplicate_reconcile_in_progress': + throw creationInProgress(); + case 'takeover': + case 'duplicate_reconcile_pending': + return reconcileWorktreeCreate(db, admission.row, source, ctx, progress, fingerprint); + } + }); + +export function createSessionWorktreeHandlers(): { + createWorktreeChat: typeof createWorktreeChatHandler; +} { + return { createWorktreeChat: createWorktreeChatHandler }; +} diff --git a/services/cloud-agent-next/src/router/handlers/worktree-deletion.test.ts b/services/cloud-agent-next/src/router/handlers/worktree-deletion.test.ts new file mode 100644 index 0000000000..38a3d9c4b7 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/worktree-deletion.test.ts @@ -0,0 +1,286 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import type { SQL } from 'drizzle-orm'; +import type { + CloudAgentWorktreeDeletionState, + CloudAgentWorktreeDeletionParams, + CloudAgentChildSessionLineage, + RecordCloudAgentWorktreeCleanupParams, +} from '@kilocode/session-ingest-contracts'; +import type { TRPCContext } from '../../types'; +import { router } from '../auth'; +import { deleteWorktree } from './worktree-deletion'; + +const mocks = vi.hoisted(() => ({ getSession: vi.fn(), getControl: vi.fn(), getDb: vi.fn() })); +vi.mock('../../sandbox-session/session-stub', () => ({ getSandboxSessionStub: mocks.getSession })); +vi.mock('../../sandbox-control/stub', () => ({ getSandboxControlStub: mocks.getControl })); +vi.mock('../../db/pg', () => ({ getPgDb: mocks.getDb })); + +const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; +const organizationId = '22222222-2222-4222-8222-222222222222'; +const userId = 'oauth/google:worktree-owner'; +const location = { sandboxId: 'usr-original-route', provider: 'cloudflare' as const }; +const kiloId = (index: number) => `ses_${String(index).padStart(26, '0')}`; +const workspaceId = (index: number): `workspace_${string}` => + `workspace_${String(index).padStart(8, '0')}-0000-4000-8000-000000000000`; + +function fixture(rootCount = 1, childCount = 0) { + let state: CloudAgentWorktreeDeletionState = { + completed: false, + manifest: { + version: 1, + sessions: [ + ...Array.from({ length: rootCount }, (_, index) => ({ + sessionId: kiloId(index), + cloudAgentSessionId: workspaceId(index), + })), + ...Array.from({ length: childCount }, (_, index) => ({ + sessionId: kiloId(rootCount + index), + cloudAgentSessionId: null, + })), + ], + }, + runtimeLocations: [], + }; + const finished = new Set(); + const readChildren = vi.fn<() => Promise>(async () => []); + const beginSession = vi.fn(async (sessionId: string) => + finished.has(sessionId) ? null : location + ); + const finishSession = vi.fn(async (sessionId: string) => { + finished.add(sessionId); + }); + mocks.getSession.mockImplementation((_env, ownerId: string, id: string) => { + expect(ownerId).toBe(userId); + return { + beginWorktreeDeletion: () => beginSession(id), + getWorktreeChildSessions: readChildren, + finishWorktreeDeletion: () => finishSession(id), + }; + }); + const cleanup = vi.fn(async (input: { sessionIds: string[] }) => ({ + deleted: true, + sessionIds: input.sessionIds, + })); + mocks.getControl.mockReturnValue({ deleteWorktreeResources: cleanup }); + const begin = vi.fn(async (_params: CloudAgentWorktreeDeletionParams) => structuredClone(state)); + const record = vi.fn(async (input: RecordCloudAgentWorktreeCleanupParams) => { + state.runtimeLocations = input.runtimeLocations ?? state.runtimeLocations; + for (const sessionId of [ + ...(input.sessionIds ?? []), + ...(input.childSessions ?? []).map(child => child.sessionId), + ]) { + if (!state.manifest.sessions.some(session => session.sessionId === sessionId)) { + state.manifest.sessions.push({ sessionId, cloudAgentSessionId: null }); + } + } + return structuredClone(state); + }); + const complete = vi.fn(async () => { + state = { ...state, completed: true }; + return { + success: true, + deletedSessionIds: state.manifest.sessions.map(session => session.sessionId), + }; + }); + let membershipCondition: SQL | undefined; + let membershipJoin: SQL | undefined; + const membership = vi.fn(async () => [{ id: 'membership' }]); + const query = { + from: () => query, + innerJoin: (_table: unknown, condition: SQL) => { + membershipJoin = condition; + return query; + }, + where: (condition: SQL) => { + membershipCondition = condition; + return query; + }, + limit: membership, + }; + mocks.getDb.mockReturnValue({ select: () => query }); + const ctx: TRPCContext = { + userId, + authToken: 'test-auth', + request: new Request('https://worker.test/trpc/deleteWorktree', { + headers: { 'x-skip-balance-check': 'true' }, + }), + env: { + SESSION_INGEST: { + beginCloudAgentWorktreeDeletion: begin, + recordCloudAgentWorktreeCleanup: record, + completeCloudAgentWorktreeDeletion: complete, + }, + } as never, + }; + return { + caller: router({ deleteWorktree }).createCaller(ctx), + ctx, + begin, + record, + complete, + cleanup, + beginSession, + readChildren, + finishSession, + membership, + getState: () => state, + membershipSql: () => + membershipCondition ? new PgDialect().sqlToQuery(membershipCondition) : undefined, + membershipJoinSql: () => + membershipJoin ? new PgDialect().sqlToQuery(membershipJoin).sql : undefined, + }; +} + +beforeEach(() => vi.resetAllMocks()); + +describe('deleteWorktree authorization and completion', () => { + it('journals retained child lineage before cold runtime cleanup and keeps it on retry', async () => { + const f = fixture(); + f.readChildren.mockResolvedValue([{ sessionId: kiloId(1), parentSessionId: kiloId(0) }]); + f.cleanup.mockRejectedValueOnce(new Error('provider unavailable')); + await expect(f.caller.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + cause: { error: 'WORKTREE_DELETION_PENDING' }, + }); + expect(f.record.mock.calls[0][0].childSessions).toEqual([ + { sessionId: kiloId(1), parentSessionId: kiloId(0), cloudAgentSessionId: workspaceId(0) }, + ]); + expect(f.getState().manifest.sessions.map(session => session.sessionId)).toEqual([ + kiloId(0), + kiloId(1), + ]); + f.readChildren.mockResolvedValue([]); + await expect(f.caller.deleteWorktree({ worktreeId })).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1)], + }); + }); + + it('deletes and retries an ownership-only root using the canonical allocation returned by ingest', async () => { + const f = fixture(); + f.getState().runtimeLocations = [location]; + f.beginSession.mockResolvedValue(null); + f.cleanup.mockRejectedValueOnce(new Error('provider temporarily unavailable')); + await expect(f.caller.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + cause: { error: 'WORKTREE_DELETION_PENDING', retryable: true }, + }); + expect(f.complete).not.toHaveBeenCalled(); + expect(f.getState().runtimeLocations).toEqual([location]); + await expect(f.caller.deleteWorktree({ worktreeId })).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0)], + }); + expect(mocks.getControl).toHaveBeenCalledWith(f.ctx.env, location.sandboxId); + }); + + it('isolates unavailable allocation history instead of repeatedly waiting for registration that is fenced', async () => { + const f = fixture(); + f.beginSession.mockResolvedValue(null); + await expect(f.caller.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + cause: { error: 'WORKTREE_RUNTIME_HISTORY_UNAVAILABLE', retryable: false }, + }); + expect(f.cleanup).not.toHaveBeenCalled(); + expect(f.complete).not.toHaveBeenCalled(); + expect(f.getState().manifest.sessions).toHaveLength(1); + }); + + it('uses only the authenticated text owner and interprets omitted organization as personal', async () => { + const f = fixture(); + await expect(f.caller.deleteWorktree({ worktreeId })).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0)], + }); + expect(f.begin).toHaveBeenCalledWith({ worktreeId, kiloUserId: userId }); + expect(mocks.getDb).not.toHaveBeenCalled(); + }); + + it('rejects an unauthenticated request and a client-supplied owner', async () => { + const f = fixture(); + const anonymous = router({ deleteWorktree }).createCaller({ ...f.ctx, userId: '' }); + await expect(anonymous.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + await expect( + f.caller.deleteWorktree({ worktreeId, kiloUserId: 'attacker' } as never) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + expect(f.begin).not.toHaveBeenCalled(); + }); + + it('denies a wrong owner or exact-scope mismatch without touching runtime resources', async () => { + const f = fixture(); + f.begin.mockRejectedValue(new Error('worktree_access_denied')); + await expect(f.caller.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(f.cleanup).not.toHaveBeenCalled(); + expect(f.beginSession).not.toHaveBeenCalled(); + }); + + it('requires current non-deleted organization membership even when balance checks are skipped', async () => { + const f = fixture(); + f.membership.mockResolvedValue([]); + await expect( + f.caller.deleteWorktree({ worktreeId, kilocodeOrganizationId: organizationId }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(f.begin).not.toHaveBeenCalled(); + expect(f.membershipSql()?.params).toEqual(expect.arrayContaining([userId, organizationId])); + expect(f.membershipJoinSql()).toContain('"deleted_at" is null'); + }); + + it('cleans every root, including unopened roots beyond 200, and returns all descendants', async () => { + const f = fixture(205, 15); + const result = await f.caller.deleteWorktree({ worktreeId }); + expect(result.deletedSessionIds).toHaveLength(220); + expect(new Set(result.deletedSessionIds).size).toBe(220); + expect(f.beginSession).toHaveBeenCalledTimes(205); + expect(f.finishSession).toHaveBeenCalledTimes(205); + expect(f.cleanup.mock.calls[0][0].sessionIds).toHaveLength(220); + expect(f.complete.mock.invocationCallOrder[0]).toBeGreaterThan( + f.finishSession.mock.invocationCallOrder.at(-1) ?? 0 + ); + }); + + it('keeps discovery and reports a retryable error after partial runtime failure', async () => { + const f = fixture(2, 2); + f.cleanup.mockRejectedValueOnce(new Error('wrapper disconnected')); + await expect(f.caller.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + cause: { retryable: true, error: 'WORKTREE_DELETION_PENDING' }, + }); + expect(f.finishSession).not.toHaveBeenCalled(); + expect(f.complete).not.toHaveBeenCalled(); + expect(f.getState().runtimeLocations).toEqual([location]); + await expect(f.caller.deleteWorktree({ worktreeId })).resolves.toMatchObject({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1), kiloId(2), kiloId(3)], + }); + }); + + it('can retry ingest cleanup after session metadata has already been erased', async () => { + const f = fixture(1, 1); + f.complete.mockRejectedValueOnce(new Error('R2 unavailable')); + await expect(f.caller.deleteWorktree({ worktreeId })).rejects.toMatchObject({ + cause: { retryable: true }, + }); + await expect(f.caller.deleteWorktree({ worktreeId })).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1)], + }); + expect(f.getState().completed).toBe(true); + }); + + it('authorizes completed tombstones on retry without requiring surviving session rows', async () => { + const f = fixture(); + await f.caller.deleteWorktree({ worktreeId, kilocodeOrganizationId: organizationId }); + const cleanupCalls = f.cleanup.mock.calls.length; + await expect( + f.caller.deleteWorktree({ worktreeId, kilocodeOrganizationId: organizationId }) + ).resolves.toMatchObject({ success: true }); + expect(f.cleanup).toHaveBeenCalledTimes(cleanupCalls); + f.membership.mockResolvedValue([]); + await expect( + f.caller.deleteWorktree({ worktreeId, kilocodeOrganizationId: organizationId }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); +}); diff --git a/services/cloud-agent-next/src/router/handlers/worktree-deletion.ts b/services/cloud-agent-next/src/router/handlers/worktree-deletion.ts new file mode 100644 index 0000000000..94454e9fdf --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/worktree-deletion.ts @@ -0,0 +1,174 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; +import { + cloudAgentWorktreeIdSchema, + cloudAgentWorktreeDeletionStateSchema, + cloudAgentWorktreeLocationSchema, + cloudAgentChildSessionLineageSchema, + sessionIdSchema, + WORKTREE_RUNTIME_HISTORY_UNAVAILABLE, + type CloudAgentWorktreeDeletionParams, + type RecordCloudAgentWorktreeCleanupParams, +} from '@kilocode/session-ingest-contracts'; +import { hasOrganizationAccess } from '@kilocode/worker-utils'; +import { getPgDb } from '../../db/pg'; +import { getSandboxSessionStub } from '../../sandbox-session/session-stub'; +import { getSandboxControlStub } from '../../sandbox-control/stub'; +import { worktreeDeleteResultSchema } from '../../shared/sandbox-control-protocol'; +import { withDORetry } from '../../utils/do-retry'; +import { getWorktreeWorkspacePath } from '../../workspace'; +import type { TRPCContext } from '../../types'; +import { protectedProcedure } from '../auth'; + +export const DeleteWorktreeInput = z + .object({ + worktreeId: cloudAgentWorktreeIdSchema, + kilocodeOrganizationId: z.uuid().optional(), + }) + .strict(); + +export const DeleteWorktreeOutput = z + .object({ + success: z.literal(true), + deletedSessionIds: z.array(sessionIdSchema), + }) + .strict(); + +export async function deleteWorktreeResources( + input: z.infer, + ctx: TRPCContext +): Promise> { + if (ctx.botId !== undefined) + throw new TRPCError({ code: 'FORBIDDEN', message: 'Worktree access denied' }); + const params: CloudAgentWorktreeDeletionParams = { + worktreeId: input.worktreeId, + kiloUserId: ctx.userId, + ...(input.kilocodeOrganizationId ? { organizationId: input.kilocodeOrganizationId } : {}), + }; + try { + if ( + params.organizationId && + !(await hasOrganizationAccess(getPgDb(ctx.env), { + kiloUserId: ctx.userId, + organizationId: params.organizationId, + })) + ) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Worktree access denied' }); + } + let state = cloudAgentWorktreeDeletionStateSchema.parse( + await ctx.env.SESSION_INGEST.beginCloudAgentWorktreeDeletion(params) + ); + if (state.completed) + return { + success: true, + deletedSessionIds: state.manifest.sessions.map(session => session.sessionId), + }; + const runtimeLocations = [...state.runtimeLocations]; + const directory = getWorktreeWorkspacePath( + params.organizationId, + ctx.userId, + params.worktreeId + ); + const childSessions: NonNullable = []; + for (const session of state.manifest.sessions) { + if (!session.cloudAgentSessionId) continue; + const cloudAgentSessionId = session.cloudAgentSessionId; + const rawLocation = await withDORetry( + () => getSandboxSessionStub(ctx.env, ctx.userId, cloudAgentSessionId), + stub => + stub.beginWorktreeDeletion({ + worktreeId: params.worktreeId, + ownerId: ctx.userId, + kiloSessionId: session.sessionId, + ...(params.organizationId ? { organizationId: params.organizationId } : {}), + }), + 'beginWorktreeDeletion' + ); + const children = z.array(cloudAgentChildSessionLineageSchema).parse( + await withDORetry( + () => getSandboxSessionStub(ctx.env, ctx.userId, cloudAgentSessionId), + stub => stub.getWorktreeChildSessions(params.worktreeId), + 'getWorktreeChildSessions' + ) + ); + childSessions.push(...children.map(child => ({ ...child, cloudAgentSessionId }))); + const location = cloudAgentWorktreeLocationSchema.nullable().parse(rawLocation); + if ( + location && + !runtimeLocations.some( + item => item.sandboxId === location.sandboxId && item.provider === location.provider + ) + ) { + runtimeLocations.push(location); + } + } + if (runtimeLocations.length === 0) throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + state = cloudAgentWorktreeDeletionStateSchema.parse( + await ctx.env.SESSION_INGEST.recordCloudAgentWorktreeCleanup({ + ...params, + runtimeLocations, + directory, + ...(childSessions.length > 0 ? { childSessions } : {}), + }) + ); + for (const location of state.runtimeLocations) { + const result = worktreeDeleteResultSchema.parse( + await withDORetry( + () => getSandboxControlStub(ctx.env, location.sandboxId), + stub => + stub.deleteWorktreeResources({ + ...params, + location, + sessionIds: state.manifest.sessions.map(session => session.sessionId), + }), + 'deleteWorktreeResources' + ) + ); + state = cloudAgentWorktreeDeletionStateSchema.parse( + await ctx.env.SESSION_INGEST.recordCloudAgentWorktreeCleanup({ + ...params, + directory, + sessionIds: result.sessionIds, + }) + ); + } + for (const session of state.manifest.sessions) { + if (!session.cloudAgentSessionId) continue; + const cloudAgentSessionId = session.cloudAgentSessionId; + await withDORetry( + () => getSandboxSessionStub(ctx.env, ctx.userId, cloudAgentSessionId), + stub => stub.finishWorktreeDeletion(params.worktreeId), + 'finishWorktreeDeletion' + ); + } + return DeleteWorktreeOutput.parse( + await ctx.env.SESSION_INGEST.completeCloudAgentWorktreeDeletion(params) + ); + } catch (error) { + if (error instanceof TRPCError) throw error; + if (error instanceof Error && error.message.includes(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE)) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Worktree runtime history is unavailable; recovery is required', + cause: { error: 'WORKTREE_RUNTIME_HISTORY_UNAVAILABLE', retryable: false }, + }); + } + if (error instanceof Error && error.message.includes('worktree_access_denied')) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Worktree access denied' }); + } + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Worktree deletion is incomplete; retry the same worktree', + cause: { + error: 'WORKTREE_DELETION_PENDING', + message: 'Worktree deletion is incomplete; retry the same worktree', + retryable: true, + }, + }); + } +} + +export const deleteWorktree = protectedProcedure + .input(DeleteWorktreeInput) + .output(DeleteWorktreeOutput) + .mutation(({ input, ctx }) => deleteWorktreeResources(input, ctx)); diff --git a/services/cloud-agent-next/src/router/schemas.test.ts b/services/cloud-agent-next/src/router/schemas.test.ts index 5aed6d342b..462cae2fe0 100644 --- a/services/cloud-agent-next/src/router/schemas.test.ts +++ b/services/cloud-agent-next/src/router/schemas.test.ts @@ -102,6 +102,15 @@ describe('grouped unified session input contracts', () => { expect(result.success).toBe(false); }); + it('rejects client provenance on the public grouped start endpoint', () => { + expect( + StartSessionInput.safeParse({ + ...baseStartInput, + options: { createdOnPlatform: 'cloud-agent-web', clientProvenance: 'browser' }, + }).success + ).toBe(false); + }); + it('accepts document attachments on grouped start and send messages', () => { expect( StartSessionInput.parse({ @@ -267,6 +276,33 @@ describe('legacy live attachment input compatibility', () => { ).toBe(false); }); + it.each(['browser', 'mobile'] as const)( + 'retains trusted %s provenance on prepareSession input', + clientProvenance => { + const result = PrepareSessionInput.parse({ + prompt: 'Create a session', + mode: 'code', + model: 'claude-sonnet-4-5-20250929', + githubRepo: 'acme/repo', + clientProvenance, + }); + + expect(result.clientProvenance).toBe(clientProvenance); + } + ); + + it('rejects unrecognized prepareSession client provenance', () => { + expect( + PrepareSessionInput.safeParse({ + prompt: 'Create a session', + mode: 'code', + model: 'claude-sonnet-4-5-20250929', + githubRepo: 'acme/repo', + clientProvenance: 'automation', + }).success + ).toBe(false); + }); + it('accepts shell-safe Git branch punctuation used by current-branch reviews', () => { const branch = 'feature/alex+metadata@v2,fix=1#manual'; diff --git a/services/cloud-agent-next/src/router/schemas.ts b/services/cloud-agent-next/src/router/schemas.ts index c2a786a809..dd8afe65a4 100644 --- a/services/cloud-agent-next/src/router/schemas.ts +++ b/services/cloud-agent-next/src/router/schemas.ts @@ -552,6 +552,7 @@ const PrepareSessionSharedFields = { .max(100) .optional() .describe('Platform that created this session (e.g. slack, app-builder)'), + clientProvenance: z.enum(['browser', 'mobile']).optional(), shallow: z .boolean() .optional() diff --git a/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts b/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts index 2aee162f00..3fc2d2bf6a 100644 --- a/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts @@ -5,6 +5,7 @@ import { parseSandboxBillingInput, type MeteredSandboxInstance, } from '../container-usage-context.js'; +import { cleanWorktreeRuntime } from './worktree-deletion.js'; import { createCloudflareProviderAdapter, decodeCloudflareProviderRef, @@ -12,7 +13,13 @@ import { type CloudflareSandboxHandle, } from './cloudflare-provider.js'; import { DEADLINE_MS } from './deadlines.js'; -import { WORKTREE_CREDENTIAL_CONTAINMENT } from './physical-lifecycle.js'; +import { + beginStop, + confirmStopped, + recordStopAttempt, + WORKTREE_CREDENTIAL_CONTAINMENT, + type PhysicalRecord, +} from './physical-lifecycle.js'; import { deriveSandboxAllocationId } from '../sandbox-id.js'; const PROVIDER_REF = encodeCloudflareProviderRef({ @@ -54,6 +61,90 @@ const billing = parseSandboxBillingInput({ afterEach(() => vi.useRealTimers()); describe('cloudflare provider adapter', () => { + it.each([true, false])( + 'cleans the exact physical allocation only after confirmed stop: %s', + async confirmed => { + const providerRef = encodeCloudflareProviderRef({ + sandboxId: intent.allocationName, + containment: true, + instanceId: intent.intentId, + }); + let physical: PhysicalRecord = { + state: 'running', + providerRef, + createIntent: intent, + stopTombstone: null, + resumable: false, + containment: { ...WORKTREE_CREDENTIAL_CONTAINMENT, providerRef }, + }; + const values = new Map([['physical_record', physical]]); + const storage = { + get: async (key: string) => values.get(key), + put: async (key: string, value: unknown) => { + values.set(key, value); + }, + }; + const destroy = vi.fn(async () => { + if (!confirmed) throw new Error('Native stop unavailable'); + }); + const getSandbox = vi.fn(() => fakeSandbox()); + const provider = createCloudflareProviderAdapter({ + sandboxId: intent.allocationName, + destroy, + getSandbox, + }); + const create = vi.spyOn(provider, 'create'); + const launch = vi.spyOn(provider, 'launch'); + const stopRuntime = vi.fn(async () => { + physical = recordStopAttempt(beginStop(physical, 'worktree_deleted', 2_000)); + await storage.put('physical_record', physical); + const result = await provider.stop(physical.providerRef, physical.createIntent); + if (result === 'terminal') physical = confirmStopped(physical); + await storage.put('physical_record', physical); + return physical; + }); + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const cleanup = cleanWorktreeRuntime({ + request: { + worktreeId, + kiloUserId: 'oauth/test', + location: { sandboxId: logicalId, provider: 'cloudflare' }, + sessionIds: ['ses_00000000000000000000000001'], + }, + directory: `/workspace/owner/worktrees/${worktreeId}`, + storage: storage as never, + getProvider: async () => provider, + stopRuntime, + exclusive: true, + hasConnection: () => false, + sendRequest: async () => { + throw new Error('No wrapper call expected'); + }, + }); + if (confirmed) { + await expect(cleanup).resolves.toMatchObject({ destroyed: true, resourcesCleaned: true }); + expect(physical.state).toBe('stopped'); + } else { + await expect(cleanup).rejects.toThrow('Worktree provider stop is unconfirmed'); + expect(physical).toMatchObject({ + state: 'stopping', + providerRef, + stopTombstone: { attempts: 1, createdAt: 2_000 }, + }); + expect(values.get(`worktree_deletion/${worktreeId}`)).toMatchObject({ + destroyed: false, + resourcesCleaned: false, + completed: false, + }); + } + expect(stopRuntime).toHaveBeenCalledOnce(); + expect(destroy).toHaveBeenCalledExactlyOnceWith(intent.allocationName, { containment: true }); + expect(getSandbox).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect(launch).not.toHaveBeenCalled(); + } + ); + it('returns the physical identity without waking, then launches against that identity', async () => { const startProcess = vi.fn().mockResolvedValue({ id: 'proc_1' }); const setOutboundHandler = vi.fn().mockResolvedValue(undefined); diff --git a/services/cloud-agent-next/src/sandbox-control/durable-state.ts b/services/cloud-agent-next/src/sandbox-control/durable-state.ts index c1a26777e7..033cff205d 100644 --- a/services/cloud-agent-next/src/sandbox-control/durable-state.ts +++ b/services/cloud-agent-next/src/sandbox-control/durable-state.ts @@ -8,7 +8,7 @@ import { } from './session-credentials.js'; import { emptyTransitionLog, type TransitionRow } from './transition-log.js'; -const PHYSICAL_KEY = 'physical_record'; +export const PHYSICAL_KEY = 'physical_record'; const ROUTES_KEY = 'session_routes'; const DEADLINES_KEY = 'deadlines'; const LOG_KEY = 'transition_log'; diff --git a/services/cloud-agent-next/src/sandbox-control/frames.ts b/services/cloud-agent-next/src/sandbox-control/frames.ts index 15e3b0faa2..94b766ebc9 100644 --- a/services/cloud-agent-next/src/sandbox-control/frames.ts +++ b/services/cloud-agent-next/src/sandbox-control/frames.ts @@ -23,6 +23,7 @@ import { sessionTerminalConnectPayloadSchema, sessionTerminalCreatePayloadSchema, sessionTerminalResizePayloadSchema, + worktreeDeletePayloadSchema, type ControlError, type ControlErrorCode, type ControlEvent, @@ -40,6 +41,8 @@ const REQUEST_PAYLOAD_SCHEMAS: Record = { 'sandbox.hello': sandboxHelloPayloadSchema, 'sandbox.status': sandboxStatusPayloadSchema, 'sandbox.shutdown': sandboxShutdownPayloadSchema, + 'worktree.prepareDeletion': worktreeDeletePayloadSchema, + 'worktree.delete': worktreeDeletePayloadSchema, 'session.attach': sessionAttachPayloadSchema, 'session.prompt': sessionPromptPayloadSchema, 'session.permission.resolve': sessionPermissionResolvePayloadSchema, diff --git a/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts b/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts index 7d0daaf3d2..da02927cf0 100644 --- a/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CloudAgentWorktreeId } from '@kilocode/session-ingest-contracts'; import { logger } from '../logger.js'; import type { SessionMetadata } from '../persistence/session-metadata.js'; import type { Env, GitTokenService } from '../types.js'; @@ -144,7 +145,7 @@ function environment(broker?: GitTokenService): CredentialEnv { function metadata( overrides: Partial = {}, - scopeId: string | null = 'worktree-a' + scopeId: CloudAgentWorktreeId | null = 'worktree_11111111-1111-4111-8111-111111111111' ): SessionMetadata { return { metadataSchemaVersion: 2, @@ -211,7 +212,10 @@ function resolve( }); } -function vercelMetadata(overrides: Partial = {}, scopeId = 'worktree-a') { +function vercelMetadata( + overrides: Partial = {}, + scopeId: CloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111' +) { return metadata( { ...overrides, @@ -247,7 +251,7 @@ describe('trusted worktree credential preparation', () => { expect(grant.kilo.token).toBe(token); expect(payload.kilo).toEqual({ - scopeId: 'worktree-a', + scopeId: 'worktree_11111111-1111-4111-8111-111111111111', token: grant.kilo.alias, targets: grant.kilo.targets, }); @@ -509,7 +513,7 @@ describe('trusted worktree credential preparation', () => { auth: { kiloSessionId: SECOND_ROOT_ID, kilocodeToken: KILO_TOKEN }, identity: { ...metadata().identity, sessionId: SECOND_SESSION_ID }, }, - 'other-worktree' + 'worktree_22222222-2222-4222-8222-222222222222' ), ], [ @@ -1050,7 +1054,10 @@ describe('credential resolution boundaries', () => { const first = await prepare(env); const sibling = await prepare( env, - metadata({ workspace: { workspacePath: '/workspace/other' } }, 'worktree-other') + metadata( + { workspace: { workspacePath: '/workspace/other' } }, + 'worktree_22222222-2222-4222-8222-222222222222' + ) ); expect(sibling.grant.kilo.alias).not.toBe(first.grant.kilo.alias); for (const overrides of [ @@ -1095,7 +1102,10 @@ describe('credential resolution boundaries', () => { const second = await prepare(env, secondRoot(), first.grant); const sibling = await prepare( env, - metadata({ workspace: { workspacePath: '/workspace/other' } }, 'worktree-other') + metadata( + { workspace: { workspacePath: '/workspace/other' } }, + 'worktree_22222222-2222-4222-8222-222222222222' + ) ); const grants = removeSessionCredentialMembership( [second.grant, sibling.grant], @@ -1162,7 +1172,7 @@ describe('native Vercel worktree policies', () => { workspace: { workspacePath: '/workspace/sibling' }, repository: { type: 'github', repo: 'acme/another' }, }, - 'worktree-sibling' + 'worktree_33333333-3333-4333-8333-333333333333' ) ); const policy = buildControlNetworkPolicy([second.grant, sibling.grant]); diff --git a/services/cloud-agent-next/src/sandbox-control/session-routes.test.ts b/services/cloud-agent-next/src/sandbox-control/session-routes.test.ts index 7e7d5ef66d..679465cf35 100644 --- a/services/cloud-agent-next/src/sandbox-control/session-routes.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/session-routes.test.ts @@ -13,6 +13,8 @@ import { } from './session-routes.js'; const OWNER = 'owner_1'; +const WORKTREE_A = 'worktree_11111111-1111-4111-8111-111111111111'; +const WORKTREE_B = 'worktree_22222222-2222-4222-8222-222222222222'; const attachInput = { sessionId: 'ses_1', @@ -37,6 +39,21 @@ function sharedDirectoryTable() { return { table, first, second }; } +function groupedTable() { + const first = attachRoute(emptyRouteTable(), { ...attachInput, worktreeId: WORKTREE_A }, OWNER); + const second = attachRoute( + first.table, + { + ...attachInput, + sessionId: 'ses_2', + kiloSessionId: 'kilo_2', + worktreeId: WORKTREE_A, + }, + OWNER + ); + return { table: first.table, first: first.route, second: second.route }; +} + describe('session routes', () => { it('attaches a route and looks it up by session, directory, and kiloSessionId', () => { const { table, route, changed } = attachedTable(); @@ -182,6 +199,29 @@ describe('session routes', () => { expect([...table.values()]).toEqual([route]); }); + it('allows different worktrees in different directories', () => { + const { table } = attachRoute( + emptyRouteTable(), + { ...attachInput, worktreeId: WORKTREE_A }, + OWNER + ); + + const next = attachRoute( + table, + { + ...attachInput, + sessionId: 'ses_2', + kiloSessionId: 'kilo_2', + directory: '/workspace/b', + worktreeId: WORKTREE_B, + }, + OWNER + ); + + expect(next.changed).toBe(true); + expect(table.size).toBe(2); + }); + it('rejects a kiloSessionId already attached to another session', () => { const { table } = attachedTable(); expect(() => @@ -198,6 +238,18 @@ describe('session routes', () => { ).toThrow('Kilo session already attached'); }); + it('rejects a duplicate root even when its worktree and directory match', () => { + const { table } = attachRoute( + emptyRouteTable(), + { ...attachInput, worktreeId: WORKTREE_A }, + OWNER + ); + + expect(() => + attachRoute(table, { ...attachInput, sessionId: 'ses_2', worktreeId: WORKTREE_A }, OWNER) + ).toThrow('Kilo session already attached'); + }); + it('rejects the same sessionId with a different directory', () => { const { table } = attachedTable(); expect(() => @@ -270,6 +322,30 @@ describe('session routes', () => { expect(hasActiveWork(table)).toBe(false); }); + it('detaches and reattaches one root without disturbing its sibling', () => { + const { table, first, second } = groupedTable(); + + expect(detachRoute(table, second.sessionId)).toEqual({ table, existed: true }); + expect(getRouteByDirectory(table, first.directory)).toBe(first); + expect(getRouteByKiloSessionId(table, second.kiloSessionId)).toBeUndefined(); + + const reattached = attachRoute( + table, + { + sessionId: second.sessionId, + kiloSessionId: second.kiloSessionId, + directory: second.directory, + ownerId: OWNER, + worktreeId: WORKTREE_A, + }, + OWNER + ); + + expect(reattached.changed).toBe(true); + expect(getRouteBySessionId(table, first.sessionId)).toBe(first); + expect(getRouteByDirectory(table, first.directory)).toBeUndefined(); + }); + it('applies a repeated session state without marking changed', () => { const { table } = attachedTable(); const first = applyReportedSessionState( @@ -386,16 +462,19 @@ describe('resolveSessionEventRoute', () => { { kiloSessionId: 'kilo_2' }, { rootKiloSessionId: 'kilo_2' }, { kiloSessionId: 'kilo_child', rootKiloSessionId: 'kilo_2' }, - ])('prefers explicit identity %j over a different directory route', identity => { + ])('rejects explicit identity %j conflicting with a different attached directory', identity => { const { table } = attachedTable(); const { route } = attachRoute( table, { ...attachInput, sessionId: 'ses_2', kiloSessionId: 'kilo_2', directory: '/workspace/b' }, OWNER ); - expect(resolveSessionEventRoute(table, { directory: attachInput.directory, ...identity })).toBe( - route - ); + expect( + resolveSessionEventRoute(table, { directory: attachInput.directory, ...identity }) + ).toBeNull(); + expect( + resolveSessionEventRoute(table, { directory: '/workspace/unclaimed-child', ...identity }) + ).toBe(route); }); it('rejects conflicting explicit roots even when the directory has only one route', () => { @@ -474,6 +553,86 @@ describe('resolveSessionEventRoute', () => { ).toBe(route); }); + it('rejects ambiguous directory-only events from grouped siblings', () => { + const { table } = groupedTable(); + + expect(resolveSessionEventRoute(table, { directory: '/workspace/a' })).toBeNull(); + expect( + resolveSessionEventRoute(table, { + directory: '/workspace/a', + kiloSessionId: 'kilo_unknown_child', + }) + ).toBeNull(); + }); + + it('resolves the exact root before an ambiguous shared directory', () => { + const { table, second } = groupedTable(); + + expect( + resolveSessionEventRoute(table, { + directory: '/workspace/a', + kiloSessionId: 'kilo_child', + rootKiloSessionId: second.kiloSessionId, + }) + ).toBe(second); + }); + + it('resolves an exact root kiloSessionId when root lineage is absent', () => { + const { table, second } = groupedTable(); + + expect( + resolveSessionEventRoute(table, { + directory: '/workspace/a', + kiloSessionId: second.kiloSessionId, + }) + ).toBe(second); + }); + + it('rejects contradictory root and exact root-session identities', () => { + const { table, first, second } = groupedTable(); + + expect( + resolveSessionEventRoute(table, { + directory: '/workspace/a', + kiloSessionId: second.kiloSessionId, + rootKiloSessionId: first.kiloSessionId, + }) + ).toBeNull(); + }); + + it('rejects an unknown root even when the kiloSessionId identifies an attached root', () => { + const { table, first } = groupedTable(); + + expect( + resolveSessionEventRoute(table, { + directory: '/workspace/a', + kiloSessionId: first.kiloSessionId, + rootKiloSessionId: 'kilo_unknown', + }) + ).toBeNull(); + }); + + it('rejects exact identities contradicted by a different attached directory', () => { + const { table } = attachedTable(); + attachRoute( + table, + { + sessionId: 'ses_2', + kiloSessionId: 'kilo_2', + directory: '/workspace/b', + ownerId: OWNER, + }, + OWNER + ); + + expect( + resolveSessionEventRoute(table, { + directory: '/workspace/b', + rootKiloSessionId: 'kilo_1', + }) + ).toBeNull(); + }); + it('returns null for an unknown directory', () => { const { table } = attachedTable(); expect(resolveSessionEventRoute(table, { directory: '/workspace/missing' })).toBeNull(); diff --git a/services/cloud-agent-next/src/sandbox-control/session-routes.ts b/services/cloud-agent-next/src/sandbox-control/session-routes.ts index aa2bd63b57..0fccb531ff 100644 --- a/services/cloud-agent-next/src/sandbox-control/session-routes.ts +++ b/services/cloud-agent-next/src/sandbox-control/session-routes.ts @@ -131,12 +131,21 @@ export function resolveSessionEventRoute( const sessionRoute = identity.kiloSessionId ? getRouteByKiloSessionId(table, identity.kiloSessionId) : undefined; - if (identity.rootKiloSessionId !== undefined) { - const rootRoute = getRouteByKiloSessionId(table, identity.rootKiloSessionId); - if (!rootRoute || (sessionRoute && sessionRoute !== rootRoute)) return null; - return rootRoute; + const rootRoute = identity.rootKiloSessionId + ? getRouteByKiloSessionId(table, identity.rootKiloSessionId) + : undefined; + if (identity.rootKiloSessionId !== undefined && !rootRoute) return null; + if (rootRoute && sessionRoute && rootRoute !== sessionRoute) return null; + const exactRoute = rootRoute ?? sessionRoute; + if (exactRoute) { + for (const route of table.values()) { + if (route.directory === identity.directory && route.directory !== exactRoute.directory) { + return null; + } + } + return exactRoute; } - if (identity.kiloSessionId !== undefined) return sessionRoute ?? null; + if (identity.kiloSessionId !== undefined) return null; return getRouteByDirectory(table, identity.directory) ?? null; } diff --git a/services/cloud-agent-next/src/sandbox-control/vercel-provider.ts b/services/cloud-agent-next/src/sandbox-control/vercel-provider.ts index 6218c2ed1a..3dc7803ed2 100644 --- a/services/cloud-agent-next/src/sandbox-control/vercel-provider.ts +++ b/services/cloud-agent-next/src/sandbox-control/vercel-provider.ts @@ -34,6 +34,17 @@ const providerRefSchema = z export type VercelProviderRef = z.infer; +export const vercelProviderLocatorSchema = z + .object({ + teamId: z.string().min(1), + projectId: z.string().min(1), + snapshotId: z.string().min(1), + runtimeBuildId: z.string().min(1), + runtime: z.literal('node24'), + }) + .strict(); +export type VercelProviderLocator = z.infer; + export type VercelControlRestClient = { createSandbox: VercelSandboxRestClient['createSandbox']; inspectByName: VercelSandboxRestClient['inspectByName']; diff --git a/services/cloud-agent-next/src/sandbox-control/worktree-deletion.ts b/services/cloud-agent-next/src/sandbox-control/worktree-deletion.ts new file mode 100644 index 0000000000..a36ede425b --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-control/worktree-deletion.ts @@ -0,0 +1,165 @@ +import { z } from 'zod'; +import { withTimeout } from '@kilocode/worker-utils'; +import { + canDestroyCloudAgentWorktreeSandboxSchema, + cloudAgentWorktreeIdSchema, + sessionIdSchema, +} from '@kilocode/session-ingest-contracts'; +import type { ProviderAdapter } from './provider'; +import { loadPhysicalRecord, loadRouteTable } from './durable-state'; +import { sameAllocation, type PhysicalRecord } from './physical-lifecycle'; +import { DEADLINE_MS } from './deadlines'; +import type { SandboxControlOutboundRequest } from './socket'; +import { + worktreeDeleteResultSchema, + worktreePrepareDeletionResultSchema, + SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, + type ResponseFrame, +} from '../shared/sandbox-control-protocol'; + +export const WORKTREE_DELETION_PREFIX = 'worktree_deletion/'; +export const EXCLUSIVE_DELETION_KEY = 'exclusive_worktree_deletion'; +export const RUNTIME_DELETED_KEY = 'runtime_deleted'; + +export const sandboxWorktreeCleanupInputSchema = canDestroyCloudAgentWorktreeSandboxSchema + .extend({ + sessionIds: z.array(sessionIdSchema), + }) + .strict(); +export type SandboxWorktreeCleanupInput = z.infer; + +const journalSchema = z + .object({ + sessionIds: z.array(sessionIdSchema), + resourcesCleaned: z.boolean(), + destroyed: z.boolean(), + completed: z.boolean().default(false), + exclusiveTeardown: z.boolean().default(false), + }) + .strict(); +export type WorktreeRuntimeDeletionJournal = z.infer; + +export async function isUnallocatedControlRuntime( + storage: DurableObjectStorage, + hasConnection: () => boolean +): Promise { + const physical = await loadPhysicalRecord(storage); + return ( + physical.state === 'stopped' && + physical.providerRef === null && + physical.createIntent === null && + physical.stopTombstone === null && + (await loadRouteTable(storage)).size === 0 && + !hasConnection() + ); +} + +export async function loadWorktreeDeletionJournal( + storage: DurableObjectStorage, + worktreeId: string +) { + return journalSchema + .optional() + .parse(await storage.get(`${WORKTREE_DELETION_PREFIX}${worktreeId}`)); +} + +export async function loadWorktreeDeletionJournals(storage: DurableObjectStorage) { + const rows = await storage.list({ prefix: WORKTREE_DELETION_PREFIX }); + return new Map( + [...rows].map( + ([key, value]) => + [ + cloudAgentWorktreeIdSchema.parse(key.slice(WORKTREE_DELETION_PREFIX.length)), + journalSchema.parse(value), + ] as const + ) + ); +} + +export async function cleanWorktreeRuntime(input: { + request: SandboxWorktreeCleanupInput; + directory: string; + storage: DurableObjectStorage; + getProvider: () => Promise; + stopRuntime: () => Promise; + hasConnection: () => boolean; + sendRequest: (request: SandboxControlOutboundRequest) => Promise; + exclusive: boolean; +}): Promise { + const key = `${WORKTREE_DELETION_PREFIX}${input.request.worktreeId}`; + const previous = await loadWorktreeDeletionJournal(input.storage, input.request.worktreeId); + const sessionIds = [...new Set([...(previous?.sessionIds ?? []), ...input.request.sessionIds])]; + const scopedCleanupConfirmed = + previous?.resourcesCleaned === true && previous.sessionIds.length === sessionIds.length; + if (scopedCleanupConfirmed && (!input.exclusive || previous.destroyed)) return previous; + const journal: WorktreeRuntimeDeletionJournal = { + sessionIds, + resourcesCleaned: scopedCleanupConfirmed, + destroyed: previous?.destroyed ?? false, + completed: false, + exclusiveTeardown: input.exclusive || (previous?.exclusiveTeardown ?? false), + }; + await input.storage.put(key, journal); + if (await isUnallocatedControlRuntime(input.storage, input.hasConnection)) { + journal.resourcesCleaned = true; + journal.destroyed = input.exclusive; + await input.storage.put(key, journal); + return journal; + } + const physical = await loadPhysicalRecord(input.storage); + if (physical.state === 'stopped') { + journal.resourcesCleaned = true; + journal.destroyed = input.exclusive; + await input.storage.put(key, journal); + return journal; + } + const provider = await input.getProvider(); + if (input.exclusive) { + if ((await input.stopRuntime()).state !== 'stopped') { + throw new Error('Worktree provider stop is unconfirmed'); + } + journal.destroyed = true; + } else if (input.hasConnection()) { + const payload = { + worktreeId: input.request.worktreeId, + directory: input.directory, + sessionIds, + }; + const prepared = await input.sendRequest({ + operation: 'worktree.prepareDeletion', + payload, + timeoutMs: SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, + }); + if (!prepared.ok) throw new Error('Worktree runtime preparation is incomplete'); + const discovery = worktreePrepareDeletionResultSchema.parse(prepared.result); + journal.sessionIds = [...new Set([...sessionIds, ...discovery.sessionIds])]; + await input.storage.put(key, journal); + const deleted = await input.sendRequest({ + operation: 'worktree.delete', + payload: { ...payload, sessionIds: journal.sessionIds }, + timeoutMs: SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, + }); + if (!deleted.ok) throw new Error('Worktree runtime cleanup is incomplete'); + const confirmed = worktreeDeleteResultSchema.parse(deleted.result); + if (journal.sessionIds.some(id => !confirmed.sessionIds.includes(id))) { + throw new Error('Worktree runtime cleanup manifest is incomplete'); + } + journal.sessionIds = [...new Set([...journal.sessionIds, ...confirmed.sessionIds])]; + } else { + const observed = await withTimeout( + provider.observe(physical.providerRef, physical.createIntent), + DEADLINE_MS.stopAttempt, + 'Worktree provider observation timed out' + ); + if (observed.status !== 'terminal') { + throw new Error('Shared worktree runtime must reconnect before cleanup'); + } + } + const current = await loadPhysicalRecord(input.storage); + if (!input.exclusive && current.state !== 'stopped' && !sameAllocation(physical, current)) { + throw new Error('Worktree provider allocation changed during cleanup'); + } + journal.resourcesCleaned = true; + await input.storage.put(key, journal); + return journal; +} diff --git a/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts new file mode 100644 index 0000000000..173d7a04f2 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts @@ -0,0 +1,163 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Env } from '../types'; +import { resolveSandboxExclusivity, sessionRuntimeLocator } from './worktree-ownership'; +import { parseSessionMetadata } from '../persistence/session-metadata'; + +const mocks = vi.hoisted(() => ({ sandboxSession: vi.fn(), legacySession: vi.fn() })); +vi.mock('../sandbox-session/session-stub', () => ({ + getSandboxSessionStub: mocks.sandboxSession, + resolveSessionStub: mocks.legacySession, +})); + +const userId = 'oauth/github:runtime-owner'; +const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; +const otherWorktreeId = 'worktree_22222222-2222-4222-8222-222222222222'; +const location = { sandboxId: `usr-${'a'.repeat(48)}`, provider: 'cloudflare' as const }; +const otherLocation = { sandboxId: `usr-${'b'.repeat(48)}`, provider: 'cloudflare' as const }; +const kiloId = 'ses_00000000000000000000000001'; +const legacyId = 'agent_33333333-3333-4333-8333-333333333333'; +const controlId = 'workspace_22222222-2222-4222-8222-222222222222'; + +function fixture(controlPlane = false) { + const sourceWorktreeId = controlPlane ? otherWorktreeId : null; + const sourceSessionId = controlPlane ? controlId : legacyId; + const ownership = vi.fn<() => Promise>(async () => ({ + kind: 'unresolved', + owners: [ + { + worktreeId: sourceWorktreeId, + organizationId: null, + sessions: [{ sessionId: kiloId, cloudAgentSessionId: sourceSessionId }], + }, + ], + })); + const env: Env = { SESSION_INGEST: { canDestroyCloudAgentWorktreeSandbox: ownership } } as never; + const getRuntimeLocation = vi.fn(async () => ({ + cloudAgentSessionId: sourceSessionId, + kiloUserId: userId, + organizationId: null, + sessionId: kiloId, + worktreeId: sourceWorktreeId, + location: otherLocation, + })); + const metadata = { + metadataSchemaVersion: 2, + identity: { sessionId: sourceSessionId, userId }, + auth: { kiloSessionId: kiloId, kilocodeToken: 'private-test-token' }, + workspace: { sandboxId: otherLocation.sandboxId }, + lifecycle: { timestamp: 1, version: 1 }, + }; + const getStoredMetadata = vi.fn<() => Promise>(async () => metadata); + mocks.sandboxSession.mockReturnValue({ getRuntimeLocation }); + mocks.legacySession.mockReturnValue({ + getRuntimeLocation: async () => { + const stored = await getStoredMetadata(); + return stored ? sessionRuntimeLocator(parseSessionMetadata(stored)) : null; + }, + }); + const params = { worktreeId, kiloUserId: userId, location, releasedWorktreeIds: [] } as const; + return { + env, + params: { ...params, releasedWorktreeIds: [] }, + metadata, + getStoredMetadata, + getRuntimeLocation, + ownership, + }; +} + +beforeEach(() => vi.resetAllMocks()); + +describe('physical sandbox ownership resolution', () => { + it('resolves unrelated legacy roots from original metadata instead of treating the whole owner as sharing', async () => { + const f = fixture(); + await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(true); + expect(mocks.legacySession).toHaveBeenCalledWith(f.env, userId, legacyId); + }); + + it('preserves confirmed legacy sharing on the exact persisted route', async () => { + const f = fixture(); + f.getStoredMetadata.mockResolvedValue({ + ...f.metadata, + workspace: { sandboxId: location.sandboxId }, + }); + await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(false); + }); + + it('resolves migrated worktrees using a read-only locator without fencing unrelated sessions', async () => { + const f = fixture(true); + await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(true); + expect(f.getRuntimeLocation).toHaveBeenCalledTimes(1); + expect(mocks.legacySession).not.toHaveBeenCalled(); + }); + + it('uses canonical allocation history for a legacy ownership-only root with no runtime metadata', async () => { + const f = fixture(); + f.getStoredMetadata.mockResolvedValue(null); + f.ownership.mockResolvedValue({ + kind: 'unresolved', + owners: [ + { + worktreeId: null, + organizationId: null, + allocationLocation: otherLocation, + sessions: [{ sessionId: kiloId, cloudAgentSessionId: legacyId }], + }, + ], + }); + await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(true); + }); + + it('prefers current persisted legacy routing over the original allocation after a route change', async () => { + const f = fixture(); + f.getStoredMetadata.mockResolvedValue({ + ...f.metadata, + workspace: { sandboxId: location.sandboxId }, + }); + f.ownership.mockResolvedValue({ + kind: 'unresolved', + owners: [ + { + worktreeId: null, + organizationId: null, + allocationLocation: otherLocation, + sessions: [{ sessionId: kiloId, cloudAgentSessionId: legacyId }], + }, + ], + }); + await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(false); + }); + + it('does not report unknown history as confirmed sharing', async () => { + const f = fixture(); + f.getStoredMetadata.mockResolvedValue(null); + await expect(resolveSandboxExclusivity(f.env, f.params)).rejects.toThrow( + 'worktree_runtime_history_unavailable' + ); + }); + + it('rejects conflicting ownership metadata instead of inferring a route', async () => { + const f = fixture(true); + f.getRuntimeLocation.mockResolvedValue({ + ...(await f.getRuntimeLocation()), + kiloUserId: 'another-owner', + }); + await expect(resolveSandboxExclusivity(f.env, f.params)).rejects.toThrow( + 'worktree_runtime_history_unavailable' + ); + }); + + it('does not accept an older ambiguous boolean ownership response', async () => { + const f = fixture(); + f.ownership.mockResolvedValue(false); + await expect(resolveSandboxExclusivity(f.env, f.params)).rejects.toThrow(); + expect(f.getStoredMetadata).not.toHaveBeenCalled(); + }); + + it('does not include credentials or transcripts in read-only runtime locators', () => { + const f = fixture(); + const locator = sessionRuntimeLocator(parseSessionMetadata(f.metadata)); + expect(locator).toMatchObject({ kiloUserId: userId, location: otherLocation }); + expect(JSON.stringify(locator)).not.toContain('private-test-token'); + }); +}); diff --git a/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts new file mode 100644 index 0000000000..314fa417a2 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { + canDestroyCloudAgentWorktreeSandboxResultSchema, + cloudAgentWorktreeIdSchema, + cloudAgentWorktreeLocationSchema, + sessionIdSchema, + WORKTREE_RUNTIME_HISTORY_UNAVAILABLE, + type CanDestroyCloudAgentWorktreeSandboxParams, +} from '@kilocode/session-ingest-contracts'; +import { getSandboxProvider, type SessionMetadata } from '../persistence/session-metadata'; +import { getSandboxSessionStub, resolveSessionStub } from '../sandbox-session/session-stub'; +import { withDORetry } from '../utils/do-retry'; +import type { Env } from '../types'; + +export const sessionRuntimeLocatorSchema = z + .object({ + cloudAgentSessionId: z.string().min(1), + kiloUserId: z.string().min(1), + organizationId: z.uuid().nullable(), + sessionId: sessionIdSchema.nullable(), + worktreeId: cloudAgentWorktreeIdSchema.nullable(), + location: cloudAgentWorktreeLocationSchema, + }) + .strict(); +export type SessionRuntimeLocator = z.infer; + +export function sessionRuntimeLocator(metadata: SessionMetadata): SessionRuntimeLocator | null { + if (!metadata.workspace?.sandboxId) return null; + return sessionRuntimeLocatorSchema.parse({ + cloudAgentSessionId: metadata.identity.sessionId, + kiloUserId: metadata.identity.userId, + organizationId: metadata.identity.orgId ?? null, + sessionId: metadata.auth.kiloSessionId ?? null, + worktreeId: metadata.workspace.worktreeId ?? null, + location: { sandboxId: metadata.workspace.sandboxId, provider: getSandboxProvider(metadata) }, + }); +} + +export async function resolveSandboxExclusivity( + env: Env, + params: CanDestroyCloudAgentWorktreeSandboxParams +): Promise { + const result = canDestroyCloudAgentWorktreeSandboxResultSchema.parse( + await env.SESSION_INGEST.canDestroyCloudAgentWorktreeSandbox(params) + ); + if (result.kind === 'exclusive') return true; + if (result.kind === 'shared') return false; + let unavailable = false; + for (const owner of result.owners) { + let located = false; + for (const session of owner.sessions) { + const locator = sessionRuntimeLocatorSchema.nullable().parse( + await withDORetry( + () => + session.cloudAgentSessionId.startsWith('workspace_') + ? getSandboxSessionStub(env, params.kiloUserId, session.cloudAgentSessionId) + : resolveSessionStub(env, params.kiloUserId, session.cloudAgentSessionId), + stub => stub.getRuntimeLocation(), + 'getRuntimeLocation' + ) + ); + if (!locator) continue; + if ( + locator.cloudAgentSessionId !== session.cloudAgentSessionId || + locator.kiloUserId !== params.kiloUserId || + locator.organizationId !== owner.organizationId || + (session.sessionId !== null && + locator.sessionId !== null && + locator.sessionId !== session.sessionId) || + (owner.worktreeId !== null && locator.worktreeId !== owner.worktreeId) + ) { + throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + } + located = true; + if ( + locator.location.sandboxId === params.location.sandboxId && + locator.location.provider === params.location.provider + ) + return false; + } + if (!located && owner.allocationLocation) { + located = true; + if ( + owner.allocationLocation.sandboxId === params.location.sandboxId && + owner.allocationLocation.provider === params.location.provider + ) + return false; + } + if (!located) unavailable = true; + } + if (unavailable) throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + return true; +} diff --git a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts index 209757890f..f870a73dd0 100644 --- a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts +++ b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts @@ -2,12 +2,25 @@ import { DurableObject } from 'cloudflare:workers'; import { TRPCError } from '@trpc/server'; import { withTimeout } from '@kilocode/worker-utils'; import { z } from 'zod'; +import { + cloudAgentWorktreeIdSchema, + cloudAgentWorktreeLocationSchema, + type CloudAgentWorktreeId, + type CloudAgentWorktreeLocation, + type CloudAgentChildSessionLineage, +} from '@kilocode/session-ingest-contracts'; +import { + sessionRuntimeLocator, + type SessionRuntimeLocator, +} from '../sandbox-control/worktree-ownership.js'; +import { and, eq, inArray, isNotNull, sql } from 'drizzle-orm'; import { buildSandboxBillingInput } from '../container-usage-context.js'; import { isCloudAgentContainerBillingEnabled } from '../container-billing-rollout.js'; import { withDORetry } from '../utils/do-retry.js'; import { drizzle } from 'drizzle-orm/durable-sqlite'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; import migrations from '../../drizzle/migrations'; +import { events, commandQueue, executionLeases } from '../db/sqlite-schema.js'; import type { Env } from '../types.js'; import type { SessionId } from '../types/ids.js'; import { dispatchedKilocodeModelId } from '../persistence/model-utils.js'; @@ -24,6 +37,7 @@ import type { CallbackTarget } from '../callbacks/index.js'; import { renderExecutionTurnContent, type AcceptedExecutionTurn, + type ExecutionTurnSubmission, type LegacyRegisteredInitialAdmissionRequest, type SessionMessageAdmissionResult, type SubmittedSessionMessageRequest, @@ -40,8 +54,9 @@ import { type PendingInteractions, } from './sandbox-control-event.js'; import { buildSignedPromptAttachments } from '../execution/attachment-prompt-parts.js'; -import { getSessionWorkspacePath } from '../workspace.js'; +import { getSessionWorkspacePath, getWorktreeWorkspacePath } from '../workspace.js'; import { + childSessionLineage, controlEventToIngestItems, ingestKiloSessionId, publishControlPlaneSessionIngest, @@ -113,6 +128,13 @@ import { const METADATA_KEY = SANDBOX_SESSION_METADATA_KEY; const MESSAGES_KEY = 'session_messages'; +const DELETED_WORKTREE_KEY = 'deleted_worktree'; +const DELETION_COMPLETED_KEY = 'deletion_completed'; + +type SandboxControlEventInput = { + identity: { directory: string; kiloSessionId?: string; rootKiloSessionId?: string }; + payload: { type: string; properties: Record; timestamp?: string }; +}; const QUEUE_RETRY_MS = 5_000; const PENDING_RUNTIME_CLEANUP_KEY = 'pending_runtime_cleanup'; const PENDING_INTERACTIONS_KEY = 'session_pending_interactions'; @@ -127,12 +149,32 @@ const pendingRuntimeCleanupSchema = z.object({ type MessageRecord = SessionMessageRecord; type DispatchPhase = 'preparing' | 'attach' | 'prompt'; +type SandboxSessionRegistrationInput = { + identity: SessionMetadata['identity']; + auth: SessionMetadata['auth']; + agent: SessionMetadata['agent']; + repository?: SessionMetadata['repository']; + workspace?: SessionMetadata['workspace']; + callback?: SessionMetadata['callback']; + profile?: SessionMetadata['profile']; + finalization?: SessionMetadata['finalization']; + message?: { initialMessageId?: string; turn: ExecutionTurnSubmission }; +}; + +type SandboxSessionInitialAdmissionInput = Omit & { + message: { initialTurn: AcceptedExecutionTurn }; +}; + export class SandboxSession extends DurableObject { private readonly sessionId: SessionId | undefined; private readonly eventQueries: EventQueries; private readonly terminalLifecycle: ReturnType; private readonly terminalBridge: ReturnType; private readonly dispatches = new Map>(); + private ingestPublicationChain: Promise = Promise.resolve(); + private deletedWorktreeId: CloudAgentWorktreeId | undefined; + private readonly activeOperations = new Set>(); + private deletionCompletion: Promise | undefined; constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); @@ -167,10 +209,17 @@ export class SandboxSession extends DurableObject { this.eventQueries = createEventQueries(db, ctx.storage.sql); void ctx.blockConcurrencyWhile(async () => { await migrate(db, migrations); + this.deletedWorktreeId = cloudAgentWorktreeIdSchema + .optional() + .parse(ctx.storage.kv.get(DELETED_WORKTREE_KEY)); + if (this.deletedWorktreeId) { + this.terminalLifecycle.beginDeletion(this.terminalLifecycle.getStoredMetadata()); + } }); } async fetch(request: Request): Promise { + if (this.deletedWorktreeId) return new Response('Session deleted', { status: 410 }); const pathname = new URL(request.url).pathname; if (pathname === '/terminal/browser') { return this.terminalBridge.handleBrowserUpgrade(request); @@ -213,11 +262,15 @@ export class SandboxSession extends DurableObject { await this.terminalBridge.handleError(ws, error); } - async receiveSandboxControlEvent(input: { - identity: { directory: string; kiloSessionId?: string; rootKiloSessionId?: string }; - payload: { type: string; properties: Record; timestamp?: string }; - wrapperInstanceId?: string; - }): Promise<{ applied: boolean }> { + receiveSandboxControlEvent( + input: SandboxControlEventInput & { wrapperInstanceId?: string } + ): Promise<{ applied: boolean }> { + return this.trackOperation(this.applySandboxControlEvent(input)); + } + + private async applySandboxControlEvent( + input: SandboxControlEventInput & { wrapperInstanceId?: string } + ): Promise<{ applied: boolean }> { const metadata = await this.getMetadata(); const epoch = this.terminalLifecycle.captureEpoch(); if (!metadata || epoch === null) { @@ -231,10 +284,21 @@ export class SandboxSession extends DurableObject { } const root = metadata.auth.kiloSessionId; if (input.identity.directory !== this.directory(metadata)) return { applied: false }; + const payloadKiloSessionId = ingestKiloSessionId(input.payload.type, input.payload.properties); + const identityKiloSessionId = input.identity.kiloSessionId; + const eventKiloSessionId = identityKiloSessionId ?? payloadKiloSessionId; if ( - input.identity.rootKiloSessionId !== undefined && - root !== undefined && - input.identity.rootKiloSessionId !== root + (input.identity.rootKiloSessionId !== undefined && + root !== undefined && + input.identity.rootKiloSessionId !== root) || + (identityKiloSessionId !== undefined && + payloadKiloSessionId !== undefined && + identityKiloSessionId !== payloadKiloSessionId) || + (metadata.workspace?.worktreeId !== undefined && + root !== undefined && + input.identity.rootKiloSessionId !== root && + identityKiloSessionId !== root && + payloadKiloSessionId !== root) ) { logger .withFields({ @@ -246,10 +310,23 @@ export class SandboxSession extends DurableObject { .warn('receiveSandboxControlEvent rejected; kilo session mismatch'); return { applied: false }; } + if (input.payload.type === 'session.created' || input.payload.type === 'session.updated') { + const info = input.payload.properties.info; + if (typeof info === 'object' && info !== null) { + if ('id' in info && info.id !== eventKiloSessionId) return { applied: false }; + if (eventKiloSessionId !== root && ('parentID' in info || 'directory' in info)) { + const directory = this.directory(metadata); + const child = childSessionLineage(info, directory); + if ( + !child || + child.sessionId !== eventKiloSessionId || + input.identity.directory !== directory + ) + return { applied: false }; + } + } + } const sessionId = this.requireSessionId(); - const eventKiloSessionId = - input.identity.kiloSessionId ?? - ingestKiloSessionId(input.payload.type, input.payload.properties); if (input.payload.type === 'session.message.outcome') { const outcome = sessionMessageOutcomeSchema.safeParse(input.payload.properties); if ( @@ -326,18 +403,27 @@ export class SandboxSession extends DurableObject { .warn('Control-plane child session ingest skipped; internal secret unavailable'); } } - if ((!isChild || internalSecret) && this.terminalLifecycle.isCurrent(epoch)) { - this.ctx.waitUntil( - publishControlPlaneSessionIngest({ - fetchIngest: request => this.env.SESSION_INGEST.fetch(request), - token, - rootKiloSessionId, - eventKiloSessionId, - cloudAgentSessionId: metadata.identity.sessionId, - ...(internalSecret ? { internalSecret } : {}), - items: ingestItems, - }) - ); + if (this.deletedWorktreeId || !this.terminalLifecycle.isCurrent(epoch)) { + return { applied: false }; + } + if (!isChild || internalSecret) { + const publication = this.ingestPublicationChain + .catch(() => undefined) + .then(() => { + if (!this.terminalLifecycle.isCurrent(epoch)) return; + return publishControlPlaneSessionIngest({ + fetchIngest: request => this.env.SESSION_INGEST.fetch(request), + token, + rootKiloSessionId, + eventKiloSessionId, + cloudAgentSessionId: metadata.identity.sessionId, + directory: this.directory(metadata), + ...(internalSecret ? { internalSecret } : {}), + items: ingestItems, + }); + }); + this.ingestPublicationChain = publication; + this.ctx.waitUntil(publication); } } return { applied: this.terminalLifecycle.isCurrent(epoch) }; @@ -393,11 +479,19 @@ export class SandboxSession extends DurableObject { return sockets.length; } + async getRuntimeLocation(): Promise { + const raw = await this.ctx.storage.get(METADATA_KEY); + return raw === undefined ? null : sessionRuntimeLocator(parseSessionMetadata(raw)); + } + async getMetadata(): Promise { - return this.terminalLifecycle.isDeleted() ? null : this.terminalLifecycle.getStoredMetadata(); + return this.deletedWorktreeId || this.terminalLifecycle.isDeleted() + ? null + : this.terminalLifecycle.getStoredMetadata(); } async getCredentialMetadata(): Promise { + if (this.deletedWorktreeId) return null; return this.terminalLifecycle.isBlocked() ? null : this.terminalLifecycle.getStoredMetadata(); } @@ -563,7 +657,7 @@ export class SandboxSession extends DurableObject { }): Promise> { if (this.pendingRuntimeCleanup()) return { success: false, error: 'Runtime cleanup is pending' }; - return this.terminalLifecycle.createTerminal(input); + return this.trackOperation(this.terminalLifecycle.createTerminal(input)); } async resizeTerminal(input?: { @@ -571,11 +665,11 @@ export class SandboxSession extends DurableObject { cols?: number; rows?: number; }): Promise> { - return this.terminalLifecycle.resizeTerminal(input); + return this.trackOperation(this.terminalLifecycle.resizeTerminal(input)); } async closeTerminal(input?: { ptyId?: string }): Promise> { - return this.terminalLifecycle.closeTerminal(input); + return this.trackOperation(this.terminalLifecycle.closeTerminal(input)); } async invalidateTerminalRuntime(input: { @@ -590,16 +684,147 @@ export class SandboxSession extends DurableObject { return this.pendingRuntimeCleanup() !== undefined; } + async beginWorktreeDeletion(input: { + worktreeId: CloudAgentWorktreeId; + kiloSessionId: string; + ownerId: string; + organizationId?: string; + }): Promise { + const worktreeId = cloudAgentWorktreeIdSchema.parse(input.worktreeId); + if (this.deletedWorktreeId && this.deletedWorktreeId !== worktreeId) { + throw new Error('Worktree identity conflict'); + } + const raw = await this.ctx.storage.get(METADATA_KEY); + const metadata = raw === undefined ? null : parseSessionMetadata(raw); + if ( + metadata && + (metadata.workspace?.worktreeId !== worktreeId || + metadata.auth.kiloSessionId !== input.kiloSessionId || + metadata.identity.userId !== input.ownerId || + metadata.identity.orgId !== input.organizationId || + metadata.workspace.workspacePath !== + getWorktreeWorkspacePath(input.organizationId, input.ownerId, worktreeId)) + ) { + throw new Error('Worktree identity conflict'); + } + if ( + this.deletedWorktreeId === worktreeId && + (await this.ctx.storage.get(DELETION_COMPLETED_KEY)) + ) + return null; + this.ctx.storage.transactionSync(() => { + this.ctx.storage.kv.put(DELETED_WORKTREE_KEY, worktreeId); + this.terminalLifecycle.beginDeletion(metadata); + const messages = this.ctx.storage.kv.get(MESSAGES_KEY) ?? []; + const cancelled = messages.map(message => + message.state === 'queued' || message.state === 'accepted' + ? { ...message, state: 'cancelled' as const } + : message + ); + this.ctx.storage.kv.put(MESSAGES_KEY, cancelled); + }); + this.deletedWorktreeId = worktreeId; + for (const socket of this.ctx.getWebSockets()) socket.close(1001, 'Worktree deleted'); + await this.ctx.storage.deleteAlarm(); + if (!metadata) return null; + return cloudAgentWorktreeLocationSchema.parse({ + sandboxId: metadata.workspace?.sandboxId, + provider: metadata.workspace?.sandboxProvider, + }); + } + + async getWorktreeChildSessions( + worktreeId: CloudAgentWorktreeId + ): Promise { + if (this.deletedWorktreeId !== worktreeId) throw new Error('Worktree deletion not started'); + await Promise.allSettled([...this.activeOperations]); + const raw = await this.ctx.storage.get(METADATA_KEY); + if (raw === undefined) return []; + const metadata = parseSessionMetadata(raw); + if (metadata.workspace?.worktreeId !== worktreeId) + throw new Error('Worktree identity conflict'); + const directory = this.directory(metadata); + const rows = drizzle(this.ctx.storage) + .select({ + id: sql`json_extract(${events.payload}, '$.properties.info.id')`, + parentID: sql`json_extract(${events.payload}, '$.properties.info.parentID')`, + directory: sql`json_extract(${events.payload}, '$.properties.info.directory')`, + }) + .from(events) + .where( + and( + eq(events.session_id, this.requireSessionId()), + eq(events.stream_event_type, 'kilocode'), + inArray(sql`json_extract(${events.payload}, '$.type')`, [ + 'session.created', + 'session.updated', + ]) + ) + ) + .orderBy(events.id) + .all(); + const children = new Map(); + for (const row of rows) { + const child = childSessionLineage(row, directory); + if (!child || child.sessionId === metadata.auth.kiloSessionId) continue; + const existing = children.get(child.sessionId); + if (existing && existing.parentSessionId !== child.parentSessionId) + throw new Error('worktree_child_lineage_conflict'); + children.set(child.sessionId, child); + } + return [...children.values()]; + } + + async finishWorktreeDeletion(worktreeId: CloudAgentWorktreeId): Promise { + if (this.deletedWorktreeId !== worktreeId) throw new Error('Worktree deletion not started'); + if (!this.deletionCompletion) { + this.deletionCompletion = this.clearDeletedWorktree(worktreeId).catch(error => { + this.deletionCompletion = undefined; + throw error; + }); + } + await this.deletionCompletion; + } + + private async clearDeletedWorktree(worktreeId: CloudAgentWorktreeId): Promise { + while (this.activeOperations.size > 0) { + await Promise.allSettled([...this.activeOperations]); + } + await this.ingestPublicationChain.catch(() => undefined); + if (await this.ctx.storage.get(DELETION_COMPLETED_KEY)) return; + for (const socket of this.ctx.getWebSockets()) socket.close(1001, 'Worktree deleted'); + await this.ctx.storage.deleteAlarm(); + const db = drizzle(this.ctx.storage, { logger: false }); + this.ctx.storage.transactionSync(() => { + db.delete(events).where(eq(events.session_id, this.requireSessionId())).run(); + db.delete(commandQueue).where(eq(commandQueue.session_id, this.requireSessionId())).run(); + db.delete(executionLeases).where(isNotNull(executionLeases.execution_id)).run(); + this.terminalLifecycle.purgeDeletedState(); + this.ctx.storage.kv.put(DELETED_WORKTREE_KEY, worktreeId); + this.ctx.storage.kv.put(DELETION_COMPLETED_KEY, true); + }); + } + + private trackOperation(operation: Promise): Promise { + this.activeOperations.add(operation); + return operation.finally(() => this.activeOperations.delete(operation)); + } + async deleteSession(): Promise { + if (this.deletedWorktreeId) throw new Error('worktree_deleting'); await this.interruptExecution(); + if (this.deletedWorktreeId) throw new Error('worktree_deleting'); const metadata = this.terminalLifecycle.getStoredMetadata(); const records = this.terminalLifecycle.beginDeletion(metadata); for (const ws of this.ctx.getWebSockets('stream')) { ws.close(1000, 'session access revoked'); } await this.terminalLifecycle.cleanupSession(metadata, records); + await this.ingestPublicationChain.catch(() => undefined); + if (this.deletedWorktreeId) throw new Error('worktree_deleting'); await this.ctx.storage.deleteAlarm(); this.ctx.storage.transactionSync(() => { + if (this.deletedWorktreeId) throw new Error('worktree_deleting'); const pendingCleanup = this.pendingRuntimeCleanup(); this.eventQueries.deleteOlderThan(Number.MAX_SAFE_INTEGER); this.terminalLifecycle.purgeDeletedState(); @@ -608,18 +833,31 @@ export class SandboxSession extends DurableObject { if (this.pendingRuntimeCleanup()) await this.armQueueRetry(); } - async registerSession(input: { - identity: SessionMetadata['identity']; - auth: SessionMetadata['auth']; - agent: SessionMetadata['agent']; - repository?: SessionMetadata['repository']; - workspace?: SessionMetadata['workspace']; - callback?: SessionMetadata['callback']; - profile?: SessionMetadata['profile']; - finalization?: SessionMetadata['finalization']; - }): Promise { + async registerSession(input: SandboxSessionRegistrationInput): Promise { + if (this.deletedWorktreeId) return { success: false, error: 'worktree_deleting' }; if (this.terminalLifecycle.isBlocked()) return { success: false, error: 'Session not found' }; - if (this.terminalLifecycle.getStoredMetadata()) return { success: true }; + const initialMessage = input.message + ? this.initialMessageFromRegistration(input.message) + : undefined; + const existing = this.terminalLifecycle.getStoredMetadata(); + if (existing) { + const metadata = this.enforceGroupedFinalization(existing); + try { + validateControlSessionOptions(metadata); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unsupported session options', + }; + } + if (initialMessage && !metadata.initialMessage) { + this.ctx.storage.kv.put( + METADATA_KEY, + serializeSessionMetadata({ ...metadata, initialMessage }) + ); + } + return { success: true }; + } try { validateControlSessionOptions(input); } catch (error) { @@ -637,18 +875,23 @@ export class SandboxSession extends DurableObject { upstreamBranch: input.repository.upstreamBranch ?? input.repository.branch, } : input.repository; + const finalization = input.workspace?.worktreeId + ? { ...input.finalization, autoCommit: false } + : input.finalization; const metadata = parseSessionMetadata({ metadataSchemaVersion: 2, identity: input.identity, auth: input.auth, agent: input.agent, ...(repository ? { repository } : {}), + ...(initialMessage ? { initialMessage } : {}), workspace: input.workspace ?? {}, ...(input.callback ? { callback: input.callback } : {}), ...(input.profile ? { profile: input.profile } : {}), - ...(input.finalization ? { finalization: input.finalization } : {}), + ...(finalization ? { finalization } : {}), lifecycle: { version: 1, timestamp: Date.now() }, }); + if (this.deletedWorktreeId) return { success: false, error: 'worktree_deleting' }; if (this.terminalLifecycle.isBlocked()) return { success: false, error: 'Session not found' }; this.ctx.storage.kv.put(METADATA_KEY, serializeSessionMetadata(metadata)); this.ctx.storage.kv.put(PENDING_INTERACTIONS_KEY, { @@ -659,17 +902,9 @@ export class SandboxSession extends DurableObject { return { success: true }; } - async createSessionWithInitialAdmission(input: { - identity: SessionMetadata['identity']; - auth: SessionMetadata['auth']; - agent: SessionMetadata['agent']; - repository?: SessionMetadata['repository']; - workspace?: SessionMetadata['workspace']; - callback?: SessionMetadata['callback']; - profile?: SessionMetadata['profile']; - finalization?: SessionMetadata['finalization']; - message: { initialTurn: AcceptedExecutionTurn }; - }): Promise { + async createSessionWithInitialAdmission( + input: SandboxSessionInitialAdmissionInput + ): Promise { try { validateControlSessionOptions(input); } catch (error) { @@ -679,7 +914,38 @@ export class SandboxSession extends DurableObject { error: error instanceof Error ? error.message : 'Unsupported session options', }; } - const registered = await this.registerSession(input); + const initialTurn = input.message.initialTurn; + const existing = await this.getMetadata(); + if ( + existing?.initialMessage && + !this.initialMessageMatches(existing.initialMessage, initialTurn) + ) { + return { + success: false, + code: 'BAD_REQUEST', + error: 'Initial turn does not match registered session intent', + }; + } + const registered = await this.registerSession({ + ...input, + message: { + initialMessageId: initialTurn.messageId, + turn: + initialTurn.type === 'prompt' + ? { + type: 'prompt', + id: initialTurn.messageId, + prompt: initialTurn.prompt, + ...(initialTurn.attachments ? { attachments: initialTurn.attachments } : {}), + } + : { + type: 'command', + id: initialTurn.messageId, + command: initialTurn.command, + arguments: initialTurn.arguments, + }, + }, + }); if (!registered.success) { return { success: false, @@ -689,7 +955,7 @@ export class SandboxSession extends DurableObject { } return this.queueAndDispatch( { - turn: input.message.initialTurn, + turn: initialTurn, agent: input.agent, finalization: input.finalization, }, @@ -738,6 +1004,13 @@ export class SandboxSession extends DurableObject { request: SubmittedSessionMessageRequest ): Promise { const messageId = request.turn.id ?? createMessageId(); + if (request.turn.type === 'command' && request.turn.attachments !== undefined) { + return { + success: false, + code: 'BAD_REQUEST', + error: 'Attachments cannot be attached to slash commands', + }; + } const turn: AcceptedExecutionTurn = request.turn.type === 'prompt' ? { @@ -773,7 +1046,7 @@ export class SandboxSession extends DurableObject { async alarm(): Promise { if (this.pendingRuntimeCleanup()) await this.transferRuntimeCleanup(); const epoch = this.terminalLifecycle.captureEpoch(); - if (epoch === null) return; + if (epoch === null || this.deletedWorktreeId) return; const now = Date.now(); const messages = this.loadMessages(); if (!this.terminalLifecycle.isCurrent(epoch)) return; @@ -820,16 +1093,29 @@ export class SandboxSession extends DurableObject { origin: 'initial' | 'followup' ): Promise { const epoch = this.terminalLifecycle.captureEpoch(); - const metadata = this.terminalLifecycle.getStoredMetadata(); - if (epoch === null || !metadata) { + const storedMetadata = this.terminalLifecycle.getStoredMetadata(); + if (epoch === null || !storedMetadata || this.deletedWorktreeId) { return { success: false, code: 'NOT_FOUND', error: 'Session not found' }; } + const metadata = this.enforceGroupedFinalization(storedMetadata); + const replayInput = metadata.workspace?.worktreeId + ? { ...input, finalization: { ...input.finalization, autoCommit: false } } + : input; + const admissionInput = metadata.workspace?.worktreeId + ? { + ...input, + finalization: { ...metadata.finalization, ...input.finalization, autoCommit: false }, + } + : input; const messageId = input.turn.messageId; const messages = this.ctx.storage.kv.get(MESSAGES_KEY) ?? []; const existing = messages.find(message => message.messageId === messageId); const intent = existing ? undefined - : resolveSessionMessageIntent(input, origin === 'followup' ? metadata.agent : undefined); + : resolveSessionMessageIntent( + admissionInput, + origin === 'followup' ? metadata.agent : undefined + ); if (!existing && !intent) { return { success: false, code: 'BAD_REQUEST', error: 'Session is missing a valid model' }; } @@ -879,9 +1165,13 @@ export class SandboxSession extends DurableObject { const latestMessages = this.ctx.storage.kv.get(MESSAGES_KEY) ?? []; const duplicate = latestMessages.find(message => message.messageId === messageId); if (duplicate) { - const [frozen] = freezeLegacyQueuedMessages([duplicate], latestMetadata.agent); + const [frozen] = freezeLegacyQueuedMessages( + [duplicate], + latestMetadata.agent, + latestMetadata.workspace?.worktreeId ? latestMetadata.finalization : undefined + ); if ( - !matchesSessionMessageReplay(frozen, intent ?? input) || + !matchesSessionMessageReplay(frozen, intent ?? replayInput) || (intent && frozen.intent && (intent.agent.variant !== frozen.intent.agent.variant || @@ -904,7 +1194,11 @@ export class SandboxSession extends DurableObject { } if (validationFailure) return validationFailure; if (!intent) return { success: false, code: 'NOT_FOUND', error: 'Message not found' }; - const nextMessages = freezeLegacyQueuedMessages(latestMessages, latestMetadata.agent); + const nextMessages = freezeLegacyQueuedMessages( + latestMessages, + latestMetadata.agent, + latestMetadata.workspace?.worktreeId ? latestMetadata.finalization : undefined + ); nextMessages.push(createSessionMessageRecord(intent)); const nextMetadata = intent.agent.model === undefined @@ -930,7 +1224,11 @@ export class SandboxSession extends DurableObject { return result; } - private async dispatchQueued( + private dispatchQueued(messageId: string, options?: { allowCreate?: boolean }): Promise { + return this.trackOperation(this.runDispatchQueued(messageId, options)); + } + + private async runDispatchQueued( messageId: string, options?: { allowCreate?: boolean } ): Promise { @@ -949,7 +1247,9 @@ export class SandboxSession extends DurableObject { messageId: string, options?: { allowCreate?: boolean } ): Promise { - const metadata = this.terminalLifecycle.getStoredMetadata(); + if (this.deletedWorktreeId) return; + const storedMetadata = this.terminalLifecycle.getStoredMetadata(); + const metadata = storedMetadata ? this.enforceGroupedFinalization(storedMetadata) : null; const epoch = this.terminalLifecycle.captureEpoch(); const sandboxId = metadata?.workspace?.sandboxId; const kiloSessionId = metadata?.auth.kiloSessionId; @@ -963,7 +1263,11 @@ export class SandboxSession extends DurableObject { if (!this.terminalLifecycle.isCurrent(epoch) || nextQueuedMessageId(messages) !== messageId) { return undefined; } - const frozen = freezeLegacyQueuedMessages(messages, metadata.agent).map(message => + const frozen = freezeLegacyQueuedMessages( + messages, + metadata.agent, + metadata.workspace?.worktreeId ? metadata.finalization : undefined + ).map(message => message.messageId === messageId && message.deliveryDeadlineAt === undefined ? { ...message, deliveryDeadlineAt: Date.now() + SESSION_DELIVERY_TIMEOUT_MS } : message @@ -1061,6 +1365,9 @@ export class SandboxSession extends DurableObject { sessionId, provider, ...(acquisition ? { acquisition } : { allowCreate }), + ...(metadata.workspace?.worktreeId + ? { worktreeId: metadata.workspace.worktreeId } + : {}), billing: buildSandboxBillingInput( metadata, sandboxId, @@ -1173,6 +1480,9 @@ export class SandboxSession extends DurableObject { this.terminalLifecycle.recordAttachment({ metadata, sandboxId, wrapperInstanceId, epoch }); recorder.finalize({ status: 'completed' }); phase = 'prompt'; + const finalization = metadata.workspace?.worktreeId + ? { ...intent.finalization, autoCommit: false } + : intent.finalization; const prompt = await wait(async () => controlRequestResult( await control.request({ @@ -1194,7 +1504,7 @@ export class SandboxSession extends DurableObject { ...(model !== undefined ? { model } : {}), ...(intent.agent.variant !== undefined ? { variant: intent.agent.variant } : {}), }, - ...(intent.finalization ? { finalization: intent.finalization } : {}), + ...(finalization ? { finalization } : {}), ...(attachments.length ? { attachments } : {}), }, }) @@ -1234,6 +1544,7 @@ export class SandboxSession extends DurableObject { } private async compensateSessionAttachment(metadata: SessionMetadata): Promise { + if (this.deletedWorktreeId) return; try { await withTimeout( this.terminalLifecycle.cleanupSession(metadata, []), @@ -1456,6 +1767,7 @@ export class SandboxSession extends DurableObject { const response = await withTimeout( control.request({ operation: 'session.sync', + expectedWrapperInstanceId: message.wrapperInstanceId, session: { sessionId: metadata.identity.sessionId, kiloSessionId, @@ -1468,7 +1780,30 @@ export class SandboxSession extends DurableObject { ); if (!this.isCurrentAcceptedMessage(message, epoch)) return undefined; if (!response.ok) throw new Error('Session sync failed'); - const result = sessionSyncResultSchema.parse(response.result); + const parsed = sessionSyncResultSchema.parse(response.result); + const belongsToRoot = (request: unknown): boolean => { + if ( + typeof request !== 'object' || + request === null || + Array.isArray(request) || + !('id' in request) || + typeof request.id !== 'string' || + request.id.length === 0 || + !('sessionID' in request) || + typeof request.sessionID !== 'string' || + request.sessionID.length === 0 + ) + return false; + const root = 'rootKiloSessionId' in request ? request.rootKiloSessionId : undefined; + return root === undefined ? request.sessionID === kiloSessionId : root === kiloSessionId; + }; + const result = metadata.workspace?.worktreeId + ? { + ...parsed, + questions: parsed.questions.filter(belongsToRoot), + permissions: parsed.permissions.filter(belongsToRoot), + } + : parsed; if (this.readPendingInteractions()?.revision === revision) { this.ctx.storage.kv.put(PENDING_INTERACTIONS_KEY, { revision: (revision ?? 0) + 1, @@ -1518,6 +1853,64 @@ export class SandboxSession extends DurableObject { return streamCloudStatus(this.loadMessages()); } + private initialMessageFromRegistration( + message: NonNullable + ): NonNullable { + const turn = message.turn; + if (turn.type === 'command') { + return { + id: message.initialMessageId ?? turn.id ?? undefined, + prompt: + turn.arguments.length > 0 ? `/${turn.command} ${turn.arguments}` : `/${turn.command}`, + turn: { type: 'command', command: turn.command, arguments: turn.arguments }, + }; + } + return { + id: message.initialMessageId ?? turn.id ?? undefined, + prompt: turn.prompt, + ...(turn.attachments ? { attachments: turn.attachments } : {}), + turn: { + type: 'prompt', + prompt: turn.prompt, + ...(turn.attachments ? { attachments: turn.attachments } : {}), + }, + }; + } + + private initialMessageMatches( + initialMessage: NonNullable, + turn: AcceptedExecutionTurn + ): boolean { + if (initialMessage.id !== turn.messageId || initialMessage.turn?.type !== turn.type) { + return false; + } + if (turn.type === 'command') { + return ( + initialMessage.turn.type === 'command' && + initialMessage.turn.command === turn.command && + initialMessage.turn.arguments === turn.arguments + ); + } + return ( + initialMessage.turn.type === 'prompt' && + initialMessage.turn.prompt === turn.prompt && + JSON.stringify(initialMessage.turn.attachments) === JSON.stringify(turn.attachments) + ); + } + + private enforceGroupedFinalization(metadata: SessionMetadata): SessionMetadata { + if (this.deletedWorktreeId || this.terminalLifecycle.isBlocked()) return metadata; + if (!metadata.workspace?.worktreeId || metadata.finalization?.autoCommit === false) { + return metadata; + } + const updated = serializeSessionMetadata({ + ...metadata, + finalization: { ...metadata.finalization, autoCommit: false }, + }); + this.ctx.storage.kv.put(METADATA_KEY, updated); + return updated; + } + private async requestSessionOperation( operation: 'session.permission.resolve' | 'session.question.resolve', payload: unknown @@ -1596,13 +1989,18 @@ export class SandboxSession extends DurableObject { } private loadMessages(): MessageRecord[] { - if (this.terminalLifecycle.isBlocked()) return []; + if (this.deletedWorktreeId || this.terminalLifecycle.isBlocked()) return []; return this.ctx.storage.kv.get(MESSAGES_KEY) ?? []; } private saveMessages(messages: MessageRecord[], epoch?: number): boolean { const currentEpoch = epoch ?? this.terminalLifecycle.captureEpoch(); - if (currentEpoch === null || !this.terminalLifecycle.isCurrent(currentEpoch)) return false; + if ( + this.deletedWorktreeId || + currentEpoch === null || + !this.terminalLifecycle.isCurrent(currentEpoch) + ) + return false; const events: StoredEvent[] = []; this.ctx.storage.transactionSync(() => { const before = this.loadMessages(); diff --git a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts index 32642fb0b3..a80e0ed3b6 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts @@ -19,6 +19,76 @@ describe('controlEventToIngestItems', () => { ).toEqual([{ type: 'part', data: { id: 'prt_1', type: 'text' } }]); }); + it.each(['session.created', 'session.updated'] as const)('maps %s events', type => { + expect( + controlEventToIngestItems(type, { info: { id: 'ses_root', title: 'Root session' } }) + ).toEqual([{ type: 'session', data: { id: 'ses_root', title: 'Root session' } }]); + }); + + it.each(['busy', 'idle', 'retry'] as const)('maps %s session status events', status => { + expect( + controlEventToIngestItems('session.status', { + sessionID: 'ses_root', + status: { type: status }, + }) + ).toEqual([{ type: 'session_status', data: { status } }]); + }); + + it('projects retry status without retaining runtime details', () => { + expect( + controlEventToIngestItems('session.status', { + sessionID: 'ses_root', + status: { type: 'retry', attempt: 2, message: 'Rate limited', next: 5_000 }, + }) + ).toEqual([{ type: 'session_status', data: { status: 'retry' } }]); + }); + + it('normalizes offline runtime status to the supported retry status', () => { + expect( + controlEventToIngestItems('session.status', { + sessionID: 'ses_root', + status: { type: 'offline', requestID: 'req_1', message: 'Network unavailable' }, + }) + ).toEqual([{ type: 'session_status', data: { status: 'retry' } }]); + }); + + it.each([undefined, null, 'busy', true, 1, [], {}, { type: null }, { type: 1 }])( + 'rejects malformed session status payload %p', + status => { + expect( + controlEventToIngestItems('session.status', { sessionID: 'ses_root', status }) + ).toEqual([]); + } + ); + + it.each(['unknown', 'question', 'permission', 'finalizing'])( + 'rejects unsupported %s status', + status => { + expect( + controlEventToIngestItems('session.status', { + sessionID: 'ses_root', + status: { type: status }, + }) + ).toEqual([]); + } + ); + + it.each([ + { name: 'missing', properties: { status: { type: 'busy' } } }, + { name: 'empty', properties: { sessionID: '', status: { type: 'busy' } } }, + { name: 'non-string', properties: { sessionID: 1, status: { type: 'busy' } } }, + { + name: 'noncanonical', + properties: { sessionId: 'ses_root', status: { type: 'busy' } }, + }, + { + name: 'conflicting', + properties: { sessionID: 'ses_root', sessionId: 'ses_other', status: { type: 'busy' } }, + }, + ])('rejects $name session identities', ({ properties }) => { + expect(controlEventToIngestItems('session.status', properties)).toEqual([]); + }); + it('ignores deltas and incomplete payloads', () => { expect(controlEventToIngestItems('message.part.delta', { text: 'x' })).toEqual([]); expect(controlEventToIngestItems('message.updated', {})).toEqual([]); @@ -40,6 +110,13 @@ describe('publishControlPlaneSessionIngest', () => { expect(request.method).toBe('POST'); expect(request.url).toContain('/api/session/ses_aaaaaaaaaaaaaaaaaaaaaaaaaa/ingest?v=2'); expect(request.headers.get('Authorization')).toBe('Bearer tok'); + expect(request.headers.get('Content-Length')).toBe( + String( + new TextEncoder().encode( + JSON.stringify({ data: [{ type: 'message', data: { id: 'msg_1' } }] }) + ).byteLength + ) + ); expect(request.headers.get(cloudAgentSessionScopeHeaders.cloudAgentSessionId)).toBeNull(); expect(request.headers.get(cloudAgentSessionScopeHeaders.rootKiloSessionId)).toBeNull(); expect(request.headers.get(cloudAgentSessionScopeHeaders.protocolVersion)).toBeNull(); @@ -70,6 +147,14 @@ describe('publishControlPlaneSessionIngest', () => { expect(ingestRequest.url).toBe( `https://session-ingest/internal/cloud-agent/v1/session/${child}/ingest?v=2` ); + expect(createRequest.headers.get('Content-Length')).toBeNull(); + expect(ingestRequest.headers.get('Content-Length')).toBe( + String( + new TextEncoder().encode( + JSON.stringify({ data: [{ type: 'message', data: { id: 'msg_1' } }] }) + ).byteLength + ) + ); for (const request of [createRequest, ingestRequest]) { expect(request.headers.get('Authorization')).toBe('Bearer tok'); expect(request.headers.get('X-Internal-Secret')).toBe('internal'); @@ -87,6 +172,31 @@ describe('publishControlPlaneSessionIngest', () => { }); }); + it('forwards authoritative creation lineage before a child has any messages', async () => { + const requests: Request[] = []; + const root = 'ses_aaaaaaaaaaaaaaaaaaaaaaaaaa'; + const child = 'ses_bbbbbbbbbbbbbbbbbbbbbbbbbb'; + const directory = '/workspace/owner/worktrees/worktree_11111111-1111-4111-8111-111111111111'; + await publishControlPlaneSessionIngest({ + fetchIngest: async request => { + requests.push(request); + return new Response(null, { status: 200 }); + }, + token: 'tok', + rootKiloSessionId: root, + eventKiloSessionId: child, + cloudAgentSessionId: 'workspace_11111111-1111-4111-8111-111111111111', + directory, + internalSecret: 'internal', + items: controlEventToIngestItems('session.created', { + sessionID: child, + info: { id: child, parentID: root, directory, title: 'Never run' }, + }), + }); + expect(requests).toHaveLength(2); + expect(await requests[0].json()).toEqual({ sessionId: child, parentSessionId: root }); + }); + it('stops child ingest when scoped session creation fails', async () => { const fetchIngest = vi.fn().mockResolvedValue(new Response(null, { status: 409 })); await publishControlPlaneSessionIngest({ @@ -110,5 +220,8 @@ describe('ingestKiloSessionId', () => { 'part' ); expect(ingestKiloSessionId('session.updated', { sessionID: 'top-level' })).toBe('top-level'); + expect( + ingestKiloSessionId('session.status', { sessionID: 'status-root', status: { type: 'busy' } }) + ).toBe('status-root'); }); }); diff --git a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts index 0a8a4daa4e..60e26db103 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts @@ -1,11 +1,34 @@ +import { z } from 'zod'; import { logger } from '../logger.js'; import { cloudAgentSessionScopeHeaders, cloudAgentSessionScopeProtocolVersion, + containedKiloSessionIdSchema, + type CloudAgentChildSessionLineage, } from '@kilocode/session-ingest-contracts'; export type IngestItem = { type: string; data: unknown }; +const childSessionInfoSchema = z.object({ + id: containedKiloSessionIdSchema, + parentID: containedKiloSessionIdSchema, + directory: z.string().min(1), +}); + +export function childSessionLineage( + info: unknown, + directory: string +): CloudAgentChildSessionLineage | undefined { + const parsed = childSessionInfoSchema.safeParse(info); + if ( + !parsed.success || + parsed.data.id === parsed.data.parentID || + parsed.data.directory !== directory + ) + return undefined; + return { sessionId: parsed.data.id, parentSessionId: parsed.data.parentID }; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } @@ -39,6 +62,22 @@ export function controlEventToIngestItems( if ((type === 'session.updated' || type === 'session.created') && isRecord(properties.info)) { return [{ type: 'session', data: properties.info }]; } + if (type === 'session.status' && isRecord(properties.status)) { + const sessionId = properties.sessionID; + if ( + typeof sessionId !== 'string' || + sessionId.length === 0 || + (typeof properties.sessionId === 'string' && properties.sessionId !== sessionId) + ) { + return []; + } + const status = properties.status.type; + if (status === 'busy' || status === 'idle' || status === 'retry' || status === 'offline') { + return [ + { type: 'session_status', data: { status: status === 'offline' ? 'retry' : status } }, + ]; + } + } return []; } @@ -48,6 +87,7 @@ export async function publishControlPlaneSessionIngest(params: { rootKiloSessionId: string; eventKiloSessionId?: string; cloudAgentSessionId: string; + directory?: string; internalSecret?: string; items: IngestItem[]; }): Promise { @@ -64,6 +104,22 @@ export async function publishControlPlaneSessionIngest(params: { return; } + let lineage: CloudAgentChildSessionLineage | undefined; + if (isChild && params.directory) { + for (const item of params.items) { + if ( + item.type !== 'session' || + !isRecord(item.data) || + (!('parentID' in item.data) && !('directory' in item.data)) + ) + continue; + const candidate = childSessionLineage(item.data, params.directory); + if (!candidate || candidate.sessionId !== eventKiloSessionId) return; + if (lineage && lineage.parentSessionId !== candidate.parentSessionId) return; + lineage = candidate; + } + } + const headers = new Headers({ Authorization: `Bearer ${params.token}`, 'Content-Type': 'application/json', @@ -79,6 +135,8 @@ export async function publishControlPlaneSessionIngest(params: { } const body = JSON.stringify({ data: params.items }); + const ingestHeaders = new Headers(headers); + ingestHeaders.set('Content-Length', String(new TextEncoder().encode(body).byteLength)); const ingestUrl = isChild ? `https://session-ingest/internal/cloud-agent/v1/session/${encodeURIComponent(eventKiloSessionId)}/ingest?v=2` : `https://session-ingest/api/session/${encodeURIComponent(params.rootKiloSessionId)}/ingest?v=2`; @@ -88,7 +146,10 @@ export async function publishControlPlaneSessionIngest(params: { new Request('https://session-ingest/internal/cloud-agent/v1/session', { method: 'POST', headers, - body: JSON.stringify({ sessionId: eventKiloSessionId }), + body: JSON.stringify({ + sessionId: eventKiloSessionId, + ...(lineage ? { parentSessionId: lineage.parentSessionId } : {}), + }), }) ); if (!createResponse.ok) { @@ -106,7 +167,7 @@ export async function publishControlPlaneSessionIngest(params: { const response = await params.fetchIngest( new Request(ingestUrl, { method: 'POST', - headers, + headers: ingestHeaders, body, }) ); diff --git a/services/cloud-agent-next/src/sandbox-session/control-rpc.ts b/services/cloud-agent-next/src/sandbox-session/control-rpc.ts index c2940318ab..6dcdfca266 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-rpc.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-rpc.ts @@ -11,6 +11,8 @@ import type { import type { Env } from '../types.js'; import type { SandboxAcquisition } from '../persistence/SandboxControl.js'; import type { SandboxBillingInput } from '../container-usage-context.js'; +import { getSandboxControlStub } from '../sandbox-control/stub.js'; +import { withDORetry } from '../utils/do-retry.js'; type SandboxControlRpc = { prepareSessionCredentials(input: { @@ -24,6 +26,7 @@ type SandboxControlRpc = { allowCreate?: boolean; acquisition?: SandboxAcquisition; billing?: SandboxBillingInput; + worktreeId?: string; }): Promise<{ connection: ConnectionState; physical: PhysicalState; @@ -54,5 +57,26 @@ type SandboxControlRpc = { }; export function sandboxControlRpc(env: Env, sandboxId: string): SandboxControlRpc { - return env.SANDBOX_CONTROL.getByName(sandboxId); + const stub = () => getSandboxControlStub(env, sandboxId); + return { + prepareSessionCredentials: input => + withDORetry( + stub, + control => control.prepareSessionCredentials(input), + 'prepareSessionCredentials' + ), + ensureReady: input => stub().ensureReady(input), + getStatus: () => stub().getStatus(), + quarantineRuntime: input => stub().quarantineRuntime(input), + attachSession: input => stub().attachSession(input), + detachSession: sessionId => + withDORetry(stub, control => control.detachSession(sessionId), 'detachSession'), + validateTerminalAccess: input => + withDORetry(stub, control => control.validateTerminalAccess(input), 'validateTerminalAccess'), + recordTerminalActivity: input => + withDORetry(stub, control => control.recordTerminalActivity(input), 'recordTerminalActivity'), + updateNetworkPolicy: input => + withDORetry(stub, control => control.updateNetworkPolicy(input), 'updateNetworkPolicy'), + request: input => stub().request(input), + }; } diff --git a/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts b/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts index 0b5017f030..2d6907e54c 100644 --- a/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts +++ b/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts @@ -477,6 +477,42 @@ describe('freezeLegacyQueuedMessages', () => { const frozen = freezeLegacyQueuedMessages([msg('missing', 'queued')], defaultAgent); expect(frozen).toEqual([{ messageId: 'missing', state: 'queued', legacyIntentInvalid: true }]); }); + + it('preserves unversioned intents and freezes legacy finalization before defaults change', () => { + const existing: SessionMessageRecord = { + messageId: commandTurn.messageId, + state: 'queued', + intent: { + turn: commandTurn, + agent: { mode: 'reviewer' }, + finalization: { autoCommit: false }, + }, + }; + const frozen = freezeLegacyQueuedMessages( + [ + existing, + { + messageId: promptTurn.messageId, + state: 'queued', + turn: promptTurn, + finalization: { condenseOnComplete: false }, + }, + ], + defaultAgent, + { autoCommit: false, condenseOnComplete: true } + ); + expect(frozen).toEqual([ + existing, + createSessionMessageRecord({ + turn: promptTurn, + agent: defaultAgent, + finalization: { autoCommit: false, condenseOnComplete: false }, + }), + ]); + expect( + freezeLegacyQueuedMessages(frozen, { model: 'other-model' }, { autoCommit: true }) + ).toEqual(frozen); + }); }); describe('nextQueuedMessageId', () => { @@ -578,7 +614,7 @@ describe('acceptQueuedMessage', () => { }); }); - it('preserves structured prompt attachments and command arguments', () => { + it('preserves upstream turns and durable intents with attachments, commands, and finalization', () => { const prompt = { type: 'prompt' as const, messageId: 'a', @@ -591,13 +627,37 @@ describe('acceptQueuedMessage', () => { command: 'review', arguments: '--all changes', }; + const messages: SessionMessageRecord[] = [ + { messageId: prompt.messageId, state: 'queued', turn: prompt }, + { messageId: command.messageId, state: 'queued', turn: command }, + { + messageId: prompt.messageId, + state: 'queued', + intent: { + turn: prompt, + agent: { mode: 'debug', model: 'attachment-model', variant: 'focused' }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }, + }, + { + messageId: command.messageId, + state: 'queued', + intent: { + turn: command, + agent: { mode: 'plan', model: 'command-model' }, + finalization: { autoCommit: true, condenseOnComplete: false }, + }, + }, + ]; - expect( - acceptQueuedMessage([{ messageId: 'a', state: 'queued', turn: prompt }], 'a', 10)?.[0] - ).toMatchObject({ state: 'accepted', turn: prompt }); - expect( - acceptQueuedMessage([{ messageId: 'b', state: 'queued', turn: command }], 'b', 20)?.[0] - ).toMatchObject({ state: 'accepted', turn: command }); + for (const message of messages) { + expect(acceptQueuedMessage([message], message.messageId, 10)?.[0]).toEqual({ + ...message, + state: 'accepted', + acceptedAt: 10, + lastActivityAt: 10, + }); + } }); it('does not resurrect a cancelled message after interrupt', () => { @@ -687,7 +747,7 @@ describe('streamQueuedSnapshots', () => { ]); }); - it('surfaces queued and accepted legacy prompts for /stream catch-up', () => { + it('marks accepted legacy prompts as sent without changing genuinely queued snapshots', () => { expect( streamQueuedSnapshots( [ @@ -703,7 +763,7 @@ describe('streamQueuedSnapshots', () => { ]); }); - it('renders structured prompts and commands for queued-message reconnect', () => { + it('renders upstream structured turns and durable command or attachment intents on reconnect', () => { expect( streamQueuedSnapshots( [ @@ -723,6 +783,33 @@ describe('streamQueuedSnapshots', () => { state: 'queued', turn: { type: 'command', messageId: 'c', command: 'status', arguments: '' }, }, + { + messageId: 'command', + state: 'queued', + intent: { + turn: { + type: 'command', + messageId: 'command', + command: 'compact', + arguments: '--aggressive', + }, + agent: { mode: 'plan', model: 'override-model' }, + }, + }, + { + messageId: 'attachment', + state: 'queued', + intent: { + turn: { + type: 'prompt', + messageId: 'attachment', + prompt: 'inspect attachment', + attachments: { path: 'attachment-path', files: ['document.pdf'] }, + }, + agent: { mode: 'debug', model: 'attachment-model' }, + finalization: { autoCommit: false }, + }, + }, ], 99 ) @@ -730,6 +817,80 @@ describe('streamQueuedSnapshots', () => { { messageId: 'a', content: 'hello', timestamp: 20, delivery: 'sent' }, { messageId: 'b', content: '/review --all', timestamp: 99 }, { messageId: 'c', content: '/status', timestamp: 99 }, + { messageId: 'command', content: '/compact --aggressive', timestamp: 99 }, + { messageId: 'attachment', content: 'inspect attachment', timestamp: 99 }, + ]); + }); + + it('prefers the canonical durable turn over stale upstream turns and compatibility prompts', () => { + expect( + streamQueuedSnapshots( + [ + { + messageId: 'prompt', + state: 'accepted', + prompt: 'stale compatibility text', + turn: { type: 'prompt', messageId: 'prompt', prompt: 'stale upstream text' }, + intent: { + turn: { type: 'prompt', messageId: 'prompt', prompt: 'canonical text' }, + agent: { mode: 'code', model: 'selected-model' }, + }, + }, + ], + 50 + ) + ).toEqual([ + { messageId: 'prompt', content: 'canonical text', timestamp: 50, delivery: 'sent' }, + ]); + }); + + it('keeps failed snapshots on their existing queued-then-terminal delivery path', () => { + expect( + streamQueuedSnapshots( + [ + { + messageId: 'queued_failure', + state: 'failed', + prompt: 'never sent', + failedReason: 'preparation_failed', + }, + { + messageId: 'accepted_failure', + state: 'failed', + prompt: 'already sent', + acceptedAt: 20, + failedReason: 'wrapper_failed', + }, + ], + 99 + ) + ).toEqual([ + { + messageId: 'queued_failure', + content: 'never sent', + timestamp: 99, + terminalFailure: { + messageId: 'queued_failure', + status: 'failed', + delivery: 'queued', + accepted: false, + reason: 'preparation_failed', + timestamp: 99, + }, + }, + { + messageId: 'accepted_failure', + content: 'already sent', + timestamp: 20, + terminalFailure: { + messageId: 'accepted_failure', + status: 'failed', + delivery: 'sent', + accepted: true, + reason: 'wrapper_failed', + timestamp: 20, + }, + }, ]); }); }); @@ -2569,6 +2730,7 @@ describe('SandboxSession orchestration', () => { const fixture = sessionFixture(); fixture.values.delete('session_metadata'); const finalization = { autoCommit: true, condenseOnComplete: true }; + const messageId = 'msg_123456789abcABCDEFGHIJKLMN'; await expect( fixture.session.createSessionWithInitialAdmission({ identity: fixture.metadata.identity, @@ -2576,11 +2738,14 @@ describe('SandboxSession orchestration', () => { agent: fixture.metadata.agent, workspace: fixture.metadata.workspace, finalization, - message: { initialTurn: { type: 'prompt', messageId: 'a', prompt: 'Initial web prompt' } }, + message: { initialTurn: { type: 'prompt', messageId, prompt: 'Initial web prompt' } }, }) ).resolves.toMatchObject({ success: true }); await fixture.flush(); - expect(fixture.record('a')).toMatchObject({ state: 'accepted', intent: { finalization } }); + expect(fixture.record(messageId)).toMatchObject({ + state: 'accepted', + intent: { finalization }, + }); expect((await fixture.session.getMetadata())?.finalization).toEqual(finalization); expect(fixture.control.request).toHaveBeenCalledWith( expect.objectContaining({ @@ -2739,6 +2904,9 @@ describe('SandboxSession orchestration', () => { describe('streamCloudStatus', () => { it('is ready for accepted work even with queued followers', () => { expect(streamCloudStatus([msg('a', 'queued')])).toEqual({ type: 'preparing' }); + expect(streamCloudStatus([msg('a', 'completed'), msg('b', 'queued')])).toEqual({ + type: 'preparing', + }); expect(streamCloudStatus([msg('a', 'accepted'), msg('b', 'queued')])).toEqual({ type: 'ready', }); diff --git a/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts b/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts index 067ee4f557..cb4cebfc67 100644 --- a/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts +++ b/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts @@ -6,6 +6,7 @@ import { type AgentSelection, type AgentSelectionOverride, type SessionMessageIntent, + type TurnFinalization, } from '../execution/types.js'; import { dispatchedKilocodeModelId } from '../persistence/model-utils.js'; import type { CloudMessageFailedPayload } from '../session/message-settlement-outbox.js'; @@ -45,14 +46,16 @@ export type SessionMessageRecordV2 = SessionMessageLifecycle & { readonly intent: ControlSessionMessageIntent; turn?: never; prompt?: never; + finalization?: never; legacyIntentInvalid?: never; }; type LegacySessionMessageRecord = SessionMessageLifecycle & { version?: undefined; - intent?: undefined; + intent?: ControlSessionMessageIntent; turn?: AcceptedExecutionTurn; prompt?: string; + finalization?: TurnFinalization; legacyIntentInvalid?: true; }; @@ -165,11 +168,12 @@ export function matchesSessionMessageReplay( export function freezeLegacyQueuedMessages( messages: readonly SessionMessageRecord[], - defaults?: AgentSelectionOverride + defaults?: AgentSelectionOverride, + defaultFinalization?: TurnFinalization ): SessionMessageRecord[] { return messages.map((message): SessionMessageRecord => { - if (message.state !== 'queued' || message.intent) return message; - const { turn, prompt, legacyIntentInvalid, ...record } = message; + if (message.version === 2 || message.state !== 'queued' || message.intent) return message; + const { turn, prompt, finalization, legacyIntentInvalid, ...record } = message; if (legacyIntentInvalid) return message; const legacyTurn = turn ?? @@ -177,7 +181,15 @@ export function freezeLegacyQueuedMessages( ? { type: 'prompt' as const, messageId: message.messageId, prompt } : undefined); const intent = legacyTurn - ? resolveSessionMessageIntent({ turn: legacyTurn }, defaults) + ? resolveSessionMessageIntent( + { + turn: legacyTurn, + ...(finalization || defaultFinalization + ? { finalization: { ...defaultFinalization, ...finalization } } + : {}), + }, + defaults + ) : undefined; return intent ? { ...record, ...createSessionMessageRecord(intent) } diff --git a/services/cloud-agent-next/src/sandbox-session/terminal-lifecycle.ts b/services/cloud-agent-next/src/sandbox-session/terminal-lifecycle.ts index e986b7c1cb..6a1d8ccff9 100644 --- a/services/cloud-agent-next/src/sandbox-session/terminal-lifecycle.ts +++ b/services/cloud-agent-next/src/sandbox-session/terminal-lifecycle.ts @@ -772,21 +772,6 @@ export function createSandboxTerminalLifecycle(deps: TerminalLifecycleDeps) { record.ptyId ); } - if (metadata?.auth.kiloSessionId) { - try { - await control.request({ - operation: 'session.detach', - session: { - sessionId, - kiloSessionId: metadata.auth.kiloSessionId, - directory: deps.getDirectory(metadata), - }, - payload: {}, - }); - } catch { - return; - } - } } finally { await control.detachSession(sessionId); } diff --git a/services/cloud-agent-next/src/session-service.test.ts b/services/cloud-agent-next/src/session-service.test.ts index bb0f7ac3b4..03d9706073 100644 --- a/services/cloud-agent-next/src/session-service.test.ts +++ b/services/cloud-agent-next/src/session-service.test.ts @@ -1094,6 +1094,47 @@ describe('SessionService.prepareWorkspace', () => { }); }); + it('prepares the persisted shared worktree path instead of a per-chat checkout', async () => { + const worktreeId = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; + const sessionId = 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e'; + const workspacePath = `/workspace/user_test/worktrees/${worktreeId}`; + const base = createMetadata({ upstreamBranch: 'main' }); + const metadata = { + ...base, + identity: { ...base.identity, sessionId }, + workspace: { ...base.workspace, worktreeId, workspacePath }, + } satisfies CloudAgentSessionState; + const session = createSession(false); + const sandbox = createSandbox(session); + + const result = await new SessionService().prepareWorkspace({ + sandbox, + sandboxId: 'ses-abcdef', + userId: 'user_test', + sessionId, + env: createEnv(), + metadata, + kilocodeModel: 'test-model', + }); + + expect(workspaceMocks.setupWorkspace).toHaveBeenCalledWith( + sandbox, + 'user_test', + undefined, + sessionId, + worktreeId + ); + expect(workspaceMocks.cloneGitRepo).toHaveBeenCalledWith( + session, + workspacePath, + 'https://gitlab.com/acme/repo.git', + undefined, + { platform: 'gitlab' } + ); + expect(result.context.workspacePath).toBe(workspacePath); + expect(result.ready.workspacePath).toBe(workspacePath); + }); + it('restores the destination snapshot for clone metadata on first preparation', async () => { const session = createSession(false); const sandbox = createSandbox(session); @@ -2378,6 +2419,21 @@ describe('SessionService.buildWrapperSessionReadyAndPromptRequests', () => { }); } + it('uses the persisted shared checkout when constructing wrapper requests', async () => { + const worktreeId = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; + const workspacePath = `/workspace/user_test/worktrees/${worktreeId}`; + const metadata = createMetadata(); + + const result = await buildPromptWrapperRequests({ + ...metadata, + workspace: { ...metadata.workspace, worktreeId, workspacePath }, + }); + + expect(result.context.workspacePath).toBe(workspacePath); + expect(result.ready.workspacePath).toBe(workspacePath); + expect(result.readyRequest.workspace.workspacePath).toBe(workspacePath); + }); + it('prefers and requires snapshot restore in wrapper readiness for clone metadata', async () => { const result = await buildPromptWrapperRequests(createCloneMetadata()); @@ -4026,6 +4082,32 @@ describe('SessionService session-ingest compatibility', () => { ); }); + it('forwards the validated worktree ID when creating grouped ownership', async () => { + const env = createEnv(); + const service = new SessionService(); + const worktreeId = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; + + await service.createCliSessionViaSessionIngest( + 'ses_12345678901234567890123456', + 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e', + 'oauth/google:1234', + env, + undefined, + 'cloud-agent-web', + undefined, + undefined, + undefined, + worktreeId + ); + + expect(env.SESSION_INGEST.createSessionForCloudAgent).toHaveBeenCalledWith( + expect.objectContaining({ + kiloUserId: 'oauth/google:1234', + cloudAgentWorktreeId: worktreeId, + }) + ); + }); + it('forwards cloneFromKiloSessionId and returns the typed RPC result', async () => { const env = createEnv(); const service = new SessionService(); diff --git a/services/cloud-agent-next/src/session-service.ts b/services/cloud-agent-next/src/session-service.ts index 2d874f151c..3cb68c4038 100644 --- a/services/cloud-agent-next/src/session-service.ts +++ b/services/cloud-agent-next/src/session-service.ts @@ -41,7 +41,11 @@ import { updateGitRemoteUrl, } from './workspace.js'; import { logger, WithLogTags } from './logger.js'; -import type { CreateSessionForCloudAgentResult } from '@kilocode/session-ingest-contracts'; +import type { + CloudAgentWorktreeId, + CloudAgentWorktreeLocation, + CreateSessionForCloudAgentResult, +} from '@kilocode/session-ingest-contracts'; import { timedExec } from './sandbox-timeout-logging.js'; import type { PersistenceEnv, @@ -2069,7 +2073,10 @@ export class SessionService { const devcontainerRequested = metadata.workspace?.devcontainerRequested === true || metadata.devcontainer !== undefined; const resolvedTokens = await this.resolveWorkspaceTokens(env, metadata, sandboxId as SandboxId); - const workspacePath = getSessionWorkspacePath(orgId, userId, sessionId); + const workspacePath = + metadata.workspace?.worktreeId && metadata.workspace.workspacePath + ? metadata.workspace.workspacePath + : getSessionWorkspacePath(orgId, userId, sessionId); const sessionHome = getSessionHomePath(sessionId); const branchName = metadata.workspace?.branchName ?? @@ -2338,7 +2345,10 @@ export class SessionService { logger.setTags({ sessionId, sandboxId, orgId, userId, botId: metadata.identity.botId }); logger.info('Preparing workspace'); - const workspacePath = getSessionWorkspacePath(orgId, userId, sessionId); + const workspacePath = + metadata.workspace?.worktreeId && metadata.workspace.workspacePath + ? metadata.workspace.workspacePath + : getSessionWorkspacePath(orgId, userId, sessionId); const sessionHome = getSessionHomePath(sessionId); const branchName = metadata.workspace?.branchName ?? @@ -2404,7 +2414,7 @@ export class SessionService { // mkdir-idempotent setup + createSession is still required to hand back a // usable ExecutionSession to the caller. if (await this.workspaceHasGit(sandbox, workspacePath)) { - await setupWorkspace(sandbox, userId, orgId, sessionId); + await setupWorkspace(sandbox, userId, orgId, sessionId, metadata.workspace?.worktreeId); const session = await this.buildSessionForContext( sandbox, context, @@ -2472,7 +2482,7 @@ export class SessionService { }); onProgress?.('workspace_setup', 'Setting up workspace…'); - await setupWorkspace(sandbox, userId, orgId, sessionId); + await setupWorkspace(sandbox, userId, orgId, sessionId, metadata.workspace?.worktreeId); const session = await this.buildSessionForContext( sandbox, @@ -2947,7 +2957,9 @@ export class SessionService { createdOnPlatform: string, title?: string, gitUrl?: string, - cloneFromKiloSessionId?: string + cloneFromKiloSessionId?: string, + cloudAgentWorktreeId?: CloudAgentWorktreeId, + cloudAgentWorktreeLocation?: CloudAgentWorktreeLocation ): Promise { try { return await env.SESSION_INGEST.createSessionForCloudAgent({ @@ -2959,6 +2971,8 @@ export class SessionService { title, gitUrl, cloneFromKiloSessionId, + ...(cloudAgentWorktreeId ? { cloudAgentWorktreeId } : {}), + ...(cloudAgentWorktreeLocation ? { cloudAgentWorktreeLocation } : {}), }); } catch (error) { logger diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 33f15ed149..d9ad228556 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -20,6 +20,7 @@ import type * as SandboxIdModule from '../sandbox-id.js'; import type * as SharedSandboxRouteModule from '../shared-sandbox-route.js'; import type * as MessageIdModule from './message-id.js'; import { + assertSessionOperationIdentity, createSessionWithLedger, registerNewSession, sessionCreateIntentFingerprint, @@ -123,7 +124,8 @@ const OPERATION_KEY = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'; const USER_ID = 'test-user-123'; const AUTH_TOKEN = 'test-auth-token'; const CLOUD_AGENT_SESSION_ID = 'agent_12345678-1234-1234-1234-123456789abc'; -const WORKSPACE_SESSION_ID = 'workspace_12345678-1234-1234-1234-123456789abc'; +const WORKSPACE_SESSION_ID = 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e'; +const WORKTREE_ID = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; const KILO_SESSION_ID = 'ses_12345678901234567890123456'; const INITIAL_MESSAGE_ID = 'msg_018f1e2d3c4bAbCdEfGhIjKlMn'; const ROW_ID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; @@ -257,6 +259,50 @@ function settleOptions(index: number): SettleOptions | undefined { return call[1] as SettleOptions | undefined; } +describe('assertSessionOperationIdentity', () => { + it('accepts matching first-create and sibling operation identities', () => { + expect(() => + assertSessionOperationIdentity(makeLedgerRow(), { + userId: USER_ID, + intent: 'create_cloud', + }) + ).not.toThrow(); + + expect(() => + assertSessionOperationIdentity( + makeLedgerRow({ + intent: 'create_worktree_chat', + organization_id: 'organization-1', + resource_key: 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e', + }), + { + userId: USER_ID, + intent: 'create_worktree_chat', + organizationId: 'organization-1', + resourceKey: 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e', + } + ) + ).not.toThrow(); + }); + + it.each([ + ['domain', { domain: 'other' }], + ['owner', { kilo_user_id: 'another-user' }], + ['intent', { intent: 'create_worktree_chat' }], + ['organization', { organization_id: 'organization-1' }], + ['resource', { resource_key: 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e' }], + ] as const)('rejects a mismatched %s before operation handling', (_field, overrides) => { + expect(() => + assertSessionOperationIdentity(makeLedgerRow(overrides), { + userId: USER_ID, + intent: 'create_cloud', + }) + ).toThrow( + expect.objectContaining({ code: 'CONFLICT', message: 'operation_key_reuse_mismatch' }) + ); + }); +}); + describe('createSessionWithLedger admission ladder', () => { beforeEach(() => { vi.clearAllMocks(); @@ -500,6 +546,282 @@ describe('createSessionWithLedger admission ladder', () => { ); }); + it.each([ + 'admitted', + 'duplicate_settled', + 'duplicate_in_flight', + 'duplicate_reconcile_in_progress', + 'takeover', + 'duplicate_reconcile_pending', + ] as const)( + 'rejects a sibling-intent collision before handling %s admission', + async admission => { + admitOperationMock.mockResolvedValueOnce({ + admission, + row: makeLedgerRow({ intent: 'create_worktree_chat', resource_key: WORKTREE_ID }), + }); + const doStub = makeDoStub(); + + await expect(runCreate(makeContext(doStub))).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_key_reuse_mismatch', + }); + + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.getMessageResult).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + } + ); + + it('derives the first browser worktree from its workspace ID before ownership and admission', async () => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + const request = makeRequest({ + repository: { type: 'github', repo: 'acme/repo', branch: 'main' }, + finalization: { autoCommit: true, condenseOnComplete: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }); + + const result = await runCreate(ctx, request); + + expect(createCliSessionMock).toHaveBeenCalledWith( + KILO_SESSION_ID, + WORKSPACE_SESSION_ID, + USER_ID, + expect.any(Object), + undefined, + 'cloud-agent-web', + expect.any(String), + 'https://github.com/acme/repo', + undefined, + WORKTREE_ID, + { sandboxId: 'sb-test-123', provider: 'cloudflare' } + ); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + repository: expect.objectContaining({ branch: 'main' }), + finalization: { autoCommit: false, condenseOnComplete: true }, + workspace: expect.objectContaining({ + worktreeId: WORKTREE_ID, + workspacePath: `/workspace/${USER_ID}/worktrees/${WORKTREE_ID}`, + sandboxId: 'sb-test-123', + sandboxProvider: 'cloudflare', + }), + }) + ); + expect(recordOperationProgressMock).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + ROW_ID, + expect.objectContaining({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + createIntentFingerprint: await sessionCreateIntentFingerprint({ + ...request, + finalization: { autoCommit: false, condenseOnComplete: true }, + }), + }) + ); + expect(result).toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }); + }); + + it('replays browser creates using their effective disabled auto-commit fingerprint', async () => { + const request = makeRequest({ + finalization: { autoCommit: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }); + const effectiveRequest = { ...request, finalization: { autoCommit: false } }; + admitOperationMock.mockResolvedValue({ + admission: 'duplicate_settled', + row: makeLedgerRow({ + status: 'completed', + canonical_result: { + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: + await sessionCreateIntentFingerprint(effectiveRequest), + }, + }), + }); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + + await expect(runCreate(ctx, request)).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + await expect(runCreate(ctx, effectiveRequest)).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + }); + + it('keeps organization worktrees scoped to arbitrary OAuth owner IDs', async () => { + const organizationId = '33c8c114-791f-463c-9f7f-fe74b284cfcf'; + const userId = 'oauth/google:1234'; + admitOperationMock.mockResolvedValueOnce({ + admission: 'admitted', + row: makeLedgerRow({ kilo_user_id: userId, organization_id: organizationId }), + }); + const doStub = makeDoStub(); + const ctx = { ...makeContext(doStub), userId }; + ctx.env.CONTROL_PLANE_IDS = organizationId; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + + await runCreate( + ctx, + makeRequest({ + finalization: { autoCommit: true }, + options: { + operationKey: OPERATION_KEY, + kilocodeOrganizationId: organizationId, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }) + ); + + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ userId, orgId: organizationId }), + workspace: expect.objectContaining({ + worktreeId: WORKTREE_ID, + workspacePath: `/workspace/${organizationId}/oauth-google-1234/worktrees/${WORKTREE_ID}`, + }), + finalization: { autoCommit: false }, + }) + ); + }); + + it.each([ + ['missing provenance', undefined, 'cloud-agent-web'], + ['mobile provenance', 'mobile', 'cloud-agent-web'], + ['automation origin', 'browser', 'scheduled'], + ['integration origin', 'browser', 'slack'], + ] as const)( + 'leaves %s control-plane sessions ungrouped with their requested auto-commit', + async (_label, clientProvenance, createdOnPlatform) => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + + await runCreate( + ctx, + makeRequest({ + finalization: { autoCommit: true }, + options: { operationKey: OPERATION_KEY, createdOnPlatform, clientProvenance }, + }) + ); + + expect(createCliSessionMock.mock.calls[0]).toHaveLength(9); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + finalization: { autoCommit: true }, + workspace: expect.not.objectContaining({ + worktreeId: expect.anything(), + workspacePath: expect.anything(), + }), + }) + ); + } + ); + + it.each([ + ['devcontainer', { devcontainer: true }, undefined], + ['bot identity', undefined, 'bot_123'], + ] as const)( + 'leaves %s browser sessions ungrouped with their requested auto-commit', + async (_label, runtime, botId) => { + const doStub = makeDoStub(); + const ctx = { ...makeContext(doStub), ...(botId ? { botId } : {}) }; + ctx.env.CONTROL_PLANE_IDS = '*'; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + + await runCreate( + ctx, + makeRequest({ + runtime, + finalization: { autoCommit: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }) + ); + + expect(createCliSessionMock.mock.calls[0]).toHaveLength(9); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + finalization: { autoCommit: true }, + workspace: expect.not.objectContaining({ worktreeId: expect.anything() }), + }) + ); + } + ); + + it('does not group browser requests without a stable request operation key', async () => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + + await runCreate( + ctx, + makeRequest({ + finalization: { autoCommit: true }, + options: { createdOnPlatform: 'cloud-agent-web', clientProvenance: 'browser' }, + }) + ); + + expect(createCliSessionMock.mock.calls[0]).toHaveLength(9); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ finalization: { autoCommit: true } }) + ); + }); + + it('keeps browser requests on the legacy plane ungrouped', async () => { + const doStub = makeDoStub(); + + await runCreate( + makeContext(doStub), + makeRequest({ + finalization: { autoCommit: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }) + ); + + expect(createCliSessionMock.mock.calls[0]).toHaveLength(9); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ finalization: { autoCommit: true } }) + ); + }); + it('passes a normalized GitHub repository URL to the session-ingest create call', async () => { const ctx = makeContext(makeDoStub()); @@ -1597,6 +1919,325 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { }); }); +describe('createSessionWithLedger grouped first-create ownership reconciliation', () => { + const sandboxId = 'ses-0123456789abcdef'; + const sharedSandboxId = 'usr-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + + beforeEach(() => { + vi.clearAllMocks(); + getPgDbMock.mockReturnValue(makeDb([[{ email: 'test@example.com' }]])); + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + generateKiloSessionIdMock.mockReturnValue(KILO_SESSION_ID); + generateSandboxRoutingTargetMock.mockResolvedValue({ kind: 'isolated', sandboxId }); + admitOperationMock.mockResolvedValue({ admission: 'admitted', row: makeLedgerRow() }); + settleOperationMock.mockResolvedValue({ settled: true }); + markReconcilePendingMock.mockResolvedValue({}); + recordOperationProgressMock.mockResolvedValue(undefined); + createCliSessionMock.mockReset().mockResolvedValue(undefined); + recordSessionFailureMock.mockResolvedValue(undefined); + }); + + function request(overrides: Partial = {}): SessionCreateRequest { + return makeRequest({ + finalization: { autoCommit: true, condenseOnComplete: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + ...overrides, + }); + } + + function context(doStub: ReturnType): SessionRegistrationContext { + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + return ctx; + } + + function ownershipRow(overrides: Record = {}): Record { + return { + sessionId: KILO_SESSION_ID, + cloudAgentSessionId: WORKSPACE_SESSION_ID, + cloudAgentSessionScopeId: WORKSPACE_SESSION_ID, + organizationId: null, + worktreeId: WORKTREE_ID, + ...overrides, + }; + } + + async function canonicalProgress( + input: SessionCreateRequest, + overrides: Record = {} + ): Promise> { + const effective = { + ...input, + finalization: { ...input.finalization, autoCommit: false }, + }; + return { + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + initialMessageId: INITIAL_MESSAGE_ID, + sandboxId, + sandboxProvider: 'cloudflare', + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint(effective), + ...overrides, + }; + } + + it('recovers committed ownership after a lost ingest response without reallocating or duplicating the initial turn', async () => { + const input = request(); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + let ownershipCommitted = false; + createCliSessionMock.mockImplementationOnce(async () => { + ownershipCommitted = true; + throw new Error('ownership response lost after commit'); + }); + + await expect(runCreate(ctx, input)).rejects.toThrow('ownership response lost after commit'); + + expect(ownershipCommitted).toBe(true); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { rowId: ROW_ID }); + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + + const storedProgress = Object.assign( + {}, + ...recordOperationProgressMock.mock.calls.map(call => call[2]) + ); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ status: 'reconcile_pending', canonical_result: storedProgress }), + }); + getPgDbMock.mockReturnValue(makeDb([[ownershipRow()], [{ email: 'test@example.com' }]])); + const initialTurn = input.initialTurn; + if (!initialTurn || initialTurn.type !== 'prompt') throw new Error('Expected a prompt'); + + const result = await runCreate(ctx, { + ...input, + initialTurn: { ...initialTurn, id: 'msg_retry_original_identity' }, + }); + + expect(result).toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + expect(createCliSessionMock).toHaveBeenCalledTimes(1); + expect(generateSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateSandboxRoutingTargetMock).toHaveBeenCalledTimes(1); + expect(createSessionReportMock).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ sessionId: WORKSPACE_SESSION_ID, userId: USER_ID }), + auth: expect.objectContaining({ kiloSessionId: KILO_SESSION_ID }), + finalization: { autoCommit: false, condenseOnComplete: true }, + workspace: expect.objectContaining({ + sandboxId, + sandboxProvider: 'cloudflare', + worktreeId: WORKTREE_ID, + workspacePath: `/workspace/${USER_ID}/worktrees/${WORKTREE_ID}`, + }), + message: { + initialTurn: expect.objectContaining({ + messageId: INITIAL_MESSAGE_ID, + prompt: 'Build the feature', + }), + }, + }) + ); + expect(settleOperationMock).toHaveBeenCalledTimes(1); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'completed', + canonicalResult: { + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }, + }) + ); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + }); + + it('retries an uncommitted ownership write with the original workspace, session, worktree, and sandbox', async () => { + const input = request(); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + createCliSessionMock.mockRejectedValueOnce(new Error('ownership request interrupted')); + + await expect(runCreate(ctx, input)).rejects.toThrow('ownership request interrupted'); + expect(markReconcilePendingMock).toHaveBeenCalledTimes(1); + expect(settleOperationMock).not.toHaveBeenCalled(); + + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input), + }), + }); + getPgDbMock.mockReturnValue(makeDb([[], [{ email: 'test@example.com' }]])); + createCliSessionMock.mockResolvedValueOnce({ + status: 'ready', + clone: { sessionId: KILO_SESSION_ID, copiedItemCount: 0 }, + }); + + await expect(runCreate(ctx, input)).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + + expect(createCliSessionMock).toHaveBeenCalledTimes(2); + for (const call of createCliSessionMock.mock.calls) { + expect(call[0]).toBe(KILO_SESSION_ID); + expect(call[1]).toBe(WORKSPACE_SESSION_ID); + expect(call[2]).toBe(USER_ID); + expect(call[9]).toBe(WORKTREE_ID); + } + expect(generateSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateSandboxRoutingTargetMock).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + label: 'isolated Vercel sandbox', + storedSandboxId: 'ses-fedcba9876543210', + provider: 'vercel' as const, + route: undefined, + }, + { + label: 'shared Cloudflare sandbox route', + storedSandboxId: sharedSandboxId, + provider: 'cloudflare' as const, + route: { kind: 'shared' as const, routeKey: sharedSandboxId }, + }, + ])( + 'restores the recorded $label without recomputing routing', + async ({ storedSandboxId, provider, route }) => { + const input = request(); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + const legacyGet = vi.fn(() => doStub); + const controlGet = vi.fn(() => doStub); + ctx.env.CLOUD_AGENT_SESSION = { + idFromName: vi.fn((name: string) => ({ toString: () => name })), + get: legacyGet, + } as unknown as Env['CLOUD_AGENT_SESSION']; + ctx.env.SANDBOX_SESSION = { + idFromName: vi.fn((name: string) => ({ toString: () => name })), + get: controlGet, + } as unknown as Env['SANDBOX_SESSION']; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input, { + sandboxId: storedSandboxId, + sandboxProvider: provider, + ...(route ? { sandboxRoute: route } : {}), + }), + }), + }); + getPgDbMock.mockReturnValue(makeDb([[ownershipRow()], [{ email: 'test@example.com' }]])); + + await expect(runCreate(ctx, input)).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + workspace: expect.objectContaining({ + sandboxId: storedSandboxId, + sandboxProvider: provider, + worktreeId: WORKTREE_ID, + ...(route ? { sandboxRoute: route } : {}), + }), + }) + ); + expect(controlGet).toHaveBeenCalled(); + expect(legacyGet).not.toHaveBeenCalled(); + expect(generateSandboxRoutingTargetMock).not.toHaveBeenCalled(); + expect(generateSessionIdMock).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['workspace', { cloudAgentSessionId: 'workspace_00000000-0000-4000-8000-000000000000' }], + ['ingest scope', { cloudAgentSessionScopeId: null }], + ['organization', { organizationId: 'another-organization' }], + ['worktree', { worktreeId: 'worktree_00000000-0000-4000-8000-000000000000' }], + ] as const)('rejects committed ownership with a mismatched %s', async (_label, overrides) => { + const input = request(); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input), + }), + }); + getPgDbMock.mockReturnValue(makeDb([[ownershipRow(overrides)]])); + + await expect(runCreate(ctx, input)).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'operation_key_reuse_mismatch', + }); + + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + }); + + it('rejects a changed first-chat intent before recovering committed ownership', async () => { + const input = request(); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + createCliSessionMock.mockRejectedValueOnce(new Error('ownership response lost after commit')); + + await expect(runCreate(ctx, input)).rejects.toThrow('ownership response lost after commit'); + + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input), + }), + }); + getPgDbMock.mockReturnValue(makeDb([[ownershipRow()]])); + + await expect( + runCreate(ctx, { + ...input, + initialTurn: { type: 'prompt', prompt: 'Create a different session' }, + }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'session_creation_failed', + }); + + expect(createCliSessionMock).toHaveBeenCalledTimes(1); + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + }); +}); + describe('createSessionWithLedger changed-intent rejection', () => { beforeEach(() => { vi.clearAllMocks(); @@ -1609,7 +2250,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { }); admitOperationMock.mockResolvedValue({ admission: 'admitted', - row: makeLedgerRow({}), + row: makeLedgerRow({ organization_id: ORIGINAL_OPTIONS.kilocodeOrganizationId }), }); settleOperationMock.mockResolvedValue({ settled: true }); markReconcilePendingMock.mockResolvedValue({}); @@ -1632,6 +2273,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { async function completedRowFor(request: SessionCreateRequest): Promise { return makeLedgerRow({ status: 'completed', + organization_id: request.options?.kilocodeOrganizationId ?? null, canonical_result: { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, @@ -1642,14 +2284,18 @@ describe('createSessionWithLedger changed-intent rejection', () => { } /** Asserts the request is rejected with the typed error and no effect runs. */ - async function expectRejectedWithoutEffects(request: SessionCreateRequest) { + async function expectRejectedWithoutEffects( + request: SessionCreateRequest, + identityConflict = false + ) { const doStub = makeDoStub(); const ctx = makeContext(doStub); - await expect(runCreate(ctx, request)).rejects.toMatchObject({ - code: 'BAD_REQUEST', - message: 'session_creation_failed', - }); + await expect(runCreate(ctx, request)).rejects.toMatchObject( + identityConflict + ? { code: 'CONFLICT', message: 'operation_key_reuse_mismatch' } + : { code: 'BAD_REQUEST', message: 'session_creation_failed' } + ); // Rejected before replay, reconciliation, or any external effect. expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); @@ -1698,6 +2344,27 @@ describe('createSessionWithLedger changed-intent rejection', () => { expect(settleOperationMock).not.toHaveBeenCalled(); }); + it('preserves absent and mobile create fingerprints while distinguishing browser provenance', async () => { + const withoutProvenance = makeRequest({ + options: { operationKey: OPERATION_KEY, createdOnPlatform: 'cloud-agent-web' }, + }); + const mobile = { + ...withoutProvenance, + options: { ...withoutProvenance.options, clientProvenance: 'mobile' as const }, + }; + const browser = { + ...withoutProvenance, + options: { ...withoutProvenance.options, clientProvenance: 'browser' as const }, + }; + + expect(await sessionCreateIntentFingerprint(mobile)).toBe( + await sessionCreateIntentFingerprint(withoutProvenance) + ); + expect(await sessionCreateIntentFingerprint(browser)).not.toBe( + await sessionCreateIntentFingerprint(withoutProvenance) + ); + }); + /** * One case per create input covered by the intent fingerprint. Each case * changes exactly one input on the retry; every one must reject before any @@ -1707,6 +2374,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { name: string; admission?: 'duplicate_settled' | 'takeover' | 'duplicate_reconcile_pending'; original?: SessionCreateRequest; + identityConflict?: boolean; retry: SessionCreateRequest; }> = [ { @@ -1748,6 +2416,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { }, { name: 'the organization', + identityConflict: true, retry: makeRequest({ options: { operationKey: OPERATION_KEY, kilocodeOrganizationId: 'org-xyz' }, }), @@ -1892,7 +2561,8 @@ describe('createSessionWithLedger changed-intent rejection', () => { it.each(changedIntentCases)( 'rejects a same-key retry that changes $name', - async ({ admission = 'duplicate_settled', original, retry }) => { + async ({ admission = 'duplicate_settled', original, identityConflict, retry }) => { + const originalIntent = original ?? originalRequest(); const status = admission === 'duplicate_settled' ? 'completed' @@ -1903,17 +2573,17 @@ describe('createSessionWithLedger changed-intent rejection', () => { admission, row: makeLedgerRow({ status, + organization_id: originalIntent.options?.kilocodeOrganizationId ?? null, canonical_result: { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, - [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint( - original ?? originalRequest() - ), + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: + await sessionCreateIntentFingerprint(originalIntent), }, }), }); - await expectRejectedWithoutEffects(retry); + await expectRejectedWithoutEffects(retry, identityConflict); } ); @@ -1996,6 +2666,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { admission: 'duplicate_settled', row: makeLedgerRow({ status: 'completed', + organization_id: ORIGINAL_OPTIONS.kilocodeOrganizationId, canonical_result: { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 0fbe85d242..6970cf71a3 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -28,6 +28,10 @@ import { type SettleOperationInput, } from '@kilocode/db/operation-ledger'; import type { OperationLedgerRow } from '@kilocode/db/schema'; +import { + cloudAgentWorktreeIdSchema, + type CloudAgentWorktreeId, +} from '@kilocode/session-ingest-contracts'; import { normalizeGitUrl } from '@kilocode/worker-utils'; import type { Env, SandboxId } from '../types.js'; @@ -43,6 +47,7 @@ import { resolveSessionStub } from '../sandbox-session/session-stub.js'; import { getPgDb } from '../db/pg.js'; import { generateSessionId, SessionService } from '../session-service.js'; import { sessionPlaneForNewOwner } from '../session-plane.js'; +import { getWorktreeWorkspacePath } from '../workspace.js'; import { createCloudAgentSessionReport, recordCloudAgentSandboxIdentity, @@ -113,6 +118,7 @@ export type SessionRegistrationResult = { sandboxId: SandboxId; sandboxRoute?: SharedSandboxRouteMetadata; sandboxProvider: SandboxSelection['provider']; + worktreeId?: CloudAgentWorktreeId; /** * Canonical initial turn reserved for a later legacy initiation request. * Omitted for a clone-only create, which has no synthetic initial turn. @@ -221,6 +227,26 @@ export type SessionLedgerCreateOptions = { startedAt: number; }; +export function assertSessionOperationIdentity( + row: OperationLedgerRow, + expected: { + userId: string; + intent: 'create_cloud' | 'create_worktree_chat'; + organizationId?: string | null; + resourceKey?: string | null; + } +): void { + if ( + row.domain !== 'session' || + row.kilo_user_id !== expected.userId || + row.intent !== expected.intent || + row.organization_id !== (expected.organizationId ?? null) || + row.resource_key !== (expected.resourceKey ?? null) + ) { + throw new TRPCError({ code: 'CONFLICT', message: 'operation_key_reuse_mismatch' }); + } +} + /** Lease for the `admitted` create claim (retry window). */ const SESSION_CREATE_LEDGER_LEASE_SECONDS = 120; @@ -429,6 +455,39 @@ function computeCredentialContainment( }; } +function isEligibleFirstWorktreeSession( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext +): boolean { + return ( + input.options?.clientProvenance === 'browser' && + input.options.createdOnPlatform === 'cloud-agent-web' && + input.options.operationKey !== undefined && + input.initialTurn !== undefined && + input.clone === undefined && + input.runtime?.devcontainer !== true && + ctx.botId === undefined && + sessionPlaneForNewOwner(ctx.env, { + userId: ctx.userId, + orgId: input.options.kilocodeOrganizationId, + }) === 'control' + ); +} + +function effectiveSessionRegistrationInput( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + operationKey: string +): SessionRegistrationInput { + if (!isEligibleFirstWorktreeSession(input, ctx) || input.options?.operationKey !== operationKey) { + return input; + } + return { + ...input, + finalization: { ...input.finalization, autoCommit: false }, + }; +} + async function allocateNewSession( input: SessionRegistrationInput, ctx: SessionRegistrationContext, @@ -446,6 +505,14 @@ async function allocateNewSession( input.clone && !initialTurn && cloudAgentSessionId.startsWith('agent_') ? new Date().toISOString() : undefined; + const worktreeId = + ledger && + isEligibleFirstWorktreeSession(input, ctx) && + cloudAgentSessionId.startsWith('workspace_') + ? cloudAgentWorktreeIdSchema.parse( + `worktree_${cloudAgentSessionId.slice('workspace_'.length)}` + ) + : undefined; const createdOnPlatform = input.options?.createdOnPlatform ?? 'cloud-agent'; try { @@ -564,7 +631,7 @@ async function allocateNewSession( const cloneFromKiloSessionId = input.clone?.cloneFromKiloSessionId; let ingestResult: Awaited>; try { - ingestResult = await sessionService.createCliSessionViaSessionIngest( + const ingestArgs: Parameters = [ kiloSessionId, cloudAgentSessionId, ctx.userId, @@ -573,8 +640,12 @@ async function allocateNewSession( createdOnPlatform, defaultTitle, canonicalRepositoryUrl, - cloneFromKiloSessionId - ); + cloneFromKiloSessionId, + ]; + if (worktreeId) { + ingestArgs.push(worktreeId, { sandboxId, provider: sandboxProvider }); + } + ingestResult = await sessionService.createCliSessionViaSessionIngest(...ingestArgs); } catch (error) { await recordPostSetupFailure(() => recordCloudAgentSessionFailure( @@ -585,6 +656,10 @@ async function allocateNewSession( ctx.env ) ); + if (worktreeId && ledger) { + await ledger.onTransportFailure(); + throw error; + } if (cloneFromKiloSessionId) { // A clone create must never settle the row failed on a thrown ingest // outcome: the clone may have committed. Keep the row reconcile-pending @@ -640,6 +715,7 @@ async function allocateNewSession( sandboxId, sandboxRoute, sandboxProvider, + ...(worktreeId ? { worktreeId } : {}), initialTurn, reportingCreatedAt, credentialContainment, @@ -666,7 +742,7 @@ async function allocateNewSession( } /** - * Rebuilds a clone allocation from the ledger progress recorded by the + * Rebuilds an allocation from the ledger progress recorded by the * original create, so a same-key retry can resume the stored destination IDs * instead of allocating fresh ones. When the create carried an initial turn it * keeps the stored `initialMessageId` (the retry's own message id is never @@ -674,7 +750,7 @@ async function allocateNewSession( * fingerprint already proved unchanged. A clone-only create recorded no * `initialMessageId` and rebuilds no initial turn. */ -function rebuildCloneAllocation( +function rebuildRecordedSessionAllocation( input: SessionRegistrationInput, ctx: SessionRegistrationContext, row: OperationLedgerRow @@ -691,6 +767,7 @@ function rebuildCloneAllocation( .datetime({ offset: true }) .optional() .safeParse(canonical.reportingCreatedAt); + const worktreeCreate = isEligibleFirstWorktreeSession(input, ctx); if ( !reportingCreatedAt.success || @@ -705,6 +782,20 @@ function rebuildCloneAllocation( throw creationInProgressError(); } + let worktreeId: CloudAgentWorktreeId | undefined; + if (worktreeCreate) { + if (!cloudAgentSessionId.startsWith('workspace_')) { + throw creationInProgressError(); + } + const parsed = cloudAgentWorktreeIdSchema.safeParse( + `worktree_${cloudAgentSessionId.slice('workspace_'.length)}` + ); + if (!parsed.success) { + throw creationInProgressError(); + } + worktreeId = parsed.data; + } + let route: SharedSandboxRouteMetadata | undefined; if (sandboxRoute !== undefined) { if ( @@ -736,6 +827,7 @@ function rebuildCloneAllocation( sandboxId: sandboxId as SandboxId, sandboxRoute: route, sandboxProvider, + ...(worktreeId ? { worktreeId } : {}), initialTurn, reportingCreatedAt: !initialTurn && cloudAgentSessionId.startsWith('agent_') @@ -811,6 +903,16 @@ function buildSessionRegistrationCommand( sandboxId: allocation.sandboxId, sandboxProvider: allocation.sandboxProvider, shallow: input.options?.shallow, + ...(allocation.worktreeId + ? { + worktreeId: allocation.worktreeId, + workspacePath: getWorktreeWorkspacePath( + input.options?.kilocodeOrganizationId, + ctx.userId, + allocation.worktreeId + ), + } + : {}), ...(allocation.sandboxRoute ? { sandboxRoute: allocation.sandboxRoute } : {}), credentialContainment: allocation.credentialContainment, ...(input.runtime?.devcontainer ? { devcontainerRequested: true } : {}), @@ -1064,7 +1166,7 @@ async function registerAllocatedSession( // a first attempt allocates a fresh random `cloudAgentSessionId` // (`generateSessionId`), so only `withDORetry` can reach that DO again; a // same-operationKey replay rebuilds the ID from `row.canonical_result` in - // `rebuildCloneAllocation`, so the DO belongs to the same ledger row. Keep that + // `rebuildRecordedSessionAllocation`, so the DO belongs to the same ledger row. Keep that // property if either entry changes; otherwise an unrelated conflict would be // swallowed as success here. logger.info('Session registered for lazy preparation'); @@ -1260,6 +1362,7 @@ export async function sessionCreateIntentFingerprint( ? { kilocodeOrganizationId: input.options.kilocodeOrganizationId || undefined, createdOnPlatform: input.options.createdOnPlatform || undefined, + clientProvenance: input.options.clientProvenance === 'browser' ? 'browser' : undefined, shallow: input.options.shallow === true ? true : undefined, } : undefined, @@ -1300,28 +1403,35 @@ export async function createSessionWithLedger( options: SessionLedgerCreateOptions ): Promise { assertSupportedSandboxAllocation(input, ctx, { billingOrigin: options.billingOrigin }); + const effectiveInput = effectiveSessionRegistrationInput(input, ctx, options.operationKey); const db = getPgDb(ctx.env); const admission = await admitOperation(db, { userId: ctx.userId, - orgId: input.options?.kilocodeOrganizationId, + orgId: effectiveInput.options?.kilocodeOrganizationId, domain: 'session', intent: 'create_cloud', operationKey: options.operationKey, taxonomy: 'safe-retry', leaseSeconds: SESSION_CREATE_LEDGER_LEASE_SECONDS, }); + assertSessionOperationIdentity(admission.row, { + userId: ctx.userId, + intent: 'create_cloud', + organizationId: effectiveInput.options?.kilocodeOrganizationId, + resourceKey: null, + }); switch (admission.admission) { case 'admitted': - return executeLedgerCreate(input, ctx, options, db, admission.row, 'new'); + return executeLedgerCreate(effectiveInput, ctx, options, db, admission.row, 'new'); case 'duplicate_settled': - return replaySettledCreate(admission.row, input); + return replaySettledCreate(admission.row, effectiveInput); case 'duplicate_in_flight': case 'duplicate_reconcile_in_progress': throw creationInProgressError(); case 'takeover': case 'duplicate_reconcile_pending': - return reconcileLedgerCreate(input, ctx, options, db, admission.row); + return reconcileLedgerCreate(effectiveInput, ctx, options, db, admission.row); } } @@ -1457,9 +1567,21 @@ async function findCliSessionOwnershipRow( db: WorkerDb, userId: string, kiloSessionId: string -): Promise<{ sessionId: string } | null> { +): Promise<{ + sessionId: string; + cloudAgentSessionId: string | null; + cloudAgentSessionScopeId: string | null; + organizationId: string | null; + worktreeId: string | null; +} | null> { const [row] = await db - .select({ sessionId: cli_sessions_v2.session_id }) + .select({ + sessionId: cli_sessions_v2.session_id, + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + organizationId: cli_sessions_v2.organization_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + }) .from(cli_sessions_v2) .where( and(eq(cli_sessions_v2.kilo_user_id, userId), eq(cli_sessions_v2.session_id, kiloSessionId)) @@ -1581,7 +1703,7 @@ async function resumeCloneCreate( } // `ready` continues. - const allocation = rebuildCloneAllocation(input, ctx, row); + const allocation = rebuildRecordedSessionAllocation(input, ctx, row); const billingOrigin = { billingOrigin: options.billingOrigin }; const result = input.initialTurn === undefined @@ -1594,6 +1716,75 @@ async function resumeCloneCreate( }; } +async function resumeFirstWorktreeCreate( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + options: SessionLedgerCreateOptions, + db: WorkerDb, + row: OperationLedgerRow, + allocation: NewSessionAllocation, + ownershipExists: boolean +): Promise { + const worktreeId = allocation.worktreeId; + if (!worktreeId) { + throw creationInProgressError(); + } + + const hooks = await buildLedgerHooks(input, ctx, options, db, row, 'takeover'); + if (!ownershipExists) { + let ingestResult: Awaited>; + try { + ingestResult = await allocation.sessionService.createCliSessionViaSessionIngest( + allocation.kiloSessionId, + allocation.cloudAgentSessionId, + ctx.userId, + ctx.env, + input.options?.kilocodeOrganizationId, + input.options?.createdOnPlatform ?? 'cloud-agent', + `New session - ${new Date().toISOString()}`, + deriveCanonicalRepositoryUrl(input.repository), + undefined, + worktreeId, + { sandboxId: allocation.sandboxId, provider: allocation.sandboxProvider } + ); + } catch (error) { + await recordPostSetupFailure(() => + recordCloudAgentSessionFailure( + { + cloudAgentSessionId: allocation.cloudAgentSessionId, + failure: { stage: 'transport', code: 'do_rpc_outcome_unknown' }, + }, + ctx.env + ) + ); + await hooks.onTransportFailure(); + throw error; + } + + if (ingestResult?.status === 'rejected') { + await hooks.onFailure('ownership_row', ingestResult.code); + throw new TRPCError({ code: 'BAD_REQUEST', message: ingestResult.code }); + } + if (ingestResult?.status === 'in_progress') { + await hooks.onTransportFailure(); + throw creationInProgressError(); + } + } + + const result = await registerAndAdmitInitialTurn( + input, + ctx, + { billingOrigin: options.billingOrigin }, + allocation, + hooks + ); + return { + cloudAgentSessionId: result.cloudAgentSessionId, + kiloSessionId: result.kiloSessionId, + replayed: true, + }; +} + /** * Reads DO metadata with the standard retry wrapper. A transport failure keeps * the row reconcile-pending (`CONFLICT creation_in_progress`) because the DO @@ -1658,8 +1849,22 @@ async function reconcileLedgerCreate( return executeLedgerCreate(input, ctx, options, db, row, 'takeover'); } + const worktreeAllocation = isEligibleFirstWorktreeSession(input, ctx) + ? rebuildRecordedSessionAllocation(input, ctx, row) + : undefined; + // (b) Ownership row lookup by kiloSessionId. const ownership = await findCliSessionOwnershipRow(db, ctx.userId, ids.kiloSessionId); + if ( + ownership && + worktreeAllocation && + (ownership.cloudAgentSessionId !== ids.cloudAgentSessionId || + ownership.cloudAgentSessionScopeId !== ids.cloudAgentSessionId || + ownership.organizationId !== (input.options?.kilocodeOrganizationId ?? null) || + ownership.worktreeId !== worktreeAllocation.worktreeId) + ) { + throw new TRPCError({ code: 'CONFLICT', message: 'operation_key_reuse_mismatch' }); + } if (!ownership) { // A tombstoned destination must never be resumed: the clone was explicitly // rejected and its IDs are dead, so fall through to a fresh allocation. @@ -1671,6 +1876,9 @@ async function reconcileLedgerCreate( if (input.clone?.cloneFromKiloSessionId) { return resumeCloneCreate(input, ctx, options, db, row, ids); } + if (worktreeAllocation) { + return resumeFirstWorktreeCreate(input, ctx, options, db, row, worktreeAllocation, false); + } // Old non-clone create: the ownership row is absent, so the DO never // registered. Keep the existing fresh allocation. Remove this path only // once every non-clone create is tombstoned on rejection (out of scope). @@ -1690,6 +1898,9 @@ async function reconcileLedgerCreate( // (c) Ownership present → read the DO state. if (!(await readSessionMetadata(ctx, ids.cloudAgentSessionId))) { + if (worktreeAllocation) { + return resumeFirstWorktreeCreate(input, ctx, options, db, row, worktreeAllocation, true); + } // A single null metadata read is NOT proof of no registration (a transient // read or a pending deletion intent can hide committed metadata), so read // once more before treating the ownership row as stale. diff --git a/services/cloud-agent-next/src/session/session-requests.ts b/services/cloud-agent-next/src/session/session-requests.ts index dd38afb150..c7798df89f 100644 --- a/services/cloud-agent-next/src/session/session-requests.ts +++ b/services/cloud-agent-next/src/session/session-requests.ts @@ -67,6 +67,7 @@ export type SessionCreateRequest = { callbackTarget?: CallbackTarget; kilocodeOrganizationId?: string; createdOnPlatform?: string; + clientProvenance?: 'browser' | 'mobile'; shallow?: boolean; /** Stable per-user-intent UUID; the handler admits it only with `autoInitiate` true. */ operationKey?: string; diff --git a/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts b/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts index 9d9289d637..6b8d4a6ae0 100644 --- a/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts +++ b/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts @@ -18,7 +18,13 @@ export const SANDBOX_CONTROL_ATTACH_TIMEOUT_MS = 8 * 60_000; export const SANDBOX_CONTROL_EXECUTION_TIMEOUT_MS = 60 * 60_000; -export const SANDBOX_OPERATIONS = ['sandbox.hello', 'sandbox.status', 'sandbox.shutdown'] as const; +export const SANDBOX_OPERATIONS = [ + 'sandbox.hello', + 'sandbox.status', + 'sandbox.shutdown', + 'worktree.prepareDeletion', + 'worktree.delete', +] as const; export const SESSION_OPERATIONS = [ 'session.attach', @@ -193,6 +199,31 @@ export const sandboxShutdownResultSchema = z }) .strict(); +export const worktreeDeletePayloadSchema = z + .object({ + worktreeId: z.templateLiteral(['worktree_', z.uuid()]), + directory: z.string().min(1).max(1024), + sessionIds: z.array(z.string().startsWith('ses_').length(30)), + }) + .strict(); + +export const worktreePrepareDeletionResultSchema = z + .object({ + prepared: z.literal(true), + sessionIds: z.array(z.string().startsWith('ses_').length(30)), + }) + .strict(); + +export const worktreeDeleteResultSchema = z + .object({ + deleted: z.literal(true), + sessionIds: z.array(z.string().startsWith('ses_').length(30)), + }) + .strict(); + +export type WorktreeDeletePayload = z.infer; +export type WorktreeDeleteResult = z.infer; + export const sessionAttachPayloadSchema = z .object({ snapshotIdentity: z.string().min(1).max(512).optional(), diff --git a/services/cloud-agent-next/src/types/ids.ts b/services/cloud-agent-next/src/types/ids.ts index feed809cf9..6b6828b74b 100644 --- a/services/cloud-agent-next/src/types/ids.ts +++ b/services/cloud-agent-next/src/types/ids.ts @@ -34,7 +34,7 @@ export type SessionId = `sess_${string}` | `agent_${string}` | `workspace_${stri export type LeaseId = `lease_${string}`; /** User identifier from the authentication system */ -export type UserId = `user_${string}`; +export type UserId = string; /** Auto-incrementing event ID in SQLite storage */ export type EventId = number; diff --git a/services/cloud-agent-next/src/websocket/stream.test.ts b/services/cloud-agent-next/src/websocket/stream.test.ts index 88a13fdab8..ca78410d82 100644 --- a/services/cloud-agent-next/src/websocket/stream.test.ts +++ b/services/cloud-agent-next/src/websocket/stream.test.ts @@ -109,6 +109,18 @@ function processSdkFrame( if (event && !isChatEvent(event)) state.process(event); } +type SentStreamMessage = { + eventId: number; + sessionId: SessionId; + streamEventType: string; + timestamp: string; + data: Record; +}; + +function parseSentMessages(webSocket: { sentMessages: string[] }): SentStreamMessage[] { + return webSocket.sentMessages.map(message => JSON.parse(message) as SentStreamMessage); +} + describe('stream handler replayEvents', () => { it('sends all events when total size is within byte budget', async () => { const events = [makeEvent(1), makeEvent(2), makeEvent(3)]; @@ -969,6 +981,259 @@ describe('stream handler handleStreamRequest', () => { 'connected', ]); }); + + it('corrects accepted delivery immediately after reconnect user-message materialization', async () => { + const serverWs = makeFakeWebSocket(); + mockWebSocketPair(serverWs); + + const messageId = 'msg_accepted'; + const timestamp = 1_700_000_000_000; + const persistedSentEvent: StoredEvent = { + ...makeEvent(7, JSON.stringify({ messageId, delivery: 'sent' })), + execution_id: '', + stream_event_type: 'cloud.message.sent', + timestamp, + }; + const handler = createStreamHandler( + makeFakeState(), + makeFakeEventQueries([persistedSentEvent]), + SESSION_ID, + { + deriveQueuedMessages: async () => [ + { messageId, content: 'already running', timestamp, delivery: 'sent' }, + ], + } + ); + + await handler.handleStreamRequest( + new Request('https://example.com/stream', { + headers: { Upgrade: 'websocket' }, + }) + ); + + const events = parseSentMessages(serverWs); + expect(events.map(event => [event.eventId, event.streamEventType])).toEqual([ + [7, 'cloud.message.sent'], + [0, 'connected'], + [0, 'cloud.message.queued'], + [0, 'cloud.message.sent'], + ]); + expect(events[2]).toEqual({ + eventId: 0, + sessionId: SESSION_ID, + streamEventType: 'cloud.message.queued', + timestamp: new Date(timestamp).toISOString(), + data: { messageId, content: 'already running', delivery: 'queued' }, + }); + expect(events[3]).toEqual({ + eventId: 0, + sessionId: SESSION_ID, + streamEventType: 'cloud.message.sent', + timestamp: new Date(timestamp).toISOString(), + data: { messageId, delivery: 'sent' }, + }); + }); + + it('leaves genuinely queued and legacy snapshots queued without a sent correction', async () => { + const serverWs = makeFakeWebSocket(); + mockWebSocketPair(serverWs); + + const handler = createStreamHandler(makeFakeState(), makeFakeEventQueries([]), SESSION_ID, { + deriveQueuedMessages: async () => [ + { messageId: 'msg_queued', content: 'waiting', timestamp: 1000 }, + { messageId: 'msg_legacy', content: 'legacy pending', timestamp: 1001 }, + ], + }); + + await handler.handleStreamRequest( + new Request('https://example.com/stream?replay=false', { + headers: { Upgrade: 'websocket' }, + }) + ); + + const events = parseSentMessages(serverWs).filter( + event => event.streamEventType !== 'connected' + ); + expect( + events.map(event => ({ streamEventType: event.streamEventType, data: event.data })) + ).toEqual([ + { + streamEventType: 'cloud.message.queued', + data: { messageId: 'msg_queued', content: 'waiting', delivery: 'queued' }, + }, + { + streamEventType: 'cloud.message.queued', + data: { messageId: 'msg_legacy', content: 'legacy pending', delivery: 'queued' }, + }, + ]); + }); + + it.each([ + { accepted: false, delivery: 'queued' as const }, + { accepted: true, delivery: 'sent' as const }, + ])('preserves queued-then-failed ordering for $delivery terminal failures', async failure => { + const serverWs = makeFakeWebSocket(); + mockWebSocketPair(serverWs); + + const messageId = `msg_failed_${failure.delivery}`; + const handler = createStreamHandler(makeFakeState(), makeFakeEventQueries([]), SESSION_ID, { + deriveQueuedMessages: async () => [ + { + messageId, + content: 'failed message', + timestamp: 1000, + terminalFailure: { + messageId, + status: 'failed', + delivery: failure.delivery, + accepted: failure.accepted, + reason: 'wrapper_failed', + timestamp: 1005, + }, + }, + ], + }); + + await handler.handleStreamRequest( + new Request( + 'https://example.com/stream?replay=false&eventTypes=cloud.message.queued,cloud.message.failed', + { + headers: { Upgrade: 'websocket' }, + } + ) + ); + + const events = parseSentMessages(serverWs).filter( + event => event.streamEventType !== 'connected' + ); + expect(events.map(event => event.streamEventType)).toEqual([ + 'cloud.message.queued', + 'cloud.message.failed', + ]); + expect(events[1]?.data).toEqual({ + messageId, + status: 'failed', + delivery: failure.delivery, + accepted: failure.accepted, + reason: 'wrapper_failed', + }); + }); + + it.each([ + { + query: 'eventTypes=cloud.message.queued,cloud.message.sent', + expected: ['cloud.message.queued', 'cloud.message.sent'], + }, + { query: 'eventTypes=cloud.message.queued', expected: ['cloud.message.queued'] }, + { query: 'eventTypes=cloud.message.sent', expected: ['cloud.message.sent'] }, + { query: 'eventTypes=output', expected: [] }, + { query: 'executionIds=exec_other', expected: [] }, + { query: 'startTime=1001', expected: [] }, + { query: 'endTime=999', expected: [] }, + ])('preserves existing accepted snapshot filters for $query', async ({ query, expected }) => { + const serverWs = makeFakeWebSocket(); + mockWebSocketPair(serverWs); + + const handler = createStreamHandler(makeFakeState(), makeFakeEventQueries([]), SESSION_ID, { + deriveQueuedMessages: async () => [ + { + messageId: 'msg_accepted', + content: 'already running', + timestamp: 1000, + delivery: 'sent', + }, + ], + }); + + await handler.handleStreamRequest( + new Request(`https://example.com/stream?replay=false&${query}`, { + headers: { Upgrade: 'websocket' }, + }) + ); + + expect( + parseSentMessages(serverWs) + .filter(event => event.streamEventType !== 'connected') + .map(event => event.streamEventType) + ).toEqual(expected); + }); + + it('keeps accepted delivery corrections isolated to their owning session', async () => { + const siblingSessionId = 'sess_sibling' as SessionId; + const acceptedSocket = makeFakeWebSocket(); + const queuedSocket = makeFakeWebSocket(); + const acceptedHandler = createStreamHandler( + makeFakeState(), + makeFakeEventQueries([]), + SESSION_ID, + { + deriveQueuedMessages: async () => [ + { + messageId: 'msg_accepted', + content: 'first session', + timestamp: 1000, + delivery: 'sent', + }, + ], + } + ); + const queuedHandler = createStreamHandler( + makeFakeState(), + makeFakeEventQueries([]), + siblingSessionId, + { + deriveQueuedMessages: async () => [ + { messageId: 'msg_sibling', content: 'second session', timestamp: 1000 }, + ], + } + ); + + mockWebSocketPair(acceptedSocket); + await acceptedHandler.handleStreamRequest( + new Request('https://example.com/stream?replay=false', { + headers: { Upgrade: 'websocket' }, + }) + ); + + mockWebSocketPair(queuedSocket); + await queuedHandler.handleStreamRequest( + new Request('https://example.com/stream?replay=false', { + headers: { Upgrade: 'websocket' }, + }) + ); + + expect( + parseSentMessages(acceptedSocket) + .filter(event => event.streamEventType !== 'connected') + .map(event => ({ + sessionId: event.sessionId, + streamEventType: event.streamEventType, + messageId: event.data.messageId, + })) + ).toEqual([ + { + sessionId: SESSION_ID, + streamEventType: 'cloud.message.queued', + messageId: 'msg_accepted', + }, + { sessionId: SESSION_ID, streamEventType: 'cloud.message.sent', messageId: 'msg_accepted' }, + ]); + expect( + parseSentMessages(queuedSocket) + .filter(event => event.streamEventType !== 'connected') + .map(event => ({ + sessionId: event.sessionId, + streamEventType: event.streamEventType, + messageId: event.data.messageId, + })) + ).toEqual([ + { + sessionId: siblingSessionId, + streamEventType: 'cloud.message.queued', + messageId: 'msg_sibling', + }, + ]); + }); }); describe('formatStreamEvent', () => { diff --git a/services/cloud-agent-next/src/workspace.test.ts b/services/cloud-agent-next/src/workspace.test.ts index 690157777f..3512324908 100644 --- a/services/cloud-agent-next/src/workspace.test.ts +++ b/services/cloud-agent-next/src/workspace.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { CloudAgentWorktreeId } from '@kilocode/session-ingest-contracts'; const { mockTimeoutWarn, mockTimeoutWithFields, mockTimeoutWithTags } = vi.hoisted(() => { const warn = vi.fn(); @@ -41,6 +42,7 @@ import { setupWorkspace, getBaseWorkspacePath, getSessionWorkspacePath, + getWorktreeWorkspacePath, getSessionHomePath, sanitizeIdForPath, LOW_DISK_THRESHOLD_MB, @@ -94,6 +96,27 @@ describe('setupWorkspace', () => { }); expect(mkdir).toHaveBeenNthCalledWith(2, '/home/agent-session', { recursive: true }); }); + + it('creates a canonical shared worktree directory and a chat-specific home', async () => { + const mkdir = vi.fn().mockResolvedValue(undefined); + const sandbox = { mkdir } as unknown as SandboxInstance; + const worktreeId = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; + + const paths = await setupWorkspace( + sandbox, + 'oauth/google:1234', + undefined, + 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e', + worktreeId + ); + + expect(paths).toEqual({ + workspacePath: `/workspace/oauth-google-1234/worktrees/${worktreeId}`, + sessionHome: '/home/workspace_420ae020-e3c4-4e67-878b-66672c3d997e', + }); + expect(mkdir).toHaveBeenNthCalledWith(1, paths.workspacePath, { recursive: true }); + expect(mkdir).toHaveBeenNthCalledWith(2, paths.sessionHome, { recursive: true }); + }); }); describe('manageBranch', () => { @@ -1343,6 +1366,34 @@ describe('workspace path construction', () => { const ORG_ID = '33c8c114-791f-463c-9f7f-fe74b284cfcf'; const BOT_USER_ID = `bot-code-review-${ORG_ID}`; const SESSION_ID = 'agent_420ae020-e3c4-4e67-878b-66672c3d997e'; + const WORKTREE_ID = 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e'; + + it('builds owner-scoped canonical worktree paths for personal and organization sessions', () => { + expect(getWorktreeWorkspacePath(undefined, 'user-1', WORKTREE_ID)).toBe( + `/workspace/user-1/worktrees/${WORKTREE_ID}` + ); + expect(getWorktreeWorkspacePath(ORG_ID, 'user-1', WORKTREE_ID)).toBe( + `/workspace/${ORG_ID}/user-1/worktrees/${WORKTREE_ID}` + ); + }); + + it('preserves arbitrary OAuth user IDs in canonical worktree paths', () => { + expect(getWorktreeWorkspacePath(ORG_ID, 'oauth/google:1234', WORKTREE_ID)).toBe( + `/workspace/${ORG_ID}/oauth-google-1234/worktrees/${WORKTREE_ID}` + ); + }); + + it.each([ + '', + 'worktree_invalid', + 'worktree_../../outside', + 'worktree_420ae020-e3c4-4e67-878b-66672c3d997e/child', + 'workspace_420ae020-e3c4-4e67-878b-66672c3d997e', + ])('rejects unsafe worktree ID %s before constructing a path', worktreeId => { + expect(() => + getWorktreeWorkspacePath(ORG_ID, 'user-1', worktreeId as CloudAgentWorktreeId) + ).toThrow(/invalid worktree id/); + }); it('keeps the userId segment between the org and the sessions directory', () => { // Dropping this segment would yield `/workspace//sessions/`, diff --git a/services/cloud-agent-next/src/workspace.ts b/services/cloud-agent-next/src/workspace.ts index 2105bd924c..e00ba15487 100644 --- a/services/cloud-agent-next/src/workspace.ts +++ b/services/cloud-agent-next/src/workspace.ts @@ -1,5 +1,9 @@ import type { SandboxInstance, ExecutionSession, SystemSandboxUsageEvent } from './types.js'; import type { ExecResult, ExecOptions } from '@cloudflare/sandbox'; +import { + cloudAgentWorktreeIdSchema, + type CloudAgentWorktreeId, +} from '@kilocode/session-ingest-contracts'; import { logger } from './logger.js'; import { inspectWrapperContainers, @@ -207,6 +211,18 @@ export function getSessionWorkspacePath( return `${getBaseWorkspacePath(kilocodeOrganizationId, userId)}/sessions/${safeSessionId}`; } +export function getWorktreeWorkspacePath( + kilocodeOrganizationId: string | null | undefined, + userId: string, + worktreeId: CloudAgentWorktreeId +): string { + const parsed = cloudAgentWorktreeIdSchema.safeParse(worktreeId); + if (!parsed.success) { + throw invalidSegment('worktree id'); + } + return `${getBaseWorkspacePath(kilocodeOrganizationId, userId)}/worktrees/${parsed.data}`; +} + export function getSessionHomePath(sessionId: string): string { return `${SESSION_HOME_ROOT}/${requireGeneratedIdPathSegment(sessionId, 'session id')}`; } @@ -359,9 +375,12 @@ export async function setupWorkspace( sandbox: SandboxInstance, userId: string, kilocodeOrganizationId: string | undefined, - sessionId: string + sessionId: string, + worktreeId?: CloudAgentWorktreeId ): Promise { - const sessionWorkspacePath = getSessionWorkspacePath(kilocodeOrganizationId, userId, sessionId); + const sessionWorkspacePath = worktreeId + ? getWorktreeWorkspacePath(kilocodeOrganizationId, userId, worktreeId) + : getSessionWorkspacePath(kilocodeOrganizationId, userId, sessionId); const sessionHome = getSessionHomePath(sessionId); try { diff --git a/services/cloud-agent-next/test/e2e/client.ts b/services/cloud-agent-next/test/e2e/client.ts index d9433ecc91..c4928a5c29 100644 --- a/services/cloud-agent-next/test/e2e/client.ts +++ b/services/cloud-agent-next/test/e2e/client.ts @@ -8,9 +8,11 @@ * in the prompt (`__fake__:[:]`) and interpreted by that fake. */ +import { randomUUID } from 'node:crypto'; import WebSocket from 'ws'; import { z } from 'zod'; import { mintApiToken, mintStreamTicket, type TestUser } from './auth.js'; +import type { FakeScenarioStatus } from './fake-llm-server.js'; /** * Which tRPC surface the driver exercises. @@ -81,6 +83,44 @@ export const DEFAULT_CONFIG: Omit = { * The web client uses `httpLink` (see apps/web/src/lib/cloud-agent/ * cloud-agent-client.ts) so we match that exact wire format. */ +function summarizeTrpcError(text: string): string { + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return 'unparseable error response'; + } + if ( + typeof parsed !== 'object' || + parsed === null || + !('error' in parsed) || + typeof parsed.error !== 'object' || + parsed.error === null + ) { + return 'invalid error response'; + } + const message = + 'message' in parsed.error && typeof parsed.error.message === 'string' + ? parsed.error.message + .replace( + /(?:file:\/\/)?\/(?:Users|home|workspace|private|var|tmp)\/[^\s"')]+/g, + '[redacted-path]' + ) + .replace(/Bearer\s+[^\s,;]+/gi, 'Bearer [redacted]') + .slice(0, 240) + : 'request rejected'; + if ( + 'data' in parsed.error && + typeof parsed.error.data === 'object' && + parsed.error.data !== null && + 'code' in parsed.error.data && + typeof parsed.error.data.code === 'string' + ) { + return `${parsed.error.data.code}: ${message}`; + } + return message; +} + export async function trpcCall( config: DriverConfig, procedure: string, @@ -109,7 +149,7 @@ export async function trpcCall( const text = await response.text(); if (!response.ok) { throw new Error( - `tRPC ${procedure} failed: ${response.status} ${response.statusText} — ${text}` + `tRPC ${procedure} failed: ${response.status} ${response.statusText} — ${summarizeTrpcError(text)}` ); } const parsed: unknown = JSON.parse(text); @@ -252,6 +292,100 @@ async function startSessionLegacy( }; } +export type WorktreeSessionResult = { + cloudAgentSessionId: string; + kiloSessionId: string; + worktreeId?: string; + cloudAgentWorktreeId?: string; + replayed?: boolean; +}; + +export async function prepareBrowserSession( + config: DriverConfig, + input: { prompt: string; operationKey?: string } +): Promise { + if (!config.internalApiSecret) { + throw new Error('browser-equivalent prepareSession requires INTERNAL_API_SECRET'); + } + return trpcCall( + config, + 'prepareSession', + { + prompt: input.prompt, + mode: 'code', + model: config.model, + gitUrl: config.gitUrl, + shallow: true, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + autoInitiate: true, + operationKey: input.operationKey ?? randomUUID(), + autoCommit: true, + ...(config.kilocodeOrganizationId + ? { kilocodeOrganizationId: config.kilocodeOrganizationId } + : {}), + }, + { internalApiSecret: config.internalApiSecret } + ); +} + +export async function createWorktreeChat( + config: DriverConfig, + input: { + sourceKiloSessionId: string; + sourceCloudAgentSessionId: string; + operationKey?: string; + } +): Promise { + if (!config.internalApiSecret) { + throw new Error('createWorktreeChat requires INTERNAL_API_SECRET'); + } + return trpcCall( + config, + 'createWorktreeChat', + { + sourceKiloSessionId: input.sourceKiloSessionId, + sourceCloudAgentSessionId: input.sourceCloudAgentSessionId, + operationKey: input.operationKey ?? randomUUID(), + clientProvenance: 'browser', + ...(config.kilocodeOrganizationId + ? { kilocodeOrganizationId: config.kilocodeOrganizationId } + : {}), + }, + { internalApiSecret: config.internalApiSecret } + ); +} + +export type SessionSnapshot = { + sessionId: string; + kiloSessionId?: string; + userId: string; + orgId?: string; + sandboxId?: string; + githubRepo?: string; + gitUrl?: string; + platform?: string; + upstreamBranch?: string; + autoCommit?: boolean; + worktreeId?: string; + cloudAgentWorktreeId?: string; + initialMessageId?: string; + latestEventId?: number | null; + execution: { id: string; status: string } | null; +}; + +export async function getSessionSnapshot( + config: DriverConfig, + cloudAgentSessionId: string +): Promise { + return trpcCall( + config, + 'getSession', + { cloudAgentSessionId }, + { method: 'GET' } + ); +} + export type SendMessageResult = { executionId?: string; messageId: string; @@ -343,6 +477,26 @@ export async function answerPermission( }); } +export async function answerQuestion( + config: DriverConfig, + sessionId: string, + questionId: string, + answers: string[][] +): Promise<{ success: boolean }> { + return trpcCall<{ success: boolean }>(config, 'answerQuestion', { + sessionId, + questionId, + answers, + }); +} + +export async function deleteSession( + config: DriverConfig, + sessionId: string +): Promise<{ success: boolean }> { + return trpcCall<{ success: boolean }>(config, 'deleteSession', { sessionId }); +} + // --------------------------------------------------------------------------- // Fake-LLM gate helpers // --------------------------------------------------------------------------- @@ -391,6 +545,18 @@ export async function fetchFakeRequests(fakeLlmUrl: string): Promise { + const url = `${fakeLlmUrl.replace(/\/$/, '')}/test/scenario-status?tag=${encodeURIComponent(tag)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`fetchFakeScenarioStatus(${tag}) failed: ${response.status}`); + } + return (await response.json()) as FakeScenarioStatus; +} + /** * Poll the fake LLM server until a `gate:` scenario is actively parked — * meaning kilo has dialed the fake and the turn is blocked mid-stream. @@ -424,7 +590,7 @@ export async function waitForGateEngaged( // WebSocket stream // --------------------------------------------------------------------------- -const streamEventSchema = z.object({ +export const streamEventSchema = z.object({ eventId: z.number(), executionId: z.string().nullable().default(null), sessionId: z.string(), @@ -521,8 +687,8 @@ export function openStream( : Array.isArray(raw) ? Buffer.concat(raw).toString('utf8') : raw.toString('utf8'); - const data: unknown = JSON.parse(text); - parsed = streamEventSchema.parse(data); + const decoded: unknown = JSON.parse(text); + parsed = streamEventSchema.parse(decoded); } catch { return; } diff --git a/services/cloud-agent-next/test/e2e/fake-llm-server.ts b/services/cloud-agent-next/test/e2e/fake-llm-server.ts index 4f8887f5eb..6a25f3c558 100644 --- a/services/cloud-agent-next/test/e2e/fake-llm-server.ts +++ b/services/cloud-agent-next/test/e2e/fake-llm-server.ts @@ -16,6 +16,7 @@ * handle pattern. */ +import { createHash } from 'node:crypto'; import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; import type { AddressInfo, Socket } from 'node:net'; @@ -43,6 +44,32 @@ type GateWaiter = { cleanup: () => void; }; +type ToolKind = 'write' | 'read' | 'edit' | 'question'; + +type AdvertisedTool = { + name: string; + parameters: Record; +}; + +type ToolResult = { + id: string; + content: string; +}; + +type ScenarioCounters = Record; + +export type FakeScenarioStatus = { + tag: string; + requests: number; + toolCalls: ScenarioCounters; + toolResults: ScenarioCounters; + unsupportedToolSchema: boolean; +}; + +type InternalScenarioStatus = FakeScenarioStatus & { + seenToolResults: Set; +}; + const RELEASED_GATE_FOLLOWUP_TTL_MS = 10_000; type ServerState = { @@ -60,6 +87,7 @@ type ServerState = { nextRequestId: number; /** Count of dispatched completions, exposed for fail-fast scenario assertions. */ chatCompletionRequests: number; + scenarios: Map; }; type LogFields = Record; @@ -110,7 +138,11 @@ export function parseDirective(text: string): Directive | null { } type MessagePart = { type?: string; text?: string }; -type Message = { role?: string; content?: string | MessagePart[] }; +type Message = { + role?: string; + content?: string | MessagePart[]; + tool_call_id?: string; +}; /** * Extract the text of the last user message in an OpenAI-shape request body. @@ -150,6 +182,207 @@ export function stripKiloPromptWrapping(text: string): string { return text.replace(/[\s\S]*?<\/environment_details>/gi, '').trim(); } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function stripPromptContext(value: string): string { + const contextIndex = value.indexOf(''); + return (contextIndex < 0 ? value : value.slice(0, contextIndex)).trimEnd(); +} + +function directiveTag(directive: Directive | null): string | undefined { + if ( + directive === null || + !['gate', 'write-then-gate', 'read-edit-then-gate', 'question'].includes(directive.scenario) + ) { + return undefined; + } + return directive.args[0]?.match(/^([A-Za-z0-9_-]+)/)?.[1]; +} + +function createCounters(): ScenarioCounters { + return { write: 0, read: 0, edit: 0, question: 0 }; +} + +function scenarioStatus(state: ServerState, tag: string): InternalScenarioStatus { + const existing = state.scenarios.get(tag); + if (existing) return existing; + const created: InternalScenarioStatus = { + tag, + requests: 0, + toolCalls: createCounters(), + toolResults: createCounters(), + unsupportedToolSchema: false, + seenToolResults: new Set(), + }; + state.scenarios.set(tag, created); + return created; +} + +function advertisedTools(body: unknown): AdvertisedTool[] { + if (!isRecord(body) || !Array.isArray(body.tools)) return []; + const tools: AdvertisedTool[] = []; + for (const entry of body.tools) { + if (!isRecord(entry) || !isRecord(entry.function)) continue; + const { name, parameters } = entry.function; + if (typeof name !== 'string' || !isRecord(parameters)) continue; + tools.push({ name, parameters }); + } + return tools; +} + +function toolCallId(tag: string, kind: ToolKind): string { + const fingerprint = createHash('sha256').update(tag).digest('hex').slice(0, 12); + return `call_${fingerprint}_${kind}`; +} + +function toolResultContent(content: unknown): string { + if (typeof content === 'string') return content; + if (!Array.isArray(content)) return ''; + return content + .filter(isRecord) + .map(part => (typeof part.text === 'string' ? part.text : '')) + .join(''); +} + +function toolResults(body: unknown, tag: string, state: ServerState): ToolResult[] { + if (!isRecord(body) || !Array.isArray(body.messages)) return []; + const result: ToolResult[] = []; + const status = scenarioStatus(state, tag); + for (const message of body.messages) { + if (!isRecord(message) || message.role !== 'tool') continue; + const id = message.tool_call_id; + if (typeof id !== 'string') continue; + const kind = (['write', 'read', 'edit', 'question'] as const).find( + candidate => id === toolCallId(tag, candidate) + ); + if (!kind) continue; + if (!status.seenToolResults.has(id)) { + status.seenToolResults.add(id); + status.toolResults[kind] += 1; + } + result.push({ id, content: toolResultContent(message.content) }); + } + return result; +} + +function toolProperties(tool: AdvertisedTool): Record | null { + return isRecord(tool.parameters.properties) ? tool.parameters.properties : null; +} + +function propertyName(properties: Record, candidates: string[]): string | null { + return candidates.find(candidate => candidate in properties) ?? null; +} + +function supportsRequiredArguments( + tool: AdvertisedTool, + argumentsByName: Record +): boolean { + if (!Array.isArray(tool.parameters.required)) return true; + return tool.parameters.required.every( + required => typeof required === 'string' && required in argumentsByName + ); +} + +function resolveTool( + tools: AdvertisedTool[], + kind: ToolKind, + input: { path?: string; contents?: string; replacement?: string; question?: string } +): { tool: AdvertisedTool; arguments: Record } | null { + const names: Record = { + write: ['write', 'write_file', 'write_to_file'], + read: ['read', 'read_file'], + edit: ['edit', 'edit_file', 'search_and_replace', 'replace'], + question: ['question', 'ask_followup_question', 'ask_follow_up_question'], + }; + const tool = tools.find(candidate => names[kind].includes(candidate.name)); + if (!tool) return null; + const properties = toolProperties(tool); + if (!properties) return null; + + const args: Record = {}; + if (kind === 'question') { + if ('questions' in properties && input.question !== undefined) { + args.questions = [ + { + question: input.question, + header: 'E2E', + options: [{ label: 'Continue', description: 'Continue the E2E scenario' }], + }, + ]; + } else if ('question' in properties && input.question !== undefined) { + args.question = input.question; + if ('options' in properties) { + args.options = [{ label: 'Continue', description: 'Continue the E2E scenario' }]; + } + } else { + return null; + } + return supportsRequiredArguments(tool, args) ? { tool, arguments: args } : null; + } + + const pathProperty = propertyName(properties, ['filePath', 'file_path', 'path', 'filename']); + if (!pathProperty || input.path === undefined) return null; + args[pathProperty] = input.path; + + if (kind === 'write') { + const contentProperty = propertyName(properties, ['content', 'contents', 'text']); + if (!contentProperty || input.contents === undefined) return null; + args[contentProperty] = input.contents; + } + + if (kind === 'edit') { + const previousProperty = propertyName(properties, [ + 'oldString', + 'old_string', + 'oldText', + 'old_text', + 'search', + 'old', + ]); + const replacementProperty = propertyName(properties, [ + 'newString', + 'new_string', + 'newText', + 'new_text', + 'replace', + 'replacement', + 'new', + ]); + if ( + !previousProperty || + !replacementProperty || + input.contents === undefined || + input.replacement === undefined + ) { + return null; + } + args[previousProperty] = input.contents; + args[replacementProperty] = input.replacement; + } + + return supportsRequiredArguments(tool, args) ? { tool, arguments: args } : null; +} + +function readFileContents(result: string): string { + let content = result; + try { + const parsed: unknown = JSON.parse(result); + if (isRecord(parsed) && typeof parsed.output === 'string') content = parsed.output; + } catch { + content = result; + } + const enclosed = content.match(/<(?:content|file)>\s*\n?([\s\S]*?)\n?<\/(?:content|file)>/); + if (enclosed?.[1] !== undefined) content = enclosed[1]; + return content + .split(/\r?\n/) + .filter(line => !/^\s*\((?:End of file|Showing lines)/.test(line)) + .map(line => line.replace(/^\s*\d+\s*[:|]\s?/, '')) + .join('\n') + .trimEnd(); +} + // --------------------------------------------------------------------------- // Model catalogue // --------------------------------------------------------------------------- @@ -196,6 +429,21 @@ function modelsCatalogue(): { data: Array } { // SSE framing helpers // --------------------------------------------------------------------------- +type ToolCallDelta = { + index: number; + id?: string; + type?: 'function'; + function: { name?: string; arguments: string }; +}; + +type ChunkDelta = { + role?: string; + content?: string; + tool_calls?: ToolCallDelta[]; +}; + +type FinishReason = 'stop' | 'tool_calls'; + type Chunk = { id: string; object: 'chat.completion.chunk'; @@ -203,8 +451,8 @@ type Chunk = { model: string; choices: Array<{ index: number; - delta: { role?: string; content?: string }; - finish_reason: null | 'stop'; + delta: ChunkDelta; + finish_reason: null | FinishReason; }>; usage?: { prompt_tokens: number; @@ -229,8 +477,8 @@ function ensureStreamHeaders(res: ServerResponse): void { function makeChunk( id: string, model: string, - delta: { role?: string; content?: string }, - finishReason: null | 'stop' = null + delta: ChunkDelta, + finishReason: null | FinishReason = null ): Chunk { return { id, @@ -260,10 +508,11 @@ function writeFinish( res: ServerResponse, id: string, model: string, - completionTokens: number + completionTokens: number, + finishReason: FinishReason = 'stop' ): void { const finalChunk: Chunk = { - ...makeChunk(id, model, {}, 'stop'), + ...makeChunk(id, model, {}, finishReason), usage: { prompt_tokens: 10, completion_tokens: completionTokens, @@ -302,6 +551,8 @@ export type ScenarioContext = { id: string; model: string; state: ServerState; + body: unknown; + tools: AdvertisedTool[]; /** Correlation id for log entries of this request. */ reqLogId: number; }; @@ -318,6 +569,134 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +function writeAssistantResponse(ctx: ScenarioContext, content: string): void { + writeChunk(ctx.res, makeChunk(ctx.id, ctx.model, { role: 'assistant', content })); + writeFinish(ctx.res, ctx.id, ctx.model, content.length); + ctx.res.end(); +} + +function parkGate(ctx: ScenarioContext, tag: string, completion: string): void { + const releasedFollowupExpiresAt = ctx.state.releasedGateFollowups.get(tag); + if (releasedFollowupExpiresAt !== undefined) { + ctx.state.releasedGateFollowups.delete(tag); + if (Date.now() <= releasedFollowupExpiresAt) { + writeAssistantResponse(ctx, completion); + logEvent('scenario.unparked', { + reqId: ctx.reqLogId, + scenario: 'gate', + tag, + reason: 'released-followup', + }); + return; + } + } + + ensureStreamHeaders(ctx.res); + ctx.state.liveResponses.add(ctx.res); + let releasedByTest = false; + + const cleanup = (): void => { + ctx.state.liveResponses.delete(ctx.res); + const waiters = ctx.state.gates.get(tag); + if (!waiters) return; + const remaining = waiters.filter(waiter => waiter.res !== ctx.res); + if (remaining.length === 0) { + ctx.state.gates.delete(tag); + } else { + ctx.state.gates.set(tag, remaining); + } + }; + + const release = (): void => { + releasedByTest = true; + cleanup(); + writeAssistantResponse(ctx, completion); + logEvent('scenario.unparked', { + reqId: ctx.reqLogId, + scenario: 'gate', + tag, + reason: 'released', + }); + }; + + ctx.res.on('close', () => { + cleanup(); + if (!releasedByTest) { + logEvent('scenario.unparked', { + reqId: ctx.reqLogId, + scenario: 'gate', + tag, + reason: 'client-closed', + }); + } + }); + + const waiters = ctx.state.gates.get(tag) ?? []; + waiters.push({ res: ctx.res, req: ctx.req, model: ctx.model, release, cleanup }); + ctx.state.gates.set(tag, waiters); + logEvent('scenario.parked', { + reqId: ctx.reqLogId, + scenario: 'gate', + tag, + waiterCount: waiters.length, + }); +} + +function writeToolCall( + ctx: ScenarioContext, + tag: string, + kind: ToolKind, + resolved: { tool: AdvertisedTool; arguments: Record } +): void { + scenarioStatus(ctx.state, tag).toolCalls[kind] += 1; + writeChunk( + ctx.res, + makeChunk(ctx.id, ctx.model, { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: toolCallId(tag, kind), + type: 'function', + function: { name: resolved.tool.name, arguments: '' }, + }, + ], + }) + ); + writeChunk( + ctx.res, + makeChunk(ctx.id, ctx.model, { + tool_calls: [{ index: 0, function: { arguments: JSON.stringify(resolved.arguments) } }], + }) + ); + writeFinish(ctx.res, ctx.id, ctx.model, 1, 'tool_calls'); + ctx.res.end(); +} + +function writeUnsupportedToolSchema(ctx: ScenarioContext, tag: string, kind: ToolKind): void { + scenarioStatus(ctx.state, tag).unsupportedToolSchema = true; + writeJsonError(ctx.res, 422, `unsupported ${kind} tool schema`, 'unsupported_tool_schema'); + logEvent('scenario.unsupported', { + reqId: ctx.reqLogId, + tag, + advertisedTools: ctx.tools.length, + }); +} + +function runToolScenario( + ctx: ScenarioContext, + tag: string, + kind: ToolKind, + input: { path?: string; contents?: string; replacement?: string; question?: string } +): void { + const resolved = resolveTool(ctx.tools, kind, input); + if (!resolved) { + writeUnsupportedToolSchema(ctx, tag, kind); + return; + } + writeToolCall(ctx, tag, kind, resolved); +} + /** * Scenario registry. Each handler writes SSE chunks to `ctx.res` and is * responsible for closing the response (or leaving it open for `hang`/`gate`). @@ -379,91 +758,102 @@ export const scenarioRegistry: Record = { }, gate(args, ctx) { - const rawArg = args[0] ?? ''; - // Kilo augments the user message with `...` and other - // system context. Strip anything past the first non-tag character so both - // the title-model call and the primary code call share the same tag. - const tag = rawArg.match(/^([A-Za-z0-9_-]+)/)?.[1] ?? ''; + const match = stripPromptContext(args[0] ?? '').match( + /^([A-Za-z0-9_-]+)(?::([A-Za-z0-9_-]+))?/ + ); + const tag = match?.[1]; if (!tag) { writeJsonError(ctx.res, 402, 'gate directive requires a tag', 'invalid_request'); return; } + parkGate(ctx, tag, match[2] ?? 'done'); + }, - const releasedFollowupExpiresAt = ctx.state.releasedGateFollowups.get(tag); - if (releasedFollowupExpiresAt !== undefined) { - ctx.state.releasedGateFollowups.delete(tag); - if (Date.now() <= releasedFollowupExpiresAt) { - ensureStreamHeaders(ctx.res); - writeChunk(ctx.res, makeChunk(ctx.id, ctx.model, { role: 'assistant', content: 'done' })); - writeFinish(ctx.res, ctx.id, ctx.model, 4); - ctx.res.end(); - logEvent('scenario.unparked', { - reqId: ctx.reqLogId, - scenario: 'gate', - tag, - reason: 'released-followup', - }); - return; - } + 'write-then-gate'(args, ctx) { + const parsed = stripPromptContext(args[0] ?? '').match(/^([A-Za-z0-9_-]+):([^:]+):([\s\S]+)$/); + if (!parsed?.[1] || !parsed[2] || parsed[3] === undefined) { + writeJsonError( + ctx.res, + 402, + 'write-then-gate directive requires tag, path, and contents', + 'invalid_request' + ); + return; } + const [, tag, path, contents] = parsed; + if (ctx.tools.length === 0) { + writeAssistantResponse(ctx, `done-${tag}`); + return; + } + const results = toolResults(ctx.body, tag, ctx.state); + if (!results.some(result => result.id === toolCallId(tag, 'write'))) { + runToolScenario(ctx, tag, 'write', { path, contents }); + return; + } + parkGate(ctx, tag, `done-${tag}`); + }, - ensureStreamHeaders(ctx.res); - ctx.state.liveResponses.add(ctx.res); - - let releasedByTest = false; - - const cleanup = (): void => { - ctx.state.liveResponses.delete(ctx.res); - const waiters = ctx.state.gates.get(tag); - if (!waiters) return; - const next = waiters.filter(w => w.res !== ctx.res); - if (next.length === 0) { - ctx.state.gates.delete(tag); - } else { - ctx.state.gates.set(tag, next); + 'read-edit-then-gate'(args, ctx) { + const parsed = stripPromptContext(args[0] ?? '').match(/^([A-Za-z0-9_-]+):([^:]+):([\s\S]+)$/); + if (!parsed?.[1] || !parsed[2] || parsed[3] === undefined) { + writeJsonError( + ctx.res, + 402, + 'read-edit-then-gate directive requires tag, path, and replacement', + 'invalid_request' + ); + return; + } + const [, tag, path, replacement] = parsed; + if (ctx.tools.length === 0) { + writeAssistantResponse(ctx, `done-${tag}`); + return; + } + const results = toolResults(ctx.body, tag, ctx.state); + const readResult = results.find(result => result.id === toolCallId(tag, 'read')); + if (!readResult) { + runToolScenario(ctx, tag, 'read', { path }); + return; + } + if (!results.some(result => result.id === toolCallId(tag, 'edit'))) { + const contents = readFileContents(readResult.content); + if (!contents) { + writeJsonError( + ctx.res, + 422, + 'read tool returned no editable file contents', + 'invalid_tool_result' + ); + return; } - }; - - const release = (): void => { - releasedByTest = true; - cleanup(); - writeChunk(ctx.res, makeChunk(ctx.id, ctx.model, { role: 'assistant', content: 'done' })); - writeFinish(ctx.res, ctx.id, ctx.model, 4); - ctx.res.end(); - logEvent('scenario.unparked', { - reqId: ctx.reqLogId, - scenario: 'gate', - tag, - reason: 'released', - }); - }; + runToolScenario(ctx, tag, 'edit', { path, contents, replacement }); + return; + } + parkGate(ctx, tag, `done-${tag}`); + }, - ctx.res.on('close', () => { - cleanup(); - if (!releasedByTest) { - logEvent('scenario.unparked', { - reqId: ctx.reqLogId, - scenario: 'gate', - tag, - reason: 'client-closed', - }); - } - }); - const existing = ctx.state.gates.get(tag) ?? []; - existing.push({ - res: ctx.res, - req: ctx.req, - model: ctx.model, - release, - cleanup, - }); - ctx.state.gates.set(tag, existing); - logEvent('scenario.parked', { - reqId: ctx.reqLogId, - scenario: 'gate', - tag, - waiterCount: existing.length, - }); + question(args, ctx) { + const parsed = stripPromptContext(args[0] ?? '').match(/^([A-Za-z0-9_-]+):([\s\S]+)$/); + if (!parsed?.[1] || !parsed[2]) { + writeJsonError( + ctx.res, + 402, + 'question directive requires a tag and question text', + 'invalid_request' + ); + return; + } + const [, tag, question] = parsed; + if (ctx.tools.length === 0) { + writeAssistantResponse(ctx, `done-${tag}`); + return; + } + const results = toolResults(ctx.body, tag, ctx.state); + if (!results.some(result => result.id === toolCallId(tag, 'question'))) { + runToolScenario(ctx, tag, 'question', { question }); + return; + } + writeAssistantResponse(ctx, `done-${tag}`); }, }; @@ -510,11 +900,14 @@ async function handleChatCompletions( return; } - const messages = (body as { messages?: unknown }).messages; + const messages = isRecord(body) ? body.messages : undefined; const messageCount = Array.isArray(messages) ? messages.length : 0; - const bodyModel = (body as { model?: string }).model; + const bodyModel = isRecord(body) && typeof body.model === 'string' ? body.model : undefined; const prompt = extractLastUserMessageText(body); const directive = parseDirective(prompt); + const tools = advertisedTools(body); + const tag = directiveTag(directive); + if (tag) scenarioStatus(state, tag).requests += 1; logEvent('request.start', { reqId: reqLogId, @@ -522,7 +915,9 @@ async function handleChatCompletions( model: bodyModel, messages: messageCount, scenario: directive?.scenario ?? 'echo', - args: directive ? directive.args.join('|') : '(default)', + args: directive === null ? '(default)' : undefined, + tag, + tools: tools.length, }); const ctx: ScenarioContext = { @@ -531,6 +926,8 @@ async function handleChatCompletions( id: randomId(), model: FAKE_MODEL.id, state, + body, + tools, reqLogId, }; @@ -658,6 +1055,25 @@ function handleRequestCounts(res: ServerResponse, state: ServerState): void { res.end(JSON.stringify({ chatCompletions: state.chatCompletionRequests })); } +function handleScenarioStatus(req: IncomingMessage, res: ServerResponse, state: ServerState): void { + const tag = new URL(req.url ?? '/', 'http://localhost').searchParams.get('tag'); + if (!tag || !/^[A-Za-z0-9_-]+$/.test(tag)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'valid tag query param required' })); + return; + } + const status = scenarioStatus(state, tag); + const response: FakeScenarioStatus = { + tag, + requests: status.requests, + toolCalls: status.toolCalls, + toolResults: status.toolResults, + unsupportedToolSchema: status.unsupportedToolSchema, + }; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); +} + /** * Snapshot of all currently parked gate waiters, grouped by tag. Tests use * this after expected completions to assert the fake server has no stale @@ -689,6 +1105,7 @@ export async function startFakeLlmServer(opts?: { liveResponses: new Set(), nextRequestId: 0, chatCompletionRequests: 0, + scenarios: new Map(), }; const sockets = new Set(); @@ -743,6 +1160,10 @@ export async function startFakeLlmServer(opts?: { handleRequestCounts(res, state); return; } + if (route === 'GET /test/scenario-status') { + handleScenarioStatus(req, res, state); + return; + } logEvent('request.unknown', { method: req.method, path: url.pathname }); res.writeHead(404, { 'Content-Type': 'application/json' }); @@ -773,6 +1194,7 @@ export async function startFakeLlmServer(opts?: { } state.gates.clear(); state.releasedGateFollowups.clear(); + state.scenarios.clear(); // End any in-flight hang responses. for (const res of state.liveResponses) { if (!res.writableEnded) res.end(); diff --git a/services/cloud-agent-next/test/e2e/lifecycle.ts b/services/cloud-agent-next/test/e2e/lifecycle.ts index f98738b68f..6f58f494dd 100644 --- a/services/cloud-agent-next/test/e2e/lifecycle.ts +++ b/services/cloud-agent-next/test/e2e/lifecycle.ts @@ -6,34 +6,65 @@ */ import { execFile } from 'node:child_process'; +import { createHash, randomUUID } from 'node:crypto'; import { promisify } from 'node:util'; import { + and, + cli_sessions_v2, + computeDatabaseUrl, + createDrizzleClient, + eq, + inArray, +} from '@kilocode/db'; +import { createKiloClient, type Message, type Part } from '@kilocode/sdk/v2'; +import { + answerQuestion, + createWorktreeChat, + deleteSession, fetchFakeRequests, + fetchFakeScenarioStatus, fetchFakeWaiters, getMessageResult, + getSessionSnapshot, interruptSession, isMessageCompleted, messageIdFromEvent, openStream, + prepareBrowserSession, releaseGate, sendMessage, startSession, waitForGateEngaged, type ApiVersion, type DriverConfig, + type StreamConnection, type StreamEvent, + type WorktreeSessionResult, } from './client.js'; +import { mintApiToken } from './auth.js'; import { startCallbackServer, type CallbackServerHandle } from './callback-server.js'; +import { createMessageId } from '../../src/session/message-id.js'; +import { generateKiloSessionId } from '../../src/utils/kilo-session-id.js'; import { + controlPlaneKiloRootExists, + importControlPlaneKiloRoot, + inspectControlPlaneKiloRoot, + inspectControlPlaneQuestions, + inspectControlPlaneWorkspaceFile, killSandboxFamily, listSandboxContainers, listSandboxesForAgentSession, + promptControlPlaneKiloRoot, sandboxFamilyKey, + stopOwnedControlPlaneSandbox, readKiloCliLog, readWrapperLog, tailLines, + waitForControlPlaneKiloCompletion, + waitForControlPlaneKiloRuntime, waitForNewSandboxPresent, waitForSandboxFamilyGone, + type ControlPlaneKiloRuntime, type SandboxContainer, } from './sandbox-control.js'; @@ -200,6 +231,925 @@ async function snapshotSandboxIds(): Promise> { return new Set(containers.map(container => container.id)); } +export async function lifecycleGateZero(args: LifecycleArgs): Promise { + const startedAt = Date.now(); + const { config, conversation, timeoutMs = 120_000, api = 'unified' } = args; + const kiloConfig = { ...config, model: config.model.replace(/^kilo\//, '') }; + const runId = randomUUID(); + const rootAGate = `root-a-${runId}`; + const rootBGate = `root-b-${runId}`; + let session: Awaited> | undefined; + let stream: ReturnType | undefined; + let ownedSandbox: SandboxContainer | undefined; + let finished = false; + + try { + if (api !== 'unified') { + throw new Error('Gate 0 requires the unified control-plane session creation boundary'); + } + + session = await startSession(kiloConfig, { prompt: fakeDirective(`gate:${rootAGate}`) }, api); + if (!/^workspace_[0-9a-f-]{36}$/i.test(session.cloudAgentSessionId)) { + throw new Error( + `expected a control-plane workspace_* session, got ${session.cloudAgentSessionId}; enroll the driver owner in CONTROL_PLANE_IDS` + ); + } + if (!/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(session.kiloSessionId)) { + throw new Error(`expected a generated root ses_* ID, got ${session.kiloSessionId}`); + } + + stream = openStream(config, session.cloudAgentSessionId, { replay: false }); + const [locatedRuntime, rootAEngaged] = await Promise.all([ + waitForControlPlaneKiloRuntime(session.kiloSessionId, timeoutMs, sandbox => { + ownedSandbox = sandbox; + }), + waitForGateEngaged(config, rootAGate, timeoutMs), + ]); + if (!locatedRuntime) { + throw new Error('no per-directory Kilo listener proved ownership of root A'); + } + if (!rootAEngaged) { + throw new Error(`root A gate ${rootAGate} did not engage`); + } + + const rootA = await inspectControlPlaneKiloRoot(locatedRuntime, session.kiloSessionId); + if ( + rootA.processId !== locatedRuntime.processId || + rootA.directory !== locatedRuntime.directory + ) { + throw new Error('root A did not resolve to its discovered prepared Kilo runtime'); + } + + const rootBId = generateKiloSessionId(); + if (rootBId === rootA.id) { + throw new Error('Gate 0 generated the same Kilo session ID for both roots'); + } + const rootB = await importControlPlaneKiloRoot(locatedRuntime, rootBId); + if (rootB.processId !== rootA.processId || rootB.directory !== rootA.directory) { + throw new Error('imported root B did not resolve to root A’s Kilo process and directory'); + } + + const rootBMessageId = createMessageId(); + await promptControlPlaneKiloRoot(locatedRuntime, { + kiloSessionId: rootB.id, + messageId: rootBMessageId, + gateTag: rootBGate, + model: kiloConfig.model, + }); + if (!(await waitForGateEngaged(config, rootBGate, Math.min(timeoutMs, 40_000)))) { + throw new Error(`root B gate ${rootBGate} did not engage while root A remained parked`); + } + + const [rootAGateEngaged, rootBGateEngaged, confirmedRootA, confirmedRootB] = await Promise.all([ + waitForGateEngaged(config, rootAGate, 1_000), + waitForGateEngaged(config, rootBGate, 1_000), + inspectControlPlaneKiloRoot(locatedRuntime, rootA.id), + inspectControlPlaneKiloRoot(locatedRuntime, rootB.id), + ]); + if (!rootAGateEngaged || !rootBGateEngaged) { + throw new Error( + `gates were not engaged simultaneously: rootA=${rootAGateEngaged}, rootB=${rootBGateEngaged}` + ); + } + if ( + confirmedRootA.processId !== confirmedRootB.processId || + confirmedRootA.processId !== locatedRuntime.processId || + confirmedRootA.directory !== confirmedRootB.directory || + confirmedRootA.directory !== locatedRuntime.directory + ) { + throw new Error('simultaneously running roots did not share one Kilo process and directory'); + } + + await releaseGate(config.fakeLlmUrl, rootBGate); + const rootBCompletion = await waitForControlPlaneKiloCompletion(locatedRuntime, { + kiloSessionId: rootB.id, + messageId: rootBMessageId, + timeoutMs: Math.min(timeoutMs, 25_000), + }); + const rootAEngagedAfterB = await waitForGateEngaged(config, rootAGate, 1_000); + if (!rootAEngagedAfterB) { + throw new Error('root A stopped running when only root B was released'); + } + + await releaseGate(config.fakeLlmUrl, rootAGate); + const rootACompletion = await waitForControlPlaneKiloCompletion(locatedRuntime, { + kiloSessionId: rootA.id, + messageId: session.messageId, + timeoutMs: Math.min(timeoutMs, 25_000), + }); + const [finalRootA, finalRootB, waiters] = await Promise.all([ + inspectControlPlaneKiloRoot(locatedRuntime, rootA.id), + inspectControlPlaneKiloRoot(locatedRuntime, rootB.id), + fetchFakeWaiters(config.fakeLlmUrl), + ]); + if ( + finalRootA.processId !== finalRootB.processId || + finalRootA.processId !== locatedRuntime.processId || + finalRootA.directory !== finalRootB.directory + ) { + throw new Error( + 'completed roots no longer resolve to the original Kilo process and directory' + ); + } + const ownedWaiters = waiters.tags.filter( + waiter => (waiter.tag === rootAGate || waiter.tag === rootBGate) && waiter.count > 0 + ); + if (ownedWaiters.length > 0) { + throw new Error('Gate 0 left an owned fake-model gate engaged after completion'); + } + + finished = true; + const directoryFingerprint = createHash('sha256') + .update(locatedRuntime.directory) + .digest('hex') + .slice(0, 16); + return { + name: 'gate-0', + conversation, + ok: true, + message: [ + `workspace=${session.cloudAgentSessionId}`, + `rootA=${rootA.id}`, + `rootB=${rootB.id}`, + `container=${locatedRuntime.container.id}`, + `kiloPid=${locatedRuntime.processId}`, + `directoryFingerprint=${directoryFingerprint}`, + ...(locatedRuntime.logPath ? [`controlLog=${locatedRuntime.logPath}`] : []), + `simultaneousGates=${rootAGateEngaged}/${rootBGateEngaged}`, + `rootAEngagedAfterB=${rootAEngagedAfterB}`, + `completedB=${rootBCompletion.assistantMessageId}`, + `completedA=${rootACompletion.assistantMessageId}`, + ].join('; '), + events: [...stream.events], + durationMs: Date.now() - startedAt, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + name: 'gate-0', + conversation, + ok: false, + message: session + ? `threw: ${message}; workspace=${session.cloudAgentSessionId}; rootA=${session.kiloSessionId}` + : `threw: ${message}`, + events: stream ? [...stream.events] : [], + durationMs: Date.now() - startedAt, + }; + } finally { + stream?.close(); + await Promise.all([ + releaseGate(config.fakeLlmUrl, rootAGate).catch(() => undefined), + releaseGate(config.fakeLlmUrl, rootBGate).catch(() => undefined), + ]); + if (!finished && session) { + await interruptSession(config, session.cloudAgentSessionId).catch(() => undefined); + } + if (ownedSandbox && session) { + const sandbox = ownedSandbox; + await stopOwnedControlPlaneSandbox(sandbox, session.kiloSessionId).catch(error => { + const message = error instanceof Error ? error.message : 'unknown Docker error'; + console.warn(`gate-0 owned sandbox cleanup failed (${sandbox.id}): ${message}`); + }); + } + } +} + +type WorktreeOwnershipRow = { + sessionId: string; + userId: string; + organizationId: string | null; + parentSessionId: string | null; + cloudAgentSessionId: string | null; + cloudAgentSessionScopeId: string | null; + worktreeId: string | null; +}; + +type PublicTranscriptEntry = { info: Message; parts: Part[] }; + +type PublicKiloClient = ReturnType; + +async function readWorktreeOwnership( + config: DriverConfig, + kiloSessionIds: string[] +): Promise { + const driver = createDrizzleClient({ + connectionString: process.env.DATABASE_URL ?? computeDatabaseUrl(), + poolConfig: { application_name: 'cloud-agent-next-worktree-e2e', max: 1 }, + }); + try { + return await driver.db + .select({ + sessionId: cli_sessions_v2.session_id, + userId: cli_sessions_v2.kilo_user_id, + organizationId: cli_sessions_v2.organization_id, + parentSessionId: cli_sessions_v2.parent_session_id, + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, config.user.id), + inArray(cli_sessions_v2.session_id, kiloSessionIds) + ) + ); + } finally { + await driver.pool.end(); + } +} + +function requireWorktreeSessionIdentity(session: WorktreeSessionResult, label: string): void { + if (!/^workspace_[0-9a-f-]{36}$/i.test(session.cloudAgentSessionId)) { + throw new Error(`${label} did not receive a control-plane workspace_* identity`); + } + if (!/^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$/.test(session.kiloSessionId)) { + throw new Error(`${label} did not receive a valid root ses_* identity`); + } +} + +async function requireWorktreeGate( + config: DriverConfig, + tag: string, + timeoutMs: number, + stream?: StreamConnection +): Promise { + const deadline = Date.now() + timeoutMs; + const firstEvent = stream?.events.length ?? 0; + while (Date.now() < deadline) { + const [engaged, status] = await Promise.all([ + waitForGateEngaged(config, tag, 200, 50), + fetchFakeScenarioStatus(config.fakeLlmUrl, tag), + ]); + if (status.unsupportedToolSchema) { + throw new Error(`unsupported real Kilo tool schema for fake directive ${tag}`); + } + if (engaged) return; + const failure = stream?.events + .slice(firstEvent) + .find(event => + ['error', 'interrupted', 'cloud.message.failed'].includes(event.streamEventType) + ); + if (failure) { + throw new Error( + `fake directive ${tag} terminated as ${failure.streamEventType} before gating` + ); + } + } + const status = await fetchFakeScenarioStatus(config.fakeLlmUrl, tag); + if (status.requests > 0 && Object.values(status.toolCalls).every(count => count === 0)) { + throw new Error(`required real Kilo tool schema was not advertised for fake directive ${tag}`); + } + throw new Error( + `fake directive ${tag} did not engage within ${timeoutMs}ms; requests=${status.requests}; toolCalls=${JSON.stringify(status.toolCalls)}; toolResults=${JSON.stringify(status.toolResults)}` + ); +} + +async function requirePublicSessionProjection( + client: PublicKiloClient, + kiloSessionId: string, + privateDirectory: string +): Promise { + const result = await client.session.get({ sessionID: kiloSessionId }); + if (result.error !== undefined || result.data === undefined) { + throw new Error(`public SDK session ${kiloSessionId} returned HTTP ${result.response.status}`); + } + const expectedDirectory = `/cloud-agent/sessions/${kiloSessionId}`; + if (result.data.id !== kiloSessionId || result.data.directory !== expectedDirectory) { + throw new Error(`public SDK session ${kiloSessionId} did not expose its synthetic directory`); + } + const serialized = JSON.stringify(result.data); + if (serialized.includes(privateDirectory) || result.data.path !== undefined) { + throw new Error(`public SDK session ${kiloSessionId} exposed private checkout data`); + } + return expectedDirectory; +} + +function publicAssistantText(entry: PublicTranscriptEntry): string { + return entry.parts + .filter(part => part.type === 'text') + .map(part => part.text) + .join(''); +} + +async function waitForPublicTranscript( + client: PublicKiloClient, + kiloSessionId: string, + marker: string, + timeoutMs: number +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const result = await client.session.messages({ sessionID: kiloSessionId, limit: 100 }); + if (result.error === undefined && result.data !== undefined) { + const entries = result.data; + if ( + entries.some( + entry => + entry.info.role === 'assistant' && + entry.info.sessionID === kiloSessionId && + publicAssistantText(entry).includes(marker) + ) + ) { + return entries; + } + } + await new Promise(resolve => setTimeout(resolve, 150)); + } + throw new Error(`persisted transcript ${kiloSessionId} did not contain its completion marker`); +} + +function questionFromEvent( + event: StreamEvent, + kiloSessionId: string +): { id: string; sessionId: string } | null { + if (event.streamEventType !== 'kilocode') return null; + const data = event.data; + if (data.type !== 'question.asked' && data.event !== 'question.asked') return null; + const properties = data.properties; + if (typeof properties !== 'object' || properties === null) return null; + if (!('id' in properties) || !('sessionID' in properties)) return null; + if (typeof properties.id !== 'string' || properties.sessionID !== kiloSessionId) return null; + return { id: properties.id, sessionId: kiloSessionId }; +} + +async function waitForWorktreeQuestion( + config: DriverConfig, + stream: StreamConnection, + kiloSessionId: string, + tag: string, + timeoutMs: number +): Promise<{ id: string; sessionId: string } | 'unsupported'> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const existing = stream.events + .map(event => questionFromEvent(event, kiloSessionId)) + .find(question => question !== null); + if (existing) return existing; + const status = await fetchFakeScenarioStatus(config.fakeLlmUrl, tag); + if (status.unsupportedToolSchema) return 'unsupported'; + const matching = await stream.waitFor( + event => questionFromEvent(event, kiloSessionId) !== null, + Math.min(250, deadline - Date.now()) + ); + if (matching) { + const question = questionFromEvent(matching, kiloSessionId); + if (question) return question; + } + } + const status = await fetchFakeScenarioStatus(config.fakeLlmUrl, tag); + if (status.toolCalls.question === 0) return 'unsupported'; + throw new Error(`root-scoped question ${tag} did not reach its owning stream`); +} + +async function waitForOwnedCompletion( + runtime: ControlPlaneKiloRuntime, + session: WorktreeSessionResult, + messageId: string, + marker: string, + timeoutMs = 15_000 +): Promise { + await waitForControlPlaneKiloCompletion(runtime, { + kiloSessionId: session.kiloSessionId, + messageId, + expectedText: marker, + timeoutMs, + }); +} + +export async function lifecycleWorktreeShared(args: LifecycleArgs): Promise { + const startedAt = Date.now(); + const { config, conversation, api = 'unified', timeoutMs = 120_000 } = args; + const kiloConfig = { ...config, model: config.model.replace(/^kilo\//, '') }; + const runId = randomUUID(); + const ownerTags = new Set(); + const events: StreamEvent[] = []; + const streams = new Set(); + const initialTag = `a-share-${runId}`; + const siblingTag = `b-share-${runId}`; + const filename = `worktree-e2e-${runId}.txt`; + const originalContents = `original-${runId}`; + const replacementContents = `edited-${runId}`; + const initialMarker = `done-${initialTag}`; + const siblingMarker = `done-${siblingTag}`; + let rootA: WorktreeSessionResult | undefined; + let rootB: WorktreeSessionResult | undefined; + let ownedSandbox: SandboxContainer | undefined; + + function connect(sessionId: string): StreamConnection { + const stream = openStream(config, sessionId, { onEvent: event => events.push(event) }); + streams.add(stream); + return stream; + } + + async function releaseOwned(tag: string): Promise { + await releaseGate(config.fakeLlmUrl, tag); + ownerTags.delete(tag); + } + + try { + if (api !== 'unified') { + throw new Error('worktree-shared requires the trusted browser-equivalent creation boundary'); + } + + const createOperationKey = randomUUID(); + const bootstrapMarker = `bootstrap-${runId}`; + const bootstrapPrompt = fakeDirective(`echo:${bootstrapMarker}`); + rootA = await prepareBrowserSession(kiloConfig, { + prompt: bootstrapPrompt, + operationKey: createOperationKey, + }); + requireWorktreeSessionIdentity(rootA, 'first chat'); + const streamA = connect(rootA.cloudAgentSessionId); + const runtime = await waitForControlPlaneKiloRuntime( + rootA.kiloSessionId, + Math.min(timeoutMs, 75_000), + sandbox => { + ownedSandbox = sandbox; + } + ); + if (!runtime) { + throw new Error('first chat did not resolve to its owned prepared control-plane sandbox'); + } + + const firstMetadata = await getSessionSnapshot(config, rootA.cloudAgentSessionId); + const initialMessageId = firstMetadata.initialMessageId; + if ( + firstMetadata.userId !== config.user.id || + firstMetadata.kiloSessionId !== rootA.kiloSessionId || + firstMetadata.autoCommit !== false || + typeof initialMessageId !== 'string' + ) { + throw new Error('first chat did not preserve ownership and authoritative auto-commit policy'); + } + await waitForOwnedCompletion(runtime, rootA, initialMessageId, bootstrapMarker, 20_000); + const replayedFirstChat = await prepareBrowserSession(kiloConfig, { + prompt: bootstrapPrompt, + operationKey: createOperationKey, + }); + if ( + replayedFirstChat.cloudAgentSessionId !== rootA.cloudAgentSessionId || + replayedFirstChat.kiloSessionId !== rootA.kiloSessionId + ) { + throw new Error( + 'same-key browser creation did not replay its canonical first-chat identities' + ); + } + const firstOwnership = await readWorktreeOwnership(config, [rootA.kiloSessionId]); + const sourceRow = firstOwnership[0]; + if ( + firstOwnership.length !== 1 || + !sourceRow || + sourceRow.worktreeId !== rootA.cloudAgentSessionId.replace(/^workspace_/, 'worktree_') || + sourceRow.parentSessionId !== null || + sourceRow.cloudAgentSessionScopeId !== rootA.cloudAgentSessionId || + !runtime.directory.endsWith(`/worktrees/${sourceRow.worktreeId}`) + ) { + throw new Error('first chat did not persist one canonical root ownership/worktree record'); + } + ownerTags.add(initialTag); + const firstSharedMessage = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootA.cloudAgentSessionId, + prompt: fakeDirective(`write-then-gate:${initialTag}:${filename}:${originalContents}`), + }); + await requireWorktreeGate(config, initialTag, 20_000, streamA); + const writtenFile = await inspectControlPlaneWorkspaceFile(runtime, { + kiloSessionId: rootA.kiloSessionId, + filePath: filename, + }); + if (!writtenFile.exists || writtenFile.contents !== originalContents || !writtenFile.dirty) { + throw new Error('first chat did not create the dirty shared file through its real Kilo tool'); + } + + const requestsBeforeSibling = await fetchFakeRequests(config.fakeLlmUrl); + const siblingOperationKey = randomUUID(); + rootB = await createWorktreeChat(kiloConfig, { + sourceKiloSessionId: rootA.kiloSessionId, + sourceCloudAgentSessionId: rootA.cloudAgentSessionId, + operationKey: siblingOperationKey, + }); + requireWorktreeSessionIdentity(rootB, 'sibling chat'); + if ( + rootB.cloudAgentSessionId === rootA.cloudAgentSessionId || + rootB.kiloSessionId === rootA.kiloSessionId || + rootB.worktreeId !== sourceRow.worktreeId + ) { + throw new Error('sibling chat did not receive distinct identities in the source worktree'); + } + const requestsAfterSibling = await fetchFakeRequests(config.fakeLlmUrl); + if (requestsAfterSibling.chatCompletions !== requestsBeforeSibling.chatCompletions) { + throw new Error('lazy sibling creation unexpectedly invoked the model'); + } + if (await controlPlaneKiloRootExists(runtime, rootB.kiloSessionId)) { + throw new Error('lazy sibling creation unexpectedly attached its Kilo root'); + } + if (!(await waitForGateEngaged(config, initialTag, 500))) { + throw new Error('sibling creation interrupted the active first chat'); + } + + const siblingReplay = await createWorktreeChat(kiloConfig, { + sourceKiloSessionId: rootA.kiloSessionId, + sourceCloudAgentSessionId: rootA.cloudAgentSessionId, + operationKey: siblingOperationKey, + }); + if ( + siblingReplay.cloudAgentSessionId !== rootB.cloudAgentSessionId || + siblingReplay.kiloSessionId !== rootB.kiloSessionId || + siblingReplay.worktreeId !== rootB.worktreeId + ) { + throw new Error('same-key sibling creation did not replay its canonical identities'); + } + + const ownership = await readWorktreeOwnership(config, [ + rootA.kiloSessionId, + rootB.kiloSessionId, + ]); + const siblingRow = ownership.find(row => row.sessionId === rootB?.kiloSessionId); + if ( + ownership.length !== 2 || + !siblingRow || + siblingRow.userId !== sourceRow.userId || + siblingRow.organizationId !== sourceRow.organizationId || + siblingRow.worktreeId !== sourceRow.worktreeId || + siblingRow.parentSessionId !== null || + siblingRow.cloudAgentSessionId !== rootB.cloudAgentSessionId || + siblingRow.cloudAgentSessionScopeId !== rootB.cloudAgentSessionId || + siblingRow.cloudAgentSessionScopeId === sourceRow.cloudAgentSessionScopeId + ) { + throw new Error( + 'siblings did not retain independent root ownership and authorization scopes' + ); + } + const siblingMetadata = await getSessionSnapshot(config, rootB.cloudAgentSessionId); + if ( + !firstMetadata.sandboxId || + siblingMetadata.sandboxId !== firstMetadata.sandboxId || + siblingMetadata.userId !== firstMetadata.userId || + siblingMetadata.orgId !== firstMetadata.orgId || + siblingMetadata.gitUrl !== firstMetadata.gitUrl || + siblingMetadata.githubRepo !== firstMetadata.githubRepo || + siblingMetadata.platform !== firstMetadata.platform || + siblingMetadata.upstreamBranch !== firstMetadata.upstreamBranch || + siblingMetadata.autoCommit !== false + ) { + throw new Error( + 'sibling runtime metadata did not preserve the source physical route and policy' + ); + } + + const publicClient = createKiloClient({ + baseUrl: `${config.workerUrl.replace(/\/$/, '')}/kilo`, + headers: { Authorization: `Bearer ${mintApiToken(config.user, config.nextAuthSecret)}` }, + }); + const [publicDirectoryA, publicDirectoryB] = await Promise.all([ + requirePublicSessionProjection(publicClient, rootA.kiloSessionId, runtime.directory), + requirePublicSessionProjection(publicClient, rootB.kiloSessionId, runtime.directory), + ]); + if (publicDirectoryA === publicDirectoryB) { + throw new Error('sibling public SDK directory projections were not independently scoped'); + } + + let streamB = connect(rootB.cloudAgentSessionId); + ownerTags.add(siblingTag); + const siblingMessage = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootB.cloudAgentSessionId, + prompt: fakeDirective(`read-edit-then-gate:${siblingTag}:${filename}:${replacementContents}`), + }); + await requireWorktreeGate(config, siblingTag, 40_000, streamB); + const [gateA, gateB, inspectedA, inspectedB, writerStatus, readerStatus] = await Promise.all([ + waitForGateEngaged(config, initialTag, 500), + waitForGateEngaged(config, siblingTag, 500), + inspectControlPlaneKiloRoot(runtime, rootA.kiloSessionId), + inspectControlPlaneKiloRoot(runtime, rootB.kiloSessionId), + fetchFakeScenarioStatus(config.fakeLlmUrl, initialTag), + fetchFakeScenarioStatus(config.fakeLlmUrl, siblingTag), + ]); + if ( + !gateA || + !gateB || + inspectedA.processId !== inspectedB.processId || + inspectedA.processId !== runtime.processId || + inspectedA.directory !== inspectedB.directory || + writerStatus.toolCalls.write < 1 || + writerStatus.toolResults.write < 1 || + readerStatus.toolCalls.read < 1 || + readerStatus.toolResults.read < 1 || + readerStatus.toolCalls.edit < 1 || + readerStatus.toolResults.edit < 1 + ) { + throw new Error( + 'siblings did not simultaneously execute genuine file tools in one Kilo process' + ); + } + const [activeA, activeB, editedFile] = await Promise.all([ + getSessionSnapshot(config, rootA.cloudAgentSessionId), + getSessionSnapshot(config, rootB.cloudAgentSessionId), + inspectControlPlaneWorkspaceFile(runtime, { + kiloSessionId: rootB.kiloSessionId, + filePath: filename, + }), + ]); + if ( + activeA.execution?.status !== 'running' || + activeB.execution?.status !== 'running' || + editedFile.contents !== replacementContents || + !editedFile.dirty || + editedFile.head !== writtenFile.head + ) { + throw new Error('shared file/runtime state did not show two active uncommitted root turns'); + } + + await releaseOwned(siblingTag); + await waitForOwnedCompletion(runtime, rootB, siblingMessage.messageId, siblingMarker); + if (!(await waitForGateEngaged(config, initialTag, 500))) { + throw new Error('completing the sibling unexpectedly interrupted the first chat'); + } + await releaseOwned(initialTag); + await waitForOwnedCompletion(runtime, rootA, firstSharedMessage.messageId, initialMarker); + + const [transcriptA, transcriptB] = await Promise.all([ + waitForPublicTranscript(publicClient, rootA.kiloSessionId, initialMarker, 15_000), + waitForPublicTranscript(publicClient, rootB.kiloSessionId, siblingMarker, 15_000), + ]); + if ( + transcriptA.some(entry => publicAssistantText(entry).includes(siblingMarker)) || + transcriptB.some(entry => publicAssistantText(entry).includes(initialMarker)) || + JSON.stringify(transcriptA).includes(runtime.directory) || + JSON.stringify(transcriptB).includes(runtime.directory) || + transcriptA.some( + entry => + entry.info.role === 'assistant' && + (entry.info.path.cwd !== publicDirectoryA || entry.info.path.root !== publicDirectoryA) + ) || + transcriptB.some( + entry => + entry.info.role === 'assistant' && + (entry.info.path.cwd !== publicDirectoryB || entry.info.path.root !== publicDirectoryB) + ) || + !transcriptA.some(entry => + entry.parts.some(part => part.type === 'tool' && part.state.status === 'completed') + ) || + !transcriptB.some(entry => + entry.parts.some(part => part.type === 'tool' && part.state.status === 'completed') + ) + ) { + throw new Error( + 'durable sibling transcripts leaked attribution or omitted completed real tools' + ); + } + + const questionGate = `a-question-${runId}`; + const questionTag = `b-question-${runId}`; + const questionMarker = `done-${questionTag}`; + ownerTags.add(questionGate); + const gatedQuestion = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootA.cloudAgentSessionId, + prompt: fakeDirective(`gate:${questionGate}:done-${questionGate}`), + }); + await requireWorktreeGate(config, questionGate, 12_000, streamA); + const questionMessage = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootB.cloudAgentSessionId, + prompt: fakeDirective(`question:${questionTag}:Choose the isolated sibling answer`), + }); + const pendingQuestion = await waitForWorktreeQuestion( + config, + streamB, + rootB.kiloSessionId, + questionTag, + 12_000 + ); + let questionCoverage = 'unsupported-tool-schema'; + let questionRefresh = 'unsupported'; + if (pendingQuestion !== 'unsupported') { + const questionVisibility = await inspectControlPlaneQuestions(runtime, { + kiloSessionId: rootB.kiloSessionId, + questionId: pendingQuestion.id, + }); + if (!questionVisibility.scoped.matchingQuestion) { + throw new Error( + `owning Kilo question is not visible in its checkout; unscoped=${questionVisibility.unscoped.count}; scoped=${questionVisibility.scoped.count}` + ); + } + if ( + streamA.events.some(event => questionFromEvent(event, rootB?.kiloSessionId ?? '') !== null) + ) { + throw new Error('the sibling question appeared on the first root stream'); + } + let wrongRootRejected = false; + try { + const result = await answerQuestion(config, rootA.cloudAgentSessionId, pendingQuestion.id, [ + ['Continue'], + ]); + wrongRootRejected = result.success !== true; + } catch { + wrongRootRejected = true; + } + if (!wrongRootRejected) { + throw new Error('the first root was allowed to answer the sibling question'); + } + + const requestsBeforeRefresh = await fetchFakeScenarioStatus(config.fakeLlmUrl, questionTag); + streamB.close(); + streams.delete(streamB); + streamB = connect(rootB.cloudAgentSessionId); + const refreshed = await streamB.waitFor( + event => questionFromEvent(event, rootB?.kiloSessionId ?? '')?.id === pendingQuestion.id, + 4_000 + ); + const requestsAfterRefresh = await fetchFakeScenarioStatus(config.fakeLlmUrl, questionTag); + if (requestsAfterRefresh.requests !== requestsBeforeRefresh.requests) { + throw new Error( + 'reconnecting the sibling question unexpectedly started another model turn' + ); + } + if (!refreshed) { + throw new Error('the owning root did not replay its still-open question after refresh'); + } + questionRefresh = 'replayed'; + const answer = await answerQuestion(config, rootB.cloudAgentSessionId, pendingQuestion.id, [ + ['Continue'], + ]); + if (!answer.success) { + throw new Error('the owning sibling could not resolve its real Kilo question'); + } + await waitForOwnedCompletion( + runtime, + rootB, + questionMessage.messageId, + questionMarker, + 10_000 + ); + questionCoverage = 'isolated'; + } else { + throw new Error('the real Kilo question tool is unavailable or its schema is unsupported'); + } + if (!(await waitForGateEngaged(config, questionGate, 500))) { + throw new Error('sibling question handling unexpectedly settled the first root'); + } + await releaseOwned(questionGate); + await waitForOwnedCompletion(runtime, rootA, gatedQuestion.messageId, `done-${questionGate}`); + + const cancellationGateA = `a-cancel-${runId}`; + const cancellationGateB = `b-cancel-${runId}`; + ownerTags.add(cancellationGateA); + ownerTags.add(cancellationGateB); + const cancelTurnA = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootA.cloudAgentSessionId, + prompt: fakeDirective(`gate:${cancellationGateA}:done-${cancellationGateA}`), + }); + await requireWorktreeGate(config, cancellationGateA, 12_000, streamA); + await sendMessage(kiloConfig, { + cloudAgentSessionId: rootB.cloudAgentSessionId, + prompt: fakeDirective(`gate:${cancellationGateB}:done-${cancellationGateB}`), + }); + await requireWorktreeGate(config, cancellationGateB, 12_000, streamB); + if (!(await waitForGateEngaged(config, cancellationGateA, 500))) { + throw new Error('cancellation gates did not engage simultaneously'); + } + const cancellationEventStart = streamB.events.length; + const interruptedPromise = streamB.waitFor( + event => + streamB.events.indexOf(event) >= cancellationEventStart && + ['interrupted', 'cloud.message.failed'].includes(event.streamEventType), + 5_000 + ); + const interruption = await interruptSession(config, rootB.cloudAgentSessionId); + if (!interruption.success) { + throw new Error('targeted sibling cancellation was not accepted'); + } + const interrupted = await interruptedPromise; + if (!interrupted || !(await waitForGateEngaged(config, cancellationGateA, 500))) { + throw new Error('targeted cancellation did not preserve the active first root'); + } + await releaseOwned(cancellationGateA); + await waitForOwnedCompletion( + runtime, + rootA, + cancelTurnA.messageId, + `done-${cancellationGateA}` + ); + + const [persistedA, persistedB] = await Promise.all([ + getSessionSnapshot(config, rootA.cloudAgentSessionId), + getSessionSnapshot(config, rootB.cloudAgentSessionId), + ]); + if ( + typeof persistedA.latestEventId !== 'number' || + typeof persistedB.latestEventId !== 'number' || + persistedA.latestEventId < 1 || + persistedB.latestEventId < 1 + ) { + throw new Error( + 'sibling Durable Objects did not expose independent persisted event watermarks' + ); + } + + const deletionGate = `a-delete-${runId}`; + ownerTags.add(deletionGate); + const deleteTurn = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootA.cloudAgentSessionId, + prompt: fakeDirective(`gate:${deletionGate}:done-${deletionGate}`), + }); + await requireWorktreeGate(config, deletionGate, 12_000, streamA); + const deleted = await deleteSession(config, rootB.cloudAgentSessionId); + if (!deleted.success) { + throw new Error('targeted sibling runtime deletion returned success: false'); + } + let deletedMetadataRejected = false; + try { + await getSessionSnapshot(config, rootB.cloudAgentSessionId); + } catch { + deletedMetadataRejected = true; + } + if (!deletedMetadataRejected || !(await waitForGateEngaged(config, deletionGate, 500))) { + throw new Error('sibling deletion removed the first root or left sibling runtime metadata'); + } + const survivingRoot = await inspectControlPlaneKiloRoot(runtime, rootA.kiloSessionId); + if (survivingRoot.processId !== runtime.processId) { + throw new Error('sibling deletion replaced or detached the surviving Kilo runtime'); + } + await releaseOwned(deletionGate); + await waitForOwnedCompletion(runtime, rootA, deleteTurn.messageId, `done-${deletionGate}`); + const afterDelete = await sendMessage(kiloConfig, { + cloudAgentSessionId: rootA.cloudAgentSessionId, + prompt: fakeDirective(`echo:survived-${runId}`), + }); + await waitForOwnedCompletion(runtime, rootA, afterDelete.messageId, `survived-${runId}`); + const finalFile = await inspectControlPlaneWorkspaceFile(runtime, { + kiloSessionId: rootA.kiloSessionId, + filePath: filename, + }); + if ( + finalFile.contents !== replacementContents || + !finalFile.dirty || + finalFile.head !== writtenFile.head + ) { + throw new Error('grouped turns committed or lost the shared agent-edited checkout file'); + } + + const directoryFingerprint = createHash('sha256') + .update(runtime.directory) + .digest('hex') + .slice(0, 16); + return { + name: 'worktree-shared', + conversation, + ok: true, + message: [ + `worktree=${sourceRow.worktreeId}`, + `workspaceA=${rootA.cloudAgentSessionId}`, + `workspaceB=${rootB.cloudAgentSessionId}`, + `rootA=${rootA.kiloSessionId}`, + `rootB=${rootB.kiloSessionId}`, + `sandbox=${firstMetadata.sandboxId}`, + `container=${runtime.container.id}`, + `kiloPid=${runtime.processId}`, + `directoryFingerprint=${directoryFingerprint}`, + `file=${filename}`, + 'lazySibling=true', + 'simultaneousGates=true', + 'realFileTools=true', + `question=${questionCoverage}`, + `questionRefresh=${questionRefresh}`, + 'targetedCancellation=true', + 'targetedRuntimeDeletion=true', + `eventWatermarks=${persistedA.latestEventId}/${persistedB.latestEventId}`, + 'publicDirectories=isolated', + 'autoCommit=false', + ].join('; '), + events, + durationMs: Date.now() - startedAt, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + name: 'worktree-shared', + conversation, + ok: false, + message: `threw: ${message}`, + events, + durationMs: Date.now() - startedAt, + }; + } finally { + for (const stream of streams) stream.close(); + await Promise.all( + [...ownerTags].map(tag => releaseGate(config.fakeLlmUrl, tag).catch(() => undefined)) + ); + if (rootB) { + await interruptSession(config, rootB.cloudAgentSessionId).catch(() => undefined); + } + if (rootA) { + await interruptSession(config, rootA.cloudAgentSessionId).catch(() => undefined); + } + if (ownedSandbox && rootA) { + await stopOwnedControlPlaneSandbox(ownedSandbox, rootA.kiloSessionId).catch(error => { + const message = error instanceof Error ? error.message : 'unknown Docker error'; + console.warn(`worktree-shared owned sandbox cleanup failed: ${message}`); + }); + } + } +} + // --------------------------------------------------------------------------- // Scenarios // --------------------------------------------------------------------------- @@ -274,6 +1224,16 @@ export async function lifecycleCold(args: LifecycleArgs): Promise Promise > = { + 'gate-0': lifecycleGateZero, + 'worktree-shared': lifecycleWorktreeShared, cold: lifecycleCold, hot: lifecycleHot, followup: lifecycleFollowup, diff --git a/services/cloud-agent-next/test/e2e/run.ts b/services/cloud-agent-next/test/e2e/run.ts index 0060d8549e..0d8d18a30c 100644 --- a/services/cloud-agent-next/test/e2e/run.ts +++ b/services/cloud-agent-next/test/e2e/run.ts @@ -20,6 +20,7 @@ * the session port offset is non-zero. */ +import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ensureTestUser, loadDevVars, loadRepoEnvFiles, DRIVER_USER_EMAIL_SUFFIX } from './auth.js'; @@ -148,7 +149,9 @@ async function main(): Promise { loadRepoEnvFiles(SERVICE_PACKAGE_DIR); const devVars = loadDevVars(SERVICE_PACKAGE_DIR); const email = - process.env.E2E_USER_EMAIL ?? `kilo-e2e-driver-${Date.now()}${DRIVER_USER_EMAIL_SUFFIX}`; + lifecycle === 'worktree-shared' + ? `kilo-worktree-e2e-${randomUUID()}${DRIVER_USER_EMAIL_SUFFIX}` + : (process.env.E2E_USER_EMAIL ?? `kilo-e2e-driver-${Date.now()}${DRIVER_USER_EMAIL_SUFFIX}`); const user = await ensureTestUser(process.env.DATABASE_URL, email, { funded: process.env.E2E_FUNDED === '1', }); diff --git a/services/cloud-agent-next/test/e2e/sandbox-control.ts b/services/cloud-agent-next/test/e2e/sandbox-control.ts index 9671f105ab..b10f70aacb 100644 --- a/services/cloud-agent-next/test/e2e/sandbox-control.ts +++ b/services/cloud-agent-next/test/e2e/sandbox-control.ts @@ -29,6 +29,711 @@ export type SandboxContainer = { isProxy: boolean; }; +export type ControlPlaneKiloRuntime = { + container: SandboxContainer; + kiloSessionId: string; + serverUrl: string; + directory: string; + processId: number; + logPath?: string; +}; + +export type ControlPlaneKiloRoot = { + id: string; + directory: string; + processId: number; +}; + +export type ControlPlaneKiloCompletion = { + sessionId: string; + messageId: string; + assistantMessageId: string; +}; + +export type ControlPlaneWorkspaceFile = { + exists: boolean; + contents?: string; + dirty: boolean; + head: string; +}; + +export type ControlPlaneQuestionVisibility = { + unscoped: { status: number; count: number; matchingQuestion: boolean }; + scoped: { status: number; count: number; matchingQuestion: boolean }; +}; + +type ControlPlaneKiloOperation = { + action: + | 'discover' + | 'inspect' + | 'exists' + | 'import' + | 'prompt' + | 'completion' + | 'file' + | 'questions' + | 'exclusive'; + kiloSessionId: string; + serverUrl?: string; + directory?: string; + processId?: number; + ownerKiloSessionId?: string; + sourceKiloSessionId?: string; + messageId?: string; + gateTag?: string; + model?: string; + expectedText?: string; + filePath?: string; + questionId?: string; +}; + +const CONTROL_PLANE_KILO_SCRIPT = String.raw` +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const request = JSON.parse(process.argv[1] ?? '{}'); + +function kiloListeners() { + const sockets = new Map(); + for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) { + let rows; + try { + rows = fs.readFileSync(table, 'utf8').split('\n').slice(1); + } catch { + continue; + } + for (const row of rows) { + const fields = row.trim().split(/\s+/); + const [address, hexPort] = fields[1]?.split(':') ?? []; + const host = address === '0100007F' || address === '0000000000000000FFFF00000100007F' + ? '127.0.0.1' + : address === '00000000000000000000000001000000' ? '[::1]' : null; + const port = parseInt(hexPort ?? '', 16); + if (fields[3] !== '0A' || !host || !Number.isInteger(port) || port < 1 || port > 65535 || !fields[9]) continue; + sockets.set(fields[9], { serverUrl: 'http://' + host + ':' + port, owners: new Set() }); + } + } + for (const pid of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(pid)) continue; + let descriptors; + try { + descriptors = fs.readdirSync('/proc/' + pid + '/fd'); + } catch { + continue; + } + for (const fd of descriptors) { + try { + const inode = fs.readlinkSync('/proc/' + pid + '/fd/' + fd).match(/^socket:\[(\d+)\]$/)?.[1]; + sockets.get(inode)?.owners.add(Number(pid)); + } catch { + continue; + } + } + } + const listeners = []; + for (const { serverUrl, owners } of sockets.values()) { + if (owners.size !== 1) continue; + const [processId] = owners; + try { + if (path.basename(fs.readlinkSync('/proc/' + processId + '/exe')) !== 'kilo') continue; + const argv = fs.readFileSync('/proc/' + processId + '/cmdline', 'utf8').split('\0'); + if (path.basename(argv[0]) !== 'kilo' || argv[1] !== 'serve') continue; + const cwd = fs.readlinkSync('/proc/' + processId + '/cwd'); + const directory = fs.realpathSync(cwd); + if (!path.isAbsolute(cwd) || cwd !== directory) continue; + if (!fs.existsSync(path.join(directory, '.kilo-bootstrap-complete'))) continue; + listeners.push({ serverUrl, processId, directory }); + } catch { + continue; + } + } + return listeners; +} + +function sameListener(left, right) { + return left.serverUrl === right.serverUrl && left.processId === right.processId && left.directory === right.directory; +} + +async function getRoot(serverUrl, kiloSessionId, directory = request.directory) { + const endpoint = new URL('/session/' + encodeURIComponent(kiloSessionId), serverUrl); + endpoint.searchParams.set('directory', directory); + const response = await fetch(endpoint, { signal: AbortSignal.timeout(3_000), redirect: 'error' }); + if (!response.ok) return null; + const root = await response.json(); + if ( + typeof root !== 'object' || + root === null || + root.id !== kiloSessionId || + root.directory !== directory || + (root.parentID !== undefined && root.parentID !== null) || + fs.realpathSync(root.directory) !== directory + ) { + return null; + } + return { id: root.id, directory: root.directory, parentID: null }; +} + +function rootResult(root, serverUrl) { + const matches = kiloListeners().filter(listener => sameListener(listener, request)); + if (matches.length !== 1 || serverUrl !== request.serverUrl || root.directory !== request.directory) { + return { ok: false, reason: 'Kilo listener identity changed' }; + } + return { ok: true, id: root.id, directory: root.directory, processId: request.processId }; +} + +async function run() { + if (request.action === 'discover') { + const matches = []; + for (const listener of kiloListeners()) { + let root; + try { + root = await getRoot(listener.serverUrl, request.kiloSessionId, listener.directory); + } catch { + continue; + } + if (!root) continue; + const current = kiloListeners().filter(candidate => sameListener(candidate, listener)); + if (current.length !== 1) continue; + matches.push({ ...listener, kiloSessionId: root.id }); + } + if (matches.length > 1) return { ok: false, reason: 'Ambiguous Kilo root listener ownership' }; + const match = matches[0]; + if (!match) return { ok: true, matched: false }; + let logPath; + try { + for (const name of fs.readdirSync('/tmp')) { + if (!/^kilocode-control-wrapper(?:-[^/]+)?\.log$/.test(name)) continue; + const candidate = path.join('/tmp', name); + const lines = fs.readFileSync(candidate, 'utf8').split('\n'); + if ( + lines.some(line => line.endsWith('session.attach ready directory=' + match.directory)) && + lines.some(line => line.split(/\s+/).includes('expectedKiloSessionId=' + request.kiloSessionId)) + ) { + logPath = candidate; + break; + } + } + } catch {} + return { ok: true, matched: true, ...match, ...(logPath ? { logPath } : {}) }; + } + + const listeners = kiloListeners().filter(listener => sameListener(listener, request)); + if (listeners.length !== 1 || !(await getRoot(request.serverUrl, request.ownerKiloSessionId))) { + return { ok: false, reason: 'Owned Kilo listener identity did not match' }; + } + + if (request.action === 'exclusive') { + const parent = path.dirname(request.directory); + const directories = fs.readdirSync(parent).filter(name => fs.statSync(path.join(parent, name)).isDirectory()); + const exclusive = path.basename(parent) === 'worktrees' && + directories.length === 1 && path.join(parent, directories[0]) === request.directory && + kiloListeners().every(listener => listener.directory === request.directory); + return { ok: true, exclusive }; + } + + if (request.action === 'inspect') { + const root = await getRoot(request.serverUrl, request.kiloSessionId); + return root ? rootResult(root, request.serverUrl) : { ok: false, reason: 'Kilo root was not found' }; + } + + if (request.action === 'exists') { + const root = await getRoot(request.serverUrl, request.kiloSessionId); + return { ok: true, exists: root !== null && root.parentID === null }; + } + + if (request.action === 'import') { + const source = await getRoot(request.serverUrl, request.sourceKiloSessionId); + if (!source || source.parentID !== null) { + return { ok: false, reason: 'source Kilo root was not found' }; + } + if (await getRoot(request.serverUrl, request.kiloSessionId)) { + return { ok: false, reason: 'new Kilo root already exists' }; + } + const now = Date.now(); + const endpoint = new URL('/kilocode/session-import/session', request.serverUrl); + endpoint.searchParams.set('directory', source.directory); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + signal: AbortSignal.timeout(8_000), + body: JSON.stringify({ + id: request.kiloSessionId, + projectID: 'global', + slug: request.kiloSessionId.slice(0, 24), + directory: source.directory, + title: 'Cloud Agent Gate 0', + version: '7.4.20', + timeCreated: now, + timeUpdated: now, + }), + }); + if (!response.ok) { + return { ok: false, reason: 'Kilo session import returned HTTP ' + response.status }; + } + const root = await getRoot(request.serverUrl, request.kiloSessionId); + if (!root || root.directory !== source.directory) { + return { ok: false, reason: 'imported Kilo root did not preserve the source directory' }; + } + return rootResult(root, request.serverUrl); + } + + const root = await getRoot(request.serverUrl, request.kiloSessionId); + if (!root || root.parentID !== null) { + return { ok: false, reason: 'Kilo root was not found' }; + } + + if (request.action === 'questions') { + const inspectQuestions = async scoped => { + const endpoint = new URL('/question', request.serverUrl); + if (scoped) endpoint.searchParams.set('directory', root.directory); + const response = await fetch(endpoint, { signal: AbortSignal.timeout(5_000) }); + if (!response.ok) return { status: response.status, count: 0, matchingQuestion: false }; + const questions = await response.json(); + if (!Array.isArray(questions)) { + return { status: response.status, count: 0, matchingQuestion: false }; + } + return { + status: response.status, + count: questions.length, + matchingQuestion: questions.some( + question => question?.id === request.questionId && question.sessionID === root.id + ), + }; + }; + const [unscoped, scoped] = await Promise.all([inspectQuestions(false), inspectQuestions(true)]); + return { ok: true, unscoped, scoped }; + } + + if (request.action === 'file') { + if (typeof request.filePath !== 'string' || path.isAbsolute(request.filePath)) { + return { ok: false, reason: 'workspace file path must be relative' }; + } + const absolutePath = path.resolve(root.directory, request.filePath); + if (!absolutePath.startsWith(root.directory + path.sep)) { + return { ok: false, reason: 'workspace file path escaped the checkout' }; + } + const head = execFileSync('git', ['-C', root.directory, 'rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim(); + const status = execFileSync( + 'git', + ['-C', root.directory, 'status', '--porcelain', '--', request.filePath], + { encoding: 'utf8' } + ); + return { + ok: true, + exists: fs.existsSync(absolutePath), + contents: fs.existsSync(absolutePath) ? fs.readFileSync(absolutePath, 'utf8') : undefined, + dirty: status.trim().length > 0, + head, + }; + } + + if (request.action === 'prompt') { + const source = await getRoot(request.serverUrl, request.sourceKiloSessionId); + if (!source || source.directory !== root.directory) { + return { ok: false, reason: 'Kilo roots do not share one directory' }; + } + const endpoint = new URL( + '/session/' + encodeURIComponent(root.id) + '/prompt_async', + request.serverUrl + ); + endpoint.searchParams.set('directory', root.directory); + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + signal: AbortSignal.timeout(8_000), + body: JSON.stringify({ + messageID: request.messageId, + agent: 'code', + model: { providerID: 'kilo', modelID: request.model }, + parts: [{ type: 'text', text: '__fake__:gate:' + request.gateTag }], + }), + }); + return response.ok + ? { ok: true, accepted: true, status: response.status } + : { ok: false, reason: 'Kilo prompt returned HTTP ' + response.status }; + } + + if (request.action === 'completion') { + const endpoint = new URL( + '/session/' + encodeURIComponent(root.id) + '/message', + request.serverUrl + ); + endpoint.searchParams.set('directory', root.directory); + const response = await fetch(endpoint, { signal: AbortSignal.timeout(5_000) }); + if (!response.ok) { + return { ok: false, reason: 'Kilo messages returned HTTP ' + response.status }; + } + const entries = await response.json(); + if (!Array.isArray(entries)) { + return { ok: false, reason: 'Kilo messages response was not an array' }; + } + const assistants = entries.filter(entry => + entry?.info?.role === 'assistant' && + entry.info.sessionID === root.id && + entry.info.parentID === request.messageId + ); + const expectedText = request.expectedText ?? 'done'; + const assistantText = entry => Array.isArray(entry.parts) + ? entry.parts + .filter(part => part?.type === 'text' && typeof part.text === 'string') + .map(part => part.text) + .join('') + : ''; + const assistant = assistants.find(entry => assistantText(entry).includes(expectedText)) ?? assistants.at(-1); + if (!assistant) return { ok: true, found: false }; + return { + ok: true, + found: true, + sessionId: root.id, + messageId: request.messageId, + assistantMessageId: assistant.info.id, + completed: typeof assistant.info.time?.completed === 'number', + failed: assistant.info.error !== undefined, + expectedText: assistantText(assistant).includes(expectedText), + }; + } + + return { ok: false, reason: 'unsupported Kilo operation' }; +} + +run() + .then(result => process.stdout.write(JSON.stringify(result))) + .catch(error => + process.stdout.write( + JSON.stringify({ + ok: false, + reason: + request.action + ' failed (' + (error instanceof Error ? error.name : 'unknown') + ')', + }) + ) + ); +`; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +async function runControlPlaneKiloOperation( + containerId: string, + operation: ControlPlaneKiloOperation, + executeDocker: DockerCommandExecutor = executeDockerCommand +): Promise> { + const { stdout } = await executeDocker([ + 'exec', + containerId, + 'bun', + '-e', + CONTROL_PLANE_KILO_SCRIPT, + JSON.stringify(operation), + ]); + const result: unknown = JSON.parse(stdout.trim()); + if (!isRecord(result)) { + throw new Error(`Kilo ${operation.action} returned an invalid result`); + } + if (result.ok !== true) { + const reason = typeof result.reason === 'string' ? result.reason : 'unknown failure'; + throw new Error(`Kilo ${operation.action} failed: ${reason}`); + } + return result; +} + +function requireControlPlaneKiloRoot( + result: Record, + kiloSessionId: string +): ControlPlaneKiloRoot { + if ( + result.id !== kiloSessionId || + typeof result.directory !== 'string' || + typeof result.processId !== 'number' || + !Number.isSafeInteger(result.processId) || + result.processId <= 0 + ) { + throw new Error(`Kilo root ${kiloSessionId} returned invalid runtime identity`); + } + return { + id: result.id, + directory: result.directory, + processId: result.processId, + }; +} + +export async function findControlPlaneKiloRuntime( + kiloSessionId: string, + executeDocker: DockerCommandExecutor = executeDockerCommand, + onOwnedSandbox?: (sandbox: SandboxContainer) => void +): Promise { + const containers = await listSandboxContainers(executeDocker); + const matches: ControlPlaneKiloRuntime[] = []; + for (const container of containers) { + if (container.isProxy) continue; + let result: Record; + try { + result = await runControlPlaneKiloOperation( + container.id, + { action: 'discover', kiloSessionId }, + executeDocker + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('No such container') || message.includes('is not running')) continue; + throw error; + } + if (result.matched !== true) continue; + if ( + result.kiloSessionId !== kiloSessionId || + typeof result.serverUrl !== 'string' || + !/^http:\/\/(?:127\.0\.0\.1|\[::1\]):\d+$/.test(result.serverUrl) || + typeof result.directory !== 'string' || + !result.directory.startsWith('/') || + typeof result.processId !== 'number' || + !Number.isSafeInteger(result.processId) || + result.processId <= 0 || + (result.logPath !== undefined && typeof result.logPath !== 'string') + ) { + throw new Error(`Kilo root ${kiloSessionId} returned invalid control-wrapper discovery`); + } + matches.push({ + container, + kiloSessionId, + serverUrl: result.serverUrl, + directory: result.directory, + processId: result.processId, + ...(typeof result.logPath === 'string' ? { logPath: result.logPath } : {}), + }); + } + if (matches.length > 1) throw new Error('Ambiguous Kilo root container ownership'); + const runtime = matches[0]; + if (!runtime) return null; + onOwnedSandbox?.(runtime.container); + return runtime; +} + +export async function stopOwnedControlPlaneSandbox( + sandbox: SandboxContainer, + kiloSessionId: string, + executeDocker: DockerCommandExecutor = executeDockerCommand +): Promise { + const runtime = await findControlPlaneKiloRuntime(kiloSessionId, executeDocker); + if (!runtime || runtime.container.id !== sandbox.id || runtime.container.name !== sandbox.name) { + throw new Error('Cannot prove the original sandbox still owns the requested root'); + } + const result = await runControlPlaneKiloOperation( + sandbox.id, + { + action: 'exclusive', + kiloSessionId, + ownerKiloSessionId: kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + }, + executeDocker + ); + if (result.exclusive !== true) + throw new Error('Refusing cleanup of a sandbox with other worktrees'); + await killSandboxFamily(sandbox, executeDocker); +} + +export async function waitForControlPlaneKiloRuntime( + kiloSessionId: string, + timeoutMs: number, + onOwnedSandbox?: (sandbox: SandboxContainer) => void +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const runtime = await findControlPlaneKiloRuntime( + kiloSessionId, + executeDockerCommand, + onOwnedSandbox + ); + if (runtime) return runtime; + await new Promise(resolve => setTimeout(resolve, 500)); + } + return null; +} + +export async function inspectControlPlaneKiloRoot( + runtime: ControlPlaneKiloRuntime, + kiloSessionId: string +): Promise { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'inspect', + kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + }); + return requireControlPlaneKiloRoot(result, kiloSessionId); +} + +export async function controlPlaneKiloRootExists( + runtime: ControlPlaneKiloRuntime, + kiloSessionId: string +): Promise { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'exists', + kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + }); + if (typeof result.exists !== 'boolean') { + throw new Error(`Kilo root ${kiloSessionId} returned invalid existence status`); + } + return result.exists; +} + +export async function inspectControlPlaneWorkspaceFile( + runtime: ControlPlaneKiloRuntime, + input: { kiloSessionId: string; filePath: string } +): Promise { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'file', + kiloSessionId: input.kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + filePath: input.filePath, + }); + if ( + typeof result.exists !== 'boolean' || + typeof result.dirty !== 'boolean' || + typeof result.head !== 'string' || + (result.exists && typeof result.contents !== 'string') + ) { + throw new Error(`Kilo root ${input.kiloSessionId} returned invalid workspace file state`); + } + return { + exists: result.exists, + ...(typeof result.contents === 'string' ? { contents: result.contents } : {}), + dirty: result.dirty, + head: result.head, + }; +} + +export async function inspectControlPlaneQuestions( + runtime: ControlPlaneKiloRuntime, + input: { kiloSessionId: string; questionId: string } +): Promise { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'questions', + kiloSessionId: input.kiloSessionId, + questionId: input.questionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + }); + const { unscoped, scoped } = result; + if ( + !isRecord(unscoped) || + !isRecord(scoped) || + typeof unscoped.status !== 'number' || + typeof unscoped.count !== 'number' || + typeof unscoped.matchingQuestion !== 'boolean' || + typeof scoped.status !== 'number' || + typeof scoped.count !== 'number' || + typeof scoped.matchingQuestion !== 'boolean' + ) { + throw new Error('Kilo question inspection returned invalid sanitized visibility'); + } + return { + unscoped: { + status: unscoped.status, + count: unscoped.count, + matchingQuestion: unscoped.matchingQuestion, + }, + scoped: { + status: scoped.status, + count: scoped.count, + matchingQuestion: scoped.matchingQuestion, + }, + }; +} + +export async function importControlPlaneKiloRoot( + runtime: ControlPlaneKiloRuntime, + kiloSessionId: string +): Promise { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'import', + kiloSessionId, + sourceKiloSessionId: runtime.kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + }); + return requireControlPlaneKiloRoot(result, kiloSessionId); +} + +export async function promptControlPlaneKiloRoot( + runtime: ControlPlaneKiloRuntime, + input: { kiloSessionId: string; messageId: string; gateTag: string; model: string } +): Promise { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'prompt', + kiloSessionId: input.kiloSessionId, + sourceKiloSessionId: runtime.kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + messageId: input.messageId, + gateTag: input.gateTag, + model: input.model, + }); + if (result.accepted !== true) { + throw new Error(`Kilo root ${input.kiloSessionId} did not accept its prompt`); + } +} + +export async function waitForControlPlaneKiloCompletion( + runtime: ControlPlaneKiloRuntime, + input: { kiloSessionId: string; messageId: string; timeoutMs: number; expectedText?: string } +): Promise { + const deadline = Date.now() + input.timeoutMs; + while (Date.now() < deadline) { + const result = await runControlPlaneKiloOperation(runtime.container.id, { + action: 'completion', + kiloSessionId: input.kiloSessionId, + serverUrl: runtime.serverUrl, + directory: runtime.directory, + processId: runtime.processId, + ownerKiloSessionId: runtime.kiloSessionId, + messageId: input.messageId, + ...(input.expectedText ? { expectedText: input.expectedText } : {}), + }); + if (result.found === true) { + if (result.failed === true) { + throw new Error(`Kilo root ${input.kiloSessionId} finished with an assistant error`); + } + if (result.completed === true && result.expectedText === true) { + if ( + result.sessionId !== input.kiloSessionId || + result.messageId !== input.messageId || + typeof result.assistantMessageId !== 'string' + ) { + throw new Error(`Kilo root ${input.kiloSessionId} returned an invalid completion`); + } + return { + sessionId: result.sessionId, + messageId: result.messageId, + assistantMessageId: result.assistantMessageId, + }; + } + } + await new Promise(resolve => setTimeout(resolve, 200)); + } + throw new Error(`Kilo root ${input.kiloSessionId} did not complete within ${input.timeoutMs}ms`); +} + /** * List running sandbox containers. Returns proxy containers separately so * callers can kill them together with their primary. @@ -161,6 +866,11 @@ export async function killSandboxFamily( ): Promise { const familyNames = sandboxFamilyNames(sandbox); const containers = await listSandboxContainers(executeDocker); + if ( + containers.some(container => container.name === sandbox.name && container.id !== sandbox.id) + ) { + throw new Error('Refusing cleanup after sandbox container identity changed'); + } const killed: string[] = []; for (const container of containers) { if (!familyNames.has(container.name)) continue; diff --git a/services/cloud-agent-next/test/integration/sandbox-control.test.ts b/services/cloud-agent-next/test/integration/sandbox-control.test.ts index c681479f4b..6f8e900d94 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control.test.ts @@ -8,7 +8,12 @@ import { } from 'cloudflare:test'; import { drizzle } from 'drizzle-orm/durable-sqlite'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { forceDestroyControlPlaneSandbox } from '../../src/container-usage-context.js'; +import type { BillingContext } from '@kilocode/container-usage'; +import { + forceDestroyControlPlaneSandbox, + SANDBOX_USAGE_SKUS, + type SandboxBillingInput, +} from '../../src/container-usage-context.js'; import type { VercelSandboxNetworkPolicy, VercelSandboxSession, @@ -95,18 +100,25 @@ import { type SessionAttachPayload, } from '../../src/shared/sandbox-control-protocol.js'; +vi.mock('../../src/db/pg.js', () => ({ + getPgDb: () => { + throw new Error('PostgreSQL is not used by sandbox control integration tests'); + }, +})); + const sandboxId = 'sbx__control_smoke'; const ROOT_ID = 'ses_abcdefghijklmnopqrstuvwxyz'; const SECOND_ROOT_ID = 'ses_zyxwvutsrqponmlkjihgfedcba'; const THIRD_ROOT_ID = 'ses_01234567890123456789012345'; const GRANT_SESSION_ID = 'workspace_11111111-1111-4111-8111-111111111111'; const SECOND_GRANT_SESSION_ID = 'workspace_22222222-2222-4222-8222-222222222222'; -const WORKTREE_ID = 'worktree_11111111-1111-4111-8111-111111111111'; -const OTHER_WORKTREE_ID = 'worktree_22222222-2222-4222-8222-222222222222'; +const WORKTREE_ID: `worktree_${string}` = 'worktree_11111111-1111-4111-8111-111111111111'; +const OTHER_WORKTREE_ID: `worktree_${string}` = 'worktree_22222222-2222-4222-8222-222222222222'; type ProviderCreateResult = Awaited>; const KILO_TOKEN = 'fixture-real-kilo-token'; const GITHUB_TOKEN = 'fixture-real-github-token'; const HOUR = 60 * 60 * 1000; +const INITIAL_MESSAGE_ID = 'msg_018f1e2d3c4bAbCdEfGhIjKlMn'; function cloudflareRef(id: string, instanceId = 'inst_1'): string { return encodeCloudflareProviderRef({ sandboxId: id, containment: true, instanceId }); @@ -124,6 +136,15 @@ async function seedRunningCloudflare(instance: SandboxControl): Promise ); } if (physical.state !== 'running') await instance.confirmInstance(providerRef); + const running = await instance.getPhysicalRecord(); + if (!running.createIntent) throw new Error('Missing fixture create intent'); + await savePhysicalRecord(instance['ctx'].storage, { + ...running, + createIntent: { + ...running.createIntent, + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }, + }); Object.assign(instance, { provider: fakeProvider() }); return providerRef; } @@ -160,10 +181,14 @@ async function seedGrant( preparedAt: now, expiresAt: now + 4 * HOUR, }); - await saveSessionCredentialGrants(state.storage, [ - ...(await loadSessionCredentialGrants(state.storage)), - grant, - ]); + await state.storage.transaction(async () => { + await saveSessionCredentialGrants(state.storage, [ + ...(await loadSessionCredentialGrants(state.storage)).filter( + value => !value.members.some(member => member.sessionId === input.sessionId) + ), + grant, + ]); + }); return grant; } @@ -233,6 +258,61 @@ function nextMessage(ws: WebSocket): Promise { }); } +function nextMessages(ws: WebSocket, count: number): Promise { + return new Promise((resolve, reject) => { + const messages: string[] = []; + const onMessage = (event: MessageEvent) => { + messages.push(typeof event.data === 'string' ? event.data : String(event.data)); + if (messages.length !== count) return; + ws.removeEventListener('message', onMessage); + ws.removeEventListener('error', onError); + resolve(messages); + }; + const onError = () => { + ws.removeEventListener('message', onMessage); + reject(new Error('sandbox control websocket error')); + }; + ws.addEventListener('message', onMessage); + ws.addEventListener('error', onError, { once: true }); + }); +} + +type SessionStreamEvent = { + eventId: number; + sessionId: string; + streamEventType: string; + data: Record; +}; + +async function connectSessionStream( + sessionId: string, + userId: string, + eventTypes: string[] +): Promise { + const url = new URL('http://worker.test/stream'); + url.searchParams.set('sessionId', sessionId); + url.searchParams.set('userId', userId); + url.searchParams.set('eventTypes', eventTypes.join(',')); + url.searchParams.set('replay', 'false'); + const response = await SELF.fetch(url.toString(), { headers: { Upgrade: 'websocket' } }); + if (response.status !== 101 || !response.webSocket) { + throw new Error(`Unexpected session stream upgrade: ${response.status}`); + } + response.webSocket.accept(); + expect(JSON.parse(await nextMessage(response.webSocket))).toMatchObject({ + sessionId, + streamEventType: 'connected', + }); + return response.webSocket; +} + +function persistedSessionEvents(state: DurableObjectState, eventTypes: string[]) { + return createEventQueries( + drizzle(state.storage, { logger: false }), + state.storage.sql + ).findByFilters({ eventTypes }); +} + function sendHello( ws: WebSocket, requestId: string, @@ -1194,6 +1274,78 @@ function policyAuthorization( })?.headers.authorization; } +type SandboxControlStub = ReturnType<(typeof env.SANDBOX_CONTROL)['getByName']>; + +type WrapperRequest = { + type: string; + requestId: string; + operation: string; + session?: { sessionId: string; kiloSessionId: string; directory: string }; + payload?: Record; +}; + +async function deliverWrapperEvent( + stub: SandboxControlStub, + event: string, + payload: unknown, + session?: { directory: string; kiloSessionId?: string; rootKiloSessionId?: string } +): Promise { + await runInDurableObject(stub, async (instance, state) => { + const socket = state.getWebSockets('sandbox-control')[0]; + if (!socket) throw new Error('Expected sandbox-control socket'); + await instance.webSocketMessage( + socket, + JSON.stringify({ + type: 'event', + event, + payload, + ...(session ? { session } : {}), + }) + ); + }); +} + +function respondToWrapperRequest(ws: WebSocket, request: WrapperRequest, result: unknown): void { + ws.send(JSON.stringify({ type: 'response', requestId: request.requestId, ok: true, result })); +} + +function groupedRoute(sessionId: string, kiloSessionId: string, ownerId = 'owner_1') { + return { + sessionId, + kiloSessionId, + directory: '/workspace/shared', + ownerId, + worktreeId: WORKTREE_ID, + }; +} + +function groupedRegistration(input: { + ownerId: `user_${string}`; + sessionId: `workspace_${string}`; + kiloSessionId: string; + sandboxId: `usr-${string}` | `ses-${string}`; + provider?: 'cloudflare' | 'vercel'; +}) { + const repository = { + type: 'github' as const, + repo: 'Kilo-Org/cloud', + branch: 'feature/shared-worktree', + }; + return { + identity: { sessionId: input.sessionId, userId: input.ownerId }, + auth: { kiloSessionId: input.kiloSessionId, kilocodeToken: KILO_TOKEN }, + agent: { mode: 'code', model: 'test-model' }, + repository, + workspace: { + sandboxId: input.sandboxId, + sandboxProvider: input.provider ?? 'cloudflare', + workspacePath: '/workspace/shared', + worktreeId: WORKTREE_ID, + }, + finalization: { autoCommit: true, condenseOnComplete: true }, + }; +} + describe('SandboxControl in the Workers runtime', () => { it('rejects a missing credential', async () => { const response = await SELF.fetch(`http://worker.test/sandbox-control/${sandboxId}`, { @@ -2298,8 +2450,8 @@ describe('SandboxControl contained Vercel lifecycle', () => { it.each(['unmarked', 'wrong-flags', 'wrong-reference', 'old-marker'] as const)( 'fails closed and retains the exact reference for a %s warm instance', async markerKind => { - const requestedSandboxId = `sbx__containment_warm_${markerKind}`; - const stub = env.SANDBOX_CONTROL.getByName(requestedSandboxId); + const requestedSandboxId = `ses-${crypto.randomUUID().replaceAll('-', '')}` as const; + const { control: stub, registration } = await credentialFixture('vercel', requestedSandboxId); await runInDurableObject(stub, async (instance, state) => { const providerRef = encodeVercelProviderRef({ sandboxName: requestedSandboxId, @@ -2348,7 +2500,7 @@ describe('SandboxControl contained Vercel lifecycle', () => { ownerId: CONTAINMENT_OWNER, provider: 'vercel', allowCreate: true, - sessionId: GRANT_SESSION_ID, + sessionId: registration.identity.sessionId, }); expect(status.physical).toBe('stopping'); await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ @@ -2412,8 +2564,8 @@ describe('SandboxControl contained Vercel lifecycle', () => { }); it('immediately reclaims an unmarked warm Vercel instance using its exact reference', async () => { - const requestedSandboxId = 'sbx__containment_warm_reclaimed'; - const stub = env.SANDBOX_CONTROL.getByName(requestedSandboxId); + const requestedSandboxId = 'ses-abcd0002'; + const { control: stub, registration } = await credentialFixture('vercel', requestedSandboxId); await runInDurableObject(stub, async (instance, state) => { const providerRef = encodeVercelProviderRef({ sandboxName: requestedSandboxId, @@ -2446,7 +2598,7 @@ describe('SandboxControl contained Vercel lifecycle', () => { ownerId: CONTAINMENT_OWNER, provider: 'vercel', allowCreate: true, - sessionId: GRANT_SESSION_ID, + sessionId: registration.identity.sessionId, }); expect(status.physical).toBe('stopped'); await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ @@ -2459,8 +2611,8 @@ describe('SandboxControl contained Vercel lifecycle', () => { }); it('never issues native cleanup using a logical-name-only Vercel reference', async () => { - const requestedSandboxId = 'sbx__containment_warm_logical_reference'; - const stub = env.SANDBOX_CONTROL.getByName(requestedSandboxId); + const requestedSandboxId = 'ses-abcd0003'; + const { control: stub, registration } = await credentialFixture('vercel', requestedSandboxId); await runInDurableObject(stub, async (instance, state) => { const nativeCalls: string[] = []; const provider = unresolvableVercelProvider(requestedSandboxId, nativeCalls); @@ -2479,7 +2631,7 @@ describe('SandboxControl contained Vercel lifecycle', () => { ownerId: CONTAINMENT_OWNER, provider: 'vercel', allowCreate: true, - sessionId: GRANT_SESSION_ID, + sessionId: registration.identity.sessionId, }); expect(status.physical).toBe('stopping'); await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ @@ -2492,8 +2644,8 @@ describe('SandboxControl contained Vercel lifecycle', () => { }); it('fails closed when an existing creation intent requests different containment', async () => { - const requestedSandboxId = 'sbx__containment_wrong_intent'; - const stub = env.SANDBOX_CONTROL.getByName(requestedSandboxId); + const requestedSandboxId = 'ses-abcd0004'; + const { control: stub, registration } = await credentialFixture('vercel', requestedSandboxId); await runInDurableObject(stub, async (instance, state) => { const physical = claimCreate(initialPhysicalRecord(false), 'intent_previous', 1, undefined, { kilocode: false, @@ -2508,7 +2660,7 @@ describe('SandboxControl contained Vercel lifecycle', () => { ownerId: CONTAINMENT_OWNER, provider: 'vercel', allowCreate: true, - sessionId: GRANT_SESSION_ID, + sessionId: registration.identity.sessionId, }); expect(status.physical).toBe('stopping'); await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ @@ -2555,9 +2707,10 @@ describe('SandboxControl contained Vercel lifecycle', () => { expect(await state.storage.get('wrapper_credential_hash')).toBeUndefined(); expect(creates).toBe(0); await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ - state: 'failed', + state: 'stopped', providerRef: null, - stopTombstone: { reason: 'environment_failed' }, + createIntent: null, + stopTombstone: null, }); }); }); @@ -3204,11 +3357,13 @@ describe('SandboxControl mandatory worktree credentials', () => { expect(await storedGrants(control)).toEqual([]); expect(broker.kiloSubjects.size).toBe(0); expect(broker.githubSubjects.size).toBe(0); + expect(fixture.containers.launches).toEqual([]); await expect(control.getPhysicalRecord()).resolves.toMatchObject({ - state: 'failed', + state: 'stopped', providerRef: null, + createIntent: null, + stopTombstone: null, }); - await finishFailedCreation(control); } ); @@ -3575,6 +3730,7 @@ describe('SandboxControl native worktree containment', () => { ).resolves.toBeNull(); const ws = await connect(launch.env.SANDBOX_CONTROL_CREDENTIAL, fixture.sandboxId); await completeHello(ws, 'hello-native-vercel', { providerInstanceId: providerRef }); + captureAndAcceptControlRequests(ws); const rotatedKiloToken = 'fixture-rotated-kilo-token'; broker.tokens.github = 'fixture-rotated-github-token'; @@ -4886,6 +5042,12 @@ describe('SandboxControl recovery watchdogs', () => { const id = `usr-${crypto.randomUUID().replaceAll('-', '')}`; const control = env.SANDBOX_CONTROL.getByName(id); const { provider, allocations } = await installProvider(control, cloudflareRef(id)); + await registerCredentialSession({ + identity: { sessionId: GRANT_SESSION_ID, userId: 'owner_native_reaping' }, + auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, + agent: {}, + workspace: { sandboxId: id as SandboxId, workspacePath: '/workspace/reaping' }, + }); const firstNativeCall = Promise.withResolvers(); const nativeEntered = Promise.withResolvers(); let firstNativeSettled = false; @@ -5159,6 +5321,13 @@ describe('SandboxControl acquisition receipts', () => { expect(provider.create).toHaveBeenCalledTimes(1); expect(provider.launch).toHaveBeenCalledTimes(1); expect(allocations.size).toBe(1); + const launch = provider.launch.mock.calls[0]; + if (!launch) throw new Error('Expected acquisition wrapper launch'); + const wrapper = await connect(launch[1].SANDBOX_CONTROL_CREDENTIAL, sandboxId); + await completeHello(wrapper, 'hello-lost-acquisition', { + providerInstanceId: launch[0], + wrapperInstanceId: crypto.randomUUID(), + }); await control.beginStop('lost_acquisition_response'); await fireControlDeadline(control, 'stopAttempt'); await expect(control.getPhysicalRecord()).resolves.toMatchObject({ @@ -5717,7 +5886,7 @@ describe('SandboxControl durable remainder', () => { }); const promptPayload = { - messageId: 'msg_a', + messageId: INITIAL_MESSAGE_ID, turn: { type: 'prompt', prompt: 'from a' }, agent: { mode: 'code', model: 'test' }, }; @@ -5909,7 +6078,9 @@ describe('SandboxSession control-plane regressions', () => { auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_cancelled_a', prompt: 'A' } }, + message: { + initialTurn: { type: 'prompt', messageId: 'msg_ffffffffffff00000000000001', prompt: 'A' }, + }, }); const attachment = await attach.promise; const preparing = (await admissionState(session)).messages[0]; @@ -5996,7 +6167,7 @@ describe('SandboxSession control-plane regressions', () => { preparing.preparationAttemptId, ]); expect(beforeReset.messages[0]).toMatchObject({ - messageId: 'msg_cancelled_a', + messageId: 'msg_ffffffffffff00000000000001', state: 'cancelled', }); expect(requests.filter(request => request.operation === 'session.prompt')).toEqual([]); @@ -6018,7 +6189,7 @@ describe('SandboxSession control-plane regressions', () => { payload: { version: 2, attemptId: preparing.preparationAttemptId, - triggerMessageId: 'msg_cancelled_a', + triggerMessageId: 'msg_ffffffffffff00000000000001', revision: 1000, timestamp: Date.now(), step: 'ready', @@ -6097,7 +6268,7 @@ describe('SandboxSession control-plane regressions', () => { }) ).resolves.toEqual({ quarantined: false }); expect((await admissionState(session)).messages).toMatchObject([ - { messageId: 'msg_cancelled_a', state: 'cancelled' }, + { messageId: 'msg_ffffffffffff00000000000001', state: 'cancelled' }, { ...b, state: 'accepted', @@ -6187,7 +6358,13 @@ describe('SandboxSession control-plane regressions', () => { workspacePath: '/workspace/terminal', sandboxProvider, }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_delayed_a', prompt: 'A' } }, + message: { + initialTurn: { + type: 'prompt', + messageId: 'msg_ffffffffffff00000000000002', + prompt: 'A', + }, + }, }); await vi.waitFor(() => expect(heldRequest).toBeDefined()); expect(heldRequest).toMatchObject({ @@ -6206,7 +6383,9 @@ describe('SandboxSession control-plane regressions', () => { }); await vi.waitFor(() => expect(joined).toBe(true)); await expect(session.interruptExecution()).resolves.toEqual({ success: true }); - await expect(session.getMessageResult('msg_delayed_a')).resolves.toMatchObject({ + await expect( + session.getMessageResult('msg_ffffffffffff00000000000002') + ).resolves.toMatchObject({ type: 'found', result: { status: 'interrupted' }, }); @@ -6282,7 +6461,7 @@ describe('SandboxSession control-plane regressions', () => { }); const beforeDelivery = await admissionState(session); expect(beforeDelivery.messages).toMatchObject([ - { messageId: 'msg_delayed_a', state: 'cancelled' }, + { messageId: 'msg_ffffffffffff00000000000002', state: 'cancelled' }, { messageId: 'msg_replacement_b', state: 'queued', @@ -6322,7 +6501,7 @@ describe('SandboxSession control-plane regressions', () => { ).toEqual(['msg_replacement_b']); expect(oldRequests.filter(request => request.operation === operation)).toEqual([]); expect((await admissionState(session)).messages).toMatchObject([ - { messageId: 'msg_delayed_a', state: 'cancelled' }, + { messageId: 'msg_ffffffffffff00000000000002', state: 'cancelled' }, { ...beforeDelivery.messages[1], state: 'accepted', @@ -6378,7 +6557,13 @@ describe('SandboxSession control-plane regressions', () => { auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_stranded', prompt: 'head A' } }, + message: { + initialTurn: { + type: 'prompt', + messageId: 'msg_ffffffffffff00000000000003', + prompt: 'head A', + }, + }, }) ).resolves.toMatchObject({ success: true, compatibilityDelivery: 'queued' }); await vi.waitFor(() => expect(entered).toBe(true)); @@ -6389,7 +6574,7 @@ describe('SandboxSession control-plane regressions', () => { expect(alarmAt).toBeGreaterThanOrEqual(admittedAt); expect(before.messages).toMatchObject([ { - messageId: 'msg_stranded', + messageId: 'msg_ffffffffffff00000000000003', state: 'queued', deliveryDeadlineAt: expect.any(Number), preparationAttemptId: expect.any(String), @@ -6415,7 +6600,7 @@ describe('SandboxSession control-plane regressions', () => { turn: { type: 'command', id: 'msg_fresh', command: 'status', arguments: '' }, }) ).resolves.toMatchObject({ success: true }); - await waitForAccepted(session, 'msg_stranded'); + await waitForAccepted(session, 'msg_ffffffffffff00000000000003'); const recovered = await admissionState(session); expect(recovered.messages).toMatchObject([ { @@ -6426,13 +6611,13 @@ describe('SandboxSession control-plane regressions', () => { { messageId: 'msg_fresh', state: 'queued' }, ]); expect(requests.filter(request => request.operation === 'session.prompt')).toHaveLength(1); - sendOutcome(socket, 'msg_stranded'); + sendOutcome(socket, 'msg_ffffffffffff00000000000003'); await waitForAccepted(session, 'msg_fresh'); expect( requests .filter(request => request.operation === 'session.prompt') .map(request => sessionPromptPayloadSchema.parse(request.payload).messageId) - ).toEqual(['msg_stranded', 'msg_fresh']); + ).toEqual(['msg_ffffffffffff00000000000003', 'msg_fresh']); expect(provider.create).not.toHaveBeenCalled(); expect(provider.launch).not.toHaveBeenCalled(); } finally { @@ -6456,7 +6641,8 @@ describe('SandboxSession control-plane regressions', () => { captureAndAcceptControlRequests(socket, request => { if ( request.operation !== 'session.prompt' || - sessionPromptPayloadSchema.parse(request.payload).messageId !== 'msg_early' + sessionPromptPayloadSchema.parse(request.payload).messageId !== + 'msg_ffffffffffff00000000000004' ) return false; held = request; @@ -6471,7 +6657,7 @@ describe('SandboxSession control-plane regressions', () => { message: { initialTurn: { type: 'command', - messageId: 'msg_early', + messageId: 'msg_ffffffffffff00000000000004', command: 'review', arguments: '--all', }, @@ -6488,9 +6674,11 @@ describe('SandboxSession control-plane regressions', () => { return instance.alarm(); }); await vi.waitFor(() => expect(joined).toBe(true)); - sendOutcome(socket, 'msg_early', status); + sendOutcome(socket, 'msg_ffffffffffff00000000000004', status); await vi.waitFor(async () => { - await expect(session.getMessageResult('msg_early')).resolves.toMatchObject({ + await expect( + session.getMessageResult('msg_ffffffffffff00000000000004') + ).resolves.toMatchObject({ type: 'found', result: { status: status === 'cancelled' ? 'interrupted' : status }, }); @@ -6498,11 +6686,13 @@ describe('SandboxSession control-plane regressions', () => { await waitForAccepted(session, 'msg_after_early'); const terminal = await admissionState(session); const events = await lifecycleEvents(session); - expect(events.filter(event => event.data.messageId === 'msg_early')).toMatchObject([ + expect( + events.filter(event => event.data.messageId === 'msg_ffffffffffff00000000000004') + ).toMatchObject([ { type: status === 'completed' ? 'cloud.message.completed' : 'cloud.message.failed', data: { - messageId: 'msg_early', + messageId: 'msg_ffffffffffff00000000000004', status: status === 'cancelled' ? 'interrupted' : status, delivery: 'sent', accepted: true, @@ -6517,7 +6707,12 @@ describe('SandboxSession control-plane regressions', () => { await expect( session.admitSubmittedMessage({ userId: fixture.ownerId, - turn: { type: 'command', id: 'msg_early', command: 'review', arguments: '--all' }, + turn: { + type: 'command', + id: 'msg_ffffffffffff00000000000004', + command: 'review', + arguments: '--all', + }, }) ).resolves.toMatchObject({ success: false, code: 'BAD_REQUEST' }); await expect(control.getStatus()).resolves.toMatchObject({ @@ -6547,19 +6742,21 @@ describe('SandboxSession control-plane regressions', () => { auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_old_a', prompt: 'A' } }, + message: { + initialTurn: { type: 'prompt', messageId: 'msg_ffffffffffff00000000000005', prompt: 'A' }, + }, }); - await waitForAccepted(session, 'msg_old_a'); + await waitForAccepted(session, 'msg_ffffffffffff00000000000005'); const attemptId = (await admissionState(session)).messages[0]?.preparationAttemptId; expect(attemptId).toEqual(expect.any(String)); await session.admitSubmittedMessage({ userId: fixture.ownerId, turn: { type: 'command', id: 'msg_current_b', command: 'status', arguments: '' }, }); - sendOutcome(socket, 'msg_old_a'); + sendOutcome(socket, 'msg_ffffffffffff00000000000005'); await waitForAccepted(session, 'msg_current_b'); const events = await lifecycleEvents(session); - sendOutcome(socket, 'msg_old_a'); + sendOutcome(socket, 'msg_ffffffffffff00000000000005'); socket.send( JSON.stringify({ type: 'event', @@ -6568,7 +6765,7 @@ describe('SandboxSession control-plane regressions', () => { payload: { version: 2, attemptId, - triggerMessageId: 'msg_old_a', + triggerMessageId: 'msg_ffffffffffff00000000000005', revision: 100, timestamp: Date.now(), step: 'workspace_setup', @@ -6600,7 +6797,7 @@ describe('SandboxSession control-plane regressions', () => { }); }); expect((await admissionState(session)).messages).toMatchObject([ - { messageId: 'msg_old_a', state: 'completed' }, + { messageId: 'msg_ffffffffffff00000000000005', state: 'completed' }, { messageId: 'msg_current_b', state: 'accepted', @@ -6663,11 +6860,15 @@ describe('SandboxSession control-plane regressions', () => { agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, message: { - initialTurn: { type: 'prompt', messageId: 'msg_interrupt', prompt: 'interrupt me' }, + initialTurn: { + type: 'prompt', + messageId: 'msg_ffffffffffff00000000000006', + prompt: 'interrupt me', + }, }, }); await entered.promise; - if (phase === 'execution') await waitForAccepted(session, 'msg_interrupt'); + if (phase === 'execution') await waitForAccepted(session, 'msg_ffffffffffff00000000000006'); await session.admitSubmittedMessage({ userId: fixture.ownerId, turn: { type: 'command', id: 'msg_cancel_follower', command: 'status', arguments: '' }, @@ -6682,7 +6883,7 @@ describe('SandboxSession control-plane regressions', () => { await expect(session.interruptExecution()).resolves.toMatchObject({ success: true }); await vi.waitFor(() => expect(activeWork).toBe(false)); expect((await admissionState(session)).messages).toMatchObject([ - { messageId: 'msg_interrupt', state: 'cancelled' }, + { messageId: 'msg_ffffffffffff00000000000006', state: 'cancelled' }, { messageId: 'msg_cancel_follower', state: 'cancelled' }, ]); if (phase === 'execution') { @@ -6693,7 +6894,7 @@ describe('SandboxSession control-plane regressions', () => { kiloSessionId: ROOT_ID, directory: '/workspace/terminal', }, - payload: { messageId: 'msg_interrupt' }, + payload: { messageId: 'msg_ffffffffffff00000000000006' }, }, ]); expect(provider.stop).not.toHaveBeenCalled(); @@ -6712,10 +6913,10 @@ describe('SandboxSession control-plane regressions', () => { const failures = events.filter(event => event.type === 'cloud.message.failed'); expect(failures.map(event => event.data.messageId).sort()).toEqual([ 'msg_cancel_follower', - 'msg_interrupt', + 'msg_ffffffffffff00000000000006', ]); expect( - failures.find(event => event.data.messageId === 'msg_interrupt')?.data + failures.find(event => event.data.messageId === 'msg_ffffffffffff00000000000006')?.data ).toMatchObject({ status: 'interrupted', accepted: phase === 'execution', @@ -6750,9 +6951,11 @@ describe('SandboxSession control-plane regressions', () => { auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_unhealthy', prompt: 'A' } }, + message: { + initialTurn: { type: 'prompt', messageId: 'msg_ffffffffffff00000000000007', prompt: 'A' }, + }, }); - await waitForAccepted(session, 'msg_unhealthy'); + await waitForAccepted(session, 'msg_ffffffffffff00000000000007'); socket.send( JSON.stringify({ type: 'event', @@ -6761,7 +6964,9 @@ describe('SandboxSession control-plane regressions', () => { }) ); await vi.waitFor(async () => { - await expect(session.getMessageResult('msg_unhealthy')).resolves.toMatchObject({ + await expect( + session.getMessageResult('msg_ffffffffffff00000000000007') + ).resolves.toMatchObject({ type: 'found', result: { status: 'failed' }, }); @@ -6777,7 +6982,7 @@ describe('SandboxSession control-plane regressions', () => { ).toMatchObject([ { data: { - messageId: 'msg_unhealthy', + messageId: 'msg_ffffffffffff00000000000007', accepted: true, delivery: 'sent', reason: 'kilo_unhealthy', @@ -6843,7 +7048,11 @@ describe('SandboxSession control-plane regressions', () => { await waitForAccepted(session, 'msg_recovered'); await session.failWaitingMessages('late_old_runtime_failure', fixture.wrapperInstanceId); expect((await admissionState(session)).messages).toMatchObject([ - { messageId: 'msg_unhealthy', state: 'failed', failedReason: 'kilo_unhealthy' }, + { + messageId: 'msg_ffffffffffff00000000000007', + state: 'failed', + failedReason: 'kilo_unhealthy', + }, { messageId: 'msg_recovered', state: 'accepted', @@ -6886,14 +7095,14 @@ describe('SandboxSession control-plane regressions', () => { message: { initialTurn: { type: 'prompt', - messageId: 'msg_initial_model', + messageId: INITIAL_MESSAGE_ID, prompt: 'initial prompt', }, }, }) ).resolves.toMatchObject({ success: true }); - await waitForAccepted(session, 'msg_initial_model'); - await completeTurn(session, 'msg_initial_model', fixture.wrapperInstanceId); + await waitForAccepted(session, INITIAL_MESSAGE_ID); + await completeTurn(session, INITIAL_MESSAGE_ID, fixture.wrapperInstanceId); await expect( session.admitSubmittedMessage({ userId: fixture.ownerId, @@ -6908,7 +7117,7 @@ describe('SandboxSession control-plane regressions', () => { .map(request => request.payload) ).toEqual([ { - messageId: 'msg_initial_model', + messageId: INITIAL_MESSAGE_ID, turn: { type: 'prompt', prompt: 'initial prompt' }, agent: { mode: 'architect', model: 'fake-deterministic', variant: 'high' }, finalization: { autoCommit: true, condenseOnComplete: true }, @@ -6950,7 +7159,7 @@ describe('SandboxSession control-plane regressions', () => { auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_a', prompt: 'A' } }, + message: { initialTurn: { type: 'prompt', messageId: INITIAL_MESSAGE_ID, prompt: 'A' } }, }) ).resolves.toMatchObject({ success: true, compatibilityDelivery: 'queued' }); await runInDurableObject(session, instance => instance.alarm()); @@ -6971,7 +7180,7 @@ describe('SandboxSession control-plane regressions', () => { expect(beforeReplay.metadata?.agent).toEqual({ mode: 'code', model: modelB }); const replay: SubmittedSessionMessageRequest = { userId: fixture.ownerId, - turn: { type: 'prompt', id: 'msg_a', prompt: 'A' }, + turn: { type: 'prompt', id: INITIAL_MESSAGE_ID, prompt: 'A' }, agent: { model: 'anthropic/claude-sonnet-4' }, }; await expect(session.admitSubmittedMessage(replay)).resolves.toMatchObject({ @@ -7002,14 +7211,14 @@ describe('SandboxSession control-plane regressions', () => { signalWrapperReady(replacement); await waitForWrapperReady(fixture); await runInDurableObject(session, instance => instance.alarm()); - await waitForAccepted(session, 'msg_a'); + await waitForAccepted(session, INITIAL_MESSAGE_ID); const accepted = await admissionState(session); await expect(session.admitSubmittedMessage(replay)).resolves.toMatchObject({ success: true, compatibilityDelivery: 'sent', }); expect(await admissionState(session)).toEqual(accepted); - await completeTurn(session, 'msg_a', fixture.wrapperInstanceId); + await completeTurn(session, INITIAL_MESSAGE_ID, fixture.wrapperInstanceId); await waitForAccepted(session, 'msg_b'); await completeTurn(session, 'msg_b', fixture.wrapperInstanceId); await waitForAccepted(session, 'msg_c'); @@ -7019,7 +7228,7 @@ describe('SandboxSession control-plane regressions', () => { .map(request => request.payload) ).toEqual([ { - messageId: 'msg_a', + messageId: INITIAL_MESSAGE_ID, turn: { type: 'prompt', prompt: 'A' }, agent: { ...agentA, model: 'anthropic/claude-sonnet-4' }, }, @@ -7059,12 +7268,14 @@ describe('SandboxSession control-plane regressions', () => { identity: { sessionId: fixture.sessionId, userId: fixture.ownerId }, auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: { mode: 'reviewer', model: agentA.model }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_initial', prompt: 'initial' } }, + message: { + initialTurn: { type: 'prompt', messageId: INITIAL_MESSAGE_ID, prompt: 'initial' }, + }, }) ).resolves.toMatchObject({ success: true }); const state = await admissionState(session); expect(state.messages[1]?.intent).toEqual({ - turn: { type: 'prompt', messageId: 'msg_initial', prompt: 'initial' }, + turn: { type: 'prompt', messageId: INITIAL_MESSAGE_ID, prompt: 'initial' }, agent: { mode: 'reviewer', model: agentA.model }, }); expect(state.metadata?.agent).toEqual({ mode: 'architect', model: agentA.model }); @@ -7572,7 +7783,9 @@ describe('SandboxSession control-plane regressions', () => { auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, agent: agentA, workspace: { sandboxId: fixture.sandboxId, workspacePath: '/workspace/terminal' }, - message: { initialTurn: { type: 'prompt', messageId: 'msg_retry_a', prompt: 'retry A' } }, + message: { + initialTurn: { type: 'prompt', messageId: INITIAL_MESSAGE_ID, prompt: 'retry A' }, + }, }); const firstRequest = await entered.promise; const original = (await admissionState(session)).messages[0]?.intent; @@ -7584,7 +7797,7 @@ describe('SandboxSession control-plane regressions', () => { await vi.waitFor(() => expect(alarmStarted).toBe(true)); const replay: SubmittedSessionMessageRequest = { userId: fixture.ownerId, - turn: { type: 'prompt', id: 'msg_retry_a', prompt: 'retry A' }, + turn: { type: 'prompt', id: INITIAL_MESSAGE_ID, prompt: 'retry A' }, }; await expect( Promise.all([session.admitSubmittedMessage(replay), session.admitSubmittedMessage(replay)]) @@ -7619,19 +7832,19 @@ describe('SandboxSession control-plane regressions', () => { }) ).resolves.toMatchObject({ success: true }); await runInDurableObject(session, instance => instance.alarm()); - await waitForAccepted(session, 'msg_retry_a'); + await waitForAccepted(session, INITIAL_MESSAGE_ID); await runInDurableObject(session, instance => instance.alarm()); const delivered = requests.filter(request => request.operation === 'session.prompt'); expect(delivered).toHaveLength(2); expect(delivered[0]?.payload).toEqual(delivered[1]?.payload); expect(delivered[1]?.payload).toEqual({ - messageId: 'msg_retry_a', + messageId: INITIAL_MESSAGE_ID, turn: { type: 'prompt', prompt: 'retry A' }, agent: { ...agentA, model: 'anthropic/claude-sonnet-4' }, }); const accepted = await admissionState(session); expect(accepted.messages).toMatchObject([ - { messageId: 'msg_retry_a', state: 'accepted', promptFailures: 1, intent: original }, + { messageId: INITIAL_MESSAGE_ID, state: 'accepted', promptFailures: 1, intent: original }, { messageId: 'msg_retry_b', state: 'queued' }, ]); expect(accepted.metadata?.agent).toEqual({ mode: 'code', model: modelB }); @@ -7786,25 +7999,15 @@ describe('SandboxSession control-plane regressions', () => { requestId: string; session: { sessionId: string; kiloSessionId: string; directory: string }; }; - if (request.operation === 'session.abort') { - abortRequests.push({ operation: request.operation, session: request.session }); - ws.send( - JSON.stringify({ - type: 'response', - requestId: request.requestId, - ok: true, - result: { status: 'aborted' }, - }) - ); - return; - } - if (request.operation !== 'session.detach') return; + if (request.operation !== 'session.abort' && request.operation !== 'session.detach') return; + abortRequests.push({ operation: request.operation, session: request.session }); ws.send( JSON.stringify({ type: 'response', requestId: request.requestId, ok: true, - result: { detached: true }, + result: + request.operation === 'session.abort' ? { status: 'aborted' } : { detached: true }, }) ); }); @@ -7833,6 +8036,14 @@ describe('SandboxSession control-plane regressions', () => { directory: '/workspace/deleted', }, }, + { + operation: 'session.detach', + session: { + sessionId, + kiloSessionId: ROOT_ID, + directory: '/workspace/deleted', + }, + }, ]); expect(provider.stop).not.toHaveBeenCalled(); expect(provider.create).not.toHaveBeenCalled(); @@ -7859,7 +8070,7 @@ describe('SandboxSession control-plane regressions', () => { } as const; const initialTurn = { type: 'command', - messageId: 'msg_initial_command', + messageId: INITIAL_MESSAGE_ID, command: 'review', arguments: '--all changes', } as const; @@ -7976,6 +8187,143 @@ describe('SandboxSession control-plane regressions', () => { }); describe('SandboxControl terminal runtime coordination', () => { + it('reuses one billed allocation for sibling roots and authorizes their terminals without weakening payer, actor, or physical identity', async () => { + const { control, registration, sandboxId } = await credentialFixture( + 'cloudflare', + 'ses-b111ed' + ); + const { provider } = await installProvider(control); + const sibling: CredentialRegistration = { + ...registration, + identity: { ...registration.identity, sessionId: SECOND_GRANT_SESSION_ID }, + auth: { ...registration.auth, kiloSessionId: SECOND_ROOT_ID }, + }; + await registerCredentialSession(sibling); + const organizationId = registration.identity.orgId; + if (!organizationId) throw new Error('Missing fixture payer'); + const billing: SandboxBillingInput = { + sandboxId, + subject: { type: 'org', id: organizationId }, + actor: { type: 'user', id: registration.identity.userId }, + sessionId: registration.identity.sessionId, + enforcementRequested: true, + }; + const first = await control.ensureReady({ + ...credentialInput(registration), + allowCreate: true, + billing, + }); + const physical = await control.getPhysicalRecord(); + const second = await control.ensureReady({ + ...credentialInput(sibling), + allowCreate: false, + billing: { ...billing, sessionId: sibling.identity.sessionId }, + }); + expect(provider.create).toHaveBeenCalledTimes(1); + expect(provider.launch).toHaveBeenCalledTimes(1); + expect(provider.create.mock.calls[0]?.[0].billing).toMatchObject({ + ...billing, + sessionId: GRANT_SESSION_ID, + }); + expect(provider.ensureBillingAdmission).toHaveBeenCalledWith(physical.providerRef, { + ...billing, + sessionId: GRANT_SESSION_ID, + }); + expect(await control.getPhysicalRecord()).toEqual(physical); + for (const change of [ + { subject: { type: 'org', id: 'other-org' } }, + { actor: { type: 'bot', id: 'other-bot' }, onBehalfOf: billing.subject }, + ] as const) { + const rejected = control + .ensureReady({ + ...credentialInput(sibling), + allowCreate: false, + billing: { ...billing, ...change, sessionId: sibling.identity.sessionId }, + }) + .then( + () => null, + (error: unknown) => error + ); + expect(await rejected).toMatchObject({ message: 'Sandbox billing allocation mismatch' }); + } + if (!first.attachment || !second.attachment) throw new Error('Missing sibling attachments'); + await control.attachSession(attachInput(registration, first.attachment)); + await control.attachSession(attachInput(sibling, second.attachment)); + const launch = provider.launch.mock.calls[0]; + const native = decodeCloudflareProviderRef(physical.providerRef); + if (!launch || !native) throw new Error('Missing billed physical allocation'); + const wrapperInstanceId = crypto.randomUUID(); + const socket = await connect(launch[1].SANDBOX_CONTROL_CREDENTIAL, sandboxId); + try { + await completeHello(socket, 'hello-billed-siblings', { + providerInstanceId: launch[0], + wrapperInstanceId, + }); + signalWrapperReady(socket); + await vi.waitFor(async () => { + await expect(control.getStatus()).resolves.toMatchObject({ connection: 'ready' }); + }); + await runInDurableObject(control, async instance => { + const namespace = instance['env'].SandboxSmallContainment; + const context: BillingContext = { + service: 'cloud-agent-next-sandbox-small-containment', + instanceId: native.sandboxId, + sku: SANDBOX_USAGE_SKUS.SandboxSmallContainment, + subject: billing.subject, + actor: billing.actor, + sessionId: GRANT_SESSION_ID, + metadata: { + container_class: 'SandboxSmallContainment', + durable_object_id: namespace.idFromName(native.sandboxId).toString(), + }, + startEpochMs: Date.now(), + generation: crypto.randomUUID(), + measurementStarted: true, + nextSeq: 1, + usageMeasuredAtMs: Date.now(), + }; + let measured = context; + const get = namespace.get.bind(namespace); + vi.spyOn(namespace, 'get').mockImplementation(id => + Object.assign(get(id), { + getBillingRuntimeStatus: async () => ({ + sandboxClassName: 'SandboxSmallContainment', + running: true, + blocked: false, + context: measured, + }), + }) + ); + Object.assign(instance['env'], { + CLOUD_AGENT_CONTAINER_BILLING_ENABLED: 'true', + CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS: organizationId, + }); + for (const member of [registration, sibling]) { + const access = { ...credentialInput(member), organizationId, wrapperInstanceId }; + await expect(instance.validateTerminalAccess(access)).resolves.toEqual({ allowed: true }); + await expect(instance.recordTerminalActivity(access)).resolves.toEqual({ allowed: true }); + for (const [change, reason] of [ + [{ subject: { type: 'org', id: 'other-org' } }, 'billing_payer_mismatch'], + [{ actor: { type: 'user', id: 'other-user' } }, 'billing_actor_mismatch'], + [{ instanceId: 'ses-f0e1' }, 'billing_runtime_mismatch'], + [{ sessionId: SECOND_GRANT_SESSION_ID }, 'billing_session_mismatch'], + ] as const) { + measured = { ...context, ...change }; + await expect(instance.validateTerminalAccess(access)).resolves.toEqual({ + allowed: false, + reason, + }); + } + measured = context; + } + }); + expect(provider.create).toHaveBeenCalledTimes(1); + expect(provider.launch).toHaveBeenCalledTimes(1); + } finally { + socket.close(); + } + }); + it.each(['cloudflare', 'vercel'] as const)( 'renews near-expiry and expired %s grants through authenticated terminal activity', async provider => { @@ -8233,6 +8581,14 @@ describe('SandboxControl terminal runtime coordination', () => { signalWrapperReady(socket); await waitForWrapperReady(fixture); + const detached = nextMessage(socket).then(message => { + const request = JSON.parse(message) as WrapperRequest; + expect(request).toMatchObject({ + operation: 'session.detach', + session: { sessionId: fixture.sessionId }, + }); + respondToWrapperRequest(socket, request, { detached: true }); + }); await runInDurableObject(control, async instance => { const input = { sessionId: fixture.sessionId, @@ -8258,6 +8614,7 @@ describe('SandboxControl terminal runtime coordination', () => { reason: 'session_not_attached', }); }); + await detached; socket.close(); }); @@ -8566,3 +8923,1771 @@ describe('SandboxControl terminal runtime coordination', () => { } ); }); + +describe('SandboxControl worktree routes', () => { + it('durably preserves concurrent sibling attaches in one directory', async () => { + const stub = env.SANDBOX_CONTROL.getByName('sbx__worktree_concurrent_attach'); + + const routes = await runInDurableObject(stub, async instance => { + await instance.initializeOwner('owner_1'); + await Promise.all([ + attachGrantedSession(instance, instance['ctx'], groupedRoute(GRANT_SESSION_ID, ROOT_ID)), + attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID) + ), + ]); + return instance.listRoutes(); + }); + + expect(routes).toHaveLength(2); + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining(groupedRoute(GRANT_SESSION_ID, ROOT_ID)), + expect.objectContaining(groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID)), + ]) + ); + }); + + it('rejects mismatched groups, duplicate roots, and worktree directory divergence', async () => { + const stub = env.SANDBOX_CONTROL.getByName('sbx__worktree_route_conflicts'); + + await runInDurableObject(stub, async instance => { + await instance.initializeOwner('owner_1'); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(GRANT_SESSION_ID, ROOT_ID) + ); + + await expect( + attachGrantedSession(instance, instance['ctx'], { + ...groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID), + worktreeId: OTHER_WORKTREE_ID, + }) + ).rejects.toThrow('Directory already attached'); + await expect( + attachGrantedSession(instance, instance['ctx'], { + ...groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID), + directory: '/workspace/other', + }) + ).rejects.toThrow('Worktree already attached to another directory'); + await expect( + attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(SECOND_GRANT_SESSION_ID, ROOT_ID) + ) + ).rejects.toThrow('Kilo session already attached'); + await expect( + attachGrantedSession(instance, instance['ctx'], { + sessionId: SECOND_GRANT_SESSION_ID, + kiloSessionId: SECOND_ROOT_ID, + directory: '/workspace/shared', + ownerId: 'owner_1', + }) + ).rejects.toThrow('Directory already attached'); + + expect(await instance.listRoutes()).toHaveLength(1); + }); + }); + + it('retains ungrouped persisted routes without inventing a worktree', async () => { + const stub = env.SANDBOX_CONTROL.getByName('sbx__worktree_legacy_route'); + + await runInDurableObject(stub, async (instance, state) => { + await instance.initializeOwner('owner_1'); + await state.storage.put('session_routes', [ + { + sessionId: 'workspace_legacy', + kiloSessionId: 'kilo_legacy', + directory: '/workspace/shared', + ownerId: 'owner_1', + lastState: null, + lastStateAt: null, + idleForMs: null, + waitingOn: null, + needsSync: false, + stalled: false, + }, + ]); + + expect(await instance.listRoutes()).toEqual([ + expect.not.objectContaining({ worktreeId: expect.anything() }), + ]); + await expect( + attachGrantedSession(instance, instance['ctx'], groupedRoute(GRANT_SESSION_ID, ROOT_ID)) + ).rejects.toThrow('Directory already attached'); + }); + }); +}); + +describe('SandboxControl targeted detach', () => { + it('awaits live wrapper detach and preserves siblings attached during that request', async () => { + const targetSandboxId = 'sbx__worktree_live_detach'; + const credential = generateSandboxCredential(); + const stub = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedRunningCredential(credential, targetSandboxId); + await runInDurableObject(stub, async instance => { + await instance.initializeOwner('owner_1'); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(GRANT_SESSION_ID, ROOT_ID) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID) + ); + }); + + const ws = await connect(credential, targetSandboxId); + await completeHello(ws, 'hello-live-detach'); + const inbound = nextMessage(ws); + const pending = runInDurableObject(stub, instance => + instance.detachSession(SECOND_GRANT_SESSION_ID) + ); + const request = JSON.parse(await inbound) as WrapperRequest; + expect(request).toMatchObject({ + operation: 'session.detach', + session: { + sessionId: SECOND_GRANT_SESSION_ID, + kiloSessionId: SECOND_ROOT_ID, + directory: '/workspace/shared', + }, + payload: {}, + }); + + await runInDurableObject(stub, async instance => { + expect(await instance.listRoutes()).toHaveLength(2); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute('workspace_33333333-3333-4333-8333-333333333333', THIRD_ROOT_ID) + ); + }); + respondToWrapperRequest(ws, request, { detached: true }); + await expect(pending).resolves.toEqual({ existed: true }); + + await runInDurableObject(stub, async instance => { + expect((await instance.listRoutes()).map(route => route.sessionId).sort()).toEqual([ + GRANT_SESSION_ID, + 'workspace_33333333-3333-4333-8333-333333333333', + ]); + }); + ws.close(); + }); + + it('retains the durable route when a connected wrapper rejects detach', async () => { + const targetSandboxId = 'sbx__worktree_failed_detach'; + const credential = generateSandboxCredential(); + const stub = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedRunningCredential(credential, targetSandboxId); + await runInDurableObject(stub, async instance => { + await instance.initializeOwner('owner_1'); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(GRANT_SESSION_ID, ROOT_ID) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID) + ); + }); + + const ws = await connect(credential, targetSandboxId); + await completeHello(ws, 'hello-failed-detach'); + const inbound = nextMessage(ws); + const pending = runInDurableObject(stub, instance => + instance.detachSession(SECOND_GRANT_SESSION_ID) + ); + const request = JSON.parse(await inbound) as WrapperRequest; + ws.send( + JSON.stringify({ + type: 'response', + requestId: request.requestId, + ok: false, + error: { code: 'not_ready', message: 'detach rejected', retryable: true }, + }) + ); + + await expect(pending).rejects.toThrow('detach rejected'); + await runInDurableObject(stub, async instance => { + expect((await instance.listRoutes()).map(route => route.sessionId).sort()).toEqual([ + GRANT_SESSION_ID, + SECOND_GRANT_SESSION_ID, + ]); + }); + ws.close(); + }); + + it('removes a disconnected root without disturbing siblings or arming idle stop', async () => { + const stub = env.SANDBOX_CONTROL.getByName('sbx__worktree_disconnected_detach'); + + await runInDurableObject(stub, async (instance, state) => { + await instance.initializeOwner('owner_1'); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(GRANT_SESSION_ID, ROOT_ID) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID) + ); + + await expect(instance.detachSession(SECOND_GRANT_SESSION_ID)).resolves.toEqual({ + existed: true, + }); + await expect(instance.detachSession(SECOND_GRANT_SESSION_ID)).resolves.toEqual({ + existed: false, + }); + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ sessionId: GRANT_SESSION_ID, kiloSessionId: ROOT_ID }), + ]); + expect((await loadDeadlines(state.storage)).idleStop).toBeUndefined(); + }); + }); +}); + +describe('SandboxControl worktree activity deadlines', () => { + it('tracks both roots, retains a prompt handoff deadline until heartbeat, and re-arms after detach', async () => { + const targetSandboxId = 'sbx__worktree_idle_deadlines'; + const credential = generateSandboxCredential(); + const stub = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedRunningCredential(credential, targetSandboxId); + await runInDurableObject(stub, async instance => { + await instance.initializeOwner('owner_1'); + await seedRunningCloudflare(instance); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(GRANT_SESSION_ID, ROOT_ID) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(SECOND_GRANT_SESSION_ID, SECOND_ROOT_ID) + ); + }); + + const ws = await connect(credential, targetSandboxId); + await completeHello(ws, 'hello-idle-deadlines'); + await deliverWrapperEvent(stub, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + await runInDurableObject(stub, async (_instance, state) => { + const deadlines = await state.storage.get<{ idleStop?: number }>('deadlines'); + expect(deadlines?.idleStop).toEqual(expect.any(Number)); + }); + + await deliverWrapperEvent(stub, 'sandbox.heartbeat', { + state: 'finalizing', + kilo: { ready: true }, + sessions: [ + { kiloSessionId: ROOT_ID, state: 'idle', idleForMs: 15 }, + { + kiloSessionId: SECOND_ROOT_ID, + state: 'finalizing', + idleForMs: 3, + waitingOn: 'finalizing', + }, + ], + }); + await runInDurableObject(stub, async (instance, state) => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ kiloSessionId: ROOT_ID, lastState: 'idle', idleForMs: 15 }), + expect.objectContaining({ + kiloSessionId: SECOND_ROOT_ID, + lastState: 'finalizing', + waitingOn: 'finalizing', + }), + ]); + await expect(instance.getStatus()).resolves.toMatchObject({ work: 'finalizing' }); + expect( + (await state.storage.get<{ idleStop?: number }>('deadlines'))?.idleStop + ).toBeUndefined(); + }); + + await deliverWrapperEvent(stub, 'sandbox.heartbeat', { + state: 'idle', + kilo: { ready: true }, + sessions: [ + { kiloSessionId: ROOT_ID, state: 'idle', idleForMs: 25 }, + { kiloSessionId: SECOND_ROOT_ID, state: 'idle', idleForMs: 5 }, + ], + }); + await runInDurableObject(stub, async (_instance, state) => { + expect((await state.storage.get<{ idleStop?: number }>('deadlines'))?.idleStop).toEqual( + expect.any(Number) + ); + }); + + const inboundPrompt = nextMessage(ws); + const prompt = runInDurableObject(stub, instance => + instance.request({ + operation: 'session.prompt', + session: { + sessionId: SECOND_GRANT_SESSION_ID, + kiloSessionId: SECOND_ROOT_ID, + directory: '/workspace/shared', + }, + payload: { + messageId: 'msg_idle_handoff', + turn: { type: 'prompt', prompt: 'remain active' }, + agent: { mode: 'code', model: 'test' }, + }, + }) + ); + const promptRequest = JSON.parse(await inboundPrompt) as WrapperRequest; + await runInDurableObject(stub, async (instance, state) => { + const deadline = (await state.storage.get<{ idleStop?: number }>('deadlines'))?.idleStop; + expect(deadline).toBeGreaterThan(Date.now()); + expect(deadline).toBeLessThanOrEqual(Date.now() + DEADLINE_MS.idleStop); + expect(await instance.listRoutes()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kiloSessionId: ROOT_ID, lastState: 'idle' }), + expect.objectContaining({ kiloSessionId: SECOND_ROOT_ID, lastState: 'active' }), + ]) + ); + }); + respondToWrapperRequest(ws, promptRequest, { + messageId: 'msg_idle_handoff', + status: 'accepted', + }); + await expect(prompt).resolves.toMatchObject({ ok: true }); + + await deliverWrapperEvent(stub, 'sandbox.heartbeat', { + state: 'active', + kilo: { ready: true }, + sessions: [ + { kiloSessionId: ROOT_ID, state: 'active', idleForMs: 0, waitingOn: 'tool' }, + { kiloSessionId: SECOND_ROOT_ID, state: 'idle', idleForMs: 2 }, + ], + }); + await runInDurableObject(stub, async (instance, state) => { + await expect(instance.getStatus()).resolves.toMatchObject({ work: 'active' }); + expect( + (await state.storage.get<{ idleStop?: number }>('deadlines'))?.idleStop + ).toBeUndefined(); + }); + + const inboundDetach = nextMessage(ws); + const detached = runInDurableObject(stub, instance => instance.detachSession(GRANT_SESSION_ID)); + const detachRequest = JSON.parse(await inboundDetach) as WrapperRequest; + expect(detachRequest.session?.kiloSessionId).toBe(ROOT_ID); + respondToWrapperRequest(ws, detachRequest, { detached: true }); + await expect(detached).resolves.toEqual({ existed: true }); + + await runInDurableObject(stub, async (instance, state) => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ kiloSessionId: SECOND_ROOT_ID, lastState: 'idle' }), + ]); + expect((await state.storage.get<{ idleStop?: number }>('deadlines'))?.idleStop).toEqual( + expect.any(Number) + ); + }); + ws.close(); + }); +}); + +describe('SandboxSession worktree admission', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ valid: true })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('preserves repository branches and exact providers while forcing grouped auto-commit off', async () => { + const ownerId = 'user_grouped_registration'; + const sessionId = 'workspace_grouped_registration'; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + + const metadata = await runInDurableObject(stub, async instance => { + const registered = await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: 'kilo_grouped_registration', + sandboxId: 'ses-acde1234', + provider: 'vercel', + }) + ); + expect(registered.success).toBe(true); + return instance.getMetadata(); + }); + + expect(metadata).toMatchObject({ + repository: { + type: 'github', + repo: 'Kilo-Org/cloud', + upstreamBranch: 'feature/shared-worktree', + }, + workspace: { + sandboxId: 'ses-acde1234', + sandboxProvider: 'vercel', + workspacePath: '/workspace/shared', + worktreeId: WORKTREE_ID, + }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }); + }); + + it('persists canonical prompt identity and attachments during registration-only creation', async () => { + const ownerId = 'user_grouped_registered_prompt'; + const sessionId = 'workspace_grouped_registered_prompt'; + const attachments = { + path: '123e4567-e89b-12d3-a456-426614174000', + files: ['123e4567-e89b-12d3-a456-426614174001.pdf'], + }; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + + await runInDurableObject(stub, async instance => { + await instance.registerSession({ + ...groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: 'kilo_grouped_registered_prompt', + sandboxId: 'usr-abcdef123410', + }), + message: { + initialMessageId: INITIAL_MESSAGE_ID, + turn: { + type: 'prompt', + id: INITIAL_MESSAGE_ID, + prompt: 'inspect the document', + attachments, + }, + }, + }); + + expect((await instance.getMetadata())?.initialMessage).toEqual({ + id: INITIAL_MESSAGE_ID, + prompt: 'inspect the document', + attachments, + turn: { type: 'prompt', prompt: 'inspect the document', attachments }, + }); + }); + }); + + it('preserves auto-commit for ungrouped sessions', async () => { + const ownerId = 'user_ungrouped_registration'; + const sessionId = 'workspace_ungrouped_registration'; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + + const metadata = await runInDurableObject(stub, async instance => { + await instance.registerSession({ + identity: { sessionId, userId: ownerId }, + auth: { kiloSessionId: 'kilo_ungrouped_registration' }, + agent: { mode: 'code', model: 'test-model' }, + workspace: { sandboxId: 'usr-abcdef12', workspacePath: '/workspace/legacy' }, + finalization: { autoCommit: true }, + }); + return instance.getMetadata(); + }); + + expect(metadata?.workspace?.worktreeId).toBeUndefined(); + expect(metadata?.finalization?.autoCommit).toBe(true); + }); + + it('forces grouped auto-commit off before first attach and prompt delivery', async () => { + const ownerId = 'user_grouped_initial'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123401'; + const kiloSessionId = ROOT_ID; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); + + const ws = await connect(credential, targetSandboxId); + await completeHello(ws, 'hello-grouped-initial', { wrapperInstanceId: crypto.randomUUID() }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + const incomingAttach = nextMessage(ws); + const initialTurn = { + type: 'prompt', + messageId: INITIAL_MESSAGE_ID, + prompt: 'first grouped turn', + } as const; + const admitted = await runInDurableObject(session, instance => + instance.createSessionWithInitialAdmission({ + ...groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }), + message: { initialTurn }, + }) + ); + expect(admitted).toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); + + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + expect(attach).toMatchObject({ + operation: 'session.attach', + session: { sessionId, kiloSessionId, directory: '/workspace/shared' }, + payload: { branch: 'feature/shared-worktree' }, + }); + await runInDurableObject(session, async (instance, state) => { + const metadata = await instance.getMetadata(); + expect(metadata?.finalization?.autoCommit).toBe(false); + expect(metadata?.initialMessage).toEqual({ + id: INITIAL_MESSAGE_ID, + prompt: 'first grouped turn', + turn: { type: 'prompt', prompt: 'first grouped turn' }, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ + messageId: INITIAL_MESSAGE_ID, + intent: expect.objectContaining({ + turn: { + type: 'prompt', + messageId: INITIAL_MESSAGE_ID, + prompt: 'first grouped turn', + }, + agent: { mode: 'code', model: 'test-model' }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }), + }), + ]); + }); + await runInDurableObject(control, async instance => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ sessionId, kiloSessionId, worktreeId: WORKTREE_ID }), + ]); + }); + + const incomingPrompt = nextMessage(ws); + respondToWrapperRequest(ws, attach, { attached: true }); + const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; + expect(prompt).toMatchObject({ + operation: 'session.prompt', + session: { sessionId, kiloSessionId }, + payload: { + messageId: INITIAL_MESSAGE_ID, + finalization: { autoCommit: false, condenseOnComplete: true }, + }, + }); + respondToWrapperRequest(ws, prompt, { + messageId: INITIAL_MESSAGE_ID, + status: 'accepted', + }); + ws.close(); + }); + + it('persists and dispatches initial command turns with their agent and arguments', async () => { + const ownerId = 'user_grouped_initial_command'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123411'; + const kiloSessionId = ROOT_ID; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-command', { + wrapperInstanceId: crypto.randomUUID(), + }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + const incomingAttach = nextMessage(wrapper); + const initialTurn = { + type: 'command' as const, + messageId: INITIAL_MESSAGE_ID, + command: 'compact', + arguments: '--aggressive', + }; + const registration = { + ...groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }), + agent: { mode: 'architect', model: 'kilo/command-model', variant: 'thinking' }, + message: { initialTurn }, + }; + const admitted = await runInDurableObject(session, instance => + instance.createSessionWithInitialAdmission(registration) + ); + expect(admitted).toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); + + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + await runInDurableObject(session, async (instance, state) => { + expect((await instance.getMetadata())?.initialMessage).toEqual({ + id: INITIAL_MESSAGE_ID, + prompt: '/compact --aggressive', + turn: { type: 'command', command: 'compact', arguments: '--aggressive' }, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ + version: 2, + intent: { + turn: initialTurn, + agent: { mode: 'architect', model: 'kilo/command-model', variant: 'thinking' }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }, + }), + ]); + await expect(instance.createSessionWithInitialAdmission(registration)).resolves.toMatchObject( + { + success: true, + messageId: INITIAL_MESSAGE_ID, + } + ); + await expect( + instance.createSessionWithInitialAdmission({ + ...registration, + message: { + initialTurn: { ...initialTurn, arguments: '--different' }, + }, + }) + ).resolves.toMatchObject({ + success: false, + code: 'BAD_REQUEST', + }); + }); + + const incomingCommand = nextMessage(wrapper); + respondToWrapperRequest(wrapper, attach, { attached: true }); + const command = JSON.parse(await incomingCommand) as WrapperRequest; + expect(command).toMatchObject({ + operation: 'session.prompt', + session: { sessionId, kiloSessionId }, + payload: { + messageId: INITIAL_MESSAGE_ID, + turn: { type: 'command', command: 'compact', arguments: '--aggressive' }, + agent: { mode: 'architect', model: 'command-model', variant: 'thinking' }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }, + }); + respondToWrapperRequest(wrapper, command, { + messageId: INITIAL_MESSAGE_ID, + status: 'accepted', + }); + wrapper.close(); + }); + + it('dispatches signed prompt attachments and preserves follow-up agent overrides', async () => { + const ownerId = 'user_grouped_attachment'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123412'; + const kiloSessionId = ROOT_ID; + const attachments = { + path: '123e4567-e89b-12d3-a456-426614174000', + files: ['123e4567-e89b-12d3-a456-426614174001.pdf'], + }; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-attachment', { + wrapperInstanceId: crypto.randomUUID(), + }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + const incomingAttach = nextMessage(wrapper); + await runInDurableObject(session, async instance => { + Object.assign(instance['env'], { + R2_ATTACHMENTS_READONLY_ACCESS_KEY_ID: 'test-access-key', + R2_ATTACHMENTS_READONLY_SECRET_ACCESS_KEY: 'test-secret-key', + R2_ENDPOINT: 'https://attachments.example.test', + R2_ATTACHMENTS_BUCKET: 'test-attachments', + }); + await instance.registerSession( + groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }) + ); + await expect( + instance.admitSubmittedMessage({ + userId: ownerId, + turn: { + type: 'prompt', + id: INITIAL_MESSAGE_ID, + prompt: 'review the document', + attachments, + }, + agent: { mode: 'debug', model: 'kilo/override-model', variant: 'focused' }, + finalization: { autoCommit: true, condenseOnComplete: false }, + }) + ).resolves.toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); + }); + + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + await runInDurableObject(session, async (_instance, state) => { + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ + intent: { + turn: { + type: 'prompt', + messageId: INITIAL_MESSAGE_ID, + prompt: 'review the document', + attachments, + }, + agent: { mode: 'debug', model: 'kilo/override-model', variant: 'focused' }, + finalization: { autoCommit: false, condenseOnComplete: false }, + }, + }), + ]); + }); + const incomingPrompt = nextMessage(wrapper); + respondToWrapperRequest(wrapper, attach, { attached: true }); + const prompt = JSON.parse(await incomingPrompt) as WrapperRequest & { + payload: { + attachments: Array<{ + mime: string; + signedUrl: string; + filename: string; + localPath: string; + }>; + }; + }; + expect(prompt).toMatchObject({ + operation: 'session.prompt', + payload: { + messageId: INITIAL_MESSAGE_ID, + turn: { type: 'prompt', prompt: 'review the document' }, + agent: { mode: 'debug', model: 'override-model', variant: 'focused' }, + finalization: { autoCommit: false, condenseOnComplete: false }, + }, + }); + expect(prompt.payload.attachments).toHaveLength(1); + expect(prompt.payload.attachments[0]).toMatchObject({ + mime: 'application/pdf', + filename: attachments.files[0], + }); + const signedUrl = prompt.payload.attachments[0]?.signedUrl; + if (!signedUrl) throw new Error('Expected a signed attachment URL'); + const parsed = new URL(signedUrl); + expect(parsed.origin).toBe('https://attachments.example.test'); + expect(parsed.pathname).toBe( + `/test-attachments/${ownerId}/cloud-agent/${attachments.path}/${attachments.files[0]}` + ); + expect(parsed.searchParams.has('X-Amz-Signature')).toBe(true); + + respondToWrapperRequest(wrapper, prompt, { + messageId: INITIAL_MESSAGE_ID, + status: 'accepted', + }); + wrapper.close(); + }); + + it('dispatches follow-up commands and preserves ungrouped per-turn finalization', async () => { + const ownerId = 'user_ungrouped_followup_command'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123416'; + const kiloSessionId = ROOT_ID; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-ungrouped-followup-command', { + wrapperInstanceId: crypto.randomUUID(), + }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + const incomingAttach = nextMessage(wrapper); + await runInDurableObject(session, async instance => { + await instance.registerSession({ + identity: { sessionId, userId: ownerId }, + auth: { kiloSessionId, kilocodeToken: KILO_TOKEN }, + agent: { mode: 'code', model: 'default-model' }, + workspace: { sandboxId: targetSandboxId, workspacePath: '/workspace/shared' }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }); + await expect( + instance.admitSubmittedMessage({ + userId: ownerId, + turn: { + type: 'command', + id: INITIAL_MESSAGE_ID, + command: 'review', + arguments: '--base main', + }, + agent: { mode: 'architect', model: 'kilo/followup-model', variant: 'deep' }, + finalization: { autoCommit: true, condenseOnComplete: false }, + }) + ).resolves.toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); + }); + + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + const incomingCommand = nextMessage(wrapper); + respondToWrapperRequest(wrapper, attach, { attached: true }); + const command = JSON.parse(await incomingCommand) as WrapperRequest; + expect(command).toMatchObject({ + operation: 'session.prompt', + payload: { + messageId: INITIAL_MESSAGE_ID, + turn: { type: 'command', command: 'review', arguments: '--base main' }, + agent: { mode: 'architect', model: 'followup-model', variant: 'deep' }, + finalization: { autoCommit: true, condenseOnComplete: false }, + }, + }); + respondToWrapperRequest(wrapper, command, { + messageId: INITIAL_MESSAGE_ID, + status: 'accepted', + }); + wrapper.close(); + }); + + it('rejects attachments on command submissions without admitting a message', async () => { + const ownerId = 'user_grouped_command_attachments'; + const sessionId = 'workspace_grouped_command_attachments'; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: 'kilo_grouped_command_attachments', + sandboxId: 'usr-abcdef123413', + }) + ); + await expect( + instance.admitSubmittedMessage({ + userId: ownerId, + turn: { + type: 'command', + id: INITIAL_MESSAGE_ID, + command: 'compact', + arguments: '', + attachments: { + path: '123e4567-e89b-12d3-a456-426614174000', + files: ['123e4567-e89b-12d3-a456-426614174001.pdf'], + }, + }, + }) + ).resolves.toEqual({ + success: false, + code: 'BAD_REQUEST', + error: 'Attachments cannot be attached to slash commands', + }); + expect(await state.storage.get('session_messages')).toBeUndefined(); + }); + }); + + it('overrides a submitted grouped turn that requests auto-commit', async () => { + const ownerId = 'user_grouped_followup'; + const sessionId = 'workspace_grouped_followup'; + const targetSandboxId = 'usr-abcdef123402'; + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await instance.claimCreate('grouped-followup-intent'); + }); + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: 'kilo_grouped_followup', + sandboxId: targetSandboxId, + }) + ); + const staleMetadata = await instance.getMetadata(); + if (!staleMetadata) throw new Error('Expected grouped session metadata'); + await state.storage.put('session_metadata', { + ...staleMetadata, + finalization: { ...staleMetadata.finalization, autoCommit: true }, + }); + const admitted = await instance.admitSubmittedMessage({ + userId: ownerId, + turn: { type: 'prompt', id: 'msg_grouped_followup', prompt: 'follow-up' }, + finalization: { autoCommit: true, condenseOnComplete: false }, + }); + expect(admitted).toMatchObject({ success: true, messageId: 'msg_grouped_followup' }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ + messageId: 'msg_grouped_followup', + intent: expect.objectContaining({ + finalization: { autoCommit: false, condenseOnComplete: false }, + }), + }), + ]); + expect((await instance.getMetadata())?.finalization?.autoCommit).toBe(false); + }); + }); + + it('dispatches older prompt-only queue records with their stored finalization fallback', async () => { + const ownerId = 'user_grouped_legacy_record'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123414'; + const kiloSessionId = ROOT_ID; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-legacy-record', { + wrapperInstanceId: crypto.randomUUID(), + }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + await runInDurableObject(session, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }) + ); + await state.storage.put('session_messages', [ + { + messageId: 'msg_legacy_record', + state: 'queued', + prompt: 'recover an older prompt', + finalization: { autoCommit: true, condenseOnComplete: false }, + }, + ]); + }); + + const incomingAttach = nextMessage(wrapper); + const dispatched = runInDurableObject(session, instance => instance.alarm()); + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + const incomingPrompt = nextMessage(wrapper); + respondToWrapperRequest(wrapper, attach, { attached: true }); + const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; + expect(prompt).toMatchObject({ + operation: 'session.prompt', + payload: { + messageId: 'msg_legacy_record', + turn: { type: 'prompt', prompt: 'recover an older prompt' }, + agent: { mode: 'code', model: 'test-model' }, + finalization: { autoCommit: false, condenseOnComplete: false }, + }, + }); + respondToWrapperRequest(wrapper, prompt, { + messageId: 'msg_legacy_record', + status: 'accepted', + }); + await expect(dispatched).resolves.toBeUndefined(); + wrapper.close(); + }); +}); + +describe('SandboxSession durable message lifecycle', () => { + beforeEach(() => { + vi.spyOn(globalThis, 'fetch').mockImplementation(async () => Response.json({ valid: true })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('persists and streams exactly one sent event per accepted sibling prompt', async () => { + const ownerId = 'user_grouped_sent'; + const targetSandboxId = 'usr-abcdef123417'; + const roots = [ + { + sessionId: GRANT_SESSION_ID, + kiloSessionId: ROOT_ID, + messageId: INITIAL_MESSAGE_ID, + prompt: 'first root turn', + }, + { + sessionId: SECOND_GRANT_SESSION_ID, + kiloSessionId: SECOND_ROOT_ID, + messageId: 'msg_018f1e2d3c4bNoPmLkJiHgFeDc', + prompt: 'second root turn', + }, + ] as const; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-sent', { wrapperInstanceId: crypto.randomUUID() }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + const sessions = await Promise.all( + roots.map(async root => { + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${root.sessionId}`); + await runInDurableObject(stub, instance => + instance.registerSession( + groupedRegistration({ + ownerId, + sessionId: root.sessionId, + kiloSessionId: root.kiloSessionId, + sandboxId: targetSandboxId, + }) + ) + ); + const stream = await connectSessionStream(root.sessionId, ownerId, ['cloud.message.sent']); + const observed: SessionStreamEvent[] = []; + stream.addEventListener('message', event => { + observed.push(JSON.parse(String(event.data)) as SessionStreamEvent); + }); + return { ...root, stub, stream, observed }; + }) + ); + + for (const session of sessions) { + const incomingAttach = nextMessage(wrapper); + const admission = await runInDurableObject(session.stub, instance => + instance.admitSubmittedMessage({ + userId: ownerId, + turn: { type: 'prompt', id: session.messageId, prompt: session.prompt }, + }) + ); + expect(admission).toMatchObject({ + success: true, + messageId: session.messageId, + compatibilityDelivery: 'queued', + }); + + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + expect(attach).toMatchObject({ + operation: 'session.attach', + session: { + sessionId: session.sessionId, + kiloSessionId: session.kiloSessionId, + directory: '/workspace/shared', + }, + }); + const incomingPrompt = nextMessage(wrapper); + respondToWrapperRequest(wrapper, attach, { attached: true }); + const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; + expect(prompt).toMatchObject({ + operation: 'session.prompt', + session: { sessionId: session.sessionId, kiloSessionId: session.kiloSessionId }, + payload: { messageId: session.messageId }, + }); + + const incomingSent = nextMessage(session.stream); + respondToWrapperRequest(wrapper, prompt, { + messageId: session.messageId, + status: 'accepted', + }); + const sent = JSON.parse(await incomingSent) as SessionStreamEvent; + expect(sent).toMatchObject({ + sessionId: session.sessionId, + streamEventType: 'cloud.message.sent', + data: { messageId: session.messageId, delivery: 'sent' }, + }); + expect(sent.eventId).toBeGreaterThan(0); + + await runInDurableObject(session.stub, async (instance, state) => { + const events = persistedSessionEvents(state, ['cloud.message.sent']); + expect(events).toHaveLength(1); + expect(events[0]?.session_id).toBe(session.sessionId); + expect(JSON.parse(events[0]?.payload ?? '')).toEqual({ + messageId: session.messageId, + delivery: 'sent', + }); + await expect( + instance.admitSubmittedMessage({ + userId: ownerId, + turn: { type: 'prompt', id: session.messageId, prompt: session.prompt }, + }) + ).resolves.toEqual({ + success: true, + outcome: 'queued', + messageId: session.messageId, + compatibilityDelivery: 'sent', + }); + expect(persistedSessionEvents(state, ['cloud.message.sent'])).toHaveLength(1); + }); + } + + for (const session of sessions) { + expect( + session.observed.map(event => ({ sessionId: event.sessionId, data: event.data })) + ).toEqual([ + { sessionId: session.sessionId, data: { messageId: session.messageId, delivery: 'sent' } }, + ]); + session.stream.close(); + } + wrapper.close(); + }); +}); + +describe('SandboxSession root-owned terminal events', () => { + it('settles only the matching root once from a fenced outcome without generic terminals', async () => { + const wrapperInstanceId = crypto.randomUUID(); + const ownerId = 'user_grouped_terminal'; + const sessionId = GRANT_SESSION_ID; + const siblingSessionId = SECOND_GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123403'; + const root = ROOT_ID; + const siblingRoot = SECOND_ROOT_ID; + const lifecycleTypes = ['cloud.message.completed', 'cloud.message.failed', 'complete', 'error']; + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await instance.claimCreate('grouped-terminal-intent'); + await attachGrantedSession(instance, instance['ctx'], groupedRoute(sessionId, root, ownerId)); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(siblingSessionId, siblingRoot, ownerId) + ); + }); + + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ ownerId, sessionId, kiloSessionId: root, sandboxId: targetSandboxId }) + ); + await state.storage.put('session_messages', [ + { messageId: 'msg_active', state: 'accepted', acceptedAt: Date.now(), wrapperInstanceId }, + { messageId: 'msg_next', state: 'queued', prompt: 'next turn' }, + ]); + }); + + const sibling = env.SANDBOX_SESSION.getByName(`${ownerId}:${siblingSessionId}`); + await runInDurableObject(sibling, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId: siblingSessionId, + kiloSessionId: siblingRoot, + sandboxId: targetSandboxId, + }) + ); + await state.storage.put('session_messages', [ + { + messageId: 'msg_sibling_active', + state: 'accepted', + acceptedAt: Date.now(), + wrapperInstanceId, + }, + ]); + }); + + const stream = await connectSessionStream(sessionId, ownerId, lifecycleTypes); + const siblingStream = await connectSessionStream(siblingSessionId, ownerId, lifecycleTypes); + const siblingEvents: SessionStreamEvent[] = []; + siblingStream.addEventListener('message', event => { + siblingEvents.push(JSON.parse(String(event.data)) as SessionStreamEvent); + }); + + await runInDurableObject(stub, async (instance, state) => { + for (const type of ['session.turn.close', 'session.error']) { + await expect( + instance.receiveSandboxControlEvent({ + identity: { + directory: '/workspace/shared', + kiloSessionId: THIRD_ROOT_ID, + rootKiloSessionId: root, + }, + payload: { type, properties: { sessionID: THIRD_ROOT_ID } }, + }) + ).resolves.toEqual({ applied: true }); + } + await expect( + instance.receiveSandboxControlEvent({ + identity: { + directory: '/workspace/shared', + kiloSessionId: root, + rootKiloSessionId: siblingRoot, + }, + payload: { type: 'session.turn.close', properties: { sessionID: root } }, + }) + ).resolves.toEqual({ applied: false }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_active', state: 'accepted' }), + expect.objectContaining({ messageId: 'msg_next', state: 'queued' }), + ]); + expect(persistedSessionEvents(state, lifecycleTypes)).toEqual([]); + }); + + await runInDurableObject(sibling, async (instance, state) => { + await expect( + instance.receiveSandboxControlEvent({ + identity: { + directory: '/workspace/shared', + kiloSessionId: root, + rootKiloSessionId: root, + }, + payload: { type: 'session.turn.close', properties: { sessionID: root } }, + }) + ).resolves.toEqual({ applied: false }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_sibling_active', state: 'accepted' }), + ]); + expect(persistedSessionEvents(state, lifecycleTypes)).toEqual([]); + }); + + const completedPayload = { + messageId: 'msg_active', + status: 'completed', + delivery: 'sent', + accepted: true, + }; + const terminalInput = { + identity: { + directory: '/workspace/shared', + kiloSessionId: root, + rootKiloSessionId: root, + }, + wrapperInstanceId, + payload: { + type: 'session.message.outcome', + properties: { messageId: 'msg_active', status: 'completed' }, + }, + }; + await runInDurableObject(stub, async (instance, state) => { + for (const type of ['session.idle', 'session.turn.close', 'session.error']) { + await instance.receiveSandboxControlEvent({ + identity: terminalInput.identity, + wrapperInstanceId, + payload: { type, properties: { sessionID: root } }, + }); + } + await expect( + instance.receiveSandboxControlEvent({ + ...terminalInput, + wrapperInstanceId: crypto.randomUUID(), + }) + ).resolves.toEqual({ applied: false }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_active', state: 'accepted' }), + expect.objectContaining({ messageId: 'msg_next', state: 'queued' }), + ]); + expect(persistedSessionEvents(state, lifecycleTypes)).toEqual([]); + }); + const incomingTerminalEvents = nextMessages(stream, 1); + await runInDurableObject(stub, async (instance, state) => { + await expect(instance.receiveSandboxControlEvent(terminalInput)).resolves.toEqual({ + applied: true, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_active', state: 'completed' }), + expect.objectContaining({ messageId: 'msg_next', state: 'queued' }), + ]); + await expect(instance.receiveSandboxControlEvent(terminalInput)).resolves.toEqual({ + applied: true, + }); + expect( + persistedSessionEvents(state, lifecycleTypes).map(event => ({ + type: event.stream_event_type, + data: JSON.parse(event.payload), + })) + ).toEqual([{ type: 'cloud.message.completed', data: completedPayload }]); + }); + + const terminalEvents = (await incomingTerminalEvents).map( + message => JSON.parse(message) as SessionStreamEvent + ); + expect( + terminalEvents.map(event => ({ + sessionId: event.sessionId, + type: event.streamEventType, + data: event.data, + })) + ).toEqual([{ sessionId, type: 'cloud.message.completed', data: completedPayload }]); + expect(terminalEvents.every(event => event.eventId > 0)).toBe(true); + + await runInDurableObject(sibling, async (_instance, state) => { + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_sibling_active', state: 'accepted' }), + ]); + expect(persistedSessionEvents(state, lifecycleTypes)).toEqual([]); + }); + expect(siblingEvents).toEqual([]); + await runInDurableObject(stub, async instance => { + await instance.interruptExecution(); + await Promise.all(instance['dispatches'].values()); + }); + siblingStream.close(); + stream.close(); + }); + + it('persists and streams one canonical failure only for a fenced root outcome', async () => { + const wrapperInstanceId = crypto.randomUUID(); + const acceptedAt = Date.now(); + const ownerId = 'user_grouped_terminal_error'; + const sessionId = GRANT_SESSION_ID; + const root = ROOT_ID; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: root, + sandboxId: 'usr-abcdef123418', + }) + ); + await state.storage.put('session_messages', [ + { messageId: 'msg_grouped_failed', state: 'accepted', acceptedAt, wrapperInstanceId }, + ]); + }); + + const lifecycleTypes = ['cloud.message.failed', 'error']; + const stream = await connectSessionStream(sessionId, ownerId, lifecycleTypes); + const incomingTerminalEvents = nextMessages(stream, 1); + const terminalInput = { + identity: { + directory: '/workspace/shared', + kiloSessionId: root, + rootKiloSessionId: root, + }, + wrapperInstanceId, + payload: { + type: 'session.message.outcome', + properties: { messageId: 'msg_grouped_failed', status: 'failed' }, + }, + }; + const failedPayload = { + messageId: 'msg_grouped_failed', + status: 'failed', + delivery: 'sent', + accepted: true, + timestamp: acceptedAt, + }; + + await runInDurableObject(stub, async (instance, state) => { + await instance.receiveSandboxControlEvent({ + identity: terminalInput.identity, + wrapperInstanceId, + payload: { type: 'session.error', properties: { sessionID: root } }, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_grouped_failed', state: 'accepted' }), + ]); + expect(persistedSessionEvents(state, lifecycleTypes)).toEqual([]); + await expect(instance.receiveSandboxControlEvent(terminalInput)).resolves.toEqual({ + applied: true, + }); + await expect(instance.receiveSandboxControlEvent(terminalInput)).resolves.toEqual({ + applied: true, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_grouped_failed', state: 'failed' }), + ]); + expect( + persistedSessionEvents(state, lifecycleTypes).map(event => ({ + type: event.stream_event_type, + data: JSON.parse(event.payload), + })) + ).toEqual([{ type: 'cloud.message.failed', data: failedPayload }]); + }); + + const streamed = (await incomingTerminalEvents).map( + message => JSON.parse(message) as SessionStreamEvent + ); + expect( + streamed.map(event => ({ + sessionId: event.sessionId, + type: event.streamEventType, + data: event.data, + })) + ).toEqual([{ sessionId, type: 'cloud.message.failed', data: failedPayload }]); + expect(streamed.every(event => event.eventId > 0)).toBe(true); + stream.close(); + }); +}); + +describe('SandboxSession running stream state', () => { + it('reconnects an accepted root as ready while preserving queued sibling turns', async () => { + const ownerId = 'user_grouped_running_stream'; + const sessionId = GRANT_SESSION_ID; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: ROOT_ID, + sandboxId: 'usr-abcdef123415', + }) + ); + await state.storage.put('session_messages', [ + { + messageId: 'msg_running', + state: 'accepted', + acceptedAt: Date.now(), + intent: { + turn: { type: 'prompt', messageId: 'msg_running', prompt: 'currently running' }, + agent: { mode: 'code', model: 'test-model' }, + }, + }, + { + messageId: 'msg_waiting', + state: 'queued', + intent: { + turn: { + type: 'command', + messageId: 'msg_waiting', + command: 'compact', + arguments: '--next', + }, + agent: { mode: 'code', model: 'test-model' }, + }, + }, + ]); + }); + + const response = await SELF.fetch( + `http://worker.test/stream?sessionId=${sessionId}&userId=${ownerId}`, + { headers: { Upgrade: 'websocket' } } + ); + if (response.status !== 101 || !response.webSocket) { + throw new Error(`Unexpected running-session stream status: ${response.status}`); + } + response.webSocket.accept(); + const events = (await nextMessages(response.webSocket, 4)).map( + message => JSON.parse(message) as SessionStreamEvent + ); + + expect(events[0]).toMatchObject({ + eventId: 0, + sessionId, + streamEventType: 'connected', + data: { cloudStatus: { type: 'ready' } }, + }); + expect(events.slice(1)).toEqual([ + expect.objectContaining({ + eventId: 0, + sessionId, + streamEventType: 'cloud.message.queued', + data: { messageId: 'msg_running', content: 'currently running', delivery: 'queued' }, + }), + expect.objectContaining({ + eventId: 0, + sessionId, + streamEventType: 'cloud.message.sent', + data: { messageId: 'msg_running', delivery: 'sent' }, + }), + expect.objectContaining({ + eventId: 0, + sessionId, + streamEventType: 'cloud.message.queued', + data: { messageId: 'msg_waiting', content: '/compact --next', delivery: 'queued' }, + }), + ]); + await runInDurableObject(stub, async (_instance, state) => { + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: 'msg_running', state: 'accepted' }), + expect.objectContaining({ messageId: 'msg_waiting', state: 'queued' }), + ]); + }); + response.webSocket.close(); + }); +}); + +describe('SandboxSession root-scoped reconnect sync', () => { + it('reconciles an accepted root before connected and filters sibling questions and permissions', async () => { + const wrapperInstanceId = crypto.randomUUID(); + const ownerId = 'user_grouped_sync'; + const activeSessionId = GRANT_SESSION_ID; + const emptySessionId = SECOND_GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123404'; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(activeSessionId, ROOT_ID, ownerId) + ); + }); + + const active = env.SANDBOX_SESSION.getByName(`${ownerId}:${activeSessionId}`); + await runInDurableObject(active, async (instance, state) => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId: activeSessionId, + kiloSessionId: ROOT_ID, + sandboxId: targetSandboxId, + }) + ); + await state.storage.put('session_messages', [ + { + messageId: INITIAL_MESSAGE_ID, + state: 'accepted', + acceptedAt: Date.now(), + wrapperInstanceId, + }, + ]); + }); + const empty = env.SANDBOX_SESSION.getByName(`${ownerId}:${emptySessionId}`); + await runInDurableObject(empty, async instance => { + await instance.registerSession( + groupedRegistration({ + ownerId, + sessionId: emptySessionId, + kiloSessionId: SECOND_ROOT_ID, + sandboxId: targetSandboxId, + }) + ); + }); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-sync', { wrapperInstanceId }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + const incomingSync = nextMessage(wrapper); + + const emptyResponse = await SELF.fetch( + `http://worker.test/stream?sessionId=${emptySessionId}&userId=${ownerId}`, + { headers: { Upgrade: 'websocket' } } + ); + if (emptyResponse.status !== 101 || !emptyResponse.webSocket) { + throw new Error(`Unexpected empty-session stream status: ${emptyResponse.status}`); + } + emptyResponse.webSocket.accept(); + const emptyConnected = JSON.parse(await nextMessage(emptyResponse.webSocket)) as { + streamEventType: string; + }; + expect(emptyConnected.streamEventType).toBe('connected'); + + const pendingActiveResponse = SELF.fetch( + `http://worker.test/stream?sessionId=${activeSessionId}&userId=${ownerId}`, + { headers: { Upgrade: 'websocket' } } + ); + const sync = JSON.parse(await incomingSync) as WrapperRequest; + expect(sync).toMatchObject({ + operation: 'session.sync', + session: { + sessionId: activeSessionId, + kiloSessionId: ROOT_ID, + directory: '/workspace/shared', + }, + payload: {}, + }); + const questions = [ + { id: 'question_root', sessionID: ROOT_ID, questions: [{ question: 'Root?' }] }, + { + id: 'question_child', + sessionID: THIRD_ROOT_ID, + rootKiloSessionId: ROOT_ID, + questions: [{ question: 'Child?' }], + }, + ]; + const permissions = [ + { id: 'permission_root', sessionID: ROOT_ID }, + { id: 'permission_child', sessionID: THIRD_ROOT_ID, rootKiloSessionId: ROOT_ID }, + ]; + respondToWrapperRequest(wrapper, sync, { + status: { type: 'busy' }, + questions: [ + ...questions, + { id: 'question_sibling', sessionID: SECOND_ROOT_ID }, + { id: 'question_contradictory', sessionID: ROOT_ID, rootKiloSessionId: SECOND_ROOT_ID }, + ], + permissions: [ + ...permissions, + { id: 'permission_sibling', sessionID: SECOND_ROOT_ID }, + { id: 'permission_contradictory', sessionID: ROOT_ID, rootKiloSessionId: SECOND_ROOT_ID }, + ], + }); + const activeResponse = await pendingActiveResponse; + if (activeResponse.status !== 101 || !activeResponse.webSocket) { + throw new Error(`Unexpected active-session stream status: ${activeResponse.status}`); + } + activeResponse.webSocket.accept(); + const events = (await nextMessages(activeResponse.webSocket, 4)).map( + message => JSON.parse(message) as SessionStreamEvent + ); + expect(events.map(event => event.streamEventType)).toEqual([ + 'kilocode', + 'connected', + 'cloud.message.queued', + 'cloud.message.sent', + ]); + expect(events[1]?.data).toMatchObject({ + cloudStatus: { type: 'ready' }, + activeMessageId: INITIAL_MESSAGE_ID, + pendingInteractions: { questions, permissions }, + }); + await runInDurableObject(active, async (_instance, state) => { + expect(await state.storage.get('session_pending_interactions')).toMatchObject({ + questions, + permissions, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ messageId: INITIAL_MESSAGE_ID, state: 'accepted' }), + ]); + }); + await runInDurableObject(control, async instance => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ sessionId: activeSessionId, kiloSessionId: ROOT_ID }), + ]); + }); + await runInDurableObject(empty, async (_instance, state) => { + expect(await state.storage.get('session_messages')).toBeUndefined(); + }); + + activeResponse.webSocket.close(); + emptyResponse.webSocket.close(); + wrapper.close(); + }); +}); + +describe('SandboxSession targeted deletion', () => { + it('detaches the deleted root before removing metadata and preserves sibling state', async () => { + const ownerId = 'user_grouped_delete'; + const sessionA = GRANT_SESSION_ID; + const sessionB = SECOND_GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123405'; + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(sessionA, ROOT_ID, ownerId) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(sessionB, SECOND_ROOT_ID, ownerId) + ); + }); + const first = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionA}`); + const second = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionB}`); + await runInDurableObject(first, instance => + instance.registerSession( + groupedRegistration({ + ownerId, + sessionId: sessionA, + kiloSessionId: ROOT_ID, + sandboxId: targetSandboxId, + }) + ) + ); + await runInDurableObject(second, instance => + instance.registerSession( + groupedRegistration({ + ownerId, + sessionId: sessionB, + kiloSessionId: SECOND_ROOT_ID, + sandboxId: targetSandboxId, + }) + ) + ); + + await runInDurableObject(second, instance => instance.deleteSession()); + + await runInDurableObject(control, async instance => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ sessionId: sessionA, kiloSessionId: ROOT_ID }), + ]); + }); + await runInDurableObject(first, async instance => { + expect((await instance.getMetadata())?.identity.sessionId).toBe(sessionA); + }); + await runInDurableObject(second, async instance => { + await expect(instance.getMetadata()).resolves.toBeNull(); + }); + }); + + it('retains cleanup metadata and the route but revokes credentials when live wrapper detach fails', async () => { + const ownerId = 'user_grouped_delete_failed'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123406'; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedRunningCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(sessionId, ROOT_ID, ownerId) + ); + }); + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + await runInDurableObject(session, instance => + instance.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: ROOT_ID, + sandboxId: targetSandboxId, + }) + ) + ); + + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-delete-failure'); + const incomingDetach = nextMessage(wrapper); + const deletion = runInDurableObject(session, instance => instance.deleteSession()); + const detach = JSON.parse(await incomingDetach) as WrapperRequest; + wrapper.send( + JSON.stringify({ + type: 'response', + requestId: detach.requestId, + ok: false, + error: { code: 'not_ready', message: 'live detach failed', retryable: true }, + }) + ); + + await expect(deletion).rejects.toThrow('live detach failed'); + await runInDurableObject(session, async (instance, state) => { + await expect(instance.getMetadata()).resolves.toBeNull(); + expect(await state.storage.get('session_metadata')).toMatchObject({ + identity: { sessionId }, + }); + expect(await state.storage.get('session_lifecycle_fence')).toMatchObject({ + state: 'deleted', + }); + }); + await runInDurableObject(control, async (instance, state) => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ sessionId, kiloSessionId: ROOT_ID }), + ]); + expect(await loadSessionCredentialGrants(state.storage)).toEqual([]); + }); + wrapper.close(); + }); +}); diff --git a/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts b/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts index ce74e7cbe1..833451cfc8 100644 --- a/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts @@ -20,6 +20,7 @@ import { type SessionCredentialGrant, } from '../../src/sandbox-control/session-credentials.js'; import { getSandboxSessionStub } from '../../src/sandbox-session/session-stub.js'; +import { createMessageId } from '../../src/session/message-id.js'; import { requestFrameSchema, responseFrameSchema, @@ -475,7 +476,7 @@ async function createFixture(ownerId?: string, organizationId?: string): Promise agent: { mode: 'code', model: 'test-model' }, workspace: { sandboxId, sandboxProvider: 'cloudflare' }, message: { - initialTurn: { messageId: `msg_${identity}`, type: 'prompt', prompt: 'Prepare workspace' }, + initialTurn: { messageId: createMessageId(), type: 'prompt', prompt: 'Prepare workspace' }, }, }) ); diff --git a/services/cloud-agent-next/test/integration/worktree-deletion.test.ts b/services/cloud-agent-next/test/integration/worktree-deletion.test.ts new file mode 100644 index 0000000000..d2e35374a4 --- /dev/null +++ b/services/cloud-agent-next/test/integration/worktree-deletion.test.ts @@ -0,0 +1,1438 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import type { CloudAgentWorktreeId } from '@kilocode/session-ingest-contracts'; +import { drizzle } from 'drizzle-orm/durable-sqlite'; +import { describe, expect, it, vi } from 'vitest'; +import { getWorktreeWorkspacePath } from '../../src/workspace'; +import { events } from '../../src/db/sqlite-schema'; +import { + generateSandboxCredential, + hashSandboxCredential, +} from '../../src/sandbox-control/credential'; +import type { ProviderAdapter } from '../../src/sandbox-control/provider'; +import type { AttachSessionInput, SandboxControl } from '../../src/persistence/SandboxControl'; +import { createControlPlaneCredential } from '../../src/sandbox-control/managed-credential'; +import { sessionCredentialGrantSchema } from '../../src/sandbox-control/session-credentials'; +import { + loadDeadlines, + loadSessionCredentialGrants, + saveDeadlines, + savePhysicalRecord, + saveSessionCredentialGrants, +} from '../../src/sandbox-control/durable-state'; +import { DEADLINE_MS } from '../../src/sandbox-control/deadlines'; +import { WORKTREE_CREDENTIAL_CONTAINMENT } from '../../src/sandbox-control/physical-lifecycle'; +import { encodeCloudflareProviderRef } from '../../src/sandbox-control/cloudflare-provider'; +import { + createVercelProviderAdapter, + type VercelControlRestClient, +} from '../../src/sandbox-control/vercel-provider'; +import { parseVercelSandboxRuntimeConfig } from '../../src/agent-sandbox/vercel/vercel-runtime-config'; +import type { VercelSandboxSession } from '../../src/agent-sandbox/vercel/vercel-sandbox-rest-client'; +import { loadWorktreeDeletionJournal } from '../../src/sandbox-control/worktree-deletion'; +import { resolveSandboxExclusivity } from '../../src/sandbox-control/worktree-ownership'; +import type { RequestFrame } from '../../src/shared/sandbox-control-protocol'; + +const userId = 'oauth/google:worktree-integration'; +const worktreeId: CloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; +const otherWorktreeId: CloudAgentWorktreeId = 'worktree_22222222-2222-4222-8222-222222222222'; +const directory = getWorktreeWorkspacePath(undefined, userId, worktreeId); +const otherDirectory = getWorktreeWorkspacePath(undefined, userId, otherWorktreeId); +const kiloId = (index: number) => `ses_${String(index).padStart(26, '0')}`; +const cloudId = (): `workspace_${string}` => `workspace_${crypto.randomUUID()}`; + +const vercelConfig = { + VERCEL_TOKEN: 'fixture-vercel-token', + VERCEL_TEAM_ID: 'team_test', + VERCEL_PROJECT_ID: 'prj_test', + VERCEL_SANDBOX_SNAPSHOT_ID: 'snap_test', + VERCEL_SANDBOX_RUNTIME_BUILD_ID: 'build_test', + VERCEL_SANDBOX_RUNTIME: 'node24', + VERCEL_SANDBOX_INITIAL_TIMEOUT_MS: '300000', + VERCEL_SANDBOX_EXTEND_DURATION_MS: '120000', +}; + +function createDeletionProvider(sandboxId: string) { + const sessions = new Map(); + const config = parseVercelSandboxRuntimeConfig(vercelConfig); + if (!config) throw new Error('Missing Vercel fixture configuration'); + const requireSession = (id: string) => { + const session = sessions.get(id); + if (!session) throw new Error('Unknown native fixture session'); + return session; + }; + const client: VercelControlRestClient = { + async createSandbox(input) { + const session: VercelSandboxSession = { + id: `vsess_${crypto.randomUUID()}`, + sourceSandboxName: input.name, + projectId: config.projectId, + runtime: 'node24', + status: 'running', + memory: 2048, + vcpus: 2, + region: 'iad1', + timeout: 300_000, + requestedAt: Date.now(), + startedAt: Date.now(), + cwd: '/', + createdAt: Date.now(), + updatedAt: Date.now(), + }; + sessions.set(session.id, session); + return { + sandbox: { + name: input.name, + currentSessionId: session.id, + status: 'running', + persistent: false, + createdAt: Date.now(), + updatedAt: Date.now(), + tags: {}, + }, + session, + routes: [], + runtime: { sandboxName: input.name, sessionId: session.id }, + }; + }, + async inspectByName() { + return null; + }, + async getSession(id) { + return { session: requireSession(id), routes: [] }; + }, + async executeCommand(sessionId, input) { + requireSession(sessionId); + return { + id: 'cmd_fixture', + name: input.command, + args: input.args ?? [], + cwd: '/', + sessionId, + exitCode: null, + startedAt: Date.now(), + }; + }, + async updateNetworkPolicy(id, name) { + const session = requireSession(id); + expect(name).toBe(session.sourceSandboxName); + return session; + }, + async stopSession(id) { + const session = { ...requireSession(id), status: 'stopped' as const }; + sessions.set(id, session); + return session; + }, + async extendSessionTimeout(id) { + return requireSession(id); + }, + async readFile() { + return new Uint8Array(); + }, + }; + let allocationName = sandboxId; + const adapter = () => + createVercelProviderAdapter({ sandboxName: allocationName, config, restClient: client }); + return { + resumable: false, + ensureBillingAdmission: (ref, billing) => adapter().ensureBillingAdmission(ref, billing), + create: intent => { + allocationName = intent.allocationName ?? sandboxId; + return adapter().create(intent); + }, + launch: (ref, launchEnv) => adapter().launch(ref, launchEnv), + observe: (ref, intent = null) => adapter().observe(ref, intent), + stop: (ref, intent = null) => adapter().stop(ref, intent), + ensureLeaseAtLeast: (ref, ms) => adapter().ensureLeaseAtLeast(ref, ms), + logs: ref => adapter().logs(ref), + updateNetworkPolicy: vi.fn(async (ref, policy) => { + const native = adapter(); + if (!native.updateNetworkPolicy) throw new Error('Missing native policy update'); + await native.updateNetworkPolicy(ref, policy); + }), + } satisfies ProviderAdapter; +} + +function installDeletionProvider(instance: SandboxControl, provider: ProviderAdapter) { + Object.assign(instance, { + env: { + ...instance['env'], + ...vercelConfig, + WORKER_URL: 'https://worker.test', + KILOCODE_BACKEND_BASE_URL: 'https://backend.example.com', + KILO_OPENROUTER_BASE: 'https://provider.example.com/api/openrouter', + KILO_SESSION_INGEST_URL: 'https://ingest.example.com', + GIT_TOKEN_SERVICE: { + issueKiloSessionCapability: async () => ({ + success: true, + capability: `kka1.fixture-${crypto.randomUUID()}`, + }), + }, + }, + provider, + createProviderAdapter: () => provider, + providerKind: 'vercel', + }); +} + +async function seedPhysical( + instance: SandboxControl, + state: DurableObjectState, + intentId: string, + providerRef: string +) { + await instance.claimCreate(intentId, false, instance.sandboxId, WORKTREE_CREDENTIAL_CONTAINMENT); + await instance.confirmInstance(providerRef); + const physical = await instance.getPhysicalRecord(); + if (!physical.createIntent) throw new Error('Missing fixture create intent'); + await savePhysicalRecord(state.storage, { + ...physical, + createIntent: { + ...physical.createIntent, + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }, + }); +} + +async function attachGrantedSession( + instance: SandboxControl, + state: DurableObjectState, + input: AttachSessionInput, + provider: 'cloudflare' | 'vercel' = 'vercel' +) { + const grants = await loadSessionCredentialGrants(state.storage); + const scopeId = input.worktreeId ?? input.sessionId; + const previous = grants.find(grant => grant.scopeId === scopeId); + const now = Date.now(); + const grant = sessionCredentialGrantSchema.parse({ + version: 1, + scopeId, + sandboxId: instance.sandboxId, + directory: input.directory, + userId, + provider, + ...(provider === 'cloudflare' + ? { outboundContainerId: `contained:${instance.sandboxId}` } + : {}), + members: [ + ...(previous?.members ?? []), + { sessionId: input.sessionId, kiloSessionId: input.kiloSessionId }, + ], + kilo: previous?.kilo ?? { + alias: createControlPlaneCredential(instance.sandboxId, 'kilo'), + token: 'test-session-token', + targets: { + backendBaseUrl: 'https://api.kilo.ai', + providerBaseUrl: 'https://api.kilo.ai/api/gateway', + sessionIngestBaseUrl: 'https://api.kilo.ai/api/session-ingest', + }, + capabilities: {}, + }, + preparedAt: now, + expiresAt: now + 4 * 60 * 60_000, + }); + await saveSessionCredentialGrants(state.storage, [ + ...grants.filter(value => value.scopeId !== scopeId), + grant, + ]); + return instance.attachSession(input); +} + +function registration( + sessionId: `workspace_${string}`, + sandboxId: `usr-${string}` | `ses-${string}` +) { + return { + identity: { sessionId, userId }, + auth: { kiloSessionId: kiloId(0), kilocodeToken: 'test-session-token' }, + agent: { mode: 'code', model: 'test-model' }, + workspace: { + sandboxId, + sandboxProvider: 'cloudflare' as const, + workspacePath: directory, + worktreeId, + }, + }; +} + +function nextFrame(socket: WebSocket): Promise { + return new Promise(resolve => + socket.addEventListener('message', event => resolve(JSON.parse(String(event.data))), { + once: true, + }) + ); +} + +async function connectControl(sandboxId: string, credential: string) { + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + const response = await control.fetch( + new Request('https://worker.test/control', { + headers: { Upgrade: 'websocket', Authorization: `Bearer ${credential}` }, + }) + ); + if (!response.webSocket) throw new Error('Expected wrapper websocket'); + const socket = response.webSocket; + socket.accept(); + const providerInstanceId = (await control.getPhysicalRecord()).providerRef; + const hello = nextFrame(socket); + socket.send( + JSON.stringify({ + type: 'request', + requestId: 'hello', + operation: 'sandbox.hello', + payload: { protocolVersion: 1, providerInstanceId }, + }) + ); + await hello; + const status = await nextFrame(socket); + socket.send( + JSON.stringify({ type: 'response', requestId: status.requestId, ok: true, result: {} }) + ); + return socket; +} + +function reply(socket: WebSocket, frame: RequestFrame, result: unknown, ok = true) { + socket.send( + JSON.stringify({ + type: 'response', + requestId: frame.requestId, + ok, + result, + ...(ok ? {} : { error: { code: 'not_ready', message: 'Retry cleanup', retryable: true } }), + }) + ); +} + +describe('worktree deletion in Durable Objects', () => { + it('retains never-run child lineage for cold deletion when scoped publication fails', async () => { + const sessionId = cloudId(); + const sandboxId = `ses-${crypto.randomUUID().replaceAll('-', '')}` as const; + const stub = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + await runInDurableObject(stub, async (instance, state) => { + const ingest = instance['env'].SESSION_INGEST; + const secret = instance['env'].INTERNAL_API_SECRET_PROD; + Object.assign(instance['env'], { + SESSION_INGEST: { fetch: async () => new Response(null, { status: 503 }) }, + INTERNAL_API_SECRET_PROD: { get: async () => 'test-internal' }, + }); + try { + await instance.registerSession(registration(sessionId, sandboxId)); + for (const [id, parentID, type] of [ + [kiloId(1), kiloId(0), 'session.created'], + [kiloId(2), kiloId(1), 'session.updated'], + ]) { + await instance.receiveSandboxControlEvent({ + identity: { directory, kiloSessionId: id, rootKiloSessionId: kiloId(0) }, + payload: { type, properties: { sessionID: id, info: { id, parentID, directory } } }, + }); + } + await instance.beginWorktreeDeletion({ + worktreeId, + kiloSessionId: kiloId(0), + ownerId: userId, + }); + await expect(instance.getWorktreeChildSessions(worktreeId)).resolves.toEqual([ + { sessionId: kiloId(1), parentSessionId: kiloId(0) }, + { sessionId: kiloId(2), parentSessionId: kiloId(1) }, + ]); + expect((await state.storage.get('session_messages')) ?? []).toEqual([]); + await instance.finishWorktreeDeletion(worktreeId); + await expect(instance.getWorktreeChildSessions(worktreeId)).resolves.toEqual([]); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { + SESSION_INGEST: ingest, + INTERNAL_API_SECRET_PROD: secret, + }); + } + }); + }); + + it('rejects contradictory session identities and foreign directories before retaining child evidence', async () => { + const sessionId = cloudId(); + const sandboxId = `ses-${crypto.randomUUID().replaceAll('-', '')}` as const; + const stub = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + await stub.registerSession(registration(sessionId, sandboxId)); + for (const input of [ + { + identity: { directory, kiloSessionId: kiloId(0), rootKiloSessionId: kiloId(0) }, + payload: { + type: 'session.created', + properties: { + sessionID: kiloId(0), + info: { id: kiloId(1), parentID: kiloId(0), directory }, + }, + }, + }, + { + identity: { directory, kiloSessionId: kiloId(1), rootKiloSessionId: kiloId(0) }, + payload: { + type: 'session.created', + properties: { + sessionID: kiloId(1), + info: { id: kiloId(1), parentID: kiloId(0), directory: otherDirectory }, + }, + }, + }, + ]) { + await expect(stub.receiveSandboxControlEvent(input)).resolves.toEqual({ applied: false }); + } + await stub.beginWorktreeDeletion({ worktreeId, kiloSessionId: kiloId(0), ownerId: userId }); + await expect(stub.getWorktreeChildSessions(worktreeId)).resolves.toEqual([]); + await stub.finishWorktreeDeletion(worktreeId); + }); + + it.each([true, false])( + 'uses persisted legacy routing while public metadata is hidden by deletion (shared: %s)', + async shared => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '').padEnd(48, '0')}`; + const otherSandboxId = `usr-${'d'.repeat(48)}`; + const legacyId = `agent_${crypto.randomUUID()}`; + const legacy = env.CLOUD_AGENT_SESSION.getByName(`${userId}:${legacyId}`); + await runInDurableObject(legacy, async (instance, state) => { + await state.storage.put({ + metadata: { + metadataSchemaVersion: 2, + identity: { sessionId: legacyId, userId }, + auth: { kiloSessionId: kiloId(1) }, + workspace: { sandboxId: shared ? sandboxId : otherSandboxId }, + lifecycle: { timestamp: 1, version: 1 }, + }, + session_deletion_intent: { reason: 'explicit', startedAt: Date.now() }, + }); + await expect(instance.getMetadata()).resolves.toBeNull(); + }); + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async instance => { + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ + kind: 'unresolved', + owners: [ + { + worktreeId: null, + organizationId: null, + allocationLocation: { + sandboxId: shared ? otherSandboxId : sandboxId, + provider: 'cloudflare', + }, + sessions: [{ sessionId: kiloId(1), cloudAgentSessionId: legacyId }], + }, + ], + }), + }, + }); + try { + await expect( + resolveSandboxExclusivity(instance['env'], { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'cloudflare' }, + }) + ).resolves.toBe(!shared); + } finally { + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + } + ); + + it.each(['cloudflare', 'vercel'] as const)( + 'completes an ownership-only %s allocation from fenced empty creation history without provider access', + async providerKind => { + const sessionId = cloudId(); + const session = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + const sandboxId: `ses-${string}` = `ses-${crypto.randomUUID().replaceAll('-', '')}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await expect( + session.beginWorktreeDeletion({ worktreeId, kiloSessionId: kiloId(0), ownerId: userId }) + ).resolves.toBeNull(); + await runInDurableObject(control, async (instance, state) => { + const memory = createDeletionProvider(sandboxId); + const create = vi.fn(memory.create.bind(memory)); + const stop = vi.fn(memory.stop.bind(memory)); + const provider = { ...memory, create, stop }; + Object.assign(instance, { provider, createProviderAdapter: () => provider }); + const original = instance['env'].SESSION_INGEST; + const token = instance['env'].VERCEL_TOKEN; + Object.assign(instance['env'], { + VERCEL_TOKEN: undefined, + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ kind: 'exclusive' }), + }, + }); + try { + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: providerKind }, + sessionIds: [kiloId(0)], + }; + await expect(instance.deleteWorktreeResources(input)).resolves.toEqual({ + deleted: true, + sessionIds: [kiloId(0)], + }); + expect(await state.storage.get('owner_id')).toBeUndefined(); + await expect(instance.deleteWorktreeResources(input)).resolves.toEqual({ + deleted: true, + sessionIds: [kiloId(0)], + }); + expect(create).not.toHaveBeenCalled(); + expect(stop).not.toHaveBeenCalled(); + expect(await state.storage.get('physical_record')).toBeUndefined(); + expect(await state.storage.get('owner_id')).toBe(userId); + expect(await state.storage.getAlarm()).toBeNull(); + await expect( + instance.ensureReady({ ownerId: userId, sessionId, worktreeId, allowCreate: true }) + ).rejects.toThrow('worktree_deleting'); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { SESSION_INGEST: original, VERCEL_TOKEN: token }); + } + }); + await session.finishWorktreeDeletion(worktreeId); + await expect( + session.beginWorktreeDeletion({ worktreeId, kiloSessionId: kiloId(0), ownerId: userId }) + ).resolves.toBeNull(); + await expect( + session.registerSession(registration(sessionId, sandboxId)) + ).resolves.toMatchObject({ success: false }); + } + ); + + it('does not block a never-started allocation on unrelated unavailable legacy history or touch its provider', async () => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '').padEnd(48, '0')}`; + const legacyId = `agent_${crypto.randomUUID()}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + expect(await instance.getPhysicalRecord()).toEqual({ + state: 'stopped', + providerRef: null, + createIntent: null, + stopTombstone: null, + resumable: false, + }); + expect(await instance.listRoutes()).toEqual([]); + expect(state.getWebSockets()).toEqual([]); + let legacyRunning = true; + const provider: ProviderAdapter = { + resumable: false, + ensureBillingAdmission: async () => undefined, + launch: async () => undefined, + create: async () => ({ providerRef: sandboxId }), + observe: async () => ({ status: legacyRunning ? 'active' : 'terminal' }), + stop: async () => { + legacyRunning = false; + return 'terminal'; + }, + ensureLeaseAtLeast: async () => undefined, + logs: async () => '', + }; + Object.assign(instance, { provider, createProviderAdapter: () => provider }); + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ + kind: 'unresolved', + owners: [ + { + worktreeId: null, + organizationId: null, + sessions: [{ sessionId: kiloId(1), cloudAgentSessionId: legacyId }], + }, + ], + }), + }, + }); + try { + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'cloudflare' as const }, + sessionIds: [kiloId(0)], + }; + await expect(instance.deleteWorktreeResources(input)).resolves.toEqual({ + deleted: true, + sessionIds: [kiloId(0)], + }); + await expect(instance.deleteWorktreeResources(input)).resolves.toMatchObject({ + deleted: true, + }); + expect(legacyRunning).toBe(true); + expect(await state.storage.get('physical_record')).toBeUndefined(); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + }); + + it('destroys the last physical runtime when concurrent worktree deletions both retain pending PostgreSQL rows', async () => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '')}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + const provider = createDeletionProvider(sandboxId); + const created = await provider.create({ + intentId: 'last-two-worktrees', + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }); + if (!('providerRef' in created)) throw new Error('Expected memory instance'); + const cleaned = new Set(); + const pending = [worktreeId, otherWorktreeId]; + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async (input: { + worktreeId: string; + releasedWorktreeIds?: string[]; + }) => ({ + kind: pending.some( + id => id !== input.worktreeId && !input.releasedWorktreeIds?.includes(id) + ) + ? 'shared' + : 'exclusive', + }), + }, + }); + installDeletionProvider(instance, provider); + Object.assign(instance, { + socketHandler: { + hasHandshakenSocket: () => true, + closeAll: () => undefined, + sendRequest: async (request: { + operation: string; + payload: { worktreeId: string; sessionIds: string[] }; + }) => { + if (request.operation === 'worktree.delete') cleaned.add(request.payload.worktreeId); + return { + type: 'response', + requestId: 'cleanup', + ok: true, + result: + request.operation === 'worktree.prepareDeletion' + ? { prepared: true, sessionIds: request.payload.sessionIds } + : { deleted: true, sessionIds: request.payload.sessionIds }, + }; + }, + }, + }); + try { + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'last-two-worktrees', created.providerRef); + await attachGrantedSession(instance, state, { + sessionId: cloudId(), + kiloSessionId: kiloId(0), + directory, + ownerId: userId, + worktreeId, + }); + await attachGrantedSession(instance, state, { + sessionId: cloudId(), + kiloSessionId: kiloId(1), + directory: otherDirectory, + ownerId: userId, + worktreeId: otherWorktreeId, + }); + const grants = await loadSessionCredentialGrants(state.storage); + const location = { sandboxId, provider: 'vercel' as const }; + await Promise.all([ + instance.deleteWorktreeResources({ + worktreeId, + kiloUserId: userId, + location, + sessionIds: [kiloId(0)], + }), + instance.deleteWorktreeResources({ + worktreeId: otherWorktreeId, + kiloUserId: userId, + location, + sessionIds: [kiloId(1)], + }), + ]); + expect(await provider.observe(created.providerRef)).toMatchObject({ status: 'terminal' }); + expect(cleaned.has(worktreeId)).toBe(true); + expect(provider.updateNetworkPolicy).toHaveBeenCalledWith( + created.providerRef, + expect.any(Object) + ); + const revokedPolicy = JSON.stringify(provider.updateNetworkPolicy.mock.calls[0]?.[1]); + expect(revokedPolicy).not.toContain(grants[0].kilo.alias); + expect(revokedPolicy).toContain(grants[1].kilo.alias); + expect(await loadSessionCredentialGrants(state.storage)).toEqual([]); + expect(await state.storage.get('physical_record')).toBeUndefined(); + expect(await loadWorktreeDeletionJournal(state.storage, worktreeId)).toMatchObject({ + destroyed: true, + completed: true, + }); + expect(await loadWorktreeDeletionJournal(state.storage, otherWorktreeId)).toMatchObject({ + destroyed: true, + completed: true, + }); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + }); + + it('resolves an unrelated legacy runtime from original metadata before destroying the exclusive target', async () => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '').padEnd(48, '0')}`; + const otherSandboxId = `usr-${'c'.repeat(48)}`; + const legacyId = 'agent_44444444-4444-4444-8444-444444444444'; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + const providerRef = encodeCloudflareProviderRef({ + sandboxId, + instanceId: 'unrelated-legacy', + containment: true, + }); + const running = new Set([providerRef, otherSandboxId]); + const provider: ProviderAdapter = { + resumable: false, + ensureBillingAdmission: async () => undefined, + launch: async () => undefined, + create: async () => ({ providerRef: sandboxId }), + stop: async ref => { + if (ref) running.delete(ref); + return 'terminal'; + }, + observe: async ref => ({ status: ref && running.has(ref) ? 'active' : 'terminal' }), + ensureLeaseAtLeast: async () => undefined, + logs: async () => '', + }; + const originalIngest = instance['env'].SESSION_INGEST; + const originalLegacy = instance['env'].CLOUD_AGENT_SESSION; + Object.assign(instance, { provider, createProviderAdapter: () => provider }); + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ + kind: 'unresolved', + owners: [ + { + worktreeId: null, + organizationId: null, + sessions: [{ sessionId: kiloId(1), cloudAgentSessionId: legacyId }], + }, + ], + }), + }, + CLOUD_AGENT_SESSION: { + idFromName: (name: string) => name, + get: () => ({ + getRuntimeLocation: async () => ({ + cloudAgentSessionId: legacyId, + kiloUserId: userId, + organizationId: null, + sessionId: kiloId(1), + worktreeId: null, + location: { sandboxId: otherSandboxId, provider: 'cloudflare' }, + }), + }), + }, + }); + try { + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'unrelated-legacy', providerRef); + await instance.deleteWorktreeResources({ + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'cloudflare' }, + sessionIds: [kiloId(0)], + }); + expect([...running]).toEqual([otherSandboxId]); + expect(await state.storage.get('physical_record')).toBeUndefined(); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { + SESSION_INGEST: originalIngest, + CLOUD_AGENT_SESSION: originalLegacy, + }); + } + }); + }); + + it('rechecks ownership after scoped cleanup and retains its receipt until exclusive teardown is confirmed', async () => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '')}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + const memory = createDeletionProvider(sandboxId); + const created = await memory.create({ + intentId: 'scoped-then-exclusive', + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }); + if (!('providerRef' in created)) throw new Error('Expected instance'); + let shared = true; + let mayStop = false; + let scopedCleanups = 0; + const provider: ProviderAdapter = { + ...memory, + stop: ref => (mayStop ? memory.stop(ref) : Promise.resolve('retryable')), + }; + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ + kind: shared ? 'shared' : 'exclusive', + }), + }, + }); + installDeletionProvider(instance, provider); + Object.assign(instance, { + socketHandler: { + hasHandshakenSocket: () => true, + closeAll: () => undefined, + sendRequest: async (request: { + operation: string; + payload: { sessionIds: string[] }; + }) => { + if (request.operation === 'worktree.delete') { + shared = false; + scopedCleanups++; + } + return { + type: 'response', + requestId: 'cleanup', + ok: true, + result: + request.operation === 'worktree.prepareDeletion' + ? { prepared: true, sessionIds: request.payload.sessionIds } + : { deleted: true, sessionIds: request.payload.sessionIds }, + }; + }, + }, + }); + try { + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'scoped-then-exclusive', created.providerRef); + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'vercel' as const }, + sessionIds: [kiloId(0)], + }; + await expect(instance.deleteWorktreeResources(input)).rejects.toThrow('unconfirmed'); + expect(await loadWorktreeDeletionJournal(state.storage, worktreeId)).toMatchObject({ + resourcesCleaned: true, + destroyed: false, + completed: false, + }); + expect((await instance.getPhysicalRecord()).providerRef).toBe(created.providerRef); + shared = true; + await expect( + instance.deleteWorktreeResources({ + ...input, + worktreeId: otherWorktreeId, + sessionIds: [kiloId(1)], + }) + ).rejects.toThrow('worktree_teardown_in_progress'); + expect(await state.storage.get('exclusive_worktree_deletion')).toBe(worktreeId); + await instance.alarm(); + expect((await instance.getPhysicalRecord()).providerRef).toBe(created.providerRef); + mayStop = true; + await instance.deleteWorktreeResources(input); + expect(await memory.observe(created.providerRef)).toMatchObject({ status: 'terminal' }); + expect(scopedCleanups).toBe(1); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + }); + + it('retains the exhausted stop budget and cleanup alarm under the exclusive fence until observation confirms death', async () => { + const sandboxId = `ses-${crypto.randomUUID().replaceAll('-', '')}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + const native = createDeletionProvider(sandboxId); + const created = await native.create({ + intentId: 'exhausted-deletion', + createdAt: Date.now(), + }); + if (!('providerRef' in created)) throw new Error('Missing native fixture allocation'); + const stop = vi.fn(async () => 'retryable'); + const observe = vi.fn(native.observe.bind(native)); + installDeletionProvider(instance, { ...native, stop, observe }); + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ kind: 'exclusive' }), + }, + }); + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'vercel' as const }, + sessionIds: [kiloId(0)], + }; + try { + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'exhausted-deletion', created.providerRef); + const receipts = [ + { + id: crypto.randomUUID(), + deadlineAt: Date.now() + 60_000, + allocation: { kind: 'intent', id: 'exhausted-deletion' }, + }, + ]; + await state.storage.put('acquisition_receipts', receipts); + for (let attempt = 1; attempt <= DEADLINE_MS.stopAttemptLadder.length; attempt++) { + await expect(instance.deleteWorktreeResources(input)).rejects.toThrow('unconfirmed'); + expect((await instance.getPhysicalRecord()).stopTombstone?.attempts).toBe(attempt); + } + const exhausted = await instance.getPhysicalRecord(); + expect(exhausted.state).toBe('unknown'); + expect(stop).toHaveBeenCalledTimes(DEADLINE_MS.stopAttemptLadder.length); + expect(await state.storage.get('exclusive_worktree_deletion')).toBe(worktreeId); + const deadlines = await loadDeadlines(state.storage); + expect(deadlines.reconciliation).toEqual(expect.any(Number)); + expect(await state.storage.getAlarm()).toBe(deadlines.reconciliation); + await saveDeadlines(state.storage, { ...deadlines, reconciliation: Date.now() }); + await instance.alarm(); + expect(observe).toHaveBeenCalledWith(created.providerRef, exhausted.createIntent); + expect(await instance.getPhysicalRecord()).toEqual(exhausted); + await expect(instance.deleteWorktreeResources(input)).rejects.toThrow('unconfirmed'); + expect(stop).toHaveBeenCalledTimes(DEADLINE_MS.stopAttemptLadder.length); + expect((await instance.getPhysicalRecord()).stopTombstone).toEqual(exhausted.stopTombstone); + expect(await state.storage.getAlarm()).not.toBeNull(); + await native.stop(created.providerRef); + await expect(instance.deleteWorktreeResources(input)).resolves.toEqual({ + deleted: true, + sessionIds: [kiloId(0)], + }); + expect(stop).toHaveBeenCalledTimes(DEADLINE_MS.stopAttemptLadder.length); + expect(await state.storage.get('physical_record')).toBeUndefined(); + expect(await state.storage.get('provider_locator')).toBeUndefined(); + expect(await state.storage.get('acquisition_receipts')).toEqual(receipts); + expect(await loadWorktreeDeletionJournal(state.storage, worktreeId)).toMatchObject({ + completed: true, + destroyed: true, + }); + expect(await state.storage.getAlarm()).toBeNull(); + } finally { + await state.storage.deleteAlarm(); + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + }); + + it('closes streams, terminalizes messages, removes transcript state and alarms, and rejects late writes and registration', async () => { + const sessionId = cloudId(); + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '').padEnd(48, '0')}` as const; + const stub = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + await stub.registerSession(registration(sessionId, sandboxId)); + await runInDurableObject(stub, async (_instance, state) => { + await state.storage.put('session_messages', [{ messageId: 'msg_active', state: 'accepted' }]); + await state.storage.setAlarm(Date.now() + 60_000); + drizzle(state.storage) + .insert(events) + .values({ + execution_id: '', + session_id: sessionId, + stream_event_type: 'kilocode', + payload: 'private transcript', + timestamp: Date.now(), + }) + .run(); + }); + const response = await stub.fetch( + new Request('https://worker.test/stream?replay=false', { headers: { Upgrade: 'websocket' } }) + ); + if (!response.webSocket) throw new Error('Expected session stream'); + response.webSocket.accept(); + const closed = new Promise(resolve => + response.webSocket?.addEventListener('close', event => resolve(event.code), { once: true }) + ); + await stub.beginWorktreeDeletion({ worktreeId, kiloSessionId: kiloId(0), ownerId: userId }); + await expect(stub.getRuntimeLocation()).resolves.toMatchObject({ + kiloUserId: userId, + worktreeId, + location: { sandboxId, provider: 'cloudflare' }, + }); + await expect(closed).resolves.toBe(1001); + await runInDurableObject(stub, async (_instance, state) => { + expect(await state.storage.get('session_messages')).toMatchObject([{ state: 'cancelled' }]); + expect(await state.storage.getAlarm()).toBeNull(); + }); + await stub.finishWorktreeDeletion(worktreeId); + await expect(stub.getRuntimeLocation()).resolves.toBeNull(); + await expect(stub.getMetadata()).resolves.toBeNull(); + await expect(stub.registerSession(registration(sessionId, sandboxId))).resolves.toMatchObject({ + success: false, + }); + await expect( + stub.receiveSandboxControlEvent({ + identity: { directory, kiloSessionId: kiloId(0) }, + payload: { type: 'session.updated', properties: { title: 'late private data' } }, + }) + ).resolves.toEqual({ applied: false }); + await runInDurableObject(stub, async (instance, state) => { + await instance.alarm(); + expect(drizzle(state.storage).select().from(events).all()).toEqual([]); + expect([...(await state.storage.list()).keys()].sort()).toEqual([ + 'deleted_worktree', + 'deletion_completed', + 'session_lifecycle_fence', + ]); + expect(await state.storage.get('session_lifecycle_fence')).toMatchObject({ + state: 'deleted', + }); + expect(await state.storage.getAlarm()).toBeNull(); + }); + }); + + it('cleans a recovered ownership-only allocation without registering a session or starting a runtime', async () => { + const sessionId = cloudId(); + const session = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + const sandboxId = `ses-${crypto.randomUUID().replaceAll('-', '')}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await expect( + session.beginWorktreeDeletion({ worktreeId, kiloSessionId: kiloId(0), ownerId: userId }) + ).resolves.toBeNull(); + await runInDurableObject(control, async (instance, state) => { + const provider = createDeletionProvider(sandboxId); + const create = vi.fn(provider.create.bind(provider)); + installDeletionProvider(instance, { ...provider, create }); + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ kind: 'exclusive' }), + }, + }); + try { + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'vercel' as const }, + sessionIds: [kiloId(0)], + }; + await expect(instance.deleteWorktreeResources(input)).resolves.toMatchObject({ + deleted: true, + }); + await expect(instance.deleteWorktreeResources(input)).resolves.toMatchObject({ + deleted: true, + }); + expect(create).not.toHaveBeenCalled(); + expect(await state.storage.get('physical_record')).toBeUndefined(); + expect(await state.storage.getAlarm()).toBeNull(); + } finally { + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + await session.finishWorktreeDeletion(worktreeId); + await expect( + session.beginWorktreeDeletion({ worktreeId, kiloSessionId: kiloId(0), ownerId: userId }) + ).resolves.toBeNull(); + await expect(session.getMetadata()).resolves.toBeNull(); + }); + + it('does not reattach after deletion overtakes an in-flight ensureReady', async () => { + const sessionId = cloudId(); + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '').padEnd(48, '0')}` as const; + const stub = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + await runInDurableObject(stub, async (instance, state) => { + const started = Promise.withResolvers(); + const ready = Promise.withResolvers<{ physical: 'running'; connection: 'ready' }>(); + const attach = vi.fn(); + const request = vi.fn(); + const original = instance['env'].SANDBOX_CONTROL; + const validation = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => Response.json({ valid: true })); + const deletionInput = { + worktreeId, + kiloSessionId: kiloId(0), + ownerId: userId, + }; + Object.assign(instance['env'], { + SANDBOX_CONTROL: { + getByName: () => ({ + getStatus: async () => ({ physical: 'stopped', connection: 'disconnected' }), + ensureReady: () => { + started.resolve(); + return ready.promise; + }, + attachSession: attach, + request, + }), + }, + }); + try { + await instance.registerSession(registration(sessionId, sandboxId)); + await expect( + instance.admitSubmittedMessage({ userId, turn: { type: 'prompt', prompt: 'start' } }) + ).resolves.toMatchObject({ success: true }); + await started.promise; + await instance.beginWorktreeDeletion(deletionInput); + ready.resolve({ physical: 'running', connection: 'ready' }); + await instance.finishWorktreeDeletion(worktreeId); + expect(attach).not.toHaveBeenCalled(); + expect(request).not.toHaveBeenCalled(); + expect(await state.storage.get('session_messages')).toBeUndefined(); + expect(await state.storage.getAlarm()).toBeNull(); + } finally { + try { + await instance.beginWorktreeDeletion(deletionInput); + } finally { + ready.resolve({ physical: 'running', connection: 'ready' }); + Object.assign(instance['env'], { SANDBOX_CONTROL: original }); + validation.mockRestore(); + } + await instance.finishWorktreeDeletion(worktreeId); + } + }); + }); + + it('journals discovered descendants before cleanup and preserves unrelated shared routes on partial failure and retry', async () => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '')}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + const credential = generateSandboxCredential(); + const root = cloudId(); + const other = cloudId(); + await control.initializeOwner(userId); + const providerRef = encodeCloudflareProviderRef({ + sandboxId, + instanceId: 'shared-test', + containment: true, + }); + await runInDurableObject(control, async (instance, state) => { + const provider: ProviderAdapter = { + resumable: false, + ensureBillingAdmission: async () => undefined, + create: async () => { + throw new Error('Shared cleanup must not allocate'); + }, + launch: async () => { + throw new Error('Shared cleanup must not launch'); + }, + observe: async () => ({ status: 'active', providerRef }), + stop: async () => { + throw new Error('Shared cleanup must not stop siblings'); + }, + ensureLeaseAtLeast: async () => undefined, + logs: async () => '', + }; + Object.assign(instance, { provider, createProviderAdapter: () => provider }); + await seedPhysical(instance, state, 'shared-test', providerRef); + await attachGrantedSession( + instance, + state, + { + sessionId: root, + kiloSessionId: kiloId(0), + directory, + ownerId: userId, + worktreeId, + }, + 'cloudflare' + ); + await attachGrantedSession( + instance, + state, + { + sessionId: other, + kiloSessionId: kiloId(99), + directory: otherDirectory, + ownerId: userId, + worktreeId: otherWorktreeId, + }, + 'cloudflare' + ); + }); + await control.setWrapperCredentialHash(await hashSandboxCredential(credential)); + const socket = await connectControl(sandboxId, credential); + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'cloudflare' as const }, + sessionIds: [kiloId(0), kiloId(1)], + }; + const preparedFrame = nextFrame(socket); + const first = control.deleteWorktreeResources(input).then( + () => undefined, + error => error + ); + const prepare = await preparedFrame; + expect(prepare.operation).toBe('worktree.prepareDeletion'); + await runInDurableObject(control, async (_instance, state) => { + const grants = await loadSessionCredentialGrants(state.storage); + expect(grants.map(grant => grant.scopeId)).toEqual([otherWorktreeId]); + expect(grants[0]?.members).toEqual([{ sessionId: other, kiloSessionId: kiloId(99) }]); + }); + const deletionFrame = nextFrame(socket); + reply(socket, prepare, { prepared: true, sessionIds: [...input.sessionIds, kiloId(2)] }); + const deletion = await deletionFrame; + expect(deletion.operation).toBe('worktree.delete'); + await runInDurableObject(control, async (_instance, state) => { + expect((await loadWorktreeDeletionJournal(state.storage, worktreeId))?.sessionIds).toContain( + kiloId(2) + ); + }); + reply(socket, deletion, undefined, false); + expect(await first).toBeInstanceOf(Error); + expect(await control.listRoutes()).toHaveLength(2); + const lateAttach = await runInDurableObject(control, (instance, state) => + attachGrantedSession( + instance, + state, + { + sessionId: cloudId(), + kiloSessionId: kiloId(3), + directory, + ownerId: userId, + worktreeId, + }, + 'cloudflare' + ) + ).then( + () => null, + error => error + ); + expect(String(lateAttach)).toContain('worktree_deleting'); + await runInDurableObject(control, (instance, state) => + attachGrantedSession( + instance, + state, + { + sessionId: cloudId(), + kiloSessionId: kiloId(100), + directory: otherDirectory, + ownerId: userId, + worktreeId: otherWorktreeId, + }, + 'cloudflare' + ) + ); + const retryFrame = nextFrame(socket); + const retry = control.deleteWorktreeResources(input); + const prepareAgain = await retryFrame; + const finalFrame = nextFrame(socket); + reply(socket, prepareAgain, { prepared: true, sessionIds: [...input.sessionIds, kiloId(2)] }); + const final = await finalFrame; + reply(socket, final, { deleted: true, sessionIds: [...input.sessionIds, kiloId(2)] }); + await expect(retry).resolves.toEqual({ + deleted: true, + sessionIds: [...input.sessionIds, kiloId(2)], + }); + expect((await control.listRoutes()).map(route => route.worktreeId)).toEqual([ + otherWorktreeId, + otherWorktreeId, + ]); + expect(await control.getOwner()).toBe(userId); + expect((await control.getPhysicalRecord()).providerRef).toBe(providerRef); + socket.close(); + }); + + it.each(['ses', 'usr'])( + 'keeps %s provider locators until confirmed teardown and preserves a later unrelated runtime on retry', + async prefix => { + const sessionId = cloudId(); + const sandboxId = `${prefix}-${crypto.randomUUID().replaceAll('-', '')}` as + | `ses-${string}` + | `usr-${string}`; + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + const memory = createDeletionProvider(sandboxId); + const created = await memory.create({ + intentId: 'exclusive', + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }); + if (!('providerRef' in created)) throw new Error('Expected memory provider instance'); + let mayStop = false; + const provider: ProviderAdapter = { + ...memory, + stop: ref => (mayStop ? memory.stop(ref) : Promise.resolve('retryable')), + }; + installDeletionProvider(instance, provider); + const original = instance['env'].SESSION_INGEST; + Object.assign(instance['env'], { + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ kind: 'exclusive' }), + }, + }); + try { + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'exclusive', created.providerRef); + const locator = await state.storage.get('provider_locator'); + expect(locator).toMatchObject({ + teamId: vercelConfig.VERCEL_TEAM_ID, + projectId: vercelConfig.VERCEL_PROJECT_ID, + snapshotId: vercelConfig.VERCEL_SANDBOX_SNAPSHOT_ID, + runtimeBuildId: vercelConfig.VERCEL_SANDBOX_RUNTIME_BUILD_ID, + runtime: 'node24', + }); + await instance.setWrapperCredentialHash( + await hashSandboxCredential(generateSandboxCredential()) + ); + const pair = new WebSocketPair(); + state.acceptWebSocket(pair[1], ['sandbox-control']); + pair[0].accept(); + pair[1].serializeAttachment({ + handshakeComplete: true, + acceptedAt: Date.now(), + protocolVersion: 1, + providerInstanceId: created.providerRef, + }); + const closed = new Promise(resolve => + pair[0].addEventListener('close', () => resolve(), { once: true }) + ); + const input = { + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'vercel' as const }, + sessionIds: [kiloId(0)], + }; + await expect(instance.deleteWorktreeResources(input)).rejects.toThrow('unconfirmed'); + expect((await instance.getPhysicalRecord()).providerRef).toBe(created.providerRef); + expect(await state.storage.get('provider_locator')).toEqual(locator); + expect(await state.storage.get('exclusive_worktree_deletion')).toBe(worktreeId); + await expect( + instance.ensureReady({ ownerId: userId, sessionId, allowCreate: true }) + ).rejects.toThrow('worktree_deleting'); + mayStop = true; + await expect(instance.deleteWorktreeResources(input)).resolves.toMatchObject({ + deleted: true, + }); + await closed; + expect(await memory.observe(created.providerRef)).toMatchObject({ status: 'terminal' }); + expect(await state.storage.get('wrapper_credential_hash')).toBeUndefined(); + expect(await state.storage.get('owner_id')).toBeUndefined(); + expect(await state.storage.get('provider_locator')).toBeUndefined(); + expect(await state.storage.get('physical_record')).toBeUndefined(); + expect(await state.storage.getAlarm()).toBeNull(); + expect(await loadWorktreeDeletionJournal(state.storage, worktreeId)).toMatchObject({ + resourcesCleaned: true, + destroyed: true, + completed: true, + }); + const workerUrl = instance['env'].WORKER_URL; + Object.assign(instance['env'], { WORKER_URL: 'https://worker.test' }); + try { + if (prefix === 'ses') { + await env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`).registerSession({ + ...registration(sessionId, sandboxId), + workspace: { + sandboxId, + sandboxProvider: 'vercel', + workspacePath: otherDirectory, + worktreeId: otherWorktreeId, + }, + }); + await instance.ensureReady({ + ownerId: userId, + sessionId, + provider: 'vercel', + allowCreate: true, + worktreeId: otherWorktreeId, + }); + } else { + const replacement = await provider.create({ + intentId: 'legacy-replacement', + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }); + if (!('providerRef' in replacement)) + throw new Error('Expected legacy fixture runtime'); + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'legacy-replacement', replacement.providerRef); + await instance.setWrapperCredentialHash( + await hashSandboxCredential(generateSandboxCredential()) + ); + } + const replacement = await instance.getPhysicalRecord(); + const credential = await state.storage.get('wrapper_credential_hash'); + expect(await memory.observe(replacement.providerRef)).toMatchObject({ + status: 'active', + }); + await instance.deleteWorktreeResources(input); + expect((await instance.getPhysicalRecord()).providerRef).toBe(replacement.providerRef); + expect(await state.storage.get('wrapper_credential_hash')).toBe(credential); + expect(await instance.getOwner()).toBe(userId); + await state.storage.deleteAlarm(); + } finally { + Object.assign(instance['env'], { WORKER_URL: workerUrl }); + } + } finally { + Object.assign(instance['env'], { SESSION_INGEST: original }); + } + }); + } + ); + + it('fences admission and waits for a late provider create before destroying its exact returned instance', async () => { + const sandboxId = `ses-${crypto.randomUUID().replaceAll('-', '')}` as const; + const sessionId = cloudId(); + await env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`).registerSession({ + ...registration(sessionId, sandboxId), + workspace: { ...registration(sessionId, sandboxId).workspace, sandboxProvider: 'vercel' }, + }); + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + await runInDurableObject(control, async (instance, state) => { + const memory = createDeletionProvider(sandboxId); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + let stoppedRef: string | null = null; + const provider: ProviderAdapter = { + ...memory, + create: async intent => { + started.resolve(); + await release.promise; + return memory.create(intent); + }, + stop: async ref => { + stoppedRef = ref; + return memory.stop(ref); + }, + }; + installDeletionProvider(instance, provider); + const original = instance['env'].SESSION_INGEST; + const workerUrl = instance['env'].WORKER_URL; + Object.assign(instance['env'], { + WORKER_URL: 'https://worker.test', + SESSION_INGEST: { + canDestroyCloudAgentWorktreeSandbox: async () => ({ kind: 'exclusive' }), + }, + }); + try { + const ready = instance.ensureReady({ + ownerId: userId, + sessionId, + provider: 'vercel', + allowCreate: true, + worktreeId, + }); + await started.promise; + let complete = false; + const deletion = instance + .deleteWorktreeResources({ + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'vercel' }, + sessionIds: [kiloId(0)], + }) + .then(result => { + complete = true; + return result; + }); + await state.storage.sync(); + await Promise.resolve(); + expect(complete).toBe(false); + expect(stoppedRef).toBeNull(); + const clock = vi + .spyOn(Date, 'now') + .mockReturnValue(Date.now() + DEADLINE_MS.createSettle + 1); + try { + release.resolve(); + await ready; + await deletion; + } finally { + clock.mockRestore(); + } + expect(stoppedRef).toContain('vsess_'); + expect(await memory.observe(stoppedRef)).toMatchObject({ status: 'terminal' }); + expect(await state.storage.getAlarm()).toBeNull(); + expect(await state.storage.get('physical_record')).toBeUndefined(); + } finally { + Object.assign(instance['env'], { SESSION_INGEST: original, WORKER_URL: workerUrl }); + } + }); + }); +}); diff --git a/services/cloud-agent-next/test/unit/fake-llm-server.test.ts b/services/cloud-agent-next/test/unit/fake-llm-server.test.ts index 6f99e5502a..d83aab6a80 100644 --- a/services/cloud-agent-next/test/unit/fake-llm-server.test.ts +++ b/services/cloud-agent-next/test/unit/fake-llm-server.test.ts @@ -7,7 +7,10 @@ * files are driver-invoked, not picked up by `pnpm run test`. */ -import { afterEach, describe, expect, it } from 'vitest'; +import path from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { streamEventSchema } from '../e2e/client.js'; import { extractLastUserMessageText, parseDirective, @@ -15,6 +18,11 @@ import { stripKiloPromptWrapping, type FakeLlmServerHandle, } from '../e2e/fake-llm-server.js'; +import { + findControlPlaneKiloRuntime, + stopOwnedControlPlaneSandbox, + type DockerCommandExecutor, +} from '../e2e/sandbox-control.js'; // --------------------------------------------------------------------------- // Pure-helper tests @@ -183,6 +191,108 @@ async function postChat(url: string, prompt: string): Promise { }); } +const advertisedFileTools = [ + { + type: 'function', + function: { + name: 'write', + parameters: { + type: 'object', + properties: { filePath: { type: 'string' }, content: { type: 'string' } }, + required: ['filePath', 'content'], + }, + }, + }, + { + type: 'function', + function: { + name: 'read', + parameters: { + type: 'object', + properties: { filePath: { type: 'string' } }, + required: ['filePath'], + }, + }, + }, + { + type: 'function', + function: { + name: 'edit', + parameters: { + type: 'object', + properties: { + filePath: { type: 'string' }, + oldString: { type: 'string' }, + newString: { type: 'string' }, + }, + required: ['filePath', 'oldString', 'newString'], + }, + }, + }, + { + type: 'function', + function: { + name: 'question', + parameters: { + type: 'object', + properties: { questions: { type: 'array' } }, + required: ['questions'], + }, + }, + }, +]; + +async function postToolChat( + url: string, + prompt: string, + history: Array> = [], + tools: unknown[] = advertisedFileTools +): Promise { + return fetch(`${url}/api/openrouter/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'kilo/fake-deterministic', + messages: [{ role: 'user', content: prompt }, ...history], + tools, + stream: true, + }), + }); +} + +async function parseSse(response: Response): Promise>> { + if (!response.body) throw new Error('Expected a streamed response body'); + const chunks = await readAllSse(response.body); + expect(chunks.at(-1)?.data).toBe('[DONE]'); + return chunks.slice(0, -1).map(chunk => JSON.parse(chunk.data)); +} + +function extractToolCall(chunks: Array>): { + id: string; + name: string; + arguments: Record; +} { + const firstChoice = chunks[0]?.choices; + const secondChoice = chunks[1]?.choices; + if (!Array.isArray(firstChoice) || !Array.isArray(secondChoice)) { + throw new Error('Expected streamed tool-call choices'); + } + const opening = firstChoice[0]?.delta?.tool_calls?.[0]; + const continuation = secondChoice[0]?.delta?.tool_calls?.[0]; + if ( + typeof opening?.id !== 'string' || + typeof opening?.function?.name !== 'string' || + typeof continuation?.function?.arguments !== 'string' + ) { + throw new Error('Expected streamed tool-call identity and arguments'); + } + return { + id: opening.id, + name: opening.function.name, + arguments: JSON.parse(continuation.function.arguments), + }; +} + async function postModelValidation( url: string, modelId: string, @@ -403,6 +513,260 @@ describe('fake-llm-server HTTP', () => { expect(contentPieces.join('')).toBe('done'); }); + it('gate emits an attributable completion marker without changing legacy defaults', async () => { + const h = await start(); + const response = await postChat( + h.url, + '__fake__:gate:attributed:root_a_completeprivate context' + ); + expect(response.status).toBe(200); + const release = await fetch(`${h.url}/test/release?tag=attributed`, { method: 'POST' }); + expect(release.status).toBe(204); + const chunks = await parseSse(response); + expect(chunks[0]?.choices).toEqual([ + expect.objectContaining({ + delta: { role: 'assistant', content: 'root_a_complete' }, + }), + ]); + }); + + it('keeps distinct completion markers isolated while both gates are engaged', async () => { + const h = await start(); + const [rootA, rootB] = await Promise.all([ + postChat(h.url, '__fake__:gate:root_a:only_root_a'), + postChat(h.url, '__fake__:gate:root_b:only_root_b'), + ]); + const [statusA, statusB] = await Promise.all([ + fetch(`${h.url}/test/gate-status?tag=root_a`), + fetch(`${h.url}/test/gate-status?tag=root_b`), + ]); + await expect(statusA.json()).resolves.toMatchObject({ engaged: true }); + await expect(statusB.json()).resolves.toMatchObject({ engaged: true }); + + expect((await fetch(`${h.url}/test/release?tag=root_b`, { method: 'POST' })).status).toBe(204); + const completedB = await parseSse(rootB); + expect(completedB[0]?.choices).toEqual([ + expect.objectContaining({ delta: { role: 'assistant', content: 'only_root_b' } }), + ]); + const stillGatedA = await fetch(`${h.url}/test/gate-status?tag=root_a`); + await expect(stillGatedA.json()).resolves.toMatchObject({ engaged: true }); + + expect((await fetch(`${h.url}/test/release?tag=root_a`, { method: 'POST' })).status).toBe(204); + const completedA = await parseSse(rootA); + expect(completedA[0]?.choices).toEqual([ + expect.objectContaining({ delta: { role: 'assistant', content: 'only_root_a' } }), + ]); + }); + + it('streams a real write call and gates only after its tool result', async () => { + const h = await start(); + const prompt = '__fake__:write-then-gate:writer:shared.txt:private-content'; + const initial = await parseSse(await postToolChat(h.url, prompt)); + const call = extractToolCall(initial); + expect(call).toMatchObject({ + name: 'write', + arguments: { filePath: 'shared.txt', content: 'private-content' }, + }); + const finishChoices = initial.at(-1)?.choices; + expect(finishChoices).toEqual([expect.objectContaining({ finish_reason: 'tool_calls' })]); + + const followup = await postToolChat(h.url, prompt, [ + { role: 'assistant', tool_calls: [{ id: call.id }] }, + { role: 'tool', tool_call_id: call.id, content: 'File written successfully' }, + ]); + const statusResponse = await fetch(`${h.url}/test/scenario-status?tag=writer`); + const status = await statusResponse.json(); + expect(status).toEqual({ + tag: 'writer', + requests: 2, + toolCalls: { write: 1, read: 0, edit: 0, question: 0 }, + toolResults: { write: 1, read: 0, edit: 0, question: 0 }, + unsupportedToolSchema: false, + }); + expect(JSON.stringify(status)).not.toContain('private-content'); + + const gate = await fetch(`${h.url}/test/gate-status?tag=writer`); + await expect(gate.json()).resolves.toEqual({ tag: 'writer', engaged: true }); + expect((await fetch(`${h.url}/test/release?tag=writer`, { method: 'POST' })).status).toBe(204); + const completed = await parseSse(followup); + expect(completed[0]?.choices).toEqual([ + expect.objectContaining({ delta: { role: 'assistant', content: 'done-writer' } }), + ]); + }); + + it('derives alternate write arguments from the advertised tool schema', async () => { + const h = await start(); + const response = await postToolChat( + h.url, + '__fake__:write-then-gate:alternate:shared.txt:private-content', + [], + [ + { + type: 'function', + function: { + name: 'write_file', + parameters: { + type: 'object', + properties: { path: { type: 'string' }, contents: { type: 'string' } }, + required: ['path', 'contents'], + }, + }, + }, + ] + ); + expect(extractToolCall(await parseSse(response))).toMatchObject({ + name: 'write_file', + arguments: { path: 'shared.txt', contents: 'private-content' }, + }); + }); + + it('reads through one real tool, edits through another, then gates', async () => { + const h = await start(); + const prompt = '__fake__:read-edit-then-gate:reader:shared.txt:replacement'; + const readCall = extractToolCall(await parseSse(await postToolChat(h.url, prompt))); + expect(readCall).toMatchObject({ name: 'read', arguments: { filePath: 'shared.txt' } }); + + const readHistory = [ + { role: 'assistant', tool_calls: [{ id: readCall.id }] }, + { + role: 'tool', + tool_call_id: readCall.id, + content: + '/private/shared.txt\n\n1: original-value\n\n(End of file - total 1 lines)\n', + }, + ]; + const editCall = extractToolCall( + await parseSse(await postToolChat(h.url, prompt, readHistory)) + ); + expect(editCall).toMatchObject({ + name: 'edit', + arguments: { + filePath: 'shared.txt', + oldString: 'original-value', + newString: 'replacement', + }, + }); + + const gated = await postToolChat(h.url, prompt, [ + ...readHistory, + { role: 'assistant', tool_calls: [{ id: editCall.id }] }, + { role: 'tool', tool_call_id: editCall.id, content: 'Edited successfully' }, + ]); + const status = await fetch(`${h.url}/test/scenario-status?tag=reader`); + await expect(status.json()).resolves.toMatchObject({ + toolCalls: { write: 0, read: 1, edit: 1, question: 0 }, + toolResults: { write: 0, read: 1, edit: 1, question: 0 }, + }); + expect((await fetch(`${h.url}/test/release?tag=reader`, { method: 'POST' })).status).toBe(204); + const completed = await parseSse(gated); + expect(completed[0]?.choices).toEqual([ + expect.objectContaining({ delta: { role: 'assistant', content: 'done-reader' } }), + ]); + }); + + it('streams a real question and finishes only after its answered tool result', async () => { + const h = await start(); + const prompt = '__fake__:question:asker:Choose a safe option: now'; + const call = extractToolCall(await parseSse(await postToolChat(h.url, prompt))); + expect(call).toMatchObject({ + name: 'question', + arguments: { + questions: [ + { + question: 'Choose a safe option: now', + header: 'E2E', + options: [{ label: 'Continue', description: 'Continue the E2E scenario' }], + }, + ], + }, + }); + + const completed = await parseSse( + await postToolChat(h.url, prompt, [ + { role: 'assistant', tool_calls: [{ id: call.id }] }, + { role: 'tool', tool_call_id: call.id, content: 'Continue' }, + ]) + ); + expect(completed[0]?.choices).toEqual([ + expect.objectContaining({ delta: { role: 'assistant', content: 'done-asker' } }), + ]); + const status = await fetch(`${h.url}/test/scenario-status?tag=asker`); + await expect(status.json()).resolves.toMatchObject({ + toolCalls: { write: 0, read: 0, edit: 0, question: 1 }, + toolResults: { write: 0, read: 0, edit: 0, question: 1 }, + unsupportedToolSchema: false, + }); + }); + + it('finishes title-model requests without advertising or inventing a tool schema', async () => { + const h = await start(); + const chunks = await parseSse( + await postChat(h.url, '__fake__:write-then-gate:title:shared.txt:secret') + ); + expect(chunks[0]?.choices).toEqual([ + expect.objectContaining({ delta: { role: 'assistant', content: 'done-title' } }), + ]); + const status = await fetch(`${h.url}/test/scenario-status?tag=title`); + await expect(status.json()).resolves.toMatchObject({ + toolCalls: { write: 0, read: 0, edit: 0, question: 0 }, + unsupportedToolSchema: false, + }); + }); + + it('reports unsupported advertised tool schemas instead of inventing tool calls', async () => { + const h = await start(); + const response = await postToolChat( + h.url, + '__fake__:write-then-gate:unsupported:shared.txt:secret', + [], + [ + { + type: 'function', + function: { + name: 'write', + parameters: { + type: 'object', + properties: { unexpected: { type: 'string' } }, + required: ['unexpected'], + }, + }, + }, + ] + ); + expect(response.status).toBe(422); + await expect(response.json()).resolves.toMatchObject({ + error: { message: 'unsupported write tool schema', type: 'unsupported_tool_schema' }, + }); + const status = await fetch(`${h.url}/test/scenario-status?tag=unsupported`); + await expect(status.json()).resolves.toMatchObject({ unsupportedToolSchema: true }); + }); + + it('never logs directive contents, raw request bodies, or authorization headers', async () => { + const h = await start(); + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const response = await fetch(`${h.url}/api/openrouter/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer secret-authorization-value', + }, + body: JSON.stringify({ + model: 'kilo/fake-deterministic', + messages: [{ role: 'user', content: '__fake__:echo:secret-prompt-value' }], + stream: true, + }), + }); + await parseSse(response); + const logged = spy.mock.calls.flat().join(' '); + expect(logged).not.toContain('secret-prompt-value'); + expect(logged).not.toContain('secret-authorization-value'); + expect(logged).not.toContain('messages":'); + } finally { + spy.mockRestore(); + } + }); + it('gate normalizes contaminated tags so concurrent kilo calls share one waiter', async () => { const h = await start(); // Real kilo primary-code calls arrive with `` tacked @@ -713,3 +1077,336 @@ describe('fake-llm-server HTTP', () => { ac.abort(); }); }); + +type ProcFixture = { + pid: number; + port: number; + inode: string; + directory: string; + roots: string[]; + address?: string; + state?: string; + fdInode?: string; + command?: string[]; + executable?: string; + canonicalDirectory?: string; + rootOverride?: Record; + bootstrapped?: boolean; +}; + +function discoveryFixture(processes: ProcFixture[], log?: string) { + const requests: URL[] = []; + const killed: string[] = []; + const forbiddenReads: string[] = []; + let afterFetch: (() => void) | undefined; + const containers = new Map([ + ['owned', processes], + ['unrelated', [] as ProcFixture[]], + ]); + const execute: DockerCommandExecutor = async args => { + if (args[0] === 'ps') { + return { + stdout: [...containers.keys()] + .map(id => `${id}\tcloud-agent-next-dev-Sandbox-${id}\timage`) + .concat('proxy\tcloud-agent-next-dev-Sandbox-owned-proxy\timage') + .join('\n'), + }; + } + if (args[0] === 'kill') { + killed.push(args[1]); + return { stdout: '' }; + } + const entries = containers.get(args[1]); + if (args[0] !== 'exec' || !entries) throw new Error('Unexpected container operation'); + const fs = { + readFileSync(filename: string): string { + if (filename === '/proc/net/tcp' || filename === '/proc/net/tcp6') { + return ( + 'header\n' + + entries + .filter(entry => (entry.address?.length ?? 8) > 8 === filename.endsWith('tcp6')) + .map( + entry => + `0: ${entry.address ?? '0100007F'}:${entry.port.toString(16)} 00000000:0000 ${entry.state ?? '0A'} 0 0 0 0 0 ${entry.inode}` + ) + .join('\n') + ); + } + const entry = entries.find(item => filename === `/proc/${item.pid}/cmdline`); + if (entry) + return ( + ( + entry.command ?? ['/usr/local/bin/kilo', 'serve', '--hostname=127.0.0.1', '--port=0'] + ).join('\0') + '\0' + ); + if (filename === '/tmp/kilocode-control-wrapper.log' && log !== undefined) return log; + forbiddenReads.push(filename); + throw new Error('Unexpected filesystem read'); + }, + readdirSync(directory: string): string[] { + if (directory === '/proc') return entries.map(entry => String(entry.pid)); + if (directory === '/tmp') return log === undefined ? [] : ['kilocode-control-wrapper.log']; + if (directory === '/workspace/worktrees') + return entries.map(entry => path.basename(entry.directory)); + if (entries.some(entry => directory === `/proc/${entry.pid}/fd`)) return ['3', '4']; + throw new Error('Unexpected directory read'); + }, + readlinkSync(filename: string): string { + for (const entry of entries) { + if (filename === `/proc/${entry.pid}/cwd`) return entry.directory; + if (filename === `/proc/${entry.pid}/exe`) + return entry.executable ?? '/opt/cli-linux-x64/bin/kilo'; + if (filename === `/proc/${entry.pid}/fd/3` || filename === `/proc/${entry.pid}/fd/4`) { + return `socket:[${entry.fdInode ?? entry.inode}]`; + } + } + throw new Error('Missing process'); + }, + realpathSync(directory: string): string { + return ( + entries.find(entry => entry.directory === directory)?.canonicalDirectory ?? directory + ); + }, + existsSync(filename: string): boolean { + return entries.some( + entry => + entry.bootstrapped !== false && + filename === `${entry.directory}/.kilo-bootstrap-complete` + ); + }, + statSync: () => ({ isDirectory: () => true }), + }; + const fetch = async (url: URL) => { + requests.push(url); + const entry = entries.find(item => String(item.port) === url.port); + const rootId = decodeURIComponent(url.pathname.slice('/session/'.length)); + const matched = + entry && + entry.roots.includes(rootId) && + url.searchParams.get('directory') === entry.directory; + const root = matched + ? { id: rootId, directory: entry.directory, ...entry.rootOverride } + : null; + afterFetch?.(); + return { ok: root !== null, json: async () => root }; + }; + let stdout = ''; + await runInNewContext(args[4].replace(/^import .*;\n/gm, ''), { + fs, + path: path.posix, + fetch, + URL, + AbortSignal, + process: { + argv: ['bun', args[5]], + stdout: { + write: (text: string) => { + stdout += text; + }, + }, + }, + execFileSync: () => { + throw new Error('Process execution is forbidden in discovery tests'); + }, + }); + return { stdout }; + }; + return { + execute, + requests, + killed, + forbiddenReads, + containers, + onFetch: (callback: () => void) => { + afterFetch = callback; + }, + }; +} + +function directoryProcesses(): ProcFixture[] { + return [ + { + pid: 101, + port: 41001, + inode: '501', + directory: '/workspace/worktrees/worktree-a', + roots: ['ses_a', 'ses_sibling'], + }, + { + pid: 202, + port: 41002, + inode: '502', + directory: '/workspace/worktrees/worktree-b', + roots: ['ses_b'], + address: '00000000000000000000000001000000', + }, + ]; +} + +describe('per-directory Kilo discovery', () => { + it('discovers exact roots in distinct PID/cwd listeners without any server log', async () => { + const fixture = discoveryFixture(directoryProcesses()); + const owner = vi.fn(); + const first = await findControlPlaneKiloRuntime('ses_a', fixture.execute, owner); + const sibling = await findControlPlaneKiloRuntime('ses_sibling', fixture.execute); + const second = await findControlPlaneKiloRuntime('ses_b', fixture.execute); + expect(first).toMatchObject({ + container: { id: 'owned' }, + processId: 101, + directory: '/workspace/worktrees/worktree-a', + serverUrl: 'http://127.0.0.1:41001', + }); + expect(sibling).toMatchObject({ processId: first?.processId, directory: first?.directory }); + expect(second).toMatchObject({ + container: { id: 'owned' }, + processId: 202, + directory: '/workspace/worktrees/worktree-b', + serverUrl: 'http://[::1]:41002', + }); + expect(first?.logPath).toBeUndefined(); + expect(owner.mock.calls.map(([container]) => container.id)).toEqual(['owned']); + expect(fixture.requests.every(url => url.searchParams.has('directory'))).toBe(true); + expect(fixture.forbiddenReads).toEqual([]); + }); + + it('uses an existing exact-root attach log only as optional evidence', async () => { + const fixture = discoveryFixture( + directoryProcesses(), + 'snapshot metadata validated status=valid expectedKiloSessionId=ses_a\nsession.attach ready directory=/workspace/worktrees/worktree-a\n' + ); + expect((await findControlPlaneKiloRuntime('ses_a', fixture.execute))?.logPath).toBe( + '/tmp/kilocode-control-wrapper.log' + ); + expect( + (await findControlPlaneKiloRuntime('ses_sibling', fixture.execute))?.logPath + ).toBeUndefined(); + }); + + it.each([ + { label: 'wrong root ID', rootOverride: { id: 'ses_other' } }, + { label: 'other directory', rootOverride: { directory: '/workspace/worktrees/worktree-b' } }, + { + label: 'descendant directory', + rootOverride: { directory: '/workspace/worktrees/worktree-a/nested' }, + }, + { label: 'child session', rootOverride: { parentID: 'ses_parent' } }, + { label: 'noncanonical cwd', canonicalDirectory: '/canonical/worktree-a' }, + { label: 'wrong inode', fdInode: '999' }, + { label: 'non-listening socket', state: '01' }, + { label: 'public listener', address: '00000000' }, + { label: 'other executable', executable: '/usr/bin/node' }, + { label: 'wrong command', command: ['/usr/bin/node', 'serve'] }, + { label: 'not Kilo serve', command: ['/usr/local/bin/kilo', 'run'] }, + { label: 'unprepared directory', bootstrapped: false }, + ])( + 'rejects $label instead of claiming container ownership', + async ({ label: _label, ...override }) => { + const entry = { ...directoryProcesses()[0], ...override }; + const fixture = discoveryFixture([entry]); + const owner = vi.fn(); + expect(await findControlPlaneKiloRuntime('ses_a', fixture.execute, owner)).toBeNull(); + expect(owner).not.toHaveBeenCalled(); + expect(fixture.forbiddenReads).toEqual([]); + } + ); + + it.each(['cwd', 'pid'])( + 'rejects ownership if the listener %s changes during the root query', + async field => { + const entries = directoryProcesses(); + const fixture = discoveryFixture(entries); + fixture.onFetch(() => { + if (field === 'cwd') entries[0].directory = '/workspace/worktrees/replacement'; + else entries[0].pid += 1; + }); + expect(await findControlPlaneKiloRuntime('ses_a', fixture.execute)).toBeNull(); + } + ); + + it('rejects a socket inode held by multiple processes', async () => { + const entries = directoryProcesses(); + entries[1].fdInode = entries[0].inode; + const fixture = discoveryFixture(entries); + expect(await findControlPlaneKiloRuntime('ses_a', fixture.execute)).toBeNull(); + expect(fixture.requests).toEqual([]); + }); + + it('discovers IPv4-mapped loopback sockets in tcp6', async () => { + const entry = directoryProcesses()[0]; + entry.address = '0000000000000000FFFF00000100007F'; + const fixture = discoveryFixture([entry]); + expect(await findControlPlaneKiloRuntime('ses_a', fixture.execute)).toMatchObject({ + serverUrl: 'http://127.0.0.1:41001', + processId: 101, + }); + }); + + it('rejects ambiguous root ownership across listeners and containers', async () => { + const entries = directoryProcesses(); + entries[1].roots = ['ses_a']; + const fixture = discoveryFixture(entries); + const owner = vi.fn(); + await expect(findControlPlaneKiloRuntime('ses_a', fixture.execute, owner)).rejects.toThrow( + 'Ambiguous Kilo root listener ownership' + ); + const duplicate = discoveryFixture([directoryProcesses()[0]]); + duplicate.containers.set('unrelated', [directoryProcesses()[0]]); + await expect(findControlPlaneKiloRuntime('ses_a', duplicate.execute, owner)).rejects.toThrow( + 'Ambiguous Kilo root container ownership' + ); + expect(owner).not.toHaveBeenCalled(); + }); + + it('does not let stale log evidence establish ownership without a root listener', async () => { + const fixture = discoveryFixture( + [], + 'kilo server started at http://127.0.0.1:41001\nsnapshot metadata validated status=missing expectedKiloSessionId=ses_a\nsnapshot missing info.id — likely an error response' + ); + const owner = vi.fn(); + expect(await findControlPlaneKiloRuntime('ses_a', fixture.execute, owner)).toBeNull(); + expect(owner).not.toHaveBeenCalled(); + expect(fixture.requests).toEqual([]); + }); + + it('refuses container cleanup when another worktree shares that container', async () => { + const fixture = discoveryFixture(directoryProcesses()); + const runtime = await findControlPlaneKiloRuntime('ses_a', fixture.execute); + if (!runtime) throw new Error('Expected owned runtime'); + await expect( + stopOwnedControlPlaneSandbox(runtime.container, 'ses_a', fixture.execute) + ).rejects.toThrow('other worktrees'); + expect(fixture.killed).toEqual([]); + }); + + it('cleans only the proven exclusive container and its proxy', async () => { + const fixture = discoveryFixture([directoryProcesses()[0]]); + const runtime = await findControlPlaneKiloRuntime('ses_a', fixture.execute); + if (!runtime) throw new Error('Expected owned runtime'); + await stopOwnedControlPlaneSandbox(runtime.container, 'ses_a', fixture.execute); + expect(fixture.killed).toEqual(['owned', 'proxy']); + }); +}); + +describe('strict harness stream events', () => { + const event = { + eventId: 1, + sessionId: 'workspace_a', + streamEventType: 'kilocode', + timestamp: '2026-08-29T00:00:00Z', + data: { type: 'question.asked', properties: { id: 'question_b', sessionID: 'ses_b' } }, + }; + + it('preserves sibling event fields and the inherited executionId default', () => { + expect(streamEventSchema.parse(event)).toEqual({ ...event, executionId: null }); + }); + + it.each([ + { ...event, eventId: '1' }, + { ...event, sessionId: undefined }, + { ...event, timestamp: undefined }, + { ...event, executionId: 1 }, + { ...event, data: [] }, + ])('rejects malformed stream envelopes', invalid => { + expect(streamEventSchema.safeParse(invalid).success).toBe(false); + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts b/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts index a79818a3a7..573c632c0d 100644 --- a/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts @@ -11,12 +11,19 @@ import { CONTROL_RUNTIME_RESERVED_ENV_VARS } from '../../../src/shared/runtime-e import { sessionPreparingPayloadSchema } from '../../../src/shared/sandbox-control-protocol'; import { buildWorktreeKiloEnvironment, + createWorktreeKiloRuntimes, WorktreeKiloRuntimeError, type WorktreeKiloAuth, type WorktreeKiloRuntime, type WorktreeKiloRuntimes, } from './worktree-runtime'; -import { rememberAttachedRoot, resetSessionDirectoryState } from './session-directories'; +import { + rememberAttachedRoot, + rememberChildSession, + resetSessionDirectoryState, + rootForSession, +} from './session-directories'; +import { fenceDirectoryOperations, resetDirectoryOperationState } from './worktree-operations'; const session = { sessionId: 'workspace_1', @@ -34,7 +41,52 @@ const kilo: WorktreeKiloAuth = { }, }; +const siblingSession = { ...session, sessionId: 'workspace_2', kiloSessionId: 'kilo_2' }; + let homeRoot: string; +const registries: WorktreeKiloRuntimes[] = []; + +function isolatedKiloRuntimes(): WorktreeKiloRuntimes { + const runtimes = createWorktreeKiloRuntimes({ + homeRoot: path.join(homeRoot, 'homes'), + inheritedEnv: {}, + onUnexpectedClose: () => { + throw new Error('Unexpected Kilo feed closure'); + }, + startServer: async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(request) { + const url = new URL(request.url); + if (url.pathname === '/global/event') { + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue( + new TextEncoder().encode( + 'data: {"payload":{"type":"server.connected","properties":{}}}\n\n' + ) + ); + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } } + ); + } + return Response.json({ id: url.pathname.split('/').at(-1) }); + }, + }); + return { + url: server.url.toString(), + close: () => { + void server.stop(true); + }, + }; + }, + }); + registries.push(runtimes); + return runtimes; +} function fakeKiloRuntimes(overrides: Partial = {}): WorktreeKiloRuntimes { const kiloClient = { @@ -71,6 +123,9 @@ function fakeKiloRuntimes(overrides: Partial = {}): WorktreeK }; }, detach: () => true, + deleteDirectory: async directory => { + runtimes.delete(directory); + }, get: directory => runtimes.get(directory), isHealthy: () => true, shutdown: () => {}, @@ -79,10 +134,13 @@ function fakeKiloRuntimes(overrides: Partial = {}): WorktreeK beforeEach(() => { resetSessionDirectoryState(); + resetDirectoryOperationState(); homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'apply-attach-test-')); }); afterEach(() => { + for (const registry of registries) registry.shutdown(); + registries.length = 0; fs.rmSync(homeRoot, { recursive: true, force: true }); }); @@ -162,6 +220,41 @@ describe('applySessionAttach', () => { expect(process.env.KILOCODE_TOKEN).toBe(envBefore); }); + it('preserves generic repository authentication and checks out only the requested upstream branch', async () => { + const gitCalls: string[][] = []; + const repositoryUrl = 'https://git.example.com/acme/demo.git'; + const fakeGitToken = 'fake-git-token'; + const authenticatedUrl = new URL(repositoryUrl); + authenticatedUrl.username = 'x-access-token'; + authenticatedUrl.password = fakeGitToken; + const result = await applySessionAttach( + session, + { + kilo, + branch: 'topic/shared-worktree', + git: { url: repositoryUrl, token: fakeGitToken }, + }, + { + kiloRuntimes: fakeKiloRuntimes(), + ...noFs, + sessionExists: async () => true, + mkdir: async () => {}, + hasGit: async () => false, + runGit: async args => { + gitCalls.push(args); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + } + ); + expect(result).toEqual({ ok: true, result: { attached: true } }); + expect(gitCalls).toEqual([ + ['clone', authenticatedUrl.href, session.directory], + ['checkout', '-B', 'topic/shared-worktree', 'origin/topic/shared-worktree'], + ['config', 'user.name', 'Kilo Code Cloud'], + ['config', 'user.email', 'agent@kilocode.ai'], + ]); + }); + it('skips clone when the directory already has git metadata', async () => { const gitCalls: string[][] = []; const result = await applySessionAttach( @@ -291,7 +384,7 @@ describe('applySessionAttach', () => { ]); }); - it('skips clone and setup when the bootstrap marker is present', async () => { + it('skips clone, branch checkout, and setup when the bootstrap marker is present', async () => { const gitCalls: string[][] = []; const setupCalls: string[] = []; const result = await applySessionAttach( @@ -300,6 +393,7 @@ describe('applySessionAttach', () => { kilo, git: { url: 'https://github.com/acme/demo.git' }, setupCommands: ['pnpm install'], + branch: 'feature/shared-worktree', }, { kiloRuntimes: fakeKiloRuntimes(), @@ -519,6 +613,267 @@ describe('applySessionAttach', () => { } }); + it('waits for directory preparation before attaching a sibling without workspace setup', async () => { + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const probed: string[] = []; + let bootstrapped = false; + const deps: ApplyAttachDeps = { + kiloRuntimes: fakeKiloRuntimes(), + mkdir: async () => {}, + hasBootstrapMarker: async () => bootstrapped, + writeBootstrapMarker: async () => { + bootstrapped = true; + }, + runSetup: async () => { + started.resolve(); + await release.promise; + return { exitCode: 0, stdout: '', stderr: '' }; + }, + sessionExists: async id => { + probed.push(id); + return true; + }, + }; + const first = applySessionAttach(session, { kilo, setupCommands: ['prepare'] }, deps); + await started.promise; + const sibling = applySessionAttach(siblingSession, { kilo }, deps); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(probed).toEqual([]); + release.resolve(); + expect(await Promise.all([first, sibling])).toEqual([ + { ok: true, result: { attached: true } }, + { ok: true, result: { attached: true } }, + ]); + expect(probed.sort()).toEqual([session.kiloSessionId, siblingSession.kiloSessionId]); + expect(bootstrapped).toBe(true); + } finally { + release.resolve(); + await Promise.allSettled([first, sibling]); + } + }); + + it('cancels a queued attach promptly without removing the active directory preparation barrier', async () => { + const started = Promise.withResolvers(); + const queued = Promise.withResolvers(); + const release = Promise.withResolvers(); + const firstAbort = new AbortController(); + const siblingAbort = new AbortController(); + const commands: string[] = []; + let markerWrites = 0; + let firstSettled = false; + const deps: ApplyAttachDeps = { + kiloRuntimes: fakeKiloRuntimes(), + ...noFs, + mkdir: async () => {}, + sessionExists: async () => true, + writeBootstrapMarker: async () => { + markerWrites++; + }, + emitPreparing: event => { + if (event.action === 'attempt_started' && event.attemptId === 'sibling') queued.resolve(); + }, + runSetup: async (command, _directory, _env, _output, signal) => { + commands.push(command); + if (command === 'first') { + if (!signal) throw new Error('Missing attach signal'); + started.resolve(signal); + await release.promise; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + const first = applySessionAttach( + session, + { kilo, setupCommands: ['first'] }, + { ...deps, signal: firstAbort.signal } + ).then(result => { + firstSettled = true; + return result; + }); + const signal = await started.promise; + const sibling = applySessionAttach( + siblingSession, + { + kilo, + setupCommands: ['sibling'], + preparation: { attemptId: 'sibling', triggerMessageId: 'sibling' }, + }, + { ...deps, signal: siblingAbort.signal } + ); + const attaches = [first, sibling]; + try { + await queued.promise; + siblingAbort.abort(); + expect( + await withTimeoutAndAbort(sibling, { + timeoutMs: 1_000, + timeoutMessage: 'Queued attach waited for active preparation', + abortMessage: 'Test cancelled', + }) + ).toMatchObject({ ok: false }); + expect(signal.aborted).toBe(false); + const next = applySessionAttach( + { ...siblingSession, sessionId: 'workspace_3', kiloSessionId: 'kilo_3' }, + { kilo, setupCommands: ['next'] }, + deps + ); + attaches.push(next); + await new Promise(resolve => setImmediate(resolve)); + expect(commands).toEqual(['first']); + firstAbort.abort(); + await new Promise(resolve => setImmediate(resolve)); + expect(signal.aborted).toBe(true); + expect(firstSettled).toBe(false); + expect(commands).toEqual(['first']); + release.resolve(); + expect(await first).toMatchObject({ ok: false }); + expect(await next).toEqual({ ok: true, result: { attached: true } }); + expect(commands).toEqual(['first', 'next']); + expect(markerWrites).toBe(1); + } finally { + release.resolve(); + await Promise.allSettled(attaches); + } + }); + + it('waits for abort-ignoring preparation before deletion after promptly cancelling a queued attach', async () => { + const started = Promise.withResolvers(); + const queued = Promise.withResolvers(); + const release = Promise.withResolvers(); + const activeAbort = new AbortController(); + const queuedAbort = new AbortController(); + const waitingQueued = Promise.withResolvers(); + const directory = path.join(homeRoot, 'deleting-worktree'); + const activeSession = { ...session, directory }; + const queuedSession = { ...siblingSession, directory }; + const otherSession = { + sessionId: 'workspace_other', + kiloSessionId: 'kilo_other', + directory: path.join(homeRoot, 'other-worktree'), + }; + const commands: string[] = []; + const markers: string[] = []; + const cleanup: string[] = []; + let activeSettled = false; + const deps: ApplyAttachDeps = { + kiloRuntimes: fakeKiloRuntimes(), + ...noFs, + mkdir: async cwd => { + fs.mkdirSync(cwd, { recursive: true }); + }, + sessionExists: async () => true, + writeBootstrapMarker: async cwd => { + markers.push(cwd); + }, + emitPreparing: event => { + if (event.action !== 'attempt_started') return; + if (event.attemptId === 'queued') queued.resolve(); + if (event.attemptId === 'waiting') waitingQueued.resolve(); + }, + runSetup: async (command, cwd, _env, _output, signal) => { + commands.push(command); + if (command === 'active') { + if (!signal) throw new Error('Missing preparation signal'); + started.resolve(signal); + await release.promise; + fs.writeFileSync(path.join(cwd, 'late-preparation'), 'finished'); + cleanup.push('preparation-finished'); + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + }; + const active = applySessionAttach( + activeSession, + { kilo, setupCommands: ['active'] }, + { ...deps, signal: activeAbort.signal } + ).then(result => { + activeSettled = true; + return result; + }); + const signal = await started.promise; + const sibling = applySessionAttach( + queuedSession, + { + kilo, + setupCommands: ['cancelled-queued'], + preparation: { attemptId: 'queued', triggerMessageId: 'queued' }, + }, + { ...deps, signal: queuedAbort.signal } + ); + let deletion: Promise | undefined; + let waiting: ReturnType | undefined; + try { + await queued.promise; + queuedAbort.abort(); + expect( + await withTimeoutAndAbort(sibling, { + timeoutMs: 1_000, + timeoutMessage: 'Queued cancellation waited for active preparation', + abortMessage: 'Test cancelled', + }) + ).toMatchObject({ ok: false }); + expect(signal.aborted).toBe(false); + waiting = applySessionAttach( + { ...activeSession, sessionId: 'workspace_waiting', kiloSessionId: 'kilo_waiting' }, + { + kilo, + setupCommands: ['waiting'], + preparation: { attemptId: 'waiting', triggerMessageId: 'waiting' }, + }, + deps + ); + await waitingQueued.promise; + activeAbort.abort(); + expect(signal.aborted).toBe(true); + deletion = fenceDirectoryOperations(directory).then(() => { + cleanup.push('deletion-started'); + fs.rmSync(directory, { recursive: true, force: true }); + }); + expect( + await withTimeoutAndAbort( + applySessionAttach( + otherSession, + { + kilo: { ...kilo, scopeId: 'other-worktree' }, + setupCommands: ['other'], + }, + deps + ), + { + timeoutMs: 1_000, + timeoutMessage: 'Other worktree preparation was blocked', + abortMessage: 'Test cancelled', + } + ) + ).toEqual({ ok: true, result: { attached: true } }); + await withTimeoutAndAbort(fenceDirectoryOperations(otherSession.directory), { + timeoutMs: 1_000, + timeoutMessage: 'Other directory fence waited for unrelated preparation', + abortMessage: 'Test cancelled', + }); + expect(activeSettled).toBe(false); + expect(cleanup).toEqual([]); + expect(fs.existsSync(directory)).toBe(true); + expect(commands).toEqual(['active', 'other']); + expect( + await applySessionAttach(queuedSession, { kilo, setupCommands: ['after-fence'] }, deps) + ).toMatchObject({ ok: false }); + release.resolve(); + expect(await active).toMatchObject({ ok: false }); + expect(await waiting).toMatchObject({ ok: false }); + await deletion; + expect(cleanup).toEqual(['preparation-finished', 'deletion-started']); + expect(fs.existsSync(directory)).toBe(false); + expect(commands).toEqual(['active', 'other']); + expect(markers).toEqual([otherSession.directory]); + } finally { + release.resolve(); + await Promise.allSettled([active, sibling, waiting, deletion]); + } + }); + it('runs setup commands and emits preparing steps', async () => { const setupCalls: Array<{ command: string; env?: Record }> = []; const events: PreparingEventDataV2[] = []; @@ -884,6 +1239,171 @@ describe('applySessionAttach', () => { } ); + it('routes sibling restore events to the pending root before committing its attachment', async () => { + const directory = path.join(homeRoot, 'shared'); + const first = { ...session, directory }; + const sibling = { ...siblingSession, directory }; + const runtimes = isolatedKiloRuntimes(); + expect( + await applySessionAttach(first, { kilo }, { ...noFs, kiloRuntimes: runtimes }) + ).toMatchObject({ ok: true }); + const observed: Array = []; + const result = await applySessionAttach( + sibling, + { kilo }, + { + ...noFs, + kiloRuntimes: runtimes, + sessionExists: async () => false, + restoreSession: async (id, directory) => { + observed.push(rootForSession(id, directory)); + rememberChildSession({ childId: 'child_sibling', parentId: id, directory }); + return { + ok: true, + downloaded: true, + imported: true, + diffs: { applied: 0, skipped: 0, total: 0 }, + }; + }, + } + ); + expect(result).toMatchObject({ ok: true }); + expect(observed).toEqual([sibling.kiloSessionId]); + expect(rootForSession('child_sibling')).toBe(sibling.kiloSessionId); + expect(rootForSession(first.kiloSessionId)).toBe(first.kiloSessionId); + expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); + }); + + it('retires a failed sibling attachment without removing a committed sibling or its runtime', async () => { + const directory = path.join(homeRoot, 'shared'); + const first = { ...session, directory }; + const sibling = { ...siblingSession, directory }; + const runtimes = isolatedKiloRuntimes(); + const deps = { ...noFs, kiloRuntimes: runtimes }; + expect(await applySessionAttach(first, { kilo }, deps)).toMatchObject({ ok: true }); + const runtime = runtimes.get(directory); + const failed = await applySessionAttach( + sibling, + { kilo }, + { + ...deps, + sessionExists: async () => false, + restoreSession: async () => ({ + ok: false, + error: 'download failed', + code: 502, + step: 'download', + }), + } + ); + expect(failed).toMatchObject({ ok: false }); + expect(rootForSession(sibling.kiloSessionId)).toBeUndefined(); + expect(rootForSession(first.kiloSessionId)).toBe(first.kiloSessionId); + expect(runtimes.get(directory)).toBe(runtime); + expect(runtime?.signal.aborted).toBe(false); + expect(runtimes.detach(sibling)).toBe(false); + expect(await applySessionAttach(sibling, { kilo }, deps)).toMatchObject({ ok: true }); + }); + + it('preserves a committed root and its children when reattachment restoration fails', async () => { + const identity = { ...session, directory: path.join(homeRoot, 'shared') }; + const runtimes = isolatedKiloRuntimes(); + const deps = { ...noFs, kiloRuntimes: runtimes }; + expect(await applySessionAttach(identity, { kilo }, deps)).toMatchObject({ ok: true }); + rememberChildSession({ childId: 'child_existing', parentId: identity.kiloSessionId }); + const runtime = runtimes.get(identity.directory); + expect( + await applySessionAttach( + identity, + { kilo }, + { + ...deps, + sessionExists: async () => false, + restoreSession: async () => ({ + ok: false, + error: 'download failed', + code: 502, + step: 'download', + }), + } + ) + ).toMatchObject({ ok: false }); + expect(rootForSession(identity.kiloSessionId)).toBe(identity.kiloSessionId); + expect(rootForSession('child_existing')).toBe(identity.kiloSessionId); + expect(runtimes.get(identity.directory)).toBe(runtime); + expect(runtime?.signal.aborted).toBe(false); + }); + + it('retires a cancelled pending root and keeps the original immutable grant for a sibling', async () => { + const directory = path.join(homeRoot, 'shared'); + const identity = { ...session, directory }; + const sibling = { ...siblingSession, directory }; + const runtimes = isolatedKiloRuntimes(); + const restoring = Promise.withResolvers(); + const release = Promise.withResolvers(); + const controller = new AbortController(); + const grant = { ...kilo, targets: { ...kilo.targets } }; + const attaching = applySessionAttach( + identity, + { kilo: grant }, + { + ...noFs, + kiloRuntimes: runtimes, + signal: controller.signal, + sessionExists: async () => false, + restoreSession: async () => { + restoring.resolve(); + await release.promise; + return { + ok: true, + downloaded: true, + imported: true, + diffs: { applied: 0, skipped: 0, total: 0 }, + }; + }, + } + ); + try { + await restoring.promise; + const runtime = runtimes.get(directory); + grant.token = 'mutated-guest'; + grant.targets.sessionIngestBaseUrl = 'https://other.example.test'; + const deps = { ...noFs, kiloRuntimes: runtimes }; + expect(await applySessionAttach(sibling, { kilo: grant }, deps)).toMatchObject({ + ok: false, + error: { code: 'unauthorized' }, + }); + expect(await applySessionAttach(sibling, { kilo }, deps)).toMatchObject({ ok: true }); + const storage = path.join( + runtime?.env.XDG_DATA_HOME ?? '', + 'kilo', + 'storage', + 'session_share' + ); + for (const id of [identity.kiloSessionId, sibling.kiloSessionId]) { + expect(JSON.parse(fs.readFileSync(path.join(storage, `${id}.json`), 'utf8'))).toEqual({ + id, + ingestPath: `/api/session/${id}/ingest`, + }); + } + controller.abort(); + release.resolve(); + expect(await attaching).toMatchObject({ ok: false }); + expect(runtimes.detach(identity)).toBe(false); + expect(rootForSession(identity.kiloSessionId)).toBeUndefined(); + expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); + expect(runtimes.get(directory)).toBe(runtime); + expect(runtime?.env.KILOCODE_TOKEN).toBe(kilo.token); + expect(runtime?.env.KILO_SESSION_INGEST_URL).toBe(kilo.targets.sessionIngestBaseUrl); + expect(runtime?.signal.aborted).toBe(false); + expect(runtimes.detach(sibling)).toBe(true); + expect(runtime?.signal.aborted).toBe(true); + } finally { + release.resolve(); + await attaching; + } + }); + it('uses snapshotIdentity when present', async () => { const ids: string[] = []; const result = await applySessionAttach( diff --git a/services/cloud-agent-next/wrapper/src/control/apply-attach.ts b/services/cloud-agent-next/wrapper/src/control/apply-attach.ts index 75ec6eab9d..49c419e886 100644 --- a/services/cloud-agent-next/wrapper/src/control/apply-attach.ts +++ b/services/cloud-agent-next/wrapper/src/control/apply-attach.ts @@ -37,6 +37,7 @@ import { type WorktreeKiloAttachment, type WorktreeKiloRuntimes, } from './worktree-runtime.js'; +import { runDirectoryOperation } from './worktree-operations'; const BOOTSTRAP_MARKER = 'kilo-bootstrap-complete'; const SETUP_COMMAND_INACTIVITY_TIMEOUT_MS = 4 * 60_000; @@ -152,7 +153,7 @@ async function serializeWorkspacePreparation( const run = () => { signal.removeEventListener('abort', onAbort); signal.throwIfAborted(); - return prepare(); + return runDirectoryOperation(directory, prepare); }; const previous = workspacePreparations.get(directory) ?? Promise.resolve(); const current = previous.then(run, run).finally(() => { @@ -251,10 +252,26 @@ export async function applySessionAttach( ) { return fail('protocol_error', 'Reserved control runtime environment variable', false); } - if (!attach.kilo) return fail('protocol_error', 'Kilo auth context is required', false); + const kilo = attach.kilo; + if (!kilo) return fail('protocol_error', 'Kilo auth context is required', false); const directory = attach.directory ?? session.directory; if (directory !== session.directory) return fail('protocol_error', 'Attachment directory mismatch', false); + try { + return await runDirectoryOperation(directory, () => + executeSessionAttach(session, { ...attach, kilo }, deps, directory) + ); + } catch { + return fail('not_ready', 'Worktree is being deleted', false); + } +} + +async function executeSessionAttach( + session: SessionRequestIdentity, + attach: SessionAttachPayload & { kilo: NonNullable }, + deps: ApplyAttachDeps, + directory: string +): Promise { const existingDirectory = directoryForSession(session.kiloSessionId); if (existingDirectory && existingDirectory !== directory) { return fail('unauthorized', 'Session directory mismatch', false); diff --git a/services/cloud-agent-next/wrapper/src/control/delete-worktree.test.ts b/services/cloud-agent-next/wrapper/src/control/delete-worktree.test.ts new file mode 100644 index 0000000000..37458bf4c5 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/control/delete-worktree.test.ts @@ -0,0 +1,692 @@ +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; +import { rejects } from 'node:assert/strict'; +import type { Stats } from 'node:fs'; +import fs from 'node:fs/promises'; +import { + createWorktreeKiloCleanupClient, + deleteWorktree, + prepareWorktreeDeletion, + validateWorktreeDirectory, + type WorktreeKiloCleanupClient, +} from './delete-worktree'; +import { runDirectoryOperation, resetDirectoryOperationState } from './worktree-operations'; +import { + rememberAttachedRoot, + resetSessionDirectoryState, + rootForSession, +} from './session-directories'; +import { createControlTerminalRuntime } from './terminal-runtime'; +import type { WorktreeKiloRuntime } from './worktree-runtime'; +import type { WrapperKiloClient, WrapperPty } from '../kilo-api'; + +const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; +const directory = `/workspace/oauth-user/worktrees/${worktreeId}`; +const otherDirectory = + '/workspace/oauth-user/worktrees/worktree_22222222-2222-4222-8222-222222222222'; +const sessionId = (index: number) => `ses_${String(index).padStart(26, '0')}`; + +afterEach(() => { + resetDirectoryOperationState(); + resetSessionDirectoryState(); +}); + +function fixture() { + const sessions = new Map< + string, + { id: string; directory: string; parent?: string; active: boolean } + >(); + const processes = new Set(); + const directories = new Set([directory, otherDirectory]); + const terminals = new Set([directory, otherDirectory]); + const disposed: string[] = []; + const retired: string[] = []; + const aborted: string[] = []; + let failDeletion: string | undefined; + const client: WorktreeKiloCleanupClient = { + listSessionIds: async dir => + [...sessions.values()] + .filter(session => session.directory === dir && !session.parent) + .map(session => session.id), + getSession: async (_dir, id) => sessions.get(id) ?? null, + children: async (_dir, id) => [...sessions.values()].filter(session => session.parent === id), + abortSession: async (_dir, id) => { + const session = sessions.get(id); + if (session) session.active = false; + aborted.push(id); + }, + stopSessionProcesses: async (_dir, id) => { + processes.delete(id); + }, + deleteSession: async (_dir, id) => { + if (id === failDeletion) throw new Error('Kilo temporarily unavailable'); + sessions.delete(id); + }, + closeTerminals: async dir => { + terminals.delete(dir); + }, + disposeDirectory: async dir => { + disposed.push(dir); + }, + }; + return { + sessions, + processes, + directories, + terminals, + disposed, + retired, + aborted, + client, + deps: { + client, + assertDirectory: async () => undefined, + retireDirectory: async (dir: string) => { + retired.push(dir); + }, + removeDirectory: async (dir: string) => { + directories.delete(dir); + }, + }, + fail: (id?: string) => { + failDeletion = id; + }, + add: (id: string, dir = directory, parent?: string) => { + sessions.set(id, { id, directory: dir, parent, active: true }); + processes.add(id); + }, + }; +} + +describe('Kilo 7.4.20 cleanup HTTP compatibility', () => { + test('awaits confirmed legacy cancellation and deletes lazy and never-run sessions without the unavailable v2 wait', async () => { + const f = fixture(); + f.add(sessionId(0)); + f.add(sessionId(2), directory, sessionId(0)); + f.add(sessionId(3), otherDirectory); + const input = { worktreeId, directory, sessionIds: [sessionId(0), sessionId(1), sessionId(2)] }; + const abortStarted = Promise.withResolvers(); + const finishAbort = Promise.withResolvers(); + const server = Bun.serve({ + port: 0, + fetch: async request => { + const url = new URL(request.url); + const dir = url.searchParams.get('directory'); + const id = url.pathname.split('/')[2]; + if (url.pathname.startsWith('/api/session/') && url.pathname.endsWith('/wait')) { + return Response.json({}, { status: 503 }); + } + if (dir !== directory) return Response.json({}, { status: 400 }); + if (url.pathname === '/api/session') { + return Response.json( + url.searchParams.has('cursor') + ? { data: [], cursor: { previous: null, next: null } } + : { + data: [...f.sessions.values()].filter(session => session.directory === dir), + cursor: { previous: 'previous-page', next: 'next-page' }, + } + ); + } + if (url.pathname.startsWith('/session/')) { + if (url.pathname.endsWith('/abort')) { + abortStarted.resolve(); + await finishAbort.promise; + await f.client.abortSession(dir, id); + return Response.json(true); + } + if (url.pathname.endsWith('/children')) { + return Response.json(await f.client.children(dir, id)); + } + if (request.method === 'DELETE') { + await f.client.deleteSession(dir, id); + return Response.json(true); + } + const session = await f.client.getSession(dir, id); + return Response.json(session ?? {}, { status: session ? 200 : 404 }); + } + if (url.pathname.startsWith('/background-process/session/')) { + await f.client.stopSessionProcesses(dir, url.pathname.split('/')[3]); + return Response.json(true); + } + if (url.pathname === '/interactive-terminal') return Response.json([]); + if (url.pathname === '/pty') { + return Response.json(f.terminals.has(dir) ? [{ id: 'pty_qa' }] : []); + } + if (url.pathname === '/pty/pty_qa' && request.method === 'DELETE') { + await f.client.closeTerminals(dir); + return Response.json(true); + } + if (url.pathname === '/instance/dispose') { + await f.client.disposeDirectory(dir); + return Response.json(true); + } + return Response.json({}, { status: 404 }); + }, + }); + try { + const deletion = deleteWorktree(input, { + ...f.deps, + client: createWorktreeKiloCleanupClient(server.url.toString()), + }); + await abortStarted.promise; + expect(f.sessions.get(sessionId(0))?.active).toBe(true); + expect(f.directories.has(directory)).toBe(true); + expect(f.terminals.has(directory)).toBe(true); + finishAbort.resolve(); + expect(await deletion).toEqual({ deleted: true, sessionIds: input.sessionIds }); + expect([...f.sessions.keys()]).toEqual([sessionId(3)]); + expect(f.sessions.get(sessionId(3))?.active).toBe(true); + expect([...f.processes]).toEqual([sessionId(3)]); + expect([...f.terminals]).toEqual([otherDirectory]); + expect([...f.directories]).toEqual([otherDirectory]); + expect(f.disposed).toEqual([directory]); + } finally { + finishAbort.resolve(); + await server.stop(true); + } + }); + + test.each([ + { status: 503, body: true }, + { status: 200, body: false }, + { status: 200, body: null }, + { status: 204, body: null }, + ])('fails closed when abort is unconfirmed: %j', async ({ status, body }) => { + const server = Bun.serve({ + port: 0, + fetch: () => + status === 204 ? new Response(null, { status }) : Response.json(body, { status }), + }); + try { + await rejects( + createWorktreeKiloCleanupClient(server.url.toString()).abortSession( + directory, + sessionId(0) + ), + /not confirmed/ + ); + } finally { + await server.stop(true); + } + }); +}); + +describe('Kilo session DELETE confirmation', () => { + test.each([200, 204])('rejects an empty successful DELETE response: %s', async status => { + const server = Bun.serve({ port: 0, fetch: () => new Response(null, { status }) }); + try { + await rejects( + createWorktreeKiloCleanupClient(server.url.toString()).deleteSession( + directory, + sessionId(0) + ), + /not confirmed/ + ); + } finally { + await server.stop(true); + } + }); + + test('accepts a missing session as already deleted', async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 404 }) }); + try { + await createWorktreeKiloCleanupClient(server.url.toString()).deleteSession( + directory, + sessionId(0) + ); + } finally { + await server.stop(true); + } + }); +}); + +describe('scoped worktree runtime deletion', () => { + test.each([true, false])( + 'awaits terminal detachment and runtime retirement before removing the checkout: live=%s', + async live => { + const f = fixture(); + if (live) { + f.add(sessionId(0)); + f.add(sessionId(1), directory, sessionId(0)); + } + f.add(sessionId(2), otherDirectory); + rememberAttachedRoot(sessionId(0), directory); + rememberAttachedRoot(sessionId(2), otherDirectory); + const detaching = Promise.withResolvers(); + const detached = Promise.withResolvers(); + const retiring = Promise.withResolvers(); + const retired = Promise.withResolvers(); + const homes = new Set([directory, otherDirectory]); + const input = { worktreeId, directory, sessionIds: [sessionId(0), sessionId(1)] }; + const deletion = deleteWorktree(input, { + ...f.deps, + client: live ? f.client : undefined, + detachTerminals: async dir => { + expect(dir).toBe(directory); + detaching.resolve(); + await detached.promise; + }, + retireDirectory: async dir => { + expect(dir).toBe(directory); + expect([...f.sessions.keys()]).toEqual([sessionId(2)]); + expect([...f.processes]).toEqual([sessionId(2)]); + expect(f.disposed).toEqual(live ? [directory] : []); + expect([...f.terminals]).toEqual(live ? [otherDirectory] : [directory, otherDirectory]); + expect(f.directories.has(directory)).toBe(true); + retiring.resolve(); + await retired.promise; + homes.delete(dir); + }, + removeDirectory: async dir => { + expect(homes.has(dir)).toBe(false); + await f.deps.removeDirectory(dir); + }, + }); + try { + await detaching.promise; + expect(homes.has(directory)).toBe(true); + expect(f.disposed).toEqual([]); + expect(f.directories.has(directory)).toBe(true); + expect(rootForSession(sessionId(0))).toBe(sessionId(0)); + detached.resolve(); + await retiring.promise; + expect(homes.has(directory)).toBe(true); + expect(f.directories.has(directory)).toBe(true); + expect(rootForSession(sessionId(0))).toBe(sessionId(0)); + retired.resolve(); + expect(await deletion).toEqual({ deleted: true, sessionIds: input.sessionIds }); + expect([...homes]).toEqual([otherDirectory]); + expect([...f.directories]).toEqual([otherDirectory]); + expect(rootForSession(sessionId(0))).toBeUndefined(); + expect(rootForSession(sessionId(2))).toBe(sessionId(2)); + expect(f.sessions.get(sessionId(2))?.active).toBe(true); + } finally { + detached.resolve(); + retired.resolve(); + await deletion; + } + } + ); + + test.each(['never-started', 'already-retired'])( + 'idempotently deletes a %s checkout without a Kilo client', + async state => { + const f = fixture(); + if (state === 'already-retired') f.directories.delete(directory); + f.add(sessionId(2), otherDirectory); + rememberAttachedRoot(sessionId(0), directory); + rememberAttachedRoot(sessionId(2), otherDirectory); + const input = { worktreeId, directory, sessionIds: [sessionId(0)] }; + const deps = { ...f.deps, client: undefined }; + expect(await prepareWorktreeDeletion(input, deps)).toEqual(input.sessionIds); + for (let attempt = 0; attempt < 2; attempt++) { + expect(await deleteWorktree(input, deps)).toEqual({ + deleted: true, + sessionIds: input.sessionIds, + }); + } + expect(f.aborted).toEqual([]); + expect(f.disposed).toEqual([]); + expect(f.retired).toEqual([directory, directory]); + expect([...f.directories]).toEqual([otherDirectory]); + expect(rootForSession(sessionId(0))).toBeUndefined(); + expect(rootForSession(sessionId(2))).toBe(sessionId(2)); + expect(f.sessions.get(sessionId(2))?.active).toBe(true); + } + ); + + test.each([true, false])( + 'keeps the checkout when runtime retirement fails and allows a clientless retry: live=%s', + async live => { + const f = fixture(); + if (live) f.add(sessionId(0)); + rememberAttachedRoot(sessionId(0), directory); + const input = { worktreeId, directory, sessionIds: [sessionId(0)] }; + await rejects( + deleteWorktree(input, { + ...f.deps, + client: live ? f.client : undefined, + retireDirectory: async () => { + throw new Error('Runtime retirement failed'); + }, + }), + /Runtime retirement failed/ + ); + expect(f.directories.has(directory)).toBe(true); + expect(rootForSession(sessionId(0))).toBe(sessionId(0)); + expect(await deleteWorktree(input, { ...f.deps, client: undefined })).toEqual({ + deleted: true, + sessionIds: input.sessionIds, + }); + expect(f.directories.has(directory)).toBe(false); + expect(rootForSession(sessionId(0))).toBeUndefined(); + } + ); + + test('keeps the checkout and runtime when clientless terminal detachment fails', async () => { + const f = fixture(); + rememberAttachedRoot(sessionId(0), directory); + await rejects( + deleteWorktree( + { worktreeId, directory, sessionIds: [sessionId(0)] }, + { + ...f.deps, + client: undefined, + detachTerminals: async () => { + throw new Error('Terminal detachment failed'); + }, + } + ), + /Terminal detachment failed/ + ); + expect(f.retired).toEqual([]); + expect(f.directories.has(directory)).toBe(true); + expect(rootForSession(sessionId(0))).toBe(sessionId(0)); + }); + + test('checks filesystem safety without a Kilo client', async () => { + const f = fixture(); + const stat = spyOn(fs, 'lstat').mockResolvedValue({ + isSymbolicLink: () => true, + isDirectory: () => true, + } as Stats); + try { + await rejects( + deleteWorktree( + { worktreeId, directory, sessionIds: [] }, + { ...f.deps, client: undefined, assertDirectory: undefined } + ), + /Invalid worktree directory/ + ); + expect(f.retired).toEqual([]); + expect(f.directories.has(directory)).toBe(true); + } finally { + stat.mockRestore(); + } + }); + + test('removes more than 200 roots and deep descendants while preserving another checkout and its resources', async () => { + const f = fixture(); + for (let index = 0; index < 205; index++) f.add(sessionId(index)); + for (let index = 205; index < 225; index++) + f.add(sessionId(index), directory, sessionId(index === 205 ? 0 : index - 1)); + f.add(sessionId(999), otherDirectory); + const input = { + worktreeId, + directory, + sessionIds: Array.from({ length: 205 }, (_, index) => sessionId(index)), + }; + const manifest = await prepareWorktreeDeletion(input, f.deps); + expect(manifest).toHaveLength(225); + const deleted = await deleteWorktree({ ...input, sessionIds: manifest }, f.deps); + expect(deleted.sessionIds).toHaveLength(225); + expect([...f.sessions.keys()]).toEqual([sessionId(999)]); + expect([...f.processes]).toEqual([sessionId(999)]); + expect([...f.terminals]).toEqual([otherDirectory]); + expect([...f.directories]).toEqual([otherDirectory]); + expect(f.disposed).toEqual([directory]); + expect(f.sessions.get(sessionId(999))?.active).toBe(true); + }); + + test('awaits pending wrapper PTYs and clears only the deleted checkout terminal state', async () => { + const f = fixture(); + f.add(sessionId(0)); + f.add(sessionId(1)); + f.add(sessionId(2), otherDirectory); + const roots = [ + { sessionId: 'workspace_first', kiloSessionId: sessionId(0), directory }, + { sessionId: 'workspace_sibling', kiloSessionId: sessionId(1), directory }, + { sessionId: 'workspace_other', kiloSessionId: sessionId(2), directory: otherDirectory }, + ] as const; + const ptys = new Map(); + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const cleanupStarted = Promise.withResolvers(); + let blockCreation = false; + let count = 0; + const kiloClient = { + serverUrl: 'http://127.0.0.1:1', + createPty: async input => { + const id = `pty_${++count}`; + if (blockCreation && input.cwd === directory) { + started.resolve(); + await release.promise; + } + const pty: WrapperPty = { + id, + title: 'Workspace terminal', + command: '/bin/bash', + args: [], + cwd: input.cwd, + status: 'running', + pid: count, + }; + ptys.set(id, pty); + return pty; + }, + resizePty: async (id, _size, dir) => { + const pty = ptys.get(id); + if (!pty || pty.cwd !== dir) throw new Error('PTY not found'); + return pty; + }, + deletePty: async (id, dir) => ptys.get(id)?.cwd === dir && ptys.delete(id), + } as WrapperKiloClient; + const kiloRuntime: WorktreeKiloRuntime = { + scopeId: 'test-scope', + directory, + env: {}, + kiloClient, + signal: new AbortController().signal, + }; + const otherKiloRuntime: WorktreeKiloRuntime = { + scopeId: 'test-scope-other', + directory: otherDirectory, + env: {}, + kiloClient, + signal: new AbortController().signal, + }; + const runtime = createControlTerminalRuntime({ + controlUrl: 'ws://127.0.0.1:1/sandbox-control/sandbox', + wrapperInstanceId: crypto.randomUUID(), + getKiloRuntime: dir => + dir === directory ? kiloRuntime : dir === otherDirectory ? otherKiloRuntime : undefined, + }); + + try { + for (const root of roots) { + rememberAttachedRoot(root.kiloSessionId, root.directory); + runtime.rememberAttachedSession(root); + } + await runtime.create(roots[0], { operationId: crypto.randomUUID() }); + const other = await runtime.create(roots[2], { operationId: crypto.randomUUID() }); + blockCreation = true; + const creation = runtime.create(roots[1], { operationId: crypto.randomUUID() }); + const creationFailure = rejects(creation, /no longer attached/); + await started.promise; + + const deletion = deleteWorktree( + { worktreeId, directory, sessionIds: [sessionId(0), sessionId(1)] }, + { + ...f.deps, + detachTerminals: dir => { + const cleanup = runtime.detachDirectory(dir); + cleanupStarted.resolve(); + return cleanup; + }, + } + ); + await cleanupStarted.promise; + expect(f.directories.has(directory)).toBe(true); + expect(rootForSession(sessionId(0))).toBe(sessionId(0)); + expect(rootForSession(sessionId(1))).toBe(sessionId(1)); + + release.resolve(); + await creationFailure; + expect(await deletion).toEqual({ deleted: true, sessionIds: [sessionId(0), sessionId(1)] }); + expect([...ptys.values()]).toEqual([other.pty]); + expect([...f.directories]).toEqual([otherDirectory]); + expect(rootForSession(sessionId(2))).toBe(sessionId(2)); + expect(await runtime.resize(roots[2], { ptyId: other.pty.id, cols: 100, rows: 30 })).toEqual( + other + ); + await rejects(runtime.create(roots[0], { operationId: crypto.randomUUID() }), /not attached/); + } finally { + release.resolve(); + runtime.shutdown(); + } + }); + + test('removes an unopened checkout even when Kilo has no session records', async () => { + const f = fixture(); + const input = { worktreeId, directory, sessionIds: [sessionId(0)] }; + expect(await deleteWorktree(input, f.deps)).toEqual({ + deleted: true, + sessionIds: [sessionId(0)], + }); + expect(f.directories.has(directory)).toBe(false); + expect(f.directories.has(otherDirectory)).toBe(true); + }); + + test('requires discovered descendants to be journaled before deleting transcripts', async () => { + const f = fixture(); + f.add(sessionId(0)); + f.add(sessionId(1), directory, sessionId(0)); + await rejects( + deleteWorktree({ worktreeId, directory, sessionIds: [sessionId(0)] }, f.deps), + /manifest changed/ + ); + expect(f.sessions.size).toBe(2); + expect(f.retired).toEqual([]); + expect(f.directories.has(directory)).toBe(true); + }); + + test('does not report success after a partial delete and resumes with the complete journal', async () => { + const f = fixture(); + f.add(sessionId(0)); + f.add(sessionId(1), directory, sessionId(0)); + const input = { worktreeId, directory, sessionIds: [sessionId(0), sessionId(1)] }; + f.fail(sessionId(0)); + await rejects(deleteWorktree(input, f.deps), /temporarily unavailable/); + expect(f.sessions.has(sessionId(1))).toBe(false); + expect(f.retired).toEqual([]); + expect(f.directories.has(directory)).toBe(true); + f.fail(); + expect(await deleteWorktree(input, f.deps)).toEqual({ + deleted: true, + sessionIds: input.sessionIds, + }); + expect(f.sessions.size).toBe(0); + expect(f.directories.has(directory)).toBe(false); + }); + + test('verifies Kilo deletion rather than trusting a successful deletion response', async () => { + const f = fixture(); + f.add(sessionId(0)); + f.client.deleteSession = async () => undefined; + await rejects( + deleteWorktree({ worktreeId, directory, sessionIds: [sessionId(0)] }, f.deps), + /not confirmed/ + ); + expect(f.disposed).toEqual([]); + expect(f.retired).toEqual([]); + expect(f.directories.has(directory)).toBe(true); + }); + + test('rejects a mismatched session directory without deleting the unrelated session', async () => { + const f = fixture(); + f.add(sessionId(0), otherDirectory); + await rejects( + prepareWorktreeDeletion({ worktreeId, directory, sessionIds: [sessionId(0)] }, f.deps), + /directory conflict/ + ); + expect(f.sessions.get(sessionId(0))?.active).toBe(true); + expect(f.directories.has(otherDirectory)).toBe(true); + }); + + test.each([true, false])( + 'rejects a journaled root attached to another directory even without a Kilo record: live=%s', + async live => { + const f = fixture(); + rememberAttachedRoot(sessionId(0), otherDirectory); + await rejects( + deleteWorktree( + { worktreeId, directory, sessionIds: [sessionId(0)] }, + { ...f.deps, client: live ? f.client : undefined } + ), + /directory conflict/ + ); + expect(rootForSession(sessionId(0), otherDirectory)).toBe(sessionId(0)); + expect(f.aborted).toEqual([]); + expect(f.retired).toEqual([]); + expect([...f.directories]).toEqual([directory, otherDirectory]); + } + ); + + test('rejects descendants belonging to another checkout before deleting any sessions', async () => { + const f = fixture(); + f.add(sessionId(0)); + f.add(sessionId(1), otherDirectory, sessionId(0)); + await rejects( + deleteWorktree({ worktreeId, directory, sessionIds: [sessionId(0), sessionId(1)] }, f.deps), + /child directory conflict/ + ); + expect(f.sessions.get(sessionId(1))?.active).toBe(true); + expect(f.sessions.size).toBe(2); + expect(f.retired).toEqual([]); + expect([...f.directories]).toEqual([directory, otherDirectory]); + }); + + test.each([ + '/workspace', + '/workspace/owner/worktrees/../other', + `${directory}/`, + `${directory}/nested`, + '/tmp/worktrees/' + worktreeId, + ])('rejects noncanonical cleanup path %s', async unsafe => { + const input = { worktreeId, directory: unsafe, sessionIds: [] }; + expect(() => + validateWorktreeDirectory({ worktreeId, directory: unsafe, sessionIds: [] }) + ).toThrow('Invalid worktree directory'); + await rejects(prepareWorktreeDeletion(input, {}), /Invalid worktree directory/); + }); + + test.each([true, false])( + 'waits for an in-flight directory operation and permanently fences later operations for that directory only: live=%s', + async live => { + const f = fixture(); + const deps = { ...f.deps, client: live ? f.client : undefined }; + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const inflight = runDirectoryOperation(directory, async () => { + started.resolve(); + await release.promise; + }); + await started.promise; + let prepared = false; + const cleanup = prepareWorktreeDeletion( + { worktreeId, directory, sessionIds: [sessionId(0)] }, + deps + ).then(ids => { + prepared = true; + return ids; + }); + await Promise.resolve(); + expect(prepared).toBe(false); + await rejects( + runDirectoryOperation(directory, async () => undefined), + /worktree_deleting/ + ); + expect(await runDirectoryOperation(otherDirectory, async () => 'preserved')).toBe( + 'preserved' + ); + release.resolve(); + await inflight; + const ids = await cleanup; + await deleteWorktree({ worktreeId, directory, sessionIds: ids }, deps); + expect(f.directories.has(directory)).toBe(false); + await rejects( + runDirectoryOperation(directory, async () => undefined), + /worktree_deleting/ + ); + } + ); +}); diff --git a/services/cloud-agent-next/wrapper/src/control/delete-worktree.ts b/services/cloud-agent-next/wrapper/src/control/delete-worktree.ts new file mode 100644 index 0000000000..eb008e1846 --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/control/delete-worktree.ts @@ -0,0 +1,228 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { createKiloClient } from '@kilocode/sdk/v2'; +import { z } from 'zod'; +import { + worktreeDeletePayloadSchema, + type WorktreeDeletePayload, + type WorktreeDeleteResult, +} from '../../../src/shared/sandbox-control-protocol'; +import { fenceDirectoryOperations } from './worktree-operations'; +import { directoryForSession, forgetAttachedRoot } from './session-directories'; + +const sessionSchema = z.object({ + id: z.string().startsWith('ses_').length(30), + directory: z.string(), +}); + +type CleanupSession = z.infer; + +export type WorktreeKiloCleanupClient = { + listSessionIds(directory: string): Promise; + getSession(directory: string, sessionId: string): Promise; + children(directory: string, sessionId: string): Promise; + abortSession(directory: string, sessionId: string): Promise; + stopSessionProcesses(directory: string, sessionId: string): Promise; + deleteSession(directory: string, sessionId: string): Promise; + closeTerminals(directory: string): Promise; + disposeDirectory(directory: string): Promise; +}; + +function requireData(result: { data?: T; error?: unknown; response: Response }): T { + if (!result.response.ok || result.error !== undefined || result.data === undefined) { + throw new Error('Kilo worktree cleanup was not confirmed'); + } + return result.data; +} + +function requireTrue(result: { data?: boolean; error?: unknown; response: Response }): void { + if (requireData(result) !== true) throw new Error('Kilo worktree cleanup was not confirmed'); +} + +export function createWorktreeKiloCleanupClient(serverUrl: string): WorktreeKiloCleanupClient { + const client = createKiloClient({ baseUrl: serverUrl }); + const options = () => ({ signal: AbortSignal.timeout(60_000) }); + return { + async listSessionIds(directory) { + const ids = new Set(); + const cursors = new Set(); + let cursor: string | undefined; + do { + const page = requireData( + await client.v2.session.list({ directory, limit: 100, cursor }, options()) + ); + for (const session of page.data) ids.add(session.id); + cursor = page.cursor.next; + if (cursor && cursors.has(cursor)) + throw new Error('Kilo session pagination did not advance'); + if (cursor) cursors.add(cursor); + } while (cursor); + return [...ids]; + }, + async getSession(directory, sessionId) { + const result = await client.session.get({ sessionID: sessionId, directory }, options()); + if (result.response.status === 404) return null; + return sessionSchema.parse(requireData(result)); + }, + async children(directory, sessionId) { + return z + .array(sessionSchema) + .parse( + requireData(await client.session.children({ sessionID: sessionId, directory }, options())) + ); + }, + async abortSession(directory, sessionId) { + const result = await client.session.abort({ sessionID: sessionId, directory }, options()); + if (result.response.status !== 404) requireTrue(result); + }, + async stopSessionProcesses(directory, sessionId) { + requireTrue( + await client.backgroundProcess.stopSession({ sessionID: sessionId, directory }, options()) + ); + }, + async deleteSession(directory, sessionId) { + const result = await client.session.delete({ sessionID: sessionId, directory }, options()); + if (result.response.status !== 404) requireTrue(result); + }, + async closeTerminals(directory) { + const terminals = requireData( + await client.interactiveTerminal.list({ directory }, options()) + ); + for (const terminal of terminals) { + requireTrue( + await client.interactiveTerminal.close( + { terminalID: terminal.info.id, directory }, + options() + ) + ); + } + const ptys = requireData(await client.pty.list({ directory }, options())); + for (const pty of ptys) { + requireTrue(await client.pty.remove({ ptyID: pty.id, directory }, options())); + } + if ( + requireData(await client.pty.list({ directory }, options())).length > 0 || + requireData(await client.interactiveTerminal.list({ directory }, options())).some( + terminal => terminal.info.status === 'running' + ) + ) { + throw new Error('Kilo terminal cleanup was not confirmed'); + } + }, + async disposeDirectory(directory) { + requireTrue(await client.instance.dispose({ directory }, options())); + }, + }; +} + +export function validateWorktreeDirectory(input: WorktreeDeletePayload): void { + const segments = input.directory.split('/'); + if ( + path.resolve(input.directory) !== input.directory || + segments[0] !== '' || + segments[1] !== 'workspace' || + (segments.length !== 5 && segments.length !== 6) || + segments.at(-2) !== 'worktrees' || + segments.at(-1) !== input.worktreeId || + segments + .slice(2, -2) + .some(segment => !/^[a-zA-Z0-9_.-]+$/.test(segment) || segment === '.' || segment === '..') || + (segments.length === 6 && !z.uuid().safeParse(segments[2]).success) + ) { + throw new Error('Invalid worktree directory'); + } +} + +async function assertNoSymlinks(directory: string): Promise { + let current = '/'; + for (const segment of directory.split('/').filter(Boolean)) { + current = path.join(current, segment); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink() || !stat.isDirectory()) + throw new Error('Invalid worktree directory'); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return; + throw error; + } + } +} + +export type WorktreeCleanupDeps = { + client?: WorktreeKiloCleanupClient; + assertDirectory?: (directory: string) => Promise; + retireDirectory?: (directory: string) => Promise; + removeDirectory?: (directory: string) => Promise; + detachRoot?: (sessionId: string) => void; + detachTerminals?: (directory: string) => Promise; +}; + +export async function prepareWorktreeDeletion( + raw: unknown, + deps: WorktreeCleanupDeps +): Promise { + const input = worktreeDeletePayloadSchema.parse(raw); + validateWorktreeDirectory(input); + await fenceDirectoryOperations(input.directory); + await (deps.assertDirectory ?? assertNoSymlinks)(input.directory); + const { client } = deps; + const sessionIds = new Set([ + ...input.sessionIds, + ...(client ? await client.listSessionIds(input.directory) : []), + ]); + for (const sessionId of sessionIds) { + const rememberedDirectory = directoryForSession(sessionId); + if (rememberedDirectory && rememberedDirectory !== input.directory) + throw new Error('Worktree session directory conflict'); + if (!client) continue; + const session = await client.getSession(input.directory, sessionId); + if (session && session.directory !== input.directory) + throw new Error('Worktree session directory conflict'); + await client.abortSession(input.directory, sessionId); + if (!session) continue; + for (const child of await client.children(input.directory, sessionId)) { + if (child.directory !== input.directory) throw new Error('Worktree child directory conflict'); + sessionIds.add(child.id); + } + } + return [...sessionIds]; +} + +export async function deleteWorktree( + raw: unknown, + deps: WorktreeCleanupDeps +): Promise { + const input = worktreeDeletePayloadSchema.parse(raw); + const sessionIds = await prepareWorktreeDeletion(input, deps); + const journaled = new Set(input.sessionIds); + if (sessionIds.some(id => !journaled.has(id))) + throw new Error('Worktree cleanup manifest changed'); + const { client } = deps; + if (client) { + for (const sessionId of sessionIds) { + await client.stopSessionProcesses(input.directory, sessionId); + } + } + await deps.detachTerminals?.(input.directory); + if (client) { + await client.closeTerminals(input.directory); + for (const sessionId of [...sessionIds].reverse()) { + await client.deleteSession(input.directory, sessionId); + } + for (const sessionId of sessionIds) { + if (await client.getSession(input.directory, sessionId)) + throw new Error('Kilo session deletion was not confirmed'); + } + await client.disposeDirectory(input.directory); + } + await deps.retireDirectory?.(input.directory); + await (deps.assertDirectory ?? assertNoSymlinks)(input.directory); + await (deps.removeDirectory ?? (directory => fs.rm(directory, { recursive: true, force: true })))( + input.directory + ); + for (const sessionId of sessionIds) { + forgetAttachedRoot(sessionId, input.directory); + deps.detachRoot?.(sessionId); + } + return { deleted: true, sessionIds }; +} diff --git a/services/cloud-agent-next/wrapper/src/control/feed.test.ts b/services/cloud-agent-next/wrapper/src/control/feed.test.ts index 842d66ec94..eaabac29ef 100644 --- a/services/cloud-agent-next/wrapper/src/control/feed.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/feed.test.ts @@ -8,9 +8,12 @@ import { } from './feed'; import type { HandlerSessionSnapshot } from './sandbox-control-handlers'; import { + directoryForSession, + forgetAttachedRoot, rememberAttachedRoot, rememberChildSession, resetSessionDirectoryState, + rootForSession, } from './session-directories'; beforeEach(() => { @@ -149,6 +152,89 @@ describe('session activity snapshots', () => { expect(sessions).toEqual([{ kiloSessionId: 'root', lastActivityAt: 0 }]); }); + it('isolates shared-worktree pending questions and ignores unknown or detached sessions', () => { + rememberAttachedRoot('root_a', '/shared'); + rememberAttachedRoot('root_b', '/shared'); + rememberChildSession({ childId: 'child_b', parentId: 'root_b' }); + const sessions: HandlerSessionSnapshot[] = [ + { kiloSessionId: 'root_a', lastActivityAt: 0 }, + { kiloSessionId: 'root_b', lastActivityAt: 0 }, + ]; + for (const [sessionID, id] of [ + ['root_a', 'question_a'], + ['child_b', 'question_b'], + ]) { + updateSessionSnapshots( + { type: 'question.asked', directory: '/shared', properties: { sessionID, id } }, + sessions, + 1 + ); + } + updateSessionSnapshots( + { + type: 'question.rejected', + directory: '/shared', + properties: { sessionID: 'child_b', requestID: 'question_b' }, + }, + sessions, + 2 + ); + expect(sessions).toEqual([ + { kiloSessionId: 'root_a', lastActivityAt: 1, pendingInputs: new Set(['question_a']) }, + { kiloSessionId: 'root_b', lastActivityAt: 2 }, + ]); + + forgetAttachedRoot('root_b'); + for (const sessionID of ['unknown', 'root_b', 'child_b']) { + updateSessionSnapshots( + { type: 'question.asked', directory: '/shared', properties: { sessionID, id: 'late' } }, + sessions, + 3 + ); + } + expect(sessions).toEqual([ + { kiloSessionId: 'root_a', lastActivityAt: 1, pendingInputs: new Set(['question_a']) }, + { kiloSessionId: 'root_b', lastActivityAt: 2 }, + ]); + }); + + it('records cross-directory child activity but rejects events from another root directory', () => { + rememberAttachedRoot('root_a', '/a'); + rememberAttachedRoot('root_b', '/b'); + rememberChildSession({ childId: 'child', parentId: 'root_a', directory: '/child' }); + const sessions: HandlerSessionSnapshot[] = [ + { kiloSessionId: 'root_a', lastActivityAt: 0 }, + { kiloSessionId: 'root_b', lastActivityAt: 0 }, + ]; + updateSessionSnapshots( + { type: 'question.asked', directory: '/child', properties: { sessionID: 'child', id: 'q' } }, + sessions, + 1 + ); + updateSessionSnapshots( + { + type: 'question.replied', + directory: '/b', + properties: { sessionID: 'child', requestID: 'q' }, + }, + sessions, + 2 + ); + updateSessionSnapshots( + { + type: 'session.status', + directory: '/b', + properties: { sessionID: 'root_a', status: { type: 'busy' } }, + }, + sessions, + 3 + ); + expect(sessions).toEqual([ + { kiloSessionId: 'root_a', lastActivityAt: 1, pendingInputs: new Set(['q']) }, + { kiloSessionId: 'root_b', lastActivityAt: 0 }, + ]); + }); + it('tracks attached roots independently', () => { rememberAttachedRoot('root-a', '/a'); rememberAttachedRoot('root-b', '/b'); @@ -173,7 +259,374 @@ describe('session activity snapshots', () => { }); }); +describe('source runtime identity', () => { + it.each(['session.created', 'session.updated'])( + 'rejects %s and activity from a child directory subsequently claimed by an independent root', + type => { + rememberAttachedRoot('root_a', '/a'); + rememberChildSession({ childId: 'child_a', parentId: 'root_a', directory: '/child-dir' }); + expect(sessionEventIdentity({ sessionId: 'child_a', runtimeDirectory: '/a' })).toEqual({ + directory: '/child-dir', + kiloSessionId: 'child_a', + rootKiloSessionId: 'root_a', + }); + rememberAttachedRoot('root_b', '/child-dir'); + const sessions: HandlerSessionSnapshot[] = [ + { kiloSessionId: 'root_a', lastActivityAt: 0 }, + { kiloSessionId: 'root_b', lastActivityAt: 0 }, + ]; + const events = [ + { + type, + directory: '/child-dir', + properties: { info: { id: 'child_a', parentID: 'root_a', directory: '/child-dir' } }, + }, + { + type, + directory: '/another-external', + properties: { + info: { id: 'late_child', parentID: 'child_a', directory: '/another-external' }, + }, + }, + { + type: 'question.asked', + directory: '/child-dir', + properties: { sessionID: 'child_a', id: 'question_a' }, + }, + { type: 'session.idle', properties: { sessionID: 'child_a' } }, + ]; + for (const event of events) { + for (const runtimeDirectory of ['/a', '/child-dir']) { + expect( + sessionEventIdentity({ + ...event, + sessionId: eventKiloSessionId(event.properties), + runtimeDirectory, + }) + ).toBeUndefined(); + } + updateSessionSnapshots(event, sessions, 1); + } + expect(sessions).toEqual([ + { kiloSessionId: 'root_a', lastActivityAt: 0 }, + { kiloSessionId: 'root_b', lastActivityAt: 0 }, + ]); + expect(rootForSession('late_child')).toBeUndefined(); + expect(directoryForSession('late_child')).toBeUndefined(); + expect(sessionEventIdentity({ sessionId: 'root_b', runtimeDirectory: '/child-dir' })).toEqual( + { + directory: '/child-dir', + kiloSessionId: 'root_b', + rootKiloSessionId: 'root_b', + } + ); + } + ); + + it.each([ + { source: '/a', target: '/b', targetRoot: 'root_b' }, + { source: '/b', target: '/a', targetRoot: 'root_a' }, + ])( + 'prevents $source from mutating $target lineage or snapshots', + ({ source, target, targetRoot }) => { + rememberAttachedRoot('root_a', '/a'); + rememberAttachedRoot('root_b', '/b'); + rememberChildSession({ + childId: 'known_child', + parentId: targetRoot, + directory: '/descendant', + }); + const sessions: HandlerSessionSnapshot[] = [ + { kiloSessionId: 'root_a', lastActivityAt: 0, pendingInputs: new Set(['question_a']) }, + { kiloSessionId: 'root_b', lastActivityAt: 0, pendingInputs: new Set(['question_b']) }, + ]; + const before = structuredClone(sessions); + const events = [ + ...['session.created', 'session.updated'].flatMap(type => + [target, '/new-descendant', undefined].map(directory => ({ + type, + directory, + properties: { info: { id: 'spoof', parentID: targetRoot, directory } }, + })) + ), + { + type: 'question.asked', + directory: target, + properties: { sessionID: targetRoot, id: 'spoof' }, + }, + { + type: 'question.replied', + directory: target, + properties: { + sessionID: targetRoot, + requestID: targetRoot === 'root_a' ? 'question_a' : 'question_b', + }, + }, + { + type: 'question.asked', + directory: '/descendant', + properties: { sessionID: 'known_child', id: 'spoof' }, + }, + { type: 'session.idle', properties: { sessionID: targetRoot } }, + { type: 'session.status', directory: target, properties: {} }, + ]; + for (const event of events) { + const identity = sessionEventIdentity({ + ...event, + sessionId: eventKiloSessionId(event.properties), + runtimeDirectory: source, + }); + if (identity) updateSessionSnapshots(event, sessions, 1); + expect(identity).toBeUndefined(); + expect(rootForSession('spoof')).toBeUndefined(); + expect(directoryForSession('spoof')).toBeUndefined(); + expect(rootForSession('known_child')).toBe(targetRoot); + expect(directoryForSession('known_child')).toBe('/descendant'); + expect(sessions).toEqual(before); + } + } + ); + + it.each(['session.created', 'session.updated'])( + 'accepts %s for a registered pending root before feed startup and snapshot creation', + type => { + rememberAttachedRoot('pending_root', '/pending'); + const sessions: HandlerSessionSnapshot[] = []; + const event = { + type, + directory: '/pending', + properties: { info: { id: 'pending_root', directory: '/pending' } }, + }; + const identity = sessionEventIdentity({ + ...event, + sessionId: eventKiloSessionId(event.properties), + runtimeDirectory: '/pending', + }); + expect(identity).toEqual({ + directory: '/pending', + kiloSessionId: 'pending_root', + rootKiloSessionId: 'pending_root', + }); + if (identity) updateSessionSnapshots(event, sessions, 1); + expect(sessions).toEqual([]); + } + ); + + it('accepts source-owned descendants in other directories and updates only their root snapshot', () => { + rememberAttachedRoot('root_a', '/a'); + rememberAttachedRoot('root_b', '/b'); + const sessions: HandlerSessionSnapshot[] = [ + { kiloSessionId: 'root_a', lastActivityAt: 0 }, + { kiloSessionId: 'root_b', lastActivityAt: 0 }, + ]; + for (const [root, source] of [ + ['root_a', '/a'], + ['root_b', '/b'], + ]) { + const childId = `child_${root}`; + const directory = `${source}/descendant`; + const created = { + type: 'session.created', + directory, + properties: { info: { id: childId, parentID: root, directory } }, + }; + const identity = sessionEventIdentity({ + ...created, + sessionId: eventKiloSessionId(created.properties), + runtimeDirectory: source, + }); + expect(identity).toEqual({ directory, kiloSessionId: childId, rootKiloSessionId: root }); + expect(rootForSession(childId)).toBe(root); + const nestedId = `nested_${root}`; + expect( + sessionEventIdentity({ + type: 'session.updated', + sessionId: nestedId, + properties: { info: { id: nestedId, parentID: childId } }, + runtimeDirectory: source, + }) + ).toEqual({ directory, kiloSessionId: nestedId, rootKiloSessionId: root }); + + const event = { + type: 'question.asked', + properties: { sessionID: nestedId, id: `question_${root}` }, + }; + const questionIdentity = sessionEventIdentity({ + ...event, + sessionId: eventKiloSessionId(event.properties), + runtimeDirectory: source, + }); + expect(questionIdentity).toEqual({ + directory, + kiloSessionId: nestedId, + rootKiloSessionId: root, + }); + if (questionIdentity) updateSessionSnapshots(event, sessions, 1); + expect( + sessionEventIdentity({ sessionId: nestedId, directory, runtimeDirectory: directory }) + ).toBeUndefined(); + } + expect(sessions).toEqual([ + { kiloSessionId: 'root_a', lastActivityAt: 1, pendingInputs: new Set(['question_root_a']) }, + { kiloSessionId: 'root_b', lastActivityAt: 1, pendingInputs: new Set(['question_root_b']) }, + ]); + }); + + it('keeps sibling roots in a shared source runtime distinct', () => { + rememberAttachedRoot('first', '/shared'); + rememberChildSession({ childId: 'first_child', parentId: 'first' }); + rememberAttachedRoot('second', '/shared'); + expect(sessionEventIdentity({ sessionId: 'first_child', runtimeDirectory: '/shared' })).toEqual( + { + directory: '/shared', + kiloSessionId: 'first_child', + rootKiloSessionId: 'first', + } + ); + for (const root of ['first', 'second']) { + expect(sessionEventIdentity({ sessionId: root, runtimeDirectory: '/shared' })).toEqual({ + directory: '/shared', + kiloSessionId: root, + rootKiloSessionId: root, + }); + } + expect( + sessionEventIdentity({ directory: '/shared', runtimeDirectory: '/shared' }) + ).toBeUndefined(); + }); +}); + describe('session event identity', () => { + it.each(['session.created', 'session.updated'])( + 'routes a never-run child from %s metadata without inferring from a shared directory', + type => { + rememberAttachedRoot('root_a', '/shared'); + rememberAttachedRoot('root_b', '/shared'); + expect( + sessionEventIdentity({ + type, + properties: { info: { id: 'child', parentID: 'root_a', directory: '/shared' } }, + sessionId: 'child', + directory: '/shared', + }) + ).toEqual({ directory: '/shared', kiloSessionId: 'child', rootKiloSessionId: 'root_a' }); + } + ); + + it('rejects unknown parents and other roots directories without caching rejected lineage', () => { + rememberAttachedRoot('root_a', '/a'); + rememberAttachedRoot('root_b', '/b'); + for (const parentID of ['unknown', 'root_b']) { + expect( + sessionEventIdentity({ + type: 'session.created', + properties: { info: { id: 'spoof', parentID, directory: '/a' } }, + sessionId: 'spoof', + directory: '/a', + }) + ).toBeUndefined(); + expect(rootForSession('spoof')).toBeUndefined(); + expect(directoryForSession('spoof')).toBeUndefined(); + } + }); + + it.each(['session.created', 'session.updated'])( + 'rejects conflicting envelope and %s metadata before remembering a child', + type => { + rememberAttachedRoot('root_a', '/a'); + rememberAttachedRoot('root_b', '/b'); + expect( + sessionEventIdentity({ + type, + properties: { info: { id: 'spoof', parentID: 'root_a', directory: '/a' } }, + sessionId: 'spoof', + directory: '/b', + }) + ).toBeUndefined(); + expect(rootForSession('spoof')).toBeUndefined(); + expect(directoryForSession('spoof')).toBeUndefined(); + } + ); + + it('preserves known-lineage cross-directory descendants and their terminal events', () => { + rememberAttachedRoot('root_a', '/shared'); + rememberAttachedRoot('root_b', '/shared'); + const identity = { + directory: '/child-worktree', + kiloSessionId: 'child_b', + rootKiloSessionId: 'root_b', + }; + expect( + sessionEventIdentity({ + type: 'session.updated', + sessionId: 'child_b', + properties: { info: { id: 'child_b', parentID: 'root_b', directory: '/child-worktree' } }, + }) + ).toEqual(identity); + expect( + sessionEventIdentity({ + type: 'session.created', + sessionId: 'nested_b', + properties: { info: { id: 'nested_b', parentID: 'child_b' } }, + }) + ).toEqual({ ...identity, kiloSessionId: 'nested_b' }); + + for (const type of ['session.idle', 'session.turn.close', 'session.error']) { + const properties = { sessionID: 'child_b', messageID: 'msg_child' }; + expect( + sessionEventIdentity({ + type, + properties, + sessionId: eventKiloSessionId(properties), + directory: '/child-worktree', + }) + ).toEqual(identity); + } + expect(sessionEventIdentity({ sessionId: 'child_b', directory: '/shared' })).toBeUndefined(); + }); + + it('drops detached root and child events instead of assigning them to the surviving sibling', () => { + rememberAttachedRoot('root_a', '/shared'); + rememberAttachedRoot('root_b', '/shared'); + rememberChildSession({ childId: 'child_b', parentId: 'root_b', directory: '/shared' }); + forgetAttachedRoot('root_b'); + + expect(sessionEventIdentity({ sessionId: 'root_b', directory: '/shared' })).toBeUndefined(); + expect(sessionEventIdentity({ sessionId: 'child_b', directory: '/shared' })).toBeUndefined(); + expect( + sessionEventIdentity({ + type: 'session.updated', + sessionId: 'late_child', + directory: '/shared', + properties: { info: { id: 'late_child', parentID: 'root_b' } }, + }) + ).toBeUndefined(); + expect(sessionEventIdentity({ sessionId: 'root_a', directory: '/shared' })).toEqual({ + directory: '/shared', + kiloSessionId: 'root_a', + rootKiloSessionId: 'root_a', + }); + }); + + it('rejects attempts to reparent roots or children and mismatched child IDs', () => { + rememberAttachedRoot('root_a', '/shared'); + rememberAttachedRoot('root_b', '/shared'); + rememberChildSession({ childId: 'child', parentId: 'root_a' }); + for (const id of ['root_a', 'child', 'different']) { + expect( + sessionEventIdentity({ + type: 'session.created', + sessionId: id === 'different' ? 'spoof' : id, + directory: '/shared', + properties: { info: { id, parentID: 'root_b' } }, + }) + ).toBeUndefined(); + } + expect(rootForSession('root_a')).toBe('root_a'); + expect(rootForSession('child')).toBe('root_a'); + expect(rootForSession('different')).toBeUndefined(); + }); + it('reads child lineage from session.created and stamps the attached root', () => { rememberAttachedRoot('root', '/ws'); const child = childFromSessionCreated({ @@ -192,6 +645,8 @@ describe('session event identity', () => { expect( childFromSessionCreated({ info: { id: 'child', parentID: 'root', directory: '/ws' } }) ).toEqual({ childId: 'child', parentId: 'root', directory: '/ws' }); + expect(childFromSessionCreated({ info: { id: 'root', directory: '/ws' } })).toBeUndefined(); + expect(childFromSessionCreated({ info: { id: 'root', parentID: null } })).toBeUndefined(); expect(childFromSessionCreated({ info: {} })).toBeUndefined(); }); @@ -208,10 +663,7 @@ describe('session event identity', () => { it('does not attribute an unknown session to another root in the same directory', () => { rememberAttachedRoot('root', '/ws'); - expect(sessionEventIdentity({ sessionId: 'unknown', directory: '/ws' })).toEqual({ - directory: '/ws', - kiloSessionId: 'unknown', - }); + expect(sessionEventIdentity({ sessionId: 'unknown', directory: '/ws' })).toBeUndefined(); }); it('keeps same-worktree root and child events distinct without guessing a directory-only owner', () => { @@ -238,7 +690,7 @@ describe('session event identity', () => { kiloSessionId: 'child', rootKiloSessionId: 'first', }); - expect(sessionEventIdentity({ directory: '/ws' })).toEqual({ directory: '/ws' }); + expect(sessionEventIdentity({ directory: '/ws' })).toBeUndefined(); }); it('returns no identity when neither session nor directory is known', () => { diff --git a/services/cloud-agent-next/wrapper/src/control/feed.ts b/services/cloud-agent-next/wrapper/src/control/feed.ts index 372128e345..a2bba4b928 100644 --- a/services/cloud-agent-next/wrapper/src/control/feed.ts +++ b/services/cloud-agent-next/wrapper/src/control/feed.ts @@ -1,5 +1,5 @@ import type { HandlerSessionSnapshot } from './sandbox-control-handlers'; -import { directoryForSession, rootForSession } from './session-directories'; +import { directoryForSession, rememberChildSession, rootForSession } from './session-directories'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; @@ -39,14 +39,40 @@ export function childFromSessionCreated( export function sessionEventIdentity(input: { sessionId?: string; directory?: string; + runtimeDirectory?: string; + type?: string; + properties?: Record; }): { directory: string; kiloSessionId?: string; rootKiloSessionId?: string } | undefined { - const directory = input.directory ?? directoryForSession(input.sessionId); + let directory = input.directory; + if ((input.type === 'session.created' || input.type === 'session.updated') && input.properties) { + const info = input.properties.info; + if (isRecord(info) && typeof info.directory === 'string') { + if (directory !== undefined && directory !== info.directory) return undefined; + directory = info.directory; + } + const child = childFromSessionCreated(input.properties); + if (child) { + if (child.childId !== input.sessionId) return undefined; + const parentRoot = rootForSession(child.parentId); + if (!parentRoot) return undefined; + if ( + input.runtimeDirectory !== undefined && + directoryForSession(parentRoot) !== input.runtimeDirectory + ) + return undefined; + rememberChildSession({ ...child, directory }); + if (rootForSession(child.childId) !== parentRoot) return undefined; + } + } + directory ??= directoryForSession(input.sessionId); const root = rootForSession(input.sessionId, directory); - if (!directory) return undefined; + if (!directory || !root) return undefined; + if (input.runtimeDirectory !== undefined && directoryForSession(root) !== input.runtimeDirectory) + return undefined; return { directory, ...(input.sessionId ? { kiloSessionId: input.sessionId } : {}), - ...(root ? { rootKiloSessionId: root } : {}), + rootKiloSessionId: root, }; } @@ -56,8 +82,9 @@ export function updateSessionSnapshots( now = Date.now() ): void { const sessionId = eventKiloSessionId(event.properties); - const kiloSessionId = rootForSession(sessionId, event.directory); - if (!kiloSessionId || !sessionId) return; + if (!sessionId) return; + const kiloSessionId = sessionEventIdentity({ ...event, sessionId })?.rootKiloSessionId; + if (!kiloSessionId) return; const snapshot = sessions.find(session => session.kiloSessionId === kiloSessionId); if (!snapshot) return; snapshot.lastActivityAt = now; diff --git a/services/cloud-agent-next/wrapper/src/control/main.ts b/services/cloud-agent-next/wrapper/src/control/main.ts index 66605aabd9..f8eea20eee 100644 --- a/services/cloud-agent-next/wrapper/src/control/main.ts +++ b/services/cloud-agent-next/wrapper/src/control/main.ts @@ -7,16 +7,12 @@ import { import { buildHeartbeatPayload, cancelControlTasks, + createSessionActivityRegistry, + refreshHeartbeatPayload, handleControlRequest, type HandlerDeps, } from './sandbox-control-handlers'; -import { - childFromSessionCreated, - eventKiloSessionId, - sessionEventIdentity, - updateSessionSnapshots, -} from './feed'; -import { rememberChildSession } from './session-directories'; +import { eventKiloSessionId, sessionEventIdentity, updateSessionSnapshots } from './feed'; import { createControlTerminalRuntime } from './terminal-runtime'; import { createWorktreeKiloRuntimes } from './worktree-runtime'; @@ -34,17 +30,20 @@ function main(): void { let control: ReturnType = null; let shuttingDown = false; const kiloRuntimes = createWorktreeKiloRuntimes({ - onEvent: (_runtime, event) => { - if (event.type === 'session.created') { - const child = childFromSessionCreated(event.properties); - if (child) rememberChildSession(child); - } - updateSessionSnapshots(event, deps.sessions); + onEvent: (runtime, event) => { const identity = sessionEventIdentity({ + ...event, sessionId: eventKiloSessionId(event.properties), - directory: event.directory, + runtimeDirectory: runtime.directory, }); if (!identity?.rootKiloSessionId) return; + updateSessionSnapshots(event, deps.sessions); + deps.activity?.observeEvent( + event.type, + identity.kiloSessionId, + identity.rootKiloSessionId, + event.properties + ); if ( !control?.sendEvent?.( 'session.event', @@ -72,6 +71,7 @@ function main(): void { }, sessions: [], tasks: new Map(), + activity: createSessionActivityRegistry(), signal: abort.signal, ...(terminalRuntime ? { terminalRuntime } : {}), emitSessionEvent: (session, payload) => { @@ -131,7 +131,7 @@ function main(): void { } }, }), - getHeartbeatPayload: () => buildHeartbeatPayload(deps), + getHeartbeatPayload: () => refreshHeartbeatPayload(deps), }); logToFile(`control-plane wrapper ready callHome=${Boolean(control)}`); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts index f3e0d43c48..e9d3a30324 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, setSystemTime, spyOn } from 'bun:test'; import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createKiloClient } from '@kilocode/sdk'; @@ -7,6 +8,7 @@ import { SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, SANDBOX_CONTROL_EXECUTION_TIMEOUT_MS, SANDBOX_CONTROL_REQUEST_TIMEOUT_MS, + sessionSyncResultSchema, type SessionEventPayload, } from '../../../src/shared/sandbox-control-protocol'; import { createWrapperKiloClient, type WrapperKiloClient, type WrapperPty } from '../kilo-api'; @@ -23,9 +25,16 @@ import { CONTROL_RUNTIME_RESERVED_ENV_VARS } from '../../../src/shared/runtime-e import { buildHeartbeatPayload, cancelControlTasks, + createSessionActivityRegistry, handleControlRequest, + refreshHeartbeatPayload, type HandlerDeps, } from './sandbox-control-handlers'; +import { + assertDirectoryActive, + fenceDirectoryOperations, + resetDirectoryOperationState, +} from './worktree-operations'; import { KILO_CONTROL_REQUEST_TIMEOUT_MS } from './sandbox-control-runtime'; import { ControlTerminalRuntimeError, @@ -123,7 +132,7 @@ function deps( : undefined; return { kiloRuntimes: runtime - ? { + ? ({ attach: () => ({ ready: Promise.resolve(runtime), signal: runtime.signal, @@ -131,10 +140,11 @@ function deps( release: () => {}, }), detach: () => true, + deleteDirectory: async () => {}, get: directory => (directory === runtime.directory ? runtime : undefined), isHealthy: () => true, shutdown: () => {}, - } + } as NonNullable) : undefined, version: '2.4.0', kiloReady: true, @@ -183,6 +193,7 @@ const promptPayload = { beforeEach(() => { resetSessionDirectoryState(); + resetDirectoryOperationState(); rememberAttachedRoot(session.kiloSessionId, session.directory); homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'control-handlers-test-')); }); @@ -208,6 +219,7 @@ function fakeTerminalRuntime( return { rememberAttachedSession: () => {}, detachSession: async () => {}, + detachDirectory: async () => {}, create: async () => ({ pty }), resize: async () => ({ pty }), close: async () => ({ success: true }), @@ -447,7 +459,9 @@ describe('handleControlRequest', () => { expect(attached).toEqual([]); }); - it('rejects attachment directories that do not match the request identity', async () => { + it('rejects mismatched attachment directories before registering a chat or terminal', async () => { + resetSessionDirectoryState(); + const activity = createSessionActivityRegistry(); const attached: unknown[] = []; const terminalRuntime = fakeTerminalRuntime({ rememberAttachedSession: identity => attached.push(identity), @@ -457,7 +471,7 @@ describe('handleControlRequest', () => { 'session.attach', session, { kilo, directory: '/workspace/different' }, - deps({ terminalRuntime }) + deps({ terminalRuntime, activity }) ); expect(result).toEqual({ @@ -465,6 +479,98 @@ describe('handleControlRequest', () => { error: { code: 'protocol_error', message: 'Attachment directory mismatch', retryable: false }, }); expect(attached).toEqual([]); + expect(activity.snapshots()).toEqual([]); + expect(rootForSession(session.kiloSessionId)).toBeUndefined(); + }); + + it('registers each successful attach as an independently idle root', async () => { + let now = 100; + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const activity = createSessionActivityRegistry(() => now); + const handlerDeps = deps({ activity }); + + expect(await handleControlRequest('session.attach', session, { kilo }, handlerDeps)).toEqual({ + ok: true, + result: { attached: true }, + }); + now = 150; + expect(await handleControlRequest('session.attach', sibling, { kilo }, handlerDeps)).toEqual({ + ok: true, + result: { attached: true }, + }); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'kilo_1', state: 'idle', idleForMs: 50 }, + { kiloSessionId: 'kilo_2', state: 'idle', idleForMs: 0 }, + ]); + }); + + it('does not register failed attachments as advisory roots', async () => { + resetSessionDirectoryState(); + const activity = createSessionActivityRegistry(() => 100); + const handlerDeps = deps({ + activity, + applyAttach: async () => { + throw new Error('Session restoration failed'); + }, + }); + + expect( + await handleControlRequest('session.attach', session, { kilo }, handlerDeps) + ).toMatchObject({ ok: false }); + expect(activity.snapshots()).toEqual([]); + expect(rootForSession(session.kiloSessionId)).toBeUndefined(); + expect(handlerDeps.tasks.size).toBe(0); + }); + + it('marks only accepted root work active and leaves rejected sibling prompts idle', async () => { + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const finished = Promise.withResolvers(); + const events: SessionEventPayload[] = []; + const activity = createSessionActivityRegistry(() => 100); + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ sendPrompt: () => finished.promise }), + emitSessionEvent: (_identity, event) => events.push(event), + }); + await handleControlRequest('session.attach', session, { kilo }, handlerDeps); + await handleControlRequest('session.attach', sibling, { kilo }, handlerDeps); + try { + expect( + await handleControlRequest('session.prompt', session, promptPayload, handlerDeps) + ).toEqual({ ok: true, result: { messageId: 'msg_1', status: 'accepted' } }); + expect( + await handleControlRequest( + 'session.prompt', + sibling, + { ...promptPayload, messageId: 'msg_2', agent: { mode: 'code' } }, + handlerDeps + ) + ).toEqual({ + ok: false, + error: { code: 'protocol_error', message: 'Invalid payload', retryable: false }, + }); + expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ + state: 'active', + pendingMessages: 1, + sessions: [ + { kiloSessionId: 'kilo_1', state: 'active', waitingOn: 'model' }, + { kiloSessionId: 'kilo_2', state: 'idle' }, + ], + }); + expect(events).toEqual([]); + finished.resolve(completion()); + await waitForTasks(handlerDeps); + expect(events).toEqual([ + { + type: 'session.message.outcome', + properties: { messageId: 'msg_1', status: 'completed' }, + }, + ]); + expect(buildHeartbeatPayload(handlerDeps).pendingMessages).toBe(0); + } finally { + finished.resolve(completion()); + await waitForTasks(handlerDeps); + } }); it('rejects reserved control environment values before attachment starts', async () => { @@ -634,10 +740,11 @@ describe('handleControlRequest', () => { throw new Error('Unexpected startup'); }, detach: () => true, + deleteDirectory: async () => {}, get: directory => runtimes.get(directory), isHealthy: () => true, shutdown: () => {}, - }, + } as NonNullable, }); for (const identity of [session, sibling, other]) { @@ -710,6 +817,7 @@ describe('handleControlRequest', () => { id: `question_${asking.kiloSessionId}`, sessionID: asking.kiloSessionId, questions: [], + ...(asking === child ? { rootKiloSessionId: identity.kiloSessionId } : {}), })), permissions: owned.map(asking => ({ id: `permission_${asking.kiloSessionId}`, @@ -718,6 +826,7 @@ describe('handleControlRequest', () => { patterns: [], metadata: {}, always: [], + ...(asking === child ? { rootKiloSessionId: identity.kiloSessionId } : {}), })), }, }); @@ -847,6 +956,99 @@ describe('handleControlRequest', () => { expect(calls).toHaveLength(before); }); + it('detaches only the targeted root, its child mappings, and its activity', async () => { + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const activity = createSessionActivityRegistry(() => 100); + rememberAttachedRoot(sibling.kiloSessionId, sibling.directory); + rememberChildSession({ childId: 'child_1', parentId: 'kilo_1', directory: '/workspace' }); + rememberChildSession({ childId: 'child_2', parentId: 'kilo_2', directory: '/workspace' }); + activity.attach('kilo_1'); + activity.attach('kilo_2'); + activity.markActive('kilo_1'); + + expect(await handleControlRequest('session.detach', sibling, {}, deps({ activity }))).toEqual({ + ok: true, + result: { detached: true }, + }); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 0, waitingOn: 'model' }, + ]); + expect(rootForSession('child_1', '/workspace')).toBe('kilo_1'); + expect(rootForSession('kilo_2', '/workspace')).toBeUndefined(); + expect(rootForSession('child_2', '/workspace')).toBeUndefined(); + }); + + it('preserves root lineage, sibling activity, tasks, and terminals when the detach directory does not match', async () => { + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const activity = createSessionActivityRegistry(() => 100); + rememberAttachedRoot(sibling.kiloSessionId, sibling.directory); + rememberChildSession({ childId: 'child_1', parentId: session.kiloSessionId }); + rememberChildSession({ childId: 'child_2', parentId: sibling.kiloSessionId }); + activity.attach(session.kiloSessionId); + activity.attach(sibling.kiloSessionId); + activity.markActive(session.kiloSessionId); + const finished = Promise.withResolvers(); + const started = Promise.withResolvers(); + const mutations: string[] = []; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + sendPrompt: () => { + started.resolve(); + return finished.promise; + }, + abortSession: async () => { + mutations.push('abort'); + return true; + }, + }), + terminalRuntime: fakeTerminalRuntime({ + detachSession: async () => { + mutations.push('terminal'); + }, + }), + emitSessionEvent: () => mutations.push('outcome'), + }); + const runtimes = handlerDeps.kiloRuntimes; + if (!runtimes) throw new Error('Expected worktree runtimes'); + runtimes.detach = () => { + mutations.push('runtime'); + return true; + }; + try { + await handleControlRequest('session.prompt', session, promptPayload, handlerDeps); + await started.promise; + const task = handlerDeps.tasks.get(session.kiloSessionId); + const snapshots = [...handlerDeps.sessions]; + expect( + await handleControlRequest( + 'session.detach', + { ...session, directory: '/workspace/different' }, + {}, + handlerDeps + ) + ).toEqual({ + ok: false, + error: { code: 'unauthorized', message: 'Session directory mismatch', retryable: false }, + }); + expect(rootForSession(session.kiloSessionId)).toBe(session.kiloSessionId); + expect(rootForSession('child_1')).toBe(session.kiloSessionId); + expect(rootForSession('child_2')).toBe(sibling.kiloSessionId); + expect(directoryForSession(session.kiloSessionId)).toBe(session.directory); + expect(handlerDeps.tasks.get(session.kiloSessionId)).toBe(task); + expect(task?.signal.aborted).toBe(false); + expect(handlerDeps.sessions).toEqual(snapshots); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: session.kiloSessionId, state: 'active', idleForMs: 0, waitingOn: 'model' }, + { kiloSessionId: sibling.kiloSessionId, state: 'idle', idleForMs: 0 }, + ]); + expect(mutations).toEqual([]); + } finally { + finished.resolve(completion()); + await waitForTasks(handlerDeps); + } + }); + it('detaches a root without stopping its worktree runtime or sibling roots', async () => { const sibling = { ...session, sessionId: 'ses_sibling', kiloSessionId: 'kilo_sibling' }; rememberAttachedRoot(sibling.kiloSessionId, sibling.directory); @@ -1195,110 +1397,660 @@ describe('handleControlRequest', () => { }); }); -describe('owned control execution', () => { - it('admits a command before completion and does not redispatch it after the RPC deadline', async () => { - const finished = Promise.withResolvers(); - const events: SessionEventPayload[] = []; - let submissions = 0; - const handlerDeps = deps({ - kiloClient: fakeKilo({ - sendCommand: () => { - submissions += 1; - return finished.promise; - }, - }), - emitSessionEvent: (_session, event) => events.push(event), - }); - const payload = { - ...promptPayload, - turn: { type: 'command', command: 'review', arguments: '' }, - }; - const admittedAt = Date.now(); - expect(await handleControlRequest('session.prompt', session, payload, handlerDeps)).toEqual({ - ok: true, - result: { messageId: 'msg_1', status: 'accepted' }, - }); - setSystemTime(admittedAt + SANDBOX_CONTROL_REQUEST_TIMEOUT_MS + 1); - expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ - state: 'active', - pendingMessages: 1, - }); - expect(await handleControlRequest('session.prompt', session, payload, handlerDeps)).toEqual({ - ok: true, - result: { messageId: 'msg_1', status: 'existing' }, - }); - expect( - await handleControlRequest( - 'session.prompt', - session, - { ...payload, messageId: 'msg_2' }, - handlerDeps - ) - ).toMatchObject({ ok: false }); - expect(submissions).toBe(1); - expect(events).toEqual([]); - finished.resolve(completion()); - await waitForTasks(handlerDeps); - expect(events).toEqual([ - { type: 'session.message.outcome', properties: { messageId: 'msg_1', status: 'completed' } }, - ]); - expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ state: 'idle', pendingMessages: 0 }); +describe('production worktree deletion routes', () => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const directory = `/workspace/oauth-user/worktrees/${worktreeId}`; + const siblingDirectory = + '/workspace/oauth-user/worktrees/worktree_22222222-2222-4222-8222-222222222222'; + const sessionId = (index: number) => `ses_${String(index).padStart(26, '0')}`; + const identity = (index: number, dir = directory) => ({ + sessionId: sessionId(index), + kiloSessionId: sessionId(index), + directory: dir, }); + const input = { worktreeId, directory, sessionIds: [sessionId(1)] }; - it('emits one message-scoped outcome even when completion precedes delivery of the acknowledgement', async () => { - const releaseAck = Promise.withResolvers(); - const events: SessionEventPayload[] = []; - const identities: unknown[] = []; - const handlerDeps = deps({ - emitSessionEvent: (identity, event) => { - identities.push(identity); - events.push(event); + function protectCheckout() { + const remove = spyOn(fsPromises, 'rm').mockResolvedValue(undefined); + const stat = spyOn(fsPromises, 'lstat').mockResolvedValue({ + isSymbolicLink: () => false, + isDirectory: () => true, + } as fs.Stats); + return { + remove, + restore: () => { + remove.mockRestore(); + stat.mockRestore(); }, - }); - let acknowledged = false; - const response = handleControlRequest( - 'session.prompt', - session, - promptPayload, - handlerDeps - ).then(async result => { - await releaseAck.promise; - acknowledged = true; - return result; - }); - await waitForTasks(handlerDeps); - expect(acknowledged).toBe(false); - expect(events).toEqual([ - { type: 'session.message.outcome', properties: { messageId: 'msg_1', status: 'completed' } }, + }; + } + + function cleanupServer() { + const sessions = new Map([ + [sessionId(1), { id: sessionId(1), directory, parentID: undefined as string | undefined }], + [sessionId(2), { id: sessionId(2), directory, parentID: undefined as string | undefined }], + [sessionId(3), { id: sessionId(3), directory, parentID: sessionId(2) }], ]); - expect(identities).toEqual([session]); - releaseAck.resolve(); - expect(await response).toMatchObject({ ok: true }); - expect(events).toHaveLength(1); - }); + const requests: Array<{ method: string; pathname: string; directory: string | null }> = []; + const credentialedRequests: string[] = []; + let ptyOpen = true; + let disposed = false; + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + fetch: request => { + const url = new URL(request.url); + const dir = url.searchParams.get('directory'); + requests.push({ method: request.method, pathname: url.pathname, directory: dir }); + if (request.headers.has('authorization') || request.headers.has('cookie')) { + credentialedRequests.push(url.pathname); + } + if (dir !== directory) return Response.json({}, { status: 400 }); + if (url.pathname === '/api/session') { + return Response.json({ + data: [...sessions.values()].filter(item => !item.parentID), + cursor: { previous: null, next: null }, + }); + } + const id = url.pathname.split('/')[2]; + if (url.pathname.startsWith('/session/') && id) { + if (url.pathname.endsWith('/children')) { + return Response.json([...sessions.values()].filter(item => item.parentID === id)); + } + if (url.pathname.endsWith('/abort')) return Response.json(true); + if (request.method === 'DELETE') { + sessions.delete(id); + return Response.json(true); + } + const found = sessions.get(id); + return Response.json(found ?? {}, { status: found ? 200 : 404 }); + } + if (url.pathname.startsWith('/background-process/session/')) return Response.json(true); + if (url.pathname === '/interactive-terminal') return Response.json([]); + if (url.pathname === '/pty') return Response.json(ptyOpen ? [{ id: 'pty_cleanup' }] : []); + if (url.pathname === '/pty/pty_cleanup' && request.method === 'DELETE') { + ptyOpen = false; + return Response.json(true); + } + if (url.pathname === '/instance/dispose') { + disposed = true; + return Response.json(true); + } + return Response.json({}, { status: 404 }); + }, + }); + return { + server, + sessions, + requests, + credentialedRequests, + isDisposed: () => disposed, + hasPty: () => ptyOpen, + }; + } - it('reports asynchronous submission failures after admission without leaking API diagnostics', async () => { - const failed = Promise.withResolvers(); - const events: SessionEventPayload[] = []; - const handlerDeps = deps({ - kiloClient: fakeKilo({ sendCommand: () => failed.promise }), - emitSessionEvent: (_session, event) => events.push(event), + it.each(['worktree.prepareDeletion', 'worktree.delete'] as const)( + 'validates %s payload and checkout directory before any runtime lookup or mutation', + async operation => { + const filesystem = protectCheckout(); + const calls: string[] = []; + const handlerDeps = deps({ + kiloRuntimes: { + get: dir => { + calls.push(`get:${dir}`); + return undefined; + }, + attach: () => { + calls.push('attach'); + throw new Error('Unexpected runtime startup'); + }, + detach: () => { + calls.push('detach'); + return true; + }, + deleteDirectory: async dir => { + calls.push(`delete:${dir}`); + }, + shutdown: () => { + calls.push('shutdown'); + }, + isHealthy: () => true, + }, + }); + try { + for (const payload of [ + {}, + { ...input, worktreeId: 'worktree_invalid' }, + { ...input, sessionIds: ['ses_short'] }, + { ...input, unexpected: true }, + { ...input, directory: '' }, + ]) { + expect(await handleControlRequest(operation, undefined, payload, handlerDeps)).toEqual({ + ok: false, + error: { code: 'protocol_error', message: 'Invalid payload', retryable: false }, + }); + } + for (const invalidDirectory of [ + '/workspace', + siblingDirectory, + `${directory}/..`, + directory.replace('/oauth-user/', '/oauth-user/../oauth-user/'), + `/workspace/not-a-uuid/oauth-user/worktrees/${worktreeId}`, + ]) { + expect( + await handleControlRequest( + operation, + undefined, + { ...input, directory: invalidDirectory }, + handlerDeps + ) + ).toEqual({ + ok: false, + error: { + code: 'protocol_error', + message: 'Invalid worktree directory', + retryable: false, + }, + }); + } + expect(calls).toEqual([]); + expect(filesystem.remove).not.toHaveBeenCalled(); + } finally { + filesystem.restore(); + } + } + ); + + it.each(['worktree.prepareDeletion', 'worktree.delete'] as const)( + 'refuses %s without runtime ownership before fencing or removing files', + async operation => { + const filesystem = protectCheckout(); + const handlerDeps = deps(); + handlerDeps.kiloRuntimes = undefined; + try { + expect(await handleControlRequest(operation, undefined, input, handlerDeps)).toEqual({ + ok: false, + error: { code: 'not_ready', message: 'Kilo is not ready', retryable: true }, + }); + expect(() => assertDirectoryActive(directory)).not.toThrow(); + expect(filesystem.remove).not.toHaveBeenCalled(); + } finally { + filesystem.restore(); + } + } + ); + + it('prepares through the selected runtime HTTP client, discovering lazy roots and children after only matching commands quiesce', async () => { + const filesystem = protectCheckout(); + const http = cleanupServer(); + const first = identity(1); + const second = identity(6); + const sibling = identity(4, siblingDirectory); + http.sessions.set(second.kiloSessionId, { + id: second.kiloSessionId, + directory, + parentID: undefined, }); - const accepted = await handleControlRequest( - 'session.prompt', - session, + const preparationInput = { ...input, sessionIds: [first.kiloSessionId, second.kiloSessionId] }; + const started = new Map( + [first, second, sibling].map(item => [item.kiloSessionId, Promise.withResolvers()]) + ); + const completionResponses = new Map( + [first, second, sibling].map(item => [ + item.kiloSessionId, + Promise.withResolvers(), + ]) + ); + const abortStarted = new Map( + [first, second].map(item => [item.kiloSessionId, Promise.withResolvers()]) + ); + const abortResponses = new Map( + [first, second].map(item => [item.kiloSessionId, Promise.withResolvers()]) + ); + const aborted: string[] = []; + const outcomes: Array<{ id: string; event: SessionEventPayload }> = []; + const lookups: string[] = []; + const handlerDeps = deps( { - ...promptPayload, - turn: { type: 'command', command: 'review', arguments: '' }, + kiloClient: fakeKilo({ + serverUrl: http.server.url.toString(), + sendCommand: options => { + started.get(options.sessionId)?.resolve(); + const response = completionResponses.get(options.sessionId); + if (!response) throw new Error('Unexpected command session'); + return response.promise; + }, + abortSession: options => { + aborted.push(options.sessionId); + abortStarted.get(options.sessionId)?.resolve(); + return abortResponses.get(options.sessionId)?.promise ?? Promise.resolve(true); + }, + }), + emitSessionEvent: (item, event) => outcomes.push({ id: item.kiloSessionId, event }), }, - handlerDeps + first ); - expect(accepted).toMatchObject({ ok: true }); - failed.reject(new Error('private-token-from-upstream')); - await waitForTasks(handlerDeps); - expect(events).toEqual([ - { - type: 'session.message.outcome', + const runtimes = handlerDeps.kiloRuntimes; + const selected = runtimes?.get(directory); + if (!runtimes || !selected) throw new Error('Expected selected worktree runtime'); + const otherRuntime = { + ...selected, + directory: siblingDirectory, + kiloClient: fakeKilo({ + sendCommand: options => { + started.get(options.sessionId)?.resolve(); + const response = completionResponses.get(options.sessionId); + if (!response) throw new Error('Unexpected sibling command'); + return response.promise; + }, + abortSession: async options => { + aborted.push(options.sessionId); + return true; + }, + }), + }; + runtimes.get = dir => { + lookups.push(dir); + return dir === directory ? selected : dir === siblingDirectory ? otherRuntime : undefined; + }; + const attach = spyOn(runtimes, 'attach'); + let preparation: ReturnType | undefined; + let prepared = false; + try { + for (const item of [first, second, sibling]) { + rememberAttachedRoot(item.kiloSessionId, item.directory); + expect( + await handleControlRequest( + 'session.prompt', + item, + { + ...promptPayload, + messageId: `message_${item.kiloSessionId}`, + turn: { type: 'command', command: 'review', arguments: '' }, + }, + handlerDeps + ) + ).toMatchObject({ ok: true, result: { status: 'accepted' } }); + } + await Promise.all([...started.values()].map(item => item.promise)); + lookups.length = 0; + expect(rootForSession(sessionId(2))).toBeUndefined(); + expect(rootForSession(sessionId(3))).toBeUndefined(); + const siblingTask = handlerDeps.tasks.get(sibling.kiloSessionId); + preparation = handleControlRequest( + 'worktree.prepareDeletion', + undefined, + preparationInput, + handlerDeps + ).then(result => { + prepared = true; + return result; + }); + await Promise.all([...abortStarted.values()].map(item => item.promise)); + expect(handlerDeps.tasks.get(first.kiloSessionId)?.signal.aborted).toBe(true); + expect(handlerDeps.tasks.get(second.kiloSessionId)?.signal.aborted).toBe(true); + expect(siblingTask?.signal.aborted).toBe(false); + expect(prepared).toBe(false); + expect(http.requests).toEqual([]); + expect( + await handleControlRequest('session.attach', identity(5), { kilo }, handlerDeps) + ).toEqual({ + ok: false, + error: { code: 'not_ready', message: 'Worktree is being deleted', retryable: false }, + }); + expect(attach).not.toHaveBeenCalled(); + abortResponses.get(first.kiloSessionId)?.resolve(true); + await handlerDeps.tasks.get(first.kiloSessionId)?.done; + expect(prepared).toBe(false); + expect(http.requests).toEqual([]); + abortResponses.get(second.kiloSessionId)?.resolve(true); + expect( + await withTimeoutAndAbort(preparation, { + timeoutMs: 1_000, + timeoutMessage: 'Deletion preparation waited for command completion', + abortMessage: 'Test cancelled', + }) + ).toEqual({ + ok: true, + result: { + prepared: true, + sessionIds: [sessionId(1), sessionId(6), sessionId(2), sessionId(3)], + }, + }); + expect(handlerDeps.tasks.size).toBe(1); + expect(handlerDeps.tasks.get(sibling.kiloSessionId)).toBe(siblingTask); + expect(siblingTask?.signal.aborted).toBe(false); + expect(aborted.toSorted()).toEqual([first.kiloSessionId, second.kiloSessionId]); + expect(outcomes.map(item => item.id).toSorted()).toEqual([ + first.kiloSessionId, + second.kiloSessionId, + ]); + expect( + outcomes.every( + item => + item.event.type === 'session.message.outcome' && + item.event.properties.status === 'cancelled' + ) + ).toBe(true); + expect(lookups).toEqual([directory]); + expect(http.requests.every(request => request.directory === directory)).toBe(true); + expect(http.requests).toContainEqual({ + method: 'POST', + pathname: `/session/${sessionId(3)}/abort`, + directory, + }); + expect(http.credentialedRequests).toEqual([]); + expect(filesystem.remove).not.toHaveBeenCalled(); + } finally { + for (const response of abortResponses.values()) response.resolve(true); + for (const response of completionResponses.values()) response.resolve(completion()); + await preparation; + await waitForTasks(handlerDeps); + attach.mockRestore(); + await http.server.stop(true); + filesystem.restore(); + } + }); + + it('deletes through the selected runtime and awaits directory terminals and retirement before removing its checkout and snapshots', async () => { + const filesystem = protectCheckout(); + const http = cleanupServer(); + const first = identity(1); + const sibling = identity(4, siblingDirectory); + const activity = createSessionActivityRegistry(() => 100); + for (const item of [first, identity(2), sibling]) { + rememberAttachedRoot(item.kiloSessionId, item.directory); + activity.attach(item.kiloSessionId); + activity.markActive(item.kiloSessionId); + } + rememberChildSession({ childId: sessionId(3), parentId: sessionId(2), directory }); + const terminals = new Set([directory, siblingDirectory]); + const detaching = Promise.withResolvers(); + const detached = Promise.withResolvers(); + const retiring = Promise.withResolvers(); + const retired = Promise.withResolvers(); + const lookups: string[] = []; + const retirements: string[] = []; + const handlerDeps = deps( + { + activity, + sessions: [first, identity(2), identity(3), sibling].map(item => ({ + kiloSessionId: item.kiloSessionId, + lastActivityAt: 100, + })), + kiloClient: fakeKilo({ serverUrl: http.server.url.toString() }), + terminalRuntime: fakeTerminalRuntime({ + detachDirectory: async dir => { + expect(dir).toBe(directory); + detaching.resolve(); + await detached.promise; + terminals.delete(dir); + }, + }), + }, + first + ); + const runtimes = handlerDeps.kiloRuntimes; + const selected = runtimes?.get(directory); + if (!runtimes || !selected) throw new Error('Expected selected worktree runtime'); + runtimes.get = dir => { + lookups.push(dir); + return dir === directory ? selected : undefined; + }; + runtimes.deleteDirectory = async dir => { + retirements.push(dir); + expect(http.sessions.size).toBe(0); + expect(http.hasPty()).toBe(false); + expect(http.isDisposed()).toBe(true); + expect(terminals).toEqual(new Set([siblingDirectory])); + retiring.resolve(); + await retired.promise; + }; + let deletion: ReturnType | undefined; + try { + deletion = handleControlRequest( + 'worktree.delete', + undefined, + { ...input, sessionIds: [sessionId(1), sessionId(2), sessionId(3)] }, + handlerDeps + ); + await detaching.promise; + expect(retirements).toEqual([]); + expect(filesystem.remove).not.toHaveBeenCalled(); + expect(http.isDisposed()).toBe(false); + detached.resolve(); + await retiring.promise; + expect(filesystem.remove).not.toHaveBeenCalled(); + expect(rootForSession(first.kiloSessionId)).toBe(first.kiloSessionId); + retired.resolve(); + expect(await deletion).toEqual({ + ok: true, + result: { deleted: true, sessionIds: [sessionId(1), sessionId(2), sessionId(3)] }, + }); + expect(lookups).toEqual([directory]); + expect(retirements).toEqual([directory]); + expect(filesystem.remove.mock.calls).toEqual([[directory, { recursive: true, force: true }]]); + expect(handlerDeps.sessions).toEqual([ + { kiloSessionId: sibling.kiloSessionId, lastActivityAt: 100 }, + ]); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: sibling.kiloSessionId, state: 'active', idleForMs: 0, waitingOn: 'model' }, + ]); + expect(rootForSession(sessionId(1))).toBeUndefined(); + expect(rootForSession(sessionId(2))).toBeUndefined(); + expect(rootForSession(sessionId(3))).toBeUndefined(); + expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); + expect(http.requests.every(request => request.directory === directory)).toBe(true); + expect(http.credentialedRequests).toEqual([]); + } finally { + detached.resolve(); + retired.resolve(); + await deletion; + await http.server.stop(true); + filesystem.restore(); + } + }); + + it('idempotently prepares and deletes an absent runtime without global lookup, startup, or credentials', async () => { + const filesystem = protectCheckout(); + const first = identity(1); + const sibling = identity(4, siblingDirectory); + const activity = createSessionActivityRegistry(() => 100); + for (const item of [first, sibling]) { + rememberAttachedRoot(item.kiloSessionId, item.directory); + activity.attach(item.kiloSessionId); + } + const lookups: string[] = []; + const retirements: string[] = []; + const detached: string[] = []; + const forbidden: string[] = []; + const siblingRuntime: WorktreeKiloRuntime = { + directory: siblingDirectory, + scopeId: path.basename(siblingDirectory), + env: {}, + signal: new AbortController().signal, + kiloClient: { + ...fakeKilo(), + get serverUrl(): string { + forbidden.push('sibling credentials'); + throw new Error('Sibling runtime must not be used for cleanup'); + }, + }, + }; + const handlerDeps = deps( + { + activity, + sessions: [first, sibling].map(item => ({ + kiloSessionId: item.kiloSessionId, + lastActivityAt: 100, + })), + kiloRuntimes: { + get: dir => { + lookups.push(dir); + if (dir === directory) return undefined; + forbidden.push('unscoped lookup'); + return siblingRuntime; + }, + attach: () => { + forbidden.push('startup'); + throw new Error('Cleanup must not start a runtime'); + }, + detach: () => { + forbidden.push('detach'); + return false; + }, + deleteDirectory: async dir => { + retirements.push(dir); + }, + isHealthy: () => true, + shutdown: () => { + forbidden.push('shutdown'); + }, + }, + terminalRuntime: fakeTerminalRuntime({ + detachDirectory: async dir => { + detached.push(dir); + }, + }), + }, + first + ); + try { + for (let attempt = 0; attempt < 2; attempt++) { + expect( + await handleControlRequest('worktree.prepareDeletion', undefined, input, handlerDeps) + ).toEqual({ ok: true, result: { prepared: true, sessionIds: input.sessionIds } }); + expect(retirements).toHaveLength(attempt); + expect( + await handleControlRequest('worktree.delete', undefined, input, handlerDeps) + ).toEqual({ ok: true, result: { deleted: true, sessionIds: input.sessionIds } }); + } + expect(lookups).toEqual([directory, directory, directory, directory]); + expect(retirements).toEqual([directory, directory]); + expect(detached).toEqual([directory, directory]); + expect(filesystem.remove.mock.calls).toEqual([ + [directory, { recursive: true, force: true }], + [directory, { recursive: true, force: true }], + ]); + expect(forbidden).toEqual([]); + expect(handlerDeps.sessions).toEqual([ + { kiloSessionId: sibling.kiloSessionId, lastActivityAt: 100 }, + ]); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: sibling.kiloSessionId, state: 'idle', idleForMs: 0 }, + ]); + expect(rootForSession(first.kiloSessionId)).toBeUndefined(); + expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); + } finally { + filesystem.restore(); + } + }); +}); + +describe('owned control execution', () => { + it('admits a command before completion and does not redispatch it after the RPC deadline', async () => { + const finished = Promise.withResolvers(); + const events: SessionEventPayload[] = []; + let submissions = 0; + const handlerDeps = deps({ + kiloClient: fakeKilo({ + sendCommand: () => { + submissions += 1; + return finished.promise; + }, + }), + emitSessionEvent: (_session, event) => events.push(event), + }); + const payload = { + ...promptPayload, + turn: { type: 'command', command: 'review', arguments: '' }, + }; + const admittedAt = Date.now(); + expect(await handleControlRequest('session.prompt', session, payload, handlerDeps)).toEqual({ + ok: true, + result: { messageId: 'msg_1', status: 'accepted' }, + }); + setSystemTime(admittedAt + SANDBOX_CONTROL_REQUEST_TIMEOUT_MS + 1); + expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ + state: 'active', + pendingMessages: 1, + }); + expect(await handleControlRequest('session.prompt', session, payload, handlerDeps)).toEqual({ + ok: true, + result: { messageId: 'msg_1', status: 'existing' }, + }); + expect( + await handleControlRequest( + 'session.prompt', + session, + { ...payload, messageId: 'msg_2' }, + handlerDeps + ) + ).toMatchObject({ ok: false }); + expect(submissions).toBe(1); + expect(events).toEqual([]); + finished.resolve(completion()); + await waitForTasks(handlerDeps); + expect(events).toEqual([ + { type: 'session.message.outcome', properties: { messageId: 'msg_1', status: 'completed' } }, + ]); + expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ state: 'idle', pendingMessages: 0 }); + }); + + it('emits one message-scoped outcome even when completion precedes delivery of the acknowledgement', async () => { + const releaseAck = Promise.withResolvers(); + const events: SessionEventPayload[] = []; + const identities: unknown[] = []; + const handlerDeps = deps({ + emitSessionEvent: (identity, event) => { + identities.push(identity); + events.push(event); + }, + }); + let acknowledged = false; + const response = handleControlRequest( + 'session.prompt', + session, + promptPayload, + handlerDeps + ).then(async result => { + await releaseAck.promise; + acknowledged = true; + return result; + }); + await waitForTasks(handlerDeps); + expect(acknowledged).toBe(false); + expect(events).toEqual([ + { type: 'session.message.outcome', properties: { messageId: 'msg_1', status: 'completed' } }, + ]); + expect(identities).toEqual([session]); + releaseAck.resolve(); + expect(await response).toMatchObject({ ok: true }); + expect(events).toHaveLength(1); + }); + + it('reports asynchronous submission failures after admission without leaking API diagnostics', async () => { + const failed = Promise.withResolvers(); + const events: SessionEventPayload[] = []; + const handlerDeps = deps({ + kiloClient: fakeKilo({ sendCommand: () => failed.promise }), + emitSessionEvent: (_session, event) => events.push(event), + }); + const accepted = await handleControlRequest( + 'session.prompt', + session, + { + ...promptPayload, + turn: { type: 'command', command: 'review', arguments: '' }, + }, + handlerDeps + ); + expect(accepted).toMatchObject({ ok: true }); + failed.reject(new Error('private-token-from-upstream')); + await waitForTasks(handlerDeps); + expect(events).toEqual([ + { + type: 'session.message.outcome', properties: { messageId: 'msg_1', status: 'failed', @@ -2708,6 +3460,179 @@ describe('control interactions and sync', () => { tool: { messageID: 'assistant_1', callID: 'tool_2' }, }; + it('answers and rejects questions belonging to the target root or its cached child in a shared directory', async () => { + rememberAttachedRoot('kilo_2', session.directory); + rememberChildSession({ childId: 'child_1', parentId: 'kilo_1', directory: session.directory }); + const answered: unknown[] = []; + const rejected: unknown[] = []; + const lookups: unknown[] = []; + const handlerDeps = deps({ + kiloClient: fakeKilo({ + getQuestions: async (directory, signal) => { + lookups.push({ directory, signal }); + return [ + { ...question, id: 'question_root', sessionID: 'kilo_1' }, + { ...question, id: 'question_child', sessionID: 'child_1' }, + { ...question, id: 'question_sibling', sessionID: 'kilo_2' }, + ]; + }, + answerQuestion: async (...args) => { + answered.push(args); + return true; + }, + rejectQuestion: async (...args) => { + rejected.push(args); + return true; + }, + }), + }); + expect( + await handleControlRequest( + 'session.question.resolve', + session, + { action: 'answer', questionId: 'question_root', answers: [['yes']] }, + handlerDeps + ) + ).toEqual({ ok: true, result: { success: true } }); + expect( + await handleControlRequest( + 'session.question.resolve', + session, + { action: 'reject', questionId: 'question_child' }, + handlerDeps + ) + ).toEqual({ ok: true, result: { success: true } }); + expect(answered).toEqual([ + ['question_root', [['yes']], session.directory, expect.any(AbortSignal)], + ]); + expect(rejected).toEqual([['question_child', session.directory, expect.any(AbortSignal)]]); + expect(lookups).toEqual([ + { directory: session.directory, signal: expect.any(AbortSignal) }, + { directory: session.directory, signal: expect.any(AbortSignal) }, + ]); + }); + + it('rejects globally valid sibling and unknown-lineage question IDs without resolving them', async () => { + rememberAttachedRoot('kilo_2', session.directory); + rememberChildSession({ childId: 'child_2', parentId: 'kilo_2', directory: session.directory }); + const resolved: string[] = []; + const handlerDeps = deps({ + kiloClient: fakeKilo({ + getQuestions: async () => [ + { ...question, id: 'question_sibling', sessionID: 'kilo_2', rootKiloSessionId: 'kilo_1' }, + { + ...question, + id: 'question_sibling_child', + sessionID: 'child_2', + rootKiloSessionId: 'kilo_1', + }, + { + ...question, + id: 'question_unknown', + sessionID: 'unmapped_child', + rootKiloSessionId: 'kilo_1', + }, + ], + answerQuestion: async questionId => { + resolved.push(questionId); + return true; + }, + rejectQuestion: async questionId => { + resolved.push(questionId); + return true; + }, + }), + }); + for (const questionId of [ + 'question_sibling', + 'question_sibling_child', + 'question_unknown', + 'question_missing', + ]) { + for (const action of ['answer', 'reject']) { + expect( + await handleControlRequest( + 'session.question.resolve', + session, + { action, questionId, ...(action === 'answer' ? { answers: [['yes']] } : {}) }, + handlerDeps + ) + ).toEqual({ + ok: false, + error: { + code: 'unauthorized', + message: 'Question is not pending for this session', + retryable: false, + }, + }); + } + } + expect(resolved).toEqual([]); + }); + + it('stamps verified child-question snapshots for reconnect without trusting sibling root claims', async () => { + const syncSession = { + sessionId: 'workspace_sync_a', + kiloSessionId: 'kilo_sync_a', + directory: '/workspace/shared', + }; + rememberAttachedRoot('kilo_sync_a', syncSession.directory); + rememberAttachedRoot('kilo_sync_b', syncSession.directory); + rememberChildSession({ + childId: 'kilo_sync_child', + parentId: 'kilo_sync_a', + directory: syncSession.directory, + }); + rememberChildSession({ + childId: 'kilo_sync_sibling_child', + parentId: 'kilo_sync_b', + directory: syncSession.directory, + }); + const rootQuestion = { ...question, id: 'question_root', sessionID: 'kilo_sync_a' }; + const childQuestion = { + ...question, + id: 'question_child', + sessionID: 'kilo_sync_child', + rootKiloSessionId: 'kilo_sync_b', + }; + const rootPermission = { ...permission, sessionID: 'kilo_sync_a' }; + const handlerDeps = deps( + { + kiloClient: fakeKilo({ + getSessionStatuses: async () => ({ kilo_sync_a: { type: 'busy' } }), + getQuestions: async () => [ + rootQuestion, + childQuestion, + ...['kilo_sync_b', 'kilo_sync_sibling_child', 'unmapped_child'].map(sessionID => ({ + ...question, + id: `question_${sessionID}`, + sessionID, + rootKiloSessionId: 'kilo_sync_a', + })), + ], + getPermissions: async () => [ + rootPermission, + { ...permission, id: 'permission_sibling', sessionID: 'kilo_sync_b' }, + ], + }), + }, + syncSession + ); + + const result = await handleControlRequest('session.sync', syncSession, {}, handlerDeps); + expect(result).toEqual({ + ok: true, + result: { + status: { type: 'busy' }, + questions: [rootQuestion, { ...childQuestion, rootKiloSessionId: 'kilo_sync_a' }], + permissions: [rootPermission], + }, + }); + if (!result.ok) throw new Error('Expected successful synchronization'); + expect(sessionSyncResultSchema.safeParse(result.result).success).toBe(true); + expect(childQuestion.rootKiloSessionId).toBe('kilo_sync_b'); + }); + it('syncs rooted child asks and answers them in their own directory without admitting sibling requests', async () => { const childDirectory = '/workspace/child'; const childQuestion = { ...question, id: 'child_question', sessionID: 'child' }; @@ -2754,8 +3679,8 @@ describe('control interactions and sync', () => { ok: true, result: { status: { type: 'idle' }, - questions: [question, childQuestion], - permissions: [permission, childPermission], + questions: [question, { ...childQuestion, rootKiloSessionId: session.kiloSessionId }], + permissions: [permission, { ...childPermission, rootKiloSessionId: session.kiloSessionId }], }, }); expect(queried).toEqual(new Set([session.directory, childDirectory])); @@ -3003,6 +3928,111 @@ describe('control interactions and sync', () => { }); describe('buildHeartbeatPayload', () => { + it('synchronously includes advisory activity snapshots without polling Kilo', () => { + let now = 100; + const activity = createSessionActivityRegistry(() => now); + activity.attach(session.kiloSessionId); + activity.markActive(session.kiloSessionId); + now = 150; + let reads = 0; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async () => { + reads += 1; + return {}; + }, + }), + }); + + expect(buildHeartbeatPayload(handlerDeps)).toEqual({ + state: 'active', + activeKiloSessions: 1, + pendingMessages: 0, + kilo: { ready: true }, + sessions: [{ kiloSessionId: 'kilo_1', state: 'active', idleForMs: 50, waitingOn: 'model' }], + }); + expect(reads).toBe(0); + }); + + it.each(['execution', 'finalizing'] as const)( + 'keeps owned %s authoritative over idle advisory status without settling the message', + async phase => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach(session.kiloSessionId); + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + rememberAttachedRoot(sibling.kiloSessionId, sibling.directory); + activity.attach(sibling.kiloSessionId); + const running = Promise.withResolvers(); + const finalizing = Promise.withResolvers(); + const finalized = Promise.withResolvers<{ success: boolean }>(); + const events: SessionEventPayload[] = []; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + sendPrompt: () => running.promise, + getSessionStatuses: async () => ({ kilo_1: { type: 'idle' }, kilo_2: { type: 'idle' } }), + }), + runAutoCommit: () => { + finalizing.resolve(); + return finalized.promise; + }, + emitSessionEvent: (_identity, event) => events.push(event), + }); + try { + await handleControlRequest( + 'session.prompt', + session, + { + ...promptPayload, + ...(phase === 'finalizing' ? { finalization: { autoCommit: true } } : {}), + }, + handlerDeps + ); + if (phase === 'finalizing') { + running.resolve(completion()); + await finalizing.promise; + } + const task = handlerDeps.tasks.get(session.kiloSessionId); + expect(task?.kind).toBe(phase); + expect(await refreshHeartbeatPayload(handlerDeps)).toMatchObject({ + state: phase === 'finalizing' ? 'finalizing' : 'active', + activeKiloSessions: 1, + pendingMessages: 1, + sessions: [ + { + kiloSessionId: 'kilo_1', + state: phase === 'finalizing' ? 'finalizing' : 'active', + waitingOn: phase === 'finalizing' ? 'finalizing' : 'model', + }, + { kiloSessionId: 'kilo_2', state: 'idle' }, + ], + }); + expect(activity.snapshots().every(snapshot => snapshot.state === 'idle')).toBe(true); + expect(handlerDeps.tasks.get(session.kiloSessionId)).toBe(task); + expect(events).toEqual([]); + running.resolve(completion()); + finalized.resolve({ success: true }); + await waitForTasks(handlerDeps); + expect(events).toEqual([ + { + type: 'session.message.outcome', + properties: { messageId: 'msg_1', status: 'completed' }, + }, + ]); + expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ + state: 'idle', + activeKiloSessions: 0, + pendingMessages: 0, + }); + } finally { + running.resolve(completion()); + finalized.resolve({ success: true }); + await waitForTasks(handlerDeps); + } + } + ); + it('ages owned input waits without treating content silence as runtime death', async () => { const finished = Promise.withResolvers(); const handlerDeps = deps({ kiloClient: fakeKilo({ sendCommand: () => finished.promise }) }); @@ -3049,4 +4079,461 @@ describe('buildHeartbeatPayload', () => { pendingMessages: 0, }); }); + + describe('Dreamy: directory barrier and activity registry', () => { + it('rejects session operations when directory is being deleted', async () => { + await fenceDirectoryOperations(session.directory); + const result = await handleControlRequest('session.prompt', session, promptPayload, deps()); + expect(result).toMatchObject({ ok: false, error: { message: 'Worktree is being deleted' } }); + }); + + it('registers and detaches activity on attach/detach cycle', async () => { + const activity = createSessionActivityRegistry(() => 100); + const handlerDeps = deps({ activity }); + + expect( + await handleControlRequest('session.attach', session, { kilo }, handlerDeps) + ).toMatchObject({ ok: true }); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'kilo_1', state: 'idle', idleForMs: 0 }, + ]); + + expect(await handleControlRequest('session.detach', session, {}, handlerDeps)).toMatchObject({ + ok: true, + }); + expect(activity.snapshots()).toEqual([]); + }); + + it('createSessionActivityRegistry: observeEvent transitions idle→active→idle', () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach('root_1'); + expect(activity.state()).toBe('idle'); + + activity.observeEvent('session.turn.open', 'root_1', 'root_1', {}); + expect(activity.state()).toBe('active'); + + activity.observeEvent('session.idle', 'root_1', 'root_1', {}); + expect(activity.state()).toBe('idle'); + }); + + it('createSessionActivityRegistry: ignores events for non-root sessions', () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach('root_1'); + activity.observeEvent('session.turn.open', 'child_1', 'root_1', {}); + expect(activity.state()).toBe('idle'); + }); + + it('createSessionActivityRegistry: reconcile resets tracked sessions', () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach('root_1'); + activity.markActive('root_1'); + expect(activity.state()).toBe('active'); + activity.reconcile({ root_1: { type: 'idle' } }); + expect(activity.state()).toBe('idle'); + }); + }); +}); + +describe('refreshHeartbeatPayload', () => { + it('does not reactivate a completed owned task from a stale busy status poll', async () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach(session.kiloSessionId); + const finished = Promise.withResolvers(); + const polling = Promise.withResolvers(); + const statuses = + Promise.withResolvers>>(); + const events: SessionEventPayload[] = []; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + sendCommand: () => finished.promise, + getSessionStatuses: () => { + polling.resolve(); + return statuses.promise; + }, + }), + emitSessionEvent: (_identity, event) => events.push(event), + }); + let refresh: ReturnType | undefined; + try { + expect( + await handleControlRequest( + 'session.prompt', + session, + { + ...promptPayload, + turn: { type: 'command', command: 'review', arguments: '' }, + }, + handlerDeps + ) + ).toMatchObject({ ok: true, result: { status: 'accepted' } }); + activity.observeEvent('session.idle', session.kiloSessionId, session.kiloSessionId, {}); + refresh = refreshHeartbeatPayload(handlerDeps); + await polling.promise; + expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ + state: 'active', + pendingMessages: 1, + }); + expect(events).toEqual([]); + finished.resolve(completion()); + await waitForTasks(handlerDeps); + expect(buildHeartbeatPayload(handlerDeps)).toMatchObject({ + state: 'idle', + pendingMessages: 0, + }); + statuses.resolve({ [session.kiloSessionId]: { type: 'busy' } }); + expect(await refresh).toEqual({ + state: 'idle', + activeKiloSessions: 0, + pendingMessages: 0, + kilo: { ready: true }, + sessions: [{ kiloSessionId: session.kiloSessionId, state: 'idle', idleForMs: 0 }], + }); + expect(handlerDeps.tasks.size).toBe(0); + expect(events).toEqual([ + { + type: 'session.message.outcome', + properties: { messageId: 'msg_1', status: 'completed' }, + }, + ]); + } finally { + finished.resolve(completion()); + statuses.resolve({}); + await refresh; + await waitForTasks(handlerDeps); + } + }); + + it('ignores a detached root stale poll for its same-runtime replacement while updating its unchanged sibling', async () => { + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const activity = createSessionActivityRegistry(() => 100); + const polling = Promise.withResolvers(); + const statuses = + Promise.withResolvers>>(); + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: () => { + polling.resolve(); + return statuses.promise; + }, + }), + }); + for (const identity of [session, sibling]) { + expect(await handleControlRequest('session.attach', identity, { kilo }, handlerDeps)).toEqual( + { + ok: true, + result: { attached: true }, + } + ); + } + const runtime = handlerDeps.kiloRuntimes?.get(session.directory); + expect(runtime).toBeDefined(); + const refresh = refreshHeartbeatPayload(handlerDeps); + try { + await polling.promise; + expect(await handleControlRequest('session.detach', session, {}, handlerDeps)).toEqual({ + ok: true, + result: { detached: true }, + }); + expect(await handleControlRequest('session.attach', session, { kilo }, handlerDeps)).toEqual({ + ok: true, + result: { attached: true }, + }); + activity.attach(sibling.kiloSessionId); + expect(handlerDeps.kiloRuntimes?.get(session.directory)).toBe(runtime); + statuses.resolve({ kilo_1: { type: 'busy' }, kilo_2: { type: 'busy', waitingOn: 'tool' } }); + expect(await refresh).toEqual({ + state: 'active', + activeKiloSessions: 1, + pendingMessages: 0, + kilo: { ready: true }, + sessions: [ + { + kiloSessionId: sibling.kiloSessionId, + state: 'active', + idleForMs: 0, + waitingOn: 'tool', + }, + { kiloSessionId: session.kiloSessionId, state: 'idle', idleForMs: 0 }, + ], + }); + expect(handlerDeps.tasks.size).toBe(0); + } finally { + statuses.resolve({}); + await refresh; + } + }); + + it('reconciles attached roots in a shared directory with exactly one status read per heartbeat', async () => { + let now = 100; + const activity = createSessionActivityRegistry(() => now); + rememberAttachedRoot('kilo_2', session.directory); + activity.attach('kilo_1'); + activity.attach('kilo_2'); + now = 150; + const reads: unknown[] = []; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async (directory, signal) => { + reads.push(directory); + expect(signal?.aborted).toBe(false); + return { kilo_1: { type: 'idle' }, kilo_2: { type: 'busy' } }; + }, + }), + }); + + const payload = await refreshHeartbeatPayload(handlerDeps); + expect(reads).toEqual([session.directory]); + expect(payload.state).toBe('active'); + expect(payload.sessions).toEqual([ + { kiloSessionId: 'kilo_1', state: 'idle', idleForMs: 50 }, + { kiloSessionId: 'kilo_2', state: 'active', idleForMs: 0, waitingOn: 'model' }, + ]); + expect(payload).toEqual(buildHeartbeatPayload(handlerDeps)); + await refreshHeartbeatPayload(handlerDeps); + expect(reads).toEqual([session.directory, session.directory]); + }); + + it('retains the previous active or finalizing state when its status read fails', async () => { + let now = 100; + const activity = createSessionActivityRegistry(() => now); + rememberAttachedRoot('kilo_2', session.directory); + activity.attach('kilo_1'); + activity.attach('kilo_2'); + activity.markActive('kilo_1'); + activity.reconcile({ kilo_1: { type: 'busy' }, kilo_2: { type: 'finalizing' } }); + now = 180; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async () => { + throw new Error('status unavailable'); + }, + }), + }); + + const payload = await refreshHeartbeatPayload(handlerDeps); + expect(payload.state).toBe('active'); + expect(payload.pendingMessages).toBe(0); + expect(payload.sessions).toEqual([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 80, waitingOn: 'model' }, + { kiloSessionId: 'kilo_2', state: 'finalizing', idleForMs: 80, waitingOn: 'finalizing' }, + ]); + }); + + it.each([false, true])( + 'polls each matching directory client once and isolates reconciliation when one read fails: %s', + async failFirst => { + let now = 100; + const activity = createSessionActivityRegistry(() => now); + const other = { sessionId: 'ses_3', kiloSessionId: 'kilo_3', directory: '/workspace/other' }; + rememberAttachedRoot('kilo_2', session.directory); + rememberAttachedRoot(other.kiloSessionId, other.directory); + rememberChildSession({ + childId: 'child', + parentId: session.kiloSessionId, + directory: '/workspace/child', + }); + for (const root of ['kilo_1', 'kilo_2', 'kilo_3']) activity.attach(root); + activity.reconcile({ + kilo_1: { type: 'busy' }, + kilo_2: { type: 'finalizing' }, + kilo_3: { type: 'busy' }, + }); + now = 180; + const reads: Array<{ client: string; directory: string | undefined }> = []; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async (directory, signal) => { + reads.push({ client: 'first', directory }); + expect(signal?.aborted).toBe(false); + if (failFirst) throw new Error('first directory unavailable'); + return { + kilo_1: { type: 'busy', waitingOn: 'tool' }, + kilo_3: { type: 'busy' }, + child: { type: 'busy' }, + }; + }, + }), + }); + const otherDeps = deps( + { + kiloClient: fakeKilo({ + getSessionStatuses: async (directory, signal) => { + reads.push({ client: 'other', directory }); + expect(signal?.aborted).toBe(false); + return { kilo_1: { type: 'idle' }, kilo_3: { type: 'idle' } }; + }, + }), + }, + other + ); + const runtimes = handlerDeps.kiloRuntimes; + const firstRuntime = runtimes?.get(session.directory); + const otherRuntime = otherDeps.kiloRuntimes?.get(other.directory); + if (!runtimes || !firstRuntime || !otherRuntime) + throw new Error('Expected directory runtimes'); + runtimes.get = directory => + directory === session.directory + ? firstRuntime + : directory === other.directory + ? otherRuntime + : undefined; + + const payload = await refreshHeartbeatPayload(handlerDeps); + expect(reads).toHaveLength(2); + expect(reads).toContainEqual({ client: 'first', directory: session.directory }); + expect(reads).toContainEqual({ client: 'other', directory: other.directory }); + expect(payload.sessions).toEqual([ + { + kiloSessionId: 'kilo_1', + state: 'active', + idleForMs: failFirst ? 80 : 0, + waitingOn: failFirst ? 'model' : 'tool', + }, + failFirst + ? { kiloSessionId: 'kilo_2', state: 'finalizing', idleForMs: 80, waitingOn: 'finalizing' } + : { kiloSessionId: 'kilo_2', state: 'idle', idleForMs: 0 }, + { kiloSessionId: 'kilo_3', state: 'idle', idleForMs: 0 }, + ]); + expect(payload.pendingMessages).toBe(0); + } + ); + + it('bounds a hung status poll and retains advisory activity after its deadline', async () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach(session.kiloSessionId); + activity.markActive(session.kiloSessionId); + const started = Promise.withResolvers(); + const response = + Promise.withResolvers>>(); + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: (_directory, signal) => { + if (!signal) throw new Error('Expected status polling signal'); + started.resolve(signal); + return response.promise; + }, + }), + }); + const timers = spyOn(globalThis, 'setTimeout'); + let refresh: ReturnType | undefined; + try { + refresh = refreshHeartbeatPayload(handlerDeps); + const signal = await started.promise; + const deadline = timers.mock.calls.find( + ([, ms]) => ms === KILO_CONTROL_REQUEST_TIMEOUT_MS + )?.[0]; + if (typeof deadline !== 'function') + throw new Error('Missing bounded status polling deadline'); + deadline(); + const payload = await refresh; + expect(signal.aborted).toBe(true); + expect(payload.sessions).toEqual([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 0, waitingOn: 'model' }, + ]); + response.resolve({ kilo_1: { type: 'idle' } }); + await new Promise(resolve => setImmediate(resolve)); + expect(buildHeartbeatPayload(handlerDeps).sessions).toEqual(payload.sessions); + } finally { + response.resolve({}); + await refresh; + timers.mockRestore(); + } + }); +}); + +describe('createSessionActivityRegistry', () => { + it('updates only exact root status and turn events while preserving true child events', () => { + let now = 100; + const activity = createSessionActivityRegistry(() => now); + activity.attach('root_a'); + activity.attach('root_b'); + activity.markActive('root_a'); + now = 140; + + for (const type of ['session.idle', 'session.turn.close', 'session.error']) { + activity.observeEvent(type, 'child_a', 'root_a', { sessionID: 'child_a' }); + } + activity.observeEvent('session.status', 'child_a', 'root_a', { + sessionID: 'child_a', + status: { type: 'idle' }, + }); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'root_a', state: 'active', idleForMs: 40, waitingOn: 'model' }, + { kiloSessionId: 'root_b', state: 'idle', idleForMs: 40 }, + ]); + + activity.observeEvent('session.status', 'root_b', 'root_b', { + sessionID: 'root_b', + status: { type: 'finalizing' }, + }); + activity.observeEvent('session.turn.close', 'root_a', 'root_a', { sessionID: 'root_a' }); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'root_a', state: 'idle', idleForMs: 0 }, + { kiloSessionId: 'root_b', state: 'finalizing', idleForMs: 0, waitingOn: 'finalizing' }, + ]); + expect(activity.state()).toBe('active'); + }); + + it('does not reset an active root when its attach is replayed', () => { + let now = 100; + const activity = createSessionActivityRegistry(() => now); + activity.attach('root'); + activity.markActive('root'); + now = 175; + activity.attach('root'); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'root', state: 'active', idleForMs: 75, waitingOn: 'model' }, + ]); + }); + + it('reconciles missing statuses as idle without letting child status settle its root', () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach('root_a'); + activity.attach('root_b'); + activity.markActive('root_a'); + activity.markActive('root_b'); + activity.reconcile({ root_a: { type: 'busy', waitingOn: 'tool' }, child_b: { type: 'busy' } }); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'root_a', state: 'active', idleForMs: 0, waitingOn: 'tool' }, + { kiloSessionId: 'root_b', state: 'idle', idleForMs: 0 }, + ]); + expect(activity.state()).toBe('active'); + }); + + it('limits status reconciliation to the supplied roots without settling other directories', () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach('root_a'); + activity.attach('root_b'); + activity.markActive('root_a'); + activity.markActive('root_b'); + activity.reconcile({ root_b: { type: 'idle' } }, ['root_a']); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'root_a', state: 'idle', idleForMs: 0 }, + { kiloSessionId: 'root_b', state: 'active', idleForMs: 0, waitingOn: 'model' }, + ]); + }); + + it('returns the sandbox to idle only after every active root settles or detaches', () => { + const activity = createSessionActivityRegistry(() => 100); + activity.attach('root_a'); + activity.attach('root_b'); + activity.observeEvent('session.turn.open', 'root_a', 'root_a', { sessionID: 'root_a' }); + activity.observeEvent('session.status', 'root_b', 'root_b', { + sessionID: 'root_b', + status: { type: 'retry' }, + }); + activity.observeEvent('session.error', 'root_a', 'root_a', { sessionID: 'root_a' }); + expect(activity.state()).toBe('active'); + activity.detach('root_b'); + expect(activity.state()).toBe('idle'); + expect(activity.snapshots()).toEqual([ + { kiloSessionId: 'root_a', state: 'idle', idleForMs: 0 }, + ]); + }); }); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts index 27b93a2a7c..07c2a6cf60 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts @@ -21,6 +21,7 @@ import { sessionTerminalCreateResultSchema, sessionTerminalResizePayloadSchema, sessionTerminalResizeResultSchema, + worktreeDeletePayloadSchema, type SandboxHeartbeatPayload, type SessionEventPayload, type SessionMessageOutcome, @@ -47,6 +48,14 @@ import { type WorktreeKiloRuntime, type WorktreeKiloRuntimes, } from './worktree-runtime.js'; +import { assertDirectoryActive, fenceDirectoryOperations } from './worktree-operations'; +import { + createWorktreeKiloCleanupClient, + deleteWorktree, + prepareWorktreeDeletion, + validateWorktreeDirectory, + type WorktreeKiloCleanupClient, +} from './delete-worktree'; export type HandlerSessionSnapshot = { kiloSessionId: string; @@ -65,13 +74,140 @@ export type OwnedSessionTask = TaskIdentity & { done: Promise; }; +type SessionActivity = { + revision: symbol; + state: 'idle' | 'active' | 'finalizing'; + lastActivityAt: number; + waitingOn?: 'model' | 'tool' | 'finalizing'; +}; + +type KiloSessionStatuses = Awaited>; + +export type SessionActivityRegistry = { + attach(rootKiloSessionId: string): void; + detach(rootKiloSessionId: string): void; + markActive(rootKiloSessionId: string): void; + observeEvent( + type: string, + kiloSessionId: string | undefined, + rootKiloSessionId: string | undefined, + properties: Record + ): void; + reconcile(statuses: KiloSessionStatuses, roots?: readonly string[]): void; + revision(rootKiloSessionId: string): symbol | undefined; + snapshots(): NonNullable; + state(): 'idle' | 'active'; +}; + +export function createSessionActivityRegistry( + now: () => number = Date.now +): SessionActivityRegistry { + const sessions = new Map(); + + function update( + rootKiloSessionId: string, + state: SessionActivity['state'], + waitingOn?: SessionActivity['waitingOn'], + refresh = false + ): void { + const session = sessions.get(rootKiloSessionId); + if (!session) return; + session.revision = Symbol(); + if (!refresh && session.state === state && session.waitingOn === waitingOn) return; + session.state = state; + session.lastActivityAt = now(); + if (waitingOn) { + session.waitingOn = waitingOn; + } else { + delete session.waitingOn; + } + } + + function applyStatus(rootKiloSessionId: string, status: { type: string; waitingOn?: unknown }) { + if (status.type === 'idle') { + update(rootKiloSessionId, 'idle'); + return; + } + if (status.type === 'finalizing') { + update(rootKiloSessionId, 'finalizing', 'finalizing'); + return; + } + update(rootKiloSessionId, 'active', status.waitingOn === 'tool' ? 'tool' : 'model'); + } + + return { + attach(rootKiloSessionId) { + if (!sessions.has(rootKiloSessionId)) { + sessions.set(rootKiloSessionId, { + revision: Symbol(), + state: 'idle', + lastActivityAt: now(), + }); + } + }, + detach(rootKiloSessionId) { + sessions.delete(rootKiloSessionId); + }, + markActive(rootKiloSessionId) { + update(rootKiloSessionId, 'active', 'model', true); + }, + observeEvent(type, kiloSessionId, rootKiloSessionId, properties) { + if (!rootKiloSessionId || kiloSessionId !== rootKiloSessionId) return; + if (type === 'session.turn.open') { + update(rootKiloSessionId, 'active', 'model', true); + return; + } + if (type === 'session.status') { + const status = properties.status; + if (typeof status === 'object' && status !== null && 'type' in status) { + if (typeof status.type === 'string') { + applyStatus(rootKiloSessionId, { + type: status.type, + waitingOn: 'waitingOn' in status ? status.waitingOn : undefined, + }); + } + } + return; + } + if (type === 'session.idle' || type === 'session.turn.close' || type === 'session.error') { + update(rootKiloSessionId, 'idle'); + } + }, + reconcile(statuses, roots = [...sessions.keys()]) { + for (const rootKiloSessionId of roots) { + applyStatus(rootKiloSessionId, statuses[rootKiloSessionId] ?? { type: 'idle' }); + } + }, + revision(rootKiloSessionId) { + return sessions.get(rootKiloSessionId)?.revision; + }, + snapshots() { + const observedAt = now(); + return [...sessions.entries()].map(([kiloSessionId, session]) => ({ + kiloSessionId, + state: session.state, + idleForMs: Math.max(0, Math.trunc(observedAt - session.lastActivityAt)), + ...(session.waitingOn ? { waitingOn: session.waitingOn } : {}), + })); + }, + state() { + for (const session of sessions.values()) { + if (session.state === 'active' || session.state === 'finalizing') return 'active'; + } + return 'idle'; + }, + }; +} + export type HandlerDeps = { kiloRuntimes?: WorktreeKiloRuntimes; + worktreeCleanupClient?: WorktreeKiloCleanupClient; version: string; kiloReady: boolean; sessions: HandlerSessionSnapshot[]; tasks: Map; signal?: AbortSignal; + activity?: SessionActivityRegistry; emitSessionEvent: (session: SessionRequestIdentity, payload: SessionEventPayload) => void; retireRuntime: (reason: string) => void; onShutdown?: () => void; @@ -111,37 +247,87 @@ function kiloFailure(error: unknown): ControlHandlerResult { export function buildHeartbeatPayload(deps: HandlerDeps): SandboxHeartbeatPayload { const now = Date.now(); - const tasks = [...deps.tasks.values()]; + const snapshots = new Map( + deps.activity?.snapshots().map(snapshot => [snapshot.kiloSessionId, snapshot]) + ); + for (const snapshot of deps.sessions) { + const task = deps.tasks.get(snapshot.kiloSessionId); + if (!task && snapshots.has(snapshot.kiloSessionId)) continue; + const waitingOn = + task?.kind === 'preparation' + ? 'preparation' + : task?.kind === 'finalizing' + ? 'finalizing' + : snapshot.pendingInputs?.size + ? 'input' + : 'model'; + snapshots.set(snapshot.kiloSessionId, { + kiloSessionId: snapshot.kiloSessionId, + state: task?.kind === 'finalizing' ? 'finalizing' : task ? 'active' : 'idle', + idleForMs: Math.max(0, now - snapshot.lastActivityAt), + ...(task ? { waitingOn } : {}), + }); + } + const sessions = [...snapshots.values()]; + const active = sessions.filter(snapshot => snapshot.state !== 'idle'); return { state: - tasks.length === 0 + active.length === 0 ? 'idle' - : tasks.every(task => task.kind === 'finalizing') + : active.every(snapshot => snapshot.state === 'finalizing') ? 'finalizing' : 'active', - activeKiloSessions: deps.tasks.size, + activeKiloSessions: active.length, pendingMessages: deps.tasks.size, kilo: { ready: deps.kiloReady && !deps.signal?.aborted }, - sessions: deps.sessions.map(snapshot => { - const task = deps.tasks.get(snapshot.kiloSessionId); - const waitingOn = - task?.kind === 'preparation' - ? 'preparation' - : task?.kind === 'finalizing' - ? 'finalizing' - : snapshot.pendingInputs?.size - ? 'input' - : 'model'; - return { - kiloSessionId: snapshot.kiloSessionId, - state: task?.kind === 'finalizing' ? 'finalizing' : task ? 'active' : 'idle', - idleForMs: Math.max(0, now - snapshot.lastActivityAt), - ...(task ? { waitingOn } : {}), - }; - }), + sessions, }; } +export async function refreshHeartbeatPayload(deps: HandlerDeps): Promise { + const { activity, kiloRuntimes } = deps; + if (activity && kiloRuntimes) { + const rootsByDirectory = new Map(); + for (const { kiloSessionId } of activity.snapshots()) { + const directory = directoryForSession(kiloSessionId); + if (!directory) continue; + const roots = rootsByDirectory.get(directory) ?? []; + roots.push(kiloSessionId); + rootsByDirectory.set(directory, roots); + } + await Promise.all( + [...rootsByDirectory].map(async ([directory, roots]) => { + const runtime = kiloRuntimes.get(directory); + if (!runtime) return; + const revisions = new Map(roots.map(root => [root, activity.revision(root)])); + try { + const statuses = await withKiloRequestDeadline( + signal => runtime.kiloClient.getSessionStatuses(directory, signal), + deps.signal ? AbortSignal.any([deps.signal, runtime.signal]) : runtime.signal + ); + if ( + runtime.signal.aborted || + deps.signal?.aborted || + kiloRuntimes.get(directory) !== runtime + ) + return; + activity.reconcile( + statuses, + roots.filter( + root => + directoryForSession(root) === directory && + activity.revision(root) === revisions.get(root) + ) + ); + } catch { + return; + } + }) + ); + } + return buildHeartbeatPayload(deps); +} + function startSessionTask( session: SessionRequestIdentity, identity: TaskIdentity, @@ -158,6 +344,7 @@ function startSessionTask( done: completion.promise, }; deps.tasks.set(session.kiloSessionId, task); + if (identity.kind !== 'preparation') deps.activity?.markActive(session.kiloSessionId); const snapshot = deps.sessions.find(item => item.kiloSessionId === session.kiloSessionId); if (snapshot) { snapshot.lastActivityAt = Date.now(); @@ -185,6 +372,7 @@ function startSessionTask( clearTimeout(timeout); if (deps.tasks.get(session.kiloSessionId) === task) { deps.tasks.delete(session.kiloSessionId); + deps.activity?.reconcile({}, [session.kiloSessionId]); const snapshot = deps.sessions.find(item => item.kiloSessionId === session.kiloSessionId); if (snapshot) { snapshot.lastActivityAt = Date.now(); @@ -231,6 +419,59 @@ export async function handleControlRequest( deps.kiloRuntimes?.shutdown(); return ok({ shuttingDown: true }); } + if (operation === 'worktree.prepareDeletion' || operation === 'worktree.delete') { + const parsed = worktreeDeletePayloadSchema.safeParse(payload); + if (!parsed.success) return fail('protocol_error', 'Invalid payload', false); + const input = parsed.data; + try { + validateWorktreeDirectory(input); + } catch { + return fail('protocol_error', 'Invalid worktree directory', false); + } + const kiloRuntimes = deps.kiloRuntimes; + if (!kiloRuntimes) return missingKilo(); + try { + const fenced = fenceDirectoryOperations(input.directory); + const tasks = [...deps.tasks.values()].filter( + task => task.session.directory === input.directory + ); + for (const task of tasks) { + task.controller.abort(new ControlTaskCancellation('cancelled', 'Worktree deleted')); + } + const results = await Promise.all(tasks.map(task => task.done)); + await fenced; + if (results.some((result, index) => !result.ok && tasks[index]?.kind !== 'preparation')) { + return fail('not_ready', 'Worktree cancellation is incomplete', true); + } + const runtime = kiloRuntimes.get(input.directory); + const client = + deps.worktreeCleanupClient ?? + (runtime ? createWorktreeKiloCleanupClient(runtime.kiloClient.serverUrl) : undefined); + const cleanupDeps = { + client, + detachRoot: (id: string) => { + deps.activity?.detach(id); + const index = deps.sessions.findIndex(snapshot => snapshot.kiloSessionId === id); + if (index !== -1) deps.sessions.splice(index, 1); + }, + detachTerminals: async (directory: string) => { + await deps.terminalRuntime?.detachDirectory(directory); + }, + retireDirectory: async (directory: string) => { + await kiloRuntimes.deleteDirectory(directory); + }, + }; + if (operation === 'worktree.prepareDeletion') { + return ok({ + prepared: true, + sessionIds: await prepareWorktreeDeletion(input, cleanupDeps), + }); + } + return ok(await deleteWorktree(input, cleanupDeps)); + } catch { + return fail('not_ready', 'Worktree cleanup is incomplete', true); + } + } if (!SESSION_OPERATION_SET.has(operation)) { return fail('unknown_operation', 'Unknown operation', false); } @@ -245,6 +486,14 @@ export async function handleControlRequest( return missingKilo(); } + const attachedDirectory = directoryForSession(session.kiloSessionId); + if ( + operation === 'session.detach' && + attachedDirectory !== undefined && + attachedDirectory !== session.directory + ) { + return fail('unauthorized', 'Session directory mismatch', false); + } const current = deps.tasks.get(session.kiloSessionId); if ( current && @@ -254,6 +503,20 @@ export async function handleControlRequest( return fail('unauthorized', 'Session task ownership mismatch', false); } + try { + assertDirectoryActive(session.directory); + return await handleSessionControlRequest(operation, session, payload, deps); + } catch { + return fail('not_ready', 'Worktree is being deleted', false); + } +} + +async function handleSessionControlRequest( + operation: string, + session: SessionRequestIdentity, + payload: unknown, + deps: HandlerDeps +): Promise { switch (operation) { case 'session.attach': return handleAttach(session, payload, deps); @@ -365,6 +628,7 @@ async function handleAttach( if (owned.signal.aborted) { return fail('not_ready', 'Session attachment cancelled', true); } + if (result.ok) deps.activity?.attach(session.kiloSessionId); return result; } ); @@ -389,6 +653,7 @@ async function handleDetach( deps.kiloRuntimes?.detach(session); const terminalCleanup = deps.terminalRuntime?.detachSession(session); forgetAttachedRoot(session.kiloSessionId, session.directory); + deps.activity?.detach(session.kiloSessionId); const index = deps.sessions.findIndex(item => item.kiloSessionId === session.kiloSessionId); if (index !== -1) deps.sessions.splice(index, 1); await terminalCleanup; @@ -838,8 +1103,16 @@ async function handleSync( status: deps.tasks.has(session.kiloSessionId) ? { type: 'busy' } : (statuses[session.kiloSessionId] ?? { type: 'idle' }), - questions: questions.map(item => item.request), - permissions: permissions.map(item => item.request), + questions: questions.map(({ request }) => + request.sessionID === session.kiloSessionId + ? request + : { ...request, rootKiloSessionId: session.kiloSessionId } + ), + permissions: permissions.map(({ request }) => + request.sessionID === session.kiloSessionId + ? request + : { ...request, rootKiloSessionId: session.kiloSessionId } + ), }); } catch (error) { return kiloFailure(error); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts index e26476493d..ef9fe7c616 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts @@ -14,6 +14,10 @@ function fakeClient(): SandboxControlClient { }; } +async function flushAsyncWork(): Promise { + await new Promise(resolve => setImmediate(resolve)); +} + describe('maybeStartSandboxControlClient', () => { it('returns null when env is missing', () => { const created: SandboxControlClientOptions[] = []; @@ -126,8 +130,7 @@ describe('maybeStartSandboxControlClient', () => { expect(started).toBe(client); expect(received?.onRequest).toBe(onRequest); - await Promise.resolve(); - await Promise.resolve(); + await flushAsyncWork(); expect(events).toEqual([ { event: 'sandbox.ready', payload: { kiloReady: true, globalFeedAttached: true } }, ]); @@ -164,8 +167,7 @@ describe('maybeStartSandboxControlClient', () => { } ); - await Promise.resolve(); - await Promise.resolve(); + await flushAsyncWork(); expect(events).toEqual([ { event: 'sandbox.ready', payload: { kiloReady: true, globalFeedAttached: true } }, { event: 'sandbox.heartbeat', payload: heartbeatPayload }, @@ -205,8 +207,7 @@ describe('maybeStartSandboxControlClient', () => { } ); - await Promise.resolve(); - await Promise.resolve(); + await flushAsyncWork(); expect(events).toEqual([]); received?.onDisconnected?.(); @@ -247,8 +248,7 @@ describe('maybeStartSandboxControlClient', () => { } ); - await Promise.resolve(); - await Promise.resolve(); + await flushAsyncWork(); received?.onDisconnected?.(); expect(connected).toEqual([client]); expect(events).toEqual([ @@ -258,6 +258,233 @@ describe('maybeStartSandboxControlClient', () => { client.close(); }); + it.each(['readiness', 'close', 'disconnect'] as const)( + 'discards an asynchronous status poll after %s is lost', + async loss => { + const timers = spyOn(globalThis, 'setTimeout'); + const events: Array<{ event: string; payload: unknown }> = []; + const pending = Promise.withResolvers(); + let ready = true; + let polls = 0; + let disconnected = 0; + let received: SandboxControlClientOptions | undefined; + const started = maybeStartSandboxControlClient( + { + SANDBOX_CONTROL_URL: 'wss://example.test/sandbox-control/sbx_1', + SANDBOX_CONTROL_CREDENTIAL: 'secret', + PROVIDER_INSTANCE_ID: 'inst_1', + }, + () => {}, + { + wrapperVersion: '2.4.0', + isReady: () => ready, + onDisconnected: () => { + disconnected++; + }, + getHeartbeatPayload: async () => { + polls++; + return pending.promise; + }, + createClient: options => { + received = options; + return { + connect: async () => {}, + close: () => {}, + sendEvent: (event, payload) => { + events.push({ event, payload }); + return true; + }, + }; + }, + } + ); + try { + await flushAsyncWork(); + expect(polls).toBe(1); + expect(events).toEqual([ + { event: 'sandbox.ready', payload: { kiloReady: true, globalFeedAttached: true } }, + ]); + if (loss === 'readiness') ready = false; + else if (loss === 'close') started?.close(); + else received?.onDisconnected?.(); + pending.resolve({ state: 'active' }); + await flushAsyncWork(); + expect(events).toEqual([ + { event: 'sandbox.ready', payload: { kiloReady: true, globalFeedAttached: true } }, + ]); + expect(polls).toBe(1); + expect(disconnected).toBe(loss === 'disconnect' ? 1 : 0); + expect(timers.mock.calls.filter(([, ms]) => ms === 30_000)).toHaveLength(0); + } finally { + started?.close(); + pending.resolve({ state: 'idle' }); + await flushAsyncWork(); + timers.mockRestore(); + } + } + ); + + it('awaits status results and permits at most one heartbeat poll at a time', async () => { + const timers = spyOn(globalThis, 'setTimeout'); + const events: Array<{ event: string; payload: unknown }> = []; + const pending = Promise.withResolvers(); + let polls = 0; + const started = maybeStartSandboxControlClient( + { + SANDBOX_CONTROL_URL: 'wss://example.test/sandbox-control/sbx_1', + SANDBOX_CONTROL_CREDENTIAL: 'secret', + PROVIDER_INSTANCE_ID: 'inst_1', + }, + () => {}, + { + wrapperVersion: '2.4.0', + getHeartbeatPayload: async () => { + polls++; + return polls === 1 ? { state: 'idle', poll: 1 } : pending.promise; + }, + createClient: () => ({ + connect: async () => {}, + close: () => {}, + sendEvent: (event, payload) => { + events.push({ event, payload }); + return true; + }, + }), + } + ); + try { + await flushAsyncWork(); + expect(events).toEqual([ + { event: 'sandbox.ready', payload: { kiloReady: true, globalFeedAttached: true } }, + { event: 'sandbox.heartbeat', payload: { state: 'idle', poll: 1 } }, + ]); + const tick = timers.mock.calls.find(([, ms]) => ms === 30_000)?.[0]; + if (typeof tick !== 'function') throw new Error('Missing heartbeat timer'); + tick(); + tick(); + await flushAsyncWork(); + expect(polls).toBe(2); + expect(events).toHaveLength(2); + pending.resolve({ state: 'active', poll: 2 }); + await flushAsyncWork(); + expect(events.filter(item => item.event === 'sandbox.heartbeat')).toEqual([ + { event: 'sandbox.heartbeat', payload: { state: 'idle', poll: 1 } }, + { event: 'sandbox.heartbeat', payload: { state: 'active', poll: 2 } }, + ]); + started?.close(); + tick(); + await flushAsyncWork(); + expect(polls).toBe(2); + } finally { + started?.close(); + pending.resolve({ state: 'idle' }); + await flushAsyncWork(); + timers.mockRestore(); + } + }); + + it('retries failed status polling without emitting fabricated idle state or logging private errors', async () => { + const timers = spyOn(globalThis, 'setTimeout'); + const events: Array<{ event: string; payload: unknown }> = []; + const logs: string[] = []; + let polls = 0; + let disconnected = 0; + const started = maybeStartSandboxControlClient( + { + SANDBOX_CONTROL_URL: 'wss://example.test/sandbox-control/sbx_1', + SANDBOX_CONTROL_CREDENTIAL: 'secret', + PROVIDER_INSTANCE_ID: 'inst_1', + }, + message => logs.push(message), + { + wrapperVersion: '2.4.0', + onDisconnected: () => { + disconnected++; + }, + getHeartbeatPayload: () => { + if (++polls === 1) { + const failed = Promise.reject(new Error('private-status-credential')); + void failed.catch(() => {}); + return failed; + } + return Promise.resolve({ + state: 'active', + sessions: [{ kiloSessionId: 'kilo_1', state: 'finalizing' }], + }); + }, + createClient: () => ({ + connect: async () => {}, + close: () => {}, + sendEvent: (event, payload) => { + events.push({ event, payload }); + return true; + }, + }), + } + ); + try { + await flushAsyncWork(); + expect(events).toEqual([ + { event: 'sandbox.ready', payload: { kiloReady: true, globalFeedAttached: true } }, + ]); + expect(disconnected).toBe(0); + const tick = timers.mock.calls.find(([, ms]) => ms === 30_000)?.[0]; + if (typeof tick !== 'function') throw new Error('Missing heartbeat retry'); + tick(); + await flushAsyncWork(); + expect(polls).toBe(2); + expect(events.at(-1)).toEqual({ + event: 'sandbox.heartbeat', + payload: { state: 'active', sessions: [{ kiloSessionId: 'kilo_1', state: 'finalizing' }] }, + }); + expect(disconnected).toBe(0); + expect(logs.join('\n')).not.toContain('private-status-credential'); + } finally { + started?.close(); + timers.mockRestore(); + } + }); + + it.each(['false', 'throw'] as const)( + 'retires the connection when an awaited heartbeat delivery returns %s', + async failure => { + const timers = spyOn(globalThis, 'setTimeout'); + let disconnected = 0; + const started = maybeStartSandboxControlClient( + { + SANDBOX_CONTROL_URL: 'wss://example.test/sandbox-control/sbx_1', + SANDBOX_CONTROL_CREDENTIAL: 'secret', + PROVIDER_INSTANCE_ID: 'inst_1', + }, + () => {}, + { + wrapperVersion: '2.4.0', + getHeartbeatPayload: async () => ({ state: 'idle' }), + onDisconnected: () => { + disconnected++; + }, + createClient: () => ({ + connect: async () => {}, + close: () => {}, + sendEvent: event => { + if (event !== 'sandbox.heartbeat') return true; + if (failure === 'throw') throw new Error('private transport error'); + return false; + }, + }), + } + ); + try { + await flushAsyncWork(); + expect(disconnected).toBe(1); + expect(timers.mock.calls.filter(([, ms]) => ms === 30_000)).toHaveLength(0); + } finally { + started?.close(); + timers.mockRestore(); + } + } + ); + it('clears reconnect after close and omits credentials from connect failure logs', async () => { const credential = 'super-secret-token'; const logs: string[] = []; @@ -290,8 +517,7 @@ describe('maybeStartSandboxControlClient', () => { } ); - await Promise.resolve(); - await Promise.resolve(); + await flushAsyncWork(); started?.close(); received?.onDisconnected?.(); received?.log?.('sandbox control reconnect scheduled in 0ms (socket closed)'); @@ -336,8 +562,7 @@ describe('startSandboxControlEventFeed', () => { started = true; }); - await Promise.resolve(); - await Promise.resolve(); + await flushAsyncWork(); expect(started).toBe(false); expect(received).toEqual([]); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts index fd345a2209..488c9850be 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts @@ -153,12 +153,13 @@ export function maybeStartSandboxControlClient( } const createClient = options.createClient ?? createSandboxControlClient; - let heartbeat: ReturnType | null = null; + let heartbeat: ReturnType | null = null; + let heartbeatInFlight = false; let closed = false; function stopHeartbeat(): void { if (!heartbeat) return; - clearInterval(heartbeat); + clearTimeout(heartbeat); heartbeat = null; } @@ -169,15 +170,35 @@ export function maybeStartSandboxControlClient( options.onDisconnected?.(); } - function sendHeartbeat(active: SandboxControlClient): void { + async function sendHeartbeat(active: SandboxControlClient): Promise { + if ( + closed || + heartbeatInFlight || + options.isReady?.() === false || + !options.getHeartbeatPayload + ) + return; + heartbeatInFlight = true; try { - if (!active.sendEvent?.('sandbox.heartbeat', options.getHeartbeatPayload?.())) { + const payload = await options.getHeartbeatPayload(); + if (closed || options.isReady?.() === false) return; + try { + if (!active.sendEvent?.('sandbox.heartbeat', payload)) handleDisconnected(); + } catch { handleDisconnected(); } } catch { - handleDisconnected(); + if (!closed) log('sandbox control heartbeat failed'); + } finally { + heartbeatInFlight = false; + if (!closed && options.isReady?.() !== false) { + heartbeat = setTimeout(() => { + heartbeat = null; + void sendHeartbeat(active); + }, HEARTBEAT_INTERVAL_MS); + heartbeat.unref(); + } } - if (options.isReady?.() === false) stopHeartbeat(); } function handleConnected(active: SandboxControlClient): void { @@ -188,10 +209,7 @@ export function maybeStartSandboxControlClient( } if (options.getHeartbeatPayload) { stopHeartbeat(); - sendHeartbeat(active); - if (closed) return; - heartbeat = setInterval(() => sendHeartbeat(active), HEARTBEAT_INTERVAL_MS); - heartbeat.unref(); + void sendHeartbeat(active); } options.onConnected?.(active); } diff --git a/services/cloud-agent-next/wrapper/src/control/session-directories.test.ts b/services/cloud-agent-next/wrapper/src/control/session-directories.test.ts index c737eb7ce1..b82d6124d1 100644 --- a/services/cloud-agent-next/wrapper/src/control/session-directories.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/session-directories.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it } from 'bun:test'; import { + directoriesForRoot, directoryForSession, forgetAttachedRoot, rememberAttachedRoot, @@ -43,7 +44,7 @@ describe('session directory root mappings', () => { expect(rootForSession('unknown')).toBeUndefined(); }); - it('does not infer an unknown session or child root from its directory', () => { + it('fails closed for named sessions without known lineage even in a unique directory', () => { rememberAttachedRoot('root', '/ws'); rememberChildSession({ childId: 'unknown', directory: '/ws' }); @@ -51,6 +52,8 @@ describe('session directory root mappings', () => { expect(rootForSession('unknown', '/ws')).toBeUndefined(); expect(rootForSession('child', '/ws')).toBeUndefined(); + expect(rootForSession('', '/ws')).toBeUndefined(); + expect(rootForSession(undefined, '/ws')).toBe('root'); expect(directoryForSession('unknown')).toBeUndefined(); expect(directoryForSession('child')).toBeUndefined(); }); @@ -93,6 +96,10 @@ describe('session directory root mappings', () => { expect(rootForSession('second', '/ws')).toBe('second'); expect(rootForSession('first-grandchild', '/ws')).toBe('first'); expect(directoryForSession('first-grandchild')).toBe('/ws'); + expect(rootForSession('first-child')).toBe('first'); + expect(rootForSession('second-child')).toBe('second'); + expect(directoriesForRoot('first', '/ws')).toEqual(['/ws']); + expect(directoriesForRoot('second', '/ws')).toEqual(['/ws']); expect(rootForSession(undefined, '/ws')).toBeUndefined(); forgetAttachedRoot('second', '/ws'); @@ -119,6 +126,102 @@ describe('session directory root mappings', () => { expect(rootForSession('child')).toBe('first'); }); + it('rejects late children and nested descendants after their parent root detached', () => { + rememberAttachedRoot('first', '/ws'); + rememberAttachedRoot('second', '/ws'); + rememberChildSession({ childId: 'child', parentId: 'second' }); + forgetAttachedRoot('second'); + + rememberChildSession({ childId: 'late-child', parentId: 'second', directory: '/ws' }); + rememberChildSession({ childId: 'late-grandchild', parentId: 'child', directory: '/ws' }); + + for (const id of ['second', 'child', 'late-child', 'late-grandchild']) { + expect(rootForSession(id, '/ws')).toBeUndefined(); + expect(directoryForSession(id)).toBeUndefined(); + } + expect(rootForSession(undefined, '/ws')).toBe('first'); + }); + + it('restores exact lineage after reattaching a previously detached root', () => { + rememberAttachedRoot('first', '/ws'); + rememberAttachedRoot('second', '/ws'); + rememberChildSession({ childId: 'child', parentId: 'second' }); + forgetAttachedRoot('second'); + + rememberAttachedRoot('second', '/ws'); + expect(rootForSession('child', '/ws')).toBeUndefined(); + rememberChildSession({ childId: 'child', parentId: 'second' }); + + expect(rootForSession('second', '/ws')).toBe('second'); + expect(rootForSession('child', '/ws')).toBe('second'); + expect(rootForSession(undefined, '/ws')).toBeUndefined(); + }); + + it('retains known-lineage descendants in other directories without polling sibling roots', () => { + rememberAttachedRoot('root', '/ws'); + rememberAttachedRoot('sibling', '/other'); + rememberChildSession({ childId: 'child', parentId: 'root', directory: '/child-worktree' }); + rememberChildSession({ childId: 'nested', parentId: 'child' }); + rememberChildSession({ childId: 'spoof', parentId: 'root', directory: '/other' }); + + expect(rootForSession('child', '/child-worktree')).toBe('root'); + expect(rootForSession('nested', '/child-worktree')).toBe('root'); + expect(directoryForSession('nested')).toBe('/child-worktree'); + expect(rootForSession('child', '/other')).toBeUndefined(); + expect(rootForSession('root', '/other')).toBeUndefined(); + expect(rootForSession('spoof')).toBeUndefined(); + expect(directoryForSession('spoof')).toBeUndefined(); + expect(directoriesForRoot('root', '/ws')).toEqual(['/ws', '/child-worktree']); + expect(directoriesForRoot('sibling', '/other')).toEqual(['/other']); + + forgetAttachedRoot('root'); + expect(rootForSession('nested', '/child-worktree')).toBeUndefined(); + expect(directoryForSession('child')).toBeUndefined(); + expect(directoryForSession('nested')).toBeUndefined(); + expect(rootForSession('sibling', '/other')).toBe('sibling'); + }); + + it('excludes existing descendants when their external directory gains an independent root', () => { + rememberAttachedRoot('root_a', '/a'); + rememberChildSession({ childId: 'child_a', parentId: 'root_a', directory: '/child-dir' }); + rememberChildSession({ childId: 'nested_a', parentId: 'child_a' }); + rememberChildSession({ childId: 'external_a', parentId: 'root_a', directory: '/external' }); + expect(rootForSession('child_a')).toBe('root_a'); + expect(directoriesForRoot('root_a', '/a')).toEqual(['/a', '/child-dir', '/external']); + + rememberAttachedRoot('root_b', '/child-dir'); + + for (const sessionId of ['child_a', 'nested_a']) { + expect(rootForSession(sessionId)).toBeUndefined(); + expect(rootForSession(sessionId, '/child-dir')).toBeUndefined(); + expect(directoryForSession(sessionId)).toBe('/child-dir'); + } + expect(rootForSession('external_a')).toBe('root_a'); + expect(rootForSession('root_b')).toBe('root_b'); + expect(directoriesForRoot('root_a', '/a')).toEqual(['/a', '/external']); + expect(directoriesForRoot('root_b', '/child-dir')).toEqual(['/child-dir']); + expect(directoriesForRoot('unattached', '/child-dir')).toEqual([]); + + rememberChildSession({ childId: 'late', parentId: 'child_a', directory: '/another-external' }); + expect(rootForSession('late')).toBeUndefined(); + expect(directoryForSession('late')).toBeUndefined(); + + forgetAttachedRoot('root_b'); + expect(rootForSession('child_a')).toBe('root_a'); + expect(rootForSession('nested_a')).toBe('root_a'); + expect(directoriesForRoot('root_a', '/a')).toEqual(['/a', '/child-dir', '/external']); + }); + + it('removes stale directory fallback when an attached root moves', () => { + rememberAttachedRoot('root', '/old'); + rememberAttachedRoot('root', '/new'); + + expect(rootForSession(undefined, '/old')).toBeUndefined(); + expect(rootForSession('root', '/old')).toBeUndefined(); + expect(rootForSession(undefined, '/new')).toBe('root'); + expect(directoryForSession('root')).toBe('/new'); + }); + it('preserves a root when the requested detach directory does not match', () => { rememberAttachedRoot('root', '/ws'); diff --git a/services/cloud-agent-next/wrapper/src/control/session-directories.ts b/services/cloud-agent-next/wrapper/src/control/session-directories.ts index cc8137ce4a..db4ea84b69 100644 --- a/services/cloud-agent-next/wrapper/src/control/session-directories.ts +++ b/services/cloud-agent-next/wrapper/src/control/session-directories.ts @@ -1,36 +1,50 @@ const directories = new Map(); const rootBySessionId = new Map(); const rootsByDirectory = new Map>(); +const detachedSessionIds = new Set(); + +function removeDirectoryRoot(directory: string, rootKiloSessionId: string): void { + const roots = rootsByDirectory.get(directory); + if (!roots) return; + roots.delete(rootKiloSessionId); + if (roots.size === 0) rootsByDirectory.delete(directory); +} export function rememberSessionDirectory(kiloSessionId: string, directory: string): void { directories.set(kiloSessionId, directory); } export function rememberAttachedRoot(rootKiloSessionId: string, directory: string): void { + const previousDirectory = directories.get(rootKiloSessionId); + if (previousDirectory && previousDirectory !== directory) { + removeDirectoryRoot(previousDirectory, rootKiloSessionId); + } directories.set(rootKiloSessionId, directory); rootBySessionId.set(rootKiloSessionId, rootKiloSessionId); + detachedSessionIds.delete(rootKiloSessionId); const roots = rootsByDirectory.get(directory) ?? new Set(); roots.add(rootKiloSessionId); rootsByDirectory.set(directory, roots); } -export function forgetAttachedRoot(rootKiloSessionId: string, directory: string): void { +export function forgetAttachedRoot(rootKiloSessionId: string, directory?: string): void { + const attachedDirectory = directories.get(rootKiloSessionId); if ( - rootBySessionId.get(rootKiloSessionId) !== rootKiloSessionId || - directories.get(rootKiloSessionId) !== directory + directory !== undefined && + (rootBySessionId.get(rootKiloSessionId) !== rootKiloSessionId || + attachedDirectory !== directory) ) { return; } - - for (const [kiloSessionId, root] of rootBySessionId) { + if (attachedDirectory) removeDirectoryRoot(attachedDirectory, rootKiloSessionId); + detachedSessionIds.add(rootKiloSessionId); + for (const [sessionId, root] of rootBySessionId) { if (root !== rootKiloSessionId) continue; - rootBySessionId.delete(kiloSessionId); - directories.delete(kiloSessionId); + rootBySessionId.delete(sessionId); + directories.delete(sessionId); + detachedSessionIds.add(sessionId); } - - const roots = rootsByDirectory.get(directory); - roots?.delete(rootKiloSessionId); - if (roots?.size === 0) rootsByDirectory.delete(directory); + directories.delete(rootKiloSessionId); } export function rememberChildSession(input: { @@ -39,20 +53,35 @@ export function rememberChildSession(input: { directory?: string; }): void { if (!input.parentId) return; - const root = rootBySessionId.get(input.parentId); + if (rootBySessionId.has(input.childId)) return; + if (detachedSessionIds.has(input.parentId)) { + detachedSessionIds.add(input.childId); + return; + } + const root = rootForSession(input.parentId); if (!root) return; - const existing = rootBySessionId.get(input.childId); - if (existing && existing !== root) return; + const childDirectory = + input.directory ?? directories.get(input.parentId) ?? directories.get(root); + const directoryRoots = childDirectory ? rootsByDirectory.get(childDirectory) : undefined; + if (directoryRoots && !directoryRoots.has(root)) return; rootBySessionId.set(input.childId, root); - const directory = input.directory ?? directories.get(input.parentId) ?? directories.get(root); - if (directory) directories.set(input.childId, directory); + detachedSessionIds.delete(input.childId); + if (childDirectory) directories.set(input.childId, childDirectory); } export function rootForSession( kiloSessionId: string | undefined, directory?: string ): string | undefined { - if (kiloSessionId) return rootBySessionId.get(kiloSessionId); + if (kiloSessionId !== undefined) { + const sessionDirectory = directories.get(kiloSessionId); + if (directory !== undefined && sessionDirectory !== directory) return undefined; + const root = rootBySessionId.get(kiloSessionId); + if (!root) return undefined; + const directoryRoots = sessionDirectory ? rootsByDirectory.get(sessionDirectory) : undefined; + if (directoryRoots && !directoryRoots.has(root)) return undefined; + return root; + } const roots = directory ? rootsByDirectory.get(directory) : undefined; return roots?.size === 1 ? roots.values().next().value : undefined; } @@ -64,13 +93,17 @@ export function directoriesForRoot(rootKiloSessionId: string, directory: string) const childDirectory = directories.get(sessionId); if (childDirectory) result.add(childDirectory); } - return [...result]; + return [...result].filter(directory => { + const roots = rootsByDirectory.get(directory); + return !roots || roots.has(rootKiloSessionId); + }); } export function resetSessionDirectoryState(): void { directories.clear(); rootBySessionId.clear(); rootsByDirectory.clear(); + detachedSessionIds.clear(); } export function directoryForSession(kiloSessionId: string | undefined): string | undefined { diff --git a/services/cloud-agent-next/wrapper/src/control/terminal-runtime.test.ts b/services/cloud-agent-next/wrapper/src/control/terminal-runtime.test.ts index 424820dff1..751f8aad25 100644 --- a/services/cloud-agent-next/wrapper/src/control/terminal-runtime.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/terminal-runtime.test.ts @@ -377,54 +377,141 @@ describe('control terminal PTY ownership', () => { expect(deleted).toEqual([{ ptyId: 'pty_first', directory: firstSession.directory }]); }); - it('fences every PTY operation to its owning session and preserves unrelated sessions on detach', async () => { - const resized: Array<{ ptyId: string; directory?: string }> = []; - const deleted: Array<{ ptyId: string; directory?: string }> = []; - let createdCount = 0; - const kiloClient = fakeKilo({ - createPty: async input => makePty(input.cwd, `pty_${++createdCount}`), - resizePty: async (ptyId, _size, directory) => { - resized.push({ ptyId, directory }); - return makePty(directory ?? '', ptyId); - }, - deletePty: async (ptyId, directory) => { - deleted.push({ ptyId, directory }); - return true; - }, - }); - const runtime = createRuntime(kiloClient); - attach(runtime, firstSession); - attach(runtime, secondSession); - await runtime.create(firstSession, creationPayload(firstOperationId)); - await runtime.create(secondSession, creationPayload(secondOperationId)); + it.each([secondSession, { ...secondSession, directory: firstSession.directory }])( + 'fences every PTY operation from a sibling in $directory', + async secondSession => { + const resized: Array<{ ptyId: string; directory?: string }> = []; + const deleted: Array<{ ptyId: string; directory?: string }> = []; + let createdCount = 0; + const kiloClient = fakeKilo({ + createPty: async input => makePty(input.cwd, `pty_${++createdCount}`), + resizePty: async (ptyId, _size, directory) => { + resized.push({ ptyId, directory }); + return makePty(directory ?? '', ptyId); + }, + deletePty: async (ptyId, directory) => { + deleted.push({ ptyId, directory }); + return true; + }, + }); + const runtime = createRuntime(kiloClient); + attach(runtime, firstSession); + attach(runtime, secondSession); + await runtime.create(firstSession, creationPayload(firstOperationId)); + await runtime.create(secondSession, creationPayload(secondOperationId)); - expect( - await terminalFailure(runtime.resize(secondSession, { ptyId: 'pty_1', cols: 100, rows: 30 })) - ).toMatchObject({ code: 'unauthorized', retryable: false }); - expect(await terminalFailure(runtime.close(secondSession, { ptyId: 'pty_1' }))).toMatchObject({ - code: 'unauthorized', - retryable: false, - }); - expect( - await terminalFailure( - runtime.resize( - { ...firstSession, directory: secondSession.directory }, - { ptyId: 'pty_1', cols: 100, rows: 30 } + expect( + await terminalFailure( + runtime.resize(secondSession, { ptyId: 'pty_1', cols: 100, rows: 30 }) ) - ) - ).toMatchObject({ code: 'unauthorized', retryable: false }); + ).toMatchObject({ code: 'unauthorized', retryable: false }); + expect(await terminalFailure(runtime.close(secondSession, { ptyId: 'pty_1' }))).toMatchObject( + { + code: 'unauthorized', + retryable: false, + } + ); + expect( + await terminalFailure(runtime.connect(secondSession, connectionPayload({ ptyId: 'pty_1' }))) + ).toMatchObject({ code: 'unauthorized', retryable: false }); + expect( + await terminalFailure(runtime.create(secondSession, creationPayload(firstOperationId))) + ).toMatchObject({ code: 'idempotency_conflict', retryable: false }); + expect( + await terminalFailure( + runtime.resize( + { ...firstSession, directory: '/workspace/unowned' }, + { ptyId: 'pty_1', cols: 100, rows: 30 } + ) + ) + ).toMatchObject({ code: 'unauthorized', retryable: false }); - await runtime.detachSession(firstSession); - expect(deleted).toEqual([{ ptyId: 'pty_1', directory: firstSession.directory }]); - expect(await terminalFailure(runtime.create(firstSession, creationPayload()))).toMatchObject({ - code: 'not_ready', - message: 'Terminal session is not attached', - }); + await runtime.detachSession(firstSession); + expect(deleted).toEqual([{ ptyId: 'pty_1', directory: firstSession.directory }]); + expect(await terminalFailure(runtime.create(firstSession, creationPayload()))).toMatchObject({ + code: 'not_ready', + message: 'Terminal session is not attached', + }); - expect(await runtime.resize(secondSession, { ptyId: 'pty_2', cols: 110, rows: 40 })).toEqual({ - pty: makePty(secondSession.directory, 'pty_2'), + expect(await runtime.resize(secondSession, { ptyId: 'pty_2', cols: 110, rows: 40 })).toEqual({ + pty: makePty(secondSession.directory, 'pty_2'), + }); + expect(resized).toEqual([{ ptyId: 'pty_2', directory: secondSession.directory }]); + attach(runtime, firstSession); + expect(await runtime.create(firstSession, creationPayload(firstOperationId))).toEqual({ + pty: makePty(firstSession.directory, 'pty_3'), + }); + } + ); + + it('fences all same-directory PTYs before waiting for pending creation and preserves another worktree', async () => { + const sibling = { ...secondSession, directory: firstSession.directory }; + const unrelated = { + ...secondSession, + sessionId: 'workspace_other', + kiloSessionId: 'kilo_other', + }; + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const deleted: Array<{ ptyId: string; directory?: string }> = []; + let created = 0; + const runtime = createRuntime( + fakeKilo({ + createPty: async input => { + const id = `pty_${++created}`; + if (id === 'pty_3') { + started.resolve(); + await release.promise; + } + return makePty(input.cwd, id); + }, + deletePty: async (ptyId, directory) => { + deleted.push({ ptyId, directory }); + return true; + }, + }) + ); + for (const identity of [firstSession, sibling, unrelated]) attach(runtime, identity); + await runtime.create(sibling, creationPayload(secondOperationId)); + await runtime.create(unrelated, creationPayload(crypto.randomUUID())); + const creation = terminalFailure( + runtime.create(firstSession, creationPayload(firstOperationId)) + ); + await started.promise; + let settled = false; + const detaching = runtime.detachDirectory(firstSession.directory).then(() => { + settled = true; }); - expect(resized).toEqual([{ ptyId: 'pty_2', directory: secondSession.directory }]); + try { + expect( + await terminalFailure(runtime.resize(sibling, { ptyId: 'pty_1', cols: 90, rows: 30 })) + ).toMatchObject({ code: 'not_ready' }); + expect(await terminalFailure(runtime.create(firstSession, creationPayload()))).toMatchObject({ + code: 'not_ready', + }); + expect(deleted).toEqual([{ ptyId: 'pty_1', directory: firstSession.directory }]); + expect(settled).toBe(false); + expect(await runtime.resize(unrelated, { ptyId: 'pty_2', cols: 90, rows: 30 })).toEqual({ + pty: makePty(unrelated.directory, 'pty_2'), + }); + release.resolve(); + expect(await creation).toMatchObject({ + code: 'not_ready', + message: 'Terminal session is no longer attached', + }); + await detaching; + expect(deleted).toEqual([ + { ptyId: 'pty_1', directory: firstSession.directory }, + { ptyId: 'pty_3', directory: firstSession.directory }, + ]); + attach(runtime, sibling); + expect(await runtime.create(sibling, creationPayload(secondOperationId))).toEqual({ + pty: makePty(sibling.directory, 'pty_4'), + }); + } finally { + release.resolve(); + await Promise.allSettled([creation, detaching]); + } }); it('keeps independent same-worktree roots eligible when a sibling detaches', async () => { @@ -580,6 +667,50 @@ describe('control terminal PTY ownership', () => { }); describe('control terminal reverse WebSocket bridge', () => { + it('closes every bridge in a deleted directory without closing an unrelated worktree', async () => { + const servers = createBridgeServers(); + const sibling = { ...secondSession, directory: firstSession.directory }; + const unrelated = { + ...secondSession, + sessionId: 'workspace_other', + kiloSessionId: 'kilo_other', + }; + let count = 0; + const deleted: string[] = []; + const runtime = createRuntime( + fakeKilo({ + serverUrl: servers.localUrl, + createPty: async input => makePty(input.cwd, `pty_${++count}`), + deletePty: async id => { + deleted.push(id); + return true; + }, + }), + servers.controlUrl + ); + try { + for (const identity of [firstSession, sibling, unrelated]) { + attach(runtime, identity); + const { pty } = await runtime.create(identity, creationPayload(crypto.randomUUID())); + await runtime.connect(identity, connectionPayload({ ptyId: pty.id })); + } + await runtime.detachDirectory(firstSession.directory); + expect(deleted).toEqual(['pty_1', 'pty_2']); + for (let index = 0; index < 2; index++) { + expect((await servers.reverseCloses.next()).code).toBe(1000); + expect((await servers.localCloses.next()).code).toBe(1000); + } + const unrelatedSocket = servers.reverseSockets[2]; + if (!unrelatedSocket) throw new Error('Expected unrelated bridge'); + unrelatedSocket.send('still connected'); + expect(await servers.localFrames.next()).toBe('still connected'); + expect(await servers.reverseFrames.next()).toBe('still connected'); + } finally { + runtime.shutdown(); + servers.stop(); + } + }); + it('authenticates the trusted reverse origin and relays initial, text, control, and binary frames unchanged', async () => { const servers = createBridgeServers('initial output'); const kiloClient = fakeKilo({ serverUrl: servers.localUrl }); diff --git a/services/cloud-agent-next/wrapper/src/control/terminal-runtime.ts b/services/cloud-agent-next/wrapper/src/control/terminal-runtime.ts index d00d0b2ec9..bdfedfa578 100644 --- a/services/cloud-agent-next/wrapper/src/control/terminal-runtime.ts +++ b/services/cloud-agent-next/wrapper/src/control/terminal-runtime.ts @@ -71,6 +71,7 @@ export class ControlTerminalRuntimeError extends Error { export type ControlTerminalRuntime = { rememberAttachedSession(identity: SessionRequestIdentity): void; detachSession(identity: SessionRequestIdentity): Promise; + detachDirectory(directory: string): Promise; create( identity: SessionRequestIdentity, payload: SessionTerminalCreatePayload @@ -370,6 +371,37 @@ export function createControlTerminalRuntime(options: { } } + async function detachSession(identity: SessionRequestIdentity): Promise { + const attached = attachedSessions.get(identity.sessionId); + if (!attached) return; + if (!sameSession(attached, identity)) { + throw new ControlTerminalRuntimeError( + 'unauthorized', + 'Terminal session ownership mismatch', + false + ); + } + + attachedSessions.delete(identity.sessionId); + + const pending: Promise[] = []; + for (const [operationId, operation] of operations) { + if (!sameSession(operation, attached)) continue; + operations.delete(operationId); + pending.push(operation.promise); + } + + for (const [ptyId, terminal] of terminals) { + if (!sameSession(terminal, attached)) continue; + const bridge = bridges.get(ptyId); + if (bridge) closeBridge(bridge, 1000, 'PTY session ended'); + terminals.delete(ptyId); + pending.push(terminal.kiloRuntime.kiloClient.deletePty(ptyId, terminal.directory)); + } + + await Promise.allSettled(pending); + } + return { rememberAttachedSession(identity) { const kiloRuntime = options.getKiloRuntime(identity.directory); @@ -411,35 +443,13 @@ export function createControlTerminalRuntime(options: { attachedSessions.set(identity.sessionId, { ...identity, wrapperInstanceId, kiloRuntime }); }, - async detachSession(identity) { - const attached = attachedSessions.get(identity.sessionId); - if (!attached) return; - if (!sameSession(attached, identity)) { - throw new ControlTerminalRuntimeError( - 'unauthorized', - 'Terminal session ownership mismatch', - false - ); - } + detachSession, - attachedSessions.delete(identity.sessionId); - - const pending: Promise[] = []; - for (const [operationId, operation] of operations) { - if (!sameSession(operation, attached)) continue; - operations.delete(operationId); - pending.push(operation.promise); - } - - for (const [ptyId, terminal] of terminals) { - if (!sameSession(terminal, attached)) continue; - const bridge = bridges.get(ptyId); - if (bridge) closeBridge(bridge, 1000, 'PTY session ended'); - terminals.delete(ptyId); - pending.push(terminal.kiloRuntime.kiloClient.deletePty(ptyId, terminal.directory)); - } - - await Promise.allSettled(pending); + async detachDirectory(directory) { + const sessions = [...attachedSessions.values()].filter( + session => session.directory === directory + ); + await Promise.all(sessions.map(detachSession)); }, async create(identity, payload) { diff --git a/services/cloud-agent-next/wrapper/src/control/worktree-operations.ts b/services/cloud-agent-next/wrapper/src/control/worktree-operations.ts new file mode 100644 index 0000000000..4d19cd244c --- /dev/null +++ b/services/cloud-agent-next/wrapper/src/control/worktree-operations.ts @@ -0,0 +1,34 @@ +const deletingDirectories = new Set(); +const operations = new Map>>(); + +export function assertDirectoryActive(directory: string): void { + if (deletingDirectories.has(directory)) throw new Error('worktree_deleting'); +} + +export function runDirectoryOperation( + directory: string, + operation: () => Promise +): Promise { + if (deletingDirectories.has(directory)) return Promise.reject(new Error('worktree_deleting')); + const pending = Promise.resolve().then(() => { + if (deletingDirectories.has(directory)) throw new Error('worktree_deleting'); + return operation(); + }); + const active = operations.get(directory) ?? new Set>(); + active.add(pending); + operations.set(directory, active); + return pending.finally(() => { + active.delete(pending); + if (active.size === 0) operations.delete(directory); + }); +} + +export async function fenceDirectoryOperations(directory: string): Promise { + deletingDirectories.add(directory); + await Promise.allSettled([...(operations.get(directory) ?? [])]); +} + +export function resetDirectoryOperationState(): void { + deletingDirectories.clear(); + operations.clear(); +} diff --git a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts index 4c33792901..d6013ac6fd 100644 --- a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -864,6 +864,28 @@ describe('worktree Kilo runtime registry', () => { expect(runtime.signal.aborted).toBe(true); }); + it('maps pending roots and children before startup and removes them only after the final failed attempt', async () => { + const harness = createRegistry(); + const identity = rootIdentity(path.join(tmpDir, 'shared')); + const first = harness.registry.attach(identity, auth); + const duplicate = harness.registry.attach(identity, auth); + expect(rootForSession(identity.kiloSessionId, identity.directory)).toBe(identity.kiloSessionId); + rememberChildSession({ childId: 'pending_child', parentId: identity.kiloSessionId }); + expect(rootForSession('pending_child', identity.directory)).toBe(identity.kiloSessionId); + first.release(); + expect(rootForSession(identity.kiloSessionId)).toBe(identity.kiloSessionId); + expect(rootForSession('pending_child')).toBe(identity.kiloSessionId); + const runtime = await duplicate.ready; + await first.ready; + duplicate.release(); + expect(rootForSession(identity.kiloSessionId)).toBeUndefined(); + expect(rootForSession('pending_child')).toBeUndefined(); + expect(directoryForSession(identity.kiloSessionId)).toBeUndefined(); + expect(directoryForSession('pending_child')).toBeUndefined(); + expect(runtime.signal.aborted).toBe(true); + expect(harness.closes).toBe(1); + }); + it('rejects mismatched root identities while startup is pending without disturbing ownership', async () => { const harness = createRegistry(); const identity = rootIdentity(path.join(tmpDir, 'shared')); @@ -930,6 +952,7 @@ describe('worktree Kilo runtime registry', () => { it('fences late startup and cleanup from a replacement lifetime and its auth files', async () => { const launched = Promise.withResolvers(); const release = Promise.withResolvers(); + const stopped = Promise.withResolvers(); const oldServer = createKiloStub(); const newServer = createKiloStub(); servers.push(oldServer, newServer); @@ -944,6 +967,7 @@ describe('worktree Kilo runtime registry', () => { } return { url: old ? oldServer.url : newServer.url, + ...(old ? { stopped: stopped.promise } : {}), close: () => { steps.push(old ? 'close-old' : 'close-new'); }, @@ -957,14 +981,26 @@ describe('worktree Kilo runtime registry', () => { await launched.promise; expect(harness.registry.detach(identity)).toBe(true); const replacement = harness.registry.attach(identity, { ...auth, token: 'replacement' }); + rememberChildSession({ + childId: 'replacement_startup_child', + parentId: identity.kiloSessionId, + }); old.release(); + expect(rootForSession(identity.kiloSessionId)).toBe(identity.kiloSessionId); + expect(rootForSession('replacement_startup_child')).toBe(identity.kiloSessionId); await Promise.resolve(); expect(steps).toEqual(['start-old']); release.resolve(); expect(await oldResult).toMatchObject({ code: 'not_ready' }); + await new Promise(resolve => setImmediate(resolve)); + expect(steps).toEqual(['start-old', 'close-old']); + expect(newServer.feedConnections).toBe(0); + stopped.resolve(); const runtime = await replacement.ready; replacement.commit(); old.release(); + expect(rootForSession(identity.kiloSessionId)).toBe(identity.kiloSessionId); + expect(rootForSession('replacement_startup_child')).toBe(identity.kiloSessionId); expect(steps).toEqual(['start-old', 'close-old', 'start-new']); expect(oldServer.feedConnections).toBe(0); expect(newServer.feedConnections).toBe(1); @@ -985,6 +1021,7 @@ describe('worktree Kilo runtime registry', () => { expect(harness.unexpectedCloses).toBe(0); } finally { release.resolve(); + stopped.resolve(); await oldResult; } }); @@ -1043,8 +1080,15 @@ describe('worktree Kilo runtime registry', () => { ...auth, scopeId: 'worktree_b', }); + rememberChildSession({ + childId: 'shutdown_child', + parentId: rootIdentity(first.directory).kiloSessionId, + }); harness.registry.shutdown(); harness.registry.shutdown(); + expect(rootForSession(rootIdentity(first.directory).kiloSessionId)).toBeUndefined(); + expect(rootForSession(rootIdentity(second.directory).kiloSessionId)).toBeUndefined(); + expect(rootForSession('shutdown_child')).toBeUndefined(); expect(first.signal.aborted).toBe(true); expect(second.signal.aborted).toBe(true); expect(harness.closes).toBe(2); @@ -1098,6 +1142,293 @@ describe('worktree Kilo runtime registry', () => { }); }); +describe('worktree directory deletion', () => { + it('waits for confirmed child exit before removing HOME while another directory stays live', async () => { + const stopped = Promise.withResolvers(); + const directory = path.join(tmpDir, 'stopping'); + const closed: string[] = []; + const harness = createRegistry({ + startServer: async options => { + const stub = createKiloStub(); + servers.push(stub); + return { + url: stub.url, + close: () => { + closed.push(options.directory); + }, + ...(options.directory === directory ? { stopped: stopped.promise } : {}), + }; + }, + }); + const runtime = await harness.registry.ensure(directory, auth); + const other = await harness.registry.ensure(path.join(tmpDir, 'other'), { + ...auth, + scopeId: 'other', + }); + const authFile = path.join(runtime.env.XDG_DATA_HOME, 'kilo', 'auth.json'); + let deleted = false; + const deletion = harness.registry.deleteDirectory(directory).then(() => { + deleted = true; + }); + try { + await new Promise(resolve => setImmediate(resolve)); + expect(runtime.signal.aborted).toBe(true); + expect(closed).toEqual([directory]); + expect(deleted).toBe(false); + expect(fs.existsSync(authFile)).toBe(true); + expect(harness.registry.get(other.directory)).toBe(other); + expect(fs.existsSync(other.env.HOME)).toBe(true); + expect(other.signal.aborted).toBe(false); + fs.writeFileSync(path.join(runtime.env.HOME, 'last-write-before-exit'), 'stopping'); + stopped.resolve(); + await deletion; + expect(deleted).toBe(true); + expect(fs.existsSync(runtime.env.HOME)).toBe(false); + expect(fs.existsSync(other.env.HOME)).toBe(true); + } finally { + stopped.resolve(); + await deletion; + } + }); + + it('fails deletion closed on an exit deadline and retries the same retirement after the child stops', async () => { + const stopped = Promise.withResolvers(); + const stub = createKiloStub(); + servers.push(stub); + let closes = 0; + const harness = createRegistry({ + startServer: async () => ({ + url: stub.url, + close: () => { + closes++; + }, + stopped: stopped.promise, + }), + }); + const directory = path.join(tmpDir, 'stuck-exit'); + const runtime = await harness.registry.ensure(directory, auth); + const checkoutFile = path.join(directory, 'checkout'); + fs.writeFileSync(checkoutFile, 'keep checkout'); + harness.registry.detach(rootIdentity(directory)); + const timers = spyOn(globalThis, 'setTimeout'); + let retry: Promise | undefined; + try { + const failure = rejected(harness.registry.deleteDirectory(directory)); + const deadline = timers.mock.calls.find(([, ms]) => ms === 30_000)?.[0]; + if (typeof deadline !== 'function') throw new Error('Missing retirement deadline'); + deadline(); + expect(await failure).toEqual(new Error('Kilo worktree retirement timed out')); + expect(fs.existsSync(runtime.env.HOME)).toBe(true); + expect(fs.readFileSync(checkoutFile, 'utf8')).toBe('keep checkout'); + expect(closes).toBe(1); + let deleted = false; + retry = harness.registry.deleteDirectory(directory).then(() => { + deleted = true; + }); + await new Promise(resolve => setImmediate(resolve)); + expect(deleted).toBe(false); + expect(fs.existsSync(runtime.env.HOME)).toBe(true); + stopped.resolve(); + await retry; + expect(fs.existsSync(runtime.env.HOME)).toBe(false); + expect(fs.readFileSync(checkoutFile, 'utf8')).toBe('keep checkout'); + expect(closes).toBe(1); + } finally { + stopped.resolve(); + await retry; + timers.mockRestore(); + } + }); + + it('removes live and retired generated homes and roots without touching another directory', async () => { + const harness = createRegistry(); + const directory = path.join(tmpDir, 'deleted'); + const identity = rootIdentity(directory); + const retired = await harness.registry.ensure(directory, auth); + const retiredHome = retired.env.HOME; + const savedSession = path.join(retiredHome, 'saved-session'); + fs.writeFileSync(savedSession, 'restore state'); + expect(harness.registry.detach(identity)).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(fs.readFileSync(savedSession, 'utf8')).toBe('restore state'); + const restored = await harness.registry.ensure(directory, auth); + expect(restored.env.HOME).toBe(retiredHome); + expect(fs.readFileSync(savedSession, 'utf8')).toBe('restore state'); + expect(harness.registry.detach(identity)).toBe(true); + const liveAuth = { ...auth, scopeId: 'new_scope', token: 'new_guest' }; + const live = await harness.registry.ensure(directory, liveAuth); + const liveHome = live.env.HOME; + expect(liveHome).not.toBe(retiredHome); + const siblingIdentity = rootIdentity(directory, 'sibling'); + const sibling = harness.registry.attach(siblingIdentity, liveAuth); + await sibling.ready; + sibling.commit(); + rememberChildSession({ childId: 'deleted_child', parentId: siblingIdentity.kiloSessionId }); + const other = await harness.registry.ensure(path.join(tmpDir, 'other'), { + ...auth, + scopeId: 'other_scope', + }); + const otherIdentity = rootIdentity(other.directory); + rememberChildSession({ childId: 'surviving_child', parentId: otherIdentity.kiloSessionId }); + const otherAuthFile = path.join(other.env.XDG_DATA_HOME, 'kilo', 'auth.json'); + const otherAuthBefore = fs.readFileSync(otherAuthFile, 'utf8'); + const checkoutFile = path.join(directory, 'checkout-file'); + fs.writeFileSync(checkoutFile, 'owned by checkout cleanup'); + live.env.HOME = other.env.HOME; + + expect(harness.registry.get(directory)).toBe(live); + await harness.registry.deleteDirectory(directory); + expect(fs.existsSync(retiredHome)).toBe(false); + expect(fs.existsSync(liveHome)).toBe(false); + expect(fs.readFileSync(checkoutFile, 'utf8')).toBe('owned by checkout cleanup'); + expect(fs.readFileSync(otherAuthFile, 'utf8')).toBe(otherAuthBefore); + expect(live.signal.aborted).toBe(true); + expect(sibling.signal.aborted).toBe(true); + expect(harness.registry.get(directory)).toBeUndefined(); + for (const id of [identity.kiloSessionId, siblingIdentity.kiloSessionId, 'deleted_child']) { + expect(rootForSession(id)).toBeUndefined(); + expect(directoryForSession(id)).toBeUndefined(); + } + expect(harness.registry.get(other.directory)).toBe(other); + expect(other.signal.aborted).toBe(false); + expect(rootForSession('surviving_child')).toBe(otherIdentity.kiloSessionId); + expect(await other.kiloClient.abortSession({ sessionId: otherIdentity.kiloSessionId })).toBe( + true + ); + expect(harness.closes).toBe(3); + expect(harness.registry.isHealthy()).toBe(true); + expect(() => harness.registry.attach(rootIdentity(directory, 'new_root'), liveAuth)).toThrow( + 'Kilo worktree is deleted' + ); + await harness.registry.deleteDirectory(directory); + expect(harness.launches).toHaveLength(4); + expect(harness.closes).toBe(3); + expect(harness.unexpectedCloses).toBe(0); + }); + + it('deletes already-retired homes idempotently after runtime entries are gone', async () => { + const harness = createRegistry(); + const directory = path.join(tmpDir, 'retired'); + const homes: string[] = []; + for (const scopeId of ['first_scope', 'second_scope']) { + const runtime = await harness.registry.ensure(directory, { ...auth, scopeId }); + homes.push(runtime.env.HOME); + expect(harness.registry.detach(rootIdentity(directory))).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(harness.registry.get(directory)).toBeUndefined(); + expect(fs.existsSync(runtime.env.HOME)).toBe(true); + } + await Promise.all([ + harness.registry.deleteDirectory(directory), + harness.registry.deleteDirectory(directory), + ]); + for (const home of homes) expect(fs.existsSync(home)).toBe(false); + await harness.registry.deleteDirectory(directory); + expect(() => harness.registry.attach(rootIdentity(directory), auth)).toThrow( + 'Kilo worktree is deleted' + ); + expect(harness.launches).toHaveLength(2); + expect(harness.closes).toBe(2); + }); + + it('permanently fences a never-started directory without launching Kilo or deleting checkout content', async () => { + const harness = createRegistry(); + const directory = path.join(tmpDir, 'never-started'); + fs.mkdirSync(directory); + fs.writeFileSync(path.join(directory, 'keep'), 'checkout'); + const deletion = harness.registry.deleteDirectory(directory); + expect(() => harness.registry.attach(rootIdentity(directory), auth)).toThrow( + 'Kilo worktree is deleted' + ); + await deletion; + await harness.registry.deleteDirectory(directory); + expect(harness.registry.get(directory)).toBeUndefined(); + expect(harness.registry.detach(rootIdentity(directory))).toBe(false); + expect(fs.readFileSync(path.join(directory, 'keep'), 'utf8')).toBe('checkout'); + expect(fs.existsSync(path.join(tmpDir, 'homes'))).toBe(false); + expect(harness.launches).toEqual([]); + expect(harness.closes).toBe(0); + expect(harness.registry.isHealthy()).toBe(true); + }); + + it.each(['active', 'retiring', 'replacement', 'shutdown'] as const)( + 'drains %s startup before deleting generated HOME state', + async state => { + const launched = Promise.withResolvers[0]>(); + const release = Promise.withResolvers(); + const steps: string[] = []; + let launches = 0; + const stub = createKiloStub(); + servers.push(stub); + const harness = createRegistry({ + startServer: async options => { + launches++; + launched.resolve(options); + await release.promise; + fs.writeFileSync(path.join(options.env.HOME, 'late-startup-state'), 'pending startup'); + steps.push('late-write'); + return { + url: stub.url, + close: () => { + steps.push('close'); + }, + }; + }, + }); + const directory = path.join(tmpDir, 'pending'); + const identity = rootIdentity(directory); + const attachment = harness.registry.attach(identity, auth); + const result = rejected(attachment.ready); + const replacements: ReturnType[] = []; + const results = [result]; + const deletions: Promise[] = []; + try { + const options = await launched.promise; + if (state === 'retiring' || state === 'replacement') harness.registry.detach(identity); + if (state === 'replacement') { + const replacement = harness.registry.attach(identity, { + ...auth, + scopeId: 'replacement_scope', + }); + replacements.push(replacement); + results.push(rejected(replacement.ready)); + } + if (state === 'shutdown') harness.registry.shutdown(); + let settled = false; + const deletion = harness.registry.deleteDirectory(directory).then(() => { + settled = true; + steps.push('deleted'); + }); + deletions.push(deletion, harness.registry.deleteDirectory(directory)); + expect(attachment.signal.aborted).toBe(true); + for (const replacement of replacements) expect(replacement.signal.aborted).toBe(true); + expect(rootForSession(identity.kiloSessionId)).toBeUndefined(); + expect(() => harness.registry.attach(identity, auth)).toThrow(); + await new Promise(resolve => setImmediate(resolve)); + expect(settled).toBe(false); + expect(fs.existsSync(options.env.HOME)).toBe(true); + expect(steps).toEqual([]); + release.resolve(); + for (const pending of results) expect(await pending).toMatchObject({ code: 'not_ready' }); + await Promise.all(deletions); + expect(steps).toEqual(['late-write', 'close', 'deleted']); + expect(fs.existsSync(options.env.HOME)).toBe(false); + expect(fs.readdirSync(path.join(tmpDir, 'homes'))).toEqual([]); + expect(harness.registry.get(directory)).toBeUndefined(); + attachment.release(); + for (const replacement of replacements) replacement.release(); + expect(launches).toBe(1); + expect(stub.feedConnections).toBe(0); + expect(harness.unexpectedCloses).toBe(0); + await harness.registry.deleteDirectory(directory); + } finally { + release.resolve(); + await Promise.allSettled([...results, ...deletions]); + } + } + ); +}); + describe('worktree attachment lifecycle', () => { it.each(['setup', 'restore', 'terminal'] as const)( 'releases a failed sole %s attachment and its routing', @@ -1286,17 +1617,24 @@ describe('worktree attachment lifecycle', () => { ); try { const signal = await restoring.promise; - expect(rootForSession(identity.kiloSessionId)).toBeUndefined(); + expect(rootForSession(identity.kiloSessionId)).toBe(identity.kiloSessionId); + rememberChildSession({ childId: 'old_child', parentId: identity.kiloSessionId }); + expect(rootForSession('old_child')).toBe(identity.kiloSessionId); expect((await handleControlRequest('session.detach', identity, {}, deps)).ok).toBe(true); + expect(rootForSession(identity.kiloSessionId)).toBeUndefined(); + expect(rootForSession('old_child')).toBeUndefined(); expect(signal.aborted).toBe(true); expect(runtime?.signal.aborted).toBe(false); expect( (await handleControlRequest('session.attach', identity, { kilo: auth }, deps)).ok ).toBe(true); + rememberChildSession({ childId: 'replacement_child', parentId: identity.kiloSessionId }); release.resolve(); expect((await pending).ok).toBe(false); expect(harness.registry.get(identity.directory)).toBe(runtime); expect(rootForSession(identity.kiloSessionId)).toBe(identity.kiloSessionId); + expect(rootForSession('replacement_child')).toBe(identity.kiloSessionId); + expect(rootForSession('old_child')).toBeUndefined(); expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); expect( ( @@ -1439,6 +1777,39 @@ setTimeout(() => process.stdout.write(line.slice(-3)), 25); }); }); + it('exposes actual child exit rather than treating SIGTERM delivery as stopped', async () => { + const options = launchEnvironment(` +import fs from 'node:fs'; +fs.writeFileSync(process.env.PID_PATH, String(process.pid)); +process.on('SIGTERM', () => fs.writeFileSync(process.env.PID_PATH + '.sigterm', 'received')); +setInterval(() => { + if (fs.existsSync(process.env.PID_PATH + '.exit')) process.exit(0); +}, 10); +console.log('kilo server listening on http://127.0.0.1:12345'); +`); + const handle = await startWorktreeKiloServer(options); + const pid = Number(fs.readFileSync(options.env.PID_PATH, 'utf8')); + let exited = false; + const stopped = handle.stopped.then(() => { + exited = true; + }); + try { + handle.close(); + handle.close(); + await waitUntil(() => fs.existsSync(`${options.env.PID_PATH}.sigterm`)); + expect(exited).toBe(false); + expect(() => process.kill(pid, 0)).not.toThrow(); + fs.writeFileSync(`${options.env.PID_PATH}.exit`, 'exit'); + await stopped; + expect(exited).toBe(true); + expect(() => process.kill(pid, 0)).toThrow(); + } finally { + fs.writeFileSync(`${options.env.PID_PATH}.exit`, 'exit'); + handle.close(); + await stopped; + } + }); + it('terminates a startup timeout and does not expose child output in errors', async () => { const options = launchEnvironment(` await Bun.write(process.env.PID_PATH, String(process.pid)); diff --git a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts index 2750e442a8..722de06b23 100644 --- a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts +++ b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts @@ -18,6 +18,7 @@ import { } from '../kilo-api.js'; import { withTimeoutAndAbort } from '../utils.js'; import { unfilteredKiloEvents } from './feed.js'; +import { forgetAttachedRoot, rememberAttachedRoot } from './session-directories.js'; import { startSandboxControlEventFeed } from './sandbox-control-runtime.js'; export type WorktreeKiloAuth = NonNullable; @@ -44,6 +45,7 @@ export type WorktreeKiloRuntimes = { env?: Record ): WorktreeKiloAttachment; detach(identity: SessionRequestIdentity): boolean; + deleteDirectory(directory: string): Promise; get(directory: string): WorktreeKiloRuntime | undefined; isHealthy(): boolean; shutdown(): void; @@ -62,6 +64,8 @@ type ServerOptions = { timeoutMs?: number; }; +type WorktreeKiloServerHandle = KiloServerHandle & { stopped?: Promise }; + type RuntimeEntry = { kilo: WorktreeKiloAuth; directory: string; @@ -71,6 +75,7 @@ type RuntimeEntry = { runtime?: WorktreeKiloRuntime; feed?: Awaited>; starting?: Promise; + stopped?: Promise; retiring?: Promise; }; @@ -171,13 +176,18 @@ export function buildWorktreeKiloEnvironment( }; } -export async function startWorktreeKiloServer(options: ServerOptions): Promise { +export async function startWorktreeKiloServer( + options: ServerOptions +): Promise }> { options.signal.throwIfAborted(); const proc = spawn('kilo', ['serve', '--hostname=127.0.0.1', '--port=0'], { cwd: options.directory, env: options.env, stdio: ['ignore', 'pipe', 'ignore'], }); + const stopped = new Promise(resolve => { + proc.once('close', () => resolve()); + }); let closed = false; const close = (): void => { if (closed) return; @@ -221,9 +231,10 @@ export async function startWorktreeKiloServer(options: ServerOptions): Promise Promise; + startServer?: (options: ServerOptions) => Promise; onEvent?: (runtime: WorktreeKiloRuntime, event: WorktreeKiloEvent) => void; onUnexpectedClose: () => void; }): WorktreeKiloRuntimes { const entries = new Map(); const directoriesByScope = new Map(); const roots = new Map(); + const homesByDirectory = new Map>(); + const deletedDirectories = new Set(); let closed = false; function findRoot(identity: SessionRequestIdentity): RootAttachment | undefined { @@ -270,13 +283,15 @@ export function createWorktreeKiloRuntimes(options: { return undefined; } - function retire(entry: RuntimeEntry): void { - if (entry.retiring) return; + function retire(entry: RuntimeEntry): Promise { + if (entry.retiring) return entry.retiring; entry.retiring = Promise.resolve(entry.starting) .catch(() => undefined) + .then(() => entry.stopped) .then(() => { if (entries.get(entry.directory) === entry) entries.delete(entry.directory); }); + void entry.retiring.catch(() => {}); if ( entries.get(entry.directory) === entry && directoriesByScope.get(entry.kilo.scopeId) === entry.directory @@ -284,14 +299,16 @@ export function createWorktreeKiloRuntimes(options: { directoriesByScope.delete(entry.kilo.scopeId); } entry.abort.abort(); + return entry.retiring; } function removeRoot(root: RootAttachment): void { if (roots.get(root.identity.kiloSessionId) !== root) return; roots.delete(root.identity.kiloSessionId); root.entry.roots.delete(root); + forgetAttachedRoot(root.identity.kiloSessionId, root.identity.directory); root.abort.abort(); - if (root.entry.roots.size === 0) retire(root.entry); + if (root.entry.roots.size === 0) void retire(root.entry); } async function start( @@ -299,7 +316,7 @@ export function createWorktreeKiloRuntimes(options: { retiring: Promise | undefined ): Promise { const { abort } = entry; - let server: KiloServerHandle | undefined; + let server: WorktreeKiloServerHandle | undefined; let serverClosed = false; const closeServer = (): void => { if (!server || serverClosed) return; @@ -323,6 +340,7 @@ export function createWorktreeKiloRuntimes(options: { env: entry.env, signal: abort.signal, }); + entry.stopped = server.stopped; abort.signal.throwIfAborted(); const client = createKiloClient({ baseUrl: server.url, directory: entry.directory }); const runtime: WorktreeKiloRuntime = { @@ -375,6 +393,9 @@ export function createWorktreeKiloRuntimes(options: { if (!path.isAbsolute(directory) || path.resolve(directory) !== directory) { throw new WorktreeKiloRuntimeError('protocol_error', 'Invalid worktree directory', false); } + if (deletedDirectories.has(directory)) { + throw new WorktreeKiloRuntimeError('not_ready', 'Kilo worktree is deleted', false); + } let root = findRoot(identity); const scopeDirectory = directoriesByScope.get(kilo.scopeId); const previous = entries.get(directory); @@ -415,6 +436,9 @@ export function createWorktreeKiloRuntimes(options: { abort: new AbortController(), roots: new Set(), }; + const homes = homesByDirectory.get(directory) ?? new Set(); + homes.add(home); + homesByDirectory.set(directory, homes); entries.set(directory, entry); directoriesByScope.set(kilo.scopeId, directory); } @@ -428,6 +452,7 @@ export function createWorktreeKiloRuntimes(options: { }; roots.set(identity.kiloSessionId, root); entry.roots.add(root); + rememberAttachedRoot(identity.kiloSessionId, directory); } const attachedRoot = root; const attempt = Symbol(); @@ -456,6 +481,24 @@ export function createWorktreeKiloRuntimes(options: { removeRoot(root); return true; }, + async deleteDirectory(directory) { + deletedDirectories.add(directory); + const entry = entries.get(directory); + for (const root of roots.values()) { + if (root.identity.directory === directory) removeRoot(root); + } + if (entry) { + await withTimeoutAndAbort(retire(entry), { + timeoutMs: KILO_STARTUP_TIMEOUT_MS, + timeoutMessage: 'Kilo worktree retirement timed out', + abortMessage: 'Kilo worktree retirement cancelled', + }); + } + for (const home of homesByDirectory.get(directory) ?? []) { + await fs.rm(home, { recursive: true, force: true }); + } + homesByDirectory.delete(directory); + }, get(directory) { const runtime = entries.get(directory)?.runtime; return !closed && runtime && !runtime.signal.aborted ? runtime : undefined; @@ -473,10 +516,8 @@ export function createWorktreeKiloRuntimes(options: { shutdown() { if (closed) return; closed = true; - for (const root of roots.values()) root.abort.abort(); - roots.clear(); - for (const entry of entries.values()) retire(entry); - entries.clear(); + for (const root of roots.values()) removeRoot(root); + for (const entry of entries.values()) void retire(entry); directoriesByScope.clear(); }, }; diff --git a/services/cloud-agent-next/wrapper/src/kilo-api.test.ts b/services/cloud-agent-next/wrapper/src/kilo-api.test.ts index 6b4e5ba772..687498de98 100644 --- a/services/cloud-agent-next/wrapper/src/kilo-api.test.ts +++ b/services/cloud-agent-next/wrapper/src/kilo-api.test.ts @@ -540,6 +540,99 @@ describe('createWrapperKiloClient generated SDK HTTP boundary', () => { } ); + it('accepts genuinely empty successful session status and pending-input results', async () => { + expect(await createClient(startStub(200, {}).url).getSessionStatuses()).toEqual({}); + expect(await createClient(startStub(200, []).url).getQuestions()).toEqual([]); + expect(await createClient(startStub(200, []).url).getPermissions()).toEqual([]); + }); + + it('scopes question lists, answers, and rejections to the owning checkout directory', async () => { + const directory = '/workspace/shared checkout & tools'; + const pending = new Map(questions.map(question => [question.id, question])); + const requests: Array<{ pathname: string; directory: string | null }> = []; + const server = Bun.serve({ + port: 0, + async fetch(request) { + const url = new URL(request.url); + requests.push({ pathname: url.pathname, directory: url.searchParams.get('directory') }); + if (url.searchParams.get('directory') !== directory) { + return Response.json({ message: 'Wrong checkout' }, { status: 404 }); + } + if (url.pathname === '/question') return Response.json([...pending.values()]); + if (url.pathname === '/question/question_1/reply') { + expect(await request.json()).toEqual({ answers: [['All']] }); + pending.delete('question_1'); + return Response.json(true); + } + if (url.pathname === '/question/question_1/reject') { + pending.delete('question_1'); + return Response.json(true); + } + return new Response(null, { status: 404 }); + }, + }); + startedServers.push(server); + const client = createClient(server.url.toString()); + + expect(await client.getQuestions(directory)).toEqual(questions); + expect(await client.answerQuestion('question_1', [['All']], directory)).toBe(true); + expect(await client.getQuestions(directory)).toEqual([]); + pending.set(questions[0].id, questions[0]); + expect(await client.rejectQuestion('question_1', directory)).toBe(true); + expect(await client.getQuestions(directory)).toEqual([]); + expect(requests).toEqual([ + { pathname: '/question', directory }, + { pathname: '/question/question_1/reply', directory }, + { pathname: '/question', directory }, + { pathname: '/question/question_1/reject', directory }, + { pathname: '/question', directory }, + ]); + }); + + it.each(['prompt', 'command'] as const)( + 'preserves the durable message ID and terminal assistant response for a synchronous %s', + async operation => { + const completion: Awaited> = { + info: { + id: 'assistant_1', + sessionID: 'ses_1', + parentID: 'message_durable', + role: 'assistant', + time: { created: 1, completed: 2 }, + modelID: 'example', + providerID: 'kilo', + mode: 'code', + agent: 'code', + path: { cwd: '/checkout', root: '/checkout' }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + error: { name: 'MessageAbortedError', data: { message: 'Cancelled' } }, + }, + parts: [], + }; + const stub = startStub(200, completion); + const client = createClient(stub.url); + const options = { sessionId: 'ses_1', messageId: 'message_durable', directory: '/checkout' }; + const result = + operation === 'prompt' + ? await client.sendPrompt({ ...options, prompt: 'hello' }) + : await client.sendCommand({ ...options, command: 'review' }); + + expect(result).toEqual(completion); + expect(stub.requests).toEqual([ + { + method: 'POST', + pathname: operation === 'prompt' ? '/session/ses_1/message' : '/session/ses_1/command', + directory: '/checkout', + body: + operation === 'prompt' + ? { messageID: 'message_durable', parts: [{ type: 'text', text: 'hello' }] } + : { messageID: 'message_durable', command: 'review', arguments: '' }, + }, + ]); + } + ); + it('retains complete typed pending question contents', async () => { const stub = startStub(200, questions); const result = await createClient(stub.url).getQuestions(); diff --git a/services/cloud-agent-next/wrapper/src/restore-session.test.ts b/services/cloud-agent-next/wrapper/src/restore-session.test.ts index f8e37ac5c1..b3ef8be06b 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.test.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.test.ts @@ -113,7 +113,18 @@ function writeSignalTerminatedMockKilo(binDir: string): void { function writeSignalIgnoringDescendantMockKilo(binDir: string, descendantMarker: string): void { const readyMarker = `${descendantMarker}.ready`; - const script = `#!/bin/sh\ntrap 'exit 0' TERM\nnode -e 'const fs = require("node:fs"); process.on("SIGTERM", () => {}); fs.writeFileSync(process.argv[1], "ready"); setTimeout(() => fs.writeFileSync(process.argv[2], "alive"), 800); setInterval(() => {}, 1000);' "${readyMarker}" "${descendantMarker}" /dev/null 2>&1 &\nwhile [ ! -f "${readyMarker}" ]; do sleep 0.01; done\nsleep 2\n`; + const script = `#!/bin/sh +trap '' TERM +sh -c ' + trap "" TERM + printf ready > "$1" + sleep 0.8 + printf alive > "$2" + exec sleep 30 +' sh "${readyMarker}" "${descendantMarker}" /dev/null 2>&1 & +trap 'exit 0' TERM +wait +`; const kiloPath = path.join(binDir, 'kilo'); fs.writeFileSync(kiloPath, script, { mode: 0o755 }); } @@ -672,23 +683,87 @@ await Bun.write(process.env.RESTORE_CAPTURE_PATH, JSON.stringify({ } }); - it('returns 404 when session-ingest returns a valid empty session export', async () => { - mockFetchOk(JSON.stringify({ info: {}, messages: [], sessionDiff: [] })); + it.each([ + JSON.stringify({ info: {}, messages: [], sessionDiff: [] }), + JSON.stringify({ info: {}, messages: [], sessionDiff: [], subagents: [] }), + ' {\n "sessionDiff": [], "info": {}, "messages": []\n } ', + ])('returns 404 for a canonical empty fresh-session export: %j', async snapshot => { + mockFetchOk(snapshot); + const capturePath = path.join(tmpDir, 'import-input.json'); + writeCapturingMockKilo(binDir, capturePath); const result = await restoreSession(SESSION_ID, workspace); expect(result).toEqual({ ok: false, - error: 'snapshot not found (empty export)', + error: 'snapshot not found (404)', code: 404, step: 'download', }); + expect(fs.existsSync(capturePath)).toBe(false); + expect(snapshotDirectories()).toEqual([]); + }); + + it.each([ + { bytes: 1_024, code: 404 }, + { bytes: 1_025, code: null }, + ])('enforces the canonical empty-snapshot boundary at $bytes bytes', async ({ bytes, code }) => { + const snapshot = JSON.stringify({ info: {}, messages: [], sessionDiff: [] }); + mockFetchOk(`${' '.repeat(bytes - snapshot.length)}${snapshot}`); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBe(code); + expect(result.step).toBe('download'); + expect(result.error).toContain(code === 404 ? 'snapshot not found (404)' : 'missing info.id'); + } + expect(snapshotDirectories()).toEqual([]); + }); + + it.each([ + '{"info":{},"messages":[],"sessionDiff":[],"messages":[]}', + '{"info":{},"messages":[],"sessionDiff":[]} trailing', + '{"info":{},"messages":[],"sessionDiff":[', + ])('keeps duplicate fields and malformed empty exports fail-closed: %j', async snapshot => { + mockFetchOk(snapshot); + const capturePath = path.join(tmpDir, 'import-input.json'); + writeCapturingMockKilo(binDir, capturePath); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBeNull(); + expect(result.step).toBe('download'); + } + expect(fs.existsSync(capturePath)).toBe(false); + expect(snapshotDirectories()).toEqual([]); + }); + + it('does not treat a mismatched session ID as an empty snapshot', async () => { + mockFetchOk( + JSON.stringify({ info: { id: 'ses_different_session' }, messages: [], sessionDiff: [] }) + ); + writeMockKilo(binDir, 1); + + const result = await restoreSession(SESSION_ID, workspace); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.code).toBeNull(); + expect(result.step).toBe('import'); + } }); for (const [description, snapshot] of [ ['null session metadata', { info: null, messages: [], sessionDiff: [] }], ['same-size malformed session diffs', { info: {}, messages: [], sessionDiff: {} }], ['missing session diffs', { info: {}, messages: [] }], + ['non-array messages', { info: {}, messages: {}, sessionDiff: [] }], + ['nonempty subagents', { info: {}, messages: [], sessionDiff: [], subagents: [{}] }], + ['non-array subagents', { info: {}, messages: [], sessionDiff: [], subagents: null }], [ 'session metadata without an id', { info: { title: 'Existing session' }, messages: [], sessionDiff: [] }, @@ -875,6 +950,7 @@ await Bun.write(process.env.RESTORE_CAPTURE_PATH, JSON.stringify({ expect(result.step).toBe('import'); expect(result.error).toContain('kilo import timed out'); } + expect(fs.readFileSync(`${descendantMarker}.ready`, 'utf8')).toBe('ready'); expect(elapsedMs).toBeGreaterThanOrEqual(600); expect(fs.existsSync(descendantMarker)).toBe(false); }); diff --git a/services/cloud-agent-next/wrapper/src/restore-session.ts b/services/cloud-agent-next/wrapper/src/restore-session.ts index f586b4adcd..c56dc119d2 100644 --- a/services/cloud-agent-next/wrapper/src/restore-session.ts +++ b/services/cloud-agent-next/wrapper/src/restore-session.ts @@ -47,6 +47,7 @@ export type RestoreSessionOptions = { const KILO_IMPORT_TIMEOUT_MS = 120_000; const EMPTY_SESSION_INGEST_EXPORT = '{"info":{},"messages":[],"sessionDiff":[]}'; +const MAX_EMPTY_SNAPSHOT_BYTES = 1_024; const JQ_SANITIZE_TOKEN_COUNTS_FILTER = 'walk(if type == "object" and ((.tokens? | type) == "object") then .tokens |= walk(if type == "number" and . < 0 then 0 else . end) else . end)'; // Drop leftover CLI UI progress parts (metadata.kilocode.lifecycle == "transient"). @@ -385,6 +386,7 @@ async function validateInfoObject(reader: JsonCharReader): Promise { const reader = createJsonCharReader(snapshotPath, signal); @@ -395,6 +397,7 @@ async function validateSnapshotInfoId( let infoIsEmpty = false; let messagesIsEmpty = false; let sessionDiffIsEmpty = false; + let subagentsAreEmpty = true; let sawInfo = false; let sawMessages = false; let sawSessionDiff = false; @@ -430,7 +433,7 @@ async function validateSnapshotInfoId( infoId = undefined; infoIsEmpty = false; } - } else if (key === 'messages' || key === 'sessionDiff') { + } else if (key === 'messages' || key === 'sessionDiff' || key === 'subagents') { const valueStart = await nextNonWhitespace(reader); if (valueStart === null) return { validation: 'invalid' }; let valueIsEmpty = false; @@ -446,9 +449,11 @@ async function validateSnapshotInfoId( if (key === 'messages') { sawMessages = true; messagesIsEmpty = valueIsEmpty; - } else { + } else if (key === 'sessionDiff') { sawSessionDiff = true; sessionDiffIsEmpty = valueIsEmpty; + } else { + subagentsAreEmpty = valueIsEmpty; } } else if (!(await skipJsonValue(reader))) { return { validation: 'invalid' }; @@ -468,6 +473,7 @@ async function validateSnapshotInfoId( if ((await nextNonWhitespace(reader)) !== null) return { validation: 'invalid' }; if ( + bytesWritten <= MAX_EMPTY_SNAPSHOT_BYTES && infoId === undefined && sawInfo && sawMessages && @@ -475,6 +481,7 @@ async function validateSnapshotInfoId( infoIsEmpty && messagesIsEmpty && sessionDiffIsEmpty && + subagentsAreEmpty && !hasUnexpectedTopLevelField ) { return { validation: 'empty' }; @@ -816,7 +823,11 @@ export async function restoreSession( // kilo with a cryptic `undefined is not an object (evaluating 'info2.id')` // and exit 1. Stream only the top-level metadata guardrail instead of // materializing the full export in the wrapper heap. - const snapshotInfoValidation = await validateSnapshotInfoId(tmpPath, options.signal); + const snapshotInfoValidation = await validateSnapshotInfoId( + tmpPath, + bytesWritten, + options.signal + ); log( `snapshot metadata validated status=${snapshotInfoValidation.validation} expectedKiloSessionId=${kiloSessionId} snapshotInfoId=${snapshotInfoValidation.infoId ?? '(missing)'} idMatchesExpected=${snapshotInfoValidation.infoId === kiloSessionId} bytes=${bytesWritten}` ); @@ -826,7 +837,7 @@ export async function restoreSession( } if (snapshotInfoValidation.validation === 'empty') { log('snapshot is an empty session export; treating it as not found'); - return fail('snapshot not found (empty export)', 404, 'download'); + return fail('snapshot not found (404)', 404, 'download'); } if (snapshotInfoValidation.validation === 'missing') { const result = fail( @@ -850,7 +861,11 @@ export async function restoreSession( } else { log(`using provided file=${filePath}`); try { - const providedInfoValidation = await validateSnapshotInfoId(tmpPath, options.signal); + const providedInfoValidation = await validateSnapshotInfoId( + tmpPath, + fs.statSync(tmpPath).size, + options.signal + ); log( `provided snapshot metadata inspected status=${providedInfoValidation.validation} expectedKiloSessionId=${kiloSessionId} snapshotInfoId=${providedInfoValidation.infoId ?? '(missing)'} idMatchesExpected=${providedInfoValidation.infoId === kiloSessionId}` ); diff --git a/services/session-ingest/src/dos/SessionAccessCacheDO.ts b/services/session-ingest/src/dos/SessionAccessCacheDO.ts index ff4c5c4e6f..4f7ccb2e11 100644 --- a/services/session-ingest/src/dos/SessionAccessCacheDO.ts +++ b/services/session-ingest/src/dos/SessionAccessCacheDO.ts @@ -33,6 +33,7 @@ export class SessionAccessCacheDO extends DurableObject { } async getAccess(sessionId: string): Promise { + if (this.ctx.storage.kv.get(`deletedSession/${sessionId}`) === true) return null; const row = this.db .select({ sessionId: sessions.session_id, @@ -59,6 +60,15 @@ export class SessionAccessCacheDO extends DurableObject { } async putValidated(access: CachedSessionAccess): Promise { + if (this.ctx.storage.kv.get(`deletedSession/${access.sessionId}`) === true) return; + const scopeKey = `cloudAgentScope/${access.sessionId}`; + const scopeIdentity = JSON.stringify([ + access.cloudAgentSessionScopeId ?? null, + access.organizationId ?? null, + ]); + const existingScope = this.ctx.storage.kv.get(scopeKey); + if (existingScope !== undefined && existingScope !== scopeIdentity) return; + if (access.cloudAgentSessionScopeId != null) this.ctx.storage.kv.put(scopeKey, scopeIdentity); const now = Date.now(); const authorizationExpiresAt = now + SESSION_ACCESS_CACHE_TTL_MS; // Expired rows are unreadable but would otherwise accumulate forever; @@ -83,6 +93,12 @@ export class SessionAccessCacheDO extends DurableObject { .run(); } + async deleteSession(sessionId: string): Promise { + this.ctx.storage.kv.put(`deletedSession/${sessionId}`, true); + this.ctx.storage.kv.delete(`cloudAgentScope/${sessionId}`); + this.db.delete(sessions).where(eq(sessions.session_id, sessionId)).run(); + } + async remove(sessionId: string): Promise { this.db.delete(sessions).where(eq(sessions.session_id, sessionId)).run(); } diff --git a/services/session-ingest/src/dos/SessionIngestDO.test.ts b/services/session-ingest/src/dos/SessionIngestDO.test.ts index 9ab52ef51b..aa32a52d84 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.test.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.test.ts @@ -33,7 +33,7 @@ vi.mock('@kilocode/db/client', () => ({ })); import { SessionIngestDO, ingestOrderCursor } from './SessionIngestDO'; -import { ingestItems } from '../db/sqlite-schema'; +import { ingestItems, ingestMeta } from '../db/sqlite-schema'; describe('SessionIngestDO ingest ordering', () => { it('uses ingested_at with id only as a tie-breaker for cursor progression', () => { @@ -1519,21 +1519,30 @@ describe('SessionIngestDO clone primitives', () => { }) ), })), - delete: vi.fn(() => ({ where: vi.fn(() => ({ run: vi.fn() })) })), + delete: vi.fn((table: unknown) => ({ + run: () => { + if (table === ingestItems) items.length = 0; + if (table === ingestMeta) meta.clear(); + }, + where: () => ({ + run: () => { + if (table === ingestMeta) { + for (const key of meta.keys()) if (key !== 'deleted') meta.delete(key); + } + }, + }), + })), }; drizzleMocks.db = db; const deleteObject = vi.fn(async () => {}); const deleteAlarm = vi.fn(async () => {}); - const deleteAll = vi.fn(async () => { - items.length = 0; - meta.clear(); - }); + const clearSqlite = vi.fn((fn: () => unknown) => fn()); const state = { storage: { setAlarm: vi.fn(), deleteAlarm, - deleteAll, + transactionSync: clearSqlite, }, waitUntil: vi.fn(), blockConcurrencyWhile: vi.fn((fn: () => void) => fn()), @@ -1546,7 +1555,7 @@ describe('SessionIngestDO clone primitives', () => { meta, deleteObject, deleteAlarm, - deleteAll, + clearSqlite, get orderByArgs() { return orderByArgs; }, @@ -1979,7 +1988,7 @@ describe('SessionIngestDO clone primitives', () => { await harness.durableObject.resetCloneStage(); // The SQLite wipe must complete before the R2 delete await opens the input gate. - expect(harness.deleteAll.mock.invocationCallOrder[0]).toBeLessThan( + expect(harness.clearSqlite.mock.invocationCallOrder[0]).toBeLessThan( harness.deleteObject.mock.invocationCallOrder[0] ); expect(harness.deleteObject).toHaveBeenCalledWith(['items/staged-blob']); @@ -2000,20 +2009,24 @@ describe('SessionIngestDO clone primitives', () => { await expect(harness.durableObject.resetCloneStage()).resolves.toBeUndefined(); - expect(harness.deleteAll).toHaveBeenCalled(); + expect(harness.clearSqlite).toHaveBeenCalled(); expect(harness.items).toHaveLength(0); expect(consoleError).toHaveBeenCalled(); consoleError.mockRestore(); }); - it('marks the DO deleted even when the clear R2 delete fails', async () => { + it('fences writes and retains R2 discovery after a failed clear, then retries cleanup', async () => { const harness = makeCloneHarness([itemRow(1, 'message/src', 'message', 1, 'items/blob')]); harness.deleteObject.mockRejectedValueOnce(new Error('r2 unavailable')); - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); - await expect(harness.durableObject.clear()).resolves.toBeUndefined(); + await expect(harness.durableObject.clear()).rejects.toThrow('r2 unavailable'); + expect(harness.meta.get('deleted')).toBe('true'); + expect(harness.items).toHaveLength(1); + expect(harness.clearSqlite).not.toHaveBeenCalled(); + await expect(harness.durableObject.clear()).resolves.toBeUndefined(); + expect(harness.items).toHaveLength(0); expect(harness.meta.get('deleted')).toBe('true'); - consoleError.mockRestore(); + expect(harness.deleteObject).toHaveBeenCalledTimes(2); }); }); diff --git a/services/session-ingest/src/dos/SessionIngestDO.ts b/services/session-ingest/src/dos/SessionIngestDO.ts index 9bae0a86f5..ae017dfc70 100644 --- a/services/session-ingest/src/dos/SessionIngestDO.ts +++ b/services/session-ingest/src/dos/SessionIngestDO.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { sessionIdSchema } from '@kilocode/session-ingest-contracts'; import { desc, eq, ne, gt, gte, lt, and, or, inArray, isNull, isNotNull, sql } from 'drizzle-orm'; import { drizzle, type DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite'; import { migrate } from 'drizzle-orm/durable-sqlite/migrator'; @@ -369,6 +370,8 @@ async function computeCloneBatchDigest(rows: CloneBatchRow[]): Promise { export class SessionIngestDO extends DurableObject { private db: DrizzleSqliteDODatabase; + private readonly activeR2Writes = new Set>(); + private cleanupOperation: Promise | undefined; constructor(state: DurableObjectState, env: Env) { super(state, env); @@ -554,6 +557,7 @@ export class SessionIngestDO extends DurableObject { if (ingestVersion >= 1) { // v1 clients send explicit open/close pairs. Only those events drive alarms. for (const event of lifecycleEvents) { + if (this.isDeleted()) return { accepted: false, reason: 'deleted', changes: [] }; if (event.type === 'session_open') { // New turn starting — clear prior emission so metrics are re-computed. this.db @@ -578,6 +582,7 @@ export class SessionIngestDO extends DurableObject { // Read before the write loop below persists incoming values: whether this session had ever // reported a status. The first-ever status write also registers as a change, and a // full-history backfill of an already-idle session must not push about an old turn. + if (this.isDeleted()) return { accepted: false, reason: 'deleted', changes: [] }; const hadPriorStatus = hasIngestMeta(this.db, 'status'); const changes: Changes = []; @@ -665,6 +670,7 @@ export class SessionIngestDO extends DurableObject { (async () => { try { await persistLiveSessionColumns(connectionString, kiloUserId, sessionId, columns); + if (this.isDeleted()) return; // Max-merge post-await so out-of-order concurrent ingests cannot regress throttle meta. if (activityValueMs !== undefined) { const prevAt = readIngestMetaNumber(this.db, 'lastActivityPersistedAtMs'); @@ -1074,6 +1080,7 @@ export class SessionIngestDO extends DurableObject { }); } + if (this.isDeleted()) return false; await this.env.O11Y.ingestSessionMetrics({ kiloUserId, sessionId, @@ -1082,6 +1089,7 @@ export class SessionIngestDO extends DurableObject { ...metrics, }); + if (this.isDeleted()) return false; // Mark metrics as emitted to prevent duplicates this.db .insert(ingestMeta) @@ -1158,25 +1166,142 @@ export class SessionIngestDO extends DurableObject { return true; } + isDeleted(): boolean { + return ( + this.db + .select({ value: ingestMeta.value }) + .from(ingestMeta) + .where(eq(ingestMeta.key, 'deleted')) + .get()?.value === 'true' + ); + } + + async stageR2Object( + params: { kiloUserId: string; sessionId: string; key: string }, + body: ReadableStream + ): Promise { + sessionIdSchema.parse(params.sessionId); + const doKey = `${params.kiloUserId}/${params.sessionId}`; + if ( + (this.ctx.id.name && this.ctx.id.name !== doKey) || + !['ingest', 'items'].some(prefix => params.key.startsWith(`${prefix}/${doKey}/`)) + ) { + throw new Error('Session R2 identity conflict'); + } + if (this.isDeleted()) { + await body.cancel(); + return false; + } + writeIngestMetaIfChanged(this.db, { key: 'kiloUserId', incomingValue: params.kiloUserId }); + writeIngestMetaIfChanged(this.db, { key: 'sessionId', incomingValue: params.sessionId }); + const key = `pendingR2/${params.key}`; + this.db + .insert(ingestMeta) + .values({ key, value: params.key }) + .onConflictDoUpdate({ target: ingestMeta.key, set: { value: params.key } }) + .run(); + const operation = (async () => { + const reservation = await this.env.SESSION_INGEST_R2.put(params.key, crypto.randomUUID(), { + onlyIf: new Headers({ 'If-None-Match': '*' }), + }); + const etag = reservation?.etag ?? (await this.env.SESSION_INGEST_R2.head(params.key))?.etag; + if (this.isDeleted()) { + await body.cancel(); + await this.env.SESSION_INGEST_R2.delete(params.key); + this.db.delete(ingestMeta).where(eq(ingestMeta.key, key)).run(); + return false; + } + if (!etag) throw new Error('Session R2 reservation disappeared'); + const written = await this.env.SESSION_INGEST_R2.put(params.key, body, { + onlyIf: { etagMatches: etag }, + }); + const deleted = this.isDeleted(); + if (!written && !deleted) throw new Error('Session R2 reservation changed'); + if (deleted) await this.env.SESSION_INGEST_R2.delete(params.key); + this.db.delete(ingestMeta).where(eq(ingestMeta.key, key)).run(); + return !deleted; + })(); + this.activeR2Writes.add(operation); + return operation.finally(() => this.activeR2Writes.delete(operation)); + } + + async clearForWorktree(kiloUserId: string, sessionId: string): Promise { + sessionIdSchema.parse(sessionId); + const doKey = `${kiloUserId}/${sessionId}`; + if (this.ctx.id.name && this.ctx.id.name !== doKey) + throw new Error('Session identity conflict'); + await this.clearSessionData({ kiloUserId, sessionId }); + } + async clear(): Promise { - await this.wipeStorage(); + const meta = this.db + .select() + .from(ingestMeta) + .where(inArray(ingestMeta.key, ['kiloUserId', 'sessionId'])) + .all(); + const kiloUserId = meta.find(row => row.key === 'kiloUserId')?.value; + const sessionId = meta.find(row => row.key === 'sessionId')?.value; + await this.clearSessionData(kiloUserId && sessionId ? { kiloUserId, sessionId } : undefined); + } + + private async clearSessionData(identity?: { + kiloUserId: string; + sessionId: string; + }): Promise { this.db .insert(ingestMeta) .values({ key: 'deleted', value: 'true' }) .onConflictDoUpdate({ target: ingestMeta.key, set: { value: 'true' } }) .run(); + if (!this.cleanupOperation) { + this.cleanupOperation = this.clearFencedSessionData(identity).finally(() => { + this.cleanupOperation = undefined; + }); + } + await this.cleanupOperation; + } + + private async clearFencedSessionData(identity?: { + kiloUserId: string; + sessionId: string; + }): Promise { + await this.ctx.storage.deleteAlarm(); + await Promise.allSettled([...this.activeR2Writes]); + const keys = new Set( + this.db + .select({ item_data_r2_key: ingestItems.item_data_r2_key }) + .from(ingestItems) + .where(isNotNull(ingestItems.item_data_r2_key)) + .all() + .flatMap(row => (row.item_data_r2_key ? [row.item_data_r2_key] : [])) + ); + for (const row of this.db.select().from(ingestMeta).all()) { + if (row.key.startsWith('pendingR2/') && row.value) keys.add(row.value); + } + const knownKeys = [...keys]; + for (let offset = 0; offset < knownKeys.length; offset += 1000) { + await this.env.SESSION_INGEST_R2.delete(knownKeys.slice(offset, offset + 1000)); + } + if (identity) { + for (const namespace of ['ingest', 'items']) { + const prefix = `${namespace}/${identity.kiloUserId}/${identity.sessionId}/`; + let cursor: string | undefined; + do { + const page = await this.env.SESSION_INGEST_R2.list({ prefix, cursor, limit: 1000 }); + if (page.objects.length > 0) + await this.env.SESSION_INGEST_R2.delete(page.objects.map(object => object.key)); + cursor = page.truncated ? page.cursor : undefined; + } while (cursor); + } + } + this.ctx.storage.transactionSync(() => { + this.db.delete(ingestItems).run(); + this.db.delete(agentNotificationDispatch).run(); + this.db.delete(ingestMeta).where(ne(ingestMeta.key, 'deleted')).run(); + }); + await this.ctx.storage.deleteAlarm(); } - /** - * Cancel the alarm, wipe SQLite, then delete the captured R2-backed item blobs. - * Shared by `clear()` and `resetCloneStage()`; `clear()` additionally marks the - * DO deleted. - * - * The R2 delete is best-effort: the SQLite wipe has already committed when it - * runs, so a transient R2 failure must not throw. A throw would leave `clear()` - * wiped but unmarked, and would turn a typed `rejected` clone result into an - * uncaught exception at the RPC boundary. - */ private async wipeStorage(): Promise { // Capture the R2 keys first (synchronous SQLite). The SQLite wipe below must // never be separated from this key read by an R2 await: an R2 await opens the @@ -1190,8 +1315,12 @@ export class SessionIngestDO extends DurableObject { const r2Keys = r2Rows.map(r => r.item_data_r2_key).filter((k): k is string => k !== null); await this.ctx.storage.deleteAlarm(); - await this.ctx.storage.deleteAll(); - await migrate(this.db, migrations); + if (this.isDeleted()) throw new Error('Session deleted'); + this.ctx.storage.transactionSync(() => { + this.db.delete(ingestItems).run(); + this.db.delete(agentNotificationDispatch).run(); + this.db.delete(ingestMeta).run(); + }); // Delete the captured R2 objects last, after the SQLite wipe is complete. if (r2Keys.length > 0) { @@ -1293,6 +1422,7 @@ export class SessionIngestDO extends DurableObject { * is rejected without mutation. */ stageCloneBatch(params: StageCloneBatchParams): StageCloneBatchResult { + if (this.isDeleted()) throw new Error('Session deleted'); const stage = this.readCloneStage(); if (stage.kind === 'complete') { @@ -1451,6 +1581,7 @@ export class SessionIngestDO extends DurableObject { /** Delete every staged row, any R2 objects the stage wrote, and the clone state markers. */ async resetCloneStage(): Promise { + if (this.isDeleted()) throw new Error('Session deleted'); await this.wipeStorage(); } } diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index f5b10de258..31dbcf3881 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -77,6 +77,16 @@ function makeStorageFake() { const store = new Map(); return { store, + kv: { + get: (key: string) => store.get(key), + put: (key: string, value: unknown) => { + store.set(key, value); + }, + delete: (key: string) => store.delete(key), + list: (opts?: { prefix?: string }) => + new Map([...store].filter(([key]) => key.startsWith(opts?.prefix ?? ''))), + }, + deleteAlarm: vi.fn(async () => undefined), put: vi.fn(async (key: string, value: unknown) => { store.set(key, value); }), @@ -445,6 +455,7 @@ describe('UserConnectionDO', () => { gitUrl: null, gitBranch: null, parentSessionId: null, + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', status: 'idle' as const, statusUpdatedAt: null, }; diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index 935065099d..f75584a716 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -1,4 +1,5 @@ import { DurableObject } from 'cloudflare:workers'; +import { z } from 'zod'; import type { Env } from '../env'; import { getSessionIngestDO } from './SessionIngestDO'; @@ -37,6 +38,17 @@ type HeartbeatSession = { }; }; +const cleanupAttachmentSchema = z.discriminatedUnion('role', [ + z + .object({ + role: z.literal('cli'), + sessions: z.array(z.object({ id: z.string() }).passthrough()), + }) + .passthrough(), + z.object({ role: z.literal('web'), subscribedSessions: z.array(z.string()) }).passthrough(), +]); +const pendingSessionSchema = z.object({ sessionId: z.string().optional() }); + type ConnectionCapabilities = { attachments?: boolean; // Old form is absent sessionClone; treat missing as incapable until @@ -594,6 +606,7 @@ export class UserConnectionDO extends DurableObject { capabilities: ConnectionCapabilities | undefined, instance: Instance | undefined ): void { + sessions = sessions.filter(session => !this.isSessionDeleted(session.id)); const { connectionId } = attachment; const now = Date.now(); this.lastHeartbeatAt.set(connectionId, now); @@ -749,12 +762,13 @@ export class UserConnectionDO extends DurableObject { * first-sights do not slide fireAt or reset attempts. */ private scheduleSessionReadyPush(kiloUserId: string, sessionId: string, title: string): void { - if (this.readyPushFireAt.has(sessionId)) return; + if (this.readyPushFireAt.has(sessionId) || this.isSessionDeleted(sessionId)) return; const key = `${READY_PUSH_KEY_PREFIX}${sessionId}`; this.ctx.waitUntil( (async () => { const existing = await this.ctx.storage.get(key); + if (this.isSessionDeleted(sessionId)) return; if (existing) { this.readyPushFireAt.set(sessionId, existing.fireAt); this.scheduleNextAlarm(Date.now()); @@ -823,6 +837,7 @@ export class UserConnectionDO extends DurableObject { await this.ctx.storage.delete(key); this.readyPushFireAt.delete(sessionId); } catch (error: unknown) { + if (this.isSessionDeleted(sessionId)) continue; const attempts = (entry.attempts ?? 0) + 1; if (attempts >= READY_PUSH_MAX_ATTEMPTS) { await this.ctx.storage.delete(key); @@ -911,6 +926,11 @@ export class UserConnectionDO extends DurableObject { event: string, data: unknown ): void { + if ( + this.isSessionDeleted(sessionId) || + (parentSessionId && this.isSessionDeleted(parentSessionId)) + ) + return; const childSubs = this.webSubscriptions.get(sessionId); const parentSubs = parentSessionId ? this.webSubscriptions.get(parentSessionId) : undefined; if (!childSubs && !parentSubs) return; @@ -1049,6 +1069,7 @@ export class UserConnectionDO extends DurableObject { // this try block. The finally clears any reservation that survives // a throw. try { + if (entry.sessionId && this.isSessionDeleted(entry.sessionId)) return; // Validate catalog result size for rehydrated entries too. if (CATALOG_DEDUPE_COMMANDS.has(entry.command) && result !== undefined) { const serializedResult = JSON.stringify(result); @@ -1321,7 +1342,7 @@ export class UserConnectionDO extends DurableObject { kiloUserId, kiloSessionId: sessionId, }); - if (!accessible) { + if (!accessible || this.isSessionDeleted(sessionId)) { return; } @@ -1663,6 +1684,7 @@ export class UserConnectionDO extends DurableObject { webConnectionId: string, now: number ): Promise { + if (msg.sessionId && this.isSessionDeleted(msg.sessionId)) return; const correlationId = msg.mutationId ?? crypto.randomUUID(); this.pendingCommands.set(correlationId, { ws, @@ -1684,6 +1706,10 @@ export class UserConnectionDO extends DurableObject { webConnectionId, state: 'pending' as const, } satisfies PendingCommandEntry); + if (msg.sessionId && this.isSessionDeleted(msg.sessionId)) { + this.ctx.storage.kv.delete(`${PENDING_COMMAND_KEY_PREFIX}${correlationId}`); + return; + } this.scheduleNextAlarm(now); this.scheduleDurablePendingAlarm(); @@ -1713,6 +1739,7 @@ export class UserConnectionDO extends DurableObject { webConnectionId: string, now: number ): void { + if (msg.sessionId && this.isSessionDeleted(msg.sessionId)) return; const correlationId = crypto.randomUUID(); const pendingCommandKey = `${PENDING_COMMAND_KEY_PREFIX}${correlationId}`; this.pendingCommands.set(correlationId, { @@ -1739,6 +1766,10 @@ export class UserConnectionDO extends DurableObject { state: 'pending' as const, } satisfies PendingCommandEntry) .then(async () => { + if (msg.sessionId && this.isSessionDeleted(msg.sessionId)) { + this.ctx.storage.kv.delete(pendingCommandKey); + return; + } const terminalEntry = this.terminalDuringInitialWrite.get(correlationId); if (terminalEntry) { await this.ctx.storage.put(pendingCommandKey, terminalEntry); @@ -1990,6 +2021,72 @@ export class UserConnectionDO extends DurableObject { return sockets.length; } + private isSessionDeleted(sessionId: string): boolean { + return this.ctx.storage.kv.get(`deletedSession/${sessionId}`) === true; + } + + async clearSession(sessionId: string): Promise { + this.ensureState(); + this.ctx.storage.kv.put(`deletedSession/${sessionId}`, true); + this.sessionOwners.delete(sessionId); + this.webSubscriptions.delete(sessionId); + this.readyPushFireAt.delete(sessionId); + this.ctx.storage.kv.delete(`${READY_PUSH_KEY_PREFIX}${sessionId}`); + this.ctx.storage.kv.delete(`${RENAME_KEY_PREFIX}${sessionId}`); + for (const [id, entry] of this.pendingCommands) { + if (entry.sessionId !== sessionId) continue; + this.pendingCommands.delete(id); + this.terminalDuringInitialWrite.delete(id); + this.completedCorrelationIds.delete(id); + this.sendToWeb(entry.ws, { + type: 'response', + id: entry.originalId, + error: { code: 'SESSION_DELETED', message: 'Session deleted' }, + }); + } + for (const [id, entry] of this.terminalDuringInitialWrite) { + if (entry.sessionId === sessionId) this.terminalDuringInitialWrite.delete(id); + } + for (const [key, value] of this.ctx.storage.kv.list({ prefix: PENDING_COMMAND_KEY_PREFIX })) { + const parsed = pendingSessionSchema.safeParse(value); + if (parsed.success && parsed.data.sessionId === sessionId) this.ctx.storage.kv.delete(key); + } + for (const [id, sessions] of this.connectionSessions) { + this.connectionSessions.set( + id, + sessions.filter(session => session.id !== sessionId) + ); + } + for (const socket of this.ctx.getWebSockets()) { + const parsed = cleanupAttachmentSchema.safeParse(socket.deserializeAttachment()); + if (!parsed.success) continue; + const attachment = parsed.data; + if (attachment.role === 'cli') { + socket.serializeAttachment({ + ...attachment, + sessions: attachment.sessions.filter(session => session.id !== sessionId), + }); + this.sendToCli(socket, { type: 'unsubscribe', sessionId }); + } else { + socket.serializeAttachment({ + ...attachment, + subscribedSessions: attachment.subscribedSessions.filter(id => id !== sessionId), + }); + } + } + const pending = [...this.ctx.storage.kv.list({ prefix: PENDING_COMMAND_KEY_PREFIX })]; + if ( + this.lastHeartbeatAt.size === 0 && + this.readyPushFireAt.size === 0 && + pending.length === 0 + ) { + await this.ctx.storage.deleteAlarm(); + } else { + this.scheduleNextAlarm(Date.now()); + this.scheduleDurablePendingAlarm(); + } + } + async notifySessionEvent(event: SessionEventPayload): Promise<{ delivered: number }> { this.ensureState(); const parsed = SessionEventPayloadSchema.parse(event); @@ -2018,6 +2115,7 @@ export class UserConnectionDO extends DurableObject { */ async notifySessionRenamed(sessionId: string, title: string): Promise<{ delivered: boolean }> { this.ensureState(); + if (this.isSessionDeleted(sessionId)) return { delivered: false }; await this.ctx.storage.put(`${RENAME_KEY_PREFIX}${sessionId}`, { title, at: Date.now(), @@ -2183,6 +2281,7 @@ export class UserConnectionDO extends DurableObject { // If storage.put fails, no live send occurs. A waitUntil retry keeps // the terminal outcome fenced while the loop continues. const webAtt = entry.ws.deserializeAttachment() as WSAttachment | null; + if (entry.sessionId && this.isSessionDeleted(entry.sessionId)) continue; const durableEntry: PendingCommandEntry = { sessionId: entry.sessionId, originalId: entry.originalId, @@ -2286,6 +2385,7 @@ export class UserConnectionDO extends DurableObject { // If storage.put fails, no live send occurs. A waitUntil retry keeps // the terminal outcome fenced while the loop continues. const webAtt = entry.ws.deserializeAttachment() as WSAttachment | null; + if (entry.sessionId && this.isSessionDeleted(entry.sessionId)) continue; const durableEntry: PendingCommandEntry = { sessionId: entry.sessionId, originalId: entry.originalId, @@ -2453,9 +2553,10 @@ export class UserConnectionDO extends DurableObject { private async getDurablePendingCommand( correlationId: string ): Promise { - return this.ctx.storage.get( + const entry = await this.ctx.storage.get( `${PENDING_COMMAND_KEY_PREFIX}${correlationId}` ); + return entry?.sessionId && this.isSessionDeleted(entry.sessionId) ? undefined : entry; } private async countDurablePendingCommands(): Promise { @@ -2485,7 +2586,12 @@ export class UserConnectionDO extends DurableObject { }); await Promise.all( [...entries].flatMap(([key, entry]) => { - if (entry.state !== 'pending' || !matches(entry)) return []; + if ( + entry.state !== 'pending' || + !matches(entry) || + (entry.sessionId && this.isSessionDeleted(entry.sessionId)) + ) + return []; const correlationId = key.slice(PENDING_COMMAND_KEY_PREFIX.length); // Skip entries already delivered live by the in-memory sweep. // Prevents a duplicate delivery when the in-memory storage.put and @@ -2589,6 +2695,10 @@ export class UserConnectionDO extends DurableObject { }); for (const [key, durable] of entries) { if (!durable || typeof durable.expiresAt !== 'number') continue; + if (durable.sessionId && this.isSessionDeleted(durable.sessionId)) { + this.ctx.storage.kv.delete(key); + continue; + } if (durable.expiresAt > now) continue; if (durable.state === 'pending') { diff --git a/services/session-ingest/src/ingest/default-session-title.ts b/services/session-ingest/src/ingest/default-session-title.ts index c1aeab6bcd..25a5037bf1 100644 --- a/services/session-ingest/src/ingest/default-session-title.ts +++ b/services/session-ingest/src/ingest/default-session-title.ts @@ -1,15 +1,4 @@ -// Duplicated from kilocode packages/opencode/src/session/session.ts:55-62 -// (isDefaultTitle). Keep in sync when the CLI default-title pattern changes. -export const DEFAULT_SESSION_TITLE_PATTERN = - /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; - -/** - * A session title counts as the creation placeholder when it is still NULL or when it - * holds the default title stamped at creation time (e.g. cloud-agent-next inserts - * `New session - ` via createSessionForCloudAgent). Agent-generated - * titles may promote a placeholder title but must never overwrite a user-chosen one. - */ -export function isDefaultSessionTitle(title: string | null | undefined): boolean { - if (title == null) return true; - return DEFAULT_SESSION_TITLE_PATTERN.test(title); -} +export { + DEFAULT_SESSION_TITLE_PATTERN, + isDefaultSessionTitle, +} from '@kilocode/session-ingest-contracts'; diff --git a/services/session-ingest/src/ingest/direct-ingest.ts b/services/session-ingest/src/ingest/direct-ingest.ts index 75936b66ab..751827432b 100644 --- a/services/session-ingest/src/ingest/direct-ingest.ts +++ b/services/session-ingest/src/ingest/direct-ingest.ts @@ -316,7 +316,9 @@ async function legacy( options: LegacyOptions ): Promise { try { - await stageAndEnqueue(request.env, legacyQueueParams(request, r2Key), body); + if (!(await stageAndEnqueue(request.env, legacyQueueParams(request, r2Key), body))) { + return { status: 404, body: { success: false, error: 'session_not_found' } }; + } } catch (error) { logEvent('warn', { event: 'direct_ingest_legacy', @@ -344,7 +346,9 @@ async function fallbackAfterDirectFailure( directError: unknown ): Promise { try { - await stageAndEnqueue(request.env, directFallbackQueueParams(request, r2Key), bytes); + if (!(await stageAndEnqueue(request.env, directFallbackQueueParams(request, r2Key), bytes))) { + return { status: 404, body: { success: false, error: 'session_not_found' } }; + } } catch (error) { logFallback(request, metrics, startedAt, error, undefined, directError); throw error; diff --git a/services/session-ingest/src/ingest/metadata.test.ts b/services/session-ingest/src/ingest/metadata.test.ts index 182aa1ff07..c453c8deb3 100644 --- a/services/session-ingest/src/ingest/metadata.test.ts +++ b/services/session-ingest/src/ingest/metadata.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { SQL } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; vi.mock('cloudflare:workers', () => ({ DurableObject: class { @@ -20,13 +22,20 @@ vi.mock('../dos/SessionAccessCacheDO', () => ({ })); vi.mock('../session-events', () => ({ - mapSessionEventRow: vi.fn((row: { session_id: string; status: string | null }) => ({ - source: 'v2' as const, - sessionId: row.session_id, - status: row.status, - statusUpdatedAt: '2026-07-25T00:00:00.000Z', - updatedAt: '2026-07-25T00:00:00.000Z', - })), + mapSessionEventRow: vi.fn( + (row: { + session_id: string; + status: string | null; + cloud_agent_worktree_id?: string | null; + }) => ({ + source: 'v2' as const, + sessionId: row.session_id, + worktreeId: row.cloud_agent_worktree_id ?? null, + status: row.status, + statusUpdatedAt: '2026-07-25T00:00:00.000Z', + updatedAt: '2026-07-25T00:00:00.000Z', + }) + ), notifyUserSessionEvent: vi.fn(), })); @@ -44,6 +53,7 @@ type StatusRow = { status: string | null }; function createTransactionDb(options: { initialStatus: string | null; + cloudAgentWorktreeId?: string | null; /** After the conditional update, status read-back (simulates concurrent overwrite). */ persistedStatus?: string | null; rowMissing?: boolean; @@ -79,6 +89,7 @@ function createTransactionDb(options: { git_url: null, git_branch: null, parent_session_id: null, + cloud_agent_worktree_id: options.cloudAgentWorktreeId ?? null, status, status_updated_at: '2026-07-25T00:00:00.000Z', }, @@ -113,10 +124,13 @@ type ApplyMetadataDbOptions = { membershipRows?: number; /** When set, the next non-lock session select is treated as a parent lookup. */ parentExists?: boolean; + parentCloudAgentScopeId?: string; + parentWorktreeId?: string; initialStatus?: string | null; rowMissing?: boolean; cloudAgentSessionScopeId?: string | null; cloudAgentSessionId?: string | null; + cloudAgentWorktreeId?: string | null; parentSessionId?: string | null; createsCycle?: boolean; scopeRootMissing?: boolean; @@ -150,6 +164,7 @@ function createApplyMetadataDb(options: ApplyMetadataDbOptions = {}) { let initialReadDone = false; let parentLookupDone = false; let lockCount = 0; + let lastCondition: SQL | undefined; function currentSessionState() { return { @@ -173,6 +188,7 @@ function createApplyMetadataDb(options: ApplyMetadataDbOptions = {}) { git_url: options.initialGitUrl ?? null, git_branch: null, parent_session_id: null, + cloud_agent_worktree_id: options.cloudAgentWorktreeId ?? null, status: options.initialStatus ?? 'idle', status_updated_at: '2026-07-25T00:00:00.000Z', }; @@ -191,6 +207,13 @@ function createApplyMetadataDb(options: ApplyMetadataDbOptions = {}) { if (options.parentExists !== undefined && !parentLookupDone) { parentLookupDone = true; queryLog.push('parent'); + const querySql = lastCondition ? new PgDialect().sqlToQuery(lastCondition).sql : ''; + if ( + (options.parentCloudAgentScopeId && + /"cloud_agent_session_scope_id" IS NULL/i.test(querySql)) || + (options.parentWorktreeId && /"cloud_agent_worktree_id" IS NULL/i.test(querySql)) + ) + return []; return options.parentExists ? [{ sessionId: 'ses_parent' }] : []; } queryLog.push('read-back'); @@ -228,9 +251,10 @@ function createApplyMetadataDb(options: ApplyMetadataDbOptions = {}) { }), })), })), - where: vi.fn(() => ({ - limit: vi.fn(() => sessionLimitResult()), - })), + where: vi.fn((condition: SQL) => { + lastCondition = condition; + return { limit: vi.fn(() => sessionLimitResult()) }; + }), })), })); @@ -286,6 +310,27 @@ describe('resetAttentionStatusOnCliDisconnect', () => { ); }); + it('preserves the worktree ID when resetting an attention status', async () => { + const cloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const db = createTransactionDb({ initialStatus: 'question', cloudAgentWorktreeId }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + const env = { HYPERDRIVE: { connectionString: 'postgres://unused' } } as never; + await resetAttentionStatusOnCliDisconnect(env, 'usr_1', 'ses_1'); + + expect(notifyUserSessionEvent).toHaveBeenCalledWith( + env, + 'usr_1', + expect.objectContaining({ + type: 'session.status.updated', + data: expect.objectContaining({ + session: expect.objectContaining({ worktreeId: cloudAgentWorktreeId }), + }), + }), + undefined + ); + }); + it('writes retry and notifies when stored status is permission', async () => { const db = createTransactionDb({ initialStatus: 'permission' }); vi.mocked(getWorkerDb).mockReturnValue(db as never); @@ -373,6 +418,20 @@ describe('applyMetadataChanges', () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined); }); + it.each([ + { parentCloudAgentScopeId: 'workspace_root' }, + { parentWorktreeId: 'worktree_11111111-1111-4111-8111-111111111111' }, + ])('does not let public parent metadata claim a Cloud Agent root: %j', identity => { + const db = createApplyMetadataDb({ parentExists: true, ...identity }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + return applyMetadataChanges(env, 'usr_1', 'ses_1', new Map([['parentId', 'ses_parent']])).then( + () => { + expect(db.updateSets).toEqual([]); + expect(notifyUserSessionEvent).not.toHaveBeenCalled(); + } + ); + }); + it('persists organization_id and invalidates access cache when the user is a member', async () => { const db = createApplyMetadataDb({ membershipRows: 1 }); vi.mocked(getWorkerDb).mockReturnValue(db as never); @@ -404,6 +463,26 @@ describe('applyMetadataChanges', () => { ); }); + it('preserves the worktree ID when broadcasting metadata updates', async () => { + const cloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const db = createApplyMetadataDb({ cloudAgentWorktreeId }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + await applyMetadataChanges(env, 'usr_1', 'ses_1', new Map([['title', 'Updated']])); + + expect(notifyUserSessionEvent).toHaveBeenCalledWith( + env, + 'usr_1', + expect.objectContaining({ + type: 'session.updated', + data: expect.objectContaining({ + session: expect.objectContaining({ worktreeId: cloudAgentWorktreeId }), + }), + }), + undefined + ); + }); + it('refuses unauthorized organization_id while persisting the rest of the batch', async () => { const db = createApplyMetadataDb({ membershipRows: 0 }); vi.mocked(getWorkerDb).mockReturnValue(db as never); diff --git a/services/session-ingest/src/ingest/metadata.ts b/services/session-ingest/src/ingest/metadata.ts index abc4240e55..502a76f5be 100644 --- a/services/session-ingest/src/ingest/metadata.ts +++ b/services/session-ingest/src/ingest/metadata.ts @@ -9,6 +9,7 @@ import { isNeedsInputStatus } from '../dos/session-ingest-attention'; import { mapSessionEventRow, notifyUserSessionEvent } from '../session-events'; import { SessionStatusSchema } from '../types/user-connection-protocol'; import { isDefaultSessionTitle } from './default-session-title'; +import { isWorktreeDeleting } from '../services/worktree-deletion'; /** Stored status written when a CLI disconnects while the session is waiting on input. */ export const CLI_DISCONNECT_ATTENTION_RESET_STATUS = 'retry' as const; @@ -95,6 +96,7 @@ export async function applyMetadataChanges( parentSessionId: cli_sessions_v2.parent_session_id, cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, gitUrl: cli_sessions_v2.git_url, }) .from(cli_sessions_v2) @@ -132,7 +134,11 @@ export async function applyMetadataChanges( } const [currentRow] = await selectCurrentRow().for('update'); - if (!currentRow) return null; + if ( + !currentRow || + (currentRow.worktreeId && (await isWorktreeDeleting(tx, currentRow.worktreeId))) + ) + return null; const cloudAgentSessionScopeId = currentRow.cloudAgentSessionScopeId; const hasCloudAgentSessionScope = cloudAgentSessionScopeId != null; const isCloudAgentManagedSession = @@ -300,7 +306,9 @@ export async function applyMetadataChanges( .where( and( eq(cli_sessions_v2.session_id, parentSessionId), - eq(cli_sessions_v2.kilo_user_id, kiloUserId) + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + sql`${cli_sessions_v2.cloud_agent_session_scope_id} IS NULL`, + sql`${cli_sessions_v2.cloud_agent_worktree_id} IS NULL` ) ) .limit(1); @@ -358,6 +366,7 @@ export async function applyMetadataChanges( git_url: cli_sessions_v2.git_url, git_branch: cli_sessions_v2.git_branch, parent_session_id: cli_sessions_v2.parent_session_id, + cloud_agent_worktree_id: cli_sessions_v2.cloud_agent_worktree_id, status: cli_sessions_v2.status, status_updated_at: cli_sessions_v2.status_updated_at, }) @@ -506,6 +515,7 @@ export async function resetAttentionStatusOnCliDisconnect( git_url: cli_sessions_v2.git_url, git_branch: cli_sessions_v2.git_branch, parent_session_id: cli_sessions_v2.parent_session_id, + cloud_agent_worktree_id: cli_sessions_v2.cloud_agent_worktree_id, status: cli_sessions_v2.status, status_updated_at: cli_sessions_v2.status_updated_at, }) diff --git a/services/session-ingest/src/ingest/stage-and-enqueue.ts b/services/session-ingest/src/ingest/stage-and-enqueue.ts index 72c71ac16d..83c0165521 100644 --- a/services/session-ingest/src/ingest/stage-and-enqueue.ts +++ b/services/session-ingest/src/ingest/stage-and-enqueue.ts @@ -1,4 +1,6 @@ import type { Env } from '../env'; +import { withDORetry } from '@kilocode/worker-utils'; +import { getSessionIngestDO } from '../dos/SessionIngestDO'; import type { IngestQueueMessage } from '../queue-consumer'; type StageAndEnqueueParams = Omit & { @@ -22,9 +24,20 @@ export async function stageAndEnqueue( env: Env, params: StageAndEnqueueParams, body: ReadableStream | Uint8Array -): Promise { +): Promise { try { - await env.SESSION_INGEST_R2.put(params.r2Key, body); + const stream = body instanceof Uint8Array ? new Blob([body]).stream() : body; + const accepted = await withDORetry( + () => getSessionIngestDO(env, params), + stub => + stub.stageR2Object( + { kiloUserId: params.kiloUserId, sessionId: params.sessionId, key: params.r2Key }, + stream + ), + 'SessionIngestDO.stageR2Object', + { maxAttempts: 1, baseBackoffMs: 0, maxBackoffMs: 0 } + ); + if (!accepted) return false; } catch (error) { throw new StageAndEnqueueError('staging_upload', error); } @@ -40,4 +53,5 @@ export async function stageAndEnqueue( await env.SESSION_INGEST_R2.delete(params.r2Key).catch(() => {}); throw new StageAndEnqueueError('queue_send', error); } + return true; } diff --git a/services/session-ingest/src/queue-consumer.test.ts b/services/session-ingest/src/queue-consumer.test.ts index c438b5b072..89491cf3d2 100644 --- a/services/session-ingest/src/queue-consumer.test.ts +++ b/services/session-ingest/src/queue-consumer.test.ts @@ -61,6 +61,19 @@ import { computeSessionMetadataUpdates } from './ingest/metadata'; const encoder = new TextEncoder(); +function mockIngestWithR2(ingest: ReturnType) { + vi.mocked(getSessionIngestDO).mockImplementation( + env => + ({ + ingest, + stageR2Object: async (params: { key: string }, body: ReadableStream) => { + await env.SESSION_INGEST_R2.put(params.key, body); + return true; + }, + }) as never + ); +} + function feedAll(extractor: ReturnType, json: string) { extractor.tokenizer.write(encoder.encode(json)); extractor.tokenizer.end(); @@ -215,7 +228,7 @@ describe('queue', () => { it('passes a slim oversized message and its R2 reference into ingest', async () => { const ingest = vi.fn(async () => ({ changes: [] })); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_compacted' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -227,7 +240,7 @@ describe('queue', () => { content: 'x'.repeat(150), }; const body = JSON.stringify({ data: [{ type: 'message', data }] }); - const put = vi.fn(async () => undefined); + const put = vi.fn(async (_key: string, _body: ReadableStream) => undefined); const deleteObject = vi.fn(async () => undefined); const env = { HYPERDRIVE: { connectionString: 'postgres://unused' }, @@ -262,7 +275,8 @@ describe('queue', () => { ); const expectedR2Key = 'items/usr_compacted/ses_compacted/message/msg_compacted/456'; - expect(put).toHaveBeenCalledWith(expectedR2Key, JSON.stringify(data)); + expect(put).toHaveBeenCalledWith(expectedR2Key, expect.any(ReadableStream)); + expect(await new Response(put.mock.calls[0][1]).text()).toBe(JSON.stringify(data)); expect(ingest).toHaveBeenCalledWith( [{ type: 'message', data: { id: 'msg_compacted' } }], 'usr_compacted', @@ -278,7 +292,7 @@ describe('queue', () => { it('splits duplicate item identities so inline updates do not reuse prior R2 refs', async () => { const ingest = vi.fn(async () => ({ changes: [] })); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_duplicate' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -289,7 +303,7 @@ describe('queue', () => { const oversizedItem = { type: 'message', data: oversizedData }; const inlineItem = { type: 'message', data: inlineData }; const body = JSON.stringify({ data: [oversizedItem, inlineItem] }); - const put = vi.fn(async () => undefined); + const put = vi.fn(async (_key: string, _body: ReadableStream) => undefined); const deleteObject = vi.fn(async () => undefined); const env = { HYPERDRIVE: { connectionString: 'postgres://unused' }, @@ -323,7 +337,8 @@ describe('queue', () => { ); const expectedR2Key = 'items/usr_duplicate/ses_duplicate/message/msg_same/1'; - expect(put).toHaveBeenCalledWith(expectedR2Key, JSON.stringify(oversizedData)); + expect(put).toHaveBeenCalledWith(expectedR2Key, expect.any(ReadableStream)); + expect(await new Response(put.mock.calls[0][1]).text()).toBe(JSON.stringify(oversizedData)); expect(ingest).toHaveBeenCalledTimes(2); expect(ingest).toHaveBeenNthCalledWith( 1, @@ -350,7 +365,7 @@ describe('queue', () => { it('batches all items in a message into a single DO ingest call', async () => { const ingest = vi.fn(async () => ({ changes: [] })); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_batch' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -405,7 +420,7 @@ describe('queue', () => { const ingest = vi.fn( async () => ({ accepted: false, reason: 'deleted', changes: [] }) as const ); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_tombstoned' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -462,7 +477,7 @@ describe('queue', () => { const ingest = vi.fn( async () => ({ accepted: false, reason: 'deleted', changes: [] }) as const ); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_tombstoned_tail' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -515,7 +530,7 @@ describe('queue', () => { const ingest = vi.fn( async () => ({ accepted: false, reason: 'deleted', changes: [] }) as const ); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_tombstoned_duplicate' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -568,7 +583,7 @@ describe('queue', () => { ? { changes: [{ name: 'title', value: 'Hello' }] } : { changes: [{ name: 'gitBranch', value: 'main' }] } ); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const transaction = vi.fn(async () => null); const limit = vi.fn(async () => [{ session_id: 'ses_split' }]); @@ -643,7 +658,7 @@ describe('queue', () => { const ingest = vi.fn(async () => { throw new Error('Durable Object is overloaded.'); }); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_overload' }]); const where = vi.fn(() => ({ limit })); const from = vi.fn(() => ({ where })); @@ -691,7 +706,7 @@ describe('queue', () => { const ingest = vi.fn(async () => { throw new Error('Durable Object is overloaded.'); }); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const limit = vi.fn(async () => [{ session_id: 'ses_cancel' }]); const where = vi.fn(() => ({ limit })); @@ -749,7 +764,7 @@ describe('queue', () => { it('does not flush buffered items when malformed JSON forces a retry', async () => { const ingest = vi.fn(async () => ({ changes: [] })); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const transaction = vi.fn(async () => null); const limit = vi.fn(async () => [{ session_id: 'ses_malformed' }]); @@ -809,7 +824,7 @@ describe('queue', () => { if (ingestCalls === 1) return { changes: [{ name: 'title', value: 'Hello' }] }; throw new Error('Durable Object is overloaded.'); }); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); // db.select powers the session-exists guard; db.transaction is the metadata flush. const transaction = vi.fn(async () => null); @@ -934,7 +949,7 @@ describe('queue session_pr_link', () => { ingestChanges: Array<{ name: string; value: string | null }>; }) { const ingest = vi.fn(async () => ({ changes: params.ingestChanges })); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); const transaction = vi.fn(async () => null); const limit = vi.fn(async () => [{ session_id: 'ses_prlink' }]); const where = vi.fn(() => ({ limit })); @@ -1252,7 +1267,7 @@ describe('remote session attention notifications', () => { }) { const ingest = params.ingest ?? vi.fn(async () => ({ changes: [], attentionSignals: [attentionSignal] })); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + mockIngestWithR2(ingest); // A single session lookup serves both the deleted-session guard and push eligibility. const limit = vi.fn(async () => [{ session_id: 'ses_remote', ...params.sessionRow }]); diff --git a/services/session-ingest/src/queue-consumer.ts b/services/session-ingest/src/queue-consumer.ts index 5f1b086047..0ad878e305 100644 --- a/services/session-ingest/src/queue-consumer.ts +++ b/services/session-ingest/src/queue-consumer.ts @@ -292,7 +292,19 @@ function createIngestChunker( : itemDataBytes; if (itemDataBytes > MAX_INGEST_ITEM_BYTES) { const itemR2Key = `items/${kiloUserId}/${sessionId}/${item_id}/${ingestedAt}`; - await env.SESSION_INGEST_R2.put(itemR2Key, itemDataJson); + const staged = await withDORetry( + () => getSessionIngestDO(env, { kiloUserId, sessionId }), + stub => + stub.stageR2Object( + { kiloUserId, sessionId, key: itemR2Key }, + new Blob([itemDataJson]).stream() + ), + 'SessionIngestDO.stageR2Object' + ); + if (!staged) { + accepted = false; + return false; + } chunkR2References[item_id] = itemR2Key; } diff --git a/services/session-ingest/src/routes/api.test.ts b/services/session-ingest/src/routes/api.test.ts index 6df688126f..b62ee30be2 100644 --- a/services/session-ingest/src/routes/api.test.ts +++ b/services/session-ingest/src/routes/api.test.ts @@ -121,13 +121,24 @@ function ingestRequest(body: string, contentLength = new TextEncoder().encode(bo }); } +function r2StagingStub(env: Parameters[0]) { + return { + stageR2Object: async (params: { key: string }, body: ReadableStream) => { + await env.SESSION_INGEST_R2.put(params.key, body); + return true; + }, + }; +} + function prepareIngestRoute( ingest: ReturnType = vi.fn(async () => ({ accepted: true, changes: [] })) ) { const { db } = makeDbFakes(); vi.mocked(getWorkerDb).mockReturnValue(db); vi.mocked(getSessionAccessCacheDO).mockReturnValue({ has: vi.fn(async () => true) } as never); - vi.mocked(getSessionIngestDO).mockReturnValue({ ingest } as never); + vi.mocked(getSessionIngestDO).mockImplementation( + env => ({ ...r2StagingStub(env), ingest }) as never + ); vi.mocked(getUserConnectionDO).mockReturnValue({ hasActiveCliSession: vi.fn(async () => true), } as never); @@ -230,6 +241,7 @@ function makeDbFakes() { describe('api routes', () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(getSessionIngestDO).mockImplementation(env => r2StagingStub(env) as never); vi.mocked(resolveAccessibleKiloSession).mockResolvedValue({ kiloSessionId: 'ses_12345678901234567890123456', organizationId: null, @@ -1002,8 +1014,9 @@ describe('api routes', () => { expect(ingest).not.toHaveBeenCalled(); expect(env.SESSION_INGEST_R2.put).toHaveBeenCalledWith( expect.stringMatching(/\/ses_12345678901234567890123456\//), - new TextEncoder().encode(body) + expect.any(ReadableStream) ); + expect(await new Response(env.SESSION_INGEST_R2.put.mock.calls[0][1]).text()).toBe(body); expect(info).toHaveBeenCalledWith( expect.objectContaining({ event: 'direct_ingest_legacy', reason: 'oversized_item' }) ); @@ -1056,8 +1069,9 @@ describe('api routes', () => { expect(ingest).toHaveBeenCalledTimes(1); expect(env.SESSION_INGEST_R2.put).toHaveBeenCalledWith( 'ingest/usr_test/ses_12345678901234567890123456/11111111-1111-4111-8111-111111111111', - new TextEncoder().encode(body) + expect.any(ReadableStream) ); + expect(await new Response(env.SESSION_INGEST_R2.put.mock.calls[0][1]).text()).toBe(body); expect(env.INGEST_QUEUE.send).toHaveBeenCalledWith( expect.objectContaining({ ingestedAt: 4567 }) ); @@ -1436,6 +1450,9 @@ describe('api routes', () => { { session_id: parentSessionId, has_access: true }, ], }); + fns.selectResult + .mockResolvedValueOnce([{ worktreeId: null, scopeId: 'cloud-agent-session-scope-1' }]) + .mockResolvedValueOnce([{ worktreeId: null }]); // Rows selected for session.deleted events fns.selectResult.mockResolvedValueOnce([ { @@ -1483,7 +1500,13 @@ describe('api routes', () => { expect(res.status).toBe(200); - const deletedRowsPredicate = fns.selectWhere.mock.calls[0]?.[0]; + const deletedRowsPredicate = fns.selectWhere.mock.calls + .map(([predicate]) => predicate) + .find( + predicate => + predicate instanceof SQL && + new PgDialect().sqlToQuery(predicate).sql.includes('"session_id" in') + ); if (!(deletedRowsPredicate instanceof SQL)) { throw new Error('Expected pre-delete predicate'); } @@ -1565,7 +1588,6 @@ describe('api routes', () => { expect(res.status).toBe(404); expect(await res.json()).toEqual({ success: false, error: 'session_not_found' }); - expect(fns.select).not.toHaveBeenCalled(); expect(fns.delete).not.toHaveBeenCalled(); expect(fns.transaction).not.toHaveBeenCalled(); }); diff --git a/services/session-ingest/src/routes/api.ts b/services/session-ingest/src/routes/api.ts index d936917bd6..159bb636a6 100644 --- a/services/session-ingest/src/routes/api.ts +++ b/services/session-ingest/src/routes/api.ts @@ -22,6 +22,7 @@ import { isDefaultSessionTitle } from '../ingest/default-session-title'; import { resolveAccessibleKiloSession } from '../services/session-access'; import { signSessionShareToken } from '../services/session-share-token'; import { canCreateCliSessionForUser } from '../services/user-session-admission'; +import { isWorktreeSessionDeleting } from '../services/worktree-deletion'; export type ApiContext = { Bindings: Env; @@ -297,6 +298,9 @@ api.delete('/session/:sessionId', async c => { if (c.get('deletionAudience')) { return deleteOwnedLeafSession(c, kiloUserId, parsed.data); } + if (await isWorktreeSessionDeleting(db, kiloUserId, parsed.data)) { + return c.json({ success: false, error: 'worktree_deleting' }, 409); + } const accessibleSession = await resolveAccessibleKiloSession(c.env, { kiloUserId, diff --git a/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts b/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts index f7f96b2508..9279e88d26 100644 --- a/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts +++ b/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts @@ -1,6 +1,8 @@ import { Hono } from 'hono'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type * as WorkerUtils from '@kilocode/worker-utils'; +import type { SQL } from 'drizzle-orm'; +import { PgDialect } from 'drizzle-orm/pg-core'; vi.mock('cloudflare:workers', () => ({ DurableObject: class DurableObject {}, @@ -64,6 +66,7 @@ function persistedRow(overrides: Record = {}) { organization_id: '11111111-1111-4111-8111-111111111111', cloud_agent_session_id: null, cloud_agent_session_scope_id: cloudAgentSessionId, + cloud_agent_worktree_id: null, created_at: '2026-01-01T00:00:00.000Z', updated_at: '2026-01-01T00:00:00.000Z', title: null, @@ -76,15 +79,20 @@ function persistedRow(overrides: Record = {}) { }; } -function makeDb(selectResults: unknown[][], insertResult: unknown[]) { +function makeDb(selectResults: unknown[][], insertResult: unknown[], updateResult: unknown[] = []) { const updateSets: unknown[] = []; const insertedValues: unknown[] = []; + const selectConditions: SQL[] = []; const select = { from: vi.fn(() => select), - where: vi.fn(() => select), + where: vi.fn((condition: SQL) => { + selectConditions.push(condition); + return select; + }), limit: vi.fn(() => select), for: vi.fn(async () => selectResults.shift() ?? []), + then: (resolve: (rows: unknown[]) => unknown) => resolve(selectResults.shift() ?? []), }; const update = { set: vi.fn(values => { @@ -92,6 +100,7 @@ function makeDb(selectResults: unknown[][], insertResult: unknown[]) { return update; }), where: vi.fn(() => update), + returning: vi.fn(async () => updateResult), then: vi.fn(resolve => resolve(undefined)), }; const insert = { @@ -110,7 +119,7 @@ function makeDb(selectResults: unknown[][], insertResult: unknown[]) { const db = { transaction: vi.fn(async callback => callback(tx)), }; - return { db, insertedValues, updateSets }; + return { db, insertedValues, updateSets, selectConditions }; } describe('Cloud Agent session scope routes', () => { @@ -159,6 +168,35 @@ describe('Cloud Agent session scope routes', () => { expect(db.transaction).not.toHaveBeenCalled(); }); + it('rejects a late descendant registration when its locked root belongs to a deleting worktree', async () => { + const { db, insertedValues } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId: null, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', + }, + ], + [{ started: '2026-08-27 01:00:00+00' }], + ], + [] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId }), + }), + env + ); + expect(response.status).toBe(404); + expect(insertedValues).toEqual([]); + expect(getSessionAccessCacheDO).not.toHaveBeenCalled(); + }); + it('atomically heals the root and creates a session-scoped child', async () => { const child = persistedRow(); const { db, insertedValues, updateSets } = makeDb( @@ -203,6 +241,208 @@ describe('Cloud Agent session scope routes', () => { }); }); + it('persists engine-created child lineage before its first turn when public bootstrap won the race', async () => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const child = persistedRow({ + organization_id: null, + parent_session_id: null, + cloud_agent_session_scope_id: null, + cloud_agent_worktree_id: null, + status: null, + }); + const linked = { + ...child, + parent_session_id: rootSessionId, + cloud_agent_session_scope_id: cloudAgentSessionId, + cloud_agent_worktree_id: worktreeId, + }; + const { db, updateSets } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId: null, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId, + }, + ], + [], + [child], + ], + [], + [linked] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const putValidated = vi.fn(async () => undefined); + vi.mocked(getSessionAccessCacheDO).mockReturnValue({ putValidated } as never); + + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), + }), + env + ); + + expect(response.status).toBe(200); + expect(updateSets).toContainEqual({ + parent_session_id: rootSessionId, + cloud_agent_session_scope_id: cloudAgentSessionId, + cloud_agent_worktree_id: worktreeId, + }); + expect(putValidated).toHaveBeenCalledWith({ + sessionId: childSessionId, + organizationId: null, + cloudAgentSessionScopeId: cloudAgentSessionId, + }); + }); + + it.each([ + ['organization', { organization_id: '22222222-2222-4222-8222-222222222222' }], + ['root scope', { cloud_agent_session_scope_id: 'another-root' }], + ['worktree', { cloud_agent_worktree_id: 'worktree_22222222-2222-4222-8222-222222222222' }], + ['cloud root', { cloud_agent_session_id: 'another-root' }], + ['parent', { parent_session_id: 'ses_cccccccccccccccccccccccccc' }], + ])('refuses to overwrite established %s when adopting an orphan', async (_name, overrides) => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const organizationId = '11111111-1111-4111-8111-111111111111'; + const child = persistedRow({ + parent_session_id: null, + cloud_agent_session_scope_id: null, + ...overrides, + }); + const { db, updateSets } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId, + }, + ], + [], + [child], + ], + [] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), + }), + env + ); + expect(response.status).toBe(409); + expect(updateSets).toEqual([]); + expect(getSessionAccessCacheDO).not.toHaveBeenCalled(); + }); + + it('requires a nested parent in the same authenticated owner, organization, root scope, and worktree', async () => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const organizationId = '11111111-1111-4111-8111-111111111111'; + const parentSessionId = 'ses_cccccccccccccccccccccccccc'; + const { db, insertedValues, selectConditions } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId, + }, + ], + [], + [], + ], + [] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId, parentSessionId }), + }), + env + ); + expect(response.status).toBe(409); + expect(insertedValues).toEqual([]); + const condition = selectConditions.at(-1); + if (!condition) throw new Error('Expected parent authorization'); + const query = new PgDialect().sqlToQuery(condition); + expect(query.params).toEqual([ + parentSessionId, + 'usr_test', + cloudAgentSessionId, + organizationId, + worktreeId, + ]); + expect(query.sql).toContain('"kilo_user_id"'); + expect(query.sql).toContain('"organization_id"'); + expect(query.sql).toContain('"cloud_agent_session_scope_id"'); + expect(query.sql).toContain('"cloud_agent_worktree_id"'); + }); + + it('does not create a child when the asserted root belongs to another user or root scope', async () => { + const { db, insertedValues, selectConditions } = makeDb([[]], []); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), + }), + env + ); + expect(response.status).toBe(404); + expect(insertedValues).toEqual([]); + const query = new PgDialect().sqlToQuery(selectConditions[0]); + expect(query.params).toEqual( + expect.arrayContaining([rootSessionId, 'usr_test', cloudAgentSessionId]) + ); + expect(query.sql).toContain('"kilo_user_id"'); + expect(query.sql).toContain('"cloud_agent_session_id"'); + }); + + it('heals missing worktree membership for an already-authorized legacy child without changing its root', async () => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const child = persistedRow({ organization_id: null }); + const { db, updateSets } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId: null, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId, + }, + ], + [], + [child], + ], + [], + [{ ...child, cloud_agent_worktree_id: worktreeId }] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + vi.mocked(getSessionAccessCacheDO).mockReturnValue({ + putValidated: async () => undefined, + } as never); + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId }), + }), + env + ); + expect(response.status).toBe(200); + expect(updateSets).toEqual([{ cloud_agent_worktree_id: worktreeId }]); + }); + it('does not claim an existing unmarked session', async () => { const { db } = makeDb( [ @@ -232,7 +472,7 @@ describe('Cloud Agent session scope routes', () => { }); it('treats an existing child in the same session scope as an idempotent bootstrap', async () => { - const existing = persistedRow(); + const existing = persistedRow({ organization_id: null, cloud_agent_worktree_id: null }); const { db } = makeDb( [ [ diff --git a/services/session-ingest/src/routes/cloud-agent-session-scope.ts b/services/session-ingest/src/routes/cloud-agent-session-scope.ts index 2c8bafe4b6..850a4dace9 100644 --- a/services/session-ingest/src/routes/cloud-agent-session-scope.ts +++ b/services/session-ingest/src/routes/cloud-agent-session-scope.ts @@ -16,11 +16,15 @@ import { handleDirectIngestRequest } from '../ingest/direct-ingest'; import { mapSessionEventRow, notifyUserSessionEvent } from '../session-events'; import { resolveAccessibleKiloSession } from '../services/session-access'; import { canCreateCliSessionForUser } from '../services/user-session-admission'; +import { isWorktreeDeleting } from '../services/worktree-deletion'; import type { ApiContext } from './api'; -const createScopedSessionSchema = z.object({ - sessionId: containedKiloSessionIdSchema, -}); +const createScopedSessionSchema = z + .object({ + sessionId: containedKiloSessionIdSchema, + parentSessionId: containedKiloSessionIdSchema.optional(), + }) + .strict(); const ingestVersionSchema = z.coerce.number().int().nonnegative().catch(0); @@ -78,6 +82,7 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS sessionId: cli_sessions_v2.session_id, organizationId: cli_sessions_v2.organization_id, cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, }) .from(cli_sessions_v2) .where( @@ -95,7 +100,9 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS .limit(1) .for('update'); - if (!root) return { status: 'root_not_found' } as const; + if (!root || (root.worktreeId && (await isWorktreeDeleting(tx, root.worktreeId)))) { + return { status: 'root_not_found' } as const; + } if ( root.organizationId !== null && !(await hasOrganizationAccess(tx, { @@ -119,15 +126,39 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS ); } + const parentSessionId = body.parentSessionId ?? assertion.data.rootKiloSessionId; + if (parentSessionId === body.sessionId) return { status: 'conflict' } as const; + if (parentSessionId !== assertion.data.rootKiloSessionId) { + const [parent] = await tx + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.session_id, parentSessionId), + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + eq(cli_sessions_v2.cloud_agent_session_scope_id, assertion.data.cloudAgentSessionId), + root.organizationId === null + ? isNull(cli_sessions_v2.organization_id) + : eq(cli_sessions_v2.organization_id, root.organizationId), + root.worktreeId + ? eq(cli_sessions_v2.cloud_agent_worktree_id, root.worktreeId) + : isNull(cli_sessions_v2.cloud_agent_worktree_id) + ) + ) + .limit(1); + if (!parent) return { status: 'conflict' } as const; + } + const [created] = await tx .insert(cli_sessions_v2) .values({ session_id: body.sessionId, kilo_user_id: kiloUserId, - parent_session_id: assertion.data.rootKiloSessionId, + parent_session_id: parentSessionId, organization_id: root.organizationId, cloud_agent_session_id: null, cloud_agent_session_scope_id: assertion.data.cloudAgentSessionId, + cloud_agent_worktree_id: root.worktreeId, }) .onConflictDoNothing({ target: [cli_sessions_v2.session_id, cli_sessions_v2.kilo_user_id], @@ -148,11 +179,46 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS .limit(1) .for('update'); + if (!existing || existing.cloud_agent_session_id !== null) { + return { status: 'conflict' } as const; + } + if ( + body.parentSessionId !== undefined && + existing.cloud_agent_session_scope_id === null && + existing.cloud_agent_worktree_id === null && + (existing.parent_session_id === null || existing.parent_session_id === parentSessionId) && + (existing.organization_id === null || existing.organization_id === root.organizationId) + ) { + const [adopted] = await tx + .update(cli_sessions_v2) + .set({ + parent_session_id: parentSessionId, + cloud_agent_session_scope_id: assertion.data.cloudAgentSessionId, + cloud_agent_worktree_id: root.worktreeId ?? null, + ...(existing.organization_id !== root.organizationId + ? { organization_id: root.organizationId } + : {}), + }) + .where( + and( + eq(cli_sessions_v2.session_id, body.sessionId), + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + isNull(cli_sessions_v2.cloud_agent_session_id), + isNull(cli_sessions_v2.cloud_agent_session_scope_id), + isNull(cli_sessions_v2.cloud_agent_worktree_id) + ) + ) + .returning(); + return adopted + ? ({ status: 'adopted', row: adopted } as const) + : ({ status: 'conflict' } as const); + } if ( - !existing || - existing.cloud_agent_session_id !== null || existing.parent_session_id === null || - existing.cloud_agent_session_scope_id !== assertion.data.cloudAgentSessionId + existing.cloud_agent_session_scope_id !== assertion.data.cloudAgentSessionId || + existing.organization_id !== root.organizationId || + (existing.cloud_agent_worktree_id != null && + existing.cloud_agent_worktree_id !== (root.worktreeId ?? null)) ) { return { status: 'conflict' } as const; } @@ -165,6 +231,23 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS ) { return { status: 'root_not_found' } as const; } + if (root.worktreeId && existing.cloud_agent_worktree_id === null) { + const [healed] = await tx + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: root.worktreeId }) + .where( + and( + eq(cli_sessions_v2.session_id, body.sessionId), + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + eq(cli_sessions_v2.cloud_agent_session_scope_id, assertion.data.cloudAgentSessionId), + isNull(cli_sessions_v2.cloud_agent_worktree_id) + ) + ) + .returning(); + return healed + ? ({ status: 'adopted', row: healed } as const) + : ({ status: 'conflict' } as const); + } return { status: 'existing', row: existing } as const; }); @@ -197,13 +280,13 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS }); } - if (result.status === 'created') { + if (result.status === 'created' || result.status === 'adopted') { const session = mapSessionEventRow(result.row); notifyUserSessionEvent( c.env, kiloUserId, { - type: 'session.created', + type: result.status === 'created' ? 'session.created' : 'session.updated', data: { source: 'v2', session, changedAt: session.updatedAt }, }, getOptionalExecutionContext(c) diff --git a/services/session-ingest/src/services/worktree-allocation.ts b/services/session-ingest/src/services/worktree-allocation.ts new file mode 100644 index 0000000000..e53e05dcf9 --- /dev/null +++ b/services/session-ingest/src/services/worktree-allocation.ts @@ -0,0 +1,85 @@ +import { and, eq, inArray, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import type { WorkerDb } from '@kilocode/db/client'; +import { operation_ledgers } from '@kilocode/db/schema'; +import { + cloudAgentWorktreeLocationSchema, + sessionIdSchema, + WORKTREE_RUNTIME_HISTORY_UNAVAILABLE, + type CloudAgentWorktreeId, + type CloudAgentWorktreeLocation, +} from '@kilocode/session-ingest-contracts'; + +const allocationSchema = z.object({ + cloudAgentSessionId: z.string().min(1), + kiloSessionId: sessionIdSchema, + sandboxId: cloudAgentWorktreeLocationSchema.shape.sandboxId, + sandboxProvider: cloudAgentWorktreeLocationSchema.shape.provider, +}); + +type SessionAllocation = z.infer & { organizationId: string | null }; + +export function firstWorktreeSessionId(worktreeId: CloudAgentWorktreeId): `workspace_${string}` { + return `workspace_${worktreeId.slice('worktree_'.length)}`; +} + +export async function readSessionAllocations( + db: Pick, + kiloUserId: string, + cloudAgentSessionIds: string[] +): Promise { + if (cloudAgentSessionIds.length === 0) return []; + const ids = [...new Set(cloudAgentSessionIds)]; + const rows = await db + .select({ + kilo_user_id: operation_ledgers.kilo_user_id, + organization_id: operation_ledgers.organization_id, + intent: operation_ledgers.intent, + resource_key: operation_ledgers.resource_key, + canonical_result: operation_ledgers.canonical_result, + }) + .from(operation_ledgers) + .where( + and( + eq(operation_ledgers.kilo_user_id, kiloUserId), + eq(operation_ledgers.domain, 'session'), + eq(operation_ledgers.intent, 'create_cloud'), + inArray(sql`${operation_ledgers.canonical_result}->>'cloudAgentSessionId'`, ids) + ) + ); + return rows.flatMap(row => { + if ( + row.kilo_user_id !== kiloUserId || + row.intent !== 'create_cloud' || + row.resource_key !== null + ) + return []; + const parsed = allocationSchema.safeParse(row.canonical_result); + if (!parsed.success || !ids.includes(parsed.data.cloudAgentSessionId)) return []; + return [{ ...parsed.data, organizationId: row.organization_id }]; + }); +} + +export function allocationLocation( + allocations: SessionAllocation[], + identity: { cloudAgentSessionId: string; sessionId: string | null; organizationId: string | null } +): CloudAgentWorktreeLocation | undefined { + const matches = allocations.filter( + allocation => + allocation.cloudAgentSessionId === identity.cloudAgentSessionId && + allocation.organizationId === identity.organizationId + ); + const first = matches[0]; + if (!first) return undefined; + if ( + matches.some( + allocation => + (identity.sessionId !== null && allocation.kiloSessionId !== identity.sessionId) || + allocation.kiloSessionId !== first.kiloSessionId || + allocation.sandboxId !== first.sandboxId || + allocation.sandboxProvider !== first.sandboxProvider + ) + ) + throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + return { sandboxId: first.sandboxId, provider: first.sandboxProvider }; +} diff --git a/services/session-ingest/src/services/worktree-deletion.test.ts b/services/session-ingest/src/services/worktree-deletion.test.ts new file mode 100644 index 0000000000..180f463888 --- /dev/null +++ b/services/session-ingest/src/services/worktree-deletion.test.ts @@ -0,0 +1,865 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PgDialect } from 'drizzle-orm/pg-core'; +import type { SQL } from 'drizzle-orm'; +import { + cli_sessions_v2, + cloud_agent_worktrees, + organization_memberships, + operation_ledgers, + type CloudAgentWorktree, +} from '@kilocode/db/schema'; +import { + cloudAgentWorktreeDeletionManifestSchema, + type CloudAgentWorktreeDeletionParams, + type CanDestroyCloudAgentWorktreeSandboxParams, +} from '@kilocode/session-ingest-contracts'; +import { + beginWorktreeDeletion, + recordWorktreeCleanup, + completeWorktreeDeletion, + canDestroyWorktreeSandbox, + registerCloudAgentWorktree, +} from './worktree-deletion'; +import type { Env } from '../env'; + +const mocks = vi.hoisted(() => ({ + getDb: vi.fn(), + ingest: vi.fn(), + cache: vi.fn(), + connection: vi.fn(), +})); +vi.mock('cloudflare:workers', () => ({ DurableObject: class {} })); +vi.mock('@kilocode/db/client', () => ({ getWorkerDb: mocks.getDb })); +vi.mock('../dos/SessionIngestDO', () => ({ getSessionIngestDO: mocks.ingest })); +vi.mock('../dos/SessionAccessCacheDO', () => ({ getSessionAccessCacheDO: mocks.cache })); +vi.mock('../dos/UserConnectionDO', () => ({ getUserConnectionDO: mocks.connection })); + +const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; +const otherWorktreeId = 'worktree_22222222-2222-4222-8222-222222222222'; +const organizationId = '33333333-3333-4333-8333-333333333333'; +const userId = 'oauth/github:worktree-owner'; +const location = { sandboxId: 'ses-original', provider: 'cloudflare' as const }; +const params = { worktreeId, kiloUserId: userId } satisfies CloudAgentWorktreeDeletionParams; +const env = { HYPERDRIVE: { connectionString: 'postgres://unused' } } as Env; +const kiloId = (index: number) => `ses_${String(index).padStart(26, '0')}`; +const cloudId = (index: number): `workspace_${string}` => + `workspace_${String(index).padStart(8, '0')}-0000-4000-8000-000000000000`; + +function worktree(values: Partial = {}): CloudAgentWorktree { + return { + worktree_id: worktreeId, + kilo_user_id: userId, + organization_id: null, + name: 'Private worktree name', + created_at: '2026-08-27 01:00:00+00', + updated_at: '2026-08-27 01:00:00+00', + deletion_started_at: null, + deletion_completed_at: null, + runtime_locations: [location], + deletion_manifest: null, + deleted_session_ids: [], + ...values, + }; +} + +type Member = { + sessionId: string; + cloudAgentSessionId: string | null; + cloudAgentSessionScopeId?: string | null; + organizationId: string | null; + worktreeId: string | null; + userId: string; + parentSessionId: string | null; +}; + +function database(rootCount = 1, descendantCount = 0) { + const worktrees: CloudAgentWorktree[] = [worktree()]; + const members: Member[] = [ + ...Array.from({ length: rootCount }, (_, index) => ({ + sessionId: kiloId(index), + cloudAgentSessionId: cloudId(index), + organizationId: null, + worktreeId, + userId, + parentSessionId: null, + })), + ...Array.from({ length: descendantCount }, (_, index) => ({ + sessionId: kiloId(rootCount + index), + cloudAgentSessionId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: kiloId(index === 0 ? 0 : rootCount + index - 1), + })), + ]; + const allocations: Array<{ + kilo_user_id: string; + organization_id: string | null; + intent: string; + resource_key: string | null; + canonical_result: Record; + }> = []; + const events: string[] = []; + const sqlQueries: { sql: string; params: unknown[] }[] = []; + let membershipAllowed = true; + const valuesOf = (condition: SQL | undefined) => + condition ? new PgDialect().sqlToQuery(condition).params : []; + const select = (columns?: Record) => { + let table: unknown; + let condition: SQL | undefined; + let limit: number | undefined; + const rows = () => { + const bound = valuesOf(condition); + if (table === cloud_agent_worktrees) { + const id = bound.find(value => typeof value === 'string' && value.startsWith('worktree_')); + return id + ? worktrees.filter(row => row.worktree_id === id) + : worktrees.filter(row => row.deletion_completed_at === null); + } + if (table === organization_memberships) + return membershipAllowed ? [{ id: 'membership' }] : []; + if (table === operation_ledgers) return allocations; + if (table === cli_sessions_v2) { + const querySql = condition ? new PgDialect().sqlToQuery(condition).sql : ''; + if (querySql.includes('"cloud_agent_session_id" is null')) { + const cursor = bound.find(value => typeof value === 'string' && value.startsWith('ses_')); + return members + .filter( + row => + row.userId === userId && + row.cloudAgentSessionId === null && + row.cloudAgentSessionScopeId == null && + row.worktreeId === null && + row.parentSessionId === null && + (querySql.includes('"organization_id" is null') + ? row.organizationId === null + : bound.includes(row.organizationId)) && + (typeof cursor !== 'string' || row.sessionId > cursor) + ) + .sort((a, b) => a.sessionId.localeCompare(b.sessionId)) + .slice(0, limit); + } + if (columns && 'userId' in columns) + return members + .filter(row => row.worktreeId === worktreeId) + .map(row => ({ userId: row.userId, organizationId: row.organizationId })); + if ( + columns && + 'worktreeId' in columns && + !bound.some(value => typeof value === 'string' && value.startsWith('ses_')) + ) + return members.filter( + row => + row.userId === userId && + row.parentSessionId === null && + row.cloudAgentSessionId !== null + ); + return members.filter( + row => + row.userId === userId && + (bound.includes(row.sessionId) || + (bound.includes(worktreeId) && row.worktreeId === worktreeId)) + ); + } + throw new Error('Unexpected table'); + }; + const query = { + from: (value: unknown) => { + table = value; + return query; + }, + innerJoin: () => query, + where: (value: SQL) => { + condition = value; + return query; + }, + orderBy: () => query, + limit: (value: number) => { + limit = value; + return query; + }, + for: async () => { + events.push(table === cloud_agent_worktrees ? 'lockWorktree' : 'lockRoots'); + return rows(); + }, + then: (resolve: (result: unknown[]) => unknown) => resolve(rows()), + }; + return query; + }; + const insert = () => ({ + values: (values: typeof cloud_agent_worktrees.$inferInsert) => ({ + onConflictDoNothing: async () => { + if (!worktrees.some(row => row.worktree_id === values.worktree_id)) + worktrees.push(worktree({ name: null, runtime_locations: [], ...values })); + }, + }), + }); + const update = (table: unknown) => ({ + set: ( + values: Partial & { + parent_session_id?: string; + cloud_agent_session_scope_id?: string; + cloud_agent_worktree_id?: string; + } + ) => { + const query = { + where: (condition: SQL) => { + if (table === cli_sessions_v2) { + for (const row of members.filter( + member => member.userId === userId && valuesOf(condition).includes(member.sessionId) + )) { + row.parentSessionId = values.parent_session_id ?? row.parentSessionId; + row.cloudAgentSessionScopeId = + values.cloud_agent_session_scope_id ?? row.cloudAgentSessionScopeId; + row.worktreeId = values.cloud_agent_worktree_id ?? row.worktreeId; + if (values.organization_id !== undefined) row.organizationId = values.organization_id; + } + } else { + Object.assign(worktrees[0], values); + events.push('updateWorktree'); + } + return query; + }, + returning: async () => [worktrees[0]], + then: (resolve: (result: undefined) => unknown) => resolve(undefined), + }; + return query; + }, + }); + const remove = () => ({ + where: async (condition: SQL) => { + const ids = valuesOf(condition); + events.push('deleteRows'); + for (let index = members.length - 1; index >= 0; index--) + if (members[index].userId === userId && ids.includes(members[index].sessionId)) + members.splice(index, 1); + }, + }); + const tx = { + select, + insert, + update, + delete: remove, + execute: async (query: SQL) => { + sqlQueries.push(new PgDialect().sqlToQuery(query)); + const included = new Set( + members.filter(row => row.worktreeId === worktreeId).map(row => row.sessionId) + ); + let previousSize = -1; + while (included.size !== previousSize) { + previousSize = included.size; + const scopes = new Set( + members + .filter(row => included.has(row.sessionId)) + .map(row => row.cloudAgentSessionId) + .filter(Boolean) + ); + for (const row of members) { + if ( + row.userId === userId && + ((row.parentSessionId !== null && included.has(row.parentSessionId)) || + (row.cloudAgentSessionScopeId && scopes.has(row.cloudAgentSessionScopeId))) + ) + included.add(row.sessionId); + } + } + return { + rows: members + .filter(row => included.has(row.sessionId)) + .map( + ({ + sessionId, + cloudAgentSessionId, + cloudAgentSessionScopeId, + organizationId, + worktreeId, + }) => ({ + sessionId, + cloudAgentSessionId, + cloudAgentSessionScopeId: cloudAgentSessionScopeId ?? null, + organizationId, + worktreeId, + }) + ), + }; + }, + }; + const db = { + ...tx, + transaction: async (callback: (transaction: typeof tx) => Promise) => callback(tx), + }; + mocks.getDb.mockReturnValue(db); + const cleared = new Set(); + const snapshots = new Map>(); + let failure: string | undefined; + mocks.ingest.mockImplementation((_env: Env, identity: { sessionId: string }) => ({ + readKiloSdkSessionSnapshot: async () => { + const info = snapshots.get(identity.sessionId); + return info + ? { + kind: 'value', + info: { + slug: 'child', + projectID: 'project', + title: 'Never run', + version: '7.4.20', + time: { created: 1, updated: 1 }, + ...info, + }, + byteLength: 100, + } + : { kind: 'pending' }; + }, + clearForWorktree: async (_user: string, session: string) => { + if (failure === session) throw new Error('R2 temporarily unavailable'); + cleared.add(session); + events.push(`clear:${session}`); + }, + })); + mocks.cache.mockReturnValue({ deleteSession: async () => undefined }); + mocks.connection.mockReturnValue({ clearSession: async () => undefined }); + return { + db, + tx, + worktrees, + members, + allocations, + events, + sqlQueries, + cleared, + snapshots, + denyMembership: () => { + membershipAllowed = false; + }, + fail: (id?: string) => { + failure = id; + }, + }; +} + +beforeEach(() => vi.resetAllMocks()); + +describe('durable worktree deletion journal', () => { + it('includes a never-run orphan in cold cleanup using retained authoritative root lineage', async () => { + const f = database(); + f.members.push({ + sessionId: kiloId(1), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }); + const initial = await beginWorktreeDeletion(env, params); + expect(initial.manifest.sessions.map(row => row.sessionId)).toEqual([kiloId(0)]); + await recordWorktreeCleanup(env, { + ...params, + childSessions: [ + { sessionId: kiloId(1), parentSessionId: kiloId(0), cloudAgentSessionId: cloudId(0) }, + ], + }); + f.fail(kiloId(1)); + await expect(completeWorktreeDeletion(env, params)).rejects.toThrow( + 'R2 temporarily unavailable' + ); + expect(f.members).toHaveLength(2); + f.fail(); + await expect(completeWorktreeDeletion(env, params)).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1)], + }); + expect(f.members).toEqual([]); + expect(f.cleared).toEqual(new Set([kiloId(0), kiloId(1)])); + await expect(beginWorktreeDeletion(env, params)).resolves.toMatchObject({ + completed: true, + manifest: { sessions: [{ sessionId: kiloId(0) }, { sessionId: kiloId(1) }] }, + }); + }); + + it('recovers an older never-run orphan snapshot after the runtime and creation event are gone', async () => { + const f = database(); + const directory = `/workspace/owner/worktrees/${worktreeId}`; + f.members.push({ + sessionId: kiloId(1), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }); + f.snapshots.set(kiloId(1), { id: kiloId(1), parentID: kiloId(0), directory }); + await beginWorktreeDeletion(env, params); + await recordWorktreeCleanup(env, { ...params, directory }); + await expect(completeWorktreeDeletion(env, params)).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1)], + }); + expect(f.members).toEqual([]); + expect(f.cleared.has(kiloId(1))).toBe(true); + }); + + it('does not infer snapshot ownership from directory alone or cross owner, organization, or established root scope', async () => { + const f = database(); + const directory = `/workspace/owner/worktrees/${worktreeId}`; + for (let index = 1; index <= 8; index++) { + f.members.push({ + sessionId: kiloId(index), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }); + f.snapshots.set(kiloId(index), { id: kiloId(index), parentID: kiloId(0), directory }); + } + f.members[2].userId = 'another-owner'; + f.members[3].organizationId = organizationId; + f.members[4].cloudAgentSessionScopeId = cloudId(9); + f.members[4].worktreeId = otherWorktreeId; + f.snapshots.set(kiloId(5), { id: kiloId(5), parentID: kiloId(99), directory }); + f.snapshots.set(kiloId(6), { + id: kiloId(6), + parentID: kiloId(0), + directory: `${directory}/other`, + }); + f.snapshots.set(kiloId(7), { id: kiloId(99), parentID: kiloId(0), directory }); + f.snapshots.set(kiloId(8), { id: kiloId(8), parentID: kiloId(8), directory }); + await beginWorktreeDeletion(env, params); + await recordWorktreeCleanup(env, { ...params, directory }); + await completeWorktreeDeletion(env, params); + expect(f.cleared).toEqual(new Set([kiloId(0), kiloId(1)])); + expect(f.members.map(row => row.sessionId)).toEqual( + Array.from({ length: 7 }, (_, index) => kiloId(index + 2)) + ); + }); + + it('includes persisted descendants newly reachable through a recovered orphan parent', async () => { + const f = database(); + const directory = `/workspace/owner/worktrees/${worktreeId}`; + f.members.push( + { + sessionId: kiloId(1), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }, + { + sessionId: kiloId(2), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: kiloId(1), + } + ); + f.snapshots.set(kiloId(1), { id: kiloId(1), parentID: kiloId(0), directory }); + await beginWorktreeDeletion(env, params); + await recordWorktreeCleanup(env, { ...params, directory }); + await expect(completeWorktreeDeletion(env, params)).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1), kiloId(2)], + }); + expect(f.members).toEqual([]); + }); + + it('paginates all orphan snapshots and resolves nested lineage independently of row order', async () => { + const f = database(); + const directory = `/workspace/owner/worktrees/${worktreeId}`; + for (let index = 1; index <= 205; index++) { + f.members.push({ + sessionId: kiloId(index), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }); + f.snapshots.set(kiloId(index), { + id: kiloId(index), + parentID: kiloId(index === 205 ? 0 : index + 1), + directory, + }); + } + await beginWorktreeDeletion(env, params); + const recovered = await recordWorktreeCleanup(env, { ...params, directory }); + expect(recovered.manifest.sessions).toHaveLength(206); + await completeWorktreeDeletion(env, params); + expect(f.members).toEqual([]); + expect(f.cleared.size).toBe(206); + }); + + it.each(['worktree', 'scope'])( + 'rejects an established foreign %s reached through a parent pointer', + async field => { + const f = database(1, 1); + if (field === 'worktree') f.members[1].worktreeId = otherWorktreeId; + else f.members[1].cloudAgentSessionScopeId = cloudId(9); + await expect(beginWorktreeDeletion(env, params)).rejects.toThrow('worktree_access_denied'); + expect(f.worktrees[0].deletion_started_at).toBeNull(); + expect(f.cleared.size).toBe(0); + } + ); + + it('rejects retained lineage that spoofs a different root even inside the same worktree', async () => { + const f = database(2); + f.members.push({ + sessionId: kiloId(2), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }); + await beginWorktreeDeletion(env, params); + await expect( + recordWorktreeCleanup(env, { + ...params, + childSessions: [ + { sessionId: kiloId(2), parentSessionId: kiloId(1), cloudAgentSessionId: cloudId(0) }, + ], + }) + ).rejects.toThrow('worktree_child_lineage_conflict'); + expect(f.members[2]).toMatchObject({ + parentSessionId: null, + cloudAgentSessionScopeId: null, + worktreeId: null, + }); + expect(f.cleared.size).toBe(0); + }); + + it.each([null, otherWorktreeId])( + 'does not count an unrelated %s root allocation as physical sharing', + async unrelatedWorktreeId => { + const f = database(); + await beginWorktreeDeletion(env, params); + const unrelatedCloudId = unrelatedWorktreeId + ? `workspace_${unrelatedWorktreeId.slice('worktree_'.length)}` + : 'agent_44444444-4444-4444-8444-444444444444'; + if (unrelatedWorktreeId) + f.worktrees.push(worktree({ worktree_id: unrelatedWorktreeId, runtime_locations: [] })); + f.members.push({ + sessionId: kiloId(1), + cloudAgentSessionId: unrelatedCloudId, + organizationId: null, + worktreeId: unrelatedWorktreeId, + userId, + parentSessionId: null, + }); + f.allocations.push({ + kilo_user_id: userId, + organization_id: null, + intent: 'create_cloud', + resource_key: null, + canonical_result: { + cloudAgentSessionId: unrelatedCloudId, + kiloSessionId: kiloId(1), + sandboxId: 'ses-unrelated-original', + sandboxProvider: 'vercel', + }, + }); + const result = await canDestroyWorktreeSandbox(env, { ...params, location }); + if (unrelatedWorktreeId) expect(result).toEqual({ kind: 'exclusive' }); + else + expect(result).toMatchObject({ + kind: 'unresolved', + owners: [ + { allocationLocation: { sandboxId: 'ses-unrelated-original', provider: 'vercel' } }, + ], + }); + } + ); + + it('recovers an ownership-only migrated root from its pre-registration allocation and preserves it on retry', async () => { + const f = database(); + const originalCloudId = `workspace_${worktreeId.slice('worktree_'.length)}`; + f.members[0].cloudAgentSessionId = originalCloudId; + f.worktrees[0].runtime_locations = []; + f.allocations.push({ + kilo_user_id: userId, + organization_id: null, + intent: 'create_cloud', + resource_key: null, + canonical_result: { + cloudAgentSessionId: originalCloudId, + kiloSessionId: kiloId(0), + sandboxId: 'ses-canonical-original', + sandboxProvider: 'vercel', + initialMessageId: 'msg_original', + }, + }); + const first = await beginWorktreeDeletion(env, params); + expect(first.runtimeLocations).toEqual([ + { sandboxId: 'ses-canonical-original', provider: 'vercel' }, + ]); + expect(f.worktrees[0].deletion_started_at).not.toBeNull(); + const retry = await beginWorktreeDeletion(env, params); + expect(retry).toEqual(first); + expect(f.allocations[0].canonical_result.sandboxId).toBe('ses-canonical-original'); + }); + + it('recovers an already-fenced ownership-only root when allocation history is available on retry', async () => { + const f = database(); + const originalCloudId = `workspace_${worktreeId.slice('worktree_'.length)}`; + f.members[0].cloudAgentSessionId = originalCloudId; + f.worktrees[0].runtime_locations = []; + expect((await beginWorktreeDeletion(env, params)).runtimeLocations).toEqual([]); + const started = f.worktrees[0].deletion_started_at; + f.allocations.push({ + kilo_user_id: userId, + organization_id: null, + intent: 'create_cloud', + resource_key: null, + canonical_result: { + cloudAgentSessionId: originalCloudId, + kiloSessionId: kiloId(0), + sandboxId: 'usr-persisted-override', + sandboxProvider: 'cloudflare', + }, + }); + expect((await beginWorktreeDeletion(env, params)).runtimeLocations).toEqual([ + { sandboxId: 'usr-persisted-override', provider: 'cloudflare' }, + ]); + expect(f.worktrees[0].deletion_started_at).toBe(started); + }); + + it('can recover a sibling-only group from its canonical first allocation after the first chat row is gone', async () => { + const f = database(); + f.worktrees[0].runtime_locations = []; + f.allocations.push({ + kilo_user_id: userId, + organization_id: null, + intent: 'create_cloud', + resource_key: null, + canonical_result: { + cloudAgentSessionId: `workspace_${worktreeId.slice('worktree_'.length)}`, + kiloSessionId: kiloId(500), + sandboxId: 'ses-original-first-chat', + sandboxProvider: 'vercel', + }, + }); + expect((await beginWorktreeDeletion(env, params)).runtimeLocations).toEqual([ + { sandboxId: 'ses-original-first-chat', provider: 'vercel' }, + ]); + }); + + it.each(['owner', 'organization', 'provider'])( + 'does not guess an allocation from invalid %s history', + async field => { + const f = database(); + const originalCloudId = `workspace_${worktreeId.slice('worktree_'.length)}`; + f.members[0].cloudAgentSessionId = originalCloudId; + f.worktrees[0].runtime_locations = []; + f.allocations.push({ + kilo_user_id: field === 'owner' ? 'unrelated-owner' : userId, + organization_id: field === 'organization' ? organizationId : null, + intent: 'create_cloud', + resource_key: null, + canonical_result: { + cloudAgentSessionId: originalCloudId, + kiloSessionId: kiloId(0), + sandboxId: 'ses-do-not-touch', + sandboxProvider: field === 'provider' ? 'unsupported' : 'vercel', + }, + }); + expect((await beginWorktreeDeletion(env, params)).runtimeLocations).toEqual([]); + expect(f.worktrees[0].runtime_locations).toEqual([]); + } + ); + + it('rejects conflicting canonical allocations rather than choosing a physical route', async () => { + const f = database(); + const originalCloudId = `workspace_${worktreeId.slice('worktree_'.length)}`; + f.members[0].cloudAgentSessionId = originalCloudId; + f.worktrees[0].runtime_locations = []; + for (const sandboxId of ['ses-first', 'ses-conflict']) + f.allocations.push({ + kilo_user_id: userId, + organization_id: null, + intent: 'create_cloud', + resource_key: null, + canonical_result: { + cloudAgentSessionId: originalCloudId, + kiloSessionId: kiloId(0), + sandboxId, + sandboxProvider: 'vercel', + }, + }); + await expect(beginWorktreeDeletion(env, params)).rejects.toThrow( + 'worktree_runtime_history_unavailable' + ); + expect(f.worktrees[0].runtime_locations).toEqual([]); + }); + + it('excludes acknowledged scoped cleanup only for worktrees whose admission has been fenced', async () => { + const f = database(); + await beginWorktreeDeletion(env, params); + f.worktrees.push(worktree({ worktree_id: otherWorktreeId })); + const input = { + ...params, + location, + releasedWorktreeIds: [otherWorktreeId], + } satisfies CanDestroyCloudAgentWorktreeSandboxParams; + await expect(canDestroyWorktreeSandbox(env, input)).resolves.toEqual({ kind: 'shared' }); + f.worktrees[1].deletion_started_at = '2026-08-27 01:00:00+00'; + await expect(canDestroyWorktreeSandbox(env, input)).resolves.toEqual({ kind: 'exclusive' }); + }); + + it('denies a different owner and personal/org scope confusion before starting deletion', async () => { + const f = database(); + await expect( + beginWorktreeDeletion(env, { ...params, kiloUserId: 'another-user' }) + ).rejects.toThrow('access_denied'); + await expect(beginWorktreeDeletion(env, { ...params, organizationId })).rejects.toThrow( + 'access_denied' + ); + expect(f.worktrees[0].deletion_started_at).toBeNull(); + expect(f.events).not.toContain('deleteRows'); + }); + + it('denies lost or soft-deleted organization membership on completed tombstone retries', async () => { + const f = database(0); + f.worktrees[0] = worktree({ + organization_id: organizationId, + deletion_started_at: '2026-08-27 02:00:00+00', + deletion_completed_at: '2026-08-27 02:01:00+00', + name: null, + deleted_session_ids: [kiloId(0)], + }); + f.denyMembership(); + await expect(beginWorktreeDeletion(env, { ...params, organizationId })).rejects.toThrow( + 'access_denied' + ); + expect(mocks.ingest).not.toHaveBeenCalled(); + }); + + it('discovers all roots and descendants without a UI limit or recursion depth cap, and keeps the manifest after rows disappear', async () => { + const f = database(205, 15); + const state = await beginWorktreeDeletion(env, params); + expect(state.manifest.sessions).toHaveLength(220); + expect(f.events.indexOf('lockWorktree')).toBeLessThan(f.events.indexOf('lockRoots')); + expect(f.sqlQueries[0].sql).toContain('WITH RECURSIVE'); + expect(f.sqlQueries[0].sql).not.toMatch(/limit|depth\s* { + const f = database(); + f.worktrees.length = 0; + const state = await beginWorktreeDeletion(env, params); + expect(state.manifest.sessions).toHaveLength(1); + expect(f.worktrees[0].kilo_user_id).toBe(userId); + expect(f.worktrees[0].organization_id).toBeNull(); + }); + + it('rejects new root registration under the same locked worktree after deletion begins', async () => { + const f = database(); + await beginWorktreeDeletion(env, params); + await expect( + registerCloudAgentWorktree(f.tx as never, { + sessionId: kiloId(100), + kiloUserId: userId, + cloudAgentSessionId: cloudId(100), + cloudAgentWorktreeId: worktreeId, + cloudAgentWorktreeLocation: location, + createdOnPlatform: 'cloud-agent-web', + }) + ).rejects.toThrow('worktree_deleting'); + expect(f.worktrees[0].kilo_user_id).toBe(userId); + expect(f.worktrees[0].organization_id).toBeNull(); + }); + + it('retains rows, names, and retry locators until every targeted ingest cleanup succeeds', async () => { + const f = database(2, 1); + await beginWorktreeDeletion(env, params); + f.fail(kiloId(1)); + await expect(completeWorktreeDeletion(env, params)).rejects.toThrow( + 'R2 temporarily unavailable' + ); + expect(f.members).toHaveLength(3); + expect(f.worktrees[0].deletion_completed_at).toBeNull(); + expect(f.worktrees[0].runtime_locations).toEqual([location]); + f.fail(); + await expect(completeWorktreeDeletion(env, params)).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1), kiloId(2)], + }); + expect(f.events.indexOf('deleteRows')).toBeGreaterThan( + f.events.lastIndexOf(`clear:${kiloId(2)}`) + ); + expect(f.members).toHaveLength(0); + expect(f.worktrees[0]).toMatchObject({ + name: null, + runtime_locations: [], + deletion_manifest: null, + }); + expect(f.worktrees[0].deletion_completed_at).not.toBeNull(); + }); + + it('journals runtime-only descendants but refuses to claim an unrelated persisted session', async () => { + const f = database(); + await beginWorktreeDeletion(env, params); + await recordWorktreeCleanup(env, { ...params, sessionIds: [kiloId(5)] }); + expect( + cloudAgentWorktreeDeletionManifestSchema + .parse(f.worktrees[0].deletion_manifest) + .sessions.map(row => row.sessionId) + ).toContain(kiloId(5)); + f.members.push({ + sessionId: kiloId(6), + cloudAgentSessionId: cloudId(6), + organizationId: null, + worktreeId: otherWorktreeId, + userId, + parentSessionId: null, + }); + await expect( + recordWorktreeCleanup(env, { ...params, sessionIds: [kiloId(6)] }) + ).rejects.toThrow('session_conflict'); + }); + + it('refuses exclusive teardown for another worktree or an unopened root with unknown runtime ownership', async () => { + const f = database(); + await beginWorktreeDeletion(env, params); + await expect(canDestroyWorktreeSandbox(env, { ...params, location })).resolves.toEqual({ + kind: 'exclusive', + }); + f.worktrees.push(worktree({ worktree_id: otherWorktreeId, runtime_locations: [location] })); + await expect(canDestroyWorktreeSandbox(env, { ...params, location })).resolves.toEqual({ + kind: 'shared', + }); + f.worktrees[1].runtime_locations = [{ ...location, sandboxId: 'ses-unrelated' }]; + f.members.push({ + sessionId: kiloId(1), + cloudAgentSessionId: cloudId(1), + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + }); + await expect(canDestroyWorktreeSandbox(env, { ...params, location })).resolves.toMatchObject({ + kind: 'unresolved', + }); + f.members[1].worktreeId = otherWorktreeId; + await expect(canDestroyWorktreeSandbox(env, { ...params, location })).resolves.toEqual({ + kind: 'exclusive', + }); + f.members.pop(); + f.worktrees[1].runtime_locations = []; + await expect(canDestroyWorktreeSandbox(env, { ...params, location })).resolves.toMatchObject({ + kind: 'unresolved', + }); + }); +}); diff --git a/services/session-ingest/src/services/worktree-deletion.ts b/services/session-ingest/src/services/worktree-deletion.ts new file mode 100644 index 0000000000..f3ede91e6b --- /dev/null +++ b/services/session-ingest/src/services/worktree-deletion.ts @@ -0,0 +1,849 @@ +import { and, eq, gt, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'; +import { z } from 'zod'; +import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; +import { + cli_sessions_v2, + cloud_agent_worktrees, + type CloudAgentWorktree, +} from '@kilocode/db/schema'; +import { + cloudAgentWorktreeDeletionManifestSchema, + cloudAgentWorktreeLocationSchema, + cloudAgentWorktreeIdSchema, + containedKiloSessionIdSchema, + kiloSdkSessionSnapshotOutcomeSchema, + WORKTREE_RUNTIME_HISTORY_UNAVAILABLE, + type CanDestroyCloudAgentWorktreeSandboxResult, + type UnresolvedCloudAgentSandboxOwner, + type CloudAgentWorktreeDeletionParams, + type CloudAgentWorktreeDeletionState, + type CloudAgentWorktreeLocation, + type CreateSessionForCloudAgentParams, + type RecordCloudAgentWorktreeCleanupParams, + type CanDestroyCloudAgentWorktreeSandboxParams, +} from '@kilocode/session-ingest-contracts'; +import { hasOrganizationAccess, withDORetry } from '@kilocode/worker-utils'; +import type { Env } from '../env'; +import { getSessionIngestDO } from '../dos/SessionIngestDO'; +import { getSessionAccessCacheDO } from '../dos/SessionAccessCacheDO'; +import { getUserConnectionDO } from '../dos/UserConnectionDO'; +import { + allocationLocation, + firstWorktreeSessionId, + readSessionAllocations, +} from './worktree-allocation'; + +type WorktreeDb = Pick; + +export const WORKTREE_ACCESS_DENIED = 'worktree_access_denied'; +export const WORKTREE_DELETING = 'worktree_deleting'; + +function locations(row: CloudAgentWorktree): CloudAgentWorktreeLocation[] { + return z.array(cloudAgentWorktreeLocationSchema).parse(row.runtime_locations); +} + +function deletionState(row: CloudAgentWorktree): CloudAgentWorktreeDeletionState { + if (row.deletion_completed_at !== null) { + return { + completed: true, + manifest: { + version: 1, + sessions: row.deleted_session_ids.map(sessionId => ({ + sessionId, + cloudAgentSessionId: null, + })), + }, + runtimeLocations: [], + }; + } + return { + completed: false, + manifest: cloudAgentWorktreeDeletionManifestSchema.parse(row.deletion_manifest), + runtimeLocations: locations(row), + }; +} + +async function authorize( + db: WorktreeDb, + row: CloudAgentWorktree | undefined, + params: CloudAgentWorktreeDeletionParams +): Promise { + if ( + !row || + row.kilo_user_id !== params.kiloUserId || + row.organization_id !== (params.organizationId ?? null) || + (row.organization_id !== null && + !(await hasOrganizationAccess(db, { + kiloUserId: params.kiloUserId, + organizationId: row.organization_id, + }))) + ) { + throw new Error(WORKTREE_ACCESS_DENIED); + } + return row; +} + +async function lockWorktree(db: WorktreeDb, params: CloudAgentWorktreeDeletionParams) { + const [row] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)) + .for('update'); + return authorize(db, row, params); +} + +export async function registerCloudAgentWorktree( + db: WorktreeDb, + params: CreateSessionForCloudAgentParams +): Promise { + if (!params.cloudAgentWorktreeId) return; + await db + .insert(cloud_agent_worktrees) + .values({ + worktree_id: params.cloudAgentWorktreeId, + kilo_user_id: params.kiloUserId, + organization_id: params.organizationId ?? null, + }) + .onConflictDoNothing({ target: cloud_agent_worktrees.worktree_id }); + const row = await lockWorktree(db, { + worktreeId: params.cloudAgentWorktreeId, + kiloUserId: params.kiloUserId, + organizationId: params.organizationId, + }); + if (row.deletion_started_at !== null) throw new Error(WORKTREE_DELETING); + if (params.cloudAgentWorktreeLocation) { + const current = locations(row); + const incoming = params.cloudAgentWorktreeLocation; + if ( + !current.some( + item => item.sandboxId === incoming.sandboxId && item.provider === incoming.provider + ) + ) { + await db + .update(cloud_agent_worktrees) + .set({ runtime_locations: [...current, incoming] }) + .where(eq(cloud_agent_worktrees.worktree_id, params.cloudAgentWorktreeId)); + } + } +} + +export async function isWorktreeDeleting( + db: Pick, + worktreeId: string +): Promise { + const [row] = await db + .select({ started: cloud_agent_worktrees.deletion_started_at }) + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, worktreeId)); + return row?.started != null; +} + +export async function isWorktreeSessionDeleting( + db: Pick, + kiloUserId: string, + sessionId: string +): Promise { + const [session] = await db + .select({ + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + scopeId: cli_sessions_v2.cloud_agent_session_scope_id, + }) + .from(cli_sessions_v2) + .where( + and(eq(cli_sessions_v2.kilo_user_id, kiloUserId), eq(cli_sessions_v2.session_id, sessionId)) + ); + if (!session) return false; + if (session.worktreeId) return isWorktreeDeleting(db, session.worktreeId); + if (!session.scopeId) return false; + const [root] = await db + .select({ worktreeId: cli_sessions_v2.cloud_agent_worktree_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, kiloUserId), + eq(cli_sessions_v2.cloud_agent_session_id, session.scopeId) + ) + ); + return root?.worktreeId ? isWorktreeDeleting(db, root.worktreeId) : false; +} + +const memberSchema = cloudAgentWorktreeDeletionManifestSchema.shape.sessions.element.extend({ + organizationId: z.uuid().nullable(), + worktreeId: cloudAgentWorktreeIdSchema.nullable(), + cloudAgentSessionScopeId: z.string().nullable(), +}); + +async function discoverMembers(db: WorktreeDb, params: CloudAgentWorktreeDeletionParams) { + const result = await db.execute(sql` + WITH RECURSIVE members AS ( + SELECT session_id, cloud_agent_session_id, organization_id, + cloud_agent_worktree_id, cloud_agent_session_scope_id + FROM ${cli_sessions_v2} + WHERE cloud_agent_worktree_id = ${params.worktreeId} AND kilo_user_id = ${params.kiloUserId} + UNION + SELECT child.session_id, child.cloud_agent_session_id, child.organization_id, + child.cloud_agent_worktree_id, child.cloud_agent_session_scope_id + FROM ${cli_sessions_v2} child + INNER JOIN members parent ON child.parent_session_id = parent.session_id + OR child.cloud_agent_session_scope_id = parent.cloud_agent_session_id + WHERE child.kilo_user_id = ${params.kiloUserId} + ) + SELECT session_id AS "sessionId", cloud_agent_session_id AS "cloudAgentSessionId", + organization_id AS "organizationId", cloud_agent_worktree_id AS "worktreeId", + cloud_agent_session_scope_id AS "cloudAgentSessionScopeId" + FROM members ORDER BY session_id + `); + const rows = z.array(memberSchema).parse(result.rows); + const rootScopes = new Set( + rows.flatMap(row => + row.worktreeId === params.worktreeId && row.cloudAgentSessionId + ? [row.cloudAgentSessionId] + : [] + ) + ); + if ( + rows.some( + row => + row.organizationId !== (params.organizationId ?? null) || + (row.worktreeId !== null && row.worktreeId !== params.worktreeId) || + (row.cloudAgentSessionScopeId !== null && !rootScopes.has(row.cloudAgentSessionScopeId)) || + (row.cloudAgentSessionId !== null && !rootScopes.has(row.cloudAgentSessionId)) + ) + ) { + throw new Error(WORKTREE_ACCESS_DENIED); + } + return rows.map(({ sessionId, cloudAgentSessionId }) => ({ sessionId, cloudAgentSessionId })); +} + +async function lockRoots(db: WorktreeDb, params: CloudAgentWorktreeDeletionParams): Promise { + await db + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + eq(cli_sessions_v2.cloud_agent_worktree_id, params.worktreeId), + isNull(cli_sessions_v2.parent_session_id) + ) + ) + .orderBy(cli_sessions_v2.session_id) + .for('update'); +} + +export async function beginWorktreeDeletion(env: Env, params: CloudAgentWorktreeDeletionParams) { + const db = getWorkerDb(env.HYPERDRIVE.connectionString); + return db.transaction(async tx => { + const owners = await tx + .select({ + userId: cli_sessions_v2.kilo_user_id, + organizationId: cli_sessions_v2.organization_id, + }) + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.cloud_agent_worktree_id, params.worktreeId)); + if ( + owners.some( + row => + row.userId !== params.kiloUserId || row.organizationId !== (params.organizationId ?? null) + ) + ) { + throw new Error(WORKTREE_ACCESS_DENIED); + } + if (owners.length > 0) { + await tx + .insert(cloud_agent_worktrees) + .values({ + worktree_id: params.worktreeId, + kilo_user_id: params.kiloUserId, + organization_id: params.organizationId ?? null, + }) + .onConflictDoNothing({ target: cloud_agent_worktrees.worktree_id }); + } + const row = await lockWorktree(tx, params); + if (row.deletion_completed_at !== null) return deletionState(row); + let manifest: CloudAgentWorktreeDeletionState['manifest']; + if (row.deletion_started_at !== null) { + manifest = cloudAgentWorktreeDeletionManifestSchema.parse(row.deletion_manifest); + } else { + await lockRoots(tx, params); + manifest = cloudAgentWorktreeDeletionManifestSchema.parse({ + version: 1, + sessions: await discoverMembers(tx, params), + }); + } + const runtimeLocations = locations(row); + if (runtimeLocations.length === 0) { + const cloudAgentSessionId = firstWorktreeSessionId(params.worktreeId); + const allocations = await readSessionAllocations(tx, params.kiloUserId, [ + cloudAgentSessionId, + ]); + const source = manifest.sessions.find( + session => session.cloudAgentSessionId === cloudAgentSessionId + ); + const recovered = allocationLocation(allocations, { + cloudAgentSessionId, + sessionId: source?.sessionId ?? null, + organizationId: params.organizationId ?? null, + }); + if (recovered) runtimeLocations.push(recovered); + } + const [updated] = await tx + .update(cloud_agent_worktrees) + .set({ + deletion_started_at: row.deletion_started_at ?? new Date().toISOString(), + deletion_manifest: manifest, + runtime_locations: runtimeLocations, + }) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)) + .returning(); + if (!updated) throw new Error(WORKTREE_DELETING); + return deletionState(updated); + }); +} + +const childSnapshotSchema = z.object({ + id: containedKiloSessionIdSchema, + parentID: containedKiloSessionIdSchema, + directory: z.string(), +}); + +async function inferChildSessionLineage( + env: Env, + db: WorktreeDb, + params: RecordCloudAgentWorktreeCleanupParams +): Promise> { + if (!params.directory) return []; + const [row] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)); + const worktree = await authorize(db, row, params); + if (worktree.deletion_started_at === null) throw new Error('worktree_deletion_not_started'); + const state = deletionState(worktree); + if (state.completed) return []; + const parents = new Map( + state.manifest.sessions.flatMap(session => + session.cloudAgentSessionId ? [[session.sessionId, session.cloudAgentSessionId] as const] : [] + ) + ); + const rootScopes = new Set(parents.values()); + if (state.manifest.sessions.length > 0) { + const owned = await db + .select({ + sessionId: cli_sessions_v2.session_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + organizationId: cli_sessions_v2.organization_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray( + cli_sessions_v2.session_id, + state.manifest.sessions.map(session => session.sessionId) + ) + ) + ); + for (const session of owned) { + if ( + session.organizationId === (params.organizationId ?? null) && + session.cloudAgentSessionScopeId && + rootScopes.has(session.cloudAgentSessionScopeId) && + (session.worktreeId === null || session.worktreeId === params.worktreeId) + ) { + const root = state.manifest.sessions.find( + item => item.cloudAgentSessionId === session.cloudAgentSessionScopeId + ); + if (root?.cloudAgentSessionId) parents.set(session.sessionId, root.cloudAgentSessionId); + } + } + } + let seeded = true; + while (seeded) { + seeded = false; + for (const child of params.childSessions ?? []) { + if ( + !parents.has(child.sessionId) && + parents.get(child.parentSessionId) === child.cloudAgentSessionId + ) { + parents.set(child.sessionId, child.cloudAgentSessionId); + seeded = true; + } + } + } + const candidates = new Map>(); + let cursor: string | undefined; + while (true) { + const page = await db + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + params.organizationId + ? eq(cli_sessions_v2.organization_id, params.organizationId) + : isNull(cli_sessions_v2.organization_id), + isNull(cli_sessions_v2.cloud_agent_session_id), + isNull(cli_sessions_v2.cloud_agent_session_scope_id), + isNull(cli_sessions_v2.cloud_agent_worktree_id), + isNull(cli_sessions_v2.parent_session_id), + cursor ? gt(cli_sessions_v2.session_id, cursor) : undefined + ) + ) + .orderBy(cli_sessions_v2.session_id) + .limit(100); + if (page.length === 0) break; + for (const candidate of page) { + const rawSnapshot = await withDORetry, unknown>( + () => + getSessionIngestDO(env, { + kiloUserId: params.kiloUserId, + sessionId: candidate.sessionId, + }), + stub => stub.readKiloSdkSessionSnapshot(), + 'SessionIngestDO.readKiloSdkSessionSnapshot' + ); + const outcome = kiloSdkSessionSnapshotOutcomeSchema.safeParse(rawSnapshot); + if (!outcome.success) continue; + const snapshot = outcome.data; + if (snapshot.kind === 'retryable_failure') + throw new Error('worktree_child_snapshot_unavailable'); + if (snapshot.kind !== 'value') continue; + const parsed = childSnapshotSchema.safeParse(snapshot.info); + if ( + parsed.success && + parsed.data.id === candidate.sessionId && + parsed.data.directory === params.directory && + parsed.data.parentID !== candidate.sessionId + ) + candidates.set(candidate.sessionId, parsed.data); + } + cursor = page.at(-1)?.sessionId; + if (page.length < 100) break; + } + const recovered: NonNullable = []; + let progress = true; + while (progress) { + progress = false; + for (const [id, candidate] of candidates) { + const cloudAgentSessionId = parents.get(candidate.parentID); + if (!cloudAgentSessionId) continue; + recovered.push({ sessionId: id, parentSessionId: candidate.parentID, cloudAgentSessionId }); + parents.set(id, cloudAgentSessionId); + candidates.delete(id); + progress = true; + } + } + return recovered; +} + +async function recoverChildSessionLineage( + db: WorktreeDb, + params: RecordCloudAgentWorktreeCleanupParams, + sessions: Map, + inferredSessionIds: ReadonlySet +): Promise { + if (!params.childSessions?.length) return; + const roots = new Map( + [...sessions.values()].flatMap(session => + session.cloudAgentSessionId ? [[session.sessionId, session.cloudAgentSessionId] as const] : [] + ) + ); + const rootScopes = new Set(roots.values()); + const pending = new Map(); + for (const child of params.childSessions) { + const previous = pending.get(child.sessionId); + if ( + roots.has(child.sessionId) || + child.sessionId === child.parentSessionId || + !rootScopes.has(child.cloudAgentSessionId) || + (previous && + (previous.parentSessionId !== child.parentSessionId || + previous.cloudAgentSessionId !== child.cloudAgentSessionId)) + ) + throw new Error('worktree_child_lineage_conflict'); + pending.set(child.sessionId, child); + } + const rows = await db + .select({ + sessionId: cli_sessions_v2.session_id, + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + organizationId: cli_sessions_v2.organization_id, + parentSessionId: cli_sessions_v2.parent_session_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray(cli_sessions_v2.session_id, [ + ...new Set([ + ...sessions.keys(), + ...pending.keys(), + ...params.childSessions.map(child => child.parentSessionId), + ]), + ]) + ) + ) + .orderBy(cli_sessions_v2.session_id) + .for('update'); + const owned = new Map(rows.map(row => [row.sessionId, row])); + const parents = new Map(roots); + for (const row of rows) { + if ( + sessions.has(row.sessionId) && + row.organizationId === (params.organizationId ?? null) && + row.cloudAgentSessionScopeId && + rootScopes.has(row.cloudAgentSessionScopeId) && + (row.worktreeId === null || row.worktreeId === params.worktreeId) + ) + parents.set(row.sessionId, row.cloudAgentSessionScopeId); + } + while (pending.size > 0) { + let progress = false; + for (const child of pending.values()) { + const parentScope = parents.get(child.parentSessionId); + if (!parentScope) continue; + const row = owned.get(child.sessionId); + if ( + parentScope !== child.cloudAgentSessionId || + (row && + (row.cloudAgentSessionId !== null || + (inferredSessionIds.has(child.sessionId) && + row.organizationId !== (params.organizationId ?? null)) || + (row.organizationId !== null && + row.organizationId !== (params.organizationId ?? null)) || + (row.cloudAgentSessionScopeId != null && + row.cloudAgentSessionScopeId !== parentScope) || + (row.worktreeId !== null && row.worktreeId !== params.worktreeId) || + (row.parentSessionId !== null && + row.parentSessionId !== child.parentSessionId && + row.cloudAgentSessionScopeId !== parentScope))) + ) + throw new Error('worktree_child_lineage_conflict'); + if (row) { + await db + .update(cli_sessions_v2) + .set({ + parent_session_id: row.parentSessionId ?? child.parentSessionId, + cloud_agent_session_scope_id: parentScope, + cloud_agent_worktree_id: params.worktreeId, + organization_id: params.organizationId ?? null, + }) + .where( + and( + eq(cli_sessions_v2.session_id, child.sessionId), + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId) + ) + ); + } + sessions.set(child.sessionId, { sessionId: child.sessionId, cloudAgentSessionId: null }); + parents.set(child.sessionId, parentScope); + pending.delete(child.sessionId); + progress = true; + } + if (!progress) throw new Error('worktree_child_lineage_conflict'); + } +} + +export async function recordWorktreeCleanup( + env: Env, + params: RecordCloudAgentWorktreeCleanupParams +) { + const db = getWorkerDb(env.HYPERDRIVE.connectionString); + const inferred = await inferChildSessionLineage(env, db, params); + const childSessions = [...(params.childSessions ?? []), ...inferred]; + return db.transaction(async tx => { + const row = await lockWorktree(tx, params); + if (row.deletion_started_at === null) throw new Error('worktree_deletion_not_started'); + const state = deletionState(row); + if (state.completed) return state; + await lockRoots(tx, params); + const sessions = new Map(state.manifest.sessions.map(session => [session.sessionId, session])); + await recoverChildSessionLineage( + tx, + { ...params, childSessions }, + sessions, + new Set(inferred.map(child => child.sessionId)) + ); + const discovered = await discoverMembers(tx, params); + if (discovered.length > 0) { + const ids = discovered.map(session => session.sessionId); + const rootScopes = new Set( + [...sessions.values()].flatMap(session => + session.cloudAgentSessionId ? [session.cloudAgentSessionId] : [] + ) + ); + await tx + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: params.worktreeId }) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray(cli_sessions_v2.session_id, ids), + isNull(cli_sessions_v2.cloud_agent_worktree_id), + params.organizationId + ? eq(cli_sessions_v2.organization_id, params.organizationId) + : isNull(cli_sessions_v2.organization_id), + or( + isNull(cli_sessions_v2.cloud_agent_session_scope_id), + inArray(cli_sessions_v2.cloud_agent_session_scope_id, [...rootScopes]) + ) + ) + ); + const fenced = await tx + .select({ + organizationId: cli_sessions_v2.organization_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + cloudAgentSessionScopeId: cli_sessions_v2.cloud_agent_session_scope_id, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray(cli_sessions_v2.session_id, ids) + ) + ) + .orderBy(cli_sessions_v2.session_id) + .for('update'); + if ( + fenced.some( + session => + session.organizationId !== (params.organizationId ?? null) || + session.worktreeId !== params.worktreeId || + (session.cloudAgentSessionScopeId != null && + !rootScopes.has(session.cloudAgentSessionScopeId)) + ) + ) + throw new Error('worktree_child_lineage_conflict'); + for (const session of discovered) sessions.set(session.sessionId, session); + } + const extraIds = [...new Set(params.sessionIds ?? [])].filter(id => !sessions.has(id)); + if (extraIds.length > 0) { + const existing = await tx + .select({ sessionId: cli_sessions_v2.session_id }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray(cli_sessions_v2.session_id, extraIds) + ) + ); + if (existing.length > 0) throw new Error('worktree_cleanup_session_conflict'); + for (const sessionId of extraIds) + sessions.set(sessionId, { sessionId, cloudAgentSessionId: null }); + } + const runtimeLocations = state.runtimeLocations; + for (const location of params.runtimeLocations ?? []) { + if ( + !runtimeLocations.some( + item => item.sandboxId === location.sandboxId && item.provider === location.provider + ) + ) { + runtimeLocations.push(location); + } + } + const manifest = { + version: 1, + sessions: [...sessions.values()], + } satisfies typeof state.manifest; + await tx + .update(cloud_agent_worktrees) + .set({ runtime_locations: runtimeLocations, deletion_manifest: manifest }) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)); + return { completed: false, manifest, runtimeLocations }; + }); +} + +export async function canDestroyWorktreeSandbox( + env: Env, + params: CanDestroyCloudAgentWorktreeSandboxParams +): Promise { + const db = getWorkerDb(env.HYPERDRIVE.connectionString); + const [row] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)); + const worktree = await authorize(db, row, params); + if (worktree.deletion_started_at === null) throw new Error('worktree_deletion_not_started'); + const targetIds = new Set( + deletionState(worktree).manifest.sessions.map(session => session.sessionId) + ); + const worktrees = await db + .select() + .from(cloud_agent_worktrees) + .where( + and( + eq(cloud_agent_worktrees.kilo_user_id, params.kiloUserId), + isNull(cloud_agent_worktrees.deletion_completed_at) + ) + ); + const released = new Set( + worktrees + .filter( + item => + item.deletion_started_at !== null && + params.releasedWorktreeIds?.includes(cloudAgentWorktreeIdSchema.parse(item.worktree_id)) + ) + .map(item => item.worktree_id) + ); + const roots = await db + .select({ + sessionId: cli_sessions_v2.session_id, + cloudAgentSessionId: cli_sessions_v2.cloud_agent_session_id, + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + organizationId: cli_sessions_v2.organization_id, + }) + .from(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + isNull(cli_sessions_v2.parent_session_id), + isNotNull(cli_sessions_v2.cloud_agent_session_id) + ) + ); + const owners = new Map< + string, + { owner: UnresolvedCloudAgentSandboxOwner; locations: CloudAgentWorktreeLocation[] } + >(); + for (const item of worktrees) { + if (item.worktree_id === params.worktreeId || released.has(item.worktree_id)) continue; + owners.set(item.worktree_id, { + owner: { + worktreeId: cloudAgentWorktreeIdSchema.parse(item.worktree_id), + organizationId: item.organization_id, + sessions: [], + }, + locations: locations(item), + }); + } + for (const root of roots) { + if (targetIds.has(root.sessionId) || (root.worktreeId && released.has(root.worktreeId))) + continue; + if (!root.cloudAgentSessionId) throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + const key = root.worktreeId ?? root.cloudAgentSessionId; + const existing = owners.get(key); + if (existing && existing.owner.organizationId !== root.organizationId) + throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + const candidate = existing ?? { + owner: { + worktreeId: root.worktreeId ? cloudAgentWorktreeIdSchema.parse(root.worktreeId) : null, + organizationId: root.organizationId, + sessions: [], + } satisfies UnresolvedCloudAgentSandboxOwner, + locations: [], + }; + candidate.owner.sessions.push({ + sessionId: root.sessionId, + cloudAgentSessionId: root.cloudAgentSessionId, + }); + owners.set(key, candidate); + } + const matches = (location: CloudAgentWorktreeLocation) => + location.sandboxId === params.location.sandboxId && + location.provider === params.location.provider; + if ([...owners.values()].some(candidate => candidate.locations.some(matches))) + return { kind: 'shared' }; + const unknown = [...owners.values()] + .filter(candidate => candidate.locations.length === 0) + .map(candidate => candidate.owner); + const allocations = await readSessionAllocations( + db, + params.kiloUserId, + unknown.flatMap(owner => + owner.worktreeId + ? [firstWorktreeSessionId(owner.worktreeId)] + : owner.sessions.map(session => session.cloudAgentSessionId) + ) + ); + const unresolved: UnresolvedCloudAgentSandboxOwner[] = []; + for (const owner of unknown) { + const sourceId = owner.worktreeId + ? firstWorktreeSessionId(owner.worktreeId) + : owner.sessions[0]?.cloudAgentSessionId; + if (!sourceId) throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); + const source = owner.sessions.find(session => session.cloudAgentSessionId === sourceId); + const location = allocationLocation(allocations, { + cloudAgentSessionId: sourceId, + sessionId: source?.sessionId ?? null, + organizationId: owner.organizationId, + }); + if (location && owner.worktreeId !== null) { + if (matches(location)) return { kind: 'shared' }; + continue; + } + if (location) owner.allocationLocation = location; + if (owner.sessions.length === 0) + owner.sessions.push({ sessionId: null, cloudAgentSessionId: sourceId }); + unresolved.push(owner); + } + return unresolved.length > 0 ? { kind: 'unresolved', owners: unresolved } : { kind: 'exclusive' }; +} + +export async function completeWorktreeDeletion( + env: Env, + params: CloudAgentWorktreeDeletionParams +): Promise<{ success: true; deletedSessionIds: string[] }> { + const db = getWorkerDb(env.HYPERDRIVE.connectionString); + const [row] = await db + .select() + .from(cloud_agent_worktrees) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)); + const worktree = await authorize(db, row, params); + if (worktree.deletion_started_at === null) throw new Error('worktree_deletion_not_started'); + const state = deletionState(worktree); + const sessionIds = state.manifest.sessions.map(session => session.sessionId); + if (state.completed) return { success: true, deletedSessionIds: sessionIds }; + for (const sessionId of sessionIds) { + await withDORetry( + () => getSessionIngestDO(env, { kiloUserId: params.kiloUserId, sessionId }), + stub => stub.clearForWorktree(params.kiloUserId, sessionId), + 'SessionIngestDO.clearForWorktree' + ); + await withDORetry( + () => getSessionAccessCacheDO(env, { kiloUserId: params.kiloUserId }), + stub => stub.deleteSession(sessionId), + 'SessionAccessCacheDO.deleteSession' + ); + await withDORetry( + () => getUserConnectionDO(env, { kiloUserId: params.kiloUserId }), + stub => stub.clearSession(sessionId), + 'UserConnectionDO.clearSession' + ); + } + return db.transaction(async tx => { + const current = await lockWorktree(tx, params); + if (current.deletion_completed_at !== null) + return { success: true, deletedSessionIds: current.deleted_session_ids }; + const currentIds = deletionState(current).manifest.sessions.map(session => session.sessionId); + const cleaned = new Set(sessionIds); + if (currentIds.some(id => !cleaned.has(id))) + throw new Error('worktree_cleanup_manifest_changed'); + await lockRoots(tx, params); + if ((await discoverMembers(tx, params)).some(session => !cleaned.has(session.sessionId))) { + throw new Error('worktree_cleanup_manifest_changed'); + } + if (sessionIds.length > 0) { + await tx + .delete(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray(cli_sessions_v2.session_id, sessionIds) + ) + ); + } + await tx + .update(cloud_agent_worktrees) + .set({ + name: null, + runtime_locations: [], + deletion_manifest: null, + deletion_completed_at: new Date().toISOString(), + deleted_session_ids: sessionIds, + }) + .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)); + return { success: true, deletedSessionIds: sessionIds }; + }); +} diff --git a/services/session-ingest/src/session-events.test.ts b/services/session-ingest/src/session-events.test.ts index 45e86a3ee5..ccc8157ee1 100644 --- a/services/session-ingest/src/session-events.test.ts +++ b/services/session-ingest/src/session-events.test.ts @@ -7,23 +7,28 @@ vi.mock('./dos/UserConnectionDO', () => ({ import { mapSessionEventRow } from './session-events'; describe('mapSessionEventRow', () => { - it('normalizes Postgres timestamp text before emitting session events', () => { - const session = mapSessionEventRow({ - session_id: 'ses_12345678901234567890123456', - created_at: '2026-04-29 01:16:12.945+00', - updated_at: '2026-04-29 02:17:13.123+00', - title: 'Fix issue', - created_on_platform: 'cloud-agent', - organization_id: null, - git_url: null, - git_branch: null, - parent_session_id: null, - status: 'busy', - status_updated_at: '2026-04-29 03:18:14.456+00', - }); + it.each(['worktree_11111111-1111-4111-8111-111111111111', null])( + 'normalizes Postgres timestamp text while preserving worktree ID %s', + cloudAgentWorktreeId => { + const session = mapSessionEventRow({ + session_id: 'ses_12345678901234567890123456', + created_at: '2026-04-29 01:16:12.945+00', + updated_at: '2026-04-29 02:17:13.123+00', + title: 'Fix issue', + created_on_platform: 'cloud-agent', + organization_id: null, + git_url: null, + git_branch: null, + parent_session_id: null, + cloud_agent_worktree_id: cloudAgentWorktreeId, + status: 'busy', + status_updated_at: '2026-04-29 03:18:14.456+00', + }); - expect(session.createdAt).toBe('2026-04-29T01:16:12.945Z'); - expect(session.updatedAt).toBe('2026-04-29T02:17:13.123Z'); - expect(session.statusUpdatedAt).toBe('2026-04-29T03:18:14.456Z'); - }); + expect(session.createdAt).toBe('2026-04-29T01:16:12.945Z'); + expect(session.updatedAt).toBe('2026-04-29T02:17:13.123Z'); + expect(session.statusUpdatedAt).toBe('2026-04-29T03:18:14.456Z'); + expect(session.worktreeId).toBe(cloudAgentWorktreeId); + } + ); }); diff --git a/services/session-ingest/src/session-events.ts b/services/session-ingest/src/session-events.ts index 2fdb33d06c..d465e38a7a 100644 --- a/services/session-ingest/src/session-events.ts +++ b/services/session-ingest/src/session-events.ts @@ -16,6 +16,7 @@ export type SessionEventDbRow = { git_url: string | null; git_branch: string | null; parent_session_id: string | null; + cloud_agent_worktree_id: string | null; status: string | null; status_updated_at: string | Date | null; }; @@ -40,6 +41,7 @@ export function mapSessionEventRow(row: SessionEventDbRow): SessionEventV2Row { gitUrl: row.git_url, gitBranch: row.git_branch, parentSessionId: row.parent_session_id, + worktreeId: row.cloud_agent_worktree_id ?? null, status: SessionStatusSchema.nullable().parse(row.status), statusUpdatedAt: toNullableIsoString(row.status_updated_at), }; diff --git a/services/session-ingest/src/session-ingest-rpc.test.ts b/services/session-ingest/src/session-ingest-rpc.test.ts index 0c8fca4d38..a4fc9e04b7 100644 --- a/services/session-ingest/src/session-ingest-rpc.test.ts +++ b/services/session-ingest/src/session-ingest-rpc.test.ts @@ -50,12 +50,17 @@ vi.mock('./services/user-session-admission', () => ({ })); import { getWorkerDb } from '@kilocode/db/client'; -import { cli_sessions_v2, organization_memberships } from '@kilocode/db/schema'; +import { + cli_sessions_v2, + cloud_agent_worktrees, + organization_memberships, +} from '@kilocode/db/schema'; import { decodeKiloSdkMessagesCursor, DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, encodeKiloSdkMessagesCursor, MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, + cloudAgentWorktreeIdSchema, messageIdSchema, partIdSchema, validateKiloSdkMessagesCursor, @@ -142,11 +147,38 @@ function makeRootWriteDb(params: { onConflictDoNothing: vi.fn(() => insert), returning: vi.fn(async () => (params.created ? [params.created] : [])), }; + let worktree: Record | undefined; + let selectedTable: unknown; const select = { - from: vi.fn(() => select), + from: vi.fn((table: unknown) => { + selectedTable = table; + return select; + }), + innerJoin: vi.fn(() => select), where: vi.fn(() => select), limit: vi.fn(() => select), - for: vi.fn(async () => (params.existing ? [params.existing] : [])), + for: vi.fn(async () => + selectedTable === cloud_agent_worktrees + ? worktree + ? [worktree] + : [] + : params.existing + ? [params.existing] + : [] + ), + then: (resolve: (rows: unknown[]) => unknown) => + resolve(selectedTable === organization_memberships ? [{ id: 'membership' }] : []), + }; + const worktreeInsert = { + values: (values: Record) => { + worktree = { + ...values, + deletion_started_at: null, + deletion_completed_at: null, + runtime_locations: [], + }; + return { onConflictDoNothing: async () => undefined }; + }, }; const updateSet = vi.fn((_values: unknown) => update); const update = { @@ -155,7 +187,7 @@ function makeRootWriteDb(params: { returning: vi.fn(async () => (params.existing ? [params.existing] : [])), }; const tx = { - insert: vi.fn(() => insert), + insert: vi.fn((table: unknown) => (table === cloud_agent_worktrees ? worktreeInsert : insert)), select: vi.fn(() => select), update: vi.fn(() => update), }; @@ -166,6 +198,23 @@ function makeRootWriteDb(params: { }; } +describe('cloudAgentWorktreeIdSchema', () => { + it('accepts a canonical worktree UUID', () => { + expect(cloudAgentWorktreeIdSchema.parse('worktree_11111111-1111-4111-8111-111111111111')).toBe( + 'worktree_11111111-1111-4111-8111-111111111111' + ); + }); + + it.each([ + 'workspace_11111111-1111-4111-8111-111111111111', + 'worktree_11111111-1111-4111-8111-111111111111/../other', + 'worktree_11111111-1111-1111-1111-111111111111', + 'worktree_11111111-1111-4111-8111-11111111111', + ])('rejects malformed worktree ID %s', value => { + expect(cloudAgentWorktreeIdSchema.safeParse(value).success).toBe(false); + }); +}); + describe('createSessionForCloudAgent', () => { const params = { sessionId: 'ses_12345678901234567890123456', @@ -198,10 +247,46 @@ describe('createSessionForCloudAgent', () => { expect.objectContaining({ cloud_agent_session_id: params.cloudAgentSessionId, cloud_agent_session_scope_id: params.cloudAgentSessionId, + cloud_agent_worktree_id: null, }) ); }); + it('persists the worktree ID when creating a grouped root', async () => { + const cloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const row = { + session_id: params.sessionId, + kilo_user_id: params.kiloUserId, + cloud_agent_session_id: params.cloudAgentSessionId, + cloud_agent_session_scope_id: params.cloudAgentSessionId, + cloud_agent_worktree_id: cloudAgentWorktreeId, + organization_id: params.organizationId, + parent_session_id: null, + updated_at: '2026-01-01T00:00:00.000Z', + }; + const fake = makeRootWriteDb({ created: row }); + const rpc = makeRpc(fake.db as never); + + await rpc.createSessionForCloudAgent({ ...params, cloudAgentWorktreeId }); + + expect(fake.values).toHaveBeenCalledWith( + expect.objectContaining({ cloud_agent_worktree_id: cloudAgentWorktreeId }) + ); + }); + + it('rejects a malformed worktree ID before any session write', async () => { + const fake = makeRootWriteDb({ created: { session_id: params.sessionId } }); + const rpc = makeRpc(fake.db as never); + + await expect( + rpc.createSessionForCloudAgent({ + ...params, + cloudAgentWorktreeId: 'worktree_11111111-1111-4111-8111-111111111111/../other', + }) + ).rejects.toThrow(); + expect(fake.values).not.toHaveBeenCalled(); + }); + it('refuses to claim an existing non-Cloud-Agent session as a root', async () => { const fake = makeRootWriteDb({ existing: { @@ -287,6 +372,70 @@ describe('createSessionForCloudAgent', () => { expect(fake.values).toHaveBeenCalledTimes(1); }); + it('accepts an idempotent retry with the same immutable worktree', async () => { + const cloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const existing = { + session_id: params.sessionId, + kilo_user_id: params.kiloUserId, + cloud_agent_session_id: params.cloudAgentSessionId, + cloud_agent_session_scope_id: params.cloudAgentSessionId, + cloud_agent_worktree_id: cloudAgentWorktreeId, + organization_id: params.organizationId, + parent_session_id: null, + }; + const fake = makeRootWriteDb({ existing }); + const rpc = makeRpc(fake.db as never); + + await expect( + rpc.createSessionForCloudAgent({ ...params, cloudAgentWorktreeId }) + ).resolves.toEqual({ + status: 'ready', + clone: { sessionId: params.sessionId, copiedItemCount: 0 }, + }); + expect(fake.updateSet).not.toHaveBeenCalledWith( + expect.objectContaining({ cloud_agent_worktree_id: expect.anything() }) + ); + }); + + it.each([ + { + stored: null, + requested: 'worktree_11111111-1111-4111-8111-111111111111', + }, + { + stored: 'worktree_11111111-1111-4111-8111-111111111111', + requested: undefined, + }, + { + stored: 'worktree_11111111-1111-4111-8111-111111111111', + requested: 'worktree_22222222-2222-4222-8222-222222222222', + }, + ] as const)( + 'refuses to change an existing root worktree from $stored to $requested', + async values => { + const fake = makeRootWriteDb({ + existing: { + session_id: params.sessionId, + kilo_user_id: params.kiloUserId, + cloud_agent_session_id: params.cloudAgentSessionId, + cloud_agent_session_scope_id: params.cloudAgentSessionId, + cloud_agent_worktree_id: values.stored, + organization_id: params.organizationId, + parent_session_id: null, + }, + }); + const rpc = makeRpc(fake.db as never); + + await expect( + rpc.createSessionForCloudAgent({ + ...params, + ...(values.requested === undefined ? {} : { cloudAgentWorktreeId: values.requested }), + }) + ).rejects.toThrow('Cloud Agent root session identity conflict'); + expect(fake.updateSet).not.toHaveBeenCalled(); + } + ); + it('accepts an idempotent retry for a personal root', async () => { const { organizationId: _organizationId, ...personalParams } = params; const existing = { @@ -428,6 +577,7 @@ describe('createSessionForCloudAgent clone path', () => { const destinationSessionId = 'ses_abcdefghijklmnopqrstuvwxyz'; const kiloUserId = 'usr_test'; const organizationId = '11111111-1111-4111-8111-111111111111'; + const cloudAgentWorktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; type CloneItem = { itemId: string; @@ -1070,6 +1220,89 @@ describe('createSessionForCloudAgent clone path', () => { expect(values).not.toHaveBeenCalled(); }); + it('rejects a destination claimed by another worktree before cloning', async () => { + const sourceStub = makePagedSourceStub([], 100); + const dest = makeDestinationStub(); + const r2 = { get: vi.fn(async () => null), put: vi.fn(async () => {}) }; + const { db, values } = makeCloneDb({ + sourceRows: [{ sessionId: sourceSessionId, organizationId }], + destinationRows: [ + { + ...createdRow, + cloud_agent_worktree_id: 'worktree_22222222-2222-4222-8222-222222222222', + }, + ], + }); + const rpc = makeCloneRpc({ db, sourceStub, destStub: dest.stub, r2 }); + + await expect( + rpc.createSessionForCloudAgent(cloneParams({ cloudAgentWorktreeId })) + ).resolves.toEqual({ status: 'rejected', code: 'destination_conflict' }); + expect(dest.stub.inspectCloneStage).not.toHaveBeenCalled(); + expect(values).not.toHaveBeenCalled(); + }); + + it('reconciles an ambiguously committed clone only when its worktree matches', async () => { + const sourceStub = makePagedSourceStub([sourceSessionItem()], 100); + const dest = makeDestinationStub(); + const r2 = { get: vi.fn(async () => null), put: vi.fn(async () => {}) }; + const { db, selectResult } = makeCloneDb({ + sourceRows: [{ sessionId: sourceSessionId, organizationId }], + destinationRows: [{ ...createdRow, cloud_agent_worktree_id: cloudAgentWorktreeId }], + insertError: new Error('postgres outcome unknown'), + }); + selectResult.mockResolvedValueOnce([]); + const rpc = makeCloneRpc({ db, sourceStub, destStub: dest.stub, r2 }); + + await expect( + rpc.createSessionForCloudAgent(cloneParams({ cloudAgentWorktreeId })) + ).resolves.toEqual({ + status: 'ready', + clone: { sessionId: destinationSessionId, copiedItemCount: 1 }, + }); + expect(dest.stub.resetCloneStage).not.toHaveBeenCalled(); + }); + + it.each([ + { + identity: 'worktree', + overrides: { cloud_agent_worktree_id: 'worktree_22222222-2222-4222-8222-222222222222' }, + }, + { + identity: 'organization', + overrides: { + cloud_agent_worktree_id: cloudAgentWorktreeId, + organization_id: '22222222-2222-4222-8222-222222222222', + }, + }, + { + identity: 'session scope', + overrides: { + cloud_agent_worktree_id: cloudAgentWorktreeId, + cloud_agent_session_scope_id: 'another-session-scope', + }, + }, + ])( + 'rejects an ambiguously committed clone with another $identity without clearing it', + async ({ overrides }) => { + const sourceStub = makePagedSourceStub([sourceSessionItem()], 100); + const dest = makeDestinationStub(); + const r2 = { get: vi.fn(async () => null), put: vi.fn(async () => {}) }; + const { db, selectResult } = makeCloneDb({ + sourceRows: [{ sessionId: sourceSessionId, organizationId }], + destinationRows: [{ ...createdRow, ...overrides }], + insertError: new Error('postgres outcome unknown'), + }); + selectResult.mockResolvedValueOnce([]); + const rpc = makeCloneRpc({ db, sourceStub, destStub: dest.stub, r2 }); + + await expect( + rpc.createSessionForCloudAgent(cloneParams({ cloudAgentWorktreeId })) + ).resolves.toEqual({ status: 'rejected', code: 'destination_conflict' }); + expect(dest.stub.resetCloneStage).not.toHaveBeenCalled(); + } + ); + it('resets the destination stage and rethrows when the insert fails without committing', async () => { const sourceStub = makePagedSourceStub([sourceSessionItem()], 100); const dest = makeDestinationStub(); diff --git a/services/session-ingest/src/session-ingest-rpc.ts b/services/session-ingest/src/session-ingest-rpc.ts index 4d6a74a638..4155b739b9 100644 --- a/services/session-ingest/src/session-ingest-rpc.ts +++ b/services/session-ingest/src/session-ingest-rpc.ts @@ -4,6 +4,12 @@ import { getWorkerDb } from '@kilocode/db/client'; import { cli_sessions_v2, organization_memberships } from '@kilocode/db/schema'; import { createSessionForCloudAgentSchema, + cloudAgentWorktreeDeletionSchema, + recordCloudAgentWorktreeCleanupSchema, + canDestroyCloudAgentWorktreeSandboxSchema, + type CloudAgentWorktreeDeletionParams, + type RecordCloudAgentWorktreeCleanupParams, + type CanDestroyCloudAgentWorktreeSandboxParams, deleteSessionForCloudAgentSchema, getCloudAgentRootSessionMessagesSchema, getSessionMessagesSchema, @@ -40,6 +46,16 @@ import { USER_SESSION_ADMISSION_ERROR, } from './services/user-session-admission'; +import { + beginWorktreeDeletion, + recordWorktreeCleanup, + completeWorktreeDeletion, + canDestroyWorktreeSandbox, + registerCloudAgentWorktree, + isWorktreeSessionDeleting, + WORKTREE_DELETING, +} from './services/worktree-deletion'; + const MAX_CLOUD_AGENT_ROOT_SESSION_TITLE_CHARACTERS = 512; const CLOUD_AGENT_ROOT_SESSION_IDENTITY_CONFLICT_ERROR = 'Cloud Agent root session identity conflict'; @@ -76,6 +92,7 @@ function destinationIdentityConflicts( | 'parent_session_id' | 'cloud_agent_session_id' | 'cloud_agent_session_scope_id' + | 'cloud_agent_worktree_id' | 'organization_id' >, parsed: CreateSessionForCloudAgentParams @@ -85,11 +102,31 @@ function destinationIdentityConflicts( existing.cloud_agent_session_id !== parsed.cloudAgentSessionId || (existing.cloud_agent_session_scope_id !== null && existing.cloud_agent_session_scope_id !== parsed.cloudAgentSessionId) || - existing.organization_id !== (parsed.organizationId ?? null) + existing.organization_id !== (parsed.organizationId ?? null) || + (existing.cloud_agent_worktree_id ?? null) !== (parsed.cloudAgentWorktreeId ?? null) ); } export class SessionIngestRPC extends WorkerEntrypoint implements SessionIngestRpcMethods { + async beginCloudAgentWorktreeDeletion(params: CloudAgentWorktreeDeletionParams) { + return beginWorktreeDeletion(this.env, cloudAgentWorktreeDeletionSchema.parse(params)); + } + + async recordCloudAgentWorktreeCleanup(params: RecordCloudAgentWorktreeCleanupParams) { + return recordWorktreeCleanup(this.env, recordCloudAgentWorktreeCleanupSchema.parse(params)); + } + + async completeCloudAgentWorktreeDeletion(params: CloudAgentWorktreeDeletionParams) { + return completeWorktreeDeletion(this.env, cloudAgentWorktreeDeletionSchema.parse(params)); + } + + async canDestroyCloudAgentWorktreeSandbox(params: CanDestroyCloudAgentWorktreeSandboxParams) { + return canDestroyWorktreeSandbox( + this.env, + canDestroyCloudAgentWorktreeSandboxSchema.parse(params) + ); + } + // Delegate HTTP requests to the Hono app so callers using the service // binding can `.fetch()` against this entrypoint (not just call RPC methods). fetch(request: Request): Response | Promise { @@ -167,9 +204,13 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn // Never clear that other session's Durable Object data. return { status: 'rejected', code: 'destination_conflict' }; } - if (await this.findMatchingCommittedRootSession(parsed)) { + const committedRootSession = await this.reconcileCommittedRootSession(parsed); + if (committedRootSession === 'matching') { return { status: 'ready', clone: { sessionId: parsed.sessionId, copiedItemCount } }; } + if (committedRootSession === 'conflict') { + return { status: 'rejected', code: 'destination_conflict' }; + } // The insert did not commit: reset the unpublished destination clone and // its R2 objects, then rethrow so the caller treats it as retryable. await withDORetry( @@ -261,6 +302,7 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn if (!(await canCreateCliSessionForUser(tx, parsed.kiloUserId))) { throw new Error(USER_SESSION_ADMISSION_ERROR); } + await registerCloudAgentWorktree(tx, parsed); const [created] = await tx .insert(cli_sessions_v2) @@ -269,6 +311,7 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn kilo_user_id: parsed.kiloUserId, cloud_agent_session_id: parsed.cloudAgentSessionId, cloud_agent_session_scope_id: parsed.cloudAgentSessionId, + cloud_agent_worktree_id: parsed.cloudAgentWorktreeId ?? null, organization_id: parsed.organizationId ?? null, created_on_platform: parsed.createdOnPlatform, ...(parsed.title !== undefined ? { title: parsed.title } : {}), @@ -328,22 +371,29 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn * insert (e.g. a transient error after the write). Used to keep same-source * retries idempotent across clone finalization and row insertion. */ - private async findMatchingCommittedRootSession( + private async reconcileCommittedRootSession( parsed: CreateSessionForCloudAgentParams - ): Promise { + ): Promise<'matching' | 'conflict' | 'missing'> { const db = getWorkerDb(this.env.HYPERDRIVE.connectionString); const rows = await db - .select({ sessionId: cli_sessions_v2.session_id }) + .select({ + parent_session_id: cli_sessions_v2.parent_session_id, + cloud_agent_session_id: cli_sessions_v2.cloud_agent_session_id, + cloud_agent_session_scope_id: cli_sessions_v2.cloud_agent_session_scope_id, + cloud_agent_worktree_id: cli_sessions_v2.cloud_agent_worktree_id, + organization_id: cli_sessions_v2.organization_id, + }) .from(cli_sessions_v2) .where( and( eq(cli_sessions_v2.session_id, parsed.sessionId), - eq(cli_sessions_v2.kilo_user_id, parsed.kiloUserId), - eq(cli_sessions_v2.cloud_agent_session_id, parsed.cloudAgentSessionId) + eq(cli_sessions_v2.kilo_user_id, parsed.kiloUserId) ) ) .limit(1); - return rows.length > 0; + const existing = rows[0]; + if (!existing) return 'missing'; + return destinationIdentityConflicts(existing, parsed) ? 'conflict' : 'matching'; } async resolveCloudAgentRootSessionForKiloSession( @@ -569,6 +619,15 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn */ async deleteSessionForCloudAgent(params: DeleteSessionForCloudAgentParams): Promise { const parsed = deleteSessionForCloudAgentSchema.parse(params); + if ( + await isWorktreeSessionDeleting( + getWorkerDb(this.env.HYPERDRIVE.connectionString), + parsed.kiloUserId, + parsed.sessionId + ) + ) { + throw new Error(WORKTREE_DELETING); + } if (parsed.cloneSourceSessionId !== undefined) { const cloneSourceSessionId = parsed.cloneSourceSessionId; diff --git a/services/session-ingest/src/types/user-connection-protocol.test.ts b/services/session-ingest/src/types/user-connection-protocol.test.ts index c08c994b5d..5255138811 100644 --- a/services/session-ingest/src/types/user-connection-protocol.test.ts +++ b/services/session-ingest/src/types/user-connection-protocol.test.ts @@ -655,6 +655,53 @@ describe('SessionEventPayloadSchema', () => { } }); + it.each(['session.created', 'session.updated'] as const)( + 'preserves a worktree ID in %s events', + type => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const result = SessionEventPayloadSchema.parse({ + type, + data: { + source: 'v2', + session: { ...session, worktreeId }, + changedAt: session.updatedAt, + }, + }); + + expect(result.data).toHaveProperty('session.worktreeId', worktreeId); + } + ); + + it('preserves a worktree ID in full-row status events', () => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const result = SessionEventPayloadSchema.parse({ + type: 'session.status.updated', + data: { + source: 'v2', + session: { ...session, worktreeId }, + previousStatus: null, + status: 'idle', + statusUpdatedAt: null, + changedAt: session.updatedAt, + }, + }); + + expect(result.data).toHaveProperty('session.worktreeId', worktreeId); + }); + + it.each([null, undefined])('accepts legacy sessions with worktree ID %s', worktreeId => { + const result = SessionEventPayloadSchema.parse({ + type: 'session.created', + data: { + source: 'v2', + session: { ...session, ...(worktreeId === undefined ? {} : { worktreeId }) }, + changedAt: session.updatedAt, + }, + }); + + expect('session' in result.data ? result.data.session.worktreeId : undefined).toBe(worktreeId); + }); + it('parses lightweight status update payloads during rollout compatibility', () => { const result = SessionEventPayloadSchema.safeParse({ type: 'session.status.updated', diff --git a/services/session-ingest/src/types/user-connection-protocol.ts b/services/session-ingest/src/types/user-connection-protocol.ts index 8df21625d5..089abf51b5 100644 --- a/services/session-ingest/src/types/user-connection-protocol.ts +++ b/services/session-ingest/src/types/user-connection-protocol.ts @@ -164,6 +164,7 @@ export const SessionEventV2RowSchema = z.object({ gitUrl: z.string().nullable(), gitBranch: z.string().nullable(), parentSessionId: z.string().nullable(), + worktreeId: z.string().nullable().optional(), status: SessionStatusSchema.nullable(), statusUpdatedAt: z.string().nullable(), }); diff --git a/services/session-ingest/test/integration/worktree-deletion.test.ts b/services/session-ingest/test/integration/worktree-deletion.test.ts new file mode 100644 index 0000000000..16dff4d1b6 --- /dev/null +++ b/services/session-ingest/test/integration/worktree-deletion.test.ts @@ -0,0 +1,330 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { drizzle } from 'drizzle-orm/durable-sqlite'; +import { describe, expect, it } from 'vitest'; +import { ingestItems, ingestMeta } from '../../src/db/sqlite-schema'; + +function identity() { + const suffix = crypto.randomUUID().replaceAll('-', ''); + const kiloUserId = `oauth/github:${suffix}`; + const sessionId = `ses_${suffix.slice(0, 26)}`; + return { + kiloUserId, + sessionId, + stub: env.SESSION_INGEST_DO.getByName(`${kiloUserId}/${sessionId}`), + }; +} + +describe('worktree session cleanup in Workers', () => { + it('reads and deletes a never-run child snapshot without waking its expired runtime', async () => { + const { kiloUserId, sessionId, stub } = identity(); + const info = { + id: sessionId, + parentID: 'ses_00000000000000000000000001', + directory: '/workspace/owner/worktrees/worktree_11111111-1111-4111-8111-111111111111', + title: 'Never-run engine child', + slug: 'never-run', + projectID: 'test-project', + version: '7.4.20', + time: { created: 1, updated: 1 }, + }; + const key = `items/${kiloUserId}/${sessionId}/session/1`; + await env.SESSION_INGEST_R2.put(key, JSON.stringify(info)); + await stub.ingest([{ type: 'session', data: info }], kiloUserId, sessionId, 2, 1, { + session: key, + }); + await expect(stub.readKiloSdkSessionSnapshot()).resolves.toMatchObject({ kind: 'value', info }); + await stub.clearForWorktree(kiloUserId, sessionId); + await expect(stub.readKiloSdkSessionSnapshot()).resolves.toEqual({ kind: 'pending' }); + expect(await env.SESSION_INGEST_R2.head(key)).toBeNull(); + await expect( + stub.ingest([{ type: 'session', data: info }], kiloUserId, sessionId, 2) + ).resolves.toMatchObject({ accepted: false, reason: 'deleted' }); + await stub.clearForWorktree(kiloUserId, sessionId); + }); + + it('fences late ingest before awaiting R2 and keeps failed inventory for an idempotent retry', async () => { + const { kiloUserId, sessionId, stub } = identity(); + const key = `items/${kiloUserId}/${sessionId}/session/1`; + await env.SESSION_INGEST_R2.put(key, JSON.stringify({ title: 'private transcript' })); + await stub.ingest( + [{ type: 'session', data: { title: 'private transcript' } }], + kiloUserId, + sessionId, + 1, + 1, + { session: key } + ); + await runInDurableObject(stub, async (instance, state) => { + const original = instance['env'].SESSION_INGEST_R2; + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + let first = true; + Object.assign(instance['env'], { + SESSION_INGEST_R2: { + list: (options?: R2ListOptions) => original.list(options), + delete: async (keys: string | string[]) => { + if (first) { + first = false; + entered.resolve(); + await release.promise; + throw new Error('R2 unavailable'); + } + await original.delete(keys); + }, + }, + }); + try { + await state.storage.setAlarm(Date.now() + 60_000); + const cleanup = instance.clearForWorktree(kiloUserId, sessionId).then( + () => undefined, + error => error + ); + await entered.promise; + await expect( + instance.ingest( + [{ type: 'session', data: { title: 'late private data' } }], + kiloUserId, + sessionId, + 1 + ) + ).resolves.toEqual({ accepted: false, reason: 'deleted', changes: [] }); + release.resolve(); + expect(await cleanup).toBeInstanceOf(Error); + expect(drizzle(state.storage).select().from(ingestItems).all()).toHaveLength(1); + expect(await state.storage.getAlarm()).toBeNull(); + } finally { + Object.assign(instance['env'], { SESSION_INGEST_R2: original }); + } + }); + expect(await env.SESSION_INGEST_R2.head(key)).not.toBeNull(); + await stub.clearForWorktree(kiloUserId, sessionId); + expect(await env.SESSION_INGEST_R2.head(key)).toBeNull(); + await runInDurableObject(stub, async (instance, state) => { + expect(drizzle(state.storage).select().from(ingestItems).all()).toEqual([]); + expect(drizzle(state.storage).select().from(ingestMeta).all()).toEqual([ + { key: 'deleted', value: 'true' }, + ]); + await expect(instance.resetCloneStage()).rejects.toThrow('Session deleted'); + expect(await state.storage.getAlarm()).toBeNull(); + }); + }); + + it('waits for an in-flight upload, removes staged and offloaded bodies, and rejects future uploads', async () => { + const { kiloUserId, sessionId, stub } = identity(); + const key = `ingest/${kiloUserId}/${sessionId}/pending`; + const otherKey = `items/${kiloUserId}/ses_other/session/1`; + await env.SESSION_INGEST_R2.put(otherKey, 'preserved'); + await runInDurableObject(stub, async (instance, state) => { + const original = instance['env'].SESSION_INGEST_R2; + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + Object.assign(instance['env'], { + SESSION_INGEST_R2: { + put: async (name: string, body: ReadableStream) => { + started.resolve(); + await release.promise; + return original.put(name, body); + }, + list: (options?: R2ListOptions) => original.list(options), + delete: (keys: string | string[]) => original.delete(keys), + }, + }); + try { + const upload = instance.stageR2Object( + { kiloUserId, sessionId, key }, + new Blob(['private staging body']).stream() + ); + await started.promise; + let finished = false; + const cleanup = instance.clearForWorktree(kiloUserId, sessionId).then(() => { + finished = true; + }); + await Promise.resolve(); + expect(finished).toBe(false); + release.resolve(); + await expect(upload).resolves.toBe(false); + await cleanup; + expect(await original.head(key)).toBeNull(); + await expect( + instance.stageR2Object({ kiloUserId, sessionId, key }, new Blob(['late body']).stream()) + ).resolves.toBe(false); + expect(await original.head(key)).toBeNull(); + expect(drizzle(state.storage).select().from(ingestMeta).all()).toEqual([ + { key: 'deleted', value: 'true' }, + ]); + } finally { + Object.assign(instance['env'], { SESSION_INGEST_R2: original }); + } + }); + expect(await env.SESSION_INGEST_R2.head(otherKey)).not.toBeNull(); + await env.SESSION_INGEST_R2.delete(otherKey); + }); + + it('prevents a late payload commit after an interrupted handler loses its in-memory upload inventory', async () => { + const { kiloUserId, sessionId, stub } = identity(); + const key = `ingest/${kiloUserId}/${sessionId}/interrupted`; + await runInDurableObject(stub, async (instance, state) => { + const original = instance['env'].SESSION_INGEST_R2; + const started = Promise.withResolvers(); + const release = Promise.withResolvers(); + const committed = Promise.withResolvers(); + const resume = Promise.withResolvers(); + Object.assign(instance['env'], { + SESSION_INGEST_R2: { + head: (name: string) => original.head(name), + list: (options?: R2ListOptions) => original.list(options), + delete: (keys: string | string[]) => original.delete(keys), + put: async ( + name: string, + value: Parameters[1], + options?: R2PutOptions + ) => { + if (typeof value === 'string') return original.put(name, value, options); + started.resolve(); + await release.promise; + const written = await original.put(name, value, options); + committed.resolve(written); + await resume.promise; + return written; + }, + }, + }); + const upload = instance.stageR2Object( + { kiloUserId, sessionId, key }, + new Blob(['private interrupted payload']).stream() + ); + try { + await started.promise; + Object.assign(instance, { activeR2Writes: new Set>() }); + await instance.clearForWorktree(kiloUserId, sessionId); + release.resolve(); + expect(await committed.promise).toBeNull(); + expect(await original.head(key)).toBeNull(); + expect(drizzle(state.storage).select().from(ingestMeta).all()).toEqual([ + { key: 'deleted', value: 'true' }, + ]); + resume.resolve(); + await expect(upload).resolves.toBe(false); + } finally { + release.resolve(); + resume.resolve(); + await upload.catch(() => undefined); + Object.assign(instance['env'], { SESSION_INGEST_R2: original }); + } + }); + }); + + it('can replace an existing body without exposing the reservation as session data', async () => { + const { kiloUserId, sessionId, stub } = identity(); + const key = `items/${kiloUserId}/${sessionId}/message/1`; + await stub.stageR2Object({ kiloUserId, sessionId, key }, new Blob(['first']).stream()); + await stub.stageR2Object({ kiloUserId, sessionId, key }, new Blob(['second']).stream()); + expect(await (await env.SESSION_INGEST_R2.get(key))?.text()).toBe('second'); + await stub.clearForWorktree(kiloUserId, sessionId); + }); + + it('accepts large R2 bodies over streaming RPC and deletes the complete per-session prefixes', async () => { + const { kiloUserId, sessionId, stub } = identity(); + const key = `items/${kiloUserId}/${sessionId}/large/1`; + const rawKey = `ingest/${kiloUserId}/${sessionId}/queued`; + const body = 'x'.repeat(2 * 1024 * 1024); + await expect( + stub.stageR2Object({ kiloUserId, sessionId, key }, new Blob([body]).stream()) + ).resolves.toBe(true); + await env.SESSION_INGEST_R2.put(rawKey, 'pending queue payload'); + expect((await env.SESSION_INGEST_R2.head(key))?.size).toBe(body.length); + await stub.clearForWorktree(kiloUserId, sessionId); + expect(await env.SESSION_INGEST_R2.head(key)).toBeNull(); + expect(await env.SESSION_INGEST_R2.head(rawKey)).toBeNull(); + await expect(stub.isDeleted()).resolves.toBe(true); + }); + + it('does not let late public bootstrap overwrite an adopted child scope, including after invalidation', async () => { + const owner = `oauth/cache-scope:${crypto.randomUUID()}`; + const cache = env.SESSION_ACCESS_CACHE_DO.getByName(owner); + const sessionId = 'ses_00000000000000000000000001'; + const scoped = { + sessionId, + organizationId: '11111111-1111-4111-8111-111111111111', + cloudAgentSessionScopeId: 'workspace_11111111-1111-4111-8111-111111111111', + }; + const unscoped = { sessionId, organizationId: null, cloudAgentSessionScopeId: null }; + await cache.putValidated(unscoped); + await cache.putValidated(scoped); + await cache.putValidated(unscoped); + await expect(cache.getAccess(sessionId)).resolves.toEqual(scoped); + await cache.invalidateOrganization(scoped.organizationId); + await cache.putValidated(unscoped); + await expect(cache.getAccess(sessionId)).resolves.toBeNull(); + await cache.putValidated(scoped); + await cache.putValidated({ ...scoped, cloudAgentSessionScopeId: 'another-root' }); + await expect(cache.getAccess(sessionId)).resolves.toEqual(scoped); + await cache.deleteSession(sessionId); + await cache.putValidated(scoped); + await expect(cache.getAccess(sessionId)).resolves.toBeNull(); + }); + + it('removes only targeted cache entries and fences stale authorization cache writes', async () => { + const owner = `oauth/cache:${crypto.randomUUID()}`; + const cache = env.SESSION_ACCESS_CACHE_DO.getByName(owner); + const target = 'ses_00000000000000000000000001'; + const other = 'ses_00000000000000000000000002'; + const access = { sessionId: target, organizationId: null, cloudAgentSessionScopeId: null }; + await cache.putValidated(access); + await cache.putValidated({ ...access, sessionId: other }); + await cache.deleteSession(target); + await cache.putValidated(access); + expect(await cache.getAccess(target)).toBeNull(); + expect(await cache.getAccess(other)).toMatchObject({ sessionId: other }); + }); + + it('keeps shared user connections alive while removing only targeted subscriptions, renames, and command data', async () => { + const owner = `oauth/connection:${crypto.randomUUID()}`; + const connection = env.USER_CONNECTION_DO.getByName(owner); + const target = 'ses_00000000000000000000000001'; + const other = 'ses_00000000000000000000000002'; + await runInDurableObject(connection, async (instance, state) => { + const pair = new WebSocketPair(); + state.acceptWebSocket(pair[1], ['web']); + pair[0].accept(); + pair[1].serializeAttachment({ + role: 'web', + connectionId: 'viewer', + kiloUserId: owner, + subscribedSessions: [target, other], + }); + await state.storage.put({ + [`rename:${target}`]: { title: 'private name', at: Date.now() }, + [`rename:${other}`]: { title: 'preserved name', at: Date.now() }, + [`readyPush:${target}`]: { + title: 'private title', + kiloUserId: owner, + fireAt: Date.now() + 60_000, + attempts: 0, + }, + 'pendingCommand/target': { sessionId: target, state: 'done', result: 'private transcript' }, + 'pendingCommand/other': { + sessionId: other, + state: 'done', + result: 'preserved', + expiresAt: Date.now() + 3_600_000, + }, + }); + await instance.clearSession(target); + expect(await state.storage.get(`rename:${target}`)).toBeUndefined(); + expect(await state.storage.get(`readyPush:${target}`)).toBeUndefined(); + expect(await state.storage.get('pendingCommand/target')).toBeUndefined(); + expect(await state.storage.get(`rename:${other}`)).toMatchObject({ title: 'preserved name' }); + expect(await state.storage.get('pendingCommand/other')).toMatchObject({ + result: 'preserved', + }); + expect(pair[1].deserializeAttachment()).toMatchObject({ subscribedSessions: [other] }); + expect(pair[0].readyState).toBe(WebSocket.OPEN); + await expect(instance.notifySessionRenamed(target, 'late private name')).resolves.toEqual({ + delivered: false, + }); + expect(await state.storage.get(`rename:${target}`)).toBeUndefined(); + pair[0].close(); + }); + }); +}); diff --git a/services/session-ingest/wrangler.test.jsonc b/services/session-ingest/wrangler.test.jsonc index bb5172a556..bdb2db8c5e 100644 --- a/services/session-ingest/wrangler.test.jsonc +++ b/services/session-ingest/wrangler.test.jsonc @@ -32,6 +32,10 @@ "name": "CONNECTION_TICKET_DO", "class_name": "ConnectionTicketDO", }, + { + "name": "USER_CONNECTION_DO", + "class_name": "UserConnectionDO", + }, ], }, From a1f08effb9a33d354ba2c1e229be5034d693646a Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Fri, 28 Aug 2026 12:03:51 +0200 Subject: [PATCH 02/14] fix(cloud-agent-next): harden shared worktree lifecycle --- .../cloud-agent-next/ChatSidebar.tsx | 13 +- .../cloud-agent-next/CloudChatPage.tsx | 23 +- .../cloud-agent-next/CloudSidebarLayout.tsx | 9 + .../hooks/useSidebarSessions.test.ts | 244 +++++- .../hooks/useSidebarSessions.ts | 118 ++- .../cloud-agent-next/session-deletion.test.ts | 5 + .../cloud-agent-next/terminal-tabs.test.ts | 312 +++++++ .../routers/cli-sessions-v2-router.test.ts | 151 +++- .../web/src/routers/cli-sessions-v2-router.ts | 72 +- .../routers/cli-sessions-v2-worktree.test.ts | 225 ++++- .../src/cloud-agent-session-scope.ts | 1 + .../session-ingest-contracts/src/index.ts | 1 + .../src/public-worktree-projection.ts | 51 ++ .../kilo-facade/public-sdk-projection.test.ts | 108 +++ .../src/kilo-facade/public-sdk-projection.ts | 56 +- .../src/persistence/SandboxControl.ts | 1 + .../src/router/handlers/session-management.ts | 13 +- .../worktree-ownership.test.ts | 49 +- .../src/sandbox-control/worktree-ownership.ts | 3 +- .../src/sandbox-outbound.test.ts | 39 + .../control-plane-ingest.test.ts | 3 + .../sandbox-session/control-plane-ingest.ts | 1 + .../test/integration/sandbox-control.test.ts | 766 ++++++++++++++---- .../control/sandbox-control-handlers.test.ts | 256 +++++- .../src/control/sandbox-control-handlers.ts | 4 + .../wrapper/src/kilo-api.test.ts | 29 + .../cloud-agent-session-scope-auth.test.ts | 7 +- .../routes/cloud-agent-session-scope.test.ts | 261 +++--- .../src/routes/cloud-agent-session-scope.ts | 1 + .../src/services/worktree-deletion.test.ts | 242 +++++- .../src/services/worktree-deletion.ts | 60 +- .../session-ingest/src/session-ingest-rpc.ts | 6 +- 32 files changed, 2722 insertions(+), 408 deletions(-) create mode 100644 packages/session-ingest-contracts/src/public-worktree-projection.ts diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index 3d10a32b15..a2cc39d4de 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -25,6 +25,7 @@ import { getSidebarWorktreeLabel, getSidebarWorktreePrSession, groupSidebarSessionsByDate, + type SidebarForegroundSessionStatus, type SidebarWorktreeDetails, type SidebarWorktreeGroup, } from './hooks/useSidebarSessions'; @@ -67,6 +68,7 @@ type ChatSidebarProps = { deletingWorktreeId?: string; isInSheet?: boolean; activeSessions?: ActiveSession[]; + foregroundSession?: SidebarForegroundSessionStatus | null; searchQuery?: string; onSearchChange?: (query: string) => void; platformFilter?: string[]; @@ -256,6 +258,7 @@ function WorktreeGroupRow({ onDeleteWorktree, isDeleting, activeSessionStatuses, + foregroundSession, }: { group: SidebarWorktreeGroup; currentSessionId?: string; @@ -267,6 +270,7 @@ function WorktreeGroupRow({ onDeleteWorktree?: (worktreeId: string) => void; isDeleting: boolean; activeSessionStatuses: ReadonlyMap; + foregroundSession?: SidebarForegroundSessionStatus | null; }) { const [hovered, setHovered] = useState(false); const [menuOpen, setMenuOpen] = useState(false); @@ -282,7 +286,12 @@ function WorktreeGroupRow({ const renameErrorId = useId(); const label = getSidebarWorktreeLabel(group); const prSession = getSidebarWorktreePrSession(group); - const activity = getSidebarWorktreeActivity(group.sessions, activeSessionStatuses); + const activity = getSidebarWorktreeActivity( + group.sessions, + activeSessionStatuses, + group.details?.sessions, + foregroundSession + ); const shouldReplaceTime = activity.isLive || activity.status !== null; const hasActions = Boolean(onCreateWorktreeChat || onRenameWorktree || onDeleteWorktree); const isActive = @@ -558,6 +567,7 @@ export function ChatSidebar({ deletingWorktreeId, isInSheet = false, activeSessions = [], + foregroundSession, searchQuery = '', onSearchChange, platformFilter, @@ -894,6 +904,7 @@ export function ChatSidebar({ onDeleteWorktree={onDeleteWorktree} isDeleting={deletingWorktreeId === item.worktreeId} activeSessionStatuses={activeSessionStatuses} + foregroundSession={foregroundSession} /> ); })} diff --git a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx index dbf048b8d1..320a077beb 100644 --- a/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudChatPage.tsx @@ -360,11 +360,30 @@ export default function CloudChatPage({ workspaceTabs.activeTabId === CHAT_TAB_ID && (sessionIdFromParams !== null || selectedWorktreeId === null); const workspaceTabScope = getWorkspaceTabScope(selectedWorktreeId, sessionIdFromParams); + const workspaceIdentityResolved = + !sessionIdFromParams || + selectedWorktreeId !== null || + (fetchedSessionData?.kiloSessionId === sessionIdFromParams && + fetchedSessionData.organizationId === (organizationId ?? null)); + const [resolvedWorkspaceScope, setResolvedWorkspaceScope] = useState({ + currentUserId, + organizationId, + scope: workspaceTabScope, + }); - useEffect(() => { + if ( + resolvedWorkspaceScope.currentUserId !== currentUserId || + resolvedWorkspaceScope.organizationId !== organizationId || + (workspaceIdentityResolved && resolvedWorkspaceScope.scope !== workspaceTabScope) + ) { + setResolvedWorkspaceScope({ + currentUserId, + organizationId, + scope: workspaceIdentityResolved ? workspaceTabScope : null, + }); setWorkspaceTabs(resetWorkspaceTabs); setTerminalStatuses({}); - }, [workspaceTabScope]); + } useEffect(() => { if (preserveTerminalSelectionRef.current) { diff --git a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index 1143098310..622edc9b72 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -253,6 +253,13 @@ export function CloudSidebarLayout({ pendingMessages, ] ); + const foregroundSession = useMemo( + () => + currentSessionId && foregroundSessionStatus !== null + ? { sessionId: currentSessionId, status: foregroundSessionStatus } + : null, + [currentSessionId, foregroundSessionStatus] + ); const sidebarSessions = useMemo(() => { if (foregroundSessionStatus === null) return sessions; return sessions.map(session => @@ -698,6 +705,7 @@ export function CloudSidebarLayout({ creatingWorktreeSourceSessionId={creatingWorktreeSourceSessionId} isInSheet activeSessions={activeSessions} + foregroundSession={foregroundSession} deletingSessionIds={deletingSessionIds} searchQuery={searchQuery} onSearchChange={setSearchQuery} @@ -728,6 +736,7 @@ export function CloudSidebarLayout({ onCreateWorktreeChat={handleCreateWorktreeChat} creatingWorktreeSourceSessionId={creatingWorktreeSourceSessionId} activeSessions={activeSessions} + foregroundSession={foregroundSession} deletingSessionIds={deletingSessionIds} searchQuery={searchQuery} onSearchChange={setSearchQuery} diff --git a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts index f767554003..6e25b201dc 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.test.ts @@ -21,6 +21,7 @@ import { groupSidebarSessions, groupSidebarSessionsByDate, mergeWorktreeChatSessions, + patchSidebarWorktreeSessionStatus, removeSidebarDbSession, sessionCacheKey, SIDEBAR_RECONCILE_DELAY_MS, @@ -616,6 +617,7 @@ describe('useSidebarSessions live update helpers', () => { name: details.name, defaultTitle: details.defaultTitle, prSession: null, + sessions: [first, latest], }, } satisfies Record; @@ -636,6 +638,7 @@ describe('useSidebarSessions live update helpers', () => { name: 'Named worktree', defaultTitle: 'First chat outside the filtered list', prSession: null, + sessions: [visible], }; const result = groupSidebarSessionsByDate([visible], new Date('2026-01-03T18:00:00.000Z'), { worktree_shared: details, @@ -720,7 +723,12 @@ describe('useSidebarSessions live update helpers', () => { for (const prSession of [hidden, { ...hidden, associatedPr: undefined }, null]) { const [group] = groupSidebarSessions([visible], { - worktree_shared: { name: null, defaultTitle: null, prSession }, + worktree_shared: { + name: null, + defaultTitle: null, + prSession, + sessions: [visible, hidden], + }, }); if (group?.type !== 'worktree') throw new Error('Expected worktree group'); @@ -780,6 +788,183 @@ describe('useSidebarSessions live update helpers', () => { isLive: true, }); }); + + it.each(['question', 'permission', 'busy', 'retry'])( + 'includes a hidden sibling with %s status outside the displayed slice', + status => { + const visible = makeStoredSession('ses_visible', '2026-01-03T12:00:00.000Z'); + const hidden = makeStoredSession('ses_hidden', '2025-01-03T12:00:00.000Z', { + sessionStatus: status, + sessionStatusUpdatedAt: '2026-01-03T12:00:00.000Z', + }); + + expect(getSidebarWorktreeActivity([visible], new Map(), [visible, hidden])).toEqual({ + status, + statusUpdatedAt: hidden.sessionStatusUpdatedAt, + isLive: false, + }); + } + ); + + it('matches hidden live sessions only against authorized group membership', () => { + const visible = makeStoredSession('ses_visible', '2026-01-03T12:00:00.000Z'); + const hidden = makeStoredSession('ses_hidden', '2025-01-03T12:00:00.000Z'); + const active = new Map([ + ['ses_hidden', 'retry'], + ['ses_other_tenant', 'permission'], + ]); + + expect(getSidebarWorktreeActivity([visible], active, [visible, hidden])).toEqual({ + status: 'retry', + statusUpdatedAt: null, + isLive: true, + }); + expect(getSidebarWorktreeActivity([visible], active, [visible])).toEqual({ + status: null, + statusUpdatedAt: null, + isLive: false, + }); + }); + + it.each([false, true])( + 'prefers the newer idle completion over a stale busy snapshot (newer visible=%s)', + newerVisible => { + const busy = makeStoredSession('ses_same', '2026-01-03T12:00:00.000Z', { + sessionStatus: 'busy', + sessionStatusUpdatedAt: '2026-01-03T12:00:00.000Z', + }); + const idle = { + ...busy, + sessionStatus: 'idle', + sessionStatusUpdatedAt: '2026-01-03T12:01:00.000Z', + }; + + expect( + getSidebarWorktreeActivity([newerVisible ? idle : busy], new Map(), [ + newerVisible ? busy : idle, + ]) + ).toEqual({ status: null, statusUpdatedAt: null, isLive: false }); + } + ); + + it('lets live idle completion clear stale stored attention instead of taking maximum priority', () => { + const waiting = makeStoredSession('ses_waiting', '2026-01-03T12:00:00.000Z', { + sessionStatus: 'question', + }); + + expect(getSidebarWorktreeActivity([], new Map([['ses_waiting', 'idle']]), [waiting])).toEqual( + { + status: null, + statusUpdatedAt: null, + isLive: true, + } + ); + expect( + getSidebarWorktreeActivity([], new Map([['ses_waiting', 'busy']]), [waiting]).status + ).toBe('question'); + }); + + it('lets a hidden foreground completion override stale snapshots without idling another sibling', () => { + const foreground = makeStoredSession('ses_foreground', '2026-01-03T12:00:00.000Z', { + sessionStatus: 'question', + }); + const background = makeStoredSession('ses_background', '2026-01-03T12:00:00.000Z', { + sessionStatus: 'busy', + }); + const active = new Map([['ses_foreground', 'busy']]); + const completed = { sessionId: 'ses_foreground', status: 'idle' }; + + expect(getSidebarWorktreeActivity([], active, [foreground], completed).status).toBeNull(); + expect( + getSidebarWorktreeActivity([background], active, [foreground, background], completed).status + ).toBe('busy'); + expect( + getSidebarWorktreeActivity([], active, [foreground], { + sessionId: 'ses_other_tenant', + status: 'idle', + }).status + ).toBe('question'); + }); + + it('prioritizes hidden attention over a foreground session that starts running', () => { + const visible = makeStoredSession('ses_visible', '2026-01-03T12:00:00.000Z'); + const hidden = makeStoredSession('ses_hidden', '2025-01-03T12:00:00.000Z'); + + expect( + getSidebarWorktreeActivity( + [visible], + new Map([['ses_hidden', 'permission']]), + [visible, hidden], + { sessionId: 'ses_visible', status: 'busy' } + ).status + ).toBe('permission'); + }); + }); + + describe('patchSidebarWorktreeSessionStatus', () => { + const initial = { + worktrees: { + worktree_shared: { + name: 'Named worktree', + defaultTitle: null, + prSession: null, + sessions: [ + { + sessionId: 'ses_hidden', + sessionStatus: 'busy', + sessionStatusUpdatedAt: '2026-01-03T12:00:00.000Z', + }, + ], + }, + }, + } satisfies Parameters[0]; + + it('updates a hidden member through attention and completion without changing membership', () => { + const waiting = patchSidebarWorktreeSessionStatus(initial, { + sessionId: 'ses_hidden', + sessionStatus: 'question', + sessionStatusUpdatedAt: '2026-01-03T12:01:00.000Z', + }); + expect( + getSidebarWorktreeActivity([], new Map(), waiting.worktrees.worktree_shared.sessions).status + ).toBe('question'); + + const completed = patchSidebarWorktreeSessionStatus(waiting, { + sessionId: 'ses_hidden', + sessionStatus: 'idle', + sessionStatusUpdatedAt: '2026-01-03T12:02:00.000Z', + }); + expect(completed.worktrees.worktree_shared.sessions).toHaveLength(1); + expect( + getSidebarWorktreeActivity([], new Map(), completed.worktrees.worktree_shared.sessions) + .status + ).toBeNull(); + expect(initial.worktrees.worktree_shared.sessions[0].sessionStatus).toBe('busy'); + expect(completed.worktrees.worktree_shared.name).toBe(initial.worktrees.worktree_shared.name); + expect( + patchSidebarWorktreeSessionStatus(completed, waiting.worktrees.worktree_shared.sessions[0]) + ).toBe(completed); + }); + + it('ignores unknown or foreign session identities and older or duplicate status updates', () => { + expect( + patchSidebarWorktreeSessionStatus(initial, { + sessionId: 'ses_foreign', + sessionStatus: 'permission', + sessionStatusUpdatedAt: '2026-01-03T12:02:00.000Z', + }) + ).toBe(initial); + expect( + patchSidebarWorktreeSessionStatus(initial, { + sessionId: 'ses_hidden', + sessionStatus: 'idle', + sessionStatusUpdatedAt: '2026-01-03T11:59:00.000Z', + }) + ).toBe(initial); + expect( + patchSidebarWorktreeSessionStatus(initial, initial.worktrees.worktree_shared.sessions[0]) + ).toBe(initial); + }); }); describe('dbSessionMatchesSearch', () => { @@ -886,6 +1071,62 @@ function getSidebarButtonMarkup(html: string, label: string): string { } describe('ChatSidebar worktree controls', () => { + it('renders hidden sibling activity from complete worktree details during search', () => { + const visible = makeStoredSession('ses_visible', '2026-01-03T12:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + }); + const hidden = makeStoredSession('ses_hidden', '2025-01-03T12:00:00.000Z', { + worktreeId: 'worktree_shared', + sessionStatus: 'question', + }); + const props: Partial = { + sessions: [visible], + searchQuery: 'visible', + worktreeDetails: { + worktree_shared: { + name: null, + defaultTitle: null, + prSession: null, + sessions: [visible, hidden], + }, + }, + }; + + expect(renderChatSidebar(props)).toContain('aria-label="Waiting for answer"'); + expect( + renderChatSidebar({ + ...props, + foregroundSession: { sessionId: hidden.sessionId, status: 'idle' }, + }) + ).not.toContain('Waiting for answer'); + }); + + it('renders a hidden live sibling on its worktree rather than relying on displayed chats', () => { + const visible = makeStoredSession('ses_visible', '2026-01-03T12:00:00.000Z', { + worktreeId: 'worktree_shared', + associatedPr: null, + }); + const hidden = makeStoredSession('ses_hidden', '2025-01-03T12:00:00.000Z'); + const html = renderChatSidebar({ + sessions: [visible], + activeSessions: [ + { id: hidden.sessionId, status: 'retry', title: 'Hidden chat', connectionId: 'cli' }, + ], + worktreeDetails: { + worktree_shared: { + name: null, + defaultTitle: null, + prSession: null, + sessions: [visible, hidden], + }, + }, + }); + const worktreeMarkup = html.slice(html.indexOf('aria-label="Open worktree')); + + expect(worktreeMarkup).toContain('Retrying'); + }); + it.each([ { create: false, rename: false, remove: false }, { create: true, rename: false, remove: false }, @@ -1013,6 +1254,7 @@ describe('ChatSidebar worktree controls', () => { name: 'Custom worktree', defaultTitle: 'Authoritative first chat', prSession: source, + sessions: [source], }; const html = renderChatSidebar({ worktreeDetails: { worktree_shared: details } }); diff --git a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts index 346fc1fe4a..be274a0dc3 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useSidebarSessions.ts @@ -9,6 +9,8 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useAtomValue, useSetAtom } from 'jotai'; import { useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; +import type { inferRouterOutputs } from '@trpc/server'; +import type { RootRouter } from '@/routers/root-router'; import { cloudAgentWorktreeIdSchema, type CloudAgentWorktreeId, @@ -168,10 +170,23 @@ export function mergeWorktreeChatSessions( }); } +type WorktreeDetailsQueryData = inferRouterOutputs['cliSessionsV2']['worktreeDetails']; + +type SidebarSessionActivity = Pick< + StoredSession, + 'sessionId' | 'sessionStatus' | 'sessionStatusUpdatedAt' +>; + +export type SidebarForegroundSessionStatus = { + sessionId: string; + status: string; +}; + export type SidebarWorktreeDetails = { name: string | null; defaultTitle: string | null; prSession: StoredSession | null; + sessions: SidebarSessionActivity[]; }; export type SidebarWorktreeGroup = { @@ -325,19 +340,40 @@ export function getSidebarWorktreeLabel(group: SidebarWorktreeGroup): string { } export function getSidebarWorktreeActivity( - sessions: StoredSession[], - activeSessionStatuses: ReadonlyMap + sessions: readonly SidebarSessionActivity[], + activeSessionStatuses: ReadonlyMap, + authoritativeSessions: readonly SidebarSessionActivity[] = sessions, + foregroundSession?: SidebarForegroundSessionStatus | null ): SidebarWorktreeActivity { + const sessionsById = new Map(authoritativeSessions.map(session => [session.sessionId, session])); + for (const session of sessions) { + const existing = sessionsById.get(session.sessionId); + if ( + !existing || + new Date(session.sessionStatusUpdatedAt ?? 0).getTime() >= + new Date(existing.sessionStatusUpdatedAt ?? 0).getTime() + ) { + sessionsById.set(session.sessionId, session); + } + } + let selectedStatus: string | null = null; let selectedStatusUpdatedAt: string | null = null; let selectedPriority = 0; let isLive = false; - for (const session of sessions) { + for (const session of sessionsById.values()) { const activeStatus = activeSessionStatuses.get(session.sessionId); if (activeStatus !== undefined) isLive = true; - - for (const status of [session.sessionStatus ?? null, activeStatus ?? null]) { + const foregroundStatus = + foregroundSession?.sessionId === session.sessionId ? foregroundSession.status : undefined; + const statuses = + foregroundStatus !== undefined + ? [foregroundStatus] + : activeStatus === 'idle' + ? [activeStatus] + : [session.sessionStatus ?? null, activeStatus ?? null]; + for (const status of statuses) { const priority = status === 'question' || status === 'permission' ? 2 @@ -348,13 +384,49 @@ export function getSidebarWorktreeActivity( selectedPriority = priority; selectedStatus = status; selectedStatusUpdatedAt = - status === session.sessionStatus ? (session.sessionStatusUpdatedAt ?? null) : null; + foregroundStatus === undefined && status === session.sessionStatus + ? (session.sessionStatusUpdatedAt ?? null) + : null; } } return { status: selectedStatus, statusUpdatedAt: selectedStatusUpdatedAt, isLive }; } +export function patchSidebarWorktreeSessionStatus( + data: WorktreeDetailsQueryData, + update: SidebarSessionActivity +): WorktreeDetailsQueryData { + const worktrees = { ...data.worktrees }; + let changed = false; + for (const [worktreeId, worktree] of Object.entries(worktrees)) { + const existing = worktree.sessions.find(session => session.sessionId === update.sessionId); + if ( + !existing || + new Date(update.sessionStatusUpdatedAt ?? 0).getTime() < + new Date(existing.sessionStatusUpdatedAt ?? 0).getTime() || + (existing.sessionStatus === update.sessionStatus && + existing.sessionStatusUpdatedAt === update.sessionStatusUpdatedAt) + ) { + continue; + } + changed = true; + worktrees[worktreeId] = { + ...worktree, + sessions: worktree.sessions.map(session => + session.sessionId === update.sessionId + ? { + ...session, + sessionStatus: update.sessionStatus ?? null, + sessionStatusUpdatedAt: update.sessionStatusUpdatedAt ?? null, + } + : session + ), + }; + } + return changed ? { ...data, worktrees } : data; +} + const SIDEBAR_LIST_LIMIT = 200; /** @@ -712,6 +784,28 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide if (!sharedConnection) return; const filters = { organizationId, createdOnPlatform, gitUrl } satisfies SidebarSessionFilters; + const patchWorktreeStatus = (update: SidebarSessionActivity) => { + const queries = queryClient.getQueriesData( + trpc.cliSessionsV2.worktreeDetails.pathFilter() + ); + for (const [queryKey, data] of queries) { + if ( + !data || + !Object.values(data.worktrees).some(worktree => + worktree.sessions.some(session => session.sessionId === update.sessionId) + ) + ) { + continue; + } + void queryClient + .cancelQueries({ queryKey, exact: true }, { revert: false, silent: true }) + .then(() => { + queryClient.setQueryData(queryKey, current => + current ? patchSidebarWorktreeSessionStatus(current, update) : current + ); + }); + } + }; const patchSearchCacheForRow = (session: DbSessionV2, filterResult: boolean | null) => { if (!isSearchActive) return; queryClient.setQueryData(searchQueryKey, (current: SearchData | undefined) => { @@ -779,6 +873,11 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide reconciliation: 'immediate' | 'delayed' ) => { if (payload.source !== 'v2') return; + patchWorktreeStatus({ + sessionId: payload.session.sessionId, + sessionStatus: payload.session.status, + sessionStatusUpdatedAt: payload.session.statusUpdatedAt, + }); const next = eventRowToDbSession(payload.session); const filterResult = eventRowMatchesSidebarFilters(payload.session, filters); if (filterResult === true) { @@ -806,6 +905,11 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide ); return; } + patchWorktreeStatus({ + sessionId: payload.sessionId, + sessionStatus: payload.status, + sessionStatusUpdatedAt: payload.statusUpdatedAt, + }); setDbSessions(prev => sortSidebarDbSessions( prev.map(s => @@ -848,6 +952,7 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide setDbSessions, queryClient, queryReconciler, + trpc, ]); const sessions = isSearchActive ? searchSessions : cachedSessions; @@ -889,6 +994,7 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide details[worktreeId] = { name: worktree.name, defaultTitle: worktree.defaultTitle, + sessions: worktree.sessions, prSession: worktree.prSession ? dbSessionToStoredSession(apiSessionToDbSession(worktree.prSession)) : null, diff --git a/apps/web/src/components/cloud-agent-next/session-deletion.test.ts b/apps/web/src/components/cloud-agent-next/session-deletion.test.ts index b4e94438be..d9fa30feb0 100644 --- a/apps/web/src/components/cloud-agent-next/session-deletion.test.ts +++ b/apps/web/src/components/cloud-agent-next/session-deletion.test.ts @@ -82,6 +82,11 @@ function createFixture() { name: null, defaultTitle: sessions[0].title, prSession: null, + sessions: sessions.map(session => ({ + sessionId: session.session_id, + sessionStatus: session.status, + sessionStatusUpdatedAt: session.status_updated_at, + })), }, }, }); diff --git a/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts b/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts index 7b2866e762..10a2e33466 100644 --- a/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/terminal-tabs.test.ts @@ -1,3 +1,9 @@ +import React, { act, createElement, useEffect, type ComponentProps, type ReactNode } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { createRequire } from 'node:module'; +import type { CloudAgentWorkspaceTabs } from './CloudAgentWorkspaceTabs'; +import type { CloudChatPage as CloudChatPageComponent } from './CloudChatPage'; +import type { StoredSession } from './types'; import { CHAT_TAB_ID, addTerminalTab, @@ -9,6 +15,163 @@ import { terminalTabId, } from './terminal-tabs'; +Object.assign(globalThis, { React }); + +let mockSessionId: string | null = 'ses_recent'; +let mockWorktreeId: string | null = 'worktree_shared'; +let mockTabs: ComponentProps; +let mockAtomValues: Record; +const mockClosedPtys: string[] = []; +const mockChats = [ + { + sessionId: 'ses_recent', + cloudAgentSessionId: 'workspace_recent', + worktreeId: 'worktree_shared', + }, + { + sessionId: 'ses_historical', + cloudAgentSessionId: 'workspace_historical', + worktreeId: 'worktree_shared', + }, +] as StoredSession[]; +const mockSetAtom = jest.fn(); +const mockManager = { + atoms: new Proxy({}, { get: (_target, key) => key }), + switchSession: jest.fn(), + destroy: jest.fn(), +}; +const mockQueryClient = { invalidateQueries: jest.fn() }; +const mockUploadEndpoint = { mutationOptions: () => ({}) }; +const mockTrpc = { + cloudAgentNext: { getAttachmentUploadUrl: mockUploadEndpoint }, + organizations: { cloudAgentNext: { getAttachmentUploadUrl: mockUploadEndpoint } }, +}; + +jest.mock('jotai', () => ({ + useAtomValue: (key: string) => mockAtomValues[key] ?? null, + useSetAtom: () => mockSetAtom, +})); +jest.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(mockSessionId ? { sessionId: mockSessionId } : {}), +})); +jest.mock('@tanstack/react-query', () => ({ + useMutation: () => ({ mutateAsync: jest.fn() }), + useQueryClient: () => mockQueryClient, +})); +jest.mock('@/lib/trpc/utils', () => ({ useTRPC: () => mockTrpc })); +jest.mock('./CloudAgentProvider', () => ({ useManager: () => mockManager })); +jest.mock('./CloudSidebarLayout', () => ({ + useWorktreeChatCreation: () => ({ + createWorktreeChat: jest.fn(), + creatingWorktreeSourceSessionId: null, + }), + useWorktreeChatTabs: () => ({ + selectedWorktreeId: mockWorktreeId, + worktreeChats: mockChats, + openWorktreeChats: mockChats, + closedWorktreeChats: [], + deletingSessionIds: [], + openSession: jest.fn(), + closeSession: jest.fn(), + renameSession: jest.fn(), + }), +})); +jest.mock('./CloudAgentWorkspaceTabs', () => ({ + CloudAgentWorkspaceTabs: (props: ComponentProps) => { + mockTabs = props; + return null; + }, +})); +jest.mock('./CloudAgentTerminalDock', () => ({ + CloudAgentTerminalPane: ({ + cloudAgentSessionId, + organizationId, + }: { + cloudAgentSessionId: string; + organizationId?: string; + }) => { + useEffect( + () => () => void mockClosedPtys.push(cloudAgentSessionId), + [cloudAgentSessionId, organizationId] + ); + return createElement('pre', { 'data-pty-owner': cloudAgentSessionId }); + }, +})); +jest.mock('@/hooks/useCloudAgentProfiles', () => ({ + useCombinedProfiles: () => ({}), + useProfiles: () => ({}), + useProfile: () => ({}), +})); +jest.mock('@/hooks/useSlashCommandSets', () => ({ + useSlashCommandSets: () => ({ availableCommands: [] }), +})); +jest.mock('@/hooks/useCelebrationSound', () => ({ + useCelebrationSound: () => ({ play: jest.fn(), soundEnabled: false, setSoundEnabled: jest.fn() }), +})); +jest.mock('@/hooks/useCliSessionPresence', () => ({ useCliSessionPresence: jest.fn() })); +jest.mock('./hooks/useSessionModels', () => ({ + useSessionModels: () => ({ + modelOptions: [], + gatewayContextLengthByModelId: new Map(), + remoteContextLengthByProviderAndModel: new Map(), + }), +})); +jest.mock('./older-messages-scroll', () => ({ + useOlderMessagesPagination: () => ({}), + shouldAnnounceOlderMessagesArrival: () => false, +})); +jest.mock('./MobileSidebarToggle', () => ({ MobileSidebarToggle: () => null })); +jest.mock('./ChatHeader', () => ({ ChatHeader: () => null })); +jest.mock('./ChatInput', () => ({ ChatInput: () => null })); +jest.mock('./OlderMessagesHeader', () => ({ OlderMessagesHeader: () => null })); +jest.mock('./MessageBubble', () => ({ MessageBubble: () => null })); +jest.mock('./ChildSessionDrawer', () => ({ ChildSessionDrawer: () => null })); +jest.mock('./PreparationDrawer', () => ({ PreparationDrawer: () => null })); +jest.mock('./SessionContinuationPanel', () => ({ SessionContinuationPanel: () => null })); +jest.mock('@/components/SetPageTitle', () => ({ SetPageTitle: () => null })); +jest.mock('./QuestionContext', () => ({ + QuestionContextProvider: ({ children }: { children: ReactNode }) => children, +})); +jest.mock('./PermissionCard', () => ({ + PermissionContextProvider: ({ children }: { children: ReactNode }) => children, + PermissionCard: () => null, +})); +jest.mock('./SuggestionCard', () => ({ + SuggestionContextProvider: ({ children }: { children: ReactNode }) => children, +})); + +function installTerminalTestDom() { + const requireFromHere = createRequire(__filename); + const { parseHTML } = requireFromHere( + '../../../../../node_modules/.pnpm/linkedom@0.18.12/node_modules/linkedom' + ) as { parseHTML: (html: string) => { window: typeof globalThis; document: Document } }; + const { window, document } = parseHTML('
'); + const previous = { + window: globalThis.window, + document: globalThis.document, + HTMLElement: globalThis.HTMLElement, + Element: globalThis.Element, + Node: globalThis.Node, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, + }; + Object.assign(globalThis, { + window, + document, + HTMLElement: window.HTMLElement, + Element: window.Element, + Node: window.Node, + requestAnimationFrame: () => 0, + cancelAnimationFrame: () => {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const container = document.getElementById('root'); + if (!container) throw new Error('Missing terminal test root'); + return { container, cleanup: () => Object.assign(globalThis, previous) }; +} + describe('cloud agent workspace terminal tabs', () => { it('starts on the chat tab without terminals', () => { expect(createWorkspaceTabsState()).toEqual({ @@ -123,6 +286,155 @@ describe('cloud agent workspace terminal tabs', () => { }); }); +describe('CloudChatPage terminal ownership across navigation', () => { + let CloudChatPage: typeof CloudChatPageComponent; + let dom: ReturnType; + let root: Root; + + beforeAll(async () => { + ({ CloudChatPage } = await import('./CloudChatPage')); + }); + + beforeEach(() => { + mockSessionId = 'ses_recent'; + mockWorktreeId = 'worktree_shared'; + mockClosedPtys.length = 0; + mockAtomValues = { + sessionId: 'workspace_recent', + fetchedSessionData: { + kiloSessionId: 'ses_recent', + organizationId: null, + worktreeId: 'worktree_shared', + }, + activity: { type: 'idle' }, + activeSessionType: 'cloud-agent', + staticMessages: [], + dynamicMessages: [], + pendingMessages: new Map(), + preparationAttempts: [], + chatUI: { shouldAutoScroll: false }, + }; + dom = installTerminalTestDom(); + root = createRoot(dom.container); + }); + + afterEach(() => { + act(() => root.unmount()); + dom.cleanup(); + }); + + function render(props: ComponentProps = {}) { + act(() => root.render(createElement(CloudChatPage, { currentUserId: 'owner', ...props }))); + } + + function openTerminal() { + act(() => mockTabs.onCreateTerminal()); + const terminal = dom.container.querySelector('[data-pty-owner]'); + expect(terminal).not.toBeNull(); + return terminal; + } + + function resolveSession(worktreeId: string | null) { + mockWorktreeId = worktreeId; + mockAtomValues.fetchedSessionData = { + kiloSessionId: mockSessionId, + organizationId: null, + worktreeId, + }; + mockAtomValues.sessionId = `workspace_${mockSessionId}`; + render(); + } + + it('keeps the mounted PTY and original owner through an uncached historical sibling and Back', () => { + render(); + const terminal = openTerminal(); + mockSessionId = 'ses_historical'; + mockWorktreeId = null; + render(); + expect(mockTabs.terminals).toHaveLength(1); + expect(dom.container.querySelector('[data-pty-owner]')).toBe(terminal); + expect(mockClosedPtys).toEqual([]); + + mockAtomValues.fetchedSessionData = null; + mockAtomValues.sessionId = null; + render(); + resolveSession('worktree_shared'); + expect(dom.container.querySelector('[data-pty-owner]')).toBe(terminal); + expect(mockTabs.terminals[0]?.cloudAgentSessionId).toBe('workspace_recent'); + expect(mockTabs.activeTabId).toBe(CHAT_TAB_ID); + + mockSessionId = 'ses_recent'; + mockWorktreeId = null; + render(); + resolveSession('worktree_shared'); + expect(dom.container.querySelector('[data-pty-owner]')).toBe(terminal); + expect(mockClosedPtys).toEqual([]); + }); + + it('keeps terminals when the final chat closes to the worktree-only route', () => { + render(); + const terminal = openTerminal(); + mockSessionId = null; + mockAtomValues.fetchedSessionData = null; + mockAtomValues.sessionId = null; + render(); + + expect(dom.container.querySelector('[data-pty-owner]')).toBe(terminal); + expect(mockTabs.terminals[0]?.cloudAgentSessionId).toBe('workspace_recent'); + expect(mockClosedPtys).toEqual([]); + }); + + it.each(['worktree_other', null])( + 'closes the original PTY only after a different destination resolves to %s', + destinationWorktreeId => { + render(); + openTerminal(); + mockSessionId = 'ses_other'; + mockWorktreeId = null; + mockAtomValues.fetchedSessionData = null; + render(); + expect(mockClosedPtys).toEqual([]); + + resolveSession(destinationWorktreeId); + expect(mockTabs.terminals).toEqual([]); + expect(dom.container.querySelector('[data-pty-owner]')).toBeNull(); + expect(mockClosedPtys).toEqual(['workspace_recent']); + } + ); + + it.each([{ currentUserId: 'another-owner' }, { organizationId: 'another-organization' }])( + 'resets immediately across tenant changes even while identity is unresolved: %j', + props => { + render(); + openTerminal(); + mockSessionId = 'ses_unresolved'; + mockWorktreeId = null; + render(props); + + expect(dom.container.querySelector('[data-pty-owner]')).toBeNull(); + expect(mockClosedPtys).toEqual(['workspace_recent']); + } + ); + + it('resolves a direct link before opening terminals and resets when leaving all chats', () => { + mockWorktreeId = null; + mockAtomValues.fetchedSessionData = null; + mockAtomValues.sessionId = null; + render(); + resolveSession('worktree_shared'); + openTerminal(); + expect(mockTabs.terminals).toHaveLength(1); + + mockSessionId = null; + mockWorktreeId = null; + mockAtomValues.fetchedSessionData = null; + mockAtomValues.sessionId = null; + render(); + expect(dom.container.querySelector('[data-pty-owner]')).toBeNull(); + expect(mockClosedPtys).toEqual(['workspace_ses_recent']); + }); +}); + describe('getWorkspaceTabScope', () => { it('keeps sibling chats in the same worktree scope', () => { const first = getWorkspaceTabScope('worktree-a', 'session-a'); diff --git a/apps/web/src/routers/cli-sessions-v2-router.test.ts b/apps/web/src/routers/cli-sessions-v2-router.test.ts index 3d99ee41ec..ffd127f20c 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.test.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.test.ts @@ -215,7 +215,8 @@ describe('cli-sessions-v2-router', () => { }); it('projects every grouped snapshot path into the distinct public chat directory', async () => { - const privateDirectory = '/srv/runtime/private/worktrees/worktree-secret'; + const privateDirectory = + '/srv/runtime/private/worktrees/worktree_12345678-1234-4234-9234-123456789abc'; const publicDirectory = `/cloud-agent/sessions/${sessionId}`; await db .update(cli_sessions_v2) @@ -250,6 +251,16 @@ describe('cli-sessions-v2-router', () => { type: 'tool', state: { status: 'completed', + output: `Updated ${privateDirectory}/src/app.ts and src/relative.ts`, + metadata: { + outputs: [ + `${privateDirectory}/result.txt`, + { + output: `Read ${privateDirectory}/nested.txt`, + relative: 'src/relative.ts', + }, + ], + }, attachments: [ { id: 'prt_nested', @@ -265,6 +276,11 @@ describe('cli-sessions-v2-router', () => { type: 'patch', files: [`${privateDirectory}/src/app.ts`, 'relative.ts'], }, + { + id: 'prt_text', + type: 'text', + text: `Read ${privateDirectory}/src/app.ts and src/relative.ts`, + }, ], }, { @@ -298,8 +314,20 @@ describe('cli-sessions-v2-router', () => { info: { path: { cwd: publicDirectory, root: publicDirectory } }, parts: [ { url: '', source: { path: publicDirectory } }, - { state: { attachments: [{ url: '', source: { path: publicDirectory } }] } }, + { + state: { + output: `Updated ${publicDirectory}/src/app.ts and src/relative.ts`, + metadata: { + outputs: [ + `${publicDirectory}/result.txt`, + { output: `Read ${publicDirectory}/nested.txt`, relative: 'src/relative.ts' }, + ], + }, + attachments: [{ url: '', source: { path: publicDirectory } }], + }, + }, { files: [publicDirectory, 'relative.ts'] }, + { text: `Read ${publicDirectory}/src/app.ts and src/relative.ts` }, ], }); expect(result.messages[1]).toMatchObject({ @@ -315,6 +343,40 @@ describe('cli-sessions-v2-router', () => { expect(JSON.stringify(result)).not.toContain(privateDirectory); }); + it.each([true, false])( + 'uses validated snapshot metadata rather than discovering prefixes in free text (metadata=%s)', + async hasDirectoryMetadata => { + const privateDirectory = + '/srv/runtime/private/worktrees/worktree_12345678-1234-4234-9234-123456789abc'; + await db + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) + .where(eq(cli_sessions_v2.session_id, sessionId)); + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + info: { id: sessionId, directory: hasDirectoryMetadata ? privateDirectory : '/' }, + messages: [ + { + info: { id: 'msg_user', role: 'user' }, + parts: [ + { id: 'prt_text', type: 'text', text: `Read ${privateDirectory}/file.txt` }, + ], + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + const caller = await createCallerForUser(regularUser.id); + const result = await caller.cliSessionsV2.getSessionMessages({ session_id: sessionId }); + + expect(result.messages[0].parts[0]).toMatchObject({ + text: `Read ${hasDirectoryMetadata ? `/cloud-agent/sessions/${sessionId}` : privateDirectory}/file.txt`, + }); + } + ); + it('preserves private-path fields unchanged for legacy ungrouped snapshots', async () => { const privateDirectory = '/legacy/unchanged/workspace'; const snapshot = { @@ -374,7 +436,8 @@ describe('cli-sessions-v2-router', () => { describe('getSessionMessagesPage', () => { const sessionId = 'ses_messages_page_test_1234'; - const privateDirectory = '/srv/runtime/private/worktrees/grouped-history'; + const privateDirectory = + '/srv/runtime/private/worktrees/worktree_12345678-1234-4234-9234-123456789abc'; let fetchSessionMessagesPage: jest.MockedFunction; function privateAssistantMessage(): KiloSdkStoredMessage { @@ -420,6 +483,34 @@ describe('cli-sessions-v2-router', () => { hash: 'patch', files: [`${privateDirectory}/src/app.ts`, 'relative.ts'], }, + { + id: 'prt_tool_01', + sessionID: sessionId, + messageID: 'msg_assistant_01', + type: 'tool', + callID: 'call_read', + tool: 'read', + state: { + status: 'completed', + input: { filePath: `${privateDirectory}/src/app.ts` }, + output: `Read ${privateDirectory}/src/app.ts and src/relative.ts`, + title: 'Read file', + time: { start: 1761000000100, end: 1761000000200 }, + metadata: { + outputs: [ + `${privateDirectory}/result.txt`, + { output: `Read ${privateDirectory}/nested.txt`, relative: 'src/relative.ts' }, + ], + }, + }, + }, + { + id: 'prt_text_01', + sessionID: sessionId, + messageID: 'msg_assistant_01', + type: 'text', + text: `Updated ${privateDirectory}/src/app.ts and src/relative.ts`, + }, ], }; } @@ -498,12 +589,13 @@ describe('cli-sessions-v2-router', () => { .update(cli_sessions_v2) .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) .where(eq(cli_sessions_v2.session_id, sessionId)); + const message = privateAssistantMessage(); fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history: { - messages: [privateAssistantMessage()], - nextCursor: null, - omittedItemCount: 0, + messages: [message], + nextCursor: 'opaque-next-cursor', + omittedItemCount: 2, }, }); const caller = await createCallerForUser(regularUser.id); @@ -523,15 +615,32 @@ describe('cli-sessions-v2-router', () => { parts: [ { url: '', source: { path: publicDirectory } }, { files: [publicDirectory, 'relative.ts'] }, + { + state: { + input: { filePath: publicDirectory }, + output: `Read ${publicDirectory}/src/app.ts and src/relative.ts`, + metadata: { + outputs: [ + `${publicDirectory}/result.txt`, + { + output: `Read ${publicDirectory}/nested.txt`, + relative: 'src/relative.ts', + }, + ], + }, + }, + }, + { text: `Updated ${publicDirectory}/src/app.ts and src/relative.ts` }, ], }, ], - nextCursor: null, - omittedItemCount: 0, + nextCursor: 'opaque-next-cursor', + omittedItemCount: 2, }, watermarkEventId: null, }); expect(JSON.stringify(result)).not.toContain(privateDirectory); + expect(message).toEqual(privateAssistantMessage()); }); it('preserves private-path fields unchanged for ungrouped paginated history', async () => { @@ -545,19 +654,21 @@ describe('cli-sessions-v2-router', () => { ).resolves.toEqual({ kiloSessionId: sessionId, history, watermarkEventId: null }); }); - it('preserves grouped typed history failures without fabricating a transcript', async () => { - await db - .update(cli_sessions_v2) - .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) - .where(eq(cli_sessions_v2.session_id, sessionId)); - const history = { kind: 'retryable_failure' as const, phase: 'page_parts' as const }; - fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); - const caller = await createCallerForUser(regularUser.id); + it.each([null, { kind: 'retryable_failure' as const, phase: 'page_parts' as const }])( + 'preserves grouped absent or failed history without fabricating a transcript: %j', + async history => { + await db + .update(cli_sessions_v2) + .set({ cloud_agent_worktree_id: 'worktree_12345678-1234-4234-9234-123456789abc' }) + .where(eq(cli_sessions_v2.session_id, sessionId)); + fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, history }); + const caller = await createCallerForUser(regularUser.id); - await expect( - caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 50 }) - ).resolves.toEqual({ kiloSessionId: sessionId, history, watermarkEventId: null }); - }); + await expect( + caller.cliSessionsV2.getSessionMessagesPage({ session_id: sessionId, limit: 50 }) + ).resolves.toEqual({ kiloSessionId: sessionId, history, watermarkEventId: null }); + } + ); it('forwards the continuation cursor to the client', async () => { fetchSessionMessagesPage.mockResolvedValueOnce({ diff --git a/apps/web/src/routers/cli-sessions-v2-router.ts b/apps/web/src/routers/cli-sessions-v2-router.ts index 7b6fbc409e..2621d58e1f 100644 --- a/apps/web/src/routers/cli-sessions-v2-router.ts +++ b/apps/web/src/routers/cli-sessions-v2-router.ts @@ -42,6 +42,7 @@ import { MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, cloudAgentWorktreeIdSchema, isDefaultSessionTitle, + projectPrivateWorktreePaths, validateKiloSdkMessagesCursor, } from '@kilocode/session-ingest-contracts'; import { baseGetSessionNextOutputSchema } from './cloud-agent-next-schemas'; @@ -141,9 +142,14 @@ function projectGroupedTranscriptPaths(value: unknown, directory: string): void } } -function projectGroupedSessionTranscript(value: T, kiloSessionId: string): T { - const projected = structuredClone(value); - projectGroupedTranscriptPaths(projected, `/cloud-agent/sessions/${kiloSessionId}`); +function projectGroupedSessionTranscript( + value: T, + kiloSessionId: string, + metadata: readonly unknown[] +): T { + const directory = `/cloud-agent/sessions/${kiloSessionId}`; + const projected = projectPrivateWorktreePaths(value, metadata, directory); + projectGroupedTranscriptPaths(projected, directory); return projected; } @@ -529,6 +535,11 @@ function projectAssociatedPr< type WorktreeDetail = { name: string | null; defaultTitle: string | null; + sessions: { + sessionId: string; + sessionStatus: string | null; + sessionStatusUpdatedAt: string | null; + }[]; prSession: | (Pick & { associatedPr: z.infer | null; @@ -849,7 +860,7 @@ export const cliSessionsV2Router = createTRPCRouter({ cli_sessions_v2.cloud_agent_worktree_id ); - const [firstSessions, prRows] = await Promise.all([ + const [firstSessions, prRows, activityRows] = await Promise.all([ db .selectDistinctOn([cli_sessions_v2.cloud_agent_worktree_id], { worktreeId: cli_sessions_v2.cloud_agent_worktree_id, @@ -888,8 +899,32 @@ export const cliSessionsV2Router = createTRPCRouter({ desc(cli_sessions_v2.updated_at), asc(cli_sessions_v2.session_id) ), + db + .select({ + worktreeId: cli_sessions_v2.cloud_agent_worktree_id, + sessions: sql`json_agg(json_build_object( + 'sessionId', ${cli_sessions_v2.session_id}, + 'sessionStatus', ${cli_sessions_v2.status}, + 'sessionStatusUpdatedAt', ${cli_sessions_v2.status_updated_at} + ) ORDER BY ${cli_sessions_v2.session_id})`, + }) + .from(cli_sessions_v2) + .leftJoin(cloud_agent_worktrees, metadataJoin) + .where(scopeCondition) + .groupBy(cli_sessions_v2.cloud_agent_worktree_id), ]); + const activityByWorktree = new Map( + activityRows.map(row => [ + row.worktreeId, + row.sessions.map(session => ({ + ...session, + sessionStatusUpdatedAt: session.sessionStatusUpdatedAt + ? new Date(session.sessionStatusUpdatedAt).toISOString() + : null, + })), + ]) + ); const prSessions = new Map>(); for (const row of prRows) { const session = projectAssociatedPr(row); @@ -918,9 +953,21 @@ export const cliSessionsV2Router = createTRPCRouter({ name: session.name, defaultTitle: session.title?.trim() && !isDefaultSessionTitle(session.title) ? session.title : null, + sessions: activityByWorktree.get(session.worktreeId) ?? [], prSession: prSessions.get(session.worktreeId) ?? null, }; } + const hasPendingPrRows = Object.values(worktrees).some( + worktree => worktree.prSession?.associatedPr?.reviewDecisionPending === true + ); + if (hasPendingPrRows) { + after(() => + triggerBatchReviewDecisionFetchIfNeeded(hasPendingPrRows, { + userId: ctx.user.id, + organizationId: input.organizationId, + }) + ); + } return { worktrees }; }), @@ -930,7 +977,7 @@ export const cliSessionsV2Router = createTRPCRouter({ db.transaction(async tx => { const membershipCondition = worktreeMembershipCondition(ctx.user.id, input.organizationId); const [session] = await tx - .select({ sessionId: cli_sessions_v2.session_id }) + .select({ createdAt: cli_sessions_v2.created_at }) .from(cli_sessions_v2) .where( and( @@ -942,6 +989,7 @@ export const cliSessionsV2Router = createTRPCRouter({ membershipCondition ) ) + .orderBy(asc(cli_sessions_v2.created_at)) .limit(1); if (session) { @@ -951,6 +999,7 @@ export const cliSessionsV2Router = createTRPCRouter({ worktree_id: input.worktreeId, kilo_user_id: ctx.user.id, organization_id: input.organizationId, + created_at: session.createdAt, }) .onConflictDoNothing({ target: cloud_agent_worktrees.worktree_id }); } @@ -1334,7 +1383,10 @@ export const cliSessionsV2Router = createTRPCRouter({ return { info: {}, messages: [] }; } return session.cloud_agent_worktree_id - ? projectGroupedSessionTranscript(snapshot, input.session_id) + ? projectGroupedSessionTranscript(snapshot, input.session_id, [ + snapshot.info, + ...snapshot.messages.map(message => message.info), + ]) : snapshot; } catch (error) { console.error( @@ -1420,7 +1472,13 @@ export const cliSessionsV2Router = createTRPCRouter({ return { ...(session.cloud_agent_worktree_id - ? projectGroupedSessionTranscript(result, input.session_id) + ? projectGroupedSessionTranscript( + result, + input.session_id, + result.history && 'messages' in result.history + ? result.history.messages.map(message => message.info) + : [] + ) : result), watermarkEventId, }; diff --git a/apps/web/src/routers/cli-sessions-v2-worktree.test.ts b/apps/web/src/routers/cli-sessions-v2-worktree.test.ts index d9231494f9..cdedcab6a8 100644 --- a/apps/web/src/routers/cli-sessions-v2-worktree.test.ts +++ b/apps/web/src/routers/cli-sessions-v2-worktree.test.ts @@ -42,6 +42,9 @@ const mockGetRuntimeSession = jest.fn<(sessionId: string) => Promise Promise>(); const mockFetchSessionSnapshot = jest.fn<() => Promise>(); const mockCaptureException = jest.fn(); +const afterCallbacks: Array<() => void | Promise> = []; +const mockBatchReviewDecisionFetch = + jest.fn<(pending: boolean, owner: { userId: string; organizationId: string | null }) => void>(); const mockGenerateCloudAgentToken = jest.fn((_user: User) => 'cloud-agent-token'); const mockCreateCloudAgentNextClient = jest.fn((_token: string) => ({ deleteSession: mockRuntimeDelete, @@ -101,7 +104,7 @@ jest.mock('@/lib/integrations/db/platform-integrations', () => ({ })); jest.mock('@/lib/integrations/platforms/github/batch-review-decisions', () => ({ - triggerBatchReviewDecisionFetchIfNeeded: jest.fn(), + triggerBatchReviewDecisionFetchIfNeeded: mockBatchReviewDecisionFetch, })); jest.mock('@/lib/cloud-agent/session-events', () => ({ @@ -109,7 +112,9 @@ jest.mock('@/lib/cloud-agent/session-events', () => ({ })); jest.mock('next/server', () => ({ - after: jest.fn(), + after: (callback: () => void | Promise) => { + afterCallbacks.push(callback); + }, })); type WorktreeCaller = ReturnType; @@ -196,6 +201,7 @@ beforeAll(async () => { beforeEach(async () => { jest.clearAllMocks(); + afterCallbacks.length = 0; const userIds = [USER_ID, OTHER_USER_ID]; await db .delete(cli_sessions_v2) @@ -349,12 +355,58 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { expect(result).toEqual({ worktrees: { - [WORKTREE_ID]: { name: null, defaultTitle: ownershipRow.title, prSession: null }, + [WORKTREE_ID]: { + name: null, + defaultTitle: ownershipRow.title, + prSession: null, + sessions: [{ sessionId: SESSION_ID, sessionStatus: null, sessionStatusUpdatedAt: null }], + }, }, }); expect(await readWorktree()).toBeUndefined(); }); + it('returns activity membership only for owned roots in the exact current scope', async () => { + const sibling = await insertSession({ + status: 'busy', + status_updated_at: '2026-04-29 01:16:12.945+00', + }); + await insertSession({ kilo_user_id: OTHER_USER_ID, status: 'question' }); + await insertSession({ organization_id: ORGANIZATION_ID, status: 'permission' }); + await insertSession({ organization_id: OTHER_ORGANIZATION_ID, status: 'question' }); + await insertSession({ parent_session_id: SESSION_ID, status: 'permission' }); + const otherWorktreeId = newWorktreeId(); + const otherWorktreeSession = await insertSession({ + cloud_agent_worktree_id: otherWorktreeId, + status: 'retry', + }); + + const details = await createCaller({ user }).worktreeDetails({ + worktreeIds: [WORKTREE_ID, otherWorktreeId], + organizationId: null, + }); + + expect(details.worktrees[WORKTREE_ID].sessions).toEqual( + [ + { sessionId: SESSION_ID, sessionStatus: null, sessionStatusUpdatedAt: null }, + { + sessionId: sibling.session_id, + sessionStatus: 'busy', + sessionStatusUpdatedAt: '2026-04-29T01:16:12.945Z', + }, + ].sort((a, b) => a.sessionId.localeCompare(b.sessionId)) + ); + expect(details.worktrees[otherWorktreeId].sessions).toEqual([ + { + sessionId: otherWorktreeSession.session_id, + sessionStatus: 'retry', + sessionStatusUpdatedAt: null, + }, + ]); + expect(mockFetchSessionSnapshot).not.toHaveBeenCalled(); + expect(mockGetRuntimeSession).not.toHaveBeenCalled(); + }); + it('derives the first root from full history rather than recent, searched, filtered, or capped rows', async () => { await db .update(cli_sessions_v2) @@ -364,6 +416,8 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { git_url: 'https://github.com/worktree-tests/original', created_at: '2020-01-01T00:00:00.000Z', updated_at: '2020-01-01T00:00:00.000Z', + status: 'question', + status_updated_at: '2020-01-01 00:00:00+00', }) .where(eq(cli_sessions_v2.session_id, SESSION_ID)); await db.insert(cli_sessions_v2).values( @@ -397,6 +451,13 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { expect(recent.cliSessions.some(session => session.session_id === SESSION_ID)).toBe(false); expect(search.results.some(session => session.session_id === SESSION_ID)).toBe(false); expect(details.worktrees[WORKTREE_ID].defaultTitle).toBe('Original worktree purpose'); + expect(details.worktrees[WORKTREE_ID].sessions).toHaveLength(206); + expect(details.worktrees[WORKTREE_ID].sessions).toContainEqual({ + sessionId: SESSION_ID, + sessionStatus: 'question', + sessionStatusUpdatedAt: '2020-01-01T00:00:00.000Z', + }); + expect(mockFetchSessionSnapshot).not.toHaveBeenCalled(); }); it.each([null, '', ' \t\n', `New session - ${INITIAL_TIME}`, `Child session - ${INITIAL_TIME}`])( @@ -413,7 +474,7 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { organizationId: null, }); - expect(details.worktrees[WORKTREE_ID]).toEqual({ + expect(details.worktrees[WORKTREE_ID]).toMatchObject({ name: null, defaultTitle: null, prSession: null, @@ -519,7 +580,7 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { worktreeIds: [WORKTREE_ID], organizationId: null, }); - expect(reloaded.worktrees[WORKTREE_ID]).toEqual({ + expect(reloaded.worktrees[WORKTREE_ID]).toMatchObject({ name: 'Custom worktree name', defaultTitle: 'New automatic first title', prSession: null, @@ -542,6 +603,48 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { expect(renamed.name).toBe('Second name'); }); + it.each([null, ORGANIZATION_ID])( + 'backdates lazy rename metadata to the oldest authorized member (organization=%s)', + async organizationId => { + await db + .update(cli_sessions_v2) + .set({ organization_id: organizationId }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + const oldest = await insertSession({ + organization_id: organizationId, + created_at: '2020-01-02 03:04:05.678+00', + }); + await insertSession({ + kilo_user_id: OTHER_USER_ID, + organization_id: organizationId, + created_at: '2010-01-01T00:00:00.000Z', + }); + await insertSession({ + organization_id: organizationId === null ? ORGANIZATION_ID : null, + created_at: '2010-01-01T00:00:00.000Z', + }); + const sessionsBeforeRename = await readSessions(); + expect(await readWorktree()).toBeUndefined(); + + await expect( + createCaller({ user }).renameWorktree({ + worktreeId: WORKTREE_ID, + organizationId, + name: 'Historical worktree', + }) + ).resolves.toEqual({ name: 'Historical worktree' }); + + const worktree = await readWorktree(); + expect(worktree).toMatchObject({ + kilo_user_id: USER_ID, + organization_id: organizationId, + name: 'Historical worktree', + }); + expect(worktree.created_at).toBe(oldest.created_at); + expect(await readSessions()).toEqual(sessionsBeforeRename); + } + ); + it('safely serializes concurrent first renames without duplicating metadata or changing ownership', async () => { const caller = createCaller({ user }); const results = await Promise.all( @@ -625,7 +728,14 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { organizationId: null, }) ).worktrees - ).toEqual({ [WORKTREE_ID]: { name: null, defaultTitle: ownershipRow.title, prSession: null } }); + ).toEqual({ + [WORKTREE_ID]: { + name: null, + defaultTitle: ownershipRow.title, + prSession: null, + sessions: [{ sessionId: SESSION_ID, sessionStatus: null, sessionStatusUpdatedAt: null }], + }, + }); expect( ( await caller.worktreeDetails({ @@ -634,11 +744,11 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { }) ).worktrees ).toEqual({ - [orgWorktreeId]: { + [orgWorktreeId]: expect.objectContaining({ name: 'Organization name', defaultTitle: 'Organization first chat', prSession: null, - }, + }), }); for (const organizationId of [null, OTHER_ORGANIZATION_ID]) { await expect( @@ -731,6 +841,105 @@ describe('cliSessionsV2 worktreeDetails and persistent names', () => { }); describe('cliSessionsV2 worktree PR projection', () => { + it.each([null, ORGANIZATION_ID])( + 'refreshes a historical search-only pending PR through the existing tenant batch (organization=%s)', + async organizationId => { + await db + .update(cli_sessions_v2) + .set({ + organization_id: organizationId, + title: 'Historical pending PR', + git_url: GIT_URL, + git_branch: BRANCH, + created_at: '2020-01-01T00:00:00.000Z', + updated_at: '2020-01-01T00:00:00.000Z', + }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(github_branch_pull_requests).values({ + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_user_id: organizationId === null ? USER_ID : null, + owned_by_organization_id: organizationId, + pr_url: PR_URL, + pr_number: 42, + pr_state: 'open', + review_decision_pending: true, + }); + const caller = createCaller({ user }); + const search = await caller.search({ search_string: 'Historical pending', organizationId }); + expect(search.results.map(session => session.session_id)).toEqual([SESSION_ID]); + expect(afterCallbacks).toHaveLength(0); + await expect( + createCaller({ user: otherUser }).worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId, + }) + ).resolves.toEqual({ worktrees: {} }); + expect(afterCallbacks).toHaveLength(0); + + const details = await caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId }); + expect(details.worktrees[WORKTREE_ID].prSession?.associatedPr?.reviewDecisionPending).toBe( + true + ); + expect(mockBatchReviewDecisionFetch).not.toHaveBeenCalled(); + expect(afterCallbacks).toHaveLength(1); + await Promise.all(afterCallbacks.splice(0).map(callback => callback())); + expect(mockBatchReviewDecisionFetch).toHaveBeenCalledTimes(1); + expect(mockBatchReviewDecisionFetch).toHaveBeenCalledWith(true, { + userId: USER_ID, + organizationId, + }); + + await db + .update(github_branch_pull_requests) + .set({ review_decision_pending: false, pr_review_decision: 'approved' }) + .where(eq(github_branch_pull_requests.git_url, GIT_URL)); + const refreshed = await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID], + organizationId, + }); + expect(refreshed.worktrees[WORKTREE_ID].prSession?.associatedPr).toMatchObject({ + reviewDecision: 'approved', + reviewDecisionPending: false, + }); + expect(afterCallbacks).toHaveLength(0); + } + ); + + it('does not schedule a batch for empty, non-pending, or no-longer-authorized worktrees', async () => { + const caller = createCaller({ user }); + await caller.worktreeDetails({ worktreeIds: [], organizationId: null }); + await caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: null }); + expect(afterCallbacks).toHaveLength(0); + await db + .update(cli_sessions_v2) + .set({ organization_id: ORGANIZATION_ID, git_url: GIT_URL, git_branch: BRANCH }) + .where(eq(cli_sessions_v2.session_id, SESSION_ID)); + await db.insert(github_branch_pull_requests).values({ + git_url: GIT_URL, + git_branch: BRANCH, + owned_by_organization_id: ORGANIZATION_ID, + pr_url: PR_URL, + pr_number: 42, + pr_state: 'open', + review_decision_pending: true, + }); + await db + .delete(organization_memberships) + .where( + and( + eq(organization_memberships.organization_id, ORGANIZATION_ID), + eq(organization_memberships.kilo_user_id, USER_ID) + ) + ); + + await expect( + caller.worktreeDetails({ worktreeIds: [WORKTREE_ID], organizationId: ORGANIZATION_ID }) + ).resolves.toEqual({ worktrees: {} }); + expect(afterCallbacks).toHaveLength(0); + expect(mockBatchReviewDecisionFetch).not.toHaveBeenCalled(); + }); + it('selects an older real PR source beyond the 200-session sidebar cap', async () => { await db .update(cli_sessions_v2) diff --git a/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts b/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts index 5e60745d92..691a0a6174 100644 --- a/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts +++ b/packages/session-ingest-contracts/src/cloud-agent-session-scope.ts @@ -6,6 +6,7 @@ export const cloudAgentSessionScopeHeaders = { cloudAgentSessionId: 'X-Kilo-Cloud-Agent-Session', rootKiloSessionId: 'X-Kilo-Root-Session', protocolVersion: 'X-Kilo-Session-Scope-Protocol', + trustedLineage: 'X-Kilo-Trusted-Session-Lineage', } as const; export const containedKiloSessionIdSchema = z.string().regex(/^ses_[A-Za-z0-9]{26}$/); diff --git a/packages/session-ingest-contracts/src/index.ts b/packages/session-ingest-contracts/src/index.ts index 1eb71fce92..36fa90b613 100644 --- a/packages/session-ingest-contracts/src/index.ts +++ b/packages/session-ingest-contracts/src/index.ts @@ -1,5 +1,6 @@ export * from './rpc-contract'; export * from './cloud-agent-session-scope'; +export * from './public-worktree-projection'; export const DEFAULT_SESSION_TITLE_PATTERN = /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; diff --git a/packages/session-ingest-contracts/src/public-worktree-projection.ts b/packages/session-ingest-contracts/src/public-worktree-projection.ts new file mode 100644 index 0000000000..1d326757f1 --- /dev/null +++ b/packages/session-ingest-contracts/src/public-worktree-projection.ts @@ -0,0 +1,51 @@ +export function projectPrivateWorktreePaths( + value: T, + metadata: readonly unknown[], + publicDirectory: string +): T { + const privateDirectories = new Set(); + const addDirectory = (directory: unknown) => { + if ( + typeof directory === 'string' && + /^\/.*\/worktrees\/worktree_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + directory + ) + ) { + privateDirectories.add(directory); + } + }; + + for (const info of metadata) { + if (typeof info !== 'object' || info === null) continue; + if ('directory' in info) addDirectory(info.directory); + if ( + 'role' in info && + info.role === 'assistant' && + 'path' in info && + typeof info.path === 'object' && + info.path !== null + ) { + if ('cwd' in info.path) addDirectory(info.path.cwd); + if ('root' in info.path) addDirectory(info.path.root); + } + } + + const project = (target: object) => { + const entries: [string, unknown][] = Object.entries(target); + for (const [key, current] of entries) { + if (typeof current === 'string') { + let projected = current; + for (const directory of privateDirectories) { + projected = projected.replaceAll(directory, publicDirectory); + } + Reflect.set(target, key, projected); + } else if (typeof current === 'object' && current !== null) { + project(current); + } + } + }; + + const projected = structuredClone(value); + if (privateDirectories.size > 0) project(projected); + return projected; +} diff --git a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts index 91988a5bec..164296e7a1 100644 --- a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts +++ b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts @@ -1,9 +1,49 @@ import { describe, expect, it } from 'vitest'; +import { projectPrivateWorktreePaths } from '@kilocode/session-ingest-contracts'; import { projectPublicStoredMessage } from './public-sdk-projection'; const kiloSessionId = 'ses_12345678901234567890123456'; +describe('projectPrivateWorktreePaths', () => { + const privateDirectory = + '/workspace/private/worktrees/worktree_12345678-1234-4234-8234-123456789abc'; + const publicDirectory = `/cloud-agent/sessions/${kiloSessionId}`; + const value = { + text: `Read ${privateDirectory}/file.txt`, + nested: [null, 1, true, { output: `Created ${privateDirectory}/other.txt` }], + relative: 'src/file.txt', + }; + + it.each([ + { directory: privateDirectory }, + { role: 'assistant', path: { cwd: privateDirectory, root: privateDirectory } }, + ])('projects validated metadata directories recursively without mutating input: %j', info => { + const original = structuredClone(value); + const projected = projectPrivateWorktreePaths(value, [info], publicDirectory); + + expect(projected).toEqual({ + text: `Read ${publicDirectory}/file.txt`, + nested: [null, 1, true, { output: `Created ${publicDirectory}/other.txt` }], + relative: 'src/file.txt', + }); + expect(value).toEqual(original); + }); + + it.each([ + null, + { directory: '/' }, + { directory: '/legacy/checkout' }, + { directory: `${privateDirectory}/file.txt` }, + { directory: `User mentioned ${privateDirectory}` }, + { role: 'user', path: { cwd: privateDirectory } }, + { role: 'assistant', text: privateDirectory }, + { role: 'assistant', path: privateDirectory }, + ])('does not infer a checkout from invalid metadata or free text: %j', info => { + expect(projectPrivateWorktreePaths(value, [info], publicDirectory)).toEqual(value); + }); +}); + describe('projectPublicStoredMessage', () => { it('redacts wrapper-local file URLs from typed stored file parts while preserving data URLs', () => { const projected = projectPublicStoredMessage( @@ -111,15 +151,27 @@ describe('projectPublicStoredMessage', () => { file: `${privateDirectory}/shared.txt`, patch: `--- ${privateDirectory}/shared.txt\n+file contents`, }, + outputs: [ + `${privateDirectory}/nested.txt`, + { output: `Created ${privateDirectory}/other.txt`, relative: 'src/relative.ts' }, + ], }, time: { start: 100, end: 101 }, }, }, + { + id: 'prt_grouped_text', + sessionID: kiloSessionId, + messageID: 'msg_grouped', + type: 'text', + text: `Updated ${privateDirectory}/shared.txt and src/relative.ts`, + }, ], }, kiloSessionId ); + expect(projected.info).toMatchObject({ path: { cwd: publicDirectory, root: publicDirectory } }); expect(JSON.stringify(projected)).not.toContain(privateDirectory); expect(projected.parts).toMatchObject([ { @@ -133,12 +185,68 @@ describe('projectPublicStoredMessage', () => { file: `${publicDirectory}/shared.txt`, patch: `--- ${publicDirectory}/shared.txt\n+file contents`, }, + outputs: [ + `${publicDirectory}/nested.txt`, + { output: `Created ${publicDirectory}/other.txt`, relative: 'src/relative.ts' }, + ], }, }, }, + { text: `Updated ${publicDirectory}/shared.txt and src/relative.ts` }, ]); }); + it('does not discover private prefixes from free text in legacy or user messages', () => { + const mentionedDirectory = + '/workspace/private/worktrees/worktree_12345678-1234-4234-8234-123456789abc'; + const text = `Keep ${mentionedDirectory}/example.txt and src/relative.ts as written`; + const projected = projectPublicStoredMessage( + { + info: { + id: 'msg_legacy', + sessionID: kiloSessionId, + role: 'assistant', + time: { created: 100 }, + parentID: 'msg_user', + modelID: 'fake', + providerID: 'kilo', + mode: 'code', + agent: 'code', + path: { cwd: '/legacy/checkout', root: '/legacy/checkout' }, + cost: 0, + tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [ + { + id: 'prt_legacy_text', + sessionID: kiloSessionId, + messageID: 'msg_legacy', + type: 'text', + text, + }, + ], + }, + kiloSessionId + ); + + expect(projected.parts).toMatchObject([{ text }]); + const userMessage = projectPublicStoredMessage( + { + info: { + id: 'msg_user', + sessionID: kiloSessionId, + role: 'user', + time: { created: 100 }, + agent: 'code', + model: { providerID: 'kilo', modelID: 'fake' }, + }, + parts: [{ ...projected.parts[0], messageID: 'msg_user' }], + }, + kiloSessionId + ); + expect(userMessage.parts).toMatchObject([{ text }]); + }); + it('preserves owner-visible typed resource file URIs', () => { const projected = projectPublicStoredMessage( { diff --git a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts index a9e15a5d5b..fde977b9b8 100644 --- a/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts +++ b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.ts @@ -1,3 +1,4 @@ +import { projectPrivateWorktreePaths } from '@kilocode/session-ingest-contracts'; import type { CloudAgentRootSessionSummary, KiloSdkAssistantMessage, @@ -167,60 +168,15 @@ export function projectPublicPart(part: KiloSdkPart, kiloSessionId: string): Kil return part; } -function privateWorktreeDirectory(message: KiloSdkStoredMessage): string | undefined { - if (message.info.role !== 'assistant') return undefined; - return [message.info.path.cwd, message.info.path.root].find(directory => - /^\/.*\/worktrees\/worktree_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( - directory - ) - ); -} - -function projectPrivateWorktreeValue( - value: unknown, - privateDirectory: string, - publicDirectory: string -): unknown { - if (typeof value === 'string') return value.replaceAll(privateDirectory, publicDirectory); - if (Array.isArray(value)) { - return value.map(item => projectPrivateWorktreeValue(item, privateDirectory, publicDirectory)); - } - if (typeof value === 'object' && value !== null) { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [ - key, - projectPrivateWorktreeValue(item, privateDirectory, publicDirectory), - ]) - ); - } - return value; -} - -function projectPrivateWorktreeMessage( - message: KiloSdkStoredMessage, - privateDirectory: string, - kiloSessionId: string -): KiloSdkStoredMessage { - const projected = { ...message }; - const publicDirectory = publicCloudAgentDirectory(kiloSessionId); - for (const [key, value] of Object.entries(message)) { - Reflect.set( - projected, - key, - projectPrivateWorktreeValue(value, privateDirectory, publicDirectory) - ); - } - return projected; -} - export function projectPublicStoredMessage( message: KiloSdkStoredMessage, kiloSessionId: string ): KiloSdkStoredMessage { - const privateDirectory = privateWorktreeDirectory(message); - const sanitized = privateDirectory - ? projectPrivateWorktreeMessage(message, privateDirectory, kiloSessionId) - : message; + const sanitized = projectPrivateWorktreePaths( + message, + [message.info], + publicCloudAgentDirectory(kiloSessionId) + ); return { info: projectPublicMessageInfo(sanitized.info, kiloSessionId), parts: sanitized.parts.map(part => projectPublicPart(part, kiloSessionId)), diff --git a/services/cloud-agent-next/src/persistence/SandboxControl.ts b/services/cloud-agent-next/src/persistence/SandboxControl.ts index 28cf28a614..1d99c991a6 100644 --- a/services/cloud-agent-next/src/persistence/SandboxControl.ts +++ b/services/cloud-agent-next/src/persistence/SandboxControl.ts @@ -1631,6 +1631,7 @@ export class SandboxControl extends DurableObject { runtime, }); await this.ctx.storage.put(PROVIDER_LOCATOR_KEY, this.vercelLocator); + this.provider = this.createProviderAdapter('vercel', next); } await this.persistPhysicalState(current, next, 'demand'); return next; diff --git a/services/cloud-agent-next/src/router/handlers/session-management.ts b/services/cloud-agent-next/src/router/handlers/session-management.ts index 695a1c7309..8773982d19 100644 --- a/services/cloud-agent-next/src/router/handlers/session-management.ts +++ b/services/cloud-agent-next/src/router/handlers/session-management.ts @@ -12,7 +12,8 @@ import { fetchSessionMetadata, } from '../../session-service.js'; import { withDORetry } from '../../utils/do-retry.js'; -import { resolveSessionStub } from '../../sandbox-session/session-stub.js'; +import { getSandboxSessionStub, resolveSessionStub } from '../../sandbox-session/session-stub.js'; +import { sessionPlaneFromId } from '../../session-plane.js'; import { protectedProcedure, publicProcedure, internalApiProtectedProcedure } from '../auth.js'; import { sessionIdSchema, @@ -78,7 +79,15 @@ async function deleteSessionResources( try { const metadata = await fetchSessionMetadata(env, userId, sessionId); - if (!metadata) { + const retainedRuntime = + !metadata && sessionPlaneFromId(sessionId) === 'control' + ? await withDORetry( + () => getSandboxSessionStub(env, userId, sessionId), + stub => stub.getRuntimeLocation(), + 'getRuntimeLocation' + ) + : null; + if (!metadata && !retainedRuntime) { logger.info('Session not found or already deleted'); return { success: true, message: 'Session not found or already deleted' }; } diff --git a/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts index 173d7a04f2..7e9088db8d 100644 --- a/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts @@ -128,14 +128,61 @@ describe('physical sandbox ownership resolution', () => { await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(false); }); - it('does not report unknown history as confirmed sharing', async () => { + it('keeps expired legacy ownership non-exclusive so scoped cleanup can proceed without destroying the sandbox', async () => { const f = fixture(); f.getStoredMetadata.mockResolvedValue(null); + await expect(resolveSandboxExclusivity(f.env, f.params)).resolves.toBe(false); + expect(mocks.legacySession).toHaveBeenCalledWith(f.env, userId, legacyId); + }); + + it('still validates later owners after encountering missing historical ownership', async () => { + const f = fixture(true); + f.getStoredMetadata.mockResolvedValue(null); + f.ownership.mockResolvedValue({ + kind: 'unresolved', + owners: [ + { + worktreeId: null, + organizationId: null, + sessions: [{ sessionId: kiloId, cloudAgentSessionId: legacyId }], + }, + { + worktreeId: otherWorktreeId, + organizationId: null, + sessions: [{ sessionId: kiloId, cloudAgentSessionId: controlId }], + }, + ], + }); + f.getRuntimeLocation.mockResolvedValue({ + ...(await f.getRuntimeLocation()), + kiloUserId: 'another-owner', + }); await expect(resolveSandboxExclusivity(f.env, f.params)).rejects.toThrow( 'worktree_runtime_history_unavailable' ); + expect(f.getStoredMetadata).toHaveBeenCalledOnce(); }); + it('rejects corrupt historical metadata rather than treating it as expired', async () => { + const f = fixture(); + f.getStoredMetadata.mockResolvedValue({ + ...f.metadata, + workspace: { sandboxId: 123 }, + }); + await expect(resolveSandboxExclusivity(f.env, f.params)).rejects.toThrow(); + }); + + it.each(['ownership', 'getStoredMetadata'] as const)( + 'propagates real %s I/O failures instead of allowing cleanup', + async method => { + const f = fixture(); + f[method].mockRejectedValue(new Error('storage unavailable')); + await expect(resolveSandboxExclusivity(f.env, f.params)).rejects.toThrow( + 'storage unavailable' + ); + } + ); + it('rejects conflicting ownership metadata instead of inferring a route', async () => { const f = fixture(true); f.getRuntimeLocation.mockResolvedValue({ diff --git a/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts index 314fa417a2..dafea50b44 100644 --- a/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts +++ b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts @@ -88,6 +88,5 @@ export async function resolveSandboxExclusivity( } if (!located) unavailable = true; } - if (unavailable) throw new Error(WORKTREE_RUNTIME_HISTORY_UNAVAILABLE); - return true; + return !unavailable; } diff --git a/services/cloud-agent-next/src/sandbox-outbound.test.ts b/services/cloud-agent-next/src/sandbox-outbound.test.ts index 7c8c2b05cd..b2d6e27787 100644 --- a/services/cloud-agent-next/src/sandbox-outbound.test.ts +++ b/services/cloud-agent-next/src/sandbox-outbound.test.ts @@ -1,5 +1,6 @@ import { Buffer } from 'node:buffer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { cloudAgentSessionScopeHeaders } from '@kilocode/session-ingest-contracts'; const sdk = vi.hoisted(() => { class StockSandbox {} @@ -1075,6 +1076,44 @@ describe('handleManagedScmOutbound Kilo authorization', () => { }); }); + it('strips forged trusted lineage from proxied child bootstrap while preserving the body', async () => { + const root = 'ses_12345678901234567890123456'; + const child = 'ses_abcdefghijklmnopqrstuvwxyz'; + const body = { sessionId: child, parentSessionId: root }; + const redeemKiloSessionCapability = vi.fn().mockResolvedValue({ + success: true, + authorization: REDEEMED_KILO_AUTHORIZATION, + routeClass: 'session_ingest', + sessionIngestScope: { cloudAgentSessionId: 'cloud-agent-session-1', rootKiloSessionId: root }, + }); + const scopedFetch = vi.fn(async (_request: Request) => new Response(null, { status: 200 })); + const publicFetch = vi.fn(); + vi.stubGlobal('fetch', publicFetch); + + const response = await handleOutbound( + new Request('https://ingest.kilosessions.ai/api/session', { + method: 'POST', + headers: { + Authorization: `Bearer ${KILO_CAPABILITY}`, + 'Content-Type': 'application/json', + [cloudAgentSessionScopeHeaders.trustedLineage]: '1', + }, + body: JSON.stringify(body), + }), + createEnv(vi.fn(), vi.fn(), redeemKiloSessionCapability, undefined, scopedFetch) + ); + + expect(response.status).toBe(200); + expect(publicFetch).not.toHaveBeenCalled(); + expect(scopedFetch).toHaveBeenCalledOnce(); + const forwarded = scopedFetch.mock.calls[0]?.[0] as Request; + expect(new URL(forwarded.url).pathname).toBe('/internal/cloud-agent/v1/session'); + expect(forwarded.headers.get('X-Internal-Secret')).toBe('trusted-internal-secret'); + expect(forwarded.headers.get(cloudAgentSessionScopeHeaders.rootKiloSessionId)).toBe(root); + expect(forwarded.headers.get(cloudAgentSessionScopeHeaders.trustedLineage)).toBeNull(); + await expect(forwarded.json()).resolves.toEqual(body); + }); + it('routes session-scoped ingest through the internal binding and treats 404 as terminal', async () => { const redeemKiloSessionCapability = vi.fn().mockResolvedValue({ success: true, diff --git a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts index a80e0ed3b6..d641ebb579 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts @@ -120,6 +120,7 @@ describe('publishControlPlaneSessionIngest', () => { expect(request.headers.get(cloudAgentSessionScopeHeaders.cloudAgentSessionId)).toBeNull(); expect(request.headers.get(cloudAgentSessionScopeHeaders.rootKiloSessionId)).toBeNull(); expect(request.headers.get(cloudAgentSessionScopeHeaders.protocolVersion)).toBeNull(); + expect(request.headers.get(cloudAgentSessionScopeHeaders.trustedLineage)).toBeNull(); expect(await request.json()).toEqual({ data: [{ type: 'message', data: { id: 'msg_1' } }] }); }); @@ -165,6 +166,7 @@ describe('publishControlPlaneSessionIngest', () => { expect(request.headers.get(cloudAgentSessionScopeHeaders.protocolVersion)).toBe( cloudAgentSessionScopeProtocolVersion ); + expect(request.headers.get(cloudAgentSessionScopeHeaders.trustedLineage)).toBeNull(); } expect(await createRequest.json()).toEqual({ sessionId: child }); expect(await ingestRequest.json()).toEqual({ @@ -194,6 +196,7 @@ describe('publishControlPlaneSessionIngest', () => { }), }); expect(requests).toHaveLength(2); + expect(requests[0].headers.get(cloudAgentSessionScopeHeaders.trustedLineage)).toBe('1'); expect(await requests[0].json()).toEqual({ sessionId: child, parentSessionId: root }); }); diff --git a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts index 60e26db103..dbc2313c43 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.ts @@ -132,6 +132,7 @@ export async function publishControlPlaneSessionIngest(params: { cloudAgentSessionScopeHeaders.protocolVersion, cloudAgentSessionScopeProtocolVersion ); + if (lineage) headers.set(cloudAgentSessionScopeHeaders.trustedLineage, '1'); } const body = JSON.stringify({ data: params.items }); diff --git a/services/cloud-agent-next/test/integration/sandbox-control.test.ts b/services/cloud-agent-next/test/integration/sandbox-control.test.ts index 6f8e900d94..70a5b9d7f0 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control.test.ts @@ -19,6 +19,10 @@ import type { VercelSandboxSession, } from '../../src/agent-sandbox/vercel/vercel-sandbox-rest-client.js'; import { parseVercelSandboxRuntimeConfig } from '../../src/agent-sandbox/vercel/vercel-runtime-config.js'; +import { TRPCError } from '@trpc/server'; +import { router } from '../../src/router/auth.js'; +import { createSessionManagementHandlers } from '../../src/router/handlers/session-management.js'; +import { requireCurrentSessionAccess } from '../../src/session-access.js'; import type { AgentSelectionOverride, SubmittedSessionMessageRequest, @@ -57,6 +61,7 @@ import { type DeadlineId, type DeadlineTable, } from '../../src/sandbox-control/deadlines.js'; +import type { VercelProviderLocator } from '../../src/sandbox-control/vercel-provider.js'; import { loadDeadlines, loadRouteTable, @@ -100,6 +105,10 @@ import { type SessionAttachPayload, } from '../../src/shared/sandbox-control-protocol.js'; +vi.mock('../../src/session-access.js', () => ({ + requireCurrentSessionAccess: vi.fn(), +})); + vi.mock('../../src/db/pg.js', () => ({ getPgDb: () => { throw new Error('PostgreSQL is not used by sandbox control integration tests'); @@ -7906,149 +7915,150 @@ describe('SandboxSession control-plane regressions', () => { } }); - it('aborts and detaches a deleted session without stopping active sibling work', async () => { - const userId = 'user_control_delete'; - const sessionId = GRANT_SESSION_ID; - const siblingSessionId = SECOND_GRANT_SESSION_ID; - const controlId = 'usr-de1e7ed'; - const credential = generateSandboxCredential(); - const control = env.SANDBOX_CONTROL.getByName(controlId); - const { provider } = await installProvider(control, cloudflareRef(controlId)); - await runInDurableObject(control, async (instance, state) => { - await instance.initializeOwner(userId); - await seedRunningCloudflare(instance); - await instance.setWrapperCredentialHash(await hashSandboxCredential(credential)); - await attachGrantedSession(instance, state, { - sessionId, - kiloSessionId: ROOT_ID, - directory: '/workspace/deleted', - ownerId: userId, - }); - await attachGrantedSession(instance, state, { - sessionId: siblingSessionId, - kiloSessionId: SECOND_ROOT_ID, - directory: '/workspace/sibling', - ownerId: userId, - }); - const routes = await loadRouteTable(state.storage); - for (const kiloSessionId of [ROOT_ID, SECOND_ROOT_ID]) { - applyReportedSessionState( - routes, - kiloSessionId, - { state: 'active', idleForMs: 0 }, - Date.now() + it.each(['accepted', 'failed', 'accepted_overdue'] as const)( + 'detaches a deleted root with %s work while preserving its sibling and message-scoped interrupts', + async messageState => { + const userId = 'user_control_delete'; + const sessionId = GRANT_SESSION_ID; + const siblingSessionId = SECOND_GRANT_SESSION_ID; + const controlId = `usr-${crypto.randomUUID().replaceAll('-', '')}` as const; + const wrapperInstanceId = crypto.randomUUID(); + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(controlId); + const { provider } = await installProvider(control, cloudflareRef(controlId)); + await runInDurableObject(control, async (instance, state) => { + await instance.initializeOwner(userId); + await seedRunningCloudflare(instance); + await instance.setWrapperCredentialHash(await hashSandboxCredential(credential)); + await attachGrantedSession(instance, state, groupedRoute(sessionId, ROOT_ID, userId)); + await attachGrantedSession( + instance, + state, + groupedRoute(siblingSessionId, SECOND_ROOT_ID, userId) ); - } - await saveRouteTable(state.storage, routes); - }); + const routes = await loadRouteTable(state.storage); + for (const kiloSessionId of [ROOT_ID, SECOND_ROOT_ID]) { + applyReportedSessionState( + routes, + kiloSessionId, + { state: 'active', idleForMs: 0 }, + Date.now() + ); + } + await saveRouteTable(state.storage, routes); + }); - const session = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); - await runInDurableObject(session, async (instance, state) => { - await instance.registerSession({ - identity: { sessionId, userId }, - auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, - agent: { mode: 'code', model: 'test' }, - workspace: { sandboxId: controlId, workspacePath: '/workspace/deleted' }, + const session = env.SANDBOX_SESSION.getByName(`${userId}:${sessionId}`); + await runInDurableObject(session, async (instance, state) => { + await instance.registerSession({ + identity: { sessionId, userId }, + auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, + agent: { mode: 'code', model: 'test' }, + workspace: { + sandboxId: controlId, + workspacePath: '/workspace/shared', + worktreeId: WORKTREE_ID, + }, + }); + const acceptedAt = messageState === 'accepted_overdue' ? 1 : Date.now(); + await state.storage.put('session_messages', [ + { + messageId: 'msg_deleted', + state: messageState === 'accepted' ? 'accepted' : 'failed', + ...(messageState === 'accepted_overdue' ? { failedReason: 'accepted_overdue' } : {}), + wrapperInstanceId, + acceptedAt, + lastActivityAt: acceptedAt, + } satisfies SessionMessageRecord, + ]); + if (messageState !== 'accepted') { + await expect(instance.getCurrentMessageWork()).resolves.toBeNull(); + } }); - await state.storage.put('session_messages', [ - { - messageId: 'msg_deleted', - state: 'accepted', - acceptedAt: 1, - lastActivityAt: 1, - } satisfies SessionMessageRecord, - ]); - }); - const ws = await connect(credential, controlId); - await completeHello(ws, 'hello-shared-delete', { - providerInstanceId: cloudflareRef(controlId), - wrapperInstanceId: crypto.randomUUID(), - }); - signalWrapperReady(ws); - await vi.waitFor(async () => { - await expect(control.getStatus()).resolves.toMatchObject({ connection: 'ready' }); - }); - ws.send( - JSON.stringify({ - type: 'event', - event: 'sandbox.heartbeat', - payload: { - state: 'active', - kilo: { ready: true }, - sessions: [ROOT_ID, SECOND_ROOT_ID].map(kiloSessionId => ({ - kiloSessionId, - state: 'active', - idleForMs: 0, - })), - }, - }) - ); - await vi.waitFor(async () => { - await runInDurableObject(control, async (_instance, state) => { - expect((await loadDeadlines(state.storage)).idleStop).toBeUndefined(); + const ws = await connect(credential, controlId); + await completeHello(ws, 'hello-shared-delete', { + providerInstanceId: cloudflareRef(controlId), + wrapperInstanceId, + }); + signalWrapperReady(ws); + await vi.waitFor(async () => { + await expect(control.getStatus()).resolves.toMatchObject({ connection: 'ready' }); }); - }); - const abortRequests: { - operation: string; - session: { sessionId: string; kiloSessionId: string; directory: string }; - }[] = []; - ws.addEventListener('message', event => { - const request = JSON.parse(String(event.data)) as { - operation?: string; - requestId: string; - session: { sessionId: string; kiloSessionId: string; directory: string }; - }; - if (request.operation !== 'session.abort' && request.operation !== 'session.detach') return; - abortRequests.push({ operation: request.operation, session: request.session }); ws.send( JSON.stringify({ - type: 'response', - requestId: request.requestId, - ok: true, - result: - request.operation === 'session.abort' ? { status: 'aborted' } : { detached: true }, + type: 'event', + event: 'sandbox.heartbeat', + payload: { + state: 'active', + kilo: { ready: true }, + sessions: [ROOT_ID, SECOND_ROOT_ID].map(kiloSessionId => ({ + kiloSessionId, + state: 'active', + idleForMs: 0, + })), + }, }) ); - }); + await vi.waitFor(async () => { + await runInDurableObject(control, async (_instance, state) => { + expect((await loadDeadlines(state.storage)).idleStop).toBeUndefined(); + }); + }); + const lifecycleRequests: { + operation: string; + session: { sessionId: string; kiloSessionId: string; directory: string }; + }[] = []; + ws.addEventListener('message', event => { + const request = JSON.parse(String(event.data)) as { + operation?: string; + requestId: string; + session: { sessionId: string; kiloSessionId: string; directory: string }; + }; + if (request.operation !== 'session.abort' && request.operation !== 'session.detach') return; + lifecycleRequests.push({ operation: request.operation, session: request.session }); + ws.send( + JSON.stringify({ + type: 'response', + requestId: request.requestId, + ok: true, + result: + request.operation === 'session.abort' ? { status: 'aborted' } : { detached: true }, + }) + ); + }); - await runInDurableObject(session, instance => instance.deleteSession()); - await runInDurableObject(control, async (instance, state) => { - await expect(instance.listRoutes()).resolves.toEqual([ - expect.objectContaining({ - sessionId: siblingSessionId, - kiloSessionId: SECOND_ROOT_ID, - lastState: 'active', - }), - ]); - await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ state: 'running' }); - expect((await loadDeadlines(state.storage)).idleStop).toBeUndefined(); - }); - await runInDurableObject(session, async instance => { - await expect(instance.getMetadata()).resolves.toBeNull(); - }); - expect(abortRequests).toEqual([ - { - operation: 'session.abort', - session: { - sessionId, - kiloSessionId: ROOT_ID, - directory: '/workspace/deleted', - }, - }, - { - operation: 'session.detach', - session: { - sessionId, - kiloSessionId: ROOT_ID, - directory: '/workspace/deleted', - }, - }, - ]); - expect(provider.stop).not.toHaveBeenCalled(); - expect(provider.create).not.toHaveBeenCalled(); - ws.close(); - }); + await runInDurableObject(session, instance => instance.deleteSession()); + await runInDurableObject(control, async (instance, state) => { + await expect(instance.listRoutes()).resolves.toEqual([ + expect.objectContaining({ + sessionId: siblingSessionId, + kiloSessionId: SECOND_ROOT_ID, + lastState: 'active', + }), + ]); + await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ state: 'running' }); + expect((await loadDeadlines(state.storage)).idleStop).toBeUndefined(); + expect( + (await loadSessionCredentialGrants(state.storage)).flatMap(grant => grant.members) + ).toEqual([{ sessionId: siblingSessionId, kiloSessionId: SECOND_ROOT_ID }]); + }); + await runInDurableObject(session, async instance => { + await expect(instance.getMetadata()).resolves.toBeNull(); + }); + const operations = + messageState === 'accepted' ? ['session.abort', 'session.detach'] : ['session.detach']; + expect(lifecycleRequests).toEqual( + operations.map(operation => ({ + operation, + session: { sessionId, kiloSessionId: ROOT_ID, directory: '/workspace/shared' }, + })) + ); + expect(provider.stop).not.toHaveBeenCalled(); + expect(provider.create).not.toHaveBeenCalled(); + ws.close(); + } + ); it('preserves repository branches and structured initial and follow-up command turns', async () => { const userId = 'user_control_commands' as const; @@ -10573,6 +10583,301 @@ describe('SandboxSession root-scoped reconnect sync', () => { }); }); +describe('SandboxControl Vercel runtime identity', () => { + const ownerId = 'user_vercel_rotation'; + const originalLocator: VercelProviderLocator = { + teamId: 'team_original', + projectId: 'project_original', + snapshotId: 'snapshot_original', + runtimeBuildId: 'build_original', + runtime: 'node24', + }; + const currentLocator: VercelProviderLocator = { + teamId: 'team_current', + projectId: 'project_current', + snapshotId: 'snapshot_current', + runtimeBuildId: 'build_current', + runtime: 'node24', + }; + + function runtimeEnv(locator: VercelProviderLocator) { + return { + VERCEL_TOKEN: 'test-vercel-token', + VERCEL_TEAM_ID: locator.teamId, + VERCEL_PROJECT_ID: locator.projectId, + VERCEL_SANDBOX_SNAPSHOT_ID: locator.snapshotId, + VERCEL_SANDBOX_RUNTIME_BUILD_ID: locator.runtimeBuildId, + VERCEL_SANDBOX_RUNTIME: locator.runtime, + VERCEL_SANDBOX_INITIAL_TIMEOUT_MS: '300000', + VERCEL_SANDBOX_EXTEND_DURATION_MS: '120000', + WORKER_URL: 'https://worker.test', + KILOCODE_BACKEND_BASE_URL: CONTAINMENT_TARGETS.backendBaseUrl, + KILO_OPENROUTER_BASE: CONTAINMENT_TARGETS.providerBaseUrl, + KILO_SESSION_INGEST_URL: CONTAINMENT_TARGETS.sessionIngestBaseUrl, + GIT_TOKEN_SERVICE: fakeCredentialBroker().binding, + }; + } + + function providerEnvelope(name: string, locator: VercelProviderLocator, intentId: string) { + return { + sandbox: { + name, + currentSessionId: 'vsess_1', + status: 'running', + persistent: false, + createdAt: 1, + updatedAt: 1, + tags: { + 'kilo-managed-by': 'cloud-agent-session', + 'kilo-create-operation': intentId, + 'kilo-runtime-build': locator.runtimeBuildId, + }, + }, + session: { + id: 'vsess_1', + sourceSandboxName: name, + projectId: locator.projectId, + sourceSnapshotId: locator.snapshotId, + runtime: locator.runtime, + status: 'running', + memory: 2048, + vcpus: 2, + region: 'iad1', + timeout: 300000, + requestedAt: 1, + cwd: '/', + createdAt: 1, + updatedAt: 1, + }, + routes: [], + }; + } + + async function seedRuntime(physicalState: 'stopped' | 'running' | 'creating' | 'failed') { + const name = `ses-${crypto.randomUUID().replaceAll('-', '')}` as const; + const stub = env.SANDBOX_CONTROL.getByName(name); + await env.SANDBOX_SESSION.getByName(`${ownerId}:${GRANT_SESSION_ID}`).registerSession({ + identity: { sessionId: GRANT_SESSION_ID, userId: ownerId }, + auth: { kiloSessionId: ROOT_ID, kilocodeToken: KILO_TOKEN }, + agent: {}, + workspace: { + sandboxId: name, + sandboxProvider: 'vercel', + workspacePath: '/workspace/contained', + }, + }); + await runInDurableObject(stub, async (instance, state) => { + const originalEnv = instance['env']; + Object.assign(instance, { env: { ...originalEnv, ...runtimeEnv(originalLocator) } }); + try { + await instance.ensureReady({ + ownerId, + sessionId: GRANT_SESSION_ID, + provider: 'vercel', + allowCreate: false, + }); + const physical = await instance.claimCreate( + 'intent_original', + false, + name, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + if (!physical.createIntent) throw new Error('Missing persisted create intent'); + await savePhysicalRecord(state.storage, { + ...physical, + createIntent: { + ...physical.createIntent, + createdAt: Date.now() - DEADLINE_MS.createSettle - 1, + }, + }); + if (physicalState !== 'creating') { + await instance.confirmInstance( + encodeVercelProviderRef({ sandboxName: name, sessionId: 'vsess_1' }) + ); + if (physicalState === 'stopped') { + await instance.beginStop('idle'); + await instance.confirmStopped(); + } else if (physicalState === 'failed') { + await instance.markFailed(); + } + } + expect(await state.storage.get('provider_locator')).toEqual(originalLocator); + } finally { + Object.assign(instance, { env: originalEnv }); + } + }); + await abortAllDurableObjects(); + return { name, stub: env.SANDBOX_CONTROL.getByName(name) }; + } + + it.each(['stopped', 'failed'] as const)( + 'creates from the current image after a %s runtime and config rotation', + async physicalState => { + const { name, stub } = await seedRuntime(physicalState); + await runInDurableObject(stub, async (instance, state) => { + const originalEnv = instance['env']; + Object.assign(instance, { env: { ...originalEnv, ...runtimeEnv(currentLocator) } }); + const requests: Request[] = []; + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const request = new Request(input, init); + requests.push(request); + const pathname = new URL(request.url).pathname; + if (pathname.endsWith('/stop')) { + return Response.json({ + session: { + ...providerEnvelope(name, originalLocator, 'intent_original').session, + status: 'stopped', + }, + }); + } + if (pathname === '/v2/sandboxes') { + const physical = await instance.getPhysicalRecord(); + expect(physical.state).toBe('creating'); + expect(await state.storage.get('provider_locator')).toEqual(currentLocator); + if (!physical.createIntent?.allocationName) + throw new Error('Missing persisted create intent'); + return Response.json( + providerEnvelope( + physical.createIntent.allocationName, + currentLocator, + physical.createIntent.intentId + ) + ); + } + if (pathname.endsWith('/network-policy')) { + const physical = await instance.getPhysicalRecord(); + if (!physical.createIntent?.allocationName) + throw new Error('Missing persisted create intent'); + return Response.json({ + session: providerEnvelope( + physical.createIntent.allocationName, + currentLocator, + physical.createIntent.intentId + ).session, + }); + } + if (pathname.endsWith('/cmd')) { + return Response.json({ + command: { + id: 'cmd_1', + name: 'sh', + args: [], + cwd: '/', + sessionId: 'vsess_1', + exitCode: null, + startedAt: 1, + }, + }); + } + throw new Error(`Unexpected provider request: ${pathname}`); + }); + try { + await expect( + instance.ensureReady({ + ownerId, + sessionId: GRANT_SESSION_ID, + provider: 'vercel', + allowCreate: true, + }) + ).resolves.toMatchObject({ physical: 'running' }); + const create = requests.find( + request => new URL(request.url).pathname === '/v2/sandboxes' + ); + if (!create) throw new Error('Missing provider create request'); + expect(new URL(create.url).searchParams.get('teamId')).toBe(currentLocator.teamId); + await expect(create.json()).resolves.toMatchObject({ + projectId: currentLocator.projectId, + source: { type: 'snapshot', snapshotId: currentLocator.snapshotId }, + runtime: currentLocator.runtime, + tags: { 'kilo-runtime-build': currentLocator.runtimeBuildId }, + }); + expect(await state.storage.get('provider_locator')).toEqual(currentLocator); + if (physicalState === 'failed') { + expect(new URL(requests[0].url).pathname).toBe('/v2/sandboxes/sessions/vsess_1/stop'); + expect(new URL(requests[0].url).searchParams.get('teamId')).toBe( + originalLocator.teamId + ); + } + } finally { + fetchMock.mockRestore(); + Object.assign(instance, { env: originalEnv }); + } + }); + } + ); + + it.each(['running', 'creating'] as const)( + 'preserves the original locator for %s runtime recovery and cleanup after config rotation', + async physicalState => { + const { name, stub } = await seedRuntime(physicalState); + await runInDurableObject(stub, async (instance, state) => { + const originalEnv = instance['env']; + Object.assign(instance, { env: { ...originalEnv, ...runtimeEnv(currentLocator) } }); + const requests: URL[] = []; + const originalEnvelope = providerEnvelope(name, originalLocator, 'intent_original'); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + const url = new URL(new Request(input, init).url); + requests.push(url); + if (url.pathname === `/v2/sandboxes/${name}`) { + return Response.json({ ...originalEnvelope, resumed: false }); + } + if (url.pathname === '/v2/sandboxes/sessions/vsess_1/stop') { + return Response.json({ session: { ...originalEnvelope.session, status: 'stopped' } }); + } + if (url.pathname === '/v2/sandboxes/sessions/vsess_1/network-policy') { + return Response.json({ session: originalEnvelope.session }); + } + throw new Error(`Unexpected provider request: ${url.pathname}`); + }); + try { + await expect( + instance.ensureReady({ + ownerId, + sessionId: GRANT_SESSION_ID, + provider: 'vercel', + allowCreate: false, + }) + ).resolves.toMatchObject({ physical: physicalState }); + expect(requests.map(url => url.pathname)).toEqual( + physicalState === 'running' ? ['/v2/sandboxes/sessions/vsess_1/network-policy'] : [] + ); + await expect(instance.claimCreate('intent_replacement')).rejects.toThrow( + `claimCreate from ${physicalState}` + ); + expect(await state.storage.get('provider_locator')).toEqual(originalLocator); + if (physicalState === 'creating') { + const physical = await instance.getPhysicalRecord(); + const provider = instance['provider']; + if (!physical.createIntent) throw new Error('Missing create recovery'); + const resolved = await provider.observe(physical.providerRef, physical.createIntent); + if (!resolved.providerRef) throw new Error('Original creation was not recovered'); + expect(resolved.status).toBe('active'); + await instance.confirmInstance(resolved.providerRef); + expect(requests[0].searchParams.get('projectId')).toBe(originalLocator.projectId); + expect(requests[0].searchParams.get('resume')).toBe('false'); + } + await saveDeadlines(state.storage, { idleStop: Date.now() - 1 }); + await instance.alarm(); + await expect(instance.getPhysicalRecord()).resolves.toMatchObject({ state: 'stopped' }); + expect(await state.storage.get('provider_locator')).toEqual(originalLocator); + expect(requests.map(url => url.pathname)).toEqual([ + ...(physicalState === 'creating' + ? [`/v2/sandboxes/${name}`] + : ['/v2/sandboxes/sessions/vsess_1/network-policy']), + '/v2/sandboxes/sessions/vsess_1/stop', + ]); + expect( + requests.every(url => url.searchParams.get('teamId') === originalLocator.teamId) + ).toBe(true); + } finally { + fetchMock.mockRestore(); + Object.assign(instance, { env: originalEnv }); + } + }); + } + ); +}); + describe('SandboxSession targeted deletion', () => { it('detaches the deleted root before removing metadata and preserves sibling state', async () => { const ownerId = 'user_grouped_delete'; @@ -10631,9 +10936,12 @@ describe('SandboxSession targeted deletion', () => { }); }); - it('retains cleanup metadata and the route but revokes credentials when live wrapper detach fails', async () => { + it('revokes credentials on failed detach and resumes public deletion without exposing fenced metadata or deleting sibling state', async () => { const ownerId = 'user_grouped_delete_failed'; const sessionId = GRANT_SESSION_ID; + const siblingSessionId = SECOND_GRANT_SESSION_ID; + const kiloSessionId = ROOT_ID; + const siblingKiloSessionId = SECOND_ROOT_ID; const targetSandboxId = 'usr-abcdef123406'; const credential = generateSandboxCredential(); const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); @@ -10643,51 +10951,179 @@ describe('SandboxSession targeted deletion', () => { await attachGrantedSession( instance, instance['ctx'], - groupedRoute(sessionId, ROOT_ID, ownerId) + groupedRoute(sessionId, kiloSessionId, ownerId) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(siblingSessionId, siblingKiloSessionId, ownerId) ); }); const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); - await runInDurableObject(session, instance => - instance.registerSession( + const sibling = env.SANDBOX_SESSION.getByName(`${ownerId}:${siblingSessionId}`); + for (const [stub, rootSessionId, rootKiloSessionId] of [ + [session, sessionId, kiloSessionId], + [sibling, siblingSessionId, siblingKiloSessionId], + ] as const) { + await stub.registerSession( groupedRegistration({ ownerId, - sessionId, - kiloSessionId: ROOT_ID, + sessionId: rootSessionId, + kiloSessionId: rootKiloSessionId, sandboxId: targetSandboxId, }) + ); + await stub.receiveSandboxControlEvent({ + identity: { directory: '/workspace/shared', kiloSessionId: rootKiloSessionId }, + payload: { + type: 'message.updated', + properties: { info: { id: INITIAL_MESSAGE_ID, sessionID: rootKiloSessionId } }, + }, + }); + } + const siblingState = await runInDurableObject(sibling, async (_instance, state) => ({ + metadata: await state.storage.get('session_metadata'), + events: persistedSessionEvents(state, ['kilocode']), + })); + const originalRoutes = await control.listRoutes(); + const siblingGrants = await runInDurableObject(control, async (_instance, state) => + (await loadSessionCredentialGrants(state.storage)).filter(grant => + grant.members.some(member => member.sessionId === siblingSessionId) ) ); + expect(siblingGrants).toHaveLength(1); + const authorization = vi.mocked(requireCurrentSessionAccess); + authorization.mockResolvedValue({ kiloSessionId, organizationId: null }); + const caller = router(createSessionManagementHandlers()).createCaller({ + env, + userId: ownerId, + authToken: 'test-token', + request: new Request('http://worker.test/trpc/deleteSession'), + }); const wrapper = await connect(credential, targetSandboxId); - await completeHello(wrapper, 'hello-grouped-delete-failure'); - const incomingDetach = nextMessage(wrapper); - const deletion = runInDurableObject(session, instance => instance.deleteSession()); - const detach = JSON.parse(await incomingDetach) as WrapperRequest; - wrapper.send( - JSON.stringify({ - type: 'response', - requestId: detach.requestId, - ok: false, - error: { code: 'not_ready', message: 'live detach failed', retryable: true }, - }) - ); + await completeHello(wrapper, 'hello-grouped-delete-failure', { + providerInstanceId: cloudflareRef(targetSandboxId), + wrapperInstanceId: crypto.randomUUID(), + }); + signalWrapperReady(wrapper); + await vi.waitFor(async () => { + await expect(control.getStatus()).resolves.toMatchObject({ connection: 'ready' }); + }); + const requests: RequestFrame[] = []; + let failDetach = true; + wrapper.addEventListener('message', event => { + const request = requestFrameSchema.parse(JSON.parse(String(event.data))); + requests.push(request); + wrapper.send( + JSON.stringify({ + type: 'response', + requestId: request.requestId, + ...(request.operation === 'session.detach' && failDetach + ? { + ok: false, + error: { code: 'not_ready', message: 'live detach failed', retryable: true }, + } + : { ok: true }), + }) + ); + }); - await expect(deletion).rejects.toThrow('live detach failed'); - await runInDurableObject(session, async (instance, state) => { - await expect(instance.getMetadata()).resolves.toBeNull(); - expect(await state.storage.get('session_metadata')).toMatchObject({ - identity: { sessionId }, + try { + await expect(caller.deleteSession({ sessionId })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to clean up session metadata', }); - expect(await state.storage.get('session_lifecycle_fence')).toMatchObject({ - state: 'deleted', + await runInDurableObject(session, async (instance, state) => { + await expect(instance.getMetadata()).resolves.toBeNull(); + await expect(instance.getRuntimeLocation()).resolves.toMatchObject({ + cloudAgentSessionId: sessionId, + sessionId: kiloSessionId, + location: { sandboxId: targetSandboxId }, + }); + expect(await state.storage.get('session_metadata')).toMatchObject({ + identity: { sessionId }, + }); + expect(await state.storage.get('session_lifecycle_fence')).toMatchObject({ + state: 'deleted', + }); + expect(persistedSessionEvents(state, ['kilocode'])).toHaveLength(1); }); - }); - await runInDurableObject(control, async (instance, state) => { - expect(await instance.listRoutes()).toEqual([ - expect.objectContaining({ sessionId, kiloSessionId: ROOT_ID }), + await expect(control.listRoutes()).resolves.toEqual(originalRoutes); + await runInDurableObject(control, async (_instance, state) => { + expect(await loadSessionCredentialGrants(state.storage)).toEqual(siblingGrants); + }); + await expect( + session.registerSession( + groupedRegistration({ + ownerId, + sessionId, + kiloSessionId, + sandboxId: targetSandboxId, + }) + ) + ).resolves.toMatchObject({ success: false }); + + const requestsBeforeRetry = requests.length; + authorization.mockRejectedValueOnce( + new TRPCError({ code: 'FORBIDDEN', message: 'Session access denied' }) + ); + await expect(caller.deleteSession({ sessionId })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + expect(requests).toHaveLength(requestsBeforeRetry); + + await expect(caller.deleteSession({ sessionId })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + }); + await expect(control.listRoutes()).resolves.toEqual(originalRoutes); + await expect(session.getRuntimeLocation()).resolves.not.toBeNull(); + await runInDurableObject(control, async (_instance, state) => { + expect(await loadSessionCredentialGrants(state.storage)).toEqual(siblingGrants); + }); + + failDetach = false; + await expect(caller.deleteSession({ sessionId })).resolves.toEqual({ success: true }); + await expect(control.listRoutes()).resolves.toEqual( + originalRoutes.filter(route => route.sessionId === siblingSessionId) + ); + await expect(control.getPhysicalRecord()).resolves.toMatchObject({ state: 'running' }); + await runInDurableObject(control, async (_instance, state) => { + expect(await loadSessionCredentialGrants(state.storage)).toEqual(siblingGrants); + }); + await runInDurableObject(session, async (instance, state) => { + await expect(instance.getRuntimeLocation()).resolves.toBeNull(); + expect(await state.storage.get('session_metadata')).toBeUndefined(); + expect(persistedSessionEvents(state, ['kilocode'])).toEqual([]); + expect(await state.storage.get('session_lifecycle_fence')).toMatchObject({ + state: 'deleted', + }); + }); + await expect( + runInDurableObject(sibling, async (_instance, state) => ({ + metadata: await state.storage.get('session_metadata'), + events: persistedSessionEvents(state, ['kilocode']), + })) + ).resolves.toEqual(siblingState); + expect(requests.map(request => request.operation)).toEqual([ + 'session.detach', + 'session.detach', + 'session.detach', ]); - expect(await loadSessionCredentialGrants(state.storage)).toEqual([]); - }); - wrapper.close(); + expect(requests.every(request => request.session?.kiloSessionId === kiloSessionId)).toBe( + true + ); + expect(authorization).toHaveBeenCalledTimes(4); + + await expect(caller.deleteSession({ sessionId })).resolves.toEqual({ + success: true, + message: 'Session not found or already deleted', + }); + expect(authorization).toHaveBeenCalledTimes(4); + expect(requests).toHaveLength(3); + } finally { + wrapper.close(); + authorization.mockReset(); + } }); }); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts index e9d3a30324..cfc35b390f 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts @@ -978,6 +978,29 @@ describe('handleControlRequest', () => { expect(rootForSession('child_2', '/workspace')).toBeUndefined(); }); + it('retains an attached root for retry until native cancellation is confirmed', async () => { + const sibling = { ...session, sessionId: 'ses_sibling', kiloSessionId: 'kilo_sibling' }; + rememberAttachedRoot(sibling.kiloSessionId, sibling.directory); + let confirmed = false; + const handlerDeps = deps({ kiloClient: fakeKilo({ abortSession: async () => confirmed }) }); + const runtime = handlerDeps.kiloRuntimes?.get(session.directory); + expect(handlerDeps.tasks.size).toBe(0); + expect(await handleControlRequest('session.detach', session, {}, handlerDeps)).toMatchObject({ + ok: false, + error: { code: 'not_ready' }, + }); + expect(rootForSession(session.kiloSessionId)).toBe(session.kiloSessionId); + expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); + expect(handlerDeps.kiloRuntimes?.get(session.directory)).toBe(runtime); + confirmed = true; + expect(await handleControlRequest('session.detach', session, {}, handlerDeps)).toEqual({ + ok: true, + result: { detached: true }, + }); + expect(rootForSession(session.kiloSessionId)).toBeUndefined(); + expect(rootForSession(sibling.kiloSessionId)).toBe(sibling.kiloSessionId); + }); + it('preserves root lineage, sibling activity, tasks, and terminals when the detach directory does not match', async () => { const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; const activity = createSessionActivityRegistry(() => 100); @@ -1049,7 +1072,7 @@ describe('handleControlRequest', () => { } }); - it('detaches a root without stopping its worktree runtime or sibling roots', async () => { + it('aborts a native root without an owned task and detaches without stopping its sibling', async () => { const sibling = { ...session, sessionId: 'ses_sibling', kiloSessionId: 'kilo_sibling' }; rememberAttachedRoot(sibling.kiloSessionId, sibling.directory); const started = Promise.withResolvers(); @@ -1091,11 +1114,12 @@ describe('handleControlRequest', () => { expect(runtimes.get(session.directory)).toBe(runtime); expect(handlerDeps.tasks.get(sibling.kiloSessionId)?.signal.aborted).toBe(false); expect(events).toEqual([]); + expect(aborted).toEqual([session.kiloSessionId]); expect(await handleControlRequest('session.abort', sibling, {}, handlerDeps)).toEqual({ ok: true, result: { status: 'aborted' }, }); - expect(aborted).toEqual([sibling.kiloSessionId]); + expect(aborted).toEqual([session.kiloSessionId, sibling.kiloSessionId]); expect(events).toEqual([ { type: 'session.message.outcome', @@ -3651,6 +3675,15 @@ describe('control interactions and sync', () => { const queried = new Set(); const handlerDeps = deps({ kiloClient: fakeKilo({ + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { + expect(signal?.aborted).toBe(false); + return directory === session.directory + ? { kilo_1: { type: 'busy' }, sibling: { type: 'idle' } } + : {}; + }, getQuestions: async directory => { if (directory) queried.add(directory); return directory === childDirectory @@ -3678,7 +3711,7 @@ describe('control interactions and sync', () => { expect(await handleControlRequest('session.sync', session, {}, handlerDeps)).toEqual({ ok: true, result: { - status: { type: 'idle' }, + status: { type: 'busy' }, questions: [question, { ...childQuestion, rootKiloSessionId: session.kiloSessionId }], permissions: [permission, { ...childPermission, rootKiloSessionId: session.kiloSessionId }], }, @@ -4268,21 +4301,29 @@ describe('refreshHeartbeatPayload', () => { it('reconciles attached roots in a shared directory with exactly one status read per heartbeat', async () => { let now = 100; const activity = createSessionActivityRegistry(() => now); - rememberAttachedRoot('kilo_2', session.directory); - activity.attach('kilo_1'); - activity.attach('kilo_2'); - now = 150; + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; const reads: unknown[] = []; const handlerDeps = deps({ activity, kiloClient: fakeKilo({ - getSessionStatuses: async (directory, signal) => { + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { reads.push(directory); expect(signal?.aborted).toBe(false); - return { kilo_1: { type: 'idle' }, kilo_2: { type: 'busy' } }; + return directory === session.directory + ? { kilo_1: { type: 'idle' }, kilo_2: { type: 'busy' } } + : {}; }, }), }); + for (const identity of [session, sibling]) { + expect( + (await handleControlRequest('session.attach', identity, { kilo }, handlerDeps)).ok + ).toBe(true); + } + now = 150; const payload = await refreshHeartbeatPayload(handlerDeps); expect(reads).toEqual([session.directory]); @@ -4296,15 +4337,75 @@ describe('refreshHeartbeatPayload', () => { expect(reads).toEqual([session.directory, session.directory]); }); + it('reconciles each attached worktree from its own directory-scoped statuses', async () => { + let now = 100; + const first = session; + const second = { sessionId: 'ses_2', kiloSessionId: 'kilo_2', directory: '/workspace/second' }; + const activity = createSessionActivityRegistry(() => now); + const handlerDeps = deps( + { + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { + expect(signal?.aborted).toBe(false); + return directory === first.directory + ? { kilo_1: { type: 'busy', waitingOn: 'tool' } } + : {}; + }, + }), + }, + first + ); + const otherDeps = deps( + { + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { + expect(signal?.aborted).toBe(false); + return directory === second.directory ? { kilo_2: { type: 'finalizing' } } : {}; + }, + }), + }, + second + ); + expect((await handleControlRequest('session.attach', first, { kilo }, handlerDeps)).ok).toBe( + true + ); + expect((await handleControlRequest('session.attach', second, { kilo }, otherDeps)).ok).toBe( + true + ); + const runtimes = handlerDeps.kiloRuntimes; + const firstRuntime = runtimes?.get(first.directory); + const secondRuntime = otherDeps.kiloRuntimes?.get(second.directory); + if (!runtimes || !firstRuntime || !secondRuntime) + throw new Error('Expected directory runtimes'); + runtimes.get = directory => + directory === first.directory + ? firstRuntime + : directory === second.directory + ? secondRuntime + : undefined; + now = 150; + + const payload = await refreshHeartbeatPayload(handlerDeps); + + expect(payload.state).toBe('active'); + expect(payload.sessions).toEqual([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 0, waitingOn: 'tool' }, + { kiloSessionId: 'kilo_2', state: 'finalizing', idleForMs: 0, waitingOn: 'finalizing' }, + ]); + }); + it('retains the previous active or finalizing state when its status read fails', async () => { let now = 100; const activity = createSessionActivityRegistry(() => now); - rememberAttachedRoot('kilo_2', session.directory); - activity.attach('kilo_1'); - activity.attach('kilo_2'); - activity.markActive('kilo_1'); - activity.reconcile({ kilo_1: { type: 'busy' }, kilo_2: { type: 'finalizing' } }); - now = 180; + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; const handlerDeps = deps({ activity, kiloClient: fakeKilo({ @@ -4313,8 +4414,20 @@ describe('refreshHeartbeatPayload', () => { }, }), }); + expect((await handleControlRequest('session.attach', session, { kilo }, handlerDeps)).ok).toBe( + true + ); + expect((await handleControlRequest('session.attach', sibling, { kilo }, handlerDeps)).ok).toBe( + true + ); + activity.reconcile({ kilo_1: { type: 'busy' }, kilo_2: { type: 'finalizing' } }, [ + 'kilo_1', + 'kilo_2', + ]); + now = 180; const payload = await refreshHeartbeatPayload(handlerDeps); + expect(payload.state).toBe('active'); expect(payload.pendingMessages).toBe(0); expect(payload.sessions).toEqual([ @@ -4323,6 +4436,114 @@ describe('refreshHeartbeatPayload', () => { ]); }); + it('preserves failed-directory activity while a successful empty poll idles only its own root', async () => { + let now = 100; + let failFirstDirectory = false; + const first = session; + const firstSibling = { ...first, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const second = { + sessionId: 'ses_3', + kiloSessionId: 'kilo_3', + directory: '/workspace/second', + }; + const activity = createSessionActivityRegistry(() => now); + const handlerDeps = deps( + { + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { + expect(signal?.aborted).toBe(false); + if (directory !== first.directory) return {}; + if (failFirstDirectory) throw new Error('status unavailable'); + return { kilo_1: { type: 'busy', waitingOn: 'tool' }, kilo_2: { type: 'finalizing' } }; + }, + }), + }, + first + ); + const otherDeps = deps( + { + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { + expect(signal?.aborted).toBe(false); + return directory === second.directory && !failFirstDirectory + ? { kilo_3: { type: 'busy' } } + : {}; + }, + }), + }, + second + ); + for (const identity of [first, firstSibling]) { + expect( + (await handleControlRequest('session.attach', identity, { kilo }, handlerDeps)).ok + ).toBe(true); + } + expect((await handleControlRequest('session.attach', second, { kilo }, otherDeps)).ok).toBe( + true + ); + const runtimes = handlerDeps.kiloRuntimes; + const firstRuntime = runtimes?.get(first.directory); + const secondRuntime = otherDeps.kiloRuntimes?.get(second.directory); + if (!runtimes || !firstRuntime || !secondRuntime) + throw new Error('Expected directory runtimes'); + runtimes.get = directory => + directory === first.directory + ? firstRuntime + : directory === second.directory + ? secondRuntime + : undefined; + expect((await refreshHeartbeatPayload(handlerDeps)).sessions).toEqual([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 0, waitingOn: 'tool' }, + { kiloSessionId: 'kilo_2', state: 'finalizing', idleForMs: 0, waitingOn: 'finalizing' }, + { kiloSessionId: 'kilo_3', state: 'active', idleForMs: 0, waitingOn: 'model' }, + ]); + now = 180; + failFirstDirectory = true; + + const payload = await refreshHeartbeatPayload(handlerDeps); + + expect(payload.state).toBe('active'); + expect(payload.sessions).toEqual([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 80, waitingOn: 'tool' }, + { kiloSessionId: 'kilo_2', state: 'finalizing', idleForMs: 80, waitingOn: 'finalizing' }, + { kiloSessionId: 'kilo_3', state: 'idle', idleForMs: 0 }, + ]); + }); + + it('does not use a directoryless poll to idle a root without a directory mapping', async () => { + let now = 100; + let reads = 0; + const activity = createSessionActivityRegistry(() => now); + activity.attach('unmapped_root'); + activity.markActive('unmapped_root'); + now = 180; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async () => { + reads += 1; + return {}; + }, + }), + }); + + const payload = await refreshHeartbeatPayload(handlerDeps); + expect(payload.state).toBe('active'); + expect(payload.pendingMessages).toBe(0); + expect(payload.sessions).toEqual([ + { kiloSessionId: 'unmapped_root', state: 'active', idleForMs: 80, waitingOn: 'model' }, + ]); + expect(reads).toBe(0); + }); + it.each([false, true])( 'polls each matching directory client once and isolates reconciliation when one read fails: %s', async failFirst => { @@ -4498,7 +4719,10 @@ describe('createSessionActivityRegistry', () => { activity.attach('root_b'); activity.markActive('root_a'); activity.markActive('root_b'); - activity.reconcile({ root_a: { type: 'busy', waitingOn: 'tool' }, child_b: { type: 'busy' } }); + activity.reconcile({ root_a: { type: 'busy', waitingOn: 'tool' }, child_b: { type: 'busy' } }, [ + 'root_a', + 'root_b', + ]); expect(activity.snapshots()).toEqual([ { kiloSessionId: 'root_a', state: 'active', idleForMs: 0, waitingOn: 'tool' }, { kiloSessionId: 'root_b', state: 'idle', idleForMs: 0 }, diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts index 07c2a6cf60..28ce347405 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts @@ -650,6 +650,10 @@ async function handleDetach( if (!result.ok && task.kind !== 'preparation') return result; } try { + if (!task) { + const runtime = sessionKiloRuntime(session, deps); + if (runtime) await abortKiloSession(session, runtime.kiloClient); + } deps.kiloRuntimes?.detach(session); const terminalCleanup = deps.terminalRuntime?.detachSession(session); forgetAttachedRoot(session.kiloSessionId, session.directory); diff --git a/services/cloud-agent-next/wrapper/src/kilo-api.test.ts b/services/cloud-agent-next/wrapper/src/kilo-api.test.ts index 687498de98..fc72af638a 100644 --- a/services/cloud-agent-next/wrapper/src/kilo-api.test.ts +++ b/services/cloud-agent-next/wrapper/src/kilo-api.test.ts @@ -294,6 +294,35 @@ describe('createWrapperKiloClient generated SDK HTTP boundary', () => { } } + it('reads directory-scoped status without treating another checkout as empty', async () => { + const directory = '/workspace/shared checkout & tools'; + const requests: Array<{ method: string; pathname: string; directory: string | null }> = []; + const server = Bun.serve({ + port: 0, + fetch: request => { + const url = new URL(request.url); + requests.push({ + method: request.method, + pathname: url.pathname, + directory: url.searchParams.get('directory'), + }); + return Response.json( + url.searchParams.get('directory') === directory + ? { root_a: { type: 'busy', waitingOn: 'tool' } } + : {} + ); + }, + }); + startedServers.push(server); + const client = createClient(server.url.toString()); + + expect(await client.getSessionStatuses(directory)).toEqual({ + root_a: { type: 'busy', waitingOn: 'tool' }, + }); + expect(requests).toEqual([{ method: 'GET', pathname: '/session/status', directory }]); + expect(await client.getSessionStatuses()).toEqual({}); + }); + it.each([ { name: 'empty object', body: {} }, { name: 'success-shaped object', body: { success: true } }, diff --git a/services/session-ingest/src/cloud-agent-session-scope-auth.test.ts b/services/session-ingest/src/cloud-agent-session-scope-auth.test.ts index d2ca378f57..7ca411c84a 100644 --- a/services/session-ingest/src/cloud-agent-session-scope-auth.test.ts +++ b/services/session-ingest/src/cloud-agent-session-scope-auth.test.ts @@ -56,12 +56,15 @@ describe('Cloud Agent session scope internal authentication', () => { expect(response.status).toBe(401); }); - it('rejects a JWT-only request', async () => { + it('rejects a JWT-only request even with a trusted lineage marker', async () => { const response = await app.request( '/internal/cloud-agent/v1/session', { method: 'POST', - headers: { Authorization: 'Bearer valid-user-token' }, + headers: { + Authorization: 'Bearer valid-user-token', + 'X-Kilo-Trusted-Session-Lineage': '1', + }, }, env ); diff --git a/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts b/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts index 9279e88d26..84d235ab33 100644 --- a/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts +++ b/services/session-ingest/src/routes/cloud-agent-session-scope.test.ts @@ -197,106 +197,180 @@ describe('Cloud Agent session scope routes', () => { expect(getSessionAccessCacheDO).not.toHaveBeenCalled(); }); - it('atomically heals the root and creates a session-scoped child', async () => { - const child = persistedRow(); - const { db, insertedValues, updateSets } = makeDb( - [ + it.each([undefined, rootSessionId])( + 'creates a new scoped child with untrusted parent %s', + async parentSessionId => { + const child = persistedRow(); + const { db, insertedValues, updateSets } = makeDb( [ - { - sessionId: rootSessionId, - organizationId: child.organization_id, - cloudAgentSessionScopeId: null, - }, + [ + { + sessionId: rootSessionId, + organizationId: child.organization_id, + cloudAgentSessionScopeId: null, + }, + ], ], - ], - [child] - ); - vi.mocked(getWorkerDb).mockReturnValue(db as never); - const putValidated = vi.fn(async () => undefined); - vi.mocked(getSessionAccessCacheDO).mockReturnValue({ putValidated } as never); + [child] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const putValidated = vi.fn(async () => undefined); + vi.mocked(getSessionAccessCacheDO).mockReturnValue({ putValidated } as never); + + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId, parentSessionId }), + }), + env + ); + + expect(response.status).toBe(200); + expect(updateSets).toContainEqual({ cloud_agent_session_scope_id: cloudAgentSessionId }); + expect(insertedValues).toContainEqual( + expect.objectContaining({ + session_id: childSessionId, + parent_session_id: rootSessionId, + cloud_agent_session_scope_id: cloudAgentSessionId, + cloud_agent_session_id: null, + }) + ); + expect(putValidated).toHaveBeenCalledWith({ + sessionId: childSessionId, + organizationId: child.organization_id, + cloudAgentSessionScopeId: cloudAgentSessionId, + }); + } + ); - const response = await makeApp().fetch( - new Request('http://local/session', { - method: 'POST', - headers: assertionHeaders(), - body: JSON.stringify({ sessionId: childSessionId }), - }), - env - ); - - expect(response.status).toBe(200); - expect(updateSets).toContainEqual({ cloud_agent_session_scope_id: cloudAgentSessionId }); - expect(insertedValues).toContainEqual( - expect.objectContaining({ - session_id: childSessionId, + it.each([ + { organizationId: null, existingOrganizationId: null }, + { organizationId: '11111111-1111-4111-8111-111111111111', existingOrganizationId: null }, + { + organizationId: '11111111-1111-4111-8111-111111111111', + existingOrganizationId: '11111111-1111-4111-8111-111111111111', + }, + ])( + 'adopts a public-sync-first orphan with trusted lineage from $existingOrganizationId into $organizationId', + async ({ organizationId, existingOrganizationId }) => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const child = persistedRow({ + organization_id: existingOrganizationId, + parent_session_id: null, + cloud_agent_session_scope_id: null, + title: 'Already synced child', + git_url: 'https://github.com/acme/repo', + status: 'idle', + }); + const linked = { + ...child, + organization_id: organizationId, parent_session_id: rootSessionId, cloud_agent_session_scope_id: cloudAgentSessionId, - cloud_agent_session_id: null, - }) - ); - expect(putValidated).toHaveBeenCalledWith({ - sessionId: childSessionId, - organizationId: child.organization_id, - cloudAgentSessionScopeId: cloudAgentSessionId, - }); - }); - - it('persists engine-created child lineage before its first turn when public bootstrap won the race', async () => { - const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; - const child = persistedRow({ - organization_id: null, - parent_session_id: null, - cloud_agent_session_scope_id: null, - cloud_agent_worktree_id: null, - status: null, - }); - const linked = { - ...child, - parent_session_id: rootSessionId, - cloud_agent_session_scope_id: cloudAgentSessionId, - cloud_agent_worktree_id: worktreeId, - }; - const { db, updateSets } = makeDb( - [ + cloud_agent_worktree_id: worktreeId, + }; + const { db, updateSets } = makeDb( [ - { - sessionId: rootSessionId, - organizationId: null, - cloudAgentSessionScopeId: cloudAgentSessionId, - worktreeId, - }, + [ + { + sessionId: rootSessionId, + organizationId, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId, + }, + ], + [], + [child], ], [], - [child], - ], - [], - [linked] - ); - vi.mocked(getWorkerDb).mockReturnValue(db as never); - const putValidated = vi.fn(async () => undefined); - vi.mocked(getSessionAccessCacheDO).mockReturnValue({ putValidated } as never); - - const response = await makeApp().fetch( - new Request('http://local/session', { - method: 'POST', - headers: assertionHeaders(), - body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), - }), - env - ); + [linked] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + const putValidated = vi.fn(async () => undefined); + vi.mocked(getSessionAccessCacheDO).mockReturnValue({ putValidated } as never); + + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: { + ...assertionHeaders(), + [cloudAgentSessionScopeHeaders.trustedLineage]: '1', + }, + body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), + }), + env + ); + + expect(response.status).toBe(200); + expect(updateSets).toEqual([ + { + parent_session_id: rootSessionId, + cloud_agent_session_scope_id: cloudAgentSessionId, + cloud_agent_worktree_id: worktreeId, + ...(existingOrganizationId !== organizationId ? { organization_id: organizationId } : {}), + }, + ]); + expect(putValidated).toHaveBeenCalledWith({ + sessionId: childSessionId, + organizationId, + cloudAgentSessionScopeId: cloudAgentSessionId, + }); + } + ); - expect(response.status).toBe(200); - expect(updateSets).toContainEqual({ - parent_session_id: rootSessionId, - cloud_agent_session_scope_id: cloudAgentSessionId, - cloud_agent_worktree_id: worktreeId, - }); - expect(putValidated).toHaveBeenCalledWith({ - sessionId: childSessionId, - organizationId: null, - cloudAgentSessionScopeId: cloudAgentSessionId, - }); - }); + it.each([ + { organizationId: null, title: null, status: null }, + { + organizationId: '11111111-1111-4111-8111-111111111111', + title: 'Unrelated populated CLI session', + status: 'idle', + }, + ])( + 'rejects untrusted adoption with a supplied parent in scope $organizationId', + async ({ organizationId, title, status }) => { + const worktreeId = 'worktree_11111111-1111-4111-8111-111111111111'; + const child = persistedRow({ + organization_id: organizationId, + parent_session_id: null, + cloud_agent_session_scope_id: null, + title, + status, + }); + const { db, updateSets } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId, + }, + ], + [], + [child], + ], + [], + [{ ...child, parent_session_id: rootSessionId, cloud_agent_worktree_id: worktreeId }] + ); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + vi.mocked(getSessionAccessCacheDO).mockReturnValue({ putValidated: vi.fn() } as never); + + const response = await makeApp().fetch( + new Request('http://local/session', { + method: 'POST', + headers: assertionHeaders(), + body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), + }), + env + ); + + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ success: false, error: 'session_scope_conflict' }); + expect(updateSets).toEqual([]); + expect(getSessionAccessCacheDO).not.toHaveBeenCalled(); + } + ); it.each([ ['organization', { organization_id: '22222222-2222-4222-8222-222222222222' }], @@ -331,7 +405,10 @@ describe('Cloud Agent session scope routes', () => { const response = await makeApp().fetch( new Request('http://local/session', { method: 'POST', - headers: assertionHeaders(), + headers: { + ...assertionHeaders(), + [cloudAgentSessionScopeHeaders.trustedLineage]: '1', + }, body: JSON.stringify({ sessionId: childSessionId, parentSessionId: rootSessionId }), }), env diff --git a/services/session-ingest/src/routes/cloud-agent-session-scope.ts b/services/session-ingest/src/routes/cloud-agent-session-scope.ts index 850a4dace9..57cc7ce0ce 100644 --- a/services/session-ingest/src/routes/cloud-agent-session-scope.ts +++ b/services/session-ingest/src/routes/cloud-agent-session-scope.ts @@ -183,6 +183,7 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS return { status: 'conflict' } as const; } if ( + c.req.header(cloudAgentSessionScopeHeaders.trustedLineage) === '1' && body.parentSessionId !== undefined && existing.cloud_agent_session_scope_id === null && existing.cloud_agent_worktree_id === null && diff --git a/services/session-ingest/src/services/worktree-deletion.test.ts b/services/session-ingest/src/services/worktree-deletion.test.ts index 180f463888..cc7e25bb50 100644 --- a/services/session-ingest/src/services/worktree-deletion.test.ts +++ b/services/session-ingest/src/services/worktree-deletion.test.ts @@ -21,6 +21,7 @@ import { registerCloudAgentWorktree, } from './worktree-deletion'; import type { Env } from '../env'; +import type { SessionEventPayload } from '../types/user-connection-protocol'; const mocks = vi.hoisted(() => ({ getDb: vi.fn(), @@ -39,6 +40,7 @@ const otherWorktreeId = 'worktree_22222222-2222-4222-8222-222222222222'; const organizationId = '33333333-3333-4333-8333-333333333333'; const userId = 'oauth/github:worktree-owner'; const location = { sandboxId: 'ses-original', provider: 'cloudflare' as const }; +const createdAt = '2026-08-27 01:00:00+00'; const params = { worktreeId, kiloUserId: userId } satisfies CloudAgentWorktreeDeletionParams; const env = { HYPERDRIVE: { connectionString: 'postgres://unused' } } as Env; const kiloId = (index: number) => `ses_${String(index).padStart(26, '0')}`; @@ -51,7 +53,7 @@ function worktree(values: Partial = {}): CloudAgentWorktree kilo_user_id: userId, organization_id: null, name: 'Private worktree name', - created_at: '2026-08-27 01:00:00+00', + created_at: createdAt, updated_at: '2026-08-27 01:00:00+00', deletion_started_at: null, deletion_completed_at: null, @@ -70,6 +72,10 @@ type Member = { worktreeId: string | null; userId: string; parentSessionId: string | null; + createdAt?: string; + gitUrl?: string; + gitBranch?: string; + createdOnPlatform?: string; }; function database(rootCount = 1, descendantCount = 0) { @@ -101,12 +107,14 @@ function database(rootCount = 1, descendantCount = 0) { }> = []; const events: string[] = []; const sqlQueries: { sql: string; params: unknown[] }[] = []; + const candidateQueries: { sql: string; params: unknown[] }[] = []; let membershipAllowed = true; const valuesOf = (condition: SQL | undefined) => condition ? new PgDialect().sqlToQuery(condition).params : []; const select = (columns?: Record) => { let table: unknown; let condition: SQL | undefined; + let orderedColumn: unknown; let limit: number | undefined; const rows = () => { const bound = valuesOf(condition); @@ -122,7 +130,12 @@ function database(rootCount = 1, descendantCount = 0) { if (table === cli_sessions_v2) { const querySql = condition ? new PgDialect().sqlToQuery(condition).sql : ''; if (querySql.includes('"cloud_agent_session_id" is null')) { + candidateQueries.push({ sql: querySql, params: bound }); const cursor = bound.find(value => typeof value === 'string' && value.startsWith('ses_')); + const createdAtParameter = querySql.match(/"created_at" >= \$(\d+)/)?.[1]; + const minimumCreatedAt = createdAtParameter + ? bound[Number(createdAtParameter) - 1] + : undefined; return members .filter( row => @@ -134,6 +147,8 @@ function database(rootCount = 1, descendantCount = 0) { (querySql.includes('"organization_id" is null') ? row.organizationId === null : bound.includes(row.organizationId)) && + (typeof minimumCreatedAt !== 'string' || + Date.parse(row.createdAt ?? createdAt) >= Date.parse(minimumCreatedAt)) && (typeof cursor !== 'string' || row.sessionId > cursor) ) .sort((a, b) => a.sessionId.localeCompare(b.sessionId)) @@ -142,7 +157,16 @@ function database(rootCount = 1, descendantCount = 0) { if (columns && 'userId' in columns) return members .filter(row => row.worktreeId === worktreeId) - .map(row => ({ userId: row.userId, organizationId: row.organizationId })); + .map(row => ({ + userId: row.userId, + organizationId: row.organizationId, + createdAt: row.createdAt ?? createdAt, + })) + .sort((a, b) => + orderedColumn === cli_sessions_v2.created_at + ? Date.parse(a.createdAt) - Date.parse(b.createdAt) + : 0 + ); if ( columns && 'worktreeId' in columns && @@ -173,7 +197,10 @@ function database(rootCount = 1, descendantCount = 0) { condition = value; return query; }, - orderBy: () => query, + orderBy: (column: unknown) => { + orderedColumn = column; + return query; + }, limit: (value: number) => { limit = value; return query; @@ -227,13 +254,23 @@ function database(rootCount = 1, descendantCount = 0) { }, }); const remove = () => ({ - where: async (condition: SQL) => { - const ids = valuesOf(condition); - events.push('deleteRows'); - for (let index = members.length - 1; index >= 0; index--) - if (members[index].userId === userId && ids.includes(members[index].sessionId)) - members.splice(index, 1); - }, + where: (condition: SQL) => ({ + returning: async () => { + const ids = valuesOf(condition); + const deleted = members.filter(row => row.userId === userId && ids.includes(row.sessionId)); + events.push('deleteRows'); + for (let index = members.length - 1; index >= 0; index--) + if (deleted.includes(members[index])) members.splice(index, 1); + return deleted.map(row => ({ + sessionId: row.sessionId, + parentSessionId: row.parentSessionId, + organizationId: row.organizationId, + gitUrl: row.gitUrl ?? null, + gitBranch: row.gitBranch ?? null, + createdOnPlatform: row.createdOnPlatform ?? null, + })); + }, + }), }); const tx = { select, @@ -284,16 +321,32 @@ function database(rootCount = 1, descendantCount = 0) { }; }, }; + let commitError: Error | undefined; const db = { ...tx, - transaction: async (callback: (transaction: typeof tx) => Promise) => callback(tx), + transaction: async (callback: (transaction: typeof tx) => Promise) => { + const previousWorktrees = structuredClone(worktrees); + const previousMembers = structuredClone(members); + try { + const result = await callback(tx); + if (commitError) throw commitError; + events.push('commit'); + return result; + } catch (error) { + worktrees.splice(0, worktrees.length, ...previousWorktrees); + members.splice(0, members.length, ...previousMembers); + throw error; + } + }, }; mocks.getDb.mockReturnValue(db); const cleared = new Set(); const snapshots = new Map>(); + const snapshotReads: string[] = []; let failure: string | undefined; mocks.ingest.mockImplementation((_env: Env, identity: { sessionId: string }) => ({ readKiloSdkSessionSnapshot: async () => { + snapshotReads.push(identity.sessionId); const info = snapshots.get(identity.sessionId); return info ? { @@ -317,7 +370,22 @@ function database(rootCount = 1, descendantCount = 0) { }, })); mocks.cache.mockReturnValue({ deleteSession: async () => undefined }); - mocks.connection.mockReturnValue({ clearSession: async () => undefined }); + const notifications: { kiloUserId: string; event: SessionEventPayload }[] = []; + const pendingNotifications: Promise[] = []; + const executionContext: ExecutionContext = { + waitUntil: promise => { + pendingNotifications.push(promise); + }, + passThroughOnException: () => undefined, + props: {}, + }; + mocks.connection.mockImplementation((_env: Env, { kiloUserId }: { kiloUserId: string }) => ({ + clearSession: async () => undefined, + notifySessionEvent: async (event: SessionEventPayload) => { + notifications.push({ kiloUserId, event }); + events.push('notifySessionEvent'); + }, + })); return { db, tx, @@ -326,8 +394,16 @@ function database(rootCount = 1, descendantCount = 0) { allocations, events, sqlQueries, + candidateQueries, cleared, snapshots, + snapshotReads, + notifications, + pendingNotifications, + executionContext, + failCommit: (error?: Error) => { + commitError = error; + }, denyMembership: () => { membershipAllowed = false; }, @@ -340,6 +416,89 @@ function database(rootCount = 1, descendantCount = 0) { beforeEach(() => vi.resetAllMocks()); describe('durable worktree deletion journal', () => { + it.each([null, organizationId])( + 'publishes only committed root and descendant deletions in scope %s, once per deleted row', + async scope => { + const f = database(2, 2); + const scopedParams = { ...params, ...(scope ? { organizationId: scope } : {}) }; + f.worktrees[0].organization_id = scope; + for (const row of f.members) { + row.organizationId = scope; + row.gitUrl = `https://github.com/acme/repo-${row.sessionId}`; + row.gitBranch = `branch-${row.sessionId}`; + row.createdOnPlatform = row.cloudAgentSessionId ? 'cloud-agent-web' : 'cli'; + } + const expectedEvents = f.members.map(row => ({ + kiloUserId: userId, + event: { + type: 'session.deleted', + data: { + source: 'v2', + sessionId: row.sessionId, + parentSessionId: row.parentSessionId, + organizationId: scope, + gitUrl: row.gitUrl, + gitBranch: row.gitBranch, + createdOnPlatform: row.createdOnPlatform, + deletedAt: expect.any(String), + }, + }, + })); + const unrelated: Member = { + sessionId: kiloId(50), + cloudAgentSessionId: cloudId(50), + organizationId: scope, + worktreeId: otherWorktreeId, + userId, + parentSessionId: null, + }; + f.members.push(unrelated); + await beginWorktreeDeletion(env, scopedParams); + await recordWorktreeCleanup(env, { ...scopedParams, sessionIds: [kiloId(99)] }); + expect(f.notifications).toEqual([]); + + const result = await completeWorktreeDeletion(env, scopedParams, f.executionContext); + await Promise.all(f.pendingNotifications); + expect(result).toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(1), kiloId(2), kiloId(3), kiloId(99)], + }); + expect(f.notifications).toHaveLength(4); + expect(f.notifications).toEqual(expect.arrayContaining(expectedEvents)); + expect(f.pendingNotifications).toHaveLength(4); + expect(f.events.indexOf('notifySessionEvent')).toBeGreaterThan( + f.events.lastIndexOf('commit') + ); + expect(f.members).toEqual([unrelated]); + + await expect( + completeWorktreeDeletion(env, scopedParams, f.executionContext) + ).resolves.toEqual(result); + expect(f.notifications).toHaveLength(4); + expect(f.pendingNotifications).toHaveLength(4); + } + ); + + it('does not publish deletions when SQL commit fails and publishes once after a successful retry', async () => { + const f = database(2, 1); + await beginWorktreeDeletion(env, params); + f.failCommit(new Error('transaction commit failed')); + await expect(completeWorktreeDeletion(env, params, f.executionContext)).rejects.toThrow( + 'transaction commit failed' + ); + expect(f.events).toContain('deleteRows'); + expect(f.members).toHaveLength(3); + expect(f.worktrees[0].deletion_completed_at).toBeNull(); + expect(f.notifications).toEqual([]); + expect(f.pendingNotifications).toEqual([]); + + f.failCommit(); + await completeWorktreeDeletion(env, params, f.executionContext); + await Promise.all(f.pendingNotifications); + expect(f.members).toEqual([]); + expect(f.notifications).toHaveLength(3); + }); + it('includes a never-run orphan in cold cleanup using retained authoritative root lineage', async () => { const f = database(); f.members.push({ @@ -380,6 +539,7 @@ describe('durable worktree deletion journal', () => { it('recovers an older never-run orphan snapshot after the runtime and creation event are gone', async () => { const f = database(); const directory = `/workspace/owner/worktrees/${worktreeId}`; + f.worktrees[0].created_at = '2026-07-01 01:00:00.123+00'; f.members.push({ sessionId: kiloId(1), cloudAgentSessionId: null, @@ -388,6 +548,7 @@ describe('durable worktree deletion journal', () => { worktreeId: null, userId, parentSessionId: null, + createdAt: '2026-07-02 01:00:00.123+00', }); f.snapshots.set(kiloId(1), { id: kiloId(1), parentID: kiloId(0), directory }); await beginWorktreeDeletion(env, params); @@ -400,6 +561,53 @@ describe('durable worktree deletion journal', () => { expect(f.cleared.has(kiloId(1))).toBe(true); }); + it('excludes pre-worktree history from every recovery pass but includes boundary and late children', async () => { + const f = database(); + const directory = `/workspace/owner/worktrees/${worktreeId}`; + for (const [index, childCreatedAt] of [ + [1, '2026-08-27 00:59:59.999+00'], + [2, createdAt], + ] as const) { + f.members.push({ + sessionId: kiloId(index), + cloudAgentSessionId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + createdAt: childCreatedAt, + }); + f.snapshots.set(kiloId(index), { id: kiloId(index), parentID: kiloId(0), directory }); + } + await beginWorktreeDeletion(env, params); + await recordWorktreeCleanup(env, { ...params, directory }); + expect(f.snapshotReads).toEqual([kiloId(2)]); + + f.members.push({ + sessionId: kiloId(3), + cloudAgentSessionId: null, + organizationId: null, + worktreeId: null, + userId, + parentSessionId: null, + createdAt: new Date(Date.now() + 1_000).toISOString(), + }); + f.snapshots.set(kiloId(3), { id: kiloId(3), parentID: kiloId(2), directory }); + await recordWorktreeCleanup(env, { ...params, directory }); + expect(f.snapshotReads).toEqual([kiloId(2), kiloId(3)]); + expect(f.candidateQueries).toHaveLength(2); + for (const query of f.candidateQueries) { + expect(query.sql).toContain('"created_at" >='); + expect(query.params).toContain(createdAt); + } + await expect(completeWorktreeDeletion(env, params)).resolves.toEqual({ + success: true, + deletedSessionIds: [kiloId(0), kiloId(2), kiloId(3)], + }); + expect(f.members.map(row => row.sessionId)).toEqual([kiloId(1)]); + expect(f.worktrees[0].created_at).toBe(createdAt); + }); + it('does not infer snapshot ownership from directory alone or cross owner, organization, or established root scope', async () => { const f = database(); const directory = `/workspace/owner/worktrees/${worktreeId}`; @@ -755,13 +963,16 @@ describe('durable worktree deletion journal', () => { ); }); - it('lazily creates metadata for existing groups without inventing a name', async () => { - const f = database(); + it('lazily creates metadata from the oldest existing member without resetting the worktree lifetime', async () => { + const f = database(2); + f.members[1].createdAt = '2026-07-01 01:00:00.123+00'; f.worktrees.length = 0; const state = await beginWorktreeDeletion(env, params); - expect(state.manifest.sessions).toHaveLength(1); + expect(state.manifest.sessions).toHaveLength(2); expect(f.worktrees[0].kilo_user_id).toBe(userId); expect(f.worktrees[0].organization_id).toBeNull(); + expect(f.worktrees[0].name).toBeNull(); + expect(f.worktrees[0].created_at).toBe(f.members[1].createdAt); }); it('rejects new root registration under the same locked worktree after deletion begins', async () => { @@ -791,6 +1002,7 @@ describe('durable worktree deletion journal', () => { expect(f.members).toHaveLength(3); expect(f.worktrees[0].deletion_completed_at).toBeNull(); expect(f.worktrees[0].runtime_locations).toEqual([location]); + expect(f.notifications).toEqual([]); f.fail(); await expect(completeWorktreeDeletion(env, params)).resolves.toEqual({ success: true, diff --git a/services/session-ingest/src/services/worktree-deletion.ts b/services/session-ingest/src/services/worktree-deletion.ts index f3ede91e6b..52a9dac8b8 100644 --- a/services/session-ingest/src/services/worktree-deletion.ts +++ b/services/session-ingest/src/services/worktree-deletion.ts @@ -1,4 +1,4 @@ -import { and, eq, gt, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'; +import { and, eq, gt, gte, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'; import { z } from 'zod'; import { getWorkerDb, type WorkerDb } from '@kilocode/db/client'; import { @@ -27,6 +27,7 @@ import type { Env } from '../env'; import { getSessionIngestDO } from '../dos/SessionIngestDO'; import { getSessionAccessCacheDO } from '../dos/SessionAccessCacheDO'; import { getUserConnectionDO } from '../dos/UserConnectionDO'; +import { notifyUserSessionEvent } from '../session-events'; import { allocationLocation, firstWorktreeSessionId, @@ -237,9 +238,11 @@ export async function beginWorktreeDeletion(env: Env, params: CloudAgentWorktree .select({ userId: cli_sessions_v2.kilo_user_id, organizationId: cli_sessions_v2.organization_id, + createdAt: cli_sessions_v2.created_at, }) .from(cli_sessions_v2) - .where(eq(cli_sessions_v2.cloud_agent_worktree_id, params.worktreeId)); + .where(eq(cli_sessions_v2.cloud_agent_worktree_id, params.worktreeId)) + .orderBy(cli_sessions_v2.created_at); if ( owners.some( row => @@ -248,13 +251,15 @@ export async function beginWorktreeDeletion(env: Env, params: CloudAgentWorktree ) { throw new Error(WORKTREE_ACCESS_DENIED); } - if (owners.length > 0) { + const firstOwner = owners[0]; + if (firstOwner) { await tx .insert(cloud_agent_worktrees) .values({ worktree_id: params.worktreeId, kilo_user_id: params.kiloUserId, organization_id: params.organizationId ?? null, + created_at: firstOwner.createdAt, }) .onConflictDoNothing({ target: cloud_agent_worktrees.worktree_id }); } @@ -387,6 +392,7 @@ async function inferChildSessionLineage( isNull(cli_sessions_v2.cloud_agent_session_scope_id), isNull(cli_sessions_v2.cloud_agent_worktree_id), isNull(cli_sessions_v2.parent_session_id), + gte(cli_sessions_v2.created_at, worktree.created_at), cursor ? gt(cli_sessions_v2.session_id, cursor) : undefined ) ) @@ -783,7 +789,8 @@ export async function canDestroyWorktreeSandbox( export async function completeWorktreeDeletion( env: Env, - params: CloudAgentWorktreeDeletionParams + params: CloudAgentWorktreeDeletionParams, + executionContext?: ExecutionContext ): Promise<{ success: true; deletedSessionIds: string[] }> { const db = getWorkerDb(env.HYPERDRIVE.connectionString); const [row] = await db @@ -812,10 +819,10 @@ export async function completeWorktreeDeletion( 'UserConnectionDO.clearSession' ); } - return db.transaction(async tx => { + const { deletedSessionIds, deletedSessions } = await db.transaction(async tx => { const current = await lockWorktree(tx, params); if (current.deletion_completed_at !== null) - return { success: true, deletedSessionIds: current.deleted_session_ids }; + return { deletedSessionIds: current.deleted_session_ids, deletedSessions: [] }; const currentIds = deletionState(current).manifest.sessions.map(session => session.sessionId); const cleaned = new Set(sessionIds); if (currentIds.some(id => !cleaned.has(id))) @@ -824,16 +831,25 @@ export async function completeWorktreeDeletion( if ((await discoverMembers(tx, params)).some(session => !cleaned.has(session.sessionId))) { throw new Error('worktree_cleanup_manifest_changed'); } - if (sessionIds.length > 0) { - await tx - .delete(cli_sessions_v2) - .where( - and( - eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), - inArray(cli_sessions_v2.session_id, sessionIds) - ) - ); - } + const deletedSessions = + sessionIds.length > 0 + ? await tx + .delete(cli_sessions_v2) + .where( + and( + eq(cli_sessions_v2.kilo_user_id, params.kiloUserId), + inArray(cli_sessions_v2.session_id, sessionIds) + ) + ) + .returning({ + sessionId: cli_sessions_v2.session_id, + parentSessionId: cli_sessions_v2.parent_session_id, + organizationId: cli_sessions_v2.organization_id, + gitUrl: cli_sessions_v2.git_url, + gitBranch: cli_sessions_v2.git_branch, + createdOnPlatform: cli_sessions_v2.created_on_platform, + }) + : []; await tx .update(cloud_agent_worktrees) .set({ @@ -844,6 +860,16 @@ export async function completeWorktreeDeletion( deleted_session_ids: sessionIds, }) .where(eq(cloud_agent_worktrees.worktree_id, params.worktreeId)); - return { success: true, deletedSessionIds: sessionIds }; + return { deletedSessionIds: sessionIds, deletedSessions }; }); + const deletedAt = new Date().toISOString(); + for (const session of deletedSessions) { + notifyUserSessionEvent( + env, + params.kiloUserId, + { type: 'session.deleted', data: { source: 'v2', ...session, deletedAt } }, + executionContext + ); + } + return { success: true, deletedSessionIds }; } diff --git a/services/session-ingest/src/session-ingest-rpc.ts b/services/session-ingest/src/session-ingest-rpc.ts index 4155b739b9..230ff1aa34 100644 --- a/services/session-ingest/src/session-ingest-rpc.ts +++ b/services/session-ingest/src/session-ingest-rpc.ts @@ -117,7 +117,11 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn } async completeCloudAgentWorktreeDeletion(params: CloudAgentWorktreeDeletionParams) { - return completeWorktreeDeletion(this.env, cloudAgentWorktreeDeletionSchema.parse(params)); + return completeWorktreeDeletion( + this.env, + cloudAgentWorktreeDeletionSchema.parse(params), + this.ctx + ); } async canDestroyCloudAgentWorktreeSandbox(params: CanDestroyCloudAgentWorktreeSandboxParams) { From d23c8b0249f345b3a9769642f5bf999a063f8fba Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Sat, 29 Aug 2026 23:59:37 +0200 Subject: [PATCH 03/14] fix(cloud-agent-next): align stacked lifecycle contracts and fixtures --- .../cloud-agent-next/src/kilo-facade/user-kilo-facade.ts | 3 +-- .../src/persistence/CloudAgentSession.ts | 5 ++--- .../cloud-agent-next/src/persistence/SandboxControl.ts | 5 +++-- .../src/sandbox-control/lifecycle.test.ts | 9 +++++++++ .../src/session/legacy-prepared-admission.ts | 6 +++--- services/cloud-agent-next/src/session/queue-message.ts | 4 ++-- .../test/integration/sandbox-terminal.test.ts | 5 +++-- services/session-ingest/src/session-ingest-rpc.ts | 3 ++- 8 files changed, 25 insertions(+), 15 deletions(-) diff --git a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts index b2a502755c..86bd429fc1 100644 --- a/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts +++ b/services/cloud-agent-next/src/kilo-facade/user-kilo-facade.ts @@ -25,7 +25,6 @@ import type { } from '../execution/types.js'; import { getPgDb } from '../db/pg.js'; import type { CloudAgentSession } from '../persistence/CloudAgentSession.js'; -import type { UserId } from '../types/ids.js'; import type { Env } from '../types.js'; import { withDORetry } from '../utils/do-retry.js'; import { resolveSessionStub } from '../sandbox-session/session-stub.js'; @@ -837,7 +836,7 @@ async function admitBasicPrompt(params: { ...(parsed.prompt.agent ? { agent: parsed.prompt.agent } : {}), } satisfies QueueExecutionTurnCommand; const request: SubmittedSessionMessageRequest = { - userId: params.userId as UserId, + userId: params.userId, ...command, }; const admitPrompt = params.deps?.admitPrompt ?? defaultAdmitPrompt; diff --git a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts index c6a37a3352..c02a22cf6b 100644 --- a/services/cloud-agent-next/src/persistence/CloudAgentSession.ts +++ b/services/cloud-agent-next/src/persistence/CloudAgentSession.ts @@ -42,7 +42,6 @@ import { type EventSourceId, type EventId, type SessionId, - type UserId, } from '../types/ids.js'; import type { ExecutionMetadata, @@ -1119,7 +1118,7 @@ export class CloudAgentSession extends DurableObject { return { sessionId: metadata.identity.sessionId as SessionId, - userId: metadata.identity.userId as UserId, + userId: metadata.identity.userId, orgId: metadata.identity.orgId, sandboxId, kiloSessionId: metadata.auth.kiloSessionId, @@ -2608,7 +2607,7 @@ export class CloudAgentSession extends DurableObject { const initialTurn = input.message.initialTurn; const admitInitialTurn = () => this.getSessionMessageQueue().admitAcceptedMessage({ - userId: input.identity.userId as UserId, + userId: input.identity.userId, botId: input.identity.botId, turn: initialTurn, agent: input.agent, diff --git a/services/cloud-agent-next/src/persistence/SandboxControl.ts b/services/cloud-agent-next/src/persistence/SandboxControl.ts index 1d99c991a6..7fbf555217 100644 --- a/services/cloud-agent-next/src/persistence/SandboxControl.ts +++ b/services/cloud-agent-next/src/persistence/SandboxControl.ts @@ -1327,7 +1327,7 @@ export class SandboxControl extends DurableObject { await this.ctx.storage.put(RUNTIME_DELETED_KEY, true); this.socketHandler.closeAll('Worktree deleted'); await Promise.allSettled([...this.lifecycleOperations]); - await this.eraseRecord(); + await this.eraseRecord({ preserveAcquisitionReceipts: true }); await this.ctx.storage.deleteAlarm(); for (const [worktreeId, receipt] of await loadWorktreeDeletionJournals(this.ctx.storage)) { if (receipt.resourcesCleaned) { @@ -1818,7 +1818,7 @@ export class SandboxControl extends DurableObject { return next; } - async eraseRecord(): Promise { + async eraseRecord(options?: { preserveAcquisitionReceipts: true }): Promise { await eraseSandboxRecord(this.ctx.storage); await this.ctx.storage.delete([ OWNER_ID_KEY, @@ -1830,6 +1830,7 @@ export class SandboxControl extends DurableObject { BILLING_INPUT_KEY, CREDENTIAL_POLICY_DIRTY_KEY, PROVIDER_LOCATOR_KEY, + ...(options?.preserveAcquisitionReceipts ? [] : [ACQUISITION_RECEIPTS_KEY]), ]); this.vercelLocator = undefined; this.activeConnection = null; diff --git a/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts b/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts index 702dbb887a..91e43215a3 100644 --- a/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts @@ -144,6 +144,11 @@ async function harness( async get(key: string): Promise { return structuredClone(records.get(key)) as T | undefined; }, + async list(options?: { prefix?: string }) { + return new Map( + structuredClone([...records].filter(([key]) => key.startsWith(options?.prefix ?? ''))) + ); + }, async put(key: string | Record, value?: unknown) { if (typeof key === 'string') records.set(key, structuredClone(value)); else @@ -262,6 +267,7 @@ async function harness( .mockResolvedValue({ type: 'response', requestId: 'request_1', ok: true }); const socket = { getConnectionIdentity: () => connection, + hasHandshakenSocket: () => connection !== null, closeAll: vi.fn(() => { connection = null; }), @@ -563,6 +569,9 @@ describe('SandboxControl lifecycle boundaries', () => { expect(h.alarmAt).toBe(alarmAt); await expect(h.acquire(acquisition)).rejects.toThrow('acquisition expired'); expect(h.allocations.size).toBe(1); + const receipts = structuredClone(h.records.get('acquisition_receipts')); + await h.control.eraseRecord({ preserveAcquisitionReceipts: true }); + expect(h.records.get('acquisition_receipts')).toEqual(receipts); await h.control.eraseRecord(); expect(h.records.has('acquisition_receipts')).toBe(false); }); diff --git a/services/cloud-agent-next/src/session/legacy-prepared-admission.ts b/services/cloud-agent-next/src/session/legacy-prepared-admission.ts index 7b8024e543..3d6f07b150 100644 --- a/services/cloud-agent-next/src/session/legacy-prepared-admission.ts +++ b/services/cloud-agent-next/src/session/legacy-prepared-admission.ts @@ -7,7 +7,7 @@ import type { QueueAckResponse } from '../router/schemas.js'; import { logger } from '../logger.js'; import { recordCloudAgentSessionFailure } from '../telemetry/session-reports.js'; import type { Env } from '../types.js'; -import type { SessionId, UserId } from '../types/ids.js'; +import type { SessionId } from '../types/ids.js'; import { withDORetry } from '../utils/do-retry.js'; import { resolveSessionStub } from '../sandbox-session/session-stub.js'; import { projectAdmissionToPublicAck, throwAdmissionError } from './queue-message.js'; @@ -22,7 +22,7 @@ export async function replayLegacyPreparedInitialMessageIfAlreadyAdmitted( ): Promise { const sessionId = input.cloudAgentSessionId as SessionId; const request: LegacyRegisteredInitialAdmissionRequest = { - userId: ctx.userId as UserId, + userId: ctx.userId, botId: ctx.botId, }; const result = await withDORetry< @@ -65,7 +65,7 @@ export async function admitLegacyPreparedInitialMessage( ): Promise { const sessionId = input.cloudAgentSessionId as SessionId; const request: LegacyRegisteredInitialAdmissionRequest = { - userId: ctx.userId as UserId, + userId: ctx.userId, botId: ctx.botId, }; let result: SessionMessageAdmissionResult; diff --git a/services/cloud-agent-next/src/session/queue-message.ts b/services/cloud-agent-next/src/session/queue-message.ts index d38928aaf4..770b1d6939 100644 --- a/services/cloud-agent-next/src/session/queue-message.ts +++ b/services/cloud-agent-next/src/session/queue-message.ts @@ -14,7 +14,7 @@ import type { SubmittedSessionMessageRequest, RetryableResultCode, } from '../execution/types.js'; -import type { SessionId, UserId } from '../types/ids.js'; +import type { SessionId } from '../types/ids.js'; import type { Env } from '../types.js'; import type { CloudAgentSession } from '../persistence/CloudAgentSession.js'; import type { QueueAckResponse } from '../router/schemas.js'; @@ -160,7 +160,7 @@ export async function queueMessage( ): Promise { const sessionId = input.cloudAgentSessionId as SessionId; const request: SubmittedSessionMessageRequest = { - userId: ctx.userId as UserId, + userId: ctx.userId, botId: ctx.botId, turn: { ...input.turn, diff --git a/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts b/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts index 833451cfc8..41b61df1f4 100644 --- a/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts @@ -469,6 +469,7 @@ async function createFixture(ownerId?: string, organizationId?: string): Promise }); }); + const messageId = createMessageId(); const admission = await runInDurableObject(sessionStub, instance => instance.createSessionWithInitialAdmission({ identity: sessionIdentity, @@ -476,14 +477,14 @@ async function createFixture(ownerId?: string, organizationId?: string): Promise agent: { mode: 'code', model: 'test-model' }, workspace: { sandboxId, sandboxProvider: 'cloudflare' }, message: { - initialTurn: { messageId: createMessageId(), type: 'prompt', prompt: 'Prepare workspace' }, + initialTurn: { messageId, type: 'prompt', prompt: 'Prepare workspace' }, }, }) ); if (!admission.success) throw new Error(`Session admission failed: ${admission.error}`); await prepared.promise; await vi.waitFor(async () => { - await expect(sessionStub.getMessageResult(`msg_${identity}`)).resolves.toMatchObject({ + await expect(sessionStub.getMessageResult(messageId)).resolves.toMatchObject({ type: 'found', result: { status: 'running' }, }); diff --git a/services/session-ingest/src/session-ingest-rpc.ts b/services/session-ingest/src/session-ingest-rpc.ts index 230ff1aa34..1366190209 100644 --- a/services/session-ingest/src/session-ingest-rpc.ts +++ b/services/session-ingest/src/session-ingest-rpc.ts @@ -521,10 +521,11 @@ export class SessionIngestRPC extends WorkerEntrypoint implements SessionIn const sessions: CloudAgentRootSessionSummary[] = []; for (const row of rows) { if (!row.cloudAgentSessionId) continue; + const title: string | null = row.title; sessions.push({ kiloSessionId: row.kiloSessionId, cloudAgentSessionId: row.cloudAgentSessionId, - title: row.title?.slice(0, MAX_CLOUD_AGENT_ROOT_SESSION_TITLE_CHARACTERS) ?? null, + title: title?.slice(0, MAX_CLOUD_AGENT_ROOT_SESSION_TITLE_CHARACTERS) ?? null, created: databaseTimestampToMilliseconds(row.createdAt), updated: databaseTimestampToMilliseconds(row.updatedAt), }); From 75bb1a2f0e927209373493c2f23bae1f44577364 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Sun, 30 Aug 2026 08:29:04 +0200 Subject: [PATCH 04/14] fix(cloud-agent-next): preserve unrelated sync during deletion probes --- .../src/persistence/SandboxControl.ts | 39 ++- .../integration/worktree-deletion.test.ts | 291 +++++++++++++++++- 2 files changed, 323 insertions(+), 7 deletions(-) diff --git a/services/cloud-agent-next/src/persistence/SandboxControl.ts b/services/cloud-agent-next/src/persistence/SandboxControl.ts index 7fbf555217..a4eb14fbb2 100644 --- a/services/cloud-agent-next/src/persistence/SandboxControl.ts +++ b/services/cloud-agent-next/src/persistence/SandboxControl.ts @@ -444,8 +444,7 @@ export class SandboxControl extends DurableObject { } async request(input: SandboxControlOutboundRequest): Promise { - if (input.session) - this.assertWorktreeAdmission(this.worktreeIdFromDirectory(input.session.directory)); + await this.assertRequestWorktreeAdmission(input); if (input.operation === 'worktree.delete' || input.operation === 'worktree.prepareDeletion') { throw new Error('Worktree cleanup requires the deletion coordinator'); } @@ -530,9 +529,8 @@ export class SandboxControl extends DurableObject { if (!isCurrent()) throw new Error('Sandbox wrapper runtime changed'); }); } + await this.assertRequestWorktreeAdmission(input); if (!isCurrent()) throw new Error('Sandbox wrapper runtime changed'); - if (input.session) - this.assertWorktreeAdmission(this.worktreeIdFromDirectory(input.session.directory)); return this.socketHandler.sendRequest(input); } @@ -1441,6 +1439,39 @@ export class SandboxControl extends DurableObject { return parsed.success ? parsed.data : undefined; } + private async assertRequestWorktreeAdmission( + input: SandboxControlOutboundRequest + ): Promise { + const session = input.session; + if (!session) return; + const worktreeId = this.worktreeIdFromDirectory(session.directory); + if (input.operation === 'session.sync' && this.exclusiveDeletionWorktreeId) { + const allowed = await this.ctx.storage.transaction(async () => { + const exclusiveWorktreeId = this.exclusiveDeletionWorktreeId; + if (!exclusiveWorktreeId) return false; + const route = (await loadRouteTable(this.ctx.storage)).get(session.sessionId); + const routeWorktreeId = route?.worktreeId ?? worktreeId; + if ( + !route || + route.kiloSessionId !== session.kiloSessionId || + route.directory !== session.directory || + routeWorktreeId === exclusiveWorktreeId || + (routeWorktreeId && this.deletingWorktrees.has(routeWorktreeId)) || + (worktreeId && this.deletingWorktrees.has(worktreeId)) + ) { + return false; + } + const journal = await loadWorktreeDeletionJournal(this.ctx.storage, exclusiveWorktreeId); + return ( + this.exclusiveDeletionWorktreeId === exclusiveWorktreeId && + journal?.exclusiveTeardown === false + ); + }); + if (allowed) return; + } + this.assertWorktreeAdmission(worktreeId); + } + private assertWorktreeAdmission(worktreeId?: string): void { if ( this.exclusiveDeletionWorktreeId || diff --git a/services/cloud-agent-next/test/integration/worktree-deletion.test.ts b/services/cloud-agent-next/test/integration/worktree-deletion.test.ts index d2e35374a4..d0f4d027c2 100644 --- a/services/cloud-agent-next/test/integration/worktree-deletion.test.ts +++ b/services/cloud-agent-next/test/integration/worktree-deletion.test.ts @@ -28,7 +28,10 @@ import { } from '../../src/sandbox-control/vercel-provider'; import { parseVercelSandboxRuntimeConfig } from '../../src/agent-sandbox/vercel/vercel-runtime-config'; import type { VercelSandboxSession } from '../../src/agent-sandbox/vercel/vercel-sandbox-rest-client'; -import { loadWorktreeDeletionJournal } from '../../src/sandbox-control/worktree-deletion'; +import { + loadWorktreeDeletionJournal, + WORKTREE_DELETION_PREFIX, +} from '../../src/sandbox-control/worktree-deletion'; import { resolveSandboxExclusivity } from '../../src/sandbox-control/worktree-ownership'; import type { RequestFrame } from '../../src/shared/sandbox-control-protocol'; @@ -262,7 +265,7 @@ function nextFrame(socket: WebSocket): Promise { ); } -async function connectControl(sandboxId: string, credential: string) { +async function connectControl(sandboxId: string, credential: string, wrapperInstanceId?: string) { const control = env.SANDBOX_CONTROL.getByName(sandboxId); const response = await control.fetch( new Request('https://worker.test/control', { @@ -279,7 +282,11 @@ async function connectControl(sandboxId: string, credential: string) { type: 'request', requestId: 'hello', operation: 'sandbox.hello', - payload: { protocolVersion: 1, providerInstanceId }, + payload: { + protocolVersion: 1, + providerInstanceId, + ...(wrapperInstanceId ? { wrapperInstanceId } : {}), + }, }) ); await hello; @@ -1082,6 +1089,284 @@ describe('worktree deletion in Durable Objects', () => { }); }); + it.each([false, true])( + 'keeps an unrelated accepted root waiting on a question healthy during deletion=%s', + async duringDeletion => { + const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '').padEnd(48, '0')}` as const; + const root = cloudId(); + const sibling = cloudId(); + const wrapperInstanceId = crypto.randomUUID(); + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(sandboxId); + const session = env.SANDBOX_SESSION.getByName(`${userId}:${sibling}`); + const siblingIdentity = { + sessionId: sibling, + kiloSessionId: kiloId(99), + directory: otherDirectory, + }; + const question = { id: 'question_sibling', sessionID: kiloId(99), questions: [] }; + const providerRef = encodeCloudflareProviderRef({ + sandboxId, + instanceId: 'shared-sync', + containment: true, + }); + const stop = vi.fn(async () => 'terminal'); + const create = vi.fn(async () => { + throw new Error('Read-only synchronization must not allocate'); + }); + const provider: ProviderAdapter = { + resumable: false, + ensureBillingAdmission: async () => undefined, + create, + launch: async () => { + throw new Error('Read-only synchronization must not launch'); + }, + observe: async () => ({ + status: stop.mock.calls.length ? 'terminal' : 'active', + providerRef, + }), + stop, + ensureLeaseAtLeast: async () => undefined, + logs: async () => '', + }; + const registered = registration(sibling, sandboxId); + await session.registerSession({ + ...registered, + auth: { ...registered.auth, kiloSessionId: siblingIdentity.kiloSessionId }, + workspace: { + ...registered.workspace, + worktreeId: otherWorktreeId, + workspacePath: otherDirectory, + }, + }); + await runInDurableObject(session, async (_instance, state) => { + const acceptedAt = Date.now() - DEADLINE_MS.acceptedOverdue - 1_000; + await state.storage.put('session_messages', [ + { + messageId: 'msg_waiting_for_answer', + prompt: 'Wait for my answer', + state: 'accepted', + acceptedAt, + lastActivityAt: acceptedAt, + wrapperInstanceId, + }, + ]); + }); + await runInDurableObject(control, async (instance, state) => { + Object.assign(instance, { provider, createProviderAdapter: () => provider }); + await instance.initializeOwner(userId); + await seedPhysical(instance, state, 'shared-sync', providerRef); + for (const route of [ + { sessionId: root, kiloSessionId: kiloId(0), directory, worktreeId }, + { ...siblingIdentity, worktreeId: otherWorktreeId }, + ]) { + await attachGrantedSession(instance, state, { ...route, ownerId: userId }, 'cloudflare'); + } + }); + await control.setWrapperCredentialHash(await hashSandboxCredential(credential)); + const socket = await connectControl(sandboxId, credential, wrapperInstanceId); + const requests: RequestFrame[] = []; + socket.addEventListener('message', event => { + const frame = JSON.parse(String(event.data)) as RequestFrame; + requests.push(frame); + reply( + socket, + frame, + frame.operation === 'session.sync' + ? { status: { type: 'idle' }, questions: [question], permissions: [] } + : frame.operation === 'worktree.prepareDeletion' + ? { prepared: true, sessionIds: [kiloId(0)] } + : { deleted: true, sessionIds: [kiloId(0)] } + ); + }); + socket.send( + JSON.stringify({ + type: 'event', + event: 'sandbox.ready', + payload: { kiloReady: true, globalFeedAttached: true }, + }) + ); + try { + await vi.waitFor(async () => { + expect(await control.getStatus()).toMatchObject({ + connection: 'ready', + wrapperInstanceId, + }); + }); + await runInDurableObject(control, async (instance, state) => { + const siblingSession = instance['env'].SANDBOX_SESSION.getByName(`${userId}:${sibling}`); + const leaseStarted = Promise.withResolvers(); + const releaseLease = Promise.withResolvers(); + provider.ensureLeaseAtLeast = async () => { + leaseStarted.resolve(); + await releaseLease.promise; + }; + let heartbeat: Promise | undefined; + let deletion: ReturnType | undefined; + try { + if (duringDeletion) { + const wrapper = state.getWebSockets('sandbox-control')[0]; + if (!wrapper) throw new Error('Missing connected wrapper'); + heartbeat = instance.webSocketMessage( + wrapper, + JSON.stringify({ + type: 'event', + event: 'sandbox.heartbeat', + payload: { + state: 'active', + kilo: { ready: true }, + sessions: [ + { + kiloSessionId: kiloId(99), + state: 'active', + idleForMs: 0, + waitingOn: 'input', + }, + ], + }, + }) + ); + await leaseStarted.promise; + deletion = instance.deleteWorktreeResources({ + worktreeId, + kiloUserId: userId, + location: { sandboxId, provider: 'cloudflare' }, + sessionIds: [kiloId(0)], + }); + await vi.waitFor(async () => { + expect(await state.storage.get('exclusive_worktree_deletion')).toBe(worktreeId); + }); + for (const identity of [ + { sessionId: root, kiloSessionId: kiloId(0), directory }, + { ...siblingIdentity, sessionId: cloudId(), kiloSessionId: kiloId(100) }, + { ...siblingIdentity, kiloSessionId: kiloId(0) }, + { ...siblingIdentity, directory }, + ]) { + await expect( + instance.request({ + operation: 'session.sync', + session: identity, + expectedWrapperInstanceId: wrapperInstanceId, + payload: {}, + }) + ).rejects.toThrow('worktree_deleting'); + } + await expect( + instance.request({ + operation: 'session.sync', + session: siblingIdentity, + expectedWrapperInstanceId: crypto.randomUUID(), + payload: {}, + }) + ).rejects.toThrow('Sandbox wrapper runtime changed'); + for (const operation of ['session.attach', 'session.prompt'] as const) { + await expect( + instance.request({ + operation, + session: siblingIdentity, + expectedWrapperInstanceId: wrapperInstanceId, + payload: {}, + }) + ).rejects.toThrow('worktree_deleting'); + } + await expect( + instance.attachSession({ + ...siblingIdentity, + ownerId: userId, + worktreeId: otherWorktreeId, + }) + ).rejects.toThrow('worktree_deleting'); + await expect( + instance.ensureReady({ + ownerId: userId, + sessionId: sibling, + worktreeId: otherWorktreeId, + allowCreate: true, + }) + ).rejects.toThrow('worktree_deleting'); + await expect(instance.claimCreate('blocked-allocation')).rejects.toThrow( + 'worktree_deleting' + ); + const journal = await loadWorktreeDeletionJournal(state.storage, worktreeId); + if (!journal) throw new Error('Missing deletion journal'); + await state.storage.put(`${WORKTREE_DELETION_PREFIX}${worktreeId}`, { + ...journal, + exclusiveTeardown: true, + }); + try { + await expect( + instance.request({ + operation: 'session.sync', + session: siblingIdentity, + expectedWrapperInstanceId: wrapperInstanceId, + payload: {}, + }) + ).rejects.toThrow('worktree_deleting'); + } finally { + await state.storage.put(`${WORKTREE_DELETION_PREFIX}${worktreeId}`, journal); + } + } + const deadlines = await loadDeadlines(state.storage); + await runInDurableObject(siblingSession, async (siblingInstance, siblingState) => { + await siblingInstance.alarm(); + expect(await siblingState.storage.get('session_messages')).toMatchObject([ + { messageId: 'msg_waiting_for_answer', state: 'accepted', wrapperInstanceId }, + ]); + expect(await siblingState.storage.get('session_pending_interactions')).toMatchObject({ + questions: [question], + permissions: [], + }); + expect(await siblingState.storage.get('pending_runtime_cleanup')).toBeUndefined(); + }); + expect(requests).toEqual([ + expect.objectContaining({ operation: 'session.sync', session: siblingIdentity }), + ]); + expect(await loadDeadlines(state.storage)).toEqual(deadlines); + expect(await instance.getStatus()).toMatchObject({ + physical: 'running', + connection: 'ready', + wrapperInstanceId, + }); + releaseLease.resolve(); + await heartbeat; + await deletion; + if (duringDeletion) { + await expect( + instance.request({ + operation: 'session.sync', + session: { sessionId: root, kiloSessionId: kiloId(0), directory }, + expectedWrapperInstanceId: wrapperInstanceId, + payload: {}, + }) + ).rejects.toThrow('worktree_deleting'); + } + expect(stop).not.toHaveBeenCalled(); + expect(create).not.toHaveBeenCalled(); + expect((await instance.getPhysicalRecord()).providerRef).toBe(providerRef); + expect(await instance.listRoutes()).toEqual( + duringDeletion + ? [expect.objectContaining(siblingIdentity)] + : [ + expect.objectContaining({ sessionId: root }), + expect.objectContaining(siblingIdentity), + ] + ); + } finally { + releaseLease.resolve(); + await heartbeat; + await deletion; + await state.storage.deleteAlarm(); + await runInDurableObject(siblingSession, (_instance, siblingState) => + siblingState.storage.deleteAlarm() + ); + } + }); + } finally { + socket.close(); + } + } + ); + it('journals discovered descendants before cleanup and preserves unrelated shared routes on partial failure and retry', async () => { const sandboxId = `usr-${crypto.randomUUID().replaceAll('-', '')}`; const control = env.SANDBOX_CONTROL.getByName(sandboxId); From 18e46236da512dc10724e538c542713e20962582 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Sun, 30 Aug 2026 11:21:07 +0200 Subject: [PATCH 05/14] fix(session-ingest): preserve Cloud Agent root provenance --- .../src/ingest/metadata.test.ts | 78 +++++++++++++++++++ .../session-ingest/src/ingest/metadata.ts | 5 +- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/services/session-ingest/src/ingest/metadata.test.ts b/services/session-ingest/src/ingest/metadata.test.ts index c453c8deb3..a897aaa461 100644 --- a/services/session-ingest/src/ingest/metadata.test.ts +++ b/services/session-ingest/src/ingest/metadata.test.ts @@ -649,6 +649,84 @@ describe('applyMetadataChanges', () => { expect(db.applyUpdate).toHaveBeenCalled(); }); + it.each([ + [null, 'cloud-agent'], + ['cloud-agent-session-scope-1', 'cloud-agent'], + ['cloud-agent-session-scope-1', 'cloud-agent-web'], + ['cloud-agent-session-scope-1', null], + ] as const)( + 'preserves root platform provenance without notifications (scope: %s, platform: %s)', + async (cloudAgentSessionScopeId, platform) => { + const db = createApplyMetadataDb({ + cloudAgentSessionId: 'cloud-agent-session-1', + cloudAgentSessionScopeId, + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + await applyMetadataChanges(env, 'usr_1', 'ses_1', new Map([['platform', platform]])); + + expect(db.updateSets).toEqual([]); + expect(db.applyUpdate).not.toHaveBeenCalled(); + expect(notifyUserSessionEvent).not.toHaveBeenCalled(); + } + ); + + it('preserves root platform provenance while applying and notifying other metadata changes', async () => { + const db = createApplyMetadataDb({ + cloudAgentSessionId: 'cloud-agent-session-1', + cloudAgentSessionScopeId: 'cloud-agent-session-1', + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + await applyMetadataChanges( + env, + 'usr_1', + 'ses_1', + new Map([ + ['platform', 'cloud-agent'], + ['title', 'Agent title'], + ['gitBranch', 'feature/provenance'], + ['status', 'busy'], + ]) + ); + + expect(db.updateSets).toEqual([ + { + title: 'Agent title', + git_branch: 'feature/provenance', + status: 'busy', + status_updated_at: expect.any(String), + }, + ]); + expect(notifyUserSessionEvent).toHaveBeenCalledWith( + env, + 'usr_1', + expect.objectContaining({ type: 'session.updated' }), + undefined + ); + }); + + it.each([null, 'cloud-agent-session-scope-1'])( + 'accepts platform metadata without a registered Cloud Agent root (scope: %s)', + async cloudAgentSessionScopeId => { + const db = createApplyMetadataDb({ + cloudAgentSessionScopeId, + parentSessionId: cloudAgentSessionScopeId ? 'ses_root' : null, + }); + vi.mocked(getWorkerDb).mockReturnValue(db as never); + + await applyMetadataChanges(env, 'usr_1', 'ses_1', new Map([['platform', 'vscode']])); + + expect(db.updateSets).toEqual([{ created_on_platform: 'vscode' }]); + expect(notifyUserSessionEvent).toHaveBeenCalledWith( + env, + 'usr_1', + expect.objectContaining({ type: 'session.updated' }), + undefined + ); + } + ); + it('refuses organization metadata changes for Cloud Agent session-scoped sessions', async () => { const db = createApplyMetadataDb({ cloudAgentSessionScopeId: 'cloud-agent-session-scope-1', diff --git a/services/session-ingest/src/ingest/metadata.ts b/services/session-ingest/src/ingest/metadata.ts index 502a76f5be..58f58fd821 100644 --- a/services/session-ingest/src/ingest/metadata.ts +++ b/services/session-ingest/src/ingest/metadata.ts @@ -143,6 +143,9 @@ export async function applyMetadataChanges( const hasCloudAgentSessionScope = cloudAgentSessionScopeId != null; const isCloudAgentManagedSession = hasCloudAgentSessionScope || currentRow.cloudAgentSessionId != null; + if (currentRow.cloudAgentSessionId != null) { + delete updates.created_on_platform; + } const statusChange = status === undefined @@ -344,7 +347,7 @@ export async function applyMetadataChanges( // Refused org/parent/title claims must not emit phantom session.updated events. const changedNonStatus = titleWriteApplied || - mergedChanges.has('platform') || + updates.created_on_platform !== undefined || organizationIdWriteApplied || gitUrlWriteApplied || mergedChanges.has('gitBranch') || From bceab3def146c6ac6dc637c7a24f94cdadea74d2 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Sun, 30 Aug 2026 11:34:43 +0200 Subject: [PATCH 06/14] fix(cloud-agent-next): replay pending questions on reconnect --- .../test/integration/sandbox-control.test.ts | 26 +++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/services/cloud-agent-next/test/integration/sandbox-control.test.ts b/services/cloud-agent-next/test/integration/sandbox-control.test.ts index 70a5b9d7f0..a0eb8bea2a 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control.test.ts @@ -10429,7 +10429,7 @@ describe('SandboxSession running stream state', () => { }); describe('SandboxSession root-scoped reconnect sync', () => { - it('reconciles an accepted root before connected and filters sibling questions and permissions', async () => { + it('reconciles an accepted root before connected and replays only its questions and permissions afterward', async () => { const wrapperInstanceId = crypto.randomUUID(); const ownerId = 'user_grouped_sync'; const activeSessionId = GRANT_SESSION_ID; @@ -10545,12 +10545,16 @@ describe('SandboxSession root-scoped reconnect sync', () => { throw new Error(`Unexpected active-session stream status: ${activeResponse.status}`); } activeResponse.webSocket.accept(); - const events = (await nextMessages(activeResponse.webSocket, 4)).map( + const events = (await nextMessages(activeResponse.webSocket, 8)).map( message => JSON.parse(message) as SessionStreamEvent ); expect(events.map(event => event.streamEventType)).toEqual([ 'kilocode', 'connected', + 'kilocode', + 'kilocode', + 'kilocode', + 'kilocode', 'cloud.message.queued', 'cloud.message.sent', ]); @@ -10559,6 +10563,24 @@ describe('SandboxSession root-scoped reconnect sync', () => { activeMessageId: INITIAL_MESSAGE_ID, pendingInteractions: { questions, permissions }, }); + expect(events.slice(2, 4)).toEqual( + questions.map(question => + expect.objectContaining({ + eventId: 0, + sessionId: activeSessionId, + data: { type: 'question.asked', event: 'question.asked', properties: question }, + }) + ) + ); + expect(events.slice(4, 6)).toEqual( + permissions.map(permission => + expect.objectContaining({ + eventId: 0, + sessionId: activeSessionId, + data: { type: 'permission.asked', event: 'permission.asked', properties: permission }, + }) + ) + ); await runInDurableObject(active, async (_instance, state) => { expect(await state.storage.get('session_pending_interactions')).toMatchObject({ questions, From 36102f7f41b3b2984d4ef0e12734977efcdb9c5b Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Mon, 31 Aug 2026 14:25:31 +0200 Subject: [PATCH 07/14] feat(cloud-agent-next): gate new worktree creation --- ENVIRONMENT.md | 3 +- services/cloud-agent-next/.dev.vars.example | 3 + .../src/callbacks/queue-config.test.ts | 4 +- .../router/handlers/session-worktree.test.ts | 3 +- .../src/session-plane.test.ts | 30 +++ .../cloud-agent-next/src/session-plane.ts | 10 + .../src/session/session-prepare.test.ts | 216 +++++++++++++++++- .../src/session/session-registration.ts | 63 +++-- services/cloud-agent-next/src/types.ts | 1 + services/cloud-agent-next/test/e2e/README.md | 12 + services/cloud-agent-next/test/e2e/run.ts | 12 +- .../worker-configuration.d.ts | 6 +- services/cloud-agent-next/wrangler.jsonc | 2 + 13 files changed, 334 insertions(+), 31 deletions(-) diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index cdd56218cb..9151754fad 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -362,7 +362,8 @@ When `VERCEL_TARGET_ENV` is absent in local development or a script process, tra - `KILO_BIN_PATH` - Path or name of the `kilo` CLI binary; used by `services/cloud-agent-next/scripts/update-default-slash-commands.mjs`. [SERVER] - `WORKSPACE_PATH` - Filesystem path of the agent workspace. [SERVER] - `SESSION_ID` - Reserved session identifier for the `cloud-agent-next` runtime; reserved in `RESERVED_ENV_VARS`. [SERVER] -- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. [SERVER] +- `CONTROL_PLANE_IDS` - Comma-separated user or org IDs admitted to the call-home control plane at session creation. Empty admits nobody. `*` includes personal accounts. Does not enable new worktree creation by itself; that also requires `WORKTREE_CREATION_ENABLED_IDS` enrollment. [SERVER] +- `WORKTREE_CREATION_ENABLED_IDS` - Comma-separated user or org IDs allowed to create new worktrees, or `*` for all, including personal accounts. Defaults to empty/off and also requires enrollment in `CONTROL_PLANE_IDS`. Disabling it does not block existing worktrees or sibling chats in them. [SERVER] - `VERCEL_SANDBOX_ORG_IDS` - Comma-separated org IDs routed to Vercel sandboxes. Empty is off. `*` includes personal accounts. [SERVER] - `HOME` - Reserved in `RESERVED_ENV_VARS` for cloud-agent-next session home management. [SYSTEM] diff --git a/services/cloud-agent-next/.dev.vars.example b/services/cloud-agent-next/.dev.vars.example index c92dfd1c5e..57b35c5279 100644 --- a/services/cloud-agent-next/.dev.vars.example +++ b/services/cloud-agent-next/.dev.vars.example @@ -41,6 +41,9 @@ REAPER_INTERVAL_MS=300000 # Comma-separated list of org IDs that use per-session Cloudflare sandboxes, or `*` for all orgs PER_SESSION_SANDBOX_ORG_IDS= +CONTROL_PLANE_IDS= +WORKTREE_CREATION_ENABLED_IDS= + CREDENTIAL_CONTAINMENT_ENABLED=false # Repo snapshot org IDs (optional) diff --git a/services/cloud-agent-next/src/callbacks/queue-config.test.ts b/services/cloud-agent-next/src/callbacks/queue-config.test.ts index 1410e0f56c..a8f7caef3f 100644 --- a/services/cloud-agent-next/src/callbacks/queue-config.test.ts +++ b/services/cloud-agent-next/src/callbacks/queue-config.test.ts @@ -37,12 +37,14 @@ describe('callback queue retry configuration', () => { expect(dev?.max_retries).toBe(CONFIGURED_REDELIVERIES); }); - it('keeps Vercel sandbox enrollment disabled by default', () => { + it('keeps Vercel, control-plane, and worktree enrollment disabled by default', () => { const config = readWranglerConfig(); expect(config.vars?.VERCEL_SANDBOX_ORG_IDS).toBe(''); expect(config.vars?.CONTROL_PLANE_IDS).toBe(''); + expect(config.vars?.WORKTREE_CREATION_ENABLED_IDS).toBe(''); expect(config.env?.dev?.vars?.VERCEL_SANDBOX_ORG_IDS).toBe(''); expect(config.env?.dev?.vars?.CONTROL_PLANE_IDS).toBe(''); + expect(config.env?.dev?.vars?.WORKTREE_CREATION_ENABLED_IDS).toBe(''); }); }); diff --git a/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts index 0f3e25f7c7..1cf94a29e7 100644 --- a/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts @@ -266,6 +266,7 @@ function fixture(options?: { env: { INTERNAL_API_SECRET: INTERNAL_SECRET, CONTROL_PLANE_IDS: options?.controlPlaneIds ?? '*', + WORKTREE_CREATION_ENABLED_IDS: '', HYPERDRIVE: { connectionString: 'postgres://worktree-handler-test' }, SANDBOX_SESSION: sandboxSessionNamespace, CLOUD_AGENT_SESSION: legacySessionNamespace, @@ -528,7 +529,7 @@ describe('createWorktreeChat request validation and authorization', () => { }); describe('createWorktreeChat ownership, metadata, and control-plane routing', () => { - it('records canonical IDs first, preserves the source route, and registers an empty control-plane chat', async () => { + it('records canonical IDs and source routing while new worktree creation is disabled', async () => { const { caller, input, diff --git a/services/cloud-agent-next/src/session-plane.test.ts b/services/cloud-agent-next/src/session-plane.test.ts index 6cfb2532a9..d9deefd912 100644 --- a/services/cloud-agent-next/src/session-plane.test.ts +++ b/services/cloud-agent-next/src/session-plane.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { generateSessionId, isControlPlaneOwner, + isWorktreeOwner, sessionPlaneForNewOwner, sessionPlaneFromId, sessionSupportsTerminal, @@ -47,6 +48,35 @@ describe('session plane identity', () => { expect(isControlPlaneOwner({ CONTROL_PLANE_IDS: 'user-1' }, { userId: 'user-2' })).toBe(false); }); + it.each([ + [undefined, { userId: 'user-1' }, false], + ['', { userId: 'user-1' }, false], + [' , ', { userId: 'user-1' }, false], + ['user-1', { userId: 'user-1' }, true], + ['user-1', { userId: 'user-2' }, false], + ['org-1', { userId: 'user-2', orgId: 'org-1' }, true], + ['org-1', { userId: 'user-2' }, false], + [' other, org-1, ', { userId: 'user-2', orgId: 'org-1' }, true], + ['*', { userId: 'user-3' }, true], + [' oauth/google:1234 ', { userId: 'oauth/google:1234' }, true], + ] as const)( + 'matches WORKTREE_CREATION_ENABLED_IDS=%s against %j as %s', + (ids, owner, expected) => { + expect(isWorktreeOwner({ WORKTREE_CREATION_ENABLED_IDS: ids }, owner)).toBe(expected); + } + ); + + it('keeps worktree enrollment independent of control-plane routing', () => { + const owner = { userId: 'user-1' }; + const controlOnly = { CONTROL_PLANE_IDS: '*', WORKTREE_CREATION_ENABLED_IDS: '' }; + const worktreeOnly = { CONTROL_PLANE_IDS: '', WORKTREE_CREATION_ENABLED_IDS: '*' }; + + expect(sessionPlaneForNewOwner(controlOnly, owner)).toBe('control'); + expect(isWorktreeOwner(controlOnly, owner)).toBe(false); + expect(sessionPlaneForNewOwner(worktreeOnly, owner)).toBe('legacy'); + expect(isWorktreeOwner(worktreeOnly, owner)).toBe(true); + }); + it('supports control-plane terminals independently of legacy provider capabilities', () => { const legacySessionId = 'agent_12345678-1234-1234-1234-123456789abc'; const controlSessionId = 'workspace_12345678-1234-1234-1234-123456789abc'; diff --git a/services/cloud-agent-next/src/session-plane.ts b/services/cloud-agent-next/src/session-plane.ts index 85c2beb9e2..1b4d90e942 100644 --- a/services/cloud-agent-next/src/session-plane.ts +++ b/services/cloud-agent-next/src/session-plane.ts @@ -30,6 +30,16 @@ export function isControlPlaneOwner( ); } +export function isWorktreeOwner( + env: { WORKTREE_CREATION_ENABLED_IDS?: string }, + owner: { userId: string; orgId?: string } +): boolean { + return ( + ownerIdInList(env.WORKTREE_CREATION_ENABLED_IDS, owner.userId) || + ownerIdInList(env.WORKTREE_CREATION_ENABLED_IDS, owner.orgId) + ); +} + export function sessionPlaneForNewOwner( env: ControlPlaneOwnerEnv, owner: { userId: string; orgId?: string } diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index d9ad228556..6fa5b5d9dd 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -28,6 +28,7 @@ import { SESSION_CREATE_ABANDONED_OUTCOME_CODE, SESSION_CREATE_INTENT_FINGERPRINT_KEY, SESSION_CREATE_TOMBSTONED_IDS_KEY, + SESSION_CREATE_WORKTREE_ENABLED_KEY, type SessionRegistrationContext, } from './session-registration.js'; import { prepareInputToSessionCreateRequest } from '../router/handlers/session-prepare.js'; @@ -580,6 +581,7 @@ describe('createSessionWithLedger admission ladder', () => { const doStub = makeDoStub(); const ctx = makeContext(doStub); ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); const request = makeRequest({ repository: { type: 'github', repo: 'acme/repo', branch: 'main' }, @@ -624,6 +626,7 @@ describe('createSessionWithLedger admission ladder', () => { ROW_ID, expect.objectContaining({ cloudAgentSessionId: WORKSPACE_SESSION_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: true, createIntentFingerprint: await sessionCreateIntentFingerprint({ ...request, finalization: { autoCommit: false, condenseOnComplete: true }, @@ -636,7 +639,109 @@ describe('createSessionWithLedger admission ladder', () => { }); }); - it('replays browser creates using their effective disabled auto-commit fingerprint', async () => { + it.each([undefined, '', 'another-user'])( + 'keeps browser creates on the control plane without worktree enrollment: %s', + async worktreeIds => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + if (worktreeIds !== undefined) ctx.env.WORKTREE_CREATION_ENABLED_IDS = worktreeIds; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + + const result = await runCreate( + ctx, + makeRequest({ + finalization: { autoCommit: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }) + ); + + expect(result.cloudAgentSessionId).toBe(WORKSPACE_SESSION_ID); + expect(createCliSessionMock.mock.calls[0]).toHaveLength(9); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + finalization: { autoCommit: true }, + workspace: expect.not.objectContaining({ worktreeId: expect.anything() }), + }) + ); + expect(recordOperationProgressMock).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + ROW_ID, + expect.objectContaining({ [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false }) + ); + } + ); + + it.each([true, false])( + 'replays the original worktree decision after rollout changes from %s', + async worktreeEnabled => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = worktreeEnabled ? '*' : ''; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + const request = makeRequest({ + finalization: { autoCommit: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }); + const original = await runCreate(ctx, request); + const storedProgress: Record = Object.assign( + {}, + ...recordOperationProgressMock.mock.calls.map(call => call[2]) + ); + expect(storedProgress[SESSION_CREATE_WORKTREE_ENABLED_KEY]).toBe(worktreeEnabled); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = worktreeEnabled ? '' : '*'; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_settled', + row: makeLedgerRow({ status: 'completed', canonical_result: storedProgress }), + }); + + await expect(runCreate(ctx, request)).resolves.toEqual({ ...original, replayed: true }); + + expect(createCliSessionMock).toHaveBeenCalledTimes(1); + expect(generateSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ finalization: { autoCommit: !worktreeEnabled } }) + ); + } + ); + + it('rejects malformed persisted worktree decisions without allocating', async () => { + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: { + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: 'true', + }, + }), + }); + const doStub = makeDoStub(); + + await expect(runCreate(makeContext(doStub))).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'creation_in_progress', + }); + + expect(generateSessionIdMock).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + }); + + it('replays pre-flag browser creates with worktrees disabled and their original auto-commit policy', async () => { const request = makeRequest({ finalization: { autoCommit: true }, options: { @@ -686,6 +791,7 @@ describe('createSessionWithLedger admission ladder', () => { const doStub = makeDoStub(); const ctx = { ...makeContext(doStub), userId }; ctx.env.CONTROL_PLANE_IDS = organizationId; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = organizationId; generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); await runCreate( @@ -724,6 +830,7 @@ describe('createSessionWithLedger admission ladder', () => { const doStub = makeDoStub(); const ctx = makeContext(doStub); ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); await runCreate( @@ -756,6 +863,7 @@ describe('createSessionWithLedger admission ladder', () => { const doStub = makeDoStub(); const ctx = { ...makeContext(doStub), ...(botId ? { botId } : {}) }; ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); await runCreate( @@ -785,6 +893,7 @@ describe('createSessionWithLedger admission ladder', () => { const doStub = makeDoStub(); const ctx = makeContext(doStub); ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); await runCreate( @@ -801,11 +910,13 @@ describe('createSessionWithLedger admission ladder', () => { ); }); - it('keeps browser requests on the legacy plane ungrouped', async () => { + it('keeps legacy browser requests ungrouped even with worktree enrollment', async () => { const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; await runCreate( - makeContext(doStub), + ctx, makeRequest({ finalization: { autoCommit: true }, options: { @@ -1128,6 +1239,7 @@ describe('createSessionWithLedger admission ladder', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, createIntentFingerprint: expect.any(String), }); }); @@ -1888,6 +2000,7 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, createIntentFingerprint: expect.any(String), }); expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { @@ -1919,7 +2032,7 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { }); }); -describe('createSessionWithLedger grouped first-create ownership reconciliation', () => { +describe('createSessionWithLedger worktree rollout and ownership reconciliation', () => { const sandboxId = 'ses-0123456789abcdef'; const sharedSandboxId = 'usr-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; @@ -1952,6 +2065,7 @@ describe('createSessionWithLedger grouped first-create ownership reconciliation' function context(doStub: ReturnType): SessionRegistrationContext { const ctx = makeContext(doStub); ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; return ctx; } @@ -1985,7 +2099,7 @@ describe('createSessionWithLedger grouped first-create ownership reconciliation' }; } - it('recovers committed ownership after a lost ingest response without reallocating or duplicating the initial turn', async () => { + it('recovers committed ownership after worktrees are disabled without reallocating or duplicating the initial turn', async () => { const input = request(); const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); const ctx = context(doStub); @@ -1996,6 +2110,7 @@ describe('createSessionWithLedger grouped first-create ownership reconciliation' }); await expect(runCreate(ctx, input)).rejects.toThrow('ownership response lost after commit'); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; expect(ownershipCommitted).toBe(true); expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { rowId: ROW_ID }); @@ -2065,13 +2180,100 @@ describe('createSessionWithLedger grouped first-create ownership reconciliation' expect(deleteCliSessionMock).not.toHaveBeenCalled(); }); - it('retries an uncommitted ownership write with the original workspace, session, worktree, and sandbox', async () => { + it('rejects bot retries of a recorded browser worktree creation', async () => { + const input = request(); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = { ...context(doStub), botId: 'reviewer' }; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input, { + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: true, + }), + }), + }); + getPgDbMock.mockReturnValue(makeDb([[ownershipRow()], [{ email: 'test@example.com' }]])); + + await expect(runCreate(ctx, input)).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'session_creation_failed', + }); + + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(generateSessionIdMock).not.toHaveBeenCalled(); + }); + + it('recovers an ungrouped browser create after worktree rollout is enabled', async () => { + const input = request(); + const doStub = makeDoStub({ + getMetadata: vi.fn().mockResolvedValue({ identity: { sessionId: WORKSPACE_SESSION_ID } }), + getMessageResult: vi.fn().mockResolvedValue({ + type: 'found', + result: { + messageId: INITIAL_MESSAGE_ID, + status: 'queued', + createdAt: 1, + cloudAgentSessionId: WORKSPACE_SESSION_ID, + }, + } satisfies MessageResultRPCResponse), + }); + doStub.createSessionWithInitialAdmission.mockRejectedValueOnce( + new Error('initial admission response lost') + ); + const ctx = context(doStub); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; + + await expect(runCreate(ctx, input)).rejects.toThrow('initial admission response lost'); + const storedProgress: Record = Object.assign( + {}, + ...recordOperationProgressMock.mock.calls.map(call => call[2]) + ); + expect(storedProgress[SESSION_CREATE_WORKTREE_ENABLED_KEY]).toBe(false); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ status: 'reconcile_pending', canonical_result: storedProgress }), + }); + getPgDbMock.mockReturnValue( + makeDb([[ownershipRow({ worktreeId: null })], [{ email: 'test@example.com' }]]) + ); + + await expect(runCreate(ctx, input)).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + + expect(createCliSessionMock).toHaveBeenCalledTimes(1); + expect(createCliSessionMock.mock.calls[0]).toHaveLength(9); + expect(generateSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(doStub.getMessageResult).toHaveBeenCalledWith(INITIAL_MESSAGE_ID); + for (const [payload] of doStub.createSessionWithInitialAdmission.mock.calls) { + expect(payload).toMatchObject({ + identity: { sessionId: WORKSPACE_SESSION_ID }, + finalization: { autoCommit: true, condenseOnComplete: true }, + message: { initialTurn: { messageId: INITIAL_MESSAGE_ID } }, + }); + expect(payload.workspace.worktreeId).toBeUndefined(); + expect(payload.workspace.workspacePath).toBeUndefined(); + } + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + }); + + it('recovers pre-flag ownership progress with worktrees disabled and the original allocation', async () => { const input = request(); const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); const ctx = context(doStub); createCliSessionMock.mockRejectedValueOnce(new Error('ownership request interrupted')); await expect(runCreate(ctx, input)).rejects.toThrow('ownership request interrupted'); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; expect(markReconcilePendingMock).toHaveBeenCalledTimes(1); expect(settleOperationMock).not.toHaveBeenCalled(); @@ -2316,6 +2518,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, createIntentFingerprint: await sessionCreateIntentFingerprint(request), }); expect(result).toEqual({ @@ -3105,6 +3308,7 @@ describe('createSessionWithLedger clone allocation outcomes', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, reportingCreatedAt, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, createIntentFingerprint: await sessionCreateIntentFingerprint(cloneRequest()), }); expect(doStub.registerSession).toHaveBeenCalledWith( diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 6970cf71a3..043edc0d01 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -46,7 +46,7 @@ import { withDORetry } from '../utils/do-retry.js'; import { resolveSessionStub } from '../sandbox-session/session-stub.js'; import { getPgDb } from '../db/pg.js'; import { generateSessionId, SessionService } from '../session-service.js'; -import { sessionPlaneForNewOwner } from '../session-plane.js'; +import { isWorktreeOwner, sessionPlaneForNewOwner } from '../session-plane.js'; import { getWorktreeWorkspacePath } from '../workspace.js'; import { createCloudAgentSessionReport, @@ -205,6 +205,7 @@ type SessionLedgerFailureStage = type SessionCreationLedgerHooks = { db: WorkerDb; rowId: string; + worktreeEnabled: boolean; /** Settle the row `failed` at the given stage. */ onFailure: (stage: SessionLedgerFailureStage, outcomeCode: string) => Promise; /** The DO RPC threw; the commit outcome is unknown. */ @@ -466,22 +467,41 @@ function isEligibleFirstWorktreeSession( input.initialTurn !== undefined && input.clone === undefined && input.runtime?.devcontainer !== true && - ctx.botId === undefined && - sessionPlaneForNewOwner(ctx.env, { - userId: ctx.userId, - orgId: input.options.kilocodeOrganizationId, - }) === 'control' + ctx.botId === undefined ); } -function effectiveSessionRegistrationInput( +function worktreeEnabledForCreate( input: SessionRegistrationInput, ctx: SessionRegistrationContext, - operationKey: string -): SessionRegistrationInput { - if (!isEligibleFirstWorktreeSession(input, ctx) || input.options?.operationKey !== operationKey) { - return input; + row: OperationLedgerRow +): boolean { + const recorded = row.canonical_result?.[SESSION_CREATE_WORKTREE_ENABLED_KEY]; + if (recorded !== undefined) { + if (typeof recorded !== 'boolean') throw creationInProgressError(); + if (recorded && ctx.botId !== undefined) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'session_creation_failed' }); + } + return recorded; } + if ( + !isEligibleFirstWorktreeSession(input, ctx) || + input.options?.operationKey !== row.operation_key + ) { + return false; + } + const recordedIds = canonicalSessionIds(row); + if (recordedIds) return recordedIds.cloudAgentSessionId.startsWith('workspace_'); + + const owner = { userId: ctx.userId, orgId: input.options?.kilocodeOrganizationId }; + return sessionPlaneForNewOwner(ctx.env, owner) === 'control' && isWorktreeOwner(ctx.env, owner); +} + +function effectiveSessionRegistrationInput( + input: SessionRegistrationInput, + worktreeEnabled: boolean +): SessionRegistrationInput { + if (!worktreeEnabled) return input; return { ...input, finalization: { ...input.finalization, autoCommit: false }, @@ -506,9 +526,7 @@ async function allocateNewSession( ? new Date().toISOString() : undefined; const worktreeId = - ledger && - isEligibleFirstWorktreeSession(input, ctx) && - cloudAgentSessionId.startsWith('workspace_') + ledger?.worktreeEnabled && cloudAgentSessionId.startsWith('workspace_') ? cloudAgentWorktreeIdSchema.parse( `worktree_${cloudAgentSessionId.slice('workspace_'.length)}` ) @@ -530,6 +548,7 @@ async function allocateNewSession( kiloSessionId, ...(initialTurn ? { initialMessageId: initialTurn.messageId } : {}), ...(reportingCreatedAt ? { reportingCreatedAt } : {}), + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: worktreeId !== undefined, [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint(input), }); } @@ -767,7 +786,7 @@ function rebuildRecordedSessionAllocation( .datetime({ offset: true }) .optional() .safeParse(canonical.reportingCreatedAt); - const worktreeCreate = isEligibleFirstWorktreeSession(input, ctx); + const worktreeCreate = worktreeEnabledForCreate(input, ctx, row); if ( !reportingCreatedAt.success || @@ -1204,6 +1223,7 @@ export async function startNewSession( * changed request can never inherit the prior operation's session. */ export const SESSION_CREATE_INTENT_FINGERPRINT_KEY = 'createIntentFingerprint'; +export const SESSION_CREATE_WORKTREE_ENABLED_KEY = 'worktreeEnabled'; /** * Deterministic JSON serialization: object keys are sorted and undefined @@ -1403,11 +1423,10 @@ export async function createSessionWithLedger( options: SessionLedgerCreateOptions ): Promise { assertSupportedSandboxAllocation(input, ctx, { billingOrigin: options.billingOrigin }); - const effectiveInput = effectiveSessionRegistrationInput(input, ctx, options.operationKey); const db = getPgDb(ctx.env); const admission = await admitOperation(db, { userId: ctx.userId, - orgId: effectiveInput.options?.kilocodeOrganizationId, + orgId: input.options?.kilocodeOrganizationId, domain: 'session', intent: 'create_cloud', operationKey: options.operationKey, @@ -1417,9 +1436,13 @@ export async function createSessionWithLedger( assertSessionOperationIdentity(admission.row, { userId: ctx.userId, intent: 'create_cloud', - organizationId: effectiveInput.options?.kilocodeOrganizationId, + organizationId: input.options?.kilocodeOrganizationId, resourceKey: null, }); + const effectiveInput = effectiveSessionRegistrationInput( + input, + worktreeEnabledForCreate(input, ctx, admission.row) + ); switch (admission.admission) { case 'admitted': @@ -1486,12 +1509,14 @@ async function buildLedgerHooks( row: OperationLedgerRow, admissionKind: 'new' | 'takeover' ): Promise { + const worktreeEnabled = worktreeEnabledForCreate(input, ctx, row); const distinctId = await resolveSessionCreateDistinctId(db, ctx.userId); const inOrganization = input.options?.kilocodeOrganizationId != null; return { db, rowId: row.id, + worktreeEnabled, onFailure: (stage, outcomeCode) => bestEffortLedgerWrite(() => settleOperation(db, { @@ -1849,7 +1874,7 @@ async function reconcileLedgerCreate( return executeLedgerCreate(input, ctx, options, db, row, 'takeover'); } - const worktreeAllocation = isEligibleFirstWorktreeSession(input, ctx) + const worktreeAllocation = worktreeEnabledForCreate(input, ctx, row) ? rebuildRecordedSessionAllocation(input, ctx, row) : undefined; diff --git a/services/cloud-agent-next/src/types.ts b/services/cloud-agent-next/src/types.ts index fa50903f48..83783b1acb 100644 --- a/services/cloud-agent-next/src/types.ts +++ b/services/cloud-agent-next/src/types.ts @@ -597,6 +597,7 @@ export type Env = { PER_SESSION_SANDBOX_ORG_IDS?: string; /** Comma-separated user or org IDs admitted to the call-home control plane. `*` includes personal. */ CONTROL_PLANE_IDS?: string; + WORKTREE_CREATION_ENABLED_IDS?: string; CREDENTIAL_CONTAINMENT_ENABLED?: string; /** Comma-separated org IDs that receive workspace repo snapshots, or '*' for all */ REPO_SNAPSHOT_ORG_IDS?: string; diff --git a/services/cloud-agent-next/test/e2e/README.md b/services/cloud-agent-next/test/e2e/README.md index a31e83ecd9..db4c882b2c 100644 --- a/services/cloud-agent-next/test/e2e/README.md +++ b/services/cloud-agent-next/test/e2e/README.md @@ -13,6 +13,16 @@ cloud-agent-next refactor. 1. Copy `.dev.vars.example` → `.dev.vars` and fill in local values. Leave `KILO_OPENROUTER_BASE` pointed at local Next.js (`@url nextjs/api`). + For control-plane scenarios, enroll the E2E user in `CONTROL_PLANE_IDS`. + `worktree-shared` additionally requires `WORKTREE_CREATION_ENABLED_IDS`; it creates a fresh + personal user per run, so set both flags to `*` for that local scenario. + Both accept comma-separated user or org IDs or `*` and default empty/off. + Ordinary control-plane scenarios do not require `WORKTREE_CREATION_ENABLED_IDS`. + These are Worker settings read by `auth.ts` from this service's `.dev.vars`, + not driver environment overrides: prefixing the driver command with either + flag does not configure the Worker. The unannotated template entries pass + through matching root `.env.local` values during `pnpm dev:env`. Configure + the Worker before starting it, or restart it after changing these values. 2. Ensure local Postgres is up and root `.env.local` defines `POSTGRES_URL` (or export `DATABASE_URL`) — the driver inserts a test user row via `@kilocode/db`. @@ -92,6 +102,7 @@ Examples: ```bash tsx services/cloud-agent-next/test/e2e/run.ts cold echo:hi tsx services/cloud-agent-next/test/e2e/run.ts cold-hot echo:hi +tsx services/cloud-agent-next/test/e2e/run.ts worktree-shared _ tsx services/cloud-agent-next/test/e2e/run.ts hot echo:hi tsx services/cloud-agent-next/test/e2e/run.ts followup echo:continue tsx services/cloud-agent-next/test/e2e/run.ts external-kill echo:hi @@ -220,6 +231,7 @@ These are wrapped by `releaseGate()`, `waitForGateEngaged()`, | `hot` | Warmup with `echo:warmup`, then send the real prompt on the same session. Same container. | | `followup` | Same as `hot` today; kept distinct for future resume-path splits. | | `cold-hot` | One cold turn plus `echo:hot`, `slow:3:50`, and `echo:followup` hot turns on the same session/sandbox. | +| `worktree-shared` | Creates a new worktree and a sibling chat; verifies idempotent creation, a shared dirty checkout, and chat isolation. Requires both `CONTROL_PLANE_IDS` and `WORKTREE_CREATION_ENABLED_IDS` enrollment and `--api=unified`; pass `_` as the conversation placeholder. | | `external-kill` | Warmup, `docker kill` the sandbox, send another prompt, verify recovery/failure. | | `kill-mid-flight` | Cold `hang`, kill while pending, verify DO surfaces disconnect/error. | | `queue-while-busy` | Block on `gate:`, enqueue two echoes, release the gate, assert FIFO delivery through `cloud.message.*` events. | diff --git a/services/cloud-agent-next/test/e2e/run.ts b/services/cloud-agent-next/test/e2e/run.ts index 0d8d18a30c..7167afafd0 100644 --- a/services/cloud-agent-next/test/e2e/run.ts +++ b/services/cloud-agent-next/test/e2e/run.ts @@ -25,7 +25,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { ensureTestUser, loadDevVars, loadRepoEnvFiles, DRIVER_USER_EMAIL_SUFFIX } from './auth.js'; import { DEFAULT_CONFIG, type ApiVersion, type DriverConfig } from './client.js'; -import { isControlPlaneOwner } from '../../src/session-plane.js'; +import { isControlPlaneOwner, isWorktreeOwner } from '../../src/session-plane.js'; import { LIFECYCLE_SCENARIOS, type LifecycleResult } from './lifecycle.js'; const SERVICE_PACKAGE_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); @@ -156,6 +156,16 @@ async function main(): Promise { funded: process.env.E2E_FUNDED === '1', }); const expectControlPlane = Boolean(devVars.CONTROL_PLANE_IDS?.trim()); + if ( + lifecycle === 'worktree-shared' && + (!isControlPlaneOwner(devVars, { userId: user.id }) || + !isWorktreeOwner(devVars, { userId: user.id })) + ) { + throw new Error( + 'worktree-shared requires the E2E user to be enrolled in CONTROL_PLANE_IDS and WORKTREE_CREATION_ENABLED_IDS ' + + 'in the Worker .dev.vars; no session was started' + ); + } if (expectControlPlane && !isControlPlaneOwner(devVars, { userId: user.id })) { throw new Error('The E2E user is not enrolled in CONTROL_PLANE_IDS; no session was started'); } diff --git a/services/cloud-agent-next/worker-configuration.d.ts b/services/cloud-agent-next/worker-configuration.d.ts index d2daf1a0b4..f79b1368dc 100644 --- a/services/cloud-agent-next/worker-configuration.d.ts +++ b/services/cloud-agent-next/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 9a7c00558729941179c8dc59852f2105) +// Generated by Wrangler by running `wrangler types` (hash: 8d45d333d1f69f2f8355e9778e2fd5e3) // Runtime types generated with workerd@1.20260714.1 2026-06-03 nodejs_compat interface __BaseEnv_Env { SHARED_SANDBOX_OVERRIDES: KVNamespace; @@ -29,6 +29,7 @@ interface __BaseEnv_Env { REPO_SNAPSHOT_ORG_IDS?: ""; CONTAINER_BILLING_HEARTBEAT_SECONDS: "60" | "300"; CONTROL_PLANE_IDS: ""; + WORKTREE_CREATION_ENABLED_IDS: ""; VERCEL_SANDBOX_ORG_IDS: ""; VERCEL_PROJECT_ID: ""; VERCEL_TEAM_ID: ""; @@ -92,6 +93,7 @@ declare namespace Cloudflare { REPO_SNAPSHOT_ORG_IDS: ""; CONTAINER_BILLING_HEARTBEAT_SECONDS: "60"; CONTROL_PLANE_IDS: ""; + WORKTREE_CREATION_ENABLED_IDS: ""; VERCEL_SANDBOX_ORG_IDS: ""; VERCEL_PROJECT_ID: ""; VERCEL_TEAM_ID: ""; @@ -124,7 +126,7 @@ type StringifyValues> = { [Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string; }; declare namespace NodeJS { - interface ProcessEnv extends StringifyValues> {} + interface ProcessEnv extends StringifyValues> {} } declare module "*.sql" { const value: string; diff --git a/services/cloud-agent-next/wrangler.jsonc b/services/cloud-agent-next/wrangler.jsonc index 148c799c14..dabf6a53d0 100644 --- a/services/cloud-agent-next/wrangler.jsonc +++ b/services/cloud-agent-next/wrangler.jsonc @@ -65,6 +65,7 @@ "CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS": "9d278969-5453-4ae3-a51f-a8d2274a7b56", "CONTAINER_BILLING_HEARTBEAT_SECONDS": "300", "CONTROL_PLANE_IDS": "", + "WORKTREE_CREATION_ENABLED_IDS": "", "VERCEL_SANDBOX_ORG_IDS": "", "VERCEL_PROJECT_ID": "", "VERCEL_TEAM_ID": "", @@ -455,6 +456,7 @@ "REPO_SNAPSHOT_ORG_IDS": "", "CONTAINER_BILLING_HEARTBEAT_SECONDS": "60", "CONTROL_PLANE_IDS": "", + "WORKTREE_CREATION_ENABLED_IDS": "", "VERCEL_SANDBOX_ORG_IDS": "", "VERCEL_PROJECT_ID": "", "VERCEL_TEAM_ID": "", From 43a7f74241c38586e36bd7033acf4c3dfaa6ea4d Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 09:52:37 +0200 Subject: [PATCH 08/14] fix(cloud-agent-next): stabilize shared worktree credentials and dispatch --- .../src/persistence/SandboxControl.ts | 125 +- .../src/persistence/session-metadata.test.ts | 50 +- .../src/persistence/session-metadata.ts | 17 +- .../cloudflare-provider.test.ts | 92 +- .../sandbox-control/cloudflare-provider.ts | 18 +- .../src/sandbox-control/durable-state.test.ts | 26 + .../src/sandbox-control/frames.test.ts | 30 + .../src/sandbox-control/lifecycle.test.ts | 1012 ++++++++++++----- .../physical-lifecycle.test.ts | 13 +- .../src/sandbox-control/physical-lifecycle.ts | 8 + .../session-credentials.test.ts | 674 ++++++++++- .../sandbox-control/session-credentials.ts | 300 ++++- .../sandbox-control/terminal-billing.test.ts | 56 +- .../src/sandbox-control/terminal-billing.ts | 17 +- .../src/sandbox-session/SandboxSession.ts | 186 ++- .../sandbox-session/control-dispatch.test.ts | 82 ++ .../src/sandbox-session/control-dispatch.ts | 28 +- .../session-message-queue.test.ts | 507 ++++++++- .../sandbox-session/session-message-queue.ts | 16 +- .../src/session/session-prepare.test.ts | 19 +- .../src/session/session-registration.ts | 6 +- .../src/shared/sandbox-control-protocol.ts | 19 + services/cloud-agent-next/test/e2e/README.md | 69 +- .../test/e2e/multichat-real-support.test.ts | 336 ++++++ .../test/e2e/multichat-real-support.ts | 604 ++++++++++ .../test/e2e/multichat-real.ts | 797 +++++++++++++ .../test/integration/sandbox-control.test.ts | 57 +- .../worktree-credential-refresh.test.ts | 805 +++++++++++++ .../wrapper/src/control/apply-attach.ts | 24 +- .../wrapper/src/control/main.ts | 33 +- .../control/sandbox-control-handlers.test.ts | 42 +- .../src/control/sandbox-control-handlers.ts | 37 +- .../control/sandbox-control-runtime.test.ts | 21 +- .../src/control/sandbox-control-runtime.ts | 19 +- .../src/control/worktree-runtime.test.ts | 149 +++ .../wrapper/src/control/worktree-runtime.ts | 223 +++- 36 files changed, 5995 insertions(+), 522 deletions(-) create mode 100644 services/cloud-agent-next/test/e2e/multichat-real-support.test.ts create mode 100644 services/cloud-agent-next/test/e2e/multichat-real-support.ts create mode 100644 services/cloud-agent-next/test/e2e/multichat-real.ts create mode 100644 services/cloud-agent-next/test/unit/wrapper/worktree-credential-refresh.test.ts diff --git a/services/cloud-agent-next/src/persistence/SandboxControl.ts b/services/cloud-agent-next/src/persistence/SandboxControl.ts index a4eb14fbb2..c5d2c6475a 100644 --- a/services/cloud-agent-next/src/persistence/SandboxControl.ts +++ b/services/cloud-agent-next/src/persistence/SandboxControl.ts @@ -20,7 +20,11 @@ import { getSandbox } from '@cloudflare/sandbox'; import { withTimeout } from '@kilocode/worker-utils'; import { z } from 'zod'; import type { Env } from '../types.js'; -import { getSandboxProvider, type SessionMetadata } from './session-metadata.js'; +import { + getSandboxProvider, + requiresContainmentSandbox, + type SessionMetadata, +} from './session-metadata.js'; import { getSandboxSessionStub } from '../sandbox-session/session-stub.js'; import { logger } from '../logger.js'; import { @@ -69,6 +73,7 @@ import { observe, recordStopAttempt, sameAllocation, + getWorktreeCredentialContainment, WORKTREE_CREDENTIAL_CONTAINMENT, type CredentialContainmentRequirements, type ObserveResult, @@ -592,17 +597,20 @@ export class SandboxControl extends DurableObject { const provider = getSandboxProvider(metadata); await this.pinProvider(provider); const physical = await loadPhysicalRecord(this.ctx.storage); - if (!this.matchesContainment(physical, WORKTREE_CREDENTIAL_CONTAINMENT)) { + const requiredContainment = getWorktreeCredentialContainment( + requiresContainmentSandbox(metadata) + ); + if (!this.matchesContainment(physical, requiredContainment)) { throw new Error('Sandbox credential containment is unavailable'); } const outboundContainerId = - provider === 'cloudflare' + provider === 'cloudflare' && requiredContainment.kilocode ? getOutboundContainerId( this.env, decodeCloudflareProviderRef(physical.providerRef)?.sandboxId ?? physical.createIntent?.allocationName ?? this.sandboxId, - { managedScmContainment: true } + { managedScmContainment: requiredContainment.kilocode } ) : undefined; const grants = await loadSessionCredentialGrants(this.ctx.storage); @@ -641,7 +649,7 @@ export class SandboxControl extends DurableObject { if ( (deadlineAt !== undefined && Date.now() >= deadlineAt) || !sameAllocation(physical, currentPhysical) || - !this.matchesContainment(currentPhysical, WORKTREE_CREDENTIAL_CONTAINMENT) + !this.matchesContainment(currentPhysical, requiredContainment) ) { throw new Error('Sandbox changed during credential preparation'); } @@ -651,6 +659,8 @@ export class SandboxControl extends DurableObject { !currentRuntime.allowed || !this.sameTerminalRuntime(currentRuntime, terminal.runtime) || prepared.grant.scopeId !== terminal.runtime.grant.scopeId || + (prepared.grant.containmentEnabled !== false) !== + (terminal.runtime.grant.containmentEnabled !== false) || prepared.grant.kilo.alias !== terminal.runtime.grant.kilo.alias ) { throw new Error('Terminal runtime changed during credential preparation'); @@ -658,9 +668,11 @@ export class SandboxControl extends DurableObject { } this.assertWorktreeAdmission(metadata.workspace?.worktreeId); const updated = [...grants.filter(grant => grant.scopeId !== scopeId), prepared.grant]; - if (provider === 'vercel') await this.ctx.storage.put(CREDENTIAL_POLICY_DIRTY_KEY, true); + if (provider === 'vercel' && requiredContainment.kilocode) { + await this.ctx.storage.put(CREDENTIAL_POLICY_DIRTY_KEY, true); + } await saveSessionCredentialGrants(this.ctx.storage, updated); - if (provider === 'vercel') { + if (provider === 'vercel' && requiredContainment.kilocode) { const deadlines = await loadDeadlines(this.ctx.storage); const next = armDeadline( deadlines, @@ -779,6 +791,9 @@ export class SandboxControl extends DurableObject { 'Sandbox credential metadata timed out' ); const worktreeId = metadata.workspace?.worktreeId; + const requiredContainment = getWorktreeCredentialContainment( + requiresContainmentSandbox(metadata) + ); if (input.worktreeId !== undefined && input.worktreeId !== worktreeId) { throw new Error('Worktree identity conflict'); } @@ -795,15 +810,15 @@ export class SandboxControl extends DurableObject { let physical: PhysicalRecord; let creating = false; if (acquisition) { - let selected = await this.acquirePhysical(acquisition, worktreeId); + let selected = await this.acquirePhysical(acquisition, requiredContainment, worktreeId); if (selected.action === 'wait') { const step = nextEnsureReadyStep(selected.physical.state, true); if (step === 'release-failed') { await this.releaseIfAuthoritativelyDead(selected.physical); - selected = await this.acquirePhysical(acquisition, worktreeId); + selected = await this.acquirePhysical(acquisition, requiredContainment, worktreeId); } else if (step === 'observe-unknown') { await this.observeCurrentProvider(selected.physical); - selected = await this.acquirePhysical(acquisition, worktreeId); + selected = await this.acquirePhysical(acquisition, requiredContainment, worktreeId); } } if (selected.action === 'wait') return this.statusForPhysical(selected.physical); @@ -829,7 +844,7 @@ export class SandboxControl extends DurableObject { intentId, this.provider.resumable, allocationName, - WORKTREE_CREDENTIAL_CONTAINMENT + requiredContainment ); creating = true; return claimed; @@ -842,7 +857,10 @@ export class SandboxControl extends DurableObject { if (physical.stopTombstone || (physical.state !== 'creating' && physical.state !== 'running')) { return currentStatus(); } - if (!this.matchesContainment(physical, WORKTREE_CREDENTIAL_CONTAINMENT)) { + if (!this.matchesContainment(physical, requiredContainment)) { + if (this.matchesWorktreeContainment(physical)) { + throw new Error('Sandbox containment mode conflicts with the session'); + } await this.beginStop('credential_containment_unavailable'); await this.recordStopAttempt(); return currentStatus(); @@ -901,7 +919,7 @@ export class SandboxControl extends DurableObject { const intent = physical.createIntent; if (!intent) throw new Error('Sandbox create intent is unavailable'); const networkPolicy = - this.providerKind === 'vercel' + this.providerKind === 'vercel' && requiredContainment.kilocode ? await this.withCredentialUpdate(async () => buildControlNetworkPolicy( (await loadSessionCredentialGrants(this.ctx.storage)).filter( @@ -991,6 +1009,7 @@ export class SandboxControl extends DurableObject { private async acquirePhysical( acquisition: SandboxAcquisition, + requiredContainment: CredentialContainmentRequirements, worktreeId?: string ): Promise<{ physical: PhysicalRecord; @@ -1001,17 +1020,17 @@ export class SandboxControl extends DurableObject { return this.ctx.storage.transaction(async () => { const physical = await loadPhysicalRecord(this.ctx.storage, this.provider.resumable); this.assertWorktreeAdmission(worktreeId); + if ( + this.matchesWorktreeContainment(physical) && + !this.matchesContainment(physical, requiredContainment) + ) { + throw new Error('Sandbox containment mode conflicts with the session'); + } if (await this.bindAcquisition(acquisition, physical)) return { physical, action: 'reuse' }; if (physical.state !== 'stopped' || physical.stopTombstone) { return { physical, action: 'wait' }; } - const next = claimCreate( - physical, - intentId, - Date.now(), - allocationName, - WORKTREE_CREDENTIAL_CONTAINMENT - ); + const next = claimCreate(physical, intentId, Date.now(), allocationName, requiredContainment); await this.persistPhysicalState(physical, next, 'demand'); await this.bindAcquisition(acquisition, next); return { physical: next, action: 'create' }; @@ -1137,6 +1156,14 @@ export class SandboxControl extends DurableObject { if (!grant || worktreeId !== input.worktreeId) { throw new Error('Session has no matching worktree credential grant'); } + if ( + !this.matchesContainment( + await loadPhysicalRecord(this.ctx.storage), + getWorktreeCredentialContainment(grant.containmentEnabled !== false) + ) + ) { + throw new Error('Sandbox credential containment mismatch'); + } const result = await this.mutateRoutes(table => { this.assertWorktreeAdmission(worktreeId); const attached = attachRoute(table, input, ownerId); @@ -1503,10 +1530,11 @@ export class SandboxControl extends DurableObject { let billing: SandboxTerminalAccessResult; try { - const allocationId = decodeCloudflareProviderRef(runtime.physical.providerRef)?.sandboxId; - if (!allocationId) return { allowed: false, reason: 'runtime_not_running' }; + const providerRef = decodeCloudflareProviderRef(runtime.physical.providerRef); + if (!providerRef) return { allowed: false, reason: 'runtime_not_running' }; + const allocationId = providerRef.sandboxId; const namespace = getSandboxNamespace(this.env, allocationId, { - managedScmContainment: true, + managedScmContainment: providerRef.containment, }); const sandbox = getSandbox(namespace, allocationId); billing = validateTerminalBillingRuntime({ @@ -1549,6 +1577,7 @@ export class SandboxControl extends DurableObject { left.route.kiloSessionId === right.route.kiloSessionId && left.route.directory === right.route.directory && left.grant.scopeId === right.grant.scopeId && + (left.grant.containmentEnabled !== false) === (right.grant.containmentEnabled !== false) && left.grant.kilo.alias === right.grant.kilo.alias ); } @@ -1557,6 +1586,11 @@ export class SandboxControl extends DurableObject { input: SandboxTerminalAccessInput, runtime: TerminalRuntimeSnapshot ): Promise { + if (runtime.grant.containmentEnabled === false) { + return runtime.grant.expiresAt > Date.now() + ? { allowed: true } + : { allowed: false, reason: 'credential_reattach_required' }; + } if (runtime.grant.expiresAt > Date.now() + TERMINAL_CREDENTIAL_RENEWAL_WINDOW_MS) { return { allowed: true }; } @@ -1923,6 +1957,14 @@ export class SandboxControl extends DurableObject { await this.ctx.storage.delete(CREDENTIAL_POLICY_DIRTY_KEY); return; } + if ( + this.matchesWorktreeContainment(physical) && + !(physical.createIntent?.containment ?? physical.containment)?.kilocode + ) { + await this.ctx.storage.delete(CREDENTIAL_POLICY_DIRTY_KEY); + await this.cancelDeadlineAndAlarm('credentialExpiry'); + return; + } if (physical.state !== 'running') { throw new Error('Sandbox credential policy is unavailable'); } @@ -2081,12 +2123,25 @@ export class SandboxControl extends DurableObject { return decodeVercelProviderRef(providerRef)?.sandboxName === allocationName; } const native = decodeCloudflareProviderRef(providerRef); + const containment = physical.createIntent?.containment ?? physical.containment; return ( native?.sandboxId === allocationName && + containment !== undefined && + native.containment === (containment.kilocode || containment.github) && (!physical.createIntent || native.instanceId === physical.createIntent.intentId) ); } + private matchesWorktreeContainment(physical: PhysicalRecord): boolean { + const containment = + physical.state === 'creating' ? physical.createIntent?.containment : physical.containment; + return ( + containment?.worktreeScoped === true && + containment.kilocode === containment.github && + this.matchesContainment(physical, containment) + ); + } + private matchesContainment( physical: PhysicalRecord, requiredContainment: CredentialContainmentRequirements @@ -2175,7 +2230,7 @@ export class SandboxControl extends DurableObject { return ( (this.providerKind !== 'vercel' || physical.state === 'running') && this.matchesProviderReference(physical, providerInstanceId) && - this.matchesContainment(physical, WORKTREE_CREDENTIAL_CONTAINMENT) + this.matchesWorktreeContainment(physical) ); } @@ -2189,7 +2244,7 @@ export class SandboxControl extends DurableObject { (physical.state !== 'running' && physical.state !== 'creating') || (this.providerKind === 'vercel' && physical.state !== 'running') || !this.matchesProviderReference(physical, identity.providerInstanceId) || - !this.matchesContainment(physical, WORKTREE_CREDENTIAL_CONTAINMENT) + !this.matchesWorktreeContainment(physical) ) { this.socketHandler.closeAll('Sandbox runtime unavailable'); return; @@ -2271,6 +2326,14 @@ export class SandboxControl extends DurableObject { ): Promise { if (!this.isCurrentConnection(identity)) return; if (!payload.kilo.ready) { + logger + .withFields({ + sandboxId: this.sandboxId, + wrapperInstanceId: identity.wrapperInstanceId, + connectionId: identity.connectionId, + reason: payload.kilo.reason ?? 'unknown', + }) + .warn('Sandbox control received unhealthy heartbeat'); await this.quarantineConnection(identity, 'kilo_unhealthy'); return; } @@ -2620,7 +2683,7 @@ export class SandboxControl extends DurableObject { if (physical.state !== 'running' || physical.stopTombstone || physical.providerRef === null) { return { allowed: false, reason: 'runtime_not_running' }; } - if (!this.matchesContainment(physical, WORKTREE_CREDENTIAL_CONTAINMENT)) { + if (!this.matchesWorktreeContainment(physical)) { return { allowed: false, reason: 'credential_containment_unavailable' }; } const grant = grants.find( @@ -2637,7 +2700,15 @@ export class SandboxControl extends DurableObject { member.sessionId === input.sessionId && member.kiloSessionId === route.kiloSessionId ) ); - if (!grant) return { allowed: false, reason: 'credential_scope_unavailable' }; + if ( + !grant || + !this.matchesContainment( + physical, + getWorktreeCredentialContainment(grant.containmentEnabled !== false) + ) + ) { + return { allowed: false, reason: 'credential_scope_unavailable' }; + } const connection = this.readyWrapperRuntime(); if (!connection) return { allowed: false, reason: 'runtime_not_ready' }; diff --git a/services/cloud-agent-next/src/persistence/session-metadata.test.ts b/services/cloud-agent-next/src/persistence/session-metadata.test.ts index 3a85ff4fe6..e9c5036a3b 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.test.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.test.ts @@ -121,17 +121,11 @@ describe('session metadata boundary', () => { expected: { github: false, gitlab: false, bitbucket: false, kilocode: true }, }, ])( - 'requires only Kilo and the typed $name repository despite false metadata', + 'defaults missing policy to Kilo and the typed $name repository', ({ repository, expected }) => { const workspace = { sandboxId: 'ses-abcdef', sandboxProvider, - credentialContainment: { - github: false, - gitlab: false, - bitbucket: false, - kilocode: false, - }, managedScmContainment: true, }; const metadata = parseSessionMetadata({ @@ -149,23 +143,39 @@ describe('session metadata boundary', () => { } ); + it.each([ + { github: false, gitlab: false, bitbucket: false, kilocode: false }, + { github: true, gitlab: true, bitbucket: true, kilocode: true }, + { github: false, gitlab: true, bitbucket: true, kilocode: false }, + ])('honors historical explicit flags without rewriting them: %j', credentialContainment => { + const metadata = parseSessionMetadata({ + metadataSchemaVersion: 2, + identity: { sessionId: 'workspace_containment', userId: 'user_containment' }, + auth: {}, + repository: { type: 'github', repo: 'acme/repo' }, + workspace: { + sandboxId: 'ses-abcdef', + sandboxProvider, + credentialContainment, + managedScmContainment: true, + }, + lifecycle: { version: 1, timestamp: 1 }, + }); + + expect(getEffectiveCredentialContainment(metadata)).toEqual(credentialContainment); + expect(requiresContainmentSandbox(metadata)).toBe( + Object.values(credentialContainment).some(Boolean) + ); + expect(serializeSessionMetadata(metadata).workspace?.credentialContainment).toEqual( + credentialContainment + ); + }); + it.each([ { name: 'missing policy', fields: {} }, { name: 'disabled legacy alias', fields: { managedScmContainment: false } }, { name: 'enabled legacy alias', fields: { managedScmContainment: true } }, - { - name: 'mixed policy', - fields: { - credentialContainment: { - github: false, - gitlab: true, - bitbucket: true, - kilocode: false, - }, - managedScmContainment: false, - }, - }, - ])('ignores $name without rewriting stored fields', ({ fields }) => { + ])('defaults $name without rewriting stored fields', ({ fields }) => { const workspace = { sandboxId: 'ses-abcdef', sandboxProvider, ...fields }; const metadata = parseSessionMetadata({ metadataSchemaVersion: 2, diff --git a/services/cloud-agent-next/src/persistence/session-metadata.ts b/services/cloud-agent-next/src/persistence/session-metadata.ts index 3f4abdd07d..63f1f55353 100644 --- a/services/cloud-agent-next/src/persistence/session-metadata.ts +++ b/services/cloud-agent-next/src/persistence/session-metadata.ts @@ -353,28 +353,29 @@ export type CredentialContainment = z.infer; export function getControlPlaneCredentialContainment( sessionId: string, - repository: SessionMetadata['repository'] + repository: SessionMetadata['repository'], + enabled = true ): CredentialContainment | undefined { if (sessionPlaneFromId(sessionId) !== 'control') return undefined; return { - github: repository?.type === 'github', - gitlab: repository?.type === 'gitlab', - bitbucket: repository?.type === 'bitbucket', - kilocode: true, + github: enabled && repository?.type === 'github', + gitlab: enabled && repository?.type === 'gitlab', + bitbucket: enabled && repository?.type === 'bitbucket', + kilocode: enabled, }; } export function getEffectiveCredentialContainment( metadata: SessionMetadata ): CredentialContainment { + if (metadata.workspace?.credentialContainment) { + return metadata.workspace.credentialContainment; + } const controlPlaneContainment = getControlPlaneCredentialContainment( metadata.identity.sessionId, metadata.repository ); if (controlPlaneContainment) return controlPlaneContainment; - if (metadata.workspace?.credentialContainment) { - return metadata.workspace.credentialContainment; - } const legacyContainment = metadata.workspace?.managedScmContainment === true; return { github: legacyContainment, gitlab: false, kilocode: legacyContainment }; } diff --git a/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts b/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts index 3fc2d2bf6a..903e848691 100644 --- a/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.test.ts @@ -16,6 +16,7 @@ import { DEADLINE_MS } from './deadlines.js'; import { beginStop, confirmStopped, + getWorktreeCredentialContainment, recordStopAttempt, WORKTREE_CREDENTIAL_CONTAINMENT, type PhysicalRecord, @@ -61,6 +62,76 @@ const billing = parseSandboxBillingInput({ afterEach(() => vi.useRealTimers()); describe('cloudflare provider adapter', () => { + it.each([true, false, undefined])( + 'uses persisted containment %s for billing and launch regardless of launch environment', + async enabled => { + const setOutboundHandler = vi.fn().mockResolvedValue(undefined); + const startProcess = vi.fn().mockResolvedValue({ id: 'proc_1' }); + const ensureBillingAdmission = vi.fn().mockResolvedValue({ success: true }); + const getSandbox = vi.fn(() => + fakeSandbox({ setOutboundHandler, startProcess, ensureBillingAdmission }) + ); + const provider = createCloudflareProviderAdapter({ + sandboxId: intent.allocationName, + getSandbox, + destroy: async () => undefined, + }); + const created = await provider.create({ + ...intent, + containment: enabled === undefined ? undefined : getWorktreeCredentialContainment(enabled), + billing, + }); + if (!('providerRef' in created)) throw new Error('Missing allocation'); + expect(decodeCloudflareProviderRef(created.providerRef)).toEqual({ + sandboxId: intent.allocationName, + containment: enabled ?? true, + instanceId: intent.intentId, + }); + await provider.launch(created.providerRef, { + CREDENTIAL_CONTAINMENT_ENABLED: enabled === false ? 'true' : 'false', + }); + expect(getSandbox).toHaveBeenCalledTimes(2); + for (const call of getSandbox.mock.calls) { + expect(call).toEqual([intent.allocationName, { containment: enabled ?? true }]); + } + expect(ensureBillingAdmission).toHaveBeenCalledOnce(); + expect(setOutboundHandler).toHaveBeenCalledTimes(enabled === false ? 0 : 1); + expect(startProcess).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + env: expect.objectContaining({ PROVIDER_INSTANCE_ID: created.providerRef }), + }) + ); + } + ); + + it.each([true, false])('reconstructs a fenced %s worktree ref for cleanup', async enabled => { + const getSandbox = vi.fn(() => fakeSandbox()); + const destroy = vi.fn(async () => undefined); + const provider = createCloudflareProviderAdapter({ + sandboxId: intent.allocationName, + getSandbox, + destroy, + }); + const retainedIntent = { ...intent, containment: getWorktreeCredentialContainment(enabled) }; + const providerRef = encodeCloudflareProviderRef({ + sandboxId: intent.allocationName, + containment: enabled, + instanceId: intent.intentId, + }); + await expect(provider.observe(null, retainedIntent)).resolves.toEqual({ + status: 'active', + providerRef, + }); + await expect(provider.stop(null, retainedIntent)).resolves.toBe('terminal'); + expect(getSandbox).toHaveBeenCalledExactlyOnceWith(intent.allocationName, { + containment: enabled, + }); + expect(destroy).toHaveBeenCalledExactlyOnceWith(intent.allocationName, { + containment: enabled, + }); + }); + it.each([true, false])( 'cleans the exact physical allocation only after confirmed stop: %s', async confirmed => { @@ -316,6 +387,14 @@ describe('cloudflare provider adapter', () => { it.each([ { ref: PROVIDER_REF, containment: true }, + { + ref: encodeCloudflareProviderRef({ + sandboxId: 'sbx_1', + containment: false, + instanceId: 'op_1', + }), + containment: false, + }, { ref: 'sbx_1', containment: false }, ])( 'observes, renews, and destroys only the encoded namespace without waking', @@ -707,7 +786,7 @@ describe('cloudflare provider adapter', () => { 'not-json', 'sbx_other', JSON.stringify({ sandboxId: 'sbx_1', containment: true }), - JSON.stringify({ sandboxId: 'sbx_1', containment: false, instanceId: 'op_1' }), + JSON.stringify({ sandboxId: 'sbx_1', containment: 'false', instanceId: 'op_1' }), encodeCloudflareProviderRef({ sandboxId: 'sbx_other', containment: true, @@ -761,12 +840,9 @@ describe('cloudflare provider adapter', () => { }); describe('cloudflare provider references', () => { - it('round-trips the containment namespace and exact creation intent', () => { - expect(decodeCloudflareProviderRef(PROVIDER_REF)).toEqual({ - sandboxId: 'sbx_1', - containment: true, - instanceId: 'op_1', - }); + it.each([true, false])('round-trips namespace %s and exact creation intent', containment => { + const ref = { sandboxId: 'sbx_1', containment, instanceId: 'op_1' }; + expect(decodeCloudflareProviderRef(encodeCloudflareProviderRef(ref))).toEqual(ref); }); it.each([ @@ -781,7 +857,7 @@ describe('cloudflare provider references', () => { JSON.stringify({ sandboxId: 'sbx_1', containment: true, instanceId: '' }), JSON.stringify({ sandboxId: 'sbx_1', containment: true, instanceId: 1 }), JSON.stringify({ sandboxId: 'sbx_1', containment: 'true', instanceId: 'op_1' }), - JSON.stringify({ sandboxId: 'sbx_1', containment: false, instanceId: 'op_1' }), + JSON.stringify({ sandboxId: 'sbx_1', containment: 'false', instanceId: 'op_1' }), JSON.stringify({ sandboxId: '', containment: true, instanceId: 'op_1' }), JSON.stringify({ sandboxId: 'sbx_1', containment: true, instanceId: 'op_1', extra: true }), JSON.stringify({ sandboxId: 'a'.repeat(257), containment: true, instanceId: 'op_1' }), diff --git a/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.ts b/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.ts index 7bc19208e3..8cb21a4e7c 100644 --- a/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.ts +++ b/services/cloud-agent-next/src/sandbox-control/cloudflare-provider.ts @@ -19,7 +19,7 @@ const CONTROL_WRAPPER_LOG_PATH = '/tmp/kilocode-control-wrapper.log'; const providerRefSchema = z .object({ sandboxId: z.string().min(1).max(256), - containment: z.literal(true), + containment: z.boolean(), instanceId: z.string().min(1).max(128), }) .strict(); @@ -60,7 +60,11 @@ export function createCloudflareProviderAdapter(deps: { if (ref !== null || !intent) return ref; const sandboxId = intent.allocationName ?? deps.sandboxId; return intent.containment?.worktreeScoped - ? encodeCloudflareProviderRef({ sandboxId, containment: true, instanceId: intent.intentId }) + ? encodeCloudflareProviderRef({ + sandboxId, + containment: intent.containment.kilocode || intent.containment.github, + instanceId: intent.intentId, + }) : sandboxId; }; const ensureBillingAdmission: ProviderAdapter['ensureBillingAdmission'] = async ( @@ -94,7 +98,9 @@ export function createCloudflareProviderAdapter(deps: { async create(intent: ProviderCreateIntent) { const providerRef = encodeCloudflareProviderRef({ sandboxId: intent.allocationName ?? deps.sandboxId, - containment: true, + containment: intent.containment + ? intent.containment.kilocode || intent.containment.github + : true, instanceId: intent.intentId, }); await ensureBillingAdmission(providerRef, intent.billing); @@ -103,10 +109,10 @@ export function createCloudflareProviderAdapter(deps: { async launch(ref, env) { const parsed = decodeCloudflareProviderRef(ref); if (!parsed || parsed.sandboxId !== deps.sandboxId) { - throw new Error('Invalid contained Cloudflare sandbox allocation'); + throw new Error('Invalid Cloudflare sandbox allocation'); } - const sandbox = deps.getSandbox(parsed.sandboxId, { containment: true }); - await sandbox.setOutboundHandler(MANAGED_SCM_OUTBOUND_HANDLER); + const sandbox = deps.getSandbox(parsed.sandboxId, { containment: parsed.containment }); + if (parsed.containment) await sandbox.setOutboundHandler(MANAGED_SCM_OUTBOUND_HANDLER); await sandbox.startProcess(`bun run ${CONTROL_WRAPPER_PATH}`, { cwd: '/', env: { diff --git a/services/cloud-agent-next/src/sandbox-control/durable-state.test.ts b/services/cloud-agent-next/src/sandbox-control/durable-state.test.ts index 17b7aeda0d..20d778bbef 100644 --- a/services/cloud-agent-next/src/sandbox-control/durable-state.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/durable-state.test.ts @@ -95,6 +95,32 @@ describe('sandbox control durable state', () => { expect(await loadSessionCredentialGrants(storage)).toStrictEqual(grants); }); + it('round-trips a direct credential grant without losing its scope or containment choice', async () => { + const storage = memoryStorage(); + const contained = credentialGrant(); + const grant = { + ...contained, + containmentEnabled: false, + kilo: { + token: contained.kilo.token, + targets: contained.kilo.targets, + capabilities: {}, + }, + } satisfies SessionCredentialGrant; + await saveSessionCredentialGrants(storage, [grant]); + expect(await loadSessionCredentialGrants(storage)).toStrictEqual([grant]); + }); + + it('rejects a persisted direct grant carrying a contained alias', async () => { + const storage = memoryStorage(); + await storage.put('worktree_credential_grants', [ + { ...credentialGrant(), containmentEnabled: false }, + ]); + await expect(loadSessionCredentialGrants(storage)).rejects.toThrow( + 'Invalid stored worktree credentials' + ); + }); + it.each([ { version: 2 }, { scopeId: '' }, diff --git a/services/cloud-agent-next/src/sandbox-control/frames.test.ts b/services/cloud-agent-next/src/sandbox-control/frames.test.ts index 8f9ca0a112..d61a67797a 100644 --- a/services/cloud-agent-next/src/sandbox-control/frames.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/frames.test.ts @@ -257,6 +257,36 @@ describe('sandbox control frames', () => { ).toBe(true); }); + it.each([ + 'feed_stale', + 'feed_reconnected', + 'feed_ended', + 'feed_failed', + 'process_exited', + 'credential_refresh_failed', + 'control_disconnected', + 'shutdown', + ])('preserves the allowlisted unhealthy heartbeat reason %s', reason => { + const payload = { state: 'idle', kilo: { ready: false, reason }, sessions: [] }; + expect(parseEventPayload('sandbox.heartbeat', payload)).toEqual({ ok: true, payload }); + }); + + it.each(['', 'unknown', 'Error: request failed', { message: 'request failed' }, null])( + 'rejects a non-allowlisted heartbeat reason %j without echoing it', + reason => { + expect( + parseEventPayload('sandbox.heartbeat', { + state: 'idle', + kilo: { ready: false, reason }, + sessions: [], + }) + ).toEqual({ + ok: false, + error: { code: 'protocol_error', message: 'Invalid sandbox.heartbeat payload' }, + }); + } + ); + it('accepts sandbox.heartbeat waitingOn values', () => { for (const waitingOn of ['model', 'tool', 'finalizing'] as const) { expect( diff --git a/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts b/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts index 91e43215a3..5bf5ac83ee 100644 --- a/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts @@ -17,11 +17,12 @@ import type { SandboxControlSocketHooks, } from './socket.js'; import { DEADLINE_MS } from './deadlines.js'; -import { loadDeadlines } from './durable-state.js'; +import { loadDeadlines, loadRouteTable, loadSessionCredentialGrants } from './durable-state.js'; import type * as cloudflareProvider from './cloudflare-provider.js'; import { decodeCloudflareProviderRef } from './cloudflare-provider.js'; import { WORKTREE_CREDENTIAL_CONTAINMENT } from './physical-lifecycle.js'; import { parseSessionMetadata } from '../persistence/session-metadata.js'; +import { logger } from '../logger.js'; const mocks = vi.hoisted(() => ({ getSandbox: vi.fn(), @@ -105,9 +106,10 @@ function allocation() { const configureBilling = vi.fn().mockResolvedValue(undefined); const ensureBillingAdmission = vi.fn().mockResolvedValue({ success: true }); const getBillingRuntimeStatus = vi.fn(); + const setOutboundHandler = vi.fn().mockResolvedValue(undefined); const handle = { startProcess, - setOutboundHandler: vi.fn().mockResolvedValue(undefined), + setOutboundHandler, destroy, forceDestroyForControlPlane: destroy, renewActivityTimeout, @@ -121,6 +123,7 @@ function allocation() { state, handle, startProcess, + setOutboundHandler, destroy, renewActivityTimeout, isContainerRunning, @@ -132,6 +135,7 @@ function allocation() { async function harness( options: { + containmentEnabled?: boolean; env?: Partial; configureAllocation?: (value: ReturnType, id: string) => void; } = {} @@ -215,22 +219,30 @@ async function harness( } return value.handle; }; - const namespace = { - idFromName: (id: string) => ({ toString: () => `do:${id}` }), - getByName: vi.fn(getAllocation), + const containmentEnabled = options.containmentEnabled ?? true; + const expectedNamespace = containmentEnabled ? 'SandboxSmallContainment' : 'SandboxSmall'; + const makeNamespace = (name: string) => ({ + idFromName: (id: string) => ({ toString: () => `do:${name}:${id}` }), + getByName: vi.fn((id: string) => { + expect(name).toBe(expectedNamespace); + return getAllocation(id); + }), + }); + const namespaces = { + Sandbox: makeNamespace('Sandbox'), + SandboxSmall: makeNamespace('SandboxSmall'), + SandboxContainment: makeNamespace('SandboxContainment'), + SandboxSmallContainment: makeNamespace('SandboxSmallContainment'), }; + const namespace = namespaces[expectedNamespace]; + const issueKiloSessionCapability = vi.fn(async () => ({ + success: true, + capability: 'kka1.test-capability', + })); const env = { WORKER_URL: 'https://example.test', - Sandbox: { ...namespace, getByName: vi.fn(getAllocation) }, - SandboxSmall: { ...namespace, getByName: vi.fn(getAllocation) }, - SandboxContainment: namespace, - SandboxSmallContainment: namespace, - GIT_TOKEN_SERVICE: { - issueKiloSessionCapability: vi.fn(async () => ({ - success: true, - capability: 'kka1.test-capability', - })), - }, + ...namespaces, + GIT_TOKEN_SERVICE: { issueKiloSessionCapability }, KILOCODE_BACKEND_BASE_URL: 'https://backend.example.test', KILO_OPENROUTER_BASE: 'https://provider.example.test', KILO_SESSION_INGEST_URL: 'https://ingest.example.test', @@ -250,6 +262,16 @@ async function harness( sandboxId: SANDBOX_ID, workspacePath: ROUTE.directory, sandboxProvider: options.env?.VERCEL_TOKEN ? 'vercel' : 'cloudflare', + ...(options.containmentEnabled === undefined + ? {} + : { + credentialContainment: { + github: false, + gitlab: false, + bitbucket: false, + kilocode: containmentEnabled, + }, + }), }, lifecycle: { version: 1, timestamp: Date.now() }, }) @@ -300,6 +322,8 @@ async function harness( records, env, namespace, + namespaces, + issueKiloSessionCapability, get transactionActive() { return transactionActive; }, @@ -376,6 +400,294 @@ afterEach(() => { }); describe('SandboxControl lifecycle boundaries', () => { + describe.each(['create', 'acquire'] as const)('%s credential policy', createPath => { + it.each([false, undefined])( + 'launches and attaches using persisted containment %s', + async containmentEnabled => { + const h = await harness({ + containmentEnabled, + env: { CREDENTIAL_CONTAINMENT_ENABLED: 'false' }, + }); + if (createPath === 'create') await h.create(); + else { + await h.acquire({ + id: 'credential_attempt', + deadlineAt: Date.now() + SESSION_DELIVERY_TIMEOUT_MS, + }); + } + const physical = await h.control.getPhysicalRecord(); + const native = decodeCloudflareProviderRef(physical.providerRef); + if (!native) throw new Error('Missing native allocation'); + const contained = containmentEnabled !== false; + expect(native.containment).toBe(contained); + expect(physical.createIntent?.containment).toEqual({ + kilocode: contained, + github: contained, + worktreeScoped: true, + }); + expect(mocks.getSandbox).toHaveBeenCalledWith( + contained ? h.namespaces.SandboxSmallContainment : h.namespaces.SandboxSmall, + native.sandboxId + ); + const runtime = h.runtime(physical.providerRef ?? ''); + if (!runtime) throw new Error('Missing runtime'); + expect(runtime.startProcess).toHaveBeenCalledOnce(); + expect(runtime.configureBilling).toHaveBeenCalledWith({ + ...BILLING, + sandboxId: native.sandboxId, + }); + if (contained) expect(runtime.setOutboundHandler).toHaveBeenCalled(); + else expect(runtime.setOutboundHandler).not.toHaveBeenCalled(); + await expect(h.hooks.validateHandshake?.(physical.providerRef ?? '')).resolves.toBe(true); + const identity = await h.ready(); + const payload = await h.control.prepareSessionCredentials({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + }); + const token = contained ? expect.stringMatching(/^kcp1\./) : 'test-token'; + expect(payload).toMatchObject({ + directory: ROUTE.directory, + env: { KILOCODE_TOKEN: token }, + kilo: { scopeId: ROUTE.sessionId, token }, + }); + await expect( + h.control.request({ + operation: 'session.attach', + session: ROUTE, + payload, + expectedWrapperInstanceId: identity.wrapperInstanceId, + }) + ).resolves.toMatchObject({ ok: true }); + expect(h.sendRequest).toHaveBeenCalledWith( + expect.objectContaining({ operation: 'session.attach', payload }) + ); + await expect(h.control.getStatus()).resolves.toMatchObject({ reported: 'ready' }); + if (!contained) { + expect(h.issueKiloSessionCapability).not.toHaveBeenCalled(); + expect(await loadSessionCredentialGrants(h.storage)).toEqual([ + expect.objectContaining({ containmentEnabled: false, scopeId: ROUTE.sessionId }), + ]); + } + } + ); + + it.each([ + { state: 'creating', containmentEnabled: true }, + { state: 'creating', containmentEnabled: false }, + { state: 'running', containmentEnabled: true }, + { state: 'running', containmentEnabled: false }, + ] as const)( + 'preserves the $state allocation with containment $containmentEnabled when another worktree conflicts', + async ({ state, containmentEnabled }) => { + const billingEntered = deferred(); + const releaseBilling = deferred(); + const h = await harness({ + containmentEnabled, + configureAllocation: runtime => { + if (state === 'creating') { + runtime.configureBilling.mockImplementationOnce(() => { + billingEntered.resolve(); + return releaseBilling.promise; + }); + } + }, + }); + const originalAcquisition = { + id: 'original_attempt', + deadlineAt: Date.now() + SESSION_DELIVERY_TIMEOUT_MS, + }; + const acquireOriginal = () => + h.control.ensureReady({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + acquisition: originalAcquisition, + billing: { ...BILLING, sessionId: undefined }, + }); + const acquiring = acquireOriginal(); + if (state === 'creating') await billingEntered.promise; + else await acquiring; + const identity = state === 'running' ? await h.ready() : undefined; + const physical = await h.control.getPhysicalRecord(); + expect(physical.state).toBe(state); + const runtime = [...h.allocations.values()][0]; + if (!runtime) throw new Error('Missing runtime'); + const originalMetadata = await h.session.getCredentialMetadata(); + const otherSessionId = 'workspace_22222222-2222-4222-8222-222222222222'; + const otherSession = { + ...h.session, + getCredentialMetadata: vi.fn(async () => + parseSessionMetadata({ + ...originalMetadata, + identity: { ...originalMetadata.identity, sessionId: otherSessionId }, + auth: { ...originalMetadata.auth, kiloSessionId: 'ses_22222222222222222222222222' }, + workspace: { + ...originalMetadata.workspace, + workspacePath: '/workspace/b', + worktreeId: 'worktree_22222222-2222-4222-8222-222222222222', + credentialContainment: { + github: false, + gitlab: false, + bitbucket: false, + kilocode: !containmentEnabled, + }, + }, + }) + ), + }; + mocks.session.mockImplementation((_env, ownerId: string, sessionId: string) => { + expect(ownerId).toBe(OWNER); + if (sessionId === otherSessionId) return otherSession; + expect(sessionId).toBe(ROUTE.sessionId); + return h.session; + }); + const closeAll = vi.spyOn(h.socket, 'closeAll'); + const closeCount = closeAll.mock.calls.length; + const receipts = structuredClone(h.records.get('acquisition_receipts')); + const grants = await loadSessionCredentialGrants(h.storage); + const routes = await h.control.listRoutes(); + const deadlines = await loadDeadlines(h.storage); + const alarmAt = h.alarmAt; + await expect( + h.control.ensureReady({ + ownerId: OWNER, + sessionId: otherSessionId, + ...(createPath === 'acquire' + ? { acquisition: { ...originalAcquisition, id: 'conflicting_attempt' } } + : { allowCreate: true }), + }) + ).rejects.toThrow('Sandbox containment mode conflicts with the session'); + await h.flush(); + expect(await h.control.getPhysicalRecord()).toEqual(physical); + expect(h.records.get('acquisition_receipts')).toEqual(receipts); + expect(await loadSessionCredentialGrants(h.storage)).toEqual(grants); + expect(await h.control.listRoutes()).toEqual(routes); + expect(await loadDeadlines(h.storage)).toEqual(deadlines); + expect(h.alarmAt).toBe(alarmAt); + expect(runtime.destroy).not.toHaveBeenCalled(); + expect(runtime.state.running).toBe(state === 'running'); + expect(closeAll).toHaveBeenCalledTimes(closeCount); + expect(h.session.failWaitingMessages).not.toHaveBeenCalled(); + expect(h.session.invalidateTerminalRuntime).not.toHaveBeenCalled(); + expect(h.allocations.size).toBe(1); + expect(mocks.providerCreate).toHaveBeenCalledOnce(); + await h.evict(); + expect(await h.control.getPhysicalRecord()).toEqual(physical); + expect(h.records.get('acquisition_receipts')).toEqual(receipts); + releaseBilling.resolve(); + await acquiring; + await acquireOriginal(); + const current = identity ?? (await h.ready()); + await expect( + h.control.request({ + operation: 'session.prompt', + session: ROUTE, + payload: PROMPT, + expectedWrapperInstanceId: current.wrapperInstanceId, + }) + ).resolves.toMatchObject({ ok: true }); + expect(runtime.startProcess).toHaveBeenCalledOnce(); + expect(runtime.destroy).not.toHaveBeenCalled(); + expect(h.allocations.size).toBe(1); + } + ); + }); + + it('retains direct credentials and the uncontained namespace across eviction, flag flips, and replacement', async () => { + const h = await harness({ + containmentEnabled: false, + env: { CREDENTIAL_CONTAINMENT_ENABLED: 'false' }, + }); + await h.create(); + const original = await h.ready(); + const grants = await loadSessionCredentialGrants(h.storage); + h.env.CREDENTIAL_CONTAINMENT_ENABLED = 'true'; + await h.evict(); + await expect(h.create()).resolves.toMatchObject({ reported: 'ready' }); + await expect( + h.control.prepareSessionCredentials({ ownerId: OWNER, sessionId: ROUTE.sessionId }) + ).resolves.toMatchObject({ kilo: { token: 'test-token' } }); + expect(await loadSessionCredentialGrants(h.storage)).toEqual(grants); + expect(h.allocations.size).toBe(1); + await h.control.beginStop('execution_failed'); + await h.control.recordStopAttempt(); + await h.flush(); + const oldNative = decodeCloudflareProviderRef(original.providerInstanceId); + expect(h.namespaces.SandboxSmall.getByName).toHaveBeenCalledWith(oldNative?.sandboxId); + expect(h.runtime(original.providerInstanceId)?.state.running).toBe(false); + await h.create(); + const replacement = await h.ready(); + expect(replacement.providerInstanceId).not.toBe(original.providerInstanceId); + expect(decodeCloudflareProviderRef(replacement.providerInstanceId)?.containment).toBe(false); + await expect( + h.control.validateTerminalAccess({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + wrapperInstanceId: replacement.wrapperInstanceId ?? '', + }) + ).resolves.toEqual({ allowed: true }); + for (const runtime of h.allocations.values()) { + expect(runtime.setOutboundHandler).not.toHaveBeenCalled(); + } + expect(h.namespaces.SandboxSmallContainment.getByName).not.toHaveBeenCalled(); + expect(h.issueKiloSessionCapability).not.toHaveBeenCalled(); + }); + + it('requires reattachment instead of renewing an expired direct terminal grant alone', async () => { + const h = await harness({ containmentEnabled: false }); + await h.create(); + const identity = await h.ready(); + const grants = await loadSessionCredentialGrants(h.storage); + const nearExpiry = grants.map(grant => ({ ...grant, expiresAt: Date.now() + 1 })); + h.records.set('worktree_credential_grants', nearExpiry); + const access = { + ownerId: OWNER, + sessionId: ROUTE.sessionId, + wrapperInstanceId: identity.wrapperInstanceId ?? '', + }; + await expect(h.control.validateTerminalAccess(access)).resolves.toEqual({ allowed: true }); + expect(await loadSessionCredentialGrants(h.storage)).toEqual(nearExpiry); + vi.setSystemTime(Date.now() + 2); + await expect(h.control.validateTerminalAccess(access)).resolves.toEqual({ + allowed: false, + reason: 'credential_reattach_required', + }); + expect(await loadSessionCredentialGrants(h.storage)).toEqual(nearExpiry); + expect(h.issueKiloSessionCapability).not.toHaveBeenCalled(); + expect(h.runtime(identity.providerInstanceId)?.destroy).not.toHaveBeenCalled(); + }); + + it.each(['flags', 'scope', 'provider'] as const)( + 'fails closed on a contained runtime with mismatched %s', + async mismatch => { + const h = await harness(); + await h.create(); + const identity = await h.ready(); + const physical = await h.control.getPhysicalRecord(); + h.records.set('physical_record', { + ...physical, + containment: { + ...physical.containment, + ...(mismatch === 'flags' ? { kilocode: false } : {}), + ...(mismatch === 'scope' ? { worktreeScoped: false } : {}), + ...(mismatch === 'provider' ? { providerRef: 'other_provider' } : {}), + }, + }); + await expect(h.hooks.validateHandshake?.(identity.providerInstanceId)).resolves.toBe(false); + await expect( + h.control.prepareSessionCredentials({ ownerId: OWNER, sessionId: ROUTE.sessionId }) + ).rejects.toThrow('containment is unavailable'); + await expect(h.control.attachSession(ROUTE)).rejects.toThrow('containment mismatch'); + await expect( + h.control.validateTerminalAccess({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + wrapperInstanceId: identity.wrapperInstanceId ?? '', + }) + ).resolves.toEqual({ allowed: false, reason: 'credential_containment_unavailable' }); + expect(h.sendRequest).not.toHaveBeenCalled(); + } + ); + it('binds concurrent acquisition replays to one allocation before provider I/O', async () => { const acquisition = { id: 'attempt_a', deadlineAt: Date.now() + SESSION_DELIVERY_TIMEOUT_MS }; const h = await harness({ @@ -952,6 +1264,71 @@ describe('SandboxControl lifecycle boundaries', () => { await expect(h.control.getStatus()).resolves.toMatchObject({ reported: 'working' }); }); + it.each(['session.attach', 'session.prompt'] as const)( + 'clears rejected %s activity without disturbing a sibling and retires once all work is idle', + async operation => { + const h = await harness(); + await h.create(); + const identity = await h.ready(); + await h.control.request({ operation: 'session.prompt', session: ROUTE, payload: PROMPT }); + const sibling = { + ...ROUTE, + sessionId: 'workspace_44444444-4444-4444-8444-444444444444', + kiloSessionId: 'ses_44444444444444444444444444', + directory: '/workspace/b', + }; + const metadata = await h.session.getCredentialMetadata(); + h.session.getCredentialMetadata.mockResolvedValue({ + ...metadata, + identity: { ...metadata.identity, sessionId: sibling.sessionId }, + auth: { ...metadata.auth, kiloSessionId: sibling.kiloSessionId }, + workspace: { ...metadata.workspace, workspacePath: sibling.directory }, + }); + await h.control.ensureReady({ + ownerId: OWNER, + sessionId: sibling.sessionId, + allowCreate: false, + }); + await h.control.attachSession(sibling); + h.session.getCredentialMetadata.mockResolvedValue(metadata); + h.sendRequest.mockResolvedValueOnce({ + type: 'response', + requestId: 'busy', + ok: false, + error: { code: 'session_busy', message: 'Session has work in progress', retryable: true }, + }); + await expect( + h.control.request({ + operation, + session: sibling, + payload: operation === 'session.prompt' ? { ...PROMPT, messageId: 'rejected' } : {}, + expectedWrapperInstanceId: identity.wrapperInstanceId, + }) + ).resolves.toMatchObject({ ok: false, error: { code: 'session_busy' } }); + await h.hooks.onHeartbeat?.({ ...activeHeartbeat, pendingMessages: 0 }, identity); + const routes = await loadRouteTable(h.storage); + expect(routes.get(ROUTE.sessionId)?.lastState).toBe('active'); + expect(routes.get(sibling.sessionId)).toMatchObject({ lastState: 'idle', waitingOn: null }); + expect((await loadDeadlines(h.storage)).idleStop).toBeUndefined(); + expect(h.runtime(identity.providerInstanceId)?.state.running).toBe(true); + expect(h.runtime(identity.providerInstanceId)?.destroy).not.toHaveBeenCalled(); + expect(h.session.failWaitingMessages).not.toHaveBeenCalled(); + const idleStart = Date.now(); + for (let elapsed = 0; elapsed <= DEADLINE_MS.idleStop; elapsed += 30_000) { + vi.setSystemTime(idleStart + elapsed); + await h.hooks.onHeartbeat?.( + { state: 'idle', kilo: { ready: true }, pendingMessages: 0, sessions: [] }, + identity + ); + await h.control.alarm(); + await h.flush(); + } + expect(h.runtime(identity.providerInstanceId)?.state.running).toBe(false); + expect(h.runtime(identity.providerInstanceId)?.destroy).toHaveBeenCalledOnce(); + expect(await h.control.getPhysicalRecord()).toMatchObject({ state: 'stopped' }); + } + ); + it('does not renew idle or heartbeat deadlines for polling or invalid demand', async () => { const h = await harness(); await h.create(); @@ -1108,30 +1485,34 @@ describe('SandboxControl lifecycle boundaries', () => { await expect(h.control.getStatus()).resolves.toMatchObject({ reported: 'ready' }); }); - it('does not start compute when enforced billing denies admission', async () => { - const h = await harness({ - configureAllocation: runtime => { - runtime.ensureBillingAdmission.mockResolvedValue({ - success: false, - code: 'insufficient_credits', - message: 'denied', - }); - }, - }); - const result = await h.control.ensureReady({ - ownerId: OWNER, - sessionId: ROUTE.sessionId, - allowCreate: true, - billing: { ...BILLING, enforcementRequested: true }, - }); - expect(result.physical).toBe('failed'); - expect(h.alarmAt).not.toBeNull(); - for (const runtime of h.allocations.values()) { - expect(runtime.state.running).toBe(false); - expect(runtime.startProcess).not.toHaveBeenCalled(); + it.each([true, false])( + 'does not start compute when billing denies admission with containment %s', + async containmentEnabled => { + const h = await harness({ + containmentEnabled, + configureAllocation: runtime => { + runtime.ensureBillingAdmission.mockResolvedValue({ + success: false, + code: 'insufficient_credits', + message: 'denied', + }); + }, + }); + const result = await h.control.ensureReady({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + allowCreate: true, + billing: { ...BILLING, enforcementRequested: true }, + }); + expect(result.physical).toBe('failed'); + expect(h.alarmAt).not.toBeNull(); + for (const runtime of h.allocations.values()) { + expect(runtime.state.running).toBe(false); + expect(runtime.startProcess).not.toHaveBeenCalled(); + } + await h.flush(); } - await h.flush(); - }); + ); it('adopts billing for an already-running unmetered Cloudflare allocation without waking it again', async () => { const h = await harness(); @@ -1170,43 +1551,50 @@ describe('SandboxControl lifecycle boundaries', () => { await h.flush(); }); - it('checks enforcement toggles before each warm Cloudflare handoff and denies without execution or wake', async () => { - const h = await harness(); - await h.control.ensureReady({ ownerId: OWNER, sessionId: ROUTE.sessionId, allowCreate: true }); - const connection = await h.ready(); - const runtime = h.runtime(connection.providerInstanceId); - if (!runtime) throw new Error('Missing runtime'); - h.env.CLOUD_AGENT_CONTAINER_BILLING_ENABLED = 'true'; - h.env.CLOUD_AGENT_CONTAINER_BILLING_USER_IDS = OWNER; - runtime.ensureBillingAdmission.mockResolvedValueOnce({ - success: false, - code: 'insufficient_credits', - message: 'denied', - }); - const handoff = () => - h.control - .ensureReady({ ownerId: OWNER, sessionId: ROUTE.sessionId, billing: BILLING }) - .then(() => - h.control.request({ - operation: 'session.prompt', - session: ROUTE, - payload: { ...PROMPT, messageId: 'message_B' }, - }) - ); - await expect(handoff()).rejects.toThrow('additional credits'); - expect(runtime.ensureBillingAdmission).toHaveBeenCalledWith({ - ...BILLING, - sandboxId: decodeCloudflareProviderRef(connection.providerInstanceId)?.sandboxId, - enforcementRequested: true, - }); - expect(h.sendRequest).not.toHaveBeenCalled(); - expect(runtime.startProcess).toHaveBeenCalledTimes(1); - expect(runtime.renewActivityTimeout).not.toHaveBeenCalled(); - expect(h.allocations.size).toBe(1); - await expect(handoff()).resolves.toMatchObject({ ok: true }); - expect(runtime.ensureBillingAdmission).toHaveBeenCalledTimes(2); - expect(h.sendRequest).toHaveBeenCalledTimes(1); - }); + it.each([true, false])( + 'checks warm Cloudflare billing enforcement without execution or wake with containment %s', + async containmentEnabled => { + const h = await harness({ containmentEnabled }); + await h.control.ensureReady({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + allowCreate: true, + }); + const connection = await h.ready(); + const runtime = h.runtime(connection.providerInstanceId); + if (!runtime) throw new Error('Missing runtime'); + h.env.CLOUD_AGENT_CONTAINER_BILLING_ENABLED = 'true'; + h.env.CLOUD_AGENT_CONTAINER_BILLING_USER_IDS = OWNER; + runtime.ensureBillingAdmission.mockResolvedValueOnce({ + success: false, + code: 'insufficient_credits', + message: 'denied', + }); + const handoff = () => + h.control + .ensureReady({ ownerId: OWNER, sessionId: ROUTE.sessionId, billing: BILLING }) + .then(() => + h.control.request({ + operation: 'session.prompt', + session: ROUTE, + payload: { ...PROMPT, messageId: 'message_B' }, + }) + ); + await expect(handoff()).rejects.toThrow('additional credits'); + expect(runtime.ensureBillingAdmission).toHaveBeenCalledWith({ + ...BILLING, + sandboxId: decodeCloudflareProviderRef(connection.providerInstanceId)?.sandboxId, + enforcementRequested: true, + }); + expect(h.sendRequest).not.toHaveBeenCalled(); + expect(runtime.startProcess).toHaveBeenCalledTimes(1); + expect(runtime.renewActivityTimeout).not.toHaveBeenCalled(); + expect(h.allocations.size).toBe(1); + await expect(handoff()).resolves.toMatchObject({ ok: true }); + expect(runtime.ensureBillingAdmission).toHaveBeenCalledTimes(2); + expect(h.sendRequest).toHaveBeenCalledTimes(1); + } + ); it('rejects enforced warm Vercel reuse without provider I/O or a new handoff', async () => { const fetchProvider = vi.fn(); @@ -1391,6 +1779,56 @@ describe('SandboxControl lifecycle boundaries', () => { ).resolves.toMatchObject({ ok: true }); }); + it.each([undefined, 'feed_stale', 'credential_refresh_failed'] as const)( + 'logs unhealthy heartbeat reason %s before quarantine without changing stop semantics', + async reason => { + const h = await harness(); + await h.create(); + const identity = await h.ready(); + const runtime = h.runtime(identity.providerInstanceId); + const closeAll = vi.spyOn(h.socket, 'closeAll'); + closeAll.mockClear(); + const fields = vi.spyOn(logger, 'withFields').mockReturnValue(logger); + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => { + expect(closeAll).not.toHaveBeenCalled(); + expect(runtime?.state.running).toBe(true); + }); + try { + const payload: SandboxHeartbeatPayload = { + ...activeHeartbeat, + kilo: { ready: false, ...(reason ? { reason } : {}) }, + }; + await h.hooks.onHeartbeat?.(activeHeartbeat, identity); + await h.hooks.onHeartbeat?.(payload, { + ...identity, + connectionId: crypto.randomUUID(), + }); + expect(warn).not.toHaveBeenCalled(); + await h.hooks.onHeartbeat?.(payload, identity); + expect(fields).toHaveBeenCalledExactlyOnceWith({ + sandboxId: SANDBOX_ID, + wrapperInstanceId: identity.wrapperInstanceId, + connectionId: identity.connectionId, + reason: reason ?? 'unknown', + }); + expect(warn).toHaveBeenCalledExactlyOnceWith( + 'Sandbox control received unhealthy heartbeat' + ); + await h.flush(); + expect(runtime?.state.running).toBe(false); + expect(h.session.failWaitingMessages).toHaveBeenCalledWith( + 'kilo_unhealthy', + identity.wrapperInstanceId + ); + expect(runtime?.renewActivityTimeout).toHaveBeenCalledTimes(1); + } finally { + fields.mockRestore(); + warn.mockRestore(); + closeAll.mockRestore(); + } + } + ); + it('transfers a fenced quarantine durably before stop I/O and survives eviction', async () => { const stop = deferred(); const observed = deferred<{ attempts: number | undefined; alarmAt: number | null }>(); @@ -1721,139 +2159,157 @@ describe('SandboxControl lifecycle boundaries', () => { await h.flush(); }); - it('reconciles a lost Vercel create response with a non-waking lookup across runtime config rotation', async () => { - const remote = new Map(); - const inspected: URL[] = []; - const allocated = deferred(); - vi.stubGlobal( - 'fetch', - vi.fn(async (input: string, init?: RequestInit) => { - const url = new URL(input); - if (url.pathname === '/v2/sandboxes' && init?.method === 'POST') { - if (typeof init.body !== 'string') throw new Error('Expected JSON request body'); - const body = JSON.parse(init.body) as { - name: string; - projectId: string; - runtime: string; - timeout: number; - source: { snapshotId: string }; - tags: Record; - }; - if (remote.has(body.name)) throw new Error('Name is retained'); - const sessionId = `vsess_${remote.size + 1}`; - const created: VercelSandboxCreateEnvelope = { - sandbox: { - name: body.name, - currentSessionId: sessionId, - status: 'running', - persistent: false, - createdAt: Date.now(), - updatedAt: Date.now(), - tags: body.tags, - }, - session: { - id: sessionId, - sourceSandboxName: body.name, - projectId: body.projectId, - sourceSnapshotId: body.source.snapshotId, - runtime: body.runtime, - status: 'running', - memory: 2048, - vcpus: 2, - region: 'iad1', - timeout: body.timeout, - requestedAt: Date.now(), - cwd: '/', - createdAt: Date.now(), - updatedAt: Date.now(), - }, - routes: [], - runtime: { sandboxName: body.name, sessionId }, - }; - remote.set(body.name, created); - if (remote.size === 1) { - allocated.resolve(); - return new Promise(() => undefined); + it.each([true, false])( + 'reconciles a lost Vercel create response across runtime config rotation with containment %s', + async containmentEnabled => { + const remote = new Map(); + const inspected: URL[] = []; + const policyUpdates: URL[] = []; + const allocated = deferred(); + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string, init?: RequestInit) => { + const url = new URL(input); + if (url.pathname.endsWith('/network-policy')) policyUpdates.push(url); + if (url.pathname === '/v2/sandboxes' && init?.method === 'POST') { + if (typeof init.body !== 'string') throw new Error('Expected JSON request body'); + const body = JSON.parse(init.body) as { + name: string; + projectId: string; + runtime: string; + timeout: number; + source: { snapshotId: string }; + tags: Record; + networkPolicy?: unknown; + }; + if (!containmentEnabled) expect(body.networkPolicy).toBeUndefined(); + if (remote.has(body.name)) throw new Error('Name is retained'); + const sessionId = `vsess_${remote.size + 1}`; + const created: VercelSandboxCreateEnvelope = { + sandbox: { + name: body.name, + currentSessionId: sessionId, + status: 'running', + persistent: false, + createdAt: Date.now(), + updatedAt: Date.now(), + tags: body.tags, + }, + session: { + id: sessionId, + sourceSandboxName: body.name, + projectId: body.projectId, + sourceSnapshotId: body.source.snapshotId, + runtime: body.runtime, + status: 'running', + memory: 2048, + vcpus: 2, + region: 'iad1', + timeout: body.timeout, + requestedAt: Date.now(), + cwd: '/', + createdAt: Date.now(), + updatedAt: Date.now(), + }, + routes: [], + runtime: { sandboxName: body.name, sessionId }, + }; + remote.set(body.name, created); + if (remote.size === 1) { + allocated.resolve(); + return new Promise(() => undefined); + } + return Response.json(created); } - return Response.json(created); - } - if (!url.pathname.startsWith('/v2/sandboxes/sessions/')) { - inspected.push(url); - const created = remote.get(url.pathname.split('/').at(-1) ?? ''); - return created - ? Response.json({ ...created, resumed: false }) - : new Response(null, { status: 404 }); - } - const sessionId = url.pathname.split('/')[4]; - const created = [...remote.values()].find(value => value.session.id === sessionId); - if (!created) return new Response(null, { status: 404 }); - if (url.pathname.endsWith('/stop')) { - created.session.status = 'stopped'; - return Response.json({ session: created.session }); - } - if (url.pathname.endsWith('/cmd')) { - return Response.json({ - command: { - id: 'cmd_1', - name: 'sh', - args: [], - cwd: '/', - sessionId, - exitCode: null, - startedAt: Date.now(), - }, - }); - } - return Response.json({ session: created.session, routes: [] }); - }) - ); - const h = await harness({ - env: { - VERCEL_TOKEN: 'test-token', - VERCEL_TEAM_ID: 'team_1', - VERCEL_PROJECT_ID: 'project_1', - VERCEL_SANDBOX_RUNTIME_BUILD_ID: 'build_1', - VERCEL_SANDBOX_SNAPSHOT_ID: 'snapshot_1', - VERCEL_SANDBOX_RUNTIME: 'node24', - VERCEL_SANDBOX_INITIAL_TIMEOUT_MS: '300000', - VERCEL_SANDBOX_EXTEND_DURATION_MS: '120000', - }, - }); - const creating = h.control.ensureReady({ - ownerId: OWNER, - sessionId: ROUTE.sessionId, - provider: 'vercel', - allowCreate: true, - billing: BILLING, - }); - await allocated.promise; - await vi.advanceTimersByTimeAsync(DEADLINE_MS.startup); - await expect(creating).resolves.toMatchObject({ physical: 'failed' }); - const uncertain = await h.control.getPhysicalRecord(); - expect(uncertain.providerRef).toBeNull(); - expect(uncertain.createIntent).toMatchObject({ - allocationName: [...remote.keys()][0], - vercel: { runtimeBuildId: 'build_1', snapshotId: 'snapshot_1' }, - }); - h.env.VERCEL_SANDBOX_RUNTIME_BUILD_ID = 'build_2'; - h.env.VERCEL_SANDBOX_SNAPSHOT_ID = 'snapshot_2'; - await h.evict(); - await h.fireAlarm(); - expect(inspected).toHaveLength(1); - expect(inspected[0]?.searchParams.get('resume')).toBe('false'); - expect((await h.control.getPhysicalRecord()).state).toBe('stopped'); - expect([...remote.values()][0]?.session.status).toBe('stopped'); - await h.create(); - await h.ready(); - expect(remote.size).toBe(2); - expect([...remote.values()][1]?.session.sourceSnapshotId).toBe('snapshot_2'); - expect((await h.control.getPhysicalRecord()).createIntent?.vercel?.runtimeBuildId).toBe( - 'build_2' - ); - expect(mocks.getSandbox).not.toHaveBeenCalled(); - await expect(h.control.getStatus()).resolves.toMatchObject({ reported: 'ready' }); - await h.flush(); - }); + if (!url.pathname.startsWith('/v2/sandboxes/sessions/')) { + inspected.push(url); + const created = remote.get(url.pathname.split('/').at(-1) ?? ''); + return created + ? Response.json({ ...created, resumed: false }) + : new Response(null, { status: 404 }); + } + const sessionId = url.pathname.split('/')[4]; + const created = [...remote.values()].find(value => value.session.id === sessionId); + if (!created) return new Response(null, { status: 404 }); + if (url.pathname.endsWith('/stop')) { + created.session.status = 'stopped'; + return Response.json({ session: created.session }); + } + if (url.pathname.endsWith('/cmd')) { + return Response.json({ + command: { + id: 'cmd_1', + name: 'sh', + args: [], + cwd: '/', + sessionId, + exitCode: null, + startedAt: Date.now(), + }, + }); + } + return Response.json({ session: created.session, routes: [] }); + }) + ); + const h = await harness({ + containmentEnabled, + env: { + VERCEL_TOKEN: 'test-token', + VERCEL_TEAM_ID: 'team_1', + VERCEL_PROJECT_ID: 'project_1', + VERCEL_SANDBOX_RUNTIME_BUILD_ID: 'build_1', + VERCEL_SANDBOX_SNAPSHOT_ID: 'snapshot_1', + VERCEL_SANDBOX_RUNTIME: 'node24', + VERCEL_SANDBOX_INITIAL_TIMEOUT_MS: '300000', + VERCEL_SANDBOX_EXTEND_DURATION_MS: '120000', + }, + }); + const creating = h.control.ensureReady({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + provider: 'vercel', + allowCreate: true, + billing: BILLING, + }); + await allocated.promise; + await vi.advanceTimersByTimeAsync(DEADLINE_MS.startup); + await expect(creating).resolves.toMatchObject({ physical: 'failed' }); + const uncertain = await h.control.getPhysicalRecord(); + expect(uncertain.providerRef).toBeNull(); + expect(uncertain.createIntent).toMatchObject({ + allocationName: [...remote.keys()][0], + vercel: { runtimeBuildId: 'build_1', snapshotId: 'snapshot_1' }, + }); + h.env.VERCEL_SANDBOX_RUNTIME_BUILD_ID = 'build_2'; + h.env.VERCEL_SANDBOX_SNAPSHOT_ID = 'snapshot_2'; + h.env.CREDENTIAL_CONTAINMENT_ENABLED = containmentEnabled ? 'false' : 'true'; + await h.evict(); + await h.fireAlarm(); + expect(inspected).toHaveLength(1); + expect(inspected[0]?.searchParams.get('resume')).toBe('false'); + expect((await h.control.getPhysicalRecord()).state).toBe('stopped'); + expect([...remote.values()][0]?.session.status).toBe('stopped'); + await h.create(); + await h.ready(); + expect(remote.size).toBe(2); + expect([...remote.values()][1]?.session.sourceSnapshotId).toBe('snapshot_2'); + expect((await h.control.getPhysicalRecord()).createIntent?.vercel?.runtimeBuildId).toBe( + 'build_2' + ); + expect(mocks.getSandbox).not.toHaveBeenCalled(); + await expect(h.control.getStatus()).resolves.toMatchObject({ reported: 'ready' }); + if (!containmentEnabled) { + await expect( + h.control.prepareSessionCredentials({ ownerId: OWNER, sessionId: ROUTE.sessionId }) + ).resolves.toMatchObject({ kilo: { token: 'test-token' } }); + await h.control.detachSession(ROUTE.sessionId); + expect(await loadDeadlines(h.storage)).not.toHaveProperty('credentialExpiry'); + expect(policyUpdates).toEqual([]); + expect(h.issueKiloSessionCapability).not.toHaveBeenCalled(); + } + await h.flush(); + } + ); it('quarantines an established control disconnect even when the provider remains active', async () => { const h = await harness({ @@ -2468,62 +2924,96 @@ describe('SandboxControl lifecycle boundaries', () => { expect(mocks.getSandbox).not.toHaveBeenCalled(); }); - it('validates terminal billing against the physical allocation rather than the logical route', async () => { - const h = await harness({ - env: { - CLOUD_AGENT_CONTAINER_BILLING_ENABLED: 'true', - CLOUD_AGENT_CONTAINER_BILLING_USER_IDS: OWNER, - }, - }); - await h.create(); - const connection = await h.ready(); - const runtime = h.runtime(connection.providerInstanceId); - const native = decodeCloudflareProviderRef(connection.providerInstanceId); - if (!native) throw new Error('Missing native allocation'); - const context: BillingContext = { - service: 'cloud-agent-next-sandbox-small-containment', - instanceId: native.sandboxId, - sku: SANDBOX_USAGE_SKUS.SandboxSmallContainment, - subject: BILLING.subject, - actor: BILLING.actor, - sessionId: ROUTE.sessionId, - metadata: { - origin: 'cloud-agent', - container_class: 'SandboxSmallContainment', - durable_object_id: `do:${native.sandboxId}`, - }, - startEpochMs: Date.now(), - generation: crypto.randomUUID(), - measurementStarted: true, - nextSeq: 1, - usageMeasuredAtMs: Date.now(), - }; - runtime?.getBillingRuntimeStatus.mockResolvedValue({ - sandboxClassName: 'SandboxSmallContainment', - running: true, - blocked: false, - context, - }); - await expect( - h.control.validateTerminalAccess({ - ownerId: OWNER, + it.each([true, false])( + 'validates terminal authorization and physical billing with containment %s', + async containmentEnabled => { + const h = await harness({ + containmentEnabled, + env: { + CLOUD_AGENT_CONTAINER_BILLING_ENABLED: 'true', + CLOUD_AGENT_CONTAINER_BILLING_USER_IDS: OWNER, + }, + }); + await h.create(); + const connection = await h.ready(); + const runtime = h.runtime(connection.providerInstanceId); + const native = decodeCloudflareProviderRef(connection.providerInstanceId); + if (!native) throw new Error('Missing native allocation'); + const sandboxClassName = containmentEnabled ? 'SandboxSmallContainment' : 'SandboxSmall'; + const context: BillingContext = { + service: containmentEnabled + ? 'cloud-agent-next-sandbox-small-containment' + : 'cloud-agent-next-sandbox-small', + instanceId: native.sandboxId, + sku: SANDBOX_USAGE_SKUS[sandboxClassName], + subject: BILLING.subject, + actor: BILLING.actor, sessionId: ROUTE.sessionId, - wrapperInstanceId: connection.wrapperInstanceId ?? '', - }) - ).resolves.toEqual({ allowed: true }); - expect(h.allocations.has(SANDBOX_ID)).toBe(false); - runtime?.getBillingRuntimeStatus.mockResolvedValue({ - sandboxClassName: 'SandboxSmallContainment', - running: true, - blocked: false, - context: { ...context, instanceId: SANDBOX_ID }, - }); - await expect( - h.control.validateTerminalAccess({ + metadata: { + origin: 'cloud-agent', + container_class: sandboxClassName, + durable_object_id: h.namespace.idFromName(native.sandboxId).toString(), + }, + startEpochMs: Date.now(), + generation: crypto.randomUUID(), + measurementStarted: true, + nextSeq: 1, + usageMeasuredAtMs: Date.now(), + }; + runtime?.getBillingRuntimeStatus.mockResolvedValue({ + sandboxClassName, + running: true, + blocked: false, + context, + }); + await expect( + h.control.validateTerminalAccess({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + wrapperInstanceId: connection.wrapperInstanceId ?? '', + }) + ).resolves.toEqual({ allowed: true }); + expect(h.allocations.has(SANDBOX_ID)).toBe(false); + const access = { ownerId: OWNER, sessionId: ROUTE.sessionId, wrapperInstanceId: connection.wrapperInstanceId ?? '', - }) - ).resolves.toEqual({ allowed: false, reason: 'billing_runtime_mismatch' }); - }); + }; + for (const [override, reason] of [ + [{ ownerId: 'other_owner' }, 'owner_mismatch'], + [{ sessionId: 'other_session' }, 'session_not_attached'], + [{ wrapperInstanceId: crypto.randomUUID() }, 'wrapper_instance_mismatch'], + [{ organizationId: 'other_org' }, 'credential_scope_unavailable'], + ] as const) { + await expect(h.control.validateTerminalAccess({ ...access, ...override })).resolves.toEqual( + { + allowed: false, + reason, + } + ); + } + await expect(h.control.attachSession({ ...ROUTE, ownerId: 'other_owner' })).rejects.toThrow( + 'owner mismatch' + ); + await expect(h.control.attachSession({ ...ROUTE, directory: '/other' })).rejects.toThrow(); + expect(runtime?.getBillingRuntimeStatus).toHaveBeenCalledOnce(); + if (!containmentEnabled) { + expect(h.issueKiloSessionCapability).not.toHaveBeenCalled(); + expect(runtime?.setOutboundHandler).not.toHaveBeenCalled(); + } + runtime?.getBillingRuntimeStatus.mockResolvedValue({ + sandboxClassName, + running: true, + blocked: false, + context: { ...context, instanceId: SANDBOX_ID }, + }); + await expect( + h.control.validateTerminalAccess({ + ownerId: OWNER, + sessionId: ROUTE.sessionId, + wrapperInstanceId: connection.wrapperInstanceId ?? '', + }) + ).resolves.toEqual({ allowed: false, reason: 'billing_runtime_mismatch' }); + } + ); }); diff --git a/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.test.ts b/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.test.ts index e260340b3c..ee94d1ac2a 100644 --- a/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.test.ts @@ -6,6 +6,7 @@ import { confirmStopped, exhaustStopRetries, fail, + getWorktreeCredentialContainment, initialPhysicalRecord, observe, recordStopAttempt, @@ -18,7 +19,8 @@ const INTENT_ID = 'intent_1'; const PROVIDER_REF = 'ref_1'; const CONTAINMENT_CASES = [ { name: 'legacy flags', containment: { kilocode: true, github: false } }, - { name: 'worktree scope', containment: WORKTREE_CREDENTIAL_CONTAINMENT }, + { name: 'worktree scope', containment: getWorktreeCredentialContainment(true) }, + { name: 'uncontained worktree scope', containment: getWorktreeCredentialContainment(false) }, ]; function stopped(resumable = true): PhysicalRecord { @@ -37,6 +39,15 @@ function running(): PhysicalRecord { } describe('physical sandbox lifecycle', () => { + it('keeps worktree credential scope independent of containment enforcement', () => { + expect(getWorktreeCredentialContainment(true)).toBe(WORKTREE_CREDENTIAL_CONTAINMENT); + expect(getWorktreeCredentialContainment(false)).toEqual({ + kilocode: false, + github: false, + worktreeScoped: true, + }); + }); + it('starts stopped with no handles', () => { expect(stopped(false)).toEqual({ state: 'stopped', diff --git a/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.ts b/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.ts index ec2fc8f68e..391b6ab3b6 100644 --- a/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.ts +++ b/services/cloud-agent-next/src/sandbox-control/physical-lifecycle.ts @@ -14,6 +14,14 @@ export const WORKTREE_CREDENTIAL_CONTAINMENT = { worktreeScoped: true, } satisfies CredentialContainmentRequirements; +export function getWorktreeCredentialContainment( + enabled: boolean +): CredentialContainmentRequirements { + return enabled + ? WORKTREE_CREDENTIAL_CONTAINMENT + : { kilocode: false, github: false, worktreeScoped: true }; +} + export type CreateIntent = { intentId: string; createdAt: number; diff --git a/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts b/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts index da02927cf0..111008469f 100644 --- a/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/session-credentials.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import jwt from 'jsonwebtoken'; import type { CloudAgentWorktreeId } from '@kilocode/session-ingest-contracts'; import { logger } from '../logger.js'; import type { SessionMetadata } from '../persistence/session-metadata.js'; @@ -6,6 +7,7 @@ import type { Env, GitTokenService } from '../types.js'; import { createControlPlaneCredential, parseControlPlaneCredential } from './managed-credential.js'; import { buildControlNetworkPolicy, + isContainedSessionCredentialGrant, prepareSessionCredentials, removeSessionCredentialMembership, resolveSessionCredential, @@ -72,7 +74,17 @@ function createBroker() { appType: 'standard', })), getToken: vi.fn(async () => GITHUB_TOKEN), - getCloudAgentAuthForRepo: vi.fn>(), + getCloudAgentAuthForRepo: vi.fn>( + async () => ({ + success: true, + githubToken: GITHUB_TOKEN, + installationId: '42', + accountLogin: 'acme', + appType: 'standard', + source: 'user', + gitAuthor: { name: 'bot', email: 'bot@example.com' }, + }) + ), issueKiloSessionCapability: vi.fn( async subject => { const capability = `kka1.test-${++serial}`; @@ -112,8 +124,16 @@ function createBroker() { capability: `kbb1.test-${++serial}`, gitUrl: 'https://bitbucket.org/acme/canonical-repo.git', })), - getGitLabToken: vi.fn(), - getBitbucketToken: vi.fn>(), + getGitLabToken: vi.fn(async () => ({ + success: true, + token: 'test-real-gitlab-token', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + glabIsOAuth2: false, + })), + getBitbucketToken: vi.fn>(async () => ({ + success: true, + token: 'test-real-bitbucket-token', + })), redeemKiloSessionCapability: vi.fn(), redeemGitHubSessionCapability: vi.fn(), redeemGitLabSessionCapability: vi.fn(), @@ -177,13 +197,13 @@ function secondRoot(overrides: Partial = {}): SessionMetadata { }); } -function prepare( +async function prepare( env: CredentialEnv, data = metadata(), existing?: SessionCredentialGrant, now = NOW ) { - return prepareSessionCredentials({ + const result = await prepareSessionCredentials({ env, metadata: data, sandboxId: data.workspace?.sandboxId ?? SANDBOX_ID, @@ -193,6 +213,8 @@ function prepare( existing, now, }); + if (!isContainedSessionCredentialGrant(result.grant)) throw new Error('Expected contained grant'); + return { ...result, grant: result.grant }; } function resolve( @@ -203,7 +225,7 @@ function resolve( return resolveSessionCredential({ env, grant, - credential: grant.kilo.alias, + credential: grant.kilo.alias ?? grant.kilo.token, url: 'https://provider.example.com/api/openrouter/chat/completions', method: 'POST', outboundContainerId: OUTBOUND_CONTAINER_ID, @@ -895,6 +917,646 @@ describe('trusted worktree credential preparation', () => { }); }); +function directMetadata(overrides: Partial = {}): SessionMetadata { + return metadata({ + ...overrides, + workspace: { + ...overrides.workspace, + credentialContainment: { kilocode: false, github: false, gitlab: false, bitbucket: false }, + }, + }); +} + +function prepareDirect( + env: CredentialEnv, + data = directMetadata(), + existing?: SessionCredentialGrant, + now = NOW +) { + return prepareSessionCredentials({ + env, + metadata: data, + sandboxId: data.workspace?.sandboxId ?? SANDBOX_ID, + existing, + now, + }); +} + +function expectNoCapabilities(broker: ReturnType['broker']): void { + expect(broker.issueKiloSessionCapability).not.toHaveBeenCalled(); + expect(broker.issueGitHubSessionCapability).not.toHaveBeenCalled(); + expect(broker.issueGitLabSessionCapability).not.toHaveBeenCalled(); + expect(broker.issueBitbucketSessionCapability).not.toHaveBeenCalled(); +} + +describe('direct worktree credentials', () => { + it.each(['cloudflare', 'vercel'] as const)( + 'prepares real credentials without aliases or capabilities on %s', + async provider => { + const { broker } = createBroker(); + const env = environment(broker); + const data = directMetadata({ + workspace: { + sandboxProvider: provider, + sandboxId: provider === 'vercel' ? VERCEL_SANDBOX_ID : SANDBOX_ID, + }, + }); + const { grant, payload } = await prepareDirect(env, data); + expect(grant.containmentEnabled).toBe(false); + expect(isContainedSessionCredentialGrant(grant)).toBe(false); + expect(grant.kilo.alias).toBeUndefined(); + expect(grant.kilo.capabilities).toEqual({}); + expect(grant.scm?.alias).toBeUndefined(); + expect(grant.scm?.capability).toBeUndefined(); + expect(payload.kilo).toEqual({ + scopeId: grant.scopeId, + token: KILO_TOKEN, + containmentEnabled: false, + targets: grant.kilo.targets, + organizationId: INTEGRATION_ID, + }); + expect(payload.git).toEqual({ + url: 'https://github.com/acme/repo.git', + platform: 'github', + token: GITHUB_TOKEN, + }); + expect(payload.env).toMatchObject({ + KILOCODE_TOKEN: KILO_TOKEN, + GH_TOKEN: GITHUB_TOKEN, + KILOCODE_ORGANIZATION_ID: INTEGRATION_ID, + }); + expect(broker.getCloudAgentAuthForRepo).toHaveBeenCalledWith({ + githubRepo: 'acme/repo', + userId: 'user-a', + orgId: INTEGRATION_ID, + expectedIntegrationId: INTEGRATION_ID, + allowUserAuthorization: true, + }); + expectNoCapabilities(broker); + expect(sessionCredentialGrantSchema.parse(JSON.parse(JSON.stringify(grant)))).toEqual(grant); + expect(await resolve(env, grant)).toBeNull(); + expect(() => buildControlNetworkPolicy([grant])).toThrow( + 'Invalid contained worktree credentials' + ); + } + ); + + it('prepares repository-free worktrees without a broker', async () => { + const { grant, payload } = await prepareDirect( + environment(), + directMetadata({ repository: undefined }) + ); + expect(payload.kilo.token).toBe(KILO_TOKEN); + expect(payload.git).toBeUndefined(); + expect(grant.scm).toBeUndefined(); + }); + + it('preserves profile overrides while trusting metadata for Kilo identity and scope', async () => { + const { broker } = createBroker(); + const token = `https://selected-provider.example.com/api/openrouter:${KILO_TOKEN}`; + const data = directMetadata({ + auth: { kiloSessionId: ROOT_ID, kilocodeToken: token }, + profile: { + envVars: { + GH_TOKEN: 'custom-gh', + GITHUB_TOKEN: 'custom-github', + KILOCODE_TOKEN: 'untrusted-kilo', + KILOCODE_ORGANIZATION_ID: 'untrusted-org', + PUBLIC_VALUE: 'kept', + }, + setupCommands: ['run --token=custom-gh'], + }, + }); + const { payload } = await prepareDirect(environment(broker), data); + expect(payload.env).toMatchObject({ + GH_TOKEN: 'custom-gh', + GITHUB_TOKEN: 'custom-github', + KILOCODE_TOKEN: token, + KILOCODE_ORGANIZATION_ID: INTEGRATION_ID, + PUBLIC_VALUE: 'kept', + }); + expect(payload.kilo.targets.providerBaseUrl).toBe( + 'https://selected-provider.example.com/api/openrouter' + ); + expect(payload.kilo.organizationId).toBe(INTEGRATION_ID); + expect(payload.setupCommands).toEqual(['run --token=custom-gh']); + expect(payload.git?.token).toBe(GITHUB_TOKEN); + const personal = await prepareDirect( + environment(broker), + directMetadata({ + identity: { ...data.identity, orgId: undefined }, + profile: { + envVars: { GITHUB_TOKEN: 'custom-github', KILOCODE_ORGANIZATION_ID: 'untrusted-org' }, + }, + }) + ); + expect(personal.payload.env?.GH_TOKEN).toBe('custom-github'); + expect(personal.payload.kilo.organizationId).toBeUndefined(); + expect(personal.payload.env?.KILOCODE_ORGANIZATION_ID).toBeUndefined(); + expectNoCapabilities(broker); + }); + + it('keeps explicit GitHub authentication without calling a broker', async () => { + const { broker } = createBroker(); + const { payload } = await prepareDirect( + environment(broker), + directMetadata({ repository: { type: 'github', repo: 'acme/repo', token: GITHUB_TOKEN } }) + ); + expect(payload.git?.token).toBe(GITHUB_TOKEN); + expect(broker.getCloudAgentAuthForRepo).not.toHaveBeenCalled(); + expect(broker.getTokenForRepo).not.toHaveBeenCalled(); + expectNoCapabilities(broker); + }); + + it('resolves scoped managed GitLab tokens and rotates only managed profile carriers', async () => { + const { broker } = createBroker(); + const data = directMetadata({ + repository: { + type: 'gitlab', + url: 'https://gitlab.example.com:8443/gitlab/acme/repo.git', + token: 'old-managed-token', + gitlabTokenManaged: true, + }, + profile: { + envVars: { + GITLAB_TOKEN: 'old-managed-token', + GITLAB_HOST: 'stale.example.com', + GLAB_IS_OAUTH2: 'true', + }, + }, + }); + const { payload } = await prepareDirect(environment(broker), data); + expect(payload.git?.token).toBe('test-real-gitlab-token'); + expect(payload.env).toMatchObject({ + GITLAB_TOKEN: 'test-real-gitlab-token', + GITLAB_HOST: 'gitlab.example.com:8443', + GITLAB_SUBFOLDER: 'gitlab', + GLAB_IS_OAUTH2: 'false', + }); + expect(broker.getGitLabToken).toHaveBeenCalledWith({ + userId: 'user-a', + orgId: INTEGRATION_ID, + repositoryUrl: 'https://gitlab.example.com:8443/gitlab/acme/repo.git', + createdOnPlatform: 'cloud-agent-web', + }); + const custom = await prepareDirect(environment(broker), { + ...data, + profile: { envVars: { GITLAB_TOKEN: 'custom-gitlab', GITLAB_HOST: 'custom.example.com' } }, + }); + expect(custom.payload.env).toMatchObject({ + GITLAB_TOKEN: 'custom-gitlab', + GITLAB_HOST: 'custom.example.com', + }); + expectNoCapabilities(broker); + }); + + it.each([ + 'https://other.example.com/gitlab', + 'https://gitlab.example.com/gitlab', + 'https://gitlab.example.com:8443/gitlab-other', + ])('rejects GitLab instance scope mismatch: %s', async instanceUrl => { + const { broker } = createBroker(); + broker.getGitLabToken.mockResolvedValue({ + success: true, + token: 'test-real-gitlab-token', + instanceUrl, + glabIsOAuth2: false, + }); + await expect( + prepareDirect( + environment(broker), + directMetadata({ + repository: { + type: 'gitlab', + url: 'https://gitlab.example.com:8443/gitlab/acme/repo.git', + }, + }) + ) + ).rejects.toThrow('Invalid contained worktree credentials'); + expectNoCapabilities(broker); + }); + + it('resolves Bitbucket tokens with organization, UUID, URL and integration authorization', async () => { + const { broker } = createBroker(); + const data = directMetadata({ + repository: { + type: 'bitbucket', + url: 'https://bitbucket.org/acme/repo.git', + workspaceUuid: WORKSPACE_UUID, + repositoryUuid: REPOSITORY_UUID, + bitbucketIntegrationId: INTEGRATION_ID, + }, + }); + const { payload } = await prepareDirect(environment(broker), data); + expect(payload.git?.token).toBe('test-real-bitbucket-token'); + expect(payload.env).toMatchObject({ + BITBUCKET_TOKEN: 'test-real-bitbucket-token', + KILO_BITBUCKET_WORKSPACE_SLUG: 'acme', + KILO_BITBUCKET_REPOSITORY_SLUG: 'repo', + KILO_BITBUCKET_WORKSPACE_UUID: `{${WORKSPACE_UUID}}`, + KILO_BITBUCKET_REPOSITORY_UUID: `{${REPOSITORY_UUID}}`, + }); + expect(broker.getBitbucketToken).toHaveBeenCalledWith({ + userId: 'user-a', + orgId: INTEGRATION_ID, + expectedIntegrationId: INTEGRATION_ID, + repositoryUrl: 'https://bitbucket.org/acme/repo.git', + workspaceUuid: WORKSPACE_UUID, + repositoryUuid: REPOSITORY_UUID, + }); + await expect( + prepareDirect(environment(broker), { + ...data, + identity: { ...data.identity, orgId: undefined }, + }) + ).rejects.toThrow('Invalid contained worktree credentials'); + expect(broker.getBitbucketToken).toHaveBeenCalledTimes(1); + expectNoCapabilities(broker); + }); + + it('shares only authorized memberships and renews the bounded lease', async () => { + const { broker } = createBroker(); + const env = environment(broker); + const first = await prepareDirect(env); + const second = await prepareDirect( + env, + directMetadata({ identity: secondRoot().identity, auth: secondRoot().auth }), + first.grant, + NOW + HOUR + ); + expect(second.grant.members).toHaveLength(2); + expect(second.grant.expiresAt).toBe(NOW + 5 * HOUR); + expect( + removeSessionCredentialMembership([second.grant], SECOND_SESSION_ID)[0]?.members + ).toEqual(first.grant.members); + for (const changed of [ + directMetadata({ identity: { ...metadata().identity, userId: 'other-user' } }), + directMetadata({ identity: { ...metadata().identity, orgId: REPOSITORY_UUID } }), + directMetadata({ workspace: { workspacePath: '/workspace/other' } }), + directMetadata({ + workspace: { worktreeId: 'worktree_22222222-2222-4222-8222-222222222222' }, + }), + directMetadata({ auth: { kiloSessionId: SECOND_ROOT_ID, kilocodeToken: KILO_TOKEN } }), + directMetadata({ repository: { type: 'github', repo: 'acme/other' } }), + ]) { + await expect(prepareDirect(env, changed, first.grant)).rejects.toThrow( + 'Invalid contained worktree credentials' + ); + } + expect(broker.getCloudAgentAuthForRepo).toHaveBeenCalledTimes(2); + expectNoCapabilities(broker); + }); + + it('rejects contained/direct grant reuse in both directions', async () => { + const { broker } = createBroker(); + const env = environment(broker); + const direct = await prepareDirect(env); + const contained = await prepare(env); + await expect(prepareDirect(env, directMetadata(), contained.grant)).rejects.toThrow( + 'Invalid contained worktree credentials' + ); + await expect(prepare(env, metadata(), direct.grant)).rejects.toThrow( + 'Invalid contained worktree credentials' + ); + expect(broker.getCloudAgentAuthForRepo).toHaveBeenCalledTimes(1); + expect(broker.issueKiloSessionCapability).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, '', 'invalid\nvalue', 'kcp1.alias', 'kka1.capability'])( + 'rejects invalid direct Kilo token %s', + async kilocodeToken => { + const { broker } = createBroker(); + await expect( + prepareDirect( + environment(broker), + directMetadata({ auth: { kiloSessionId: ROOT_ID, kilocodeToken } }) + ) + ).rejects.toThrow('Invalid contained worktree credentials'); + expect(broker.getCloudAgentAuthForRepo).not.toHaveBeenCalled(); + expectNoCapabilities(broker); + } + ); + + it.each(['github', 'gitlab', 'bitbucket'] as const)( + 'fails closed on %s token errors and invalid token results', + async platform => { + const { broker } = createBroker(); + const data = directMetadata({ + repository: + platform === 'github' + ? metadata().repository + : platform === 'gitlab' + ? { type: 'gitlab', url: 'https://gitlab.example.com:8443/gitlab/acme/repo.git' } + : { + type: 'bitbucket', + url: 'https://bitbucket.org/acme/repo.git', + workspaceUuid: WORKSPACE_UUID, + repositoryUuid: REPOSITORY_UUID, + }, + }); + await expect(prepareDirect(environment(), data)).rejects.toThrow('credential is unavailable'); + if (platform === 'github') { + broker.getCloudAgentAuthForRepo.mockResolvedValue({ + success: false, + reason: 'integration_mismatch', + }); + } + if (platform === 'gitlab') { + broker.getGitLabToken.mockResolvedValue({ + success: false, + reason: 'no_matching_integration', + }); + } + if (platform === 'bitbucket') { + broker.getBitbucketToken.mockResolvedValue({ + success: false, + reason: 'integration_mismatch', + }); + } + await expect(prepareDirect(environment(broker), data)).rejects.toThrow( + 'credential is unavailable' + ); + expect(broker.getTokenForRepo).not.toHaveBeenCalled(); + if (platform === 'github') { + broker.getCloudAgentAuthForRepo.mockResolvedValue({ + success: true, + githubToken: 'kgh2.not-real', + installationId: '42', + accountLogin: 'acme', + appType: 'standard', + source: 'user', + gitAuthor: { name: 'bot', email: 'bot@example.com' }, + }); + } + if (platform === 'gitlab') { + broker.getGitLabToken.mockResolvedValue({ + success: true, + token: '', + instanceUrl: 'https://gitlab.example.com:8443/gitlab', + glabIsOAuth2: false, + }); + } + if (platform === 'bitbucket') { + broker.getBitbucketToken.mockResolvedValue({ success: true, token: 'bad\ntoken' }); + } + await expect(prepareDirect(environment(broker), data)).rejects.toThrow( + 'Invalid contained worktree credentials' + ); + expectNoCapabilities(broker); + expectSecretSafeLogs(); + } + ); + + it('rejects aliases and capabilities in persisted direct grants and missing aliases in contained grants', async () => { + const { broker } = createBroker(); + const direct = await prepareDirect(environment(broker)); + const contained = await prepare(environment(broker)); + for (const invalid of [ + { ...direct.grant, kilo: { ...direct.grant.kilo, alias: contained.grant.kilo.alias } }, + { + ...direct.grant, + kilo: { ...direct.grant.kilo, capabilities: contained.grant.kilo.capabilities }, + }, + { ...direct.grant, scm: { ...direct.grant.scm, alias: contained.grant.scm?.alias } }, + { + ...direct.grant, + scm: { ...direct.grant.scm, capability: contained.grant.scm?.capability }, + }, + { ...direct.grant, containmentEnabled: undefined }, + { ...direct.grant, expiresAt: NOW + 5 * HOUR }, + { ...contained.grant, kilo: { ...contained.grant.kilo, alias: undefined } }, + ]) { + expect(sessionCredentialGrantSchema.safeParse(invalid).success).toBe(false); + } + expect(isContainedSessionCredentialGrant(contained.grant)).toBe(true); + const explicitlyContained = { ...contained.grant, containmentEnabled: true }; + expect(sessionCredentialGrantSchema.parse(explicitlyContained)).toEqual(explicitlyContained); + expect(isContainedSessionCredentialGrant(explicitlyContained)).toBe(true); + expect( + sessionCredentialGrantSchema.safeParse({ ...direct.grant, containmentEnabled: true }).success + ).toBe(false); + }); +}); + +describe('direct worktree Kilo token stability', () => { + const secret = 'worktree-jwt-test-secret'; + const env = { ...environment(), NEXTAUTH_SECRET: secret }; + const token = (issuedAt = NOW, claims: Record = {}, signingSecret = secret) => { + const payload = { + env: 'production', + kiloUserId: 'user-a', + apiTokenPepper: 'test-pepper', + version: 3, + tokenSource: 'cloud-agent', + iat: issuedAt / 1000, + exp: (issuedAt + 24 * HOUR) / 1000, + ...claims, + }; + return jwt.sign( + Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== undefined)), + signingSecret, + { algorithm: 'HS256' } + ); + }; + const data = (kilocodeToken: string, sessionId = SESSION_ID, kiloSessionId = ROOT_ID) => + directMetadata({ + repository: undefined, + identity: { ...metadata().identity, sessionId }, + auth: { kilocodeToken, kiloSessionId }, + profile: { + envVars: { + KILO_AUTH_CONTENT: JSON.stringify({ kilo: { type: 'api', key: kilocodeToken } }), + }, + setupCommands: [`authenticate --token=${kilocodeToken}`], + }, + }); + + it('keeps identical runtime credentials across three siblings and alternating reattaches', async () => { + const siblings = [ + data(token()), + data(token(NOW + 1000), SECOND_SESSION_ID, SECOND_ROOT_ID), + data(token(NOW + 2000), THIRD_SESSION_ID, THIRD_ROOT_ID), + ]; + expect(new Set(siblings.map(sibling => sibling.auth.kilocodeToken)).size).toBe(3); + const first = await prepareDirect(env, siblings[0]); + let current = first; + for (const sibling of [...siblings, ...siblings.toReversed(), ...siblings]) { + current = await prepareDirect(env, sibling, current.grant, NOW + 3000); + expect(current.grant.kilo.token).toBe(first.grant.kilo.token); + expect(current.payload.kilo).toEqual(first.payload.kilo); + expect(current.payload.env).toEqual(first.payload.env); + expect(current.payload.setupCommands).toEqual(first.payload.setupCommands); + } + expect(current.grant.members).toHaveLength(3); + expect(first.grant.members).toHaveLength(1); + expect(siblings[1].auth.kilocodeToken).not.toBe(first.grant.kilo.token); + }); + + it.each([60_000, 120_000])('refreshes when the retained JWT expires in %i ms', async lifetime => { + const original = token(NOW, { exp: (NOW + lifetime) / 1000 }); + const fresh = token(NOW + 1000); + const first = await prepareDirect(env, data(original)); + const refreshed = await prepareDirect( + env, + data(fresh, SECOND_SESSION_ID, SECOND_ROOT_ID), + first.grant, + NOW + 60_000 + ); + expect(refreshed.payload.kilo.token).toBe(fresh); + expect(refreshed.payload.env?.KILOCODE_TOKEN).toBe(fresh); + }); + + it.each([false, true])( + 'does not slide the selection window when renewing a lease (legacy grant: %s)', + async legacy => { + const original = token(); + const fresh = token(NOW + 1000); + const first = await prepareDirect(env, data(original)); + if (legacy) delete first.grant.kilo.tokenSelectedAt; + const sibling = data(fresh, SECOND_SESSION_ID, SECOND_ROOT_ID); + const renewed = await prepareDirect(env, sibling, first.grant, NOW + 3 * HOUR); + expect(renewed.grant.kilo.token).toBe(original); + expect(renewed.grant.kilo.tokenSelectedAt).toBe(NOW); + expect(renewed.grant.expiresAt).toBe(NOW + 7 * HOUR); + const persisted = sessionCredentialGrantSchema.parse( + JSON.parse(JSON.stringify(renewed.grant)) + ); + const refreshed = await prepareDirect(env, sibling, persisted, NOW + 4 * HOUR); + expect(refreshed.grant.kilo.token).toBe(fresh); + expect(refreshed.grant.kilo.tokenSelectedAt).toBe(NOW + 4 * HOUR); + const reattached = await prepareDirect( + env, + data(original), + refreshed.grant, + NOW + 4 * HOUR + 1 + ); + expect(reattached.grant.kilo.token).toBe(fresh); + } + ); + + it('refreshes an expired grant even when its JWT remains valid', async () => { + const first = await prepareDirect(env, data(token())); + const fresh = token(NOW + 1000); + const refreshed = await prepareDirect(env, data(fresh), first.grant, first.grant.expiresAt); + expect(refreshed.payload.kilo.token).toBe(fresh); + }); + + it.each([ + ['pepper', { apiTokenPepper: 'rotated-pepper' }], + ['nullable pepper', { apiTokenPepper: null }], + ['user', { kiloUserId: 'other-user' }], + ['environment', { env: 'development' }], + ['version', { version: 4 }], + ['source', { tokenSource: 'other-source' }], + ['organization', { organizationId: INTEGRATION_ID }], + ['role', { organizationRole: 'member' }], + ['bot', { botId: 'bot-a' }], + ['audience', { aud: 'internal-service' }], + ['unknown authorization', { futurePermission: 'restricted' }], + ])('does not substitute the retained token after a change to %s', async (_name, claims) => { + const first = await prepareDirect(env, data(token())); + const changed = token(NOW + 1000, claims); + const refreshed = await prepareDirect(env, data(changed), first.grant, NOW + 2000); + expect(refreshed.payload.kilo.token).toBe(changed); + expect(refreshed.payload.env?.KILOCODE_TOKEN).toBe(changed); + }); + + it.each([ + ['expired', () => token(NOW - HOUR, { exp: NOW / 1000 })], + ['future-issued', () => token(NOW + HOUR)], + ['not yet valid', () => token(NOW, { nbf: (NOW + HOUR) / 1000 })], + ['missing expiry', () => token(NOW, { exp: undefined })], + ['invalid signature', () => token(NOW, {}, 'different-test-secret')], + ['opaque', () => 'opaque-replacement-token'], + ])( + 'does not mask %s incoming credentials with a retained valid JWT', + async (_name, changedToken) => { + const first = await prepareDirect(env, data(token())); + const changed = changedToken(); + const refreshed = await prepareDirect(env, data(changed), first.grant, NOW + 2000); + expect(refreshed.payload.kilo.token).toBe(changed); + } + ); + + it.each([ + ['invalid signature', () => token(NOW, {}, 'different-test-secret')], + ['future-issued', () => token(NOW + HOUR)], + ['unknown claims', () => token(NOW, { futurePermission: 'restricted' })], + ['opaque', () => 'opaque-original-token'], + ])('does not retain %s credentials', async (_name, originalToken) => { + const first = await prepareDirect(env, data(originalToken())); + const fresh = token(NOW + 1000); + const refreshed = await prepareDirect(env, data(fresh), first.grant, NOW + 2000); + expect(refreshed.payload.kilo.token).toBe(fresh); + }); + + it('does not coalesce equivalent claims for a different metadata owner', async () => { + const first = await prepareDirect(env, data(token(NOW, { kiloUserId: 'other-user' }))); + const fresh = token(NOW + 1000, { kiloUserId: 'other-user' }); + const refreshed = await prepareDirect(env, data(fresh), first.grant, NOW + 2000); + expect(refreshed.payload.kilo.token).toBe(fresh); + }); + + it('still checks managed SCM authorization when retaining an equivalent Kilo token', async () => { + const { broker } = createBroker(); + const brokerEnv = { ...environment(broker), NEXTAUTH_SECRET: secret }; + const first = await prepareDirect(brokerEnv, { + ...data(token()), + repository: metadata().repository, + }); + broker.getCloudAgentAuthForRepo.mockResolvedValue({ + success: false, + reason: 'integration_mismatch', + }); + await expect( + prepareDirect( + brokerEnv, + { ...data(token(NOW + 1000)), repository: metadata().repository }, + first.grant, + NOW + 2000 + ) + ).rejects.toThrow('GitHub credential is unavailable'); + }); + + it('leaves contained backing-token rotation unchanged for equivalent JWTs', async () => { + const { broker } = createBroker(); + const brokerEnv = { ...environment(broker), NEXTAUTH_SECRET: secret }; + const first = await prepare(brokerEnv, metadata({ auth: data(token()).auth })); + const fresh = token(NOW + 1000); + const refreshed = await prepare( + brokerEnv, + metadata({ auth: data(fresh).auth }), + first.grant, + NOW + 2000 + ); + expect(refreshed.grant.kilo.token).toBe(fresh); + expect(refreshed.grant.kilo.tokenSelectedAt).toBeUndefined(); + expect(refreshed.grant.kilo.capabilities[SESSION_ID]).not.toEqual( + first.grant.kilo.capabilities[SESSION_ID] + ); + expect(refreshed.payload.kilo).toEqual(first.payload.kilo); + }); + + it('keeps the incoming token when the signing secret is unavailable', async () => { + const first = await prepareDirect(env, data(token())); + const fresh = token(NOW + 1000); + for (const unavailable of [ + environment(), + { + ...environment(), + NEXTAUTH_SECRET: { + get: async () => { + throw new Error('unavailable'); + }, + }, + }, + ]) { + const refreshed = await prepareDirect(unavailable, data(fresh), first.grant, NOW + 2000); + expect(refreshed.payload.kilo.token).toBe(fresh); + } + }); +}); + describe('credential failure safety', () => { beforeEach(() => vi.clearAllMocks()); diff --git a/services/cloud-agent-next/src/sandbox-control/session-credentials.ts b/services/cloud-agent-next/src/sandbox-control/session-credentials.ts index 992e27931b..90e547bbcd 100644 --- a/services/cloud-agent-next/src/sandbox-control/session-credentials.ts +++ b/services/cloud-agent-next/src/sandbox-control/session-credentials.ts @@ -1,8 +1,15 @@ import { containedKiloSessionIdSchema } from '@kilocode/session-ingest-contracts'; +import { kiloTokenPayload } from '@kilocode/worker-utils/kilo-token'; +import jwt from 'jsonwebtoken'; import { z } from 'zod'; +import { resolveSecret } from '../auth.js'; import type { VercelSandboxNetworkPolicy } from '../agent-sandbox/vercel/vercel-sandbox-rest-client.js'; import { deriveKiloSandboxTargets, type KiloTargetEnv } from '../kilo/kilo-targets.js'; -import { getSandboxProvider, type SessionMetadata } from '../persistence/session-metadata.js'; +import { + getSandboxProvider, + requiresContainmentSandbox, + type SessionMetadata, +} from '../persistence/session-metadata.js'; import { type getOutboundContainerId, isValidSandboxId } from '../sandbox-id.js'; import { buildSessionAttachPayload } from '../sandbox-session/attach-payload.js'; import { @@ -11,6 +18,9 @@ import { issueCloudAgentGitLabSessionCapability, issueCloudAgentKiloSessionCapability, resolveGitHubTokenForRepo, + resolveCloudAgentGitHubAuthForRepo, + resolveManagedGitLabToken, + resolveManagedBitbucketToken, } from '../services/git-token-service-client.js'; import { readProfileBundle } from '../session-profile.js'; import type { SessionAttachPayload } from '../shared/sandbox-control-protocol.js'; @@ -25,6 +35,16 @@ import { const WORKTREE_LEASE_MS = 4 * 60 * 60 * 1000; const CAPABILITY_CACHE_MS = 3 * 60 * 60 * 1000; const timestampSchema = z.number().int().nonnegative(); +const KILO_TOKEN_REFRESH_MARGIN_MS = 60 * 1000; +const cloudAgentTokenSchema = kiloTokenPayload + .pick({ version: true, kiloUserId: true, env: true }) + .extend({ + apiTokenPepper: z.string().nullable(), + tokenSource: z.literal('cloud-agent'), + iat: timestampSchema, + exp: timestampSchema, + }) + .strict(); const tokenSchema = z .string() .min(1) @@ -98,7 +118,7 @@ const repositorySchema = z.discriminatedUnion('type', [ const scmSchema = z .object({ purpose: z.enum(['github', 'gitlab', 'bitbucket']), - alias: tokenSchema, + alias: tokenSchema.optional(), gitUrl: z.string().url(), capability: capabilitySchema.optional(), nativeToken: realTokenSchema.optional(), @@ -106,12 +126,13 @@ const scmSchema = z .object({ instanceOrigin: z.string().url(), instanceHost: z.string().min(1), - projectPath: z.string().min(1), - integrationId: z.string().min(1), - authType: z.enum(['oauth', 'pat']), + projectPath: z.string().min(1).optional(), + integrationId: z.string().min(1).optional(), + authType: z.enum(['oauth', 'pat']).optional(), identity: z .object({ accountId: z.string().nullable(), accountLogin: z.string().nullable() }) - .strict(), + .strict() + .optional(), glabIsOAuth2: z.boolean(), }) .strict() @@ -122,6 +143,7 @@ const scmSchema = z export const sessionCredentialGrantSchema = z .object({ version: z.literal(1), + containmentEnabled: z.boolean().optional(), scopeId: scopeIdSchema, sandboxId: z .string() @@ -149,8 +171,9 @@ export const sessionCredentialGrantSchema = z repository: repositorySchema.optional(), kilo: z .object({ - alias: tokenSchema, + alias: tokenSchema.optional(), token: realTokenSchema, + tokenSelectedAt: timestampSchema.optional(), targets: targetsSchema, capabilities: z.record(z.string(), capabilitySchema), }) @@ -165,14 +188,24 @@ export const sessionCredentialGrantSchema = z if ( grant.expiresAt <= grant.preparedAt || grant.expiresAt - grant.preparedAt > WORKTREE_LEASE_MS || + (grant.kilo.tokenSelectedAt !== undefined && grant.kilo.tokenSelectedAt > grant.preparedAt) || new Set(grant.members.map(member => member.sessionId)).size !== grant.members.length || new Set(grant.members.map(member => member.kiloSessionId)).size !== grant.members.length || - (grant.provider === 'cloudflare' && !grant.outboundContainerId) + (grant.containmentEnabled !== false && + grant.provider === 'cloudflare' && + !grant.outboundContainerId) ) { reject(); } - const kiloAlias = parseControlPlaneCredential(grant.kilo.alias); - if (kiloAlias?.sandboxId !== grant.sandboxId || kiloAlias.purpose !== 'kilo') reject(); + if (grant.containmentEnabled === false) { + if (grant.kilo.alias !== undefined || Object.keys(grant.kilo.capabilities).length > 0) + reject(); + } else { + const kiloAlias = grant.kilo.alias && parseControlPlaneCredential(grant.kilo.alias); + if (!kiloAlias || kiloAlias.sandboxId !== grant.sandboxId || kiloAlias.purpose !== 'kilo') { + reject(); + } + } const targets = deriveKiloSandboxTargets( { KILOCODE_BACKEND_BASE_URL: grant.kilo.targets.backendBaseUrl, @@ -203,8 +236,21 @@ export const sessionCredentialGrantSchema = z reject(); } if (!grant.scm) return; - const alias = parseControlPlaneCredential(grant.scm.alias); - if (alias?.sandboxId !== grant.sandboxId || alias.purpose !== grant.scm.purpose) reject(); + if (grant.containmentEnabled === false) { + if ( + grant.scm.alias !== undefined || + grant.scm.capability !== undefined || + !grant.scm.nativeToken || + (grant.scm.purpose === 'gitlab') !== (grant.scm.gitlab !== undefined) + ) { + reject(); + } + return; + } + const alias = grant.scm.alias && parseControlPlaneCredential(grant.scm.alias); + if (!alias || alias.sandboxId !== grant.sandboxId || alias.purpose !== grant.scm.purpose) { + reject(); + } if (grant.provider === 'vercel') { if ( grant.scm.purpose !== 'github' || @@ -219,7 +265,12 @@ export const sessionCredentialGrantSchema = z if ( grant.scm.nativeToken || !grant.scm.capability?.credential.startsWith(prefixes[grant.scm.purpose]) || - (grant.scm.purpose === 'gitlab') !== (grant.scm.gitlab !== undefined) + (grant.scm.purpose === 'gitlab') !== (grant.scm.gitlab !== undefined) || + (grant.scm.gitlab !== undefined && + (!grant.scm.gitlab.projectPath || + !grant.scm.gitlab.integrationId || + !grant.scm.gitlab.authType || + !grant.scm.gitlab.identity)) ) { reject(); } @@ -231,8 +282,19 @@ type CredentialMember = z.infer; type CredentialRepository = z.infer; type ScmGrant = z.infer; type CachedCapability = z.infer; +type ContainedSessionCredentialGrant = SessionCredentialGrant & { + kilo: SessionCredentialGrant['kilo'] & { alias: string }; + scm?: ScmGrant & { alias: string }; +}; + +export function isContainedSessionCredentialGrant( + grant: SessionCredentialGrant +): grant is ContainedSessionCredentialGrant { + return grant.containmentEnabled !== false; +} + type CredentialEnv = Parameters[0] & - Partial> & + Partial> & KiloTargetEnv; type PreparedSessionAttachPayload = SessionAttachPayload & { @@ -311,7 +373,9 @@ function repositoryFromMetadata( } const explicit = Boolean(repository.token) && !isKnownScmCredential(repository.token, existing?.scm); - if (provider === 'cloudflare' && explicit) invalidCredentials(); + if (requiresContainmentSandbox(metadata) && provider === 'cloudflare' && explicit) { + invalidCredentials(); + } return { type: 'github', repo: repository.repo, @@ -335,7 +399,7 @@ function repositoryFromMetadata( if (repository.token) invalidCredentials(); return { type: 'git', url, ...(repository.platform ? { platform: repository.platform } : {}) }; } - if (provider === 'vercel') invalidCredentials(); + if (requiresContainmentSandbox(metadata) && provider === 'vercel') invalidCredentials(); if (repository.type === 'gitlab') { if ( repository.token && @@ -505,18 +569,63 @@ async function refreshScmCapability( return { ...grant, scm }; } -function sanitizedPayload( +async function selectDirectKiloToken( + env: CredentialEnv, + token: string, + existing: SessionCredentialGrant | undefined, + now: number +): Promise { + if ( + !existing || + existing.kilo.token === token || + existing.expiresAt <= now || + now >= (existing.kilo.tokenSelectedAt ?? existing.preparedAt) + WORKTREE_LEASE_MS + ) { + return token; + } + const secret = await resolveSecret(env.NEXTAUTH_SECRET); + if (!secret) return token; + try { + const options: jwt.VerifyOptions = { algorithms: ['HS256'], clockTimestamp: now / 1000 }; + const current = cloudAgentTokenSchema.parse(jwt.verify(existing.kilo.token, secret, options)); + const incoming = cloudAgentTokenSchema.parse(jwt.verify(token, secret, options)); + if ( + current.kiloUserId !== existing.userId || + current.iat * 1000 > now || + incoming.iat * 1000 > now || + current.exp <= current.iat || + incoming.exp <= incoming.iat || + current.exp * 1000 <= now + KILO_TOKEN_REFRESH_MARGIN_MS + ) { + return token; + } + const authorization = cloudAgentTokenSchema.omit({ iat: true, exp: true }).strip(); + return JSON.stringify(authorization.parse(current)) === + JSON.stringify(authorization.parse(incoming)) + ? existing.kilo.token + : token; + } catch { + return token; + } +} + +function preparedPayload( metadata: SessionMetadata, payload: SessionAttachPayload, grant: SessionCredentialGrant, existing: SessionCredentialGrant | undefined ): PreparedSessionAttachPayload { - const replacements: Array<[string, string]> = [[grant.kilo.token, grant.kilo.alias]]; - if (existing) replacements.push([existing.kilo.token, grant.kilo.alias]); + const contained = isContainedSessionCredentialGrant(grant); + const kiloToken = contained ? grant.kilo.alias : grant.kilo.token; + const scmToken = contained ? grant.scm?.alias : grant.scm?.nativeToken; + const replacements: Array<[string, string]> = [[grant.kilo.token, kiloToken]]; + if (metadata.auth.kilocodeToken) replacements.push([metadata.auth.kilocodeToken, kiloToken]); + if (existing) replacements.push([existing.kilo.token, kiloToken]); for (const capability of Object.values(grant.kilo.capabilities)) { - replacements.push([capability.credential, grant.kilo.alias]); + replacements.push([capability.credential, kiloToken]); } if (grant.scm) { + if (!scmToken) invalidCredentials(); const repositoryToken = metadata.repository && 'token' in metadata.repository ? metadata.repository.token : undefined; for (const token of [ @@ -526,7 +635,7 @@ function sanitizedPayload( existing?.scm?.nativeToken, existing?.scm?.capability?.credential, ]) { - if (token) replacements.push([token, grant.scm.alias]); + if (token) replacements.push([token, scmToken]); } } const sanitize = (value: string) => @@ -540,24 +649,28 @@ function sanitizedPayload( : sanitize(value), ]) ); - env.KILOCODE_TOKEN = grant.kilo.alias; - if (grant.scm?.purpose === 'github') { + env.KILOCODE_TOKEN = kiloToken; + if (!contained) { + delete env.KILOCODE_ORGANIZATION_ID; + if (grant.orgId) env.KILOCODE_ORGANIZATION_ID = grant.orgId; + } + if (grant.scm?.purpose === 'github' && scmToken) { if (env.GITHUB_TOKEN && env.GH_TOKEN === undefined) env.GH_TOKEN = env.GITHUB_TOKEN; - env.GH_TOKEN ??= grant.scm.alias; - } else if (grant.scm?.purpose === 'gitlab') { + env.GH_TOKEN ??= scmToken; + } else if (grant.scm?.purpose === 'gitlab' && scmToken) { const gitlab = grant.scm.gitlab; if (!gitlab) invalidCredentials(); - env.GITLAB_TOKEN ??= grant.scm.alias; - if (env.GITLAB_TOKEN === grant.scm.alias) { + env.GITLAB_TOKEN ??= scmToken; + if (env.GITLAB_TOKEN === scmToken) { env.GLAB_IS_OAUTH2 = gitlab.glabIsOAuth2 ? 'true' : 'false'; env.GITLAB_HOST = gitlab.instanceHost; env.GITLAB_SUBFOLDER = new URL(gitlab.instanceOrigin).pathname.replace(/^\/+|\/+$/g, ''); } - } else if (grant.scm?.purpose === 'bitbucket') { + } else if (grant.scm?.purpose === 'bitbucket' && scmToken) { const repository = grant.repository; const canonical = parseCanonicalBitbucketCloneUrl(grant.scm.gitUrl); if (repository?.type !== 'bitbucket' || !canonical) invalidCredentials(); - env.BITBUCKET_TOKEN = grant.scm.alias; + env.BITBUCKET_TOKEN = scmToken; env.KILO_BITBUCKET_WORKSPACE_SLUG = canonical.workspaceSlug; env.KILO_BITBUCKET_REPOSITORY_SLUG = canonical.repositorySlug; env.KILO_BITBUCKET_WORKSPACE_UUID = `{${repository.workspaceUuid}}`; @@ -568,7 +681,7 @@ function sanitizedPayload( directory: grant.directory, env, ...(grant.scm - ? { git: { url: grant.scm.gitUrl, platform: grant.scm.purpose, token: grant.scm.alias } } + ? { git: { url: grant.scm.gitUrl, platform: grant.scm.purpose, token: scmToken } } : grant.repository?.type === 'git' ? { git: { @@ -578,7 +691,91 @@ function sanitizedPayload( } : {}), ...(payload.setupCommands ? { setupCommands: payload.setupCommands.map(sanitize) } : {}), - kilo: { scopeId: grant.scopeId, token: grant.kilo.alias, targets: grant.kilo.targets }, + kilo: { + scopeId: grant.scopeId, + token: kiloToken, + targets: grant.kilo.targets, + ...(!contained ? { containmentEnabled: false } : {}), + ...(!contained && grant.orgId ? { organizationId: grant.orgId } : {}), + }, + }; +} + +async function resolveDirectScmCredentials( + env: CredentialEnv, + grant: SessionCredentialGrant, + payload: SessionAttachPayload +): Promise { + const repository = grant.repository; + if (!repository || repository.type === 'git') return grant; + const common = { userId: grant.userId, ...(grant.orgId ? { orgId: grant.orgId } : {}) }; + if (repository.type === 'github') { + let nativeToken = payload.git?.token; + if (repository.authentication === 'managed') { + const resolved = await resolveCloudAgentGitHubAuthForRepo(env, { + ...common, + githubRepo: repository.repo, + allowUserAuthorization: repository.allowUserAuthorization, + ...(repository.expectedIntegrationId + ? { expectedIntegrationId: repository.expectedIntegrationId } + : {}), + }); + if (!resolved.success) throw new Error('GitHub credential is unavailable'); + nativeToken = resolved.value.githubToken; + } + if (!nativeToken) invalidCredentials(); + return { + ...grant, + scm: { purpose: 'github', gitUrl: `https://github.com/${repository.repo}.git`, nativeToken }, + }; + } + if (repository.type === 'gitlab') { + const resolved = await resolveManagedGitLabToken(env, { + ...common, + repositoryUrl: repository.url, + ...(repository.createdOnPlatform ? { createdOnPlatform: repository.createdOnPlatform } : {}), + }); + if (!resolved.success) throw new Error('GitLab credential is unavailable'); + const instance = safeUrl(resolved.instanceUrl); + const repositoryUrl = new URL(repository.url); + if ( + !instance || + instance.protocol !== 'https:' || + instance.search || + instance.origin !== repositoryUrl.origin || + !repositoryUrl.pathname.startsWith(`${instance.pathname.replace(/\/+$/, '')}/`) + ) { + invalidCredentials(); + } + return { + ...grant, + scm: { + purpose: 'gitlab', + gitUrl: repository.url, + nativeToken: resolved.token, + gitlab: { + instanceOrigin: instance.toString().replace(/\/+$/, ''), + instanceHost: instance.host, + glabIsOAuth2: resolved.glabIsOAuth2, + }, + }, + }; + } + if (!grant.orgId) invalidCredentials(); + const resolved = await resolveManagedBitbucketToken(env, { + ...common, + orgId: grant.orgId, + repositoryUrl: repository.url, + workspaceUuid: repository.workspaceUuid, + repositoryUuid: repository.repositoryUuid, + ...(repository.expectedIntegrationId + ? { expectedIntegrationId: repository.expectedIntegrationId } + : {}), + }); + if (!resolved.success) throw new Error('Bitbucket credential is unavailable'); + return { + ...grant, + scm: { purpose: 'bitbucket', gitUrl: repository.url, nativeToken: resolved.token }, }; } @@ -608,6 +805,7 @@ export async function prepareSessionCredentials(input: { const token = realTokenSchema.safeParse(metadata.auth.kilocodeToken); if (!member.success || !token.success) invalidCredentials(); const provider = getSandboxProvider(metadata); + const containmentEnabled = requiresContainmentSandbox(metadata); const targets = deriveKiloSandboxTargets(env, token.data, { requireHttps: provider === 'vercel', }); @@ -621,7 +819,8 @@ export async function prepareSessionCredentials(input: { const repository = repositoryFromMetadata(metadata, provider, existing); if ( existing && - (existing.scopeId !== scopeId.data || + (isContainedSessionCredentialGrant(existing) !== containmentEnabled || + existing.scopeId !== scopeId.data || existing.directory !== payload.directory || existing.sandboxId !== sandboxId || existing.userId !== metadata.identity.userId || @@ -646,8 +845,12 @@ export async function prepareSessionCredentials(input: { ) { invalidCredentials(); } + const kiloToken = containmentEnabled + ? token.data + : await selectDirectKiloToken(env, token.data, existing, now); let grant: SessionCredentialGrant = { version: 1, + ...(!containmentEnabled ? { containmentEnabled: false as const } : {}), scopeId: scopeId.data, directory: payload.directory, sandboxId, @@ -660,16 +863,28 @@ export async function prepareSessionCredentials(input: { : [...members, member.data], ...(repository ? { repository } : {}), kilo: { - alias: existing?.kilo.alias ?? createControlPlaneCredential(sandboxId, 'kilo'), - token: token.data, + ...(containmentEnabled + ? { alias: existing?.kilo.alias ?? createControlPlaneCredential(sandboxId, 'kilo') } + : {}), + token: kiloToken, + ...(!containmentEnabled + ? { + tokenSelectedAt: + existing?.kilo.token === kiloToken + ? (existing.kilo.tokenSelectedAt ?? existing.preparedAt) + : now, + } + : {}), targets: targets.targets, - capabilities: existing?.kilo.token === token.data ? existing.kilo.capabilities : {}, + capabilities: existing?.kilo.token === kiloToken ? existing.kilo.capabilities : {}, }, ...(existing?.scm ? { scm: existing.scm } : {}), preparedAt: now, expiresAt: now + WORKTREE_LEASE_MS, }; - if (provider === 'cloudflare') { + if (!containmentEnabled) { + grant = await resolveDirectScmCredentials(env, grant, payload); + } else if (provider === 'cloudflare') { const outboundContainerId = input.outboundContainerId; if (!outboundContainerId) invalidCredentials(); grant = await refreshKiloCapability(env, grant, member.data, outboundContainerId, now); @@ -700,6 +915,9 @@ export async function prepareSessionCredentials(input: { }; } grant = validateGrant(grant); + if (!isContainedSessionCredentialGrant(grant)) { + return { grant, payload: preparedPayload(metadata, payload, grant, existing) }; + } if (provider === 'vercel') buildControlNetworkPolicy([grant]); else { buildKiloCredentialInjectionRules( @@ -713,7 +931,7 @@ export async function prepareSessionCredentials(input: { { requireHttps: false } ); } - return { grant, payload: sanitizedPayload(metadata, payload, grant, existing) }; + return { grant, payload: preparedPayload(metadata, payload, grant, existing) }; } export function removeSessionCredentialMembership( @@ -744,8 +962,13 @@ export function buildControlNetworkPolicy( ): VercelSandboxNetworkPolicy { const policies = grants.map(value => { const grant = validateGrant(value); - if (grant.provider !== 'vercel' || grant.sandboxId !== grants[0]?.sandboxId) + if ( + !isContainedSessionCredentialGrant(grant) || + grant.provider !== 'vercel' || + grant.sandboxId !== grants[0]?.sandboxId + ) { invalidCredentials(); + } return buildVercelCredentialNetworkPolicy({ kilo: { token: grant.kilo.token, @@ -793,6 +1016,7 @@ export async function resolveSessionCredential(input: { let grant = parsed.data; const alias = parseControlPlaneCredential(input.credential); if ( + !isContainedSessionCredentialGrant(grant) || grant.provider !== 'cloudflare' || now < grant.preparedAt || now >= grant.expiresAt || diff --git a/services/cloud-agent-next/src/sandbox-control/terminal-billing.test.ts b/services/cloud-agent-next/src/sandbox-control/terminal-billing.test.ts index 32e3e3d433..cb6d0bee4e 100644 --- a/services/cloud-agent-next/src/sandbox-control/terminal-billing.test.ts +++ b/services/cloud-agent-next/src/sandbox-control/terminal-billing.test.ts @@ -42,6 +42,7 @@ function billingInput( access?: SandboxTerminalAccessInput; sandboxId?: string; providerInstanceId?: string; + containment?: boolean; sandboxDurableObjectId?: string; sandboxClassName?: SandboxClassName; running?: boolean; @@ -57,7 +58,7 @@ function billingInput( options.providerInstanceId ?? encodeCloudflareProviderRef({ sandboxId, - containment: true, + containment: options.containment ?? true, instanceId: PROVIDER_CREATION_ID, }), sandboxDurableObjectId: options.sandboxDurableObjectId ?? 'durable-object-small', @@ -118,6 +119,59 @@ describe('validateTerminalBillingRuntime', () => { } ); + it.each([ + { + sandboxId: 'ses-abcdef', + sandboxClassName: 'SandboxSmall' as const, + service: 'cloud-agent-next-sandbox-small', + }, + { + sandboxId: 'crv-abcdef', + sandboxClassName: 'SandboxCodeReview' as const, + service: 'cloud-agent-next-sandbox-code-review', + }, + { + sandboxId: 'istd-abcdef', + sandboxClassName: 'Sandbox' as const, + service: 'cloud-agent-next-sandbox', + }, + { + sandboxId: 'org-abcdef', + sandboxClassName: 'Sandbox' as const, + service: 'cloud-agent-next-sandbox', + }, + ])('accepts uncontained $sandboxClassName only with its matching fenced reference', input => { + const uncontained = billingInput({ + sandboxId: input.sandboxId, + sandboxClassName: input.sandboxClassName, + containment: false, + context: context({ + service: input.service, + instanceId: input.sandboxId, + sku: SANDBOX_USAGE_SKUS[input.sandboxClassName], + ...(input.sandboxId.startsWith('org-') ? { sessionId: undefined } : {}), + metadata: { + container_class: input.sandboxClassName, + durable_object_id: 'durable-object-small', + }, + }), + }); + expect(validateTerminalBillingRuntime(uncontained)).toEqual({ allowed: true }); + expect( + validateTerminalBillingRuntime({ + ...uncontained, + providerInstanceId: encodeCloudflareProviderRef({ + sandboxId: input.sandboxId, + containment: true, + instanceId: PROVIDER_CREATION_ID, + }), + }) + ).toEqual({ allowed: false, reason: 'billing_runtime_mismatch' }); + expect( + validateTerminalBillingRuntime({ ...uncontained, sandboxDurableObjectId: 'other-namespace' }) + ).toEqual({ allowed: false, reason: 'billing_runtime_mismatch' }); + }); + it('accepts an organization bot acting for the matching payer', () => { const organizationAccess = { ...access, organizationId: 'org_team', botId: 'bot_worker' }; expect( diff --git a/services/cloud-agent-next/src/sandbox-control/terminal-billing.ts b/services/cloud-agent-next/src/sandbox-control/terminal-billing.ts index 847cc5afe0..68e5756d5b 100644 --- a/services/cloud-agent-next/src/sandbox-control/terminal-billing.ts +++ b/services/cloud-agent-next/src/sandbox-control/terminal-billing.ts @@ -32,16 +32,19 @@ type TerminalBillingRuntimeInput = { runtime: SandboxBillingRuntimeStatus | undefined; }; -function expectedSandboxClassName(sandboxId: string): SandboxClassName | undefined { +function expectedSandboxClassName( + sandboxId: string, + containment: boolean +): SandboxClassName | undefined { switch (classifySandboxId(sandboxId)) { case 'isolated-small': - return 'SandboxSmallContainment'; + return containment ? 'SandboxSmallContainment' : 'SandboxSmall'; case 'code-review': - return 'SandboxCodeReviewContainment'; + return containment ? 'SandboxCodeReviewContainment' : 'SandboxCodeReview'; case 'isolated-standard': case 'shared': case 'legacy-shared': - return 'SandboxContainment'; + return containment ? 'SandboxContainment' : 'Sandbox'; default: return undefined; } @@ -72,11 +75,13 @@ export function validateTerminalBillingRuntime( return { allowed: false, reason: 'billing_generation_inactive' }; } - const sandboxClassName = expectedSandboxClassName(input.sandboxId); const providerRef = decodeCloudflareProviderRef(input.providerInstanceId); + if (providerRef?.sandboxId !== input.sandboxId) { + return { allowed: false, reason: 'billing_runtime_mismatch' }; + } + const sandboxClassName = expectedSandboxClassName(input.sandboxId, providerRef.containment); if ( sandboxClassName === undefined || - providerRef?.sandboxId !== input.sandboxId || runtime.sandboxClassName !== sandboxClassName || context.instanceId !== input.sandboxId || context.service !== expectedUsageService(sandboxClassName) || diff --git a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts index f870a73dd0..5ec62b297f 100644 --- a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts +++ b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts @@ -90,8 +90,10 @@ import { } from '../shared/sandbox-control-protocol.js'; import type { WrapperPty } from '../kilo/wrapper-client.js'; import { + ControlRequestError, controlDispatchDisposition, controlRequestResult, + deliveryErrorLogFields, isRetryableDeliveryError, observeControlAfterStopping, safeErrorFromQueueReason, @@ -556,7 +558,7 @@ export class SandboxSession extends DurableObject { const accepted = active.find(message => message.state === 'accepted'); const preparing = accepted ? undefined : active[0]; const metadata = this.terminalLifecycle.getStoredMetadata(); - if (preparing?.wrapperInstanceId && metadata) { + if (preparing?.wrapperInstanceId && preparing.deliveryRetryScope !== 'message' && metadata) { this.retainRuntimeCleanup(metadata, preparing.wrapperInstanceId, 'preparation_interrupted'); } this.saveMessages( @@ -1294,18 +1296,70 @@ export class SandboxSession extends DurableObject { wrapperInstanceId = runtime.data; this.saveMessages( this.loadMessages().map(message => - message.messageId === messageId ? { ...message, wrapperInstanceId } : message + message.messageId === messageId + ? { + ...message, + wrapperInstanceId, + unresolvedDispatch: + message.wrapperInstanceId === wrapperInstanceId + ? message.unresolvedDispatch + : undefined, + } + : message ), epoch ); }; + const dispatch = async ( + kind: 'attach' | 'prompt', + operation: () => Promise + ): Promise => { + const unresolved = this.queuedMessage( + messageId, + epoch, + wrapperInstanceId + )?.unresolvedDispatch; + const unresolvedPrompt = + unresolved && this.terminalLifecycle.getAttachedWrapperInstanceId() === wrapperInstanceId; + const recordUnresolved = (unresolvedDispatch: true | undefined) => { + if (!isCurrent()) return; + this.saveMessages( + this.loadMessages().map(message => + message.messageId === messageId ? { ...message, unresolvedDispatch } : message + ), + epoch + ); + }; + recordUnresolved(true); + try { + const result = await operation(); + if (kind === 'attach' && !unresolvedPrompt) recordUnresolved(undefined); + return result; + } catch (error) { + if (error instanceof ControlRequestError && !unresolved) recordUnresolved(undefined); + throw error; + } + }; await this.armQueueRetry(Math.min(deadlineAt, Date.now() + QUEUE_RETRY_MS)); if (!isCurrent()) return; if (Date.now() >= deadlineAt) { - await this.failDelivery(messageId, 'preparation_timeout', wrapperInstanceId); + await this.failDelivery( + messageId, + 'preparation_timeout', + wrapperInstanceId, + queued.deliveryRetryScope + ); return; } if (this.pendingRuntimeCleanup()) return; + if (queued.deliveryRetryScope !== undefined) { + this.saveMessages( + this.loadMessages().map(message => + message.messageId === messageId ? { ...message, deliveryRetryScope: undefined } : message + ), + epoch + ); + } const intent = queued.intent; const model = dispatchedKilocodeModelId(intent?.agent.model); const control = sandboxControlRpc(this.env, sandboxId); @@ -1424,7 +1478,10 @@ export class SandboxSession extends DurableObject { return; } if (!wrapperInstanceId) throw new Error('Wrapper identity is missing'); - if (this.terminalLifecycle.getAttachedWrapperInstanceId() !== wrapperInstanceId) { + if ( + this.terminalLifecycle.getAttachedWrapperInstanceId() !== wrapperInstanceId || + status.attachment?.kilo?.containmentEnabled === false + ) { recorder.onProgress('workspace_setup', 'Setting up workspace…'); if (!status.attachment?.kilo) throw new Error('Contained session attachment is unavailable'); @@ -1449,25 +1506,28 @@ export class SandboxSession extends DurableObject { await this.compensateSessionAttachment(metadata); return; } - const attached = await wait( - async () => - controlRequestResult( - await control.request({ - operation: 'session.attach', - session, - expectedWrapperInstanceId: wrapperInstanceId, - payload: attachPayload, - timeoutMs: SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, - }) - ), - SANDBOX_CONTROL_ATTACH_TIMEOUT_MS + await dispatch('attach', () => + wait( + async () => + sessionAttachResultSchema.parse( + controlRequestResult( + await control.request({ + operation: 'session.attach', + session, + expectedWrapperInstanceId: wrapperInstanceId, + payload: attachPayload, + timeoutMs: SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, + }) + ) + ), + SANDBOX_CONTROL_ATTACH_TIMEOUT_MS + ) ); if (!isCurrent()) { if (!this.terminalLifecycle.isCurrent(epoch)) await this.compensateSessionAttachment(metadata); return; } - sessionAttachResultSchema.parse(attached); } const attachedRuntime = await wait(() => control.getStatus()); if (!isCurrent()) return; @@ -1483,41 +1543,43 @@ export class SandboxSession extends DurableObject { const finalization = metadata.workspace?.worktreeId ? { ...intent.finalization, autoCommit: false } : intent.finalization; - const prompt = await wait(async () => - controlRequestResult( - await control.request({ - operation: 'session.prompt', - session, - expectedWrapperInstanceId: wrapperInstanceId, - payload: { - messageId, - turn: - intent.turn.type === 'command' - ? { - type: 'command', - command: intent.turn.command, - arguments: intent.turn.arguments, - } - : { type: 'prompt', prompt: intent.turn.prompt }, - agent: { - mode: intent.agent.mode, - ...(model !== undefined ? { model } : {}), - ...(intent.agent.variant !== undefined ? { variant: intent.agent.variant } : {}), + await dispatch('prompt', async () => { + const prompt = await wait(async () => + controlRequestResult( + await control.request({ + operation: 'session.prompt', + session, + expectedWrapperInstanceId: wrapperInstanceId, + payload: { + messageId, + turn: + intent.turn.type === 'command' + ? { + type: 'command', + command: intent.turn.command, + arguments: intent.turn.arguments, + } + : { type: 'prompt', prompt: intent.turn.prompt }, + agent: { + mode: intent.agent.mode, + ...(model !== undefined ? { model } : {}), + ...(intent.agent.variant !== undefined ? { variant: intent.agent.variant } : {}), + }, + ...(finalization ? { finalization } : {}), + ...(attachments.length ? { attachments } : {}), }, - ...(finalization ? { finalization } : {}), - ...(attachments.length ? { attachments } : {}), - }, - }) - ) - ); + }) + ) + ); + const result = sessionPromptResultSchema.parse(prompt); + if (result.messageId !== messageId) + throw new Error('Prompt response message identity mismatch'); + }); if (!isCurrent()) { if (!this.terminalLifecycle.isCurrent(epoch)) await this.compensateSessionAttachment(metadata); return; } - const result = sessionPromptResultSchema.parse(prompt); - if (result.messageId !== messageId) - throw new Error('Prompt response message identity mismatch'); const accepted = acceptQueuedMessage(this.loadMessages(), messageId, Date.now()); if (!accepted || !this.saveMessages(accepted, epoch)) return; await this.armQueueRetry(Date.now() + DEADLINE_MS.acceptedAlarmCap); @@ -1531,7 +1593,9 @@ export class SandboxSession extends DurableObject { } return; } - logger.withFields({ sessionId, messageId, phase }).warn('Control-plane dispatch failed'); + logger + .withFields({ sessionId, messageId, phase, ...deliveryErrorLogFields(error) }) + .warn('Control-plane dispatch failed'); await this.recordDeliveryFailure({ messageId, epoch, @@ -1579,16 +1643,24 @@ export class SandboxSession extends DurableObject { error: unknown; }): Promise { const { messageId, epoch, phase, wrapperInstanceId, deadlineAt, error } = input; - if (!this.queuedMessage(messageId, epoch, wrapperInstanceId)) return; + const message = this.queuedMessage(messageId, epoch, wrapperInstanceId); + if (!message) return; + const rejection = error instanceof ControlRequestError && error.code !== 'runtime_unhealthy'; + const scope = rejection && !message.unresolvedDispatch ? 'message' : 'runtime'; if (Date.now() >= deadlineAt) { - await this.failDelivery(messageId, 'preparation_timeout', wrapperInstanceId); + await this.failDelivery(messageId, 'preparation_timeout', wrapperInstanceId, scope); return; } + const busy = rejection && error.code === 'session_busy'; const updated = - phase === 'preparing' + phase === 'preparing' || busy ? undefined : incrementDeliveryFailure(this.loadMessages(), messageId, phase); - if (updated && !this.saveMessages(updated.messages, epoch)) return; + const messages = (updated?.messages ?? this.loadMessages()).map( + (message): MessageRecord => + message.messageId === messageId ? { ...message, deliveryRetryScope: scope } : message + ); + if (!this.saveMessages(messages, epoch)) return; if (isRetryableDeliveryError(error) && !updated?.exhausted) { await this.armQueueRetry(Math.min(deadlineAt, Date.now() + QUEUE_RETRY_MS)); return; @@ -1600,7 +1672,8 @@ export class SandboxSession extends DurableObject { : phase === 'attach' ? 'attach_exhausted' : 'environment_failed', - wrapperInstanceId + wrapperInstanceId, + scope ); } @@ -1664,7 +1737,8 @@ export class SandboxSession extends DurableObject { private async failDelivery( messageId: string, reason: string, - wrapperInstanceId?: string + wrapperInstanceId?: string, + scope: 'message' | 'runtime' = 'runtime' ): Promise { const epoch = this.terminalLifecycle.captureEpoch(); const metadata = this.terminalLifecycle.getStoredMetadata(); @@ -1677,6 +1751,12 @@ export class SandboxSession extends DurableObject { message.wrapperInstanceId !== wrapperInstanceId ) return; + if (scope === 'message') { + const messages = failQueuedMessage(this.loadMessages(), messageId, reason); + if (!messages || !this.saveMessages(messages, epoch)) return; + if (nextQueuedMessageId(messages)) await this.armQueueRetry(); + return; + } if (wrapperInstanceId) this.retainRuntimeCleanup(metadata, wrapperInstanceId, reason); await this.failWaitingMessages(reason, wrapperInstanceId); if (this.pendingRuntimeCleanup()) await this.transferRuntimeCleanup(); diff --git a/services/cloud-agent-next/src/sandbox-session/control-dispatch.test.ts b/services/cloud-agent-next/src/sandbox-session/control-dispatch.test.ts index a43e44b721..a0a5eaf837 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-dispatch.test.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-dispatch.test.ts @@ -5,8 +5,10 @@ import { type ResponseFrame, } from '../shared/sandbox-control-protocol.js'; import { + ControlRequestError, controlDispatchDisposition, controlRequestResult, + deliveryErrorLogFields, isRetryableDeliveryError, observeControlAfterStopping, SESSION_DELIVERY_TIMEOUT_MS, @@ -30,6 +32,25 @@ describe('controlDispatchDisposition', () => { }); describe('controlRequestResult', () => { + it('preserves validated application rejections separately from transport exceptions', () => { + const error = { + code: 'session_busy', + message: 'Session has work in progress', + retryable: true, + }; + const response: ResponseFrame = { type: 'response', requestId: 'request', ok: false, error }; + let failure: unknown; + try { + controlRequestResult(response); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(ControlRequestError); + expect(failure).toMatchObject(error); + expect(isRetryableDeliveryError(failure)).toBe(true); + expect(Object.assign(new Error(error.message), error)).not.toBeInstanceOf(ControlRequestError); + }); + it.each([undefined, { retryable: true }, { code: '', message: 'Invalid', retryable: true }])( 'does not turn a malformed rejection %j into a retryable failure', error => { @@ -51,6 +72,53 @@ describe('controlRequestResult', () => { ); }); +describe('deliveryErrorLogFields', () => { + it.each(['session_busy', 'not_ready', 'runtime_unhealthy'])( + 'logs only the allowlisted %s code and retry classification', + code => { + const error = Object.assign( + new ControlRequestError({ code, message: 'sensitive-message', retryable: true }), + { + cause: 'sensitive-cause', + stack: 'sensitive-stack', + auth: 'sensitive-auth', + env: 'sensitive-env', + } + ); + expect(deliveryErrorLogFields(error)).toEqual({ errorCode: code, retryable: true }); + } + ); + + it('does not log an arbitrary response code', () => { + expect( + deliveryErrorLogFields( + new ControlRequestError({ + code: 'sensitive-untrusted-code', + message: 'sensitive-message', + retryable: false, + }) + ) + ).toEqual({ errorCode: 'unknown_control_error', retryable: false }); + }); + + it.each([false, true])( + 'classifies transport exceptions without copying fields when overloaded=%s', + overloaded => { + const error = Object.assign(new Error('sensitive-message'), { + code: 'session_busy', + retryable: true, + overloaded, + stack: 'sensitive-stack', + cause: 'sensitive-cause', + }); + expect(deliveryErrorLogFields(error)).toEqual({ + errorCode: 'transport_or_internal_error', + retryable: !overloaded, + }); + } + ); +}); + describe('withDeliveryDeadline', () => { beforeEach(() => { vi.useFakeTimers(); @@ -98,6 +166,20 @@ describe('withDeliveryDeadline', () => { } ); + it('preserves a confirmed rejection that arrives at the head deadline', async () => { + const rejection = new ControlRequestError({ + code: 'session_busy', + message: 'Session has work in progress', + retryable: true, + }); + const failure = withDeliveryDeadline( + () => new Promise((_resolve, reject) => setTimeout(() => reject(rejection), 1_000)), + Date.now() + 1_000 + ).catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(1_000); + expect(await failure).toBe(rejection); + }); + it('preserves an explicit transient failure before the operation cutoff', async () => { const failure = Object.assign(new Error('Transient control failure'), { retryable: true, diff --git a/services/cloud-agent-next/src/sandbox-session/control-dispatch.ts b/services/cloud-agent-next/src/sandbox-session/control-dispatch.ts index 3f44ec854e..0778a15df9 100644 --- a/services/cloud-agent-next/src/sandbox-session/control-dispatch.ts +++ b/services/cloud-agent-next/src/sandbox-session/control-dispatch.ts @@ -4,13 +4,27 @@ import { DEADLINE_MS } from '../sandbox-control/deadlines.js'; import { SANDBOX_CONTROL_ATTACH_TIMEOUT_MS, SANDBOX_CONTROL_REQUEST_TIMEOUT_MS, + controlErrorCodes, controlErrorSchema, + type ControlError, type ResponseFrame, } from '../shared/sandbox-control-protocol.js'; export const SESSION_DELIVERY_TIMEOUT_MS = DEADLINE_MS.startup + SANDBOX_CONTROL_ATTACH_TIMEOUT_MS + 2 * SANDBOX_CONTROL_REQUEST_TIMEOUT_MS; +export class ControlRequestError extends Error { + readonly code: string; + readonly retryable: boolean; + + constructor(error: ControlError) { + super(error.message); + this.name = 'ControlRequestError'; + this.code = error.code; + this.retryable = error.retryable; + } +} + export async function withDeliveryDeadline( operation: () => Promise, deadlineAt: number, @@ -27,6 +41,7 @@ export async function withDeliveryDeadline( 'Session delivery operation timed out' ); } catch (error) { + if (error instanceof ControlRequestError) throw error; if (Date.now() >= operationDeadlineAt) { throw new Error('Session delivery operation timed out'); } @@ -36,8 +51,7 @@ export async function withDeliveryDeadline( export function controlRequestResult(response: ResponseFrame): unknown { if (response.ok) return response.result; - const error = controlErrorSchema.parse(response.error); - throw Object.assign(new Error(error.message), error); + throw new ControlRequestError(controlErrorSchema.parse(response.error)); } export function isRetryableDeliveryError(error: unknown): boolean { @@ -50,6 +64,16 @@ export function isRetryableDeliveryError(error: unknown): boolean { ); } +export function deliveryErrorLogFields(error: unknown) { + return { + errorCode: + error instanceof ControlRequestError + ? (controlErrorCodes.find(code => code === error.code) ?? 'unknown_control_error') + : 'transport_or_internal_error', + retryable: isRetryableDeliveryError(error), + }; +} + export type ControlDispatchDisposition = | { action: 'send' } | { action: 'wait' } diff --git a/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts b/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts index 2d6907e54c..0d202518c2 100644 --- a/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts +++ b/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts @@ -901,7 +901,7 @@ const RUNTIME_ID = '22222222-2222-4222-8222-222222222222'; const NEXT_RUNTIME_ID = '33333333-3333-4333-8333-333333333333'; const DIRECTORY = '/workspace/session'; const KILO_CREDENTIAL = createControlPlaneCredential(SANDBOX_ID, 'kilo'); -const ATTACHMENT: SessionAttachPayload = { +const ATTACHMENT = { directory: DIRECTORY, env: { KILOCODE_TOKEN: KILO_CREDENTIAL }, kilo: { @@ -913,7 +913,7 @@ const ATTACHMENT: SessionAttachPayload = { sessionIngestBaseUrl: 'https://ingest.example.test', }, }, -}; +} satisfies SessionAttachPayload; type Control = ReturnType; type ControlStatus = Awaited>; @@ -932,16 +932,16 @@ function controlResponse(result: unknown): ResponseFrame { return { type: 'response', requestId: 'request', ok: true, result }; } -function controlFailure(retryable: boolean): ResponseFrame { +function controlFailure(retryable: boolean, code = 'not_ready'): ResponseFrame { return { type: 'response', requestId: 'request', ok: false, - error: { code: 'not_ready', message: 'Control request failed', retryable }, + error: { code, message: 'Control request failed', retryable }, }; } -function sessionFixture(overrides: Partial = {}) { +function sessionFixture(overrides: Partial = {}, sharedControl?: Control) { const values = new Map(); let alarmAt: number | null = null; const errors: unknown[] = []; @@ -990,7 +990,7 @@ function sessionFixture(overrides: Partial = {}) { }), } as unknown as DurableObjectStorage; const ctx = { - id: { name: `user_1:${SESSION_ID}` }, + id: { name: `user_1:${metadata.identity.sessionId}` }, storage, blockConcurrencyWhile: async (callback: () => Promise) => callback(), getWebSockets: () => [], @@ -1036,7 +1036,7 @@ function sessionFixture(overrides: Partial = {}) { request, } satisfies Control; const env = { - SANDBOX_CONTROL: { getByName: () => control }, + SANDBOX_CONTROL: { getByName: () => sharedControl ?? control }, CLOUD_AGENT_CONTAINER_BILLING_ENABLED: 'true', CLOUD_AGENT_CONTAINER_BILLING_ORG_IDS: 'org_1', CLOUD_AGENT_CONTAINER_BILLING_USER_IDS: 'user_1', @@ -1059,6 +1059,7 @@ function sessionFixture(overrides: Partial = {}) { const now = Date.now(); vi.clearAllTimers(); vi.setSystemTime(now); + orchestrationMocks.eventQueries.mockReturnValue(eventQueries); session = new SandboxSession(ctx, env); }, fireAlarm: () => { @@ -1108,9 +1109,9 @@ function sessionFixture(overrides: Partial = {}) { ) => session.receiveSandboxControlEvent({ identity: { - directory: DIRECTORY, - kiloSessionId: 'kilo_root', - rootKiloSessionId: 'kilo_root', + directory: metadata.workspace?.workspacePath ?? DIRECTORY, + kiloSessionId: metadata.auth.kiloSessionId, + rootKiloSessionId: metadata.auth.kiloSessionId, }, wrapperInstanceId, payload: { type: 'session.message.outcome', properties: { messageId, status: outcome } }, @@ -1515,6 +1516,14 @@ describe('SandboxSession orchestration', () => { await fixture.flush(); expect(fixture.record('b')?.state).toBe('accepted'); expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + } else if (source === 'response') { + expect(fixture.record('a')?.failedReason).toBe(reason); + expect(fixture.record('b')?.state).toBe('queued'); + expect(fixture.terminalEvents()).toHaveLength(1); + expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + fixture.control.request.mockImplementation(original); + await fixture.fireAlarm(); + expect(fixture.record('b')?.state).toBe('accepted'); } else { expect(fixture.record('a')?.failedReason).toBe(reason); expect(fixture.record('b')?.state).toBe('failed'); @@ -1528,6 +1537,7 @@ describe('SandboxSession orchestration', () => { it.each([ 'permanent response', + 'unhealthy response', 'malformed response', 'unmarked exception', 'overloaded exception', @@ -1539,6 +1549,8 @@ describe('SandboxSession orchestration', () => { await fixture.admit('b'); await fixture.flush(); if (failure === 'permanent response') response.resolve(controlFailure(false)); + else if (failure === 'unhealthy response') + response.resolve(controlFailure(false, 'runtime_unhealthy')); else if (failure === 'malformed response') { response.resolve({ type: 'response', requestId: 'request', ok: false }); } else { @@ -1551,6 +1563,12 @@ describe('SandboxSession orchestration', () => { } await fixture.flush(); expect(fixture.record('a')).toMatchObject({ state: 'failed', failedReason: reason }); + if (failure === 'permanent response') { + expect(fixture.record('b')?.state).toBe('queued'); + expect(fixture.terminalEvents()).toHaveLength(1); + expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + return; + } expect(fixture.record('b')?.state).toBe('failed'); expect(fixture.terminalEvents()).toHaveLength(2); expect(fixture.control.quarantineRuntime).toHaveBeenCalledWith( @@ -1563,7 +1581,7 @@ describe('SandboxSession orchestration', () => { }); it.each(['response', 'exception'] as const)( - 'fails at the operation cutoff even when a retryable peer %s wins the timer race', + 'distinguishes a peer %s from a transport timeout at the operation cutoff', async source => { const fixture = sessionFixture(); delegateRequest( @@ -1586,6 +1604,16 @@ describe('SandboxSession orchestration', () => { expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1); await fixture.flush(); + if (source === 'response') { + expect(fixture.record('a')).toMatchObject({ + state: 'queued', + deliveryRetryScope: 'message', + }); + expect(fixture.record('b')?.state).toBe('queued'); + expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + expect(fixture.terminalEvents()).toHaveLength(0); + return; + } expect(fixture.record('a')).toMatchObject({ state: 'failed', failedReason: reason }); expect(fixture.record('b')?.state).toBe('failed'); expect(fixture.terminalEvents()).toHaveLength(2); @@ -1598,6 +1626,347 @@ describe('SandboxSession orchestration', () => { ); }); + describe.each(['session.attach', 'session.prompt'] as const)( + '%s failure isolation', + operation => { + function sharedSessions() { + const writer = sessionFixture(); + const sibling = sessionFixture( + { + identity: { + ...writer.metadata.identity, + sessionId: 'workspace_44444444-4444-4444-8444-444444444444', + }, + auth: { ...writer.metadata.auth, kiloSessionId: 'kilo_sibling' }, + }, + writer.control + ); + writer.control.quarantineRuntime.mockImplementation(async input => { + writer.setStatus({ physical: 'stopped', connection: 'disconnected' }); + await writer.session.failWaitingMessages(input.reason, input.wrapperInstanceId); + return { quarantined: true }; + }); + return { writer, sibling }; + } + + it.each(['accepted', 'expired'] as const)( + 'keeps an accepted sibling alive while contention retries become %s across resets', + async outcome => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + expect(writer.record('writer')?.state).toBe('accepted'); + let busy = true; + const requests: SandboxControlOutboundRequest[] = []; + const original = writer.control.request.getMockImplementation(); + if (!original) throw new Error('Missing control fixture'); + delegateRequest(writer, operation, async input => { + if (input.session?.sessionId !== sibling.metadata.identity.sessionId) + return original(input); + requests.push(structuredClone(input)); + return busy ? controlFailure(true, 'session_busy') : original(input); + }); + await sibling.admit('waiting'); + await sibling.flush(); + const acquisition = sibling.acquisition('waiting'); + for (let attempt = 0; attempt < 6; attempt++) { + sibling.reload(); + const retryAt = sibling.alarmAt(); + if (retryAt === null) throw new Error('Missing contention retry alarm'); + vi.setSystemTime(retryAt); + await sibling.fireAlarm(); + expect(sibling.record('waiting')).toMatchObject({ + state: 'queued', + deliveryRetryScope: 'message', + deliveryDeadlineAt: acquisition.deadlineAt, + preparationAttemptId: acquisition.id, + }); + expect(sibling.record('waiting')?.attachFailures).toBeUndefined(); + expect(sibling.record('waiting')?.promptFailures).toBeUndefined(); + expect(writer.record('writer')?.state).toBe('accepted'); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + } + expect(requests).toEqual(Array.from({ length: 7 }, () => requests[0])); + if (outcome === 'expired') { + sibling.reload(); + vi.setSystemTime(acquisition.deadlineAt); + await sibling.fireAlarm(); + expect(sibling.record('waiting')).toMatchObject({ + state: 'failed', + failedReason: 'preparation_timeout', + terminalAt: acquisition.deadlineAt, + }); + expect(sibling.terminalEvents()).toHaveLength(1); + expect(await sibling.session.getCurrentMessageWork()).toBeNull(); + expect(await sibling.session.isSandboxCleanupScheduled()).toBe(false); + expect(await sibling.snapshot()).toMatchObject({ + sessionStatus: { type: 'idle' }, + cloudStatus: { type: 'ready' }, + pendingInteractions: { questions: [], permissions: [] }, + queuedMessages: [ + { + messageId: 'waiting', + terminalFailure: { accepted: false, reason: 'preparation_timeout' }, + }, + ], + }); + expect(await writer.session.getCurrentMessageWork()).toMatchObject({ + messageId: 'writer', + status: 'running', + }); + await sibling.fireAlarm(); + expect(requests).toHaveLength(7); + expect(sibling.alarmAt()).toBeNull(); + busy = false; + await sibling.admit('follow-up'); + await sibling.flush(); + expect(sibling.record('follow-up')?.state).toBe('accepted'); + } else { + busy = false; + await sibling.fireAlarm(); + expect(sibling.record('waiting')).toMatchObject({ + state: 'accepted', + deliveryDeadlineAt: acquisition.deadlineAt, + wrapperInstanceId: RUNTIME_ID, + }); + await sibling.outcome('waiting', 'completed'); + } + await writer.fireAlarm(); + expect(writer.record('writer')?.state).toBe('accepted'); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + expect(writer.terminalEvents()).toHaveLength(0); + await writer.outcome('writer', 'completed'); + expect(writer.record('writer')?.state).toBe('completed'); + } + ); + + it('expires only the contending head and preserves its queued follow-up', async () => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + const original = writer.control.request.getMockImplementation(); + if (!original) throw new Error('Missing control fixture'); + delegateRequest(writer, operation, async () => controlFailure(true, 'session_busy')); + await sibling.admit('waiting'); + await sibling.admit('next'); + await sibling.flush(); + const deadlineAt = sibling.acquisition('waiting').deadlineAt; + sibling.reload(); + vi.setSystemTime(deadlineAt); + await sibling.fireAlarm(); + expect(sibling.record('waiting')).toMatchObject({ + state: 'failed', + failedReason: 'preparation_timeout', + }); + expect(sibling.record('next')?.state).toBe('queued'); + expect(sibling.record('next')?.deliveryDeadlineAt).toBeUndefined(); + expect(await sibling.session.getCurrentMessageWork()).toMatchObject({ + messageId: 'next', + status: 'pending', + }); + expect(writer.record('writer')?.state).toBe('accepted'); + writer.control.request.mockImplementation(original); + await sibling.fireAlarm(); + expect(sibling.record('next')).toMatchObject({ + state: 'accepted', + deliveryDeadlineAt: Date.now() + SESSION_DELIVERY_TIMEOUT_MS, + wrapperInstanceId: RUNTIME_ID, + }); + expect(sibling.terminalEvents()).toHaveLength(1); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + 'isolates application rejection with retryable=%s from a running sibling', + async retryable => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + delegateRequest(writer, operation, async () => controlFailure(retryable)); + await sibling.admit('rejected'); + await sibling.flush(); + for (let attempt = 1; attempt < (operation === 'session.attach' ? 2 : 5); attempt++) { + sibling.reload(); + await sibling.fireAlarm(); + } + expect(sibling.record('rejected')).toMatchObject({ + state: 'failed', + failedReason: operation === 'session.attach' ? 'attach_exhausted' : 'prompt_exhausted', + }); + expect(sibling.terminalEvents()).toHaveLength(1); + expect(await sibling.session.isSandboxCleanupScheduled()).toBe(false); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + await writer.outcome('writer', 'completed'); + expect(writer.record('writer')?.state).toBe('completed'); + } + ); + + it('cancels a busy retry without quarantining the accepted sibling', async () => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + delegateRequest(writer, operation, async () => controlFailure(true, 'session_busy')); + await sibling.admit('waiting'); + await sibling.flush(); + sibling.reload(); + await expect(sibling.session.interruptExecution()).resolves.toEqual({ success: true }); + await sibling.fireAlarm(); + expect(sibling.record('waiting')?.state).toBe('cancelled'); + expect(sibling.terminalEvents()).toHaveLength(1); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + await writer.outcome('writer', 'completed'); + expect(writer.record('writer')?.state).toBe('completed'); + }); + + it.each(['stop', 'expiry'] as const)( + 'retains unresolved dispatch ownership through a lost acknowledgement, busy replay, and %s', + async action => { + const fixture = sessionFixture(); + const lostAcknowledgement = deferred(); + let requests = 0; + let remoteWorkRunning = false; + delegateRequest(fixture, operation, async () => { + requests++; + if (requests === 1) { + remoteWorkRunning = true; + return lostAcknowledgement.promise; + } + return controlFailure(true, 'session_busy'); + }); + fixture.control.quarantineRuntime.mockImplementation(async () => { + remoteWorkRunning = false; + return { quarantined: true }; + }); + await fixture.admit('waiting'); + await fixture.flush(); + const acquisition = fixture.acquisition('waiting'); + expect(fixture.record('waiting')?.unresolvedDispatch).toBe(true); + for (let attempt = 0; attempt < 3; attempt++) { + fixture.reload(); + await fixture.fireAlarm(); + expect(fixture.record('waiting')).toMatchObject({ + state: 'queued', + unresolvedDispatch: true, + deliveryRetryScope: 'runtime', + wrapperInstanceId: RUNTIME_ID, + }); + } + expect(remoteWorkRunning).toBe(true); + expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + fixture.reload(); + if (action === 'stop') { + await expect(fixture.session.interruptExecution()).resolves.toEqual({ success: true }); + } else { + vi.setSystemTime(acquisition.deadlineAt); + await fixture.fireAlarm(); + } + expect(remoteWorkRunning).toBe(false); + expect(fixture.control.quarantineRuntime).toHaveBeenCalledWith({ + ownerId: 'user_1', + sessionId: SESSION_ID, + wrapperInstanceId: RUNTIME_ID, + reason: action === 'stop' ? 'preparation_interrupted' : 'preparation_timeout', + }); + expect(fixture.record('waiting')?.state).toBe(action === 'stop' ? 'cancelled' : 'failed'); + lostAcknowledgement.resolve( + controlResponse( + operation === 'session.attach' + ? { attached: true } + : { messageId: 'waiting', status: 'accepted' } + ) + ); + await fixture.flush(); + expect(fixture.record('waiting')?.state).toBe(action === 'stop' ? 'cancelled' : 'failed'); + expect(fixture.terminalEvents()).toHaveLength(1); + expect(await fixture.session.getCurrentMessageWork()).toBeNull(); + } + ); + + it('quarantines a transport failure after a busy retry rather than retaining the previous isolation scope', async () => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + let busy = true; + delegateRequest(writer, operation, async () => { + if (busy) return controlFailure(true, 'session_busy'); + throw new Error('Control transport disconnected'); + }); + await sibling.admit('waiting'); + await sibling.flush(); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + busy = false; + sibling.reload(); + await sibling.fireAlarm(); + expect(sibling.record('waiting')?.state).toBe('failed'); + expect(writer.record('writer')?.state).toBe('failed'); + expect(writer.control.quarantineRuntime).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: sibling.metadata.identity.sessionId, + wrapperInstanceId: RUNTIME_ID, + }) + ); + }); + } + ); + + it('retains acknowledged prompt ownership until acceptance is persisted atomically', async () => { + const fixture = sessionFixture(); + let acknowledged = false; + delegateRequest(fixture, 'session.prompt', async () => { + acknowledged = true; + return controlResponse({ messageId: 'waiting', status: 'accepted' }); + }); + const writes: SessionMessageRecord[] = []; + const put = fixture.storage.kv.put.bind(fixture.storage.kv); + vi.spyOn(fixture.storage.kv, 'put').mockImplementation((key, value) => { + if (key === 'session_messages' && acknowledged) { + writes.push(...structuredClone(value as SessionMessageRecord[])); + } + put(key, value); + }); + await fixture.admit('waiting'); + await fixture.flush(); + expect(writes.some(message => message.state === 'accepted')).toBe(true); + expect( + writes + .filter(message => message.state === 'queued') + .every(message => message.unresolvedDispatch) + ).toBe(true); + expect(fixture.record('waiting')?.unresolvedDispatch).toBeUndefined(); + }); + + it('clears acknowledged attachment ownership before a fresh prompt contention rejection', async () => { + const fixture = sessionFixture(); + const lostAcknowledgement = deferred(); + let attachments = 0; + delegateRequest(fixture, 'session.attach', async () => { + attachments++; + if (attachments === 1) return lostAcknowledgement.promise; + return attachments === 2 + ? controlFailure(true, 'session_busy') + : controlResponse({ attached: true }); + }); + delegateRequest(fixture, 'session.prompt', async () => controlFailure(true, 'session_busy')); + await fixture.admit('waiting'); + await fixture.flush(); + fixture.reload(); + await fixture.fireAlarm(); + expect(fixture.record('waiting')?.unresolvedDispatch).toBe(true); + await fixture.fireAlarm(); + expect(fixture.record('waiting')).toMatchObject({ + state: 'queued', + deliveryRetryScope: 'message', + }); + expect(fixture.record('waiting')?.unresolvedDispatch).toBeUndefined(); + fixture.reload(); + await expect(fixture.session.interruptExecution()).resolves.toEqual({ success: true }); + expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + expect(fixture.record('waiting')?.state).toBe('cancelled'); + lostAcknowledgement.resolve(controlResponse({ attached: true })); + await fixture.flush(); + expect(fixture.record('waiting')?.state).toBe('cancelled'); + }); + it.each(['acknowledged', 'rejected'] as const)( 'compensates a route RPC %s after deletion without retrying or resurrecting its message', async result => { @@ -2546,6 +2915,122 @@ describe('SandboxSession orchestration', () => { expect(await fixture.snapshot()).toMatchObject({ preparationSnapshots: coldPreparation }); }); + it.each(['cloudflare', 'vercel'] as const)( + 'applies fresh direct credentials before a warm prompt across eviction on %s', + async sandboxProvider => { + const fixture = sessionFixture({ + workspace: { sandboxId: SANDBOX_ID, workspacePath: DIRECTORY, sandboxProvider }, + }); + const initial = { + ...ATTACHMENT, + kilo: { ...ATTACHMENT.kilo, containmentEnabled: false }, + }; + const ready: ControlStatus = { + physical: 'running', + connection: 'ready', + wrapperInstanceId: RUNTIME_ID, + attachment: initial, + }; + fixture.control.ensureReady.mockResolvedValue(ready); + await fixture.admit('cold'); + await fixture.flush(); + await fixture.outcome('cold', 'completed'); + await fixture.flush(); + fixture.reload(); + const refreshed = { + ...initial, + kilo: { ...initial.kilo, token: 'fresh-direct-kilo-token' }, + env: { KILOCODE_TOKEN: 'fresh-direct-kilo-token', GH_TOKEN: 'fresh-direct-git-token' }, + git: { url: 'https://github.com/acme/repo.git', token: 'fresh-direct-git-token' }, + }; + fixture.control.ensureReady.mockResolvedValue({ ...ready, attachment: refreshed }); + fixture.control.request.mockClear(); + const attached = deferred(); + delegateRequest(fixture, 'session.attach', () => attached.promise); + await fixture.admit('warm'); + await fixture.flush(); + expect(fixture.control.request).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + operation: 'session.attach', + expectedWrapperInstanceId: RUNTIME_ID, + payload: { + ...refreshed, + preparation: { + attemptId: fixture.record('warm')?.preparationAttemptId, + triggerMessageId: 'warm', + }, + }, + }) + ); + expect(fixture.record('warm')).toMatchObject({ state: 'queued', unresolvedDispatch: true }); + attached.resolve(controlResponse({ attached: true })); + await fixture.flush(); + expect(fixture.control.request.mock.calls.map(([input]) => input.operation)).toEqual([ + 'session.attach', + 'session.prompt', + ]); + expect(fixture.record('warm')).toMatchObject({ + state: 'accepted', + wrapperInstanceId: RUNTIME_ID, + }); + expect(fixture.record('warm')?.unresolvedDispatch).toBeUndefined(); + expect(fixture.control.quarantineRuntime).not.toHaveBeenCalled(); + } + ); + + it.each(['stop', 'expiry'] as const)( + 'retains ambiguous warm prompt ownership through direct reattachment and %s', + async action => { + const fixture = sessionFixture(); + fixture.control.ensureReady.mockResolvedValue({ + physical: 'running', + connection: 'ready', + wrapperInstanceId: RUNTIME_ID, + attachment: { + ...ATTACHMENT, + kilo: { ...ATTACHMENT.kilo, containmentEnabled: false }, + }, + }); + await fixture.admit('cold'); + await fixture.flush(); + await fixture.outcome('cold', 'completed'); + await fixture.flush(); + const lostPrompt = deferred(); + let prompts = 0; + delegateRequest(fixture, 'session.prompt', async () => { + prompts++; + return prompts === 1 ? lostPrompt.promise : controlFailure(true, 'session_busy'); + }); + await fixture.admit('warm'); + await fixture.flush(); + const acquisition = fixture.acquisition('warm'); + expect(fixture.record('warm')?.unresolvedDispatch).toBe(true); + fixture.reload(); + await fixture.fireAlarm(); + expect(fixture.record('warm')).toMatchObject({ + state: 'queued', + unresolvedDispatch: true, + deliveryRetryScope: 'runtime', + wrapperInstanceId: RUNTIME_ID, + }); + expect(fixture.control.attachSession).toHaveBeenCalledTimes(3); + if (action === 'stop') { + await fixture.session.interruptExecution(); + } else { + vi.setSystemTime(acquisition.deadlineAt); + await fixture.fireAlarm(); + } + expect(fixture.control.quarantineRuntime).toHaveBeenCalledWith( + expect.objectContaining({ wrapperInstanceId: RUNTIME_ID }) + ); + expect(fixture.record('warm')?.state).toBe(action === 'stop' ? 'cancelled' : 'failed'); + lostPrompt.resolve(controlResponse({ messageId: 'warm', status: 'accepted' })); + await fixture.flush(); + expect(fixture.record('warm')?.state).toBe(action === 'stop' ? 'cancelled' : 'failed'); + expect(fixture.terminalEvents()).toHaveLength(2); + } + ); + it.each(['replacement runtime', 'missing legacy attachment'] as const)( 'performs real preparation for a %s instead of assuming warmth from prior messages', async reason => { diff --git a/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts b/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts index cb4cebfc67..6ec592f827 100644 --- a/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts +++ b/services/cloud-agent-next/src/sandbox-session/session-message-queue.ts @@ -33,6 +33,8 @@ type SessionMessageLifecycle = { acceptedAt?: number; lastActivityAt?: number; deliveryDeadlineAt?: number; + deliveryRetryScope?: 'message' | 'runtime'; + unresolvedDispatch?: true; wrapperInstanceId?: string; terminalAt?: number; failedReason?: string; @@ -290,6 +292,7 @@ export function applyMessageOutcome( ? { ...item, state: outcome.status, + unresolvedDispatch: undefined, acceptedAt: item.acceptedAt ?? now, terminalAt: now, ...(outcome.reason ? { failedReason: outcome.reason } : {}), @@ -304,14 +307,15 @@ export function hasAcceptedMessage(messages: readonly SessionMessageRecord[]): b export function failQueuedMessage( messages: readonly SessionMessageRecord[], - messageId: string + messageId: string, + reason?: string ): SessionMessageRecord[] | undefined { if (!messages.some(message => message.messageId === messageId && message.state === 'queued')) { return undefined; } return messages.map(message => message.messageId === messageId && message.state === 'queued' - ? { ...message, state: 'failed' } + ? { ...message, state: 'failed', ...(reason ? { failedReason: reason } : {}) } : message ); } @@ -324,7 +328,13 @@ export function acceptQueuedMessage( if (nextQueuedMessageId(messages) !== messageId) return undefined; return messages.map(message => message.messageId === messageId - ? { ...message, state: 'accepted', acceptedAt, lastActivityAt: acceptedAt } + ? { + ...message, + state: 'accepted', + acceptedAt, + lastActivityAt: acceptedAt, + unresolvedDispatch: undefined, + } : message ); } diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 6fa5b5d9dd..200aae01e4 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -383,8 +383,8 @@ describe('createSessionWithLedger admission ladder', () => { }); }); - describe.each([undefined, 'true', 'false', ''] as const)( - 'workspace containment ignores CREDENTIAL_CONTAINMENT_ENABLED=%s', + describe.each([undefined, 'true', 'false', '', 'False', '0'] as const)( + 'workspace containment samples CREDENTIAL_CONTAINMENT_ENABLED=%s', flag => { it.each([ { @@ -421,7 +421,12 @@ describe('createSessionWithLedger admission ladder', () => { expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ identity: expect.objectContaining({ sessionId: WORKSPACE_SESSION_ID }), - workspace: expect.objectContaining({ credentialContainment: expected }), + workspace: expect.objectContaining({ + credentialContainment: + flag === 'false' + ? { github: false, gitlab: false, bitbucket: false, kilocode: false } + : expected, + }), }) ); } @@ -429,7 +434,7 @@ describe('createSessionWithLedger admission ladder', () => { } ); - it('keeps mandatory workspace containment when CREDENTIAL_CONTAINMENT_ENABLED=false', async () => { + it('persists disabled workspace containment independently of later flag changes', async () => { generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); const doStub = makeDoStub(); const ctx = makeContext(doStub); @@ -440,15 +445,17 @@ describe('createSessionWithLedger admission ladder', () => { makeRequest({ repository: { type: 'gitlab', url: 'https://gitlab.com/acme/repo.git' } }) ); + ctx.env.CREDENTIAL_CONTAINMENT_ENABLED = 'true'; + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( expect.objectContaining({ identity: expect.objectContaining({ sessionId: WORKSPACE_SESSION_ID }), workspace: expect.objectContaining({ credentialContainment: { github: false, - gitlab: true, + gitlab: false, bitbucket: false, - kilocode: true, + kilocode: false, }, }), }) diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 043edc0d01..7413db388f 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -444,7 +444,11 @@ function computeCredentialContainment( input: SessionRegistrationInput, env: Env ): CredentialContainment { - const controlPlaneContainment = getControlPlaneCredentialContainment(sessionId, input.repository); + const controlPlaneContainment = getControlPlaneCredentialContainment( + sessionId, + input.repository, + env.CREDENTIAL_CONTAINMENT_ENABLED !== 'false' + ); if (controlPlaneContainment) return controlPlaneContainment; const containmentEnabled = env.CREDENTIAL_CONTAINMENT_ENABLED !== 'false' && input.runtime?.devcontainer !== true; diff --git a/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts b/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts index 6b8d4a6ae0..0e779c3b77 100644 --- a/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts +++ b/services/cloud-agent-next/src/shared/sandbox-control-protocol.ts @@ -61,6 +61,8 @@ export const controlErrorCodes = [ 'unknown_operation', 'handshake_required', 'not_ready', + 'session_busy', + 'runtime_unhealthy', 'idempotency_conflict', ] as const; @@ -161,6 +163,18 @@ export const sandboxHeartbeatPayloadSchema = z kilo: z .object({ ready: z.boolean(), + reason: z + .enum([ + 'feed_stale', + 'feed_reconnected', + 'feed_ended', + 'feed_failed', + 'process_exited', + 'credential_refresh_failed', + 'control_disconnected', + 'shutdown', + ]) + .optional(), }) .strict(), sessions: z.array( @@ -233,6 +247,11 @@ export const sessionAttachPayloadSchema = z .object({ scopeId: z.string().min(1).max(256), token: z.string().min(1).max(4096), + containmentEnabled: z.boolean().optional(), + organizationId: z + .string() + .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/) + .optional(), targets: z .object({ backendBaseUrl: z.string().url(), diff --git a/services/cloud-agent-next/test/e2e/README.md b/services/cloud-agent-next/test/e2e/README.md index db4c882b2c..bf3330510e 100644 --- a/services/cloud-agent-next/test/e2e/README.md +++ b/services/cloud-agent-next/test/e2e/README.md @@ -38,21 +38,29 @@ cloud-agent-next refactor. ## Credential containment -Control-plane sessions (`workspace_*`) always contain managed Kilo and repository -credentials, including sessions whose older metadata disabled containment. -`CREDENTIAL_CONTAINMENT_ENABLED` cannot disable control-plane containment. -Cloudflare uses contained sandbox classes and the existing credential broker; -Vercel uses native network policies. Each worktree has stable credential aliases -shared by its registered Kilo roots, while different worktrees have separate Kilo -authentication contexts. Unsupported credential configurations fail closed rather -than exposing raw tokens. - -Cloudflare's native outbound handler intercepts ports 80 and 443. Local targets -such as `http://host.docker.internal:` can bypass interception and -reject the aliases with HTTP 401. A control-plane E2E run needs sandbox-facing -endpoints that actually traverse the native handler, plus the running -`cloudflare-git-token-service` and its capability-encryption configuration. Do not -disable containment to work around a local transport limitation. +Control-plane sessions (`workspace_*`) respect `CREDENTIAL_CONTAINMENT_ENABLED`. +Only the literal `false` disables containment; local dev defaults to `false`. +The choice is persisted when a worktree is created and inherited by sibling chats. +Changing the environment does not switch an existing worktree or running sandbox +between contained and direct credentials. + +When enabled, Cloudflare uses contained sandbox classes and the existing credential +broker; Vercel uses native network policies. Each worktree has stable credential +aliases shared by its registered Kilo roots, while different worktrees have +separate Kilo authentication contexts. Containment failures never fall back to +raw credentials. When disabled, authorized Kilo and repository credentials are +provided directly to the sandbox without alias redemption or credential injection. +Control-plane ownership, attachment scope, terminal authorization, and billing +checks still apply; direct API credentials retain their underlying access scope. +Expired direct-credential terminal leases require a session reattachment rather +than renewing only the server-side grant. + +Cloudflare's native outbound handler intercepts ports 80 and 443. With containment +enabled, local targets such as `http://host.docker.internal:` can +bypass interception and reject aliases with HTTP 401. Contained E2E runs therefore +need sandbox-facing endpoints that traverse the native handler, plus the running +`cloudflare-git-token-service` and its capability-encryption configuration. The +local-dev direct-credential mode supports the generated high-port HTTP endpoints. For new legacy sessions (`agent_*`), `CREDENTIAL_CONTAINMENT_ENABLED` controls GitHub, GitLab, Bitbucket, and Kilo credential containment together. Containment @@ -66,8 +74,8 @@ variable affects new legacy sessions, not existing ones. ## Running -> **Non-zero port offset:** the commands below use the default ports -> (`8794`/`8811`), which only match a zero-offset session. For any other +> **Non-zero port offset:** except for `multichat-real.ts`, the drivers below use +> the default ports (`8794`/`8811`), which only match a zero-offset session. For any other > session, first read the offset from `pnpm dev:status --json` > (`portOffset` field), then prefix every driver invocation with > `WORKER_URL=http://localhost:<8794 + portOffset>` and @@ -75,6 +83,24 @@ variable affects new legacy sessions, not existing ones. > driver silently hits the wrong Worker/fake-LLM and every scenario fails > at connection. See the env-var table below for the full list. +Real-model multichat acceptance (`kilo-auto/efficient`): + +```bash +pnpm exec tsx services/cloud-agent-next/test/e2e/multichat-real.ts \ + --auth /path/to/private-auth.json \ + --out dev/logs/multichat-new-run \ + --rounds 3 +``` + +This driver discovers the existing local stack's ports and requires an already +funded test user enrolled for control-plane and worktree creation. Pass credentials +only through an owned mode-600 auth file, never as a command-line token. The output +directory must not already exist. Bootstrap is API-assisted; sibling creation, +sends, and Stop use the real web endpoints. Three chats exercise repeated shared +file writes/reads, native tool overlap, Stop isolation, and post-Stop follow-ups. +Private reports and transcripts are retained; chats and sandboxes are not deleted +automatically. See the known CLI 7.4.20 limitation under Troubleshooting. + Official SDK basic-chat acceptance (pinned `@kilocode/sdk/v2` `7.4.20`): ```bash @@ -258,6 +284,15 @@ the newer `start` / `send` procedures. `prepareSession` requires ## Troubleshooting +- **Known CLI 7.4.20 stall during snapshot initialization** — Local real-model + multichat runs have stopped receiving native HTTP responses and global event + heartbeats after snapshot initialization began, while the Kilo process remained + alive. Captured container memory was about 1 GB with zero OOM events. The wrapper + correctly reports `feed_stale` and retires the shared runtime as `kilo_unhealthy`, + which can fail sibling turns. The underlying native cause is not established; + snapshot activity is a correlation, not a proven cause. This remains a known + limitation: keep snapshots and health deadlines unchanged, preserve failed-run + evidence, and distinguish failed cases from downstream checks that were not run. - **`Must provide either githubRepo or gitUrl`** — The driver defaults to a public HTTPS repo. Override with `E2E_GIT_URL=...` if your network blocks GitHub or you prefer a different test repo. diff --git a/services/cloud-agent-next/test/e2e/multichat-real-support.test.ts b/services/cloud-agent-next/test/e2e/multichat-real-support.test.ts new file mode 100644 index 0000000000..f3071393d2 --- /dev/null +++ b/services/cloud-agent-next/test/e2e/multichat-real-support.test.ts @@ -0,0 +1,336 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import { test } from 'node:test'; +import jwt from 'jsonwebtoken'; +import { + CheckError, + mintBootstrapToken, + runningNativeHold, + matchesNativeHold, + assertNativeHoldOverlap, + assertNoNativeCompletion, + hasNativeCancellation, + type NativeHold, + type NativeTurn, + type Part, +} from './multichat-real-support.js'; + +const secret = randomBytes(32).toString('hex'); +const userId = 'multichat-unit-fixture'; +const pepper = 'unit-fixture-pepper'; + +function fixture(claims: Record = {}) { + const now = Math.floor(Date.now() / 1000); + return { + userId, + token: jwt.sign( + { + version: 3, + kiloUserId: userId, + env: 'production', + apiTokenPepper: pepper, + iat: now, + exp: now + 7200, + ...claims, + }, + secret, + { algorithm: 'HS256' } + ), + }; +} + +for (const env of ['production', 'development']) { + void test(`verified ${env} fixture mints exact local Next dev cloud-agent claims`, () => { + const auth = fixture({ env }); + const original = auth.token; + const before = Math.floor(Date.now() / 1000); + const token = mintBootstrapToken(auth, secret); + const verified = jwt.verify(token, secret, { algorithms: ['HS256'] }); + assert.equal(typeof verified, 'object'); + assert(typeof verified === 'object'); + const { iat, exp, ...claims } = verified; + assert.deepEqual(claims, { + env: 'development', + kiloUserId: userId, + apiTokenPepper: pepper, + version: 3, + tokenSource: 'cloud-agent', + }); + assert(typeof iat === 'number' && typeof exp === 'number'); + assert(iat >= before && iat <= Math.floor(Date.now() / 1000)); + assert.equal(exp - iat, 3600); + assert(auth.token === original, 'The original web bearer must remain unchanged'); + }); +} + +void test('bootstrap preserves null pepper and never extends fixture expiry', () => { + const expiresAt = Math.floor(Date.now() / 1000) + 3000; + const token = mintBootstrapToken( + fixture({ apiTokenPepper: null, exp: expiresAt, tokenSource: 'cloud-agent' }), + secret + ); + const verified = jwt.verify(token, secret, { algorithms: ['HS256'] }); + assert(typeof verified === 'object'); + assert.equal(verified.apiTokenPepper, null); + assert.equal(verified.exp, expiresAt); +}); + +void test('bootstrap rejects a different signing key', () => { + assert.throws(() => mintBootstrapToken(fixture(), `${secret}-other`), CheckError); +}); + +void test('bootstrap rejects a different fixture user', () => { + assert.throws( + () => mintBootstrapToken({ ...fixture(), userId: 'another-user' }, secret), + CheckError + ); +}); + +const forbiddenClaims: { name: string; claims: Record }[] = [ + { name: 'unsupported environment', claims: { env: 'test' } }, + { name: 'unsupported version', claims: { version: 2 } }, + { name: 'admin claim', claims: { isAdmin: true } }, + { name: 'organization scope', claims: { organizationId: 'another-organization' } }, + { name: 'audience scope', claims: { aud: 'another-service' } }, + { name: 'unknown claim', claims: { unknownPermission: true } }, + { name: 'different token source', claims: { tokenSource: 'cli' } }, + { name: 'missing pepper', claims: { apiTokenPepper: undefined } }, +]; +for (const { name, claims } of forbiddenClaims) { + void test(`bootstrap rejects ${name} rather than stripping it`, () => { + assert.throws(() => mintBootstrapToken(fixture(claims), secret), CheckError); + }); +} + +void test('bootstrap rejects expired fixtures', () => { + assert.throws( + () => mintBootstrapToken(fixture({ exp: Math.floor(Date.now() / 1000) - 1 }), secret), + CheckError + ); +}); + +void test('bootstrap rejects future issuance', () => { + assert.throws( + () => mintBootstrapToken(fixture({ iat: Math.floor(Date.now() / 1000) + 120 }), secret), + CheckError + ); +}); + +void test('bootstrap rejects fixtures that cannot cover the full run', () => { + assert.throws( + () => mintBootstrapToken(fixture({ exp: Math.floor(Date.now() / 1000) + 2400 }), secret), + CheckError + ); +}); + +const hold: NativeHold = { + partId: 'part-hold', + callId: 'call-hold', + messageId: 'assistant-hold', + startedAt: 1000, + seconds: 180, +}; +function holdPart( + status: 'running' | 'completed' | 'error' = 'running', + start = 1000, + end = 181000 +): Part { + return { + id: hold.partId, + callID: hold.callId, + messageID: hold.messageId, + sessionID: 'session', + type: 'tool', + tool: 'bash', + state: { + status, + input: { command: 'sleep 180' }, + error: status === 'error' ? 'Tool execution aborted' : undefined, + time: status === 'running' ? { start } : { start, end }, + }, + }; +} +function cancelledTurn(): NativeTurn { + return { + messages: [ + { + id: hold.messageId, + sessionID: 'session', + role: 'assistant', + parentID: 'prompt', + finish: 'tool-calls', + time: { completed: 1100 }, + }, + ], + tools: [holdPart('error')], + }; +} + +void test('admission alone or a finished hold cannot establish native overlap', () => { + assert.equal(runningNativeHold([], 180), undefined); + assert.equal(runningNativeHold([holdPart('completed')], 180), undefined); + assert.equal(runningNativeHold([{ ...holdPart(), callID: undefined }], 180), undefined); + assert.deepEqual(runningNativeHold([holdPart()], 180), hold); +}); + +void test('durably running turns with serial native tool intervals fail overlap', () => { + assert.throws( + () => + assertNativeHoldOverlap([ + holdPart('completed', 1000, 10000), + holdPart('completed', 2000, 4000), + holdPart('completed', 4000, 8000), + ]), + CheckError + ); +}); + +void test('three completed native tool intervals prove real overlap', () => { + assert.deepEqual( + assertNativeHoldOverlap([ + holdPart('completed', 1000, 10000), + holdPart('completed', 2000, 6000), + holdPart('completed', 4000, 8000), + ]), + { start: 4000, end: 6000, overlapMs: 2000 } + ); +}); + +void test('stale running snapshots and sub-second intersections cannot prove overlap', () => { + assert.throws(() => assertNativeHoldOverlap([holdPart(), holdPart()]), CheckError); + assert.throws( + () => + assertNativeHoldOverlap([ + holdPart('completed', 1000, 3000), + holdPart('completed', 2500, 4000), + ]), + CheckError + ); +}); + +void test('exact native tool cancellation passes despite completed tool-step timestamp', () => { + assert.equal(hasNativeCancellation(cancelledTurn(), hold), true); +}); + +void test('pre-Stop read and completed timestamp do not prove native cancellation', () => { + const turn = cancelledTurn(); + turn.tools = [{ ...holdPart('completed'), id: 'read-part', tool: 'read' }]; + assert.equal(hasNativeCancellation(turn, hold), false); + turn.tools = [holdPart()]; + assert.equal(hasNativeCancellation(turn, hold), false); +}); + +void test('cancellation requires the captured part, call, assistant and start identity', () => { + for (const replacement of [ + { id: 'another-part' }, + { callID: 'another-call' }, + { messageID: 'another-assistant' }, + { state: { ...holdPart('error').state, status: 'error', time: { start: 2000, end: 3000 } } }, + ]) { + const part = { ...holdPart('error'), ...replacement }; + assert.equal(matchesNativeHold(part, hold), false); + assert.equal(hasNativeCancellation({ ...cancelledTurn(), tools: [part] }, hold), false); + } +}); + +void test('unrelated tool error is not cancellation without a matching MessageAbortedError', () => { + const turn = cancelledTurn(); + turn.tools = [ + { + ...holdPart('error'), + state: { ...holdPart('error').state, status: 'error', error: 'Command timed out' }, + }, + ]; + assert.equal(hasNativeCancellation(turn, hold), false); + turn.messages[0] = { + ...turn.messages[0], + id: hold.messageId, + sessionID: 'session', + role: 'assistant', + error: { name: 'MessageAbortedError' }, + }; + assert.equal(hasNativeCancellation(turn, hold), true); +}); + +function abortedShellTurn(): NativeTurn { + const turn = cancelledTurn(); + turn.messages[0] = { + ...turn.messages[0], + id: hold.messageId, + sessionID: 'session', + role: 'assistant', + error: { name: 'MessageAbortedError' }, + }; + turn.tools = [ + { + ...holdPart('completed', 1000, 1261), + state: { + ...holdPart('completed', 1000, 1261).state, + status: 'completed', + output: '(no output)\n\n\nUser aborted the command\n', + metadata: { exit: null }, + }, + }, + ]; + return turn; +} + +void test('Kilo shell abort metadata and matching assistant abort prove early cancellation', () => { + assert.equal(hasNativeCancellation(abortedShellTurn(), hold), true); +}); + +void test('shell abort result waits for matching assistant abort evidence', () => { + const turn = abortedShellTurn(); + turn.messages = []; + assert.equal(hasNativeCancellation(turn, hold), false); +}); + +for (const replacement of [ + { metadata: { exit: 0 } }, + { metadata: undefined }, + { output: 'User aborted the command' }, + { time: { start: 1000, end: 181000 } }, +]) { + void test(`ordinary completed hold cannot masquerade as shell abort: ${JSON.stringify(replacement)}`, () => { + const turn = abortedShellTurn(); + turn.tools = turn.tools.map(part => ({ + ...part, + state: { ...part.state, status: 'completed', ...replacement }, + })); + assert.throws(() => hasNativeCancellation(turn, hold), CheckError); + }); +} + +void test('successful stopped-tool completion fails even alongside assistant abort evidence', () => { + const turn = cancelledTurn(); + turn.tools.push(holdPart('completed')); + turn.messages.push({ + id: 'aborted', + sessionID: 'session', + role: 'assistant', + error: { name: 'MessageAbortedError' }, + }); + assert.throws(() => hasNativeCancellation(turn, hold), CheckError); +}); + +for (const finish of ['stop', 'end_turn', 'length', 'unknown']) { + void test(`later error-free ${finish} finish cannot be hidden by cancellation or a later abort update`, () => { + const turn = cancelledTurn(); + turn.messages.push({ + id: 'later', + sessionID: 'session', + role: 'assistant', + parentID: 'prompt', + finish, + }); + turn.messages.push({ + id: 'later', + sessionID: 'session', + role: 'assistant', + parentID: 'prompt', + finish, + error: { name: 'MessageAbortedError' }, + }); + assert.throws(() => assertNoNativeCompletion(turn, hold), CheckError); + }); +} diff --git a/services/cloud-agent-next/test/e2e/multichat-real-support.ts b/services/cloud-agent-next/test/e2e/multichat-real-support.ts new file mode 100644 index 0000000000..5d472f17f3 --- /dev/null +++ b/services/cloud-agent-next/test/e2e/multichat-real-support.ts @@ -0,0 +1,604 @@ +import { execFileSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { + closeSync, + constants, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readFileSync, + realpathSync, + renameSync, + writeFileSync, +} from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { parseEnv } from 'node:util'; +import jwt from 'jsonwebtoken'; +import WebSocket from 'ws'; +import { z } from 'zod'; + +export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../../..'); +export const MODEL = 'kilo-auto/efficient'; +export class CheckError extends Error {} +export class RpcHttpError extends CheckError { + readonly status: number; + + constructor(procedure: string, status: number) { + super(`${procedure}: HTTP ${status}; inspect private evidence`); + this.status = status; + } +} + +export function check(condition: unknown, message: string): asserts condition { + if (!condition) throw new CheckError(message); +} + +export function parse(schema: z.ZodType, value: unknown, label: string): T { + const result = schema.safeParse(value); + check(result.success, `${label}: invalid response shape`); + return result.data; +} + +export const sessionSchema = z.object({ + cloudAgentSessionId: z.string().regex(/^workspace_[0-9a-f-]{36}$/), + kiloSessionId: z.string().regex(/^ses_[A-Za-z0-9]{26}$/), + worktreeId: z.string().optional(), +}); +export type Chat = z.infer & { label: string }; + +export const messageSchema = z.object({ + id: z.string(), + sessionID: z.string(), + role: z.enum(['user', 'assistant']), + parentID: z.string().optional(), + error: z.object({ name: z.string() }).optional(), + finish: z.string().optional(), + time: z.object({ completed: z.number().optional() }).optional(), +}); +export const partSchema = z.object({ + id: z.string(), + sessionID: z.string(), + messageID: z.string(), + type: z.string(), + text: z.string().optional(), + synthetic: z.boolean().optional(), + tool: z.string().optional(), + callID: z.string().optional(), + state: z + .object({ + status: z.string(), + input: z.record(z.string(), z.unknown()).optional(), + output: z.string().optional(), + error: z.string().optional(), + metadata: z.object({ exit: z.number().nullable().optional() }).optional(), + time: z.object({ start: z.number(), end: z.number().optional() }).optional(), + }) + .optional(), +}); +export type Part = z.infer; +export type NativeTurn = { messages: z.infer[]; tools: Part[] }; +export type NativeHold = { + partId: string; + callId: string; + messageId: string; + startedAt: number; + seconds: number; +}; + +export function runningNativeHold(parts: Part[], seconds: number): NativeHold | undefined { + const part = parts.find( + value => + value.type === 'tool' && + value.tool === 'bash' && + value.state?.status === 'running' && + value.state.input?.command === `sleep ${seconds}` && + value.state.time?.end === undefined + ); + if (!part?.callID || part.state?.time?.start === undefined) return undefined; + return { + partId: part.id, + callId: part.callID, + messageId: part.messageID, + startedAt: part.state.time.start, + seconds, + }; +} + +export function matchesNativeHold(part: Part, hold: NativeHold): boolean { + return ( + part.type === 'tool' && + part.tool === 'bash' && + part.id === hold.partId && + part.callID === hold.callId && + part.messageID === hold.messageId && + part.state?.input?.command === `sleep ${hold.seconds}` && + part.state.time?.start === hold.startedAt + ); +} + +export function assertNativeHoldOverlap(parts: Part[]) { + check(parts.length >= 2, 'Native overlap requires multiple holds'); + const windows = parts.map(part => { + const time = part.state?.time; + check( + part.state?.status === 'completed' && time?.end !== undefined && time.end > time.start, + 'Native overlap requires completed tool intervals' + ); + return { start: time.start, end: time.end }; + }); + const start = Math.max(...windows.map(window => window.start)); + const end = Math.min(...windows.map(window => window.end)); + check(end - start >= 1000, 'Native hold intervals did not overlap for at least one second'); + return { start, end, overlapMs: end - start }; +} + +function isAbortedShellResult(part: Part, hold: NativeHold): boolean { + return ( + matchesNativeHold(part, hold) && + part.state?.status === 'completed' && + part.state.metadata?.exit === null && + /\s*User aborted the command\s*<\/shell_metadata>\s*$/.test( + part.state.output ?? '' + ) && + part.state.time?.end !== undefined && + part.state.time.end >= hold.startedAt && + part.state.time.end < hold.startedAt + hold.seconds * 1000 + ); +} + +export function assertNoNativeCompletion(turn: NativeTurn, hold: NativeHold) { + check( + !turn.tools.some( + part => + matchesNativeHold(part, hold) && + part.state?.status === 'completed' && + !isAbortedShellResult(part, hold) + ), + 'Stopped native hold completed successfully' + ); + check( + !turn.messages.some( + message => + message.role === 'assistant' && + !message.error && + message.finish && + !['tool-calls', 'tool_calls', 'tool_use'].includes(message.finish) + ), + 'Stopped turn emitted an error-free final finish' + ); +} + +export function hasNativeCancellation(turn: NativeTurn, hold: NativeHold): boolean { + assertNoNativeCompletion(turn, hold); + const aborted = turn.messages.some( + message => message.id === hold.messageId && message.error?.name === 'MessageAbortedError' + ); + return turn.tools.some( + part => + matchesNativeHold(part, hold) && + part.state?.time?.end !== undefined && + part.state.time.end >= hold.startedAt && + ((part.state.status === 'error' && + Boolean(part.state.error) && + (/abort|cancel|interrupt/i.test(part.state.error ?? '') || aborted)) || + (isAbortedShellResult(part, hold) && aborted)) + ); +} + +export async function until( + label: string, + timeoutMs: number, + signal: AbortSignal, + action: (remainingMs: number) => Promise +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + check(!signal.aborted, 'Run deadline or operator interruption; no mutations retried'); + const value = await action(deadline - Date.now()); + if (value !== undefined) return value; + await delay(Math.min(250, Math.max(1, deadline - Date.now())), undefined, { signal }); + } + throw new CheckError(`${label}: timed out; inspect evidence, do not resend ambiguous mutations`); +} + +export function setup(authPath: string, outPath: string) { + const authFile = resolve(ROOT, authPath); + check(realpathSync(dirname(authFile)) === dirname(authFile), 'Auth parent must not be symlinked'); + const fd = openSync(authFile, constants.O_RDONLY | constants.O_NOFOLLOW); + let auth; + try { + const stat = fstatSync(fd); + check( + stat.isFile() && (stat.mode & 0o777) === 0o600 && stat.uid === process.getuid?.(), + 'Auth must be an owned regular mode-600 file' + ); + auth = parse( + z.object({ token: z.string().min(1), userId: z.string().min(1) }).strict(), + JSON.parse(readFileSync(fd, 'utf8')), + 'Auth' + ); + } finally { + closeSync(fd); + } + const localVars = parseEnv( + readFileSync(resolve(ROOT, 'services/cloud-agent-next/.dev.vars'), 'utf8') + ); + const devVars = parse( + z.object({ NEXTAUTH_SECRET: z.string().min(1), INTERNAL_API_SECRET: z.string().min(1) }), + localVars, + 'Worker local secrets' + ); + const secrets = [ + auth.token, + ...Object.values(localVars).filter( + (value): value is string => typeof value === 'string' && value.length >= 8 + ), + ]; + function scrub(value: unknown): unknown { + if (typeof value === 'string') { + let text = value; + for (const secret of secrets) text = text.split(secret).join('[redacted]'); + return text + .replace( + /[\s\S]*?(?:<\/environment_details>|$)/gi, + '[environment omitted]' + ) + .replace( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?(?:-----END [^-]*PRIVATE KEY-----|$)/g, + '[redacted]' + ) + .replace(/\b(?:https?|wss?|postgres(?:ql)?|rediss?):\/\/[^\s"'<>]+/gi, '[url omitted]') + .replace(/\beyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+/g, '[redacted]') + .replace(/\b(?:Bearer|Basic)\s+[^\s"']+/gi, '[redacted]') + .replace(/\b(?:sk[-_]|gh[pousr]_|github_pat_|xox[baprs]-)[A-Za-z0-9_-]+/g, '[redacted]') + .replace( + /^\s*(?:export\s+)?[A-Z_][A-Z0-9_]*=[^\r\n]*/gm, + '[environment assignment omitted]' + ) + .replace( + /\b(?:credential\w*|token|secret|authorization|(?:set-)?cookie|password|api[_-]?key)\s*[=:]\s*[^\r\n]*/gi, + '[redacted]' + ); + } + if (Array.isArray(value)) return value.map(scrub); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + /credential|token|secret|authorization|cookie|password|api.?key|private.?key|ticket|headers|streamUrl|^env|^delta$/i.test( + key + ) + ? '[redacted]' + : scrub(item), + ]) + ); + } + return value; + } + const out = resolve(ROOT, outPath); + const parent = resolve(ROOT, 'dev/logs'); + check( + dirname(out) === parent && realpathSync(parent) === parent, + '--out must be a NEW direct child of this worktree dev/logs' + ); + execFileSync('git', ['check-ignore', '-q', `${relative(ROOT, out)}/report.json`], { + cwd: ROOT, + stdio: 'pipe', + }); + mkdirSync(out, { mode: 0o700 }); + check((lstatSync(out).mode & 0o777) === 0o700, 'Evidence directory must be private'); + function save(name: string, value: unknown) { + const temporary = resolve(out, `${name}.tmp`); + writeFileSync(temporary, JSON.stringify(scrub(value), null, 2), { mode: 0o600, flag: 'wx' }); + renameSync(temporary, resolve(out, name)); + } + function record(name: string, value: unknown) { + const file = openSync( + resolve(out, name), + constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW, + 0o600 + ); + try { + writeFileSync(file, `${JSON.stringify(scrub({ at: Date.now(), value }))}\n`); + } finally { + closeSync(file); + } + } + const status = parse( + z.object({ + services: z.array( + z + .object({ + name: z.string(), + port: z.number().int().min(1).max(65535), + status: z.string(), + }) + .or(z.object({ name: z.string(), port: z.literal(0), status: z.string() })) + ), + }), + JSON.parse( + execFileSync('pnpm', ['--silent', 'dev:status', '--json'], { + cwd: ROOT, + encoding: 'utf8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }) + ), + 'dev:status' + ); + function endpoint(name: string) { + const matches = status.services.filter(service => service.name === name); + const service = matches[0]; + check( + matches.length === 1 && service?.status === 'up' && service.port > 0, + `Required local service ${name} is not up` + ); + return `http://127.0.0.1:${service.port}`; + } + const base = { + web: endpoint('nextjs'), + worker: endpoint('cloud-agent-next'), + ingest: endpoint('cloudflare-session-ingest'), + }; + return { auth, devVars, out, save, record, base }; +} +export type Runtime = ReturnType & { signal: AbortSignal }; + +export function mintBootstrapToken(auth: Runtime['auth'], secret: string): string { + let verified: unknown; + try { + verified = jwt.verify(auth.token, secret, { algorithms: ['HS256'] }); + } catch { + throw new CheckError( + 'Bootstrap requires an unexpired fixture JWT verified by the local Worker NEXTAUTH_SECRET' + ); + } + const claims = parse( + z + .object({ + version: z.literal(3), + kiloUserId: z.string().min(1), + env: z.enum(['production', 'development']), + apiTokenPepper: z.string().nullable(), + tokenSource: z.literal('cloud-agent').optional(), + iat: z.number().int().nonnegative(), + exp: z.number().int().nonnegative(), + }) + .strict(), + verified, + 'Bootstrap personal fixture JWT (admin, scoped and unknown claims are rejected)' + ); + const now = Math.floor(Date.now() / 1000); + check( + claims.kiloUserId === auth.userId && claims.iat <= now && claims.exp > claims.iat, + 'Bootstrap fixture identity or issuance time mismatch' + ); + const expiresIn = Math.min(3600, claims.exp - now); + check(expiresIn >= 45 * 60, 'Bootstrap fixture must remain valid for the full 45-minute run'); + return jwt.sign( + { + env: 'development', + kiloUserId: claims.kiloUserId, + apiTokenPepper: claims.apiTokenPepper, + version: claims.version, + tokenSource: 'cloud-agent', + iat: now, + }, + secret, + { algorithm: 'HS256', expiresIn } + ); +} + +export async function rpc( + runtime: Runtime, + target: 'web' | 'worker', + procedure: string, + input: unknown, + method: 'GET' | 'POST' = 'POST', + timeoutMs = method === 'POST' ? 90_000 : 15_000, + internal = false +): Promise { + check(!runtime.signal.aborted, 'Run stopped before request'); + const url = new URL(`${target === 'web' ? '/api' : ''}/trpc/${procedure}`, runtime.base[target]); + if (method === 'GET' && input !== undefined) url.searchParams.set('input', JSON.stringify(input)); + const headers: Record = { + Authorization: `Bearer ${runtime.auth.token}`, + 'Content-Type': 'application/json', + }; + if (internal) { + check( + target === 'worker' && procedure === 'prepareSession' && method === 'POST', + 'Trusted bootstrap authentication is restricted to Worker prepareSession' + ); + headers.Authorization = `Bearer ${mintBootstrapToken(runtime.auth, runtime.devVars.NEXTAUTH_SECRET)}`; + headers['x-internal-api-key'] = runtime.devVars.INTERNAL_API_SECRET; + } + const requestId = randomUUID(); + if (method === 'POST') + runtime.record('mutations.jsonl', { requestId, target, procedure, input, status: 'intent' }); + let response; + let body: unknown; + try { + response = await fetch(url, { + method, + headers, + redirect: 'error', + body: method === 'POST' ? JSON.stringify(input) : undefined, + signal: AbortSignal.any([runtime.signal, AbortSignal.timeout(Math.max(1, timeoutMs))]), + }); + body = await response.json(); + } catch { + throw new CheckError( + `${procedure}: transport failure or deadline; mutation outcome may be ambiguous, never retried` + ); + } + runtime.record(method === 'POST' ? 'mutations.jsonl' : 'queries.jsonl', { + requestId, + procedure, + httpStatus: response.status, + response: body, + }); + if (!response.ok) throw new RpcHttpError(procedure, response.status); + return parse(z.object({ result: z.object({ data: z.unknown() }) }), body, procedure).result.data; +} + +export async function observe(runtime: Runtime, chat: Chat) { + const ticket = jwt.sign( + { + type: 'stream_ticket', + purpose: 'stream', + userId: runtime.auth.userId, + cloudAgentSessionId: chat.cloudAgentSessionId, + kiloSessionId: chat.kiloSessionId, + nonce: randomUUID(), + }, + runtime.devVars.NEXTAUTH_SECRET, + { algorithm: 'HS256', expiresIn: 600, audience: 'cloud-agent-stream' } + ); + const url = new URL('/stream', runtime.base.worker.replace('http:', 'ws:')); + url.searchParams.set('cloudAgentSessionId', chat.cloudAgentSessionId); + url.searchParams.set('ticket', ticket); + const ws = new WebSocket(url, { handshakeTimeout: 15_000, maxPayload: 4 * 1024 * 1024 }); + const messages = new Map>(); + const parts = new Map(); + const messageEvents: { sequence: number; info: z.infer }[] = []; + const toolEvents: { sequence: number; part: Part }[] = []; + const terminals = new Map(); + let connected = false; + let failure: string | undefined; + let closed = false; + let count = 0; + const stop = () => { + closed = true; + ws.terminate(); + }; + runtime.signal.addEventListener('abort', stop, { once: true }); + ws.on('error', () => { + failure = 'stream transport error'; + }); + ws.on('close', () => { + if (!closed) failure = 'stream closed unexpectedly'; + }); + ws.on('message', raw => { + try { + check(++count <= 100_000, 'stream event limit exceeded'); + const buffer = Buffer.isBuffer(raw) + ? raw + : Array.isArray(raw) + ? Buffer.concat(raw) + : Buffer.from(raw); + const event: unknown = JSON.parse(buffer.toString('utf8')); + const envelope = parse( + z.object({ + streamEventType: z.string().optional(), + type: z.string().optional(), + sessionId: z.string().optional(), + data: z.unknown().optional(), + }), + event, + 'Stream envelope' + ); + check( + !envelope.sessionId || envelope.sessionId === chat.cloudAgentSessionId, + 'foreign cloud session on stream' + ); + const type = envelope.streamEventType ?? envelope.type; + if (type !== 'kilocode') + runtime.record(`${chat.label}-events.jsonl`, { sequence: count, event }); + if (type === 'connected') connected = true; + if (type === 'cloud.message.completed' || type === 'cloud.message.failed') { + const data = parse( + z.object({ messageId: z.string(), status: z.string().optional() }), + envelope.data, + 'Terminal event' + ); + const previous = terminals.get(data.messageId); + check( + !previous || (previous.type === type && previous.status === data.status), + 'conflicting terminal events for one message' + ); + terminals.set(data.messageId, { type, status: data.status, at: Date.now() }); + } + if (type !== 'kilocode') return; + const data = parse( + z.object({ + type: z.string().optional(), + event: z.string().optional(), + properties: z.record(z.string(), z.unknown()).optional(), + }), + envelope.data, + 'Kilo event' + ); + const eventType = data.type ?? data.event; + if (eventType === 'message.part.delta') return; + runtime.record(`${chat.label}-events.jsonl`, { sequence: count, event }); + if (eventType === 'message.updated') { + const info = parse(messageSchema, data.properties?.info, 'Stream message'); + check(info.sessionID === chat.kiloSessionId, 'foreign Kilo message on stream'); + messages.set(info.id, info); + if (info.finish || info.error) messageEvents.push({ sequence: count, info }); + } + if (eventType === 'message.part.updated') { + const part = parse(partSchema, data.properties?.part, 'Stream part'); + check(part.sessionID === chat.kiloSessionId, 'foreign Kilo part on stream'); + parts.set(part.id, part); + if (part.type === 'tool' && ['completed', 'error'].includes(part.state?.status ?? '')) { + toolEvents.push({ sequence: count, part }); + } + } + } catch (error) { + failure = + error instanceof CheckError + ? error.message + : 'invalid stream frame or evidence write failure'; + ws.terminate(); + } + }); + function healthy() { + check(!failure && !closed, `${chat.label}: ${failure ?? 'observer stopped'}`); + } + try { + await until(`${chat.label} stream readiness`, 15_000, runtime.signal, async () => { + healthy(); + return connected ? true : undefined; + }); + } catch (error) { + stop(); + runtime.signal.removeEventListener('abort', stop); + throw error; + } + return { + healthy, + terminals, + sequence: () => count, + nativeEvents(messageId: string, afterSequence = 0): NativeTurn { + healthy(); + return { + messages: messageEvents + .filter(event => event.sequence > afterSequence && event.info.parentID === messageId) + .map(event => event.info), + tools: toolEvents + .filter( + event => + event.sequence > afterSequence && + messages.get(event.part.messageID)?.parentID === messageId + ) + .map(event => event.part), + }; + }, + tools(messageId: string) { + healthy(); + return [...parts.values()].filter( + part => part.type === 'tool' && messages.get(part.messageID)?.parentID === messageId + ); + }, + close() { + runtime.signal.removeEventListener('abort', stop); + stop(); + }, + }; +} +export type Observer = Awaited>; diff --git a/services/cloud-agent-next/test/e2e/multichat-real.ts b/services/cloud-agent-next/test/e2e/multichat-real.ts new file mode 100644 index 0000000000..302496d672 --- /dev/null +++ b/services/cloud-agent-next/test/e2e/multichat-real.ts @@ -0,0 +1,797 @@ +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; +import { z } from 'zod'; +import { createMessageId } from '../../src/session/message-id.js'; +import { + CheckError, + MODEL, + RpcHttpError, + assertNativeHoldOverlap, + assertNoNativeCompletion, + hasNativeCancellation, + matchesNativeHold, + runningNativeHold, + check, + messageSchema, + observe, + parse, + partSchema, + rpc, + sessionSchema, + setup, + until, + type Chat, + type NativeHold, + type Observer, + type Part, + type Runtime, +} from './multichat-real-support.js'; + +const resultSchema = z.object({ + cloudAgentSessionId: z.string(), + messageId: z.string(), + status: z.enum(['queued', 'running', 'completed', 'failed', 'interrupted']), + failure: z.unknown().optional(), +}); +const transcriptSchema = z.object({ + info: z.object({ id: z.string() }), + messages: z.array(z.object({ info: messageSchema, parts: z.array(partSchema) })), +}); +type Turn = { + chat: Chat; + messageId: string; + tag: string; + prompt: string; + expected: 'completed' | 'interrupted'; + marker: string; + write: boolean; + admittedAt?: number; + hold?: NativeHold; + stop?: { hold: NativeHold; sequence: number; requestedAt: number }; + result?: z.infer; +}; + +function fileTool(part: Part, tool: 'read' | 'write', filename: string, marker: string): boolean { + const path = part.state?.input?.filePath; + return ( + part.tool === tool && + part.state?.status === 'completed' && + typeof path === 'string' && + (path === filename || path.endsWith(`/${filename}`)) && + (tool === 'write' + ? part.state.input?.content === `${marker}\n` + : part.state.output?.includes(marker) === true) + ); +} + +async function run(runtime: Runtime, rounds: number, turnTimeoutMs: number) { + const runId = randomUUID(); + const filename = `multichat-real-${runId}.txt`; + const observers = new Map(); + const chats: Chat[] = []; + const turns: Turn[] = []; + const phases: { name: string; status: 'running' | 'passed' | 'failed' }[] = []; + let status: 'running' | 'passed' | 'failed' = 'running'; + const limits = [ + 'API-assisted public-Git bootstrap uses trusted Worker prepareSession and the generateCloudAgentToken claim shape, minted from verified local personal fixture claims with a maximum one-hour lifetime; not browser repository selection.', + 'Sibling creation, follow-ups and Stop use real personal web tRPC routes and real kilo-auto/efficient inference; no balance bypass.', + 'Streams start after initial IDs are minted; initial evidence may be replayed. Follow-up observers are ready before submission.', + 'Fresh worktree plus immediate follow-ups; no forced idle, sandbox loss, provider replacement, or physical identity assertions.', + 'Native 180-second writer and 120-second reader holds must overlap in live state and persisted intervals for at least one second; missed overlap fails without retry.', + 'Stop requires a post-request error/cancellation for the captured native hold; event and transcript audits continue through other work and the full 180-second hold duration plus 30 seconds after Stop.', + 'Browser reload, UI controls, orgs, private git credentials and billing attribution inspection remain operator coverage.', + 'Only owned observers are closed. Chats, files and sandboxes remain for the primary operator; no cleanup or resumption mutations.', + ]; + function persist() { + runtime.save('report.json', { + runId, + model: MODEL, + status, + filename, + userId: runtime.auth.userId, + rounds, + turnTimeoutMs, + chats, + turns, + phases, + limits, + }); + } + async function phase(name: string, action: () => Promise): Promise { + const entry = { name, status: 'running' as 'running' | 'passed' | 'failed' }; + phases.push(entry); + persist(); + try { + const value = await action(); + entry.status = 'passed'; + persist(); + console.log(`${name}: passed`); + return value; + } catch (error) { + entry.status = 'failed'; + throw error; + } + } + function observer(chat: Chat) { + const value = observers.get(chat.label); + check(value, `${chat.label}: no ready observer`); + value.healthy(); + return value; + } + function intent( + chat: Chat, + instruction: string, + marker: string, + write = false, + expected: Turn['expected'] = 'completed' + ) { + const tag = `turn-${randomUUID()}`; + const turn: Turn = { + chat, + messageId: createMessageId(), + tag, + prompt: `Request ${tag}. ${instruction} Do not delegate, commit, push, inspect credentials, or modify any other files.`, + marker, + write, + expected, + }; + turns.push(turn); + persist(); + return turn; + } + async function send( + chat: Chat, + instruction: string, + marker: string, + write = false, + expected: Turn['expected'] = 'completed' + ) { + observer(chat); + const turn = intent(chat, instruction, marker, write, expected); + const response = parse( + z.object({ + cloudAgentSessionId: z.string(), + messageId: z.string(), + delivery: z.enum(['sent', 'queued']), + }), + await rpc(runtime, 'web', 'cloudAgentNext.sendMessage', { + cloudAgentSessionId: chat.cloudAgentSessionId, + messageId: turn.messageId, + payload: { type: 'prompt', prompt: turn.prompt, mode: 'code', model: MODEL }, + autoCommit: false, + }), + 'sendMessage' + ); + check( + response.messageId === turn.messageId && + response.cloudAgentSessionId === chat.cloudAgentSessionId, + `${chat.label}: admission identity mismatch` + ); + turn.admittedAt = Date.now(); + persist(); + return turn; + } + async function result(turn: Turn, remainingMs = 15_000) { + const value = parse( + resultSchema, + await rpc( + runtime, + 'worker', + 'getMessageResult', + { + cloudAgentSessionId: turn.chat.cloudAgentSessionId, + messageId: turn.messageId, + }, + 'GET', + Math.min(15_000, remainingMs) + ), + 'getMessageResult' + ); + check( + value.messageId === turn.messageId && + value.cloudAgentSessionId === turn.chat.cloudAgentSessionId, + `${turn.chat.label}: result identity mismatch` + ); + if (turn.result?.status !== value.status) { + turn.result = value; + persist(); + } + return value; + } + async function settle(turn: Turn) { + const stream = observer(turn.chat); + await until( + `${turn.chat.label} ${turn.messageId} terminal`, + turnTimeoutMs, + runtime.signal, + async remaining => { + stream.healthy(); + const value = await result(turn, remaining); + if (value.status === 'queued' || value.status === 'running') return undefined; + check( + value.status === turn.expected, + `${turn.chat.label} ${turn.messageId}: expected ${turn.expected}, got ${value.status}` + ); + const event = stream.terminals.get(turn.messageId); + if (!event) return undefined; + check( + event.type === + (turn.expected === 'completed' ? 'cloud.message.completed' : 'cloud.message.failed'), + `${turn.chat.label}: streamed terminal disagrees with durable result` + ); + if (event.status) + check(event.status === turn.expected, `${turn.chat.label}: streamed status mismatch`); + return true; + } + ); + } + async function all(actions: Promise[]): Promise { + const settled = await Promise.allSettled(actions); + const failed = settled.find(item => item.status === 'rejected'); + if (failed?.status === 'rejected') throw failed.reason; + return settled.flatMap(item => (item.status === 'fulfilled' ? [item.value] : [])); + } + function holdPrompt(seconds: number) { + return `Then, as a separate foreground bash tool call, run exactly sleep ${seconds} with timeout ${(seconds + 60) * 1000} milliseconds. Wait for it to finish; do not background it. `; + } + function nativeStopObserved(turn: Turn) { + check(turn.stop, 'Stopped turn is missing its native tool identity'); + const stream = observer(turn.chat); + assertNoNativeCompletion(stream.nativeEvents(turn.messageId), turn.stop.hold); + return ( + hasNativeCancellation( + stream.nativeEvents(turn.messageId, turn.stop.sequence), + turn.stop.hold + ) && + hasNativeCancellation( + { + messages: stream.nativeEvents(turn.messageId).messages, + tools: stream.tools(turn.messageId), + }, + turn.stop.hold + ) + ); + } + function readPrompt(holdSeconds = 0) { + return ( + `Use the read tool to read ${filename} in the repository root NOW. Do not guess its contents from conversation history. ` + + (holdSeconds ? holdPrompt(holdSeconds) : '') + + 'Reply with the exact file contents you read. Do not create or modify files.' + ); + } + async function transcript(chat: Chat) { + return until(`${chat.label} transcript audit`, 90_000, runtime.signal, async remaining => { + const response = await fetch( + new URL(`/api/session/${chat.kiloSessionId}/export`, runtime.base.ingest), + { + headers: { Authorization: `Bearer ${runtime.auth.token}` }, + redirect: 'error', + signal: AbortSignal.any([ + runtime.signal, + AbortSignal.timeout(Math.max(1, Math.min(15_000, remaining))), + ]), + } + ); + check(response.ok, `${chat.label}: export HTTP ${response.status}`); + const data: unknown = await response.json(); + runtime.save(`${chat.label}-export.json`, data); + const parsed = transcriptSchema.safeParse(data); + if (!parsed.success) return undefined; + const exported = parsed.data; + check( + exported.info.id === chat.kiloSessionId, + `${chat.label}: export root identity mismatch` + ); + const ids = new Set(); + const partIds = new Set(); + const ownTurns = turns.filter(turn => turn.chat.label === chat.label); + const foreignTurns = turns.filter(turn => turn.chat.label !== chat.label); + for (const message of exported.messages) { + check(!ids.has(message.info.id), `${chat.label}: duplicate native message ID`); + ids.add(message.info.id); + check( + message.info.sessionID === chat.kiloSessionId, + `${chat.label}: foreign transcript message` + ); + check( + !foreignTurns.some( + turn => turn.messageId === message.info.id || turn.messageId === message.info.parentID + ), + `${chat.label}: sibling message leaked into transcript` + ); + const text = message.parts + .filter(part => part.type === 'text') + .map(part => part.text ?? '') + .join('\n'); + check( + !foreignTurns.some(turn => text.includes(turn.tag)), + `${chat.label}: sibling request text leaked` + ); + if (message.info.role === 'user') { + check( + ownTurns.some(turn => turn.messageId === message.info.id), + `${chat.label}: unexpected user message (possible duplicate admission)` + ); + } else { + check( + ownTurns.some(turn => turn.messageId === message.info.parentID), + `${chat.label}: orphan assistant message` + ); + } + for (const part of message.parts) { + check(!partIds.has(part.id), `${chat.label}: duplicate native part ID`); + partIds.add(part.id); + check( + part.sessionID === chat.kiloSessionId && part.messageID === message.info.id, + `${chat.label}: part identity mismatch` + ); + } + } + for (const turn of ownTurns) { + const user = exported.messages.find( + message => message.info.id === turn.messageId && message.info.role === 'user' + ); + if ( + !user || + !user.parts.some(part => part.type === 'text' && part.text?.includes(turn.prompt)) + ) + return undefined; + const assistants = exported.messages.filter( + message => message.info.role === 'assistant' && message.info.parentID === turn.messageId + ); + const parts = assistants.flatMap(message => message.parts); + if (turn.expected === 'interrupted') { + check(turn.stop, 'Interrupted turn has no saved native Stop identity'); + const cancelled = hasNativeCancellation( + { messages: assistants.map(message => message.info), tools: parts }, + turn.stop.hold + ); + if ( + !nativeStopObserved(turn) || + !cancelled || + !parts.some(part => fileTool(part, 'read', filename, turn.marker)) + ) + return undefined; + continue; + } + check( + !assistants.some(message => message.info.error), + `${chat.label}: assistant error in successful turn` + ); + check( + !parts.some(part => part.type === 'tool' && part.state?.status === 'error'), + `${chat.label}: failed tool in successful turn` + ); + if ( + !assistants.some( + message => + message.info.time?.completed && + message.parts.some( + part => part.type === 'text' && !part.synthetic && part.text?.includes(turn.marker) + ) + ) + ) + return undefined; + if (!parts.some(part => fileTool(part, 'read', filename, turn.marker))) return undefined; + if (turn.write && !parts.some(part => fileTool(part, 'write', filename, turn.marker))) + return undefined; + } + runtime.record('audits.jsonl', { + chat: chat.label, + kiloSessionId: chat.kiloSessionId, + messages: ids.size, + parts: partIds.size, + turns: ownTurns.length, + passed: true, + }); + return exported; + }); + } + async function waitForHolds(entries: { turn: Turn; seconds: number }[], label: string) { + const holds = await until(label, turnTimeoutMs, runtime.signal, async remaining => { + const values = await all(entries.map(({ turn }) => result(turn, remaining))); + check( + values.every(value => value.status === 'queued' || value.status === 'running'), + `${label}: turn terminalized before native overlap` + ); + const observed = entries.map(({ turn, seconds }) => { + const parts = observer(turn.chat).tools(turn.messageId); + const hold = turn.hold ?? runningNativeHold(parts, seconds); + if (!hold) return undefined; + const active = parts.find(part => matchesNativeHold(part, hold)); + check( + active?.state?.status === 'running', + `${label}: captured native hold ended before overlap` + ); + const read = parts.find(part => fileTool(part, 'read', filename, turn.marker)); + if (read?.state?.time?.end === undefined || read.state.time.end > hold.startedAt) + return undefined; + if (turn.write) { + const write = parts.find(part => fileTool(part, 'write', filename, turn.marker)); + if (write?.state?.time?.end === undefined || write.state.time.end > hold.startedAt) + return undefined; + } + return hold; + }); + if (values.some(value => value.status !== 'running') || observed.some(hold => !hold)) + return undefined; + return observed.filter((hold): hold is NativeHold => hold !== undefined); + }); + entries.forEach(({ turn }, index) => { + turn.hold = holds[index]; + }); + persist(); + runtime.record('barriers.jsonl', { + label, + holds, + messageIds: entries.map(({ turn }) => turn.messageId), + }); + } + async function sibling(source: Chat, label: string) { + const created = parse( + sessionSchema, + await rpc(runtime, 'web', 'cloudAgentNext.createWorktreeChat', { + sourceKiloSessionId: source.kiloSessionId, + operationKey: randomUUID(), + }), + 'createWorktreeChat' + ); + const chat = { ...created, label }; + chats.push(chat); + persist(); + check( + chat.worktreeId === source.worktreeId && chat.worktreeId, + `${label}: sibling worktree mismatch` + ); + check( + chats.filter( + other => + other.kiloSessionId === chat.kiloSessionId || + other.cloudAgentSessionId === chat.cloudAgentSessionId + ).length === 1, + `${label}: duplicate chat identity` + ); + observers.set(label, await observe(runtime, chat)); + return chat; + } + persist(); + try { + const eligibility = parse( + z.object({ balance: z.number(), isEligible: z.boolean() }), + await rpc(runtime, 'web', 'cloudAgentNext.checkEligibility', undefined, 'GET'), + 'checkEligibility' + ); + check( + eligibility.isEligible && eligibility.balance >= 1, + 'Existing personal account must have at least $1; no account or balance is modified' + ); + const marker = `shared-${randomUUID()}`; + const writer = await phase('cold-writer', async () => { + const operationKey = randomUUID(); + const messageId = createMessageId(); + const tag = `turn-${randomUUID()}`; + const prompt = `Request ${tag}. Use the write tool to create ${filename} in the repository root containing exactly ${marker} followed by a newline. Use the read tool to read it back, then reply with its exact contents. Do not delegate, commit, push, inspect credentials, or modify other files.`; + runtime.record('initial-intent.jsonl', { operationKey, messageId, tag, prompt }); + const created = parse( + sessionSchema, + await rpc( + runtime, + 'worker', + 'prepareSession', + { + prompt, + initialMessageId: messageId, + operationKey, + mode: 'code', + model: MODEL, + gitUrl: 'https://github.com/octocat/Hello-World.git', + shallow: true, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + autoInitiate: true, + autoCommit: false, + }, + 'POST', + 90_000, + true + ), + 'prepareSession' + ); + const chat: Chat = { ...created, label: 'writer' }; + chats.push(chat); + const turn: Turn = { + chat, + messageId, + tag, + prompt, + marker, + write: true, + expected: 'completed', + admittedAt: Date.now(), + }; + turns.push(turn); + persist(); + const snapshot = parse( + z.object({ + kiloSessionId: z.string(), + initialMessageId: z.string(), + }), + await rpc( + runtime, + 'worker', + 'getSession', + { cloudAgentSessionId: chat.cloudAgentSessionId }, + 'GET' + ), + 'getSession' + ); + check( + snapshot.kiloSessionId === chat.kiloSessionId && snapshot.initialMessageId === messageId, + 'Initial Worker session identity mismatch' + ); + observers.set(chat.label, await observe(runtime, chat)); + chat.worktreeId = await until( + 'Authoritative web worktree ownership', + 90_000, + runtime.signal, + async remaining => { + observer(chat); + let data: unknown; + try { + data = await rpc( + runtime, + 'web', + 'cliSessionsV2.get', + { session_id: chat.kiloSessionId }, + 'GET', + Math.min(15_000, remaining) + ); + } catch (error) { + if (error instanceof RpcHttpError && error.status === 404) return undefined; + throw error; + } + const session = parse( + z.object({ + session_id: z.string(), + kilo_user_id: z.string(), + organization_id: z.null(), + cloud_agent_session_id: z.string().nullable(), + cloud_agent_worktree_id: z.string().min(1).nullable(), + }), + data, + 'cliSessionsV2.get' + ); + check( + session.session_id === chat.kiloSessionId && + session.kilo_user_id === runtime.auth.userId, + 'Initial web session ownership mismatch' + ); + check( + session.cloud_agent_session_id === null || + session.cloud_agent_session_id === chat.cloudAgentSessionId, + 'Initial web Cloud Agent identity mismatch' + ); + if (!session.cloud_agent_session_id || !session.cloud_agent_worktree_id) return undefined; + return session.cloud_agent_worktree_id; + } + ); + persist(); + await settle(turn); + await transcript(chat); + return chat; + }); + await phase('warm-writer-followup', async () => { + await settle(await send(writer, readPrompt(), marker)); + await transcript(writer); + }); + const readers: Chat[] = []; + for (const label of ['reader-b', 'reader-c']) { + readers.push( + await phase(`serial-${label}`, async () => { + const chat = await sibling(writer, label); + await settle(await send(chat, readPrompt(), marker)); + await transcript(chat); + return chat; + }) + ); + } + let latestMarker = marker; + for (let round = 1; round <= rounds; round++) { + await phase(`overlap-${round}`, async () => { + latestMarker = `shared-${randomUUID()}`; + const writing = await send( + writer, + `Use the write tool to replace ${filename} in the repository root with exactly ${latestMarker} followed by a newline. Use the read tool to read it back. ${holdPrompt(180)}Reply with the exact contents.`, + latestMarker, + true + ); + await waitForHolds( + [{ turn: writing, seconds: 180 }], + 'successful-write-and-active-writer-hold-before-readers' + ); + const reading = await all(readers.map(chat => send(chat, readPrompt(120), latestMarker))); + const overlapping = [writing, ...reading]; + await waitForHolds( + [{ turn: writing, seconds: 180 }, ...reading.map(turn => ({ turn, seconds: 120 }))], + 'three-native-holds-active' + ); + await all(overlapping.map(settle)); + const heldParts = await all( + overlapping.map(async turn => { + const history = await transcript(turn.chat); + const hold = turn.hold; + check(hold, 'Overlap has no captured native hold'); + const part = history.messages + .filter(message => message.info.parentID === turn.messageId) + .flatMap(message => message.parts) + .find(part => matchesNativeHold(part, hold)); + check(part, 'Captured overlap hold is missing from persisted transcript'); + return part; + }) + ); + runtime.record('barriers.jsonl', { + label: 'persisted-native-hold-overlap', + ...assertNativeHoldOverlap(heldParts), + partIds: heldParts.map(part => part.id), + }); + }); + } + await phase('stop-one-sibling-continues', async () => { + const survivor = readers[0]; + check(survivor, 'Missing sibling'); + const [stopped, continuing] = await all([ + send(writer, readPrompt(180), latestMarker, false, 'interrupted'), + send(survivor, readPrompt(180), latestMarker), + ]); + check(stopped && continuing, 'Missing admitted stop turns'); + await waitForHolds( + [stopped, continuing].map(turn => ({ turn, seconds: 180 })), + 'both-native-holds-active-before-stop' + ); + check(stopped.hold && continuing.hold, 'Stop requires captured native hold identities'); + stopped.stop = { + hold: stopped.hold, + sequence: observer(writer).sequence(), + requestedAt: Date.now(), + }; + persist(); + const interrupted = parse( + z.object({ success: z.boolean() }), + await rpc(runtime, 'web', 'cloudAgentNext.interruptSession', { + sessionId: writer.cloudAgentSessionId, + }), + 'interruptSession' + ); + check(interrupted.success, 'Stop was rejected'); + await settle(stopped); + await until('Exact native hold cancellation after Stop', 90_000, runtime.signal, async () => + nativeStopObserved(stopped) ? true : undefined + ); + const continuingHold = continuing.hold; + check( + (await result(continuing)).status === 'running' && + observer(survivor) + .tools(continuing.messageId) + .some( + part => matchesNativeHold(part, continuingHold) && part.state?.status === 'running' + ), + 'Sibling native hold did not remain active after Stop' + ); + runtime.record('barriers.jsonl', { + label: 'sibling-running-after-stop', + stopped: stopped.messageId, + continuing: continuing.messageId, + }); + await settle(continuing); + await all([writer, survivor].map(transcript)); + }); + await phase('same-chat-followups-after-stop', async () => { + const followups = await all(chats.map(chat => send(chat, readPrompt(), latestMarker))); + await all(followups.map(settle)); + await all(chats.map(transcript)); + for (const turn of turns) + check( + (await result(turn)).status === turn.expected, + 'Prior terminal outcome changed after follow-up' + ); + }); + await phase('native-stop-final-recheck', async () => { + for (const turn of turns.filter(value => value.stop)) { + const stop = turn.stop; + check(stop, 'Missing native Stop evidence'); + const recheckAt = stop.requestedAt + (stop.hold.seconds + 30) * 1000; + await until( + 'Observe beyond stopped hold natural duration', + Math.max(1, recheckAt - Date.now()) + 5000, + runtime.signal, + async () => { + check( + nativeStopObserved(turn), + 'Native cancellation evidence disappeared after other work' + ); + return Date.now() >= recheckAt ? true : undefined; + } + ); + await transcript(turn.chat); + check( + (await result(turn)).status === 'interrupted' && nativeStopObserved(turn), + 'Stopped turn did not remain natively cancelled' + ); + runtime.record('audits.jsonl', { + label: 'native-stop-final-recheck', + messageId: turn.messageId, + stop, + passed: true, + }); + } + }); + for (const stream of observers.values()) stream.healthy(); + status = 'passed'; + } catch (error) { + status = 'failed'; + runtime.record('failure.jsonl', { + message: + error instanceof CheckError + ? error.message + : 'Unexpected driver, export, or filesystem error; no mutation retried', + }); + throw error; + } finally { + for (const stream of observers.values()) stream.close(); + persist(); + } +} + +async function main() { + const { values } = parseArgs({ + options: { + auth: { type: 'string' }, + out: { type: 'string' }, + rounds: { type: 'string', default: '3' }, + 'turn-timeout-seconds': { type: 'string', default: '360' }, + help: { type: 'boolean' }, + }, + }); + if (values.help) { + console.log( + 'pnpm exec tsx services/cloud-agent-next/test/e2e/multichat-real.ts --auth [--out dev/logs/] [--rounds 3] [--turn-timeout-seconds 360]' + ); + console.log( + 'Real paid local personal-account run; existing funded/enrolled account and active stack required. Discovers ports with dev:status. Never retries mutations or cleans sandboxes. Browser reload is manual.' + ); + return; + } + check(values.auth, '--auth is required; token must not be passed on the command line'); + const rounds = Number(values.rounds); + const timeoutSeconds = Number(values['turn-timeout-seconds']); + check(Number.isInteger(rounds) && rounds >= 2 && rounds <= 5, '--rounds must be 2–5'); + check( + Number.isInteger(timeoutSeconds) && timeoutSeconds >= 240 && timeoutSeconds <= 600, + '--turn-timeout-seconds must be 240–600 to cover native holds' + ); + const local = setup(values.auth, values.out ?? `dev/logs/multichat-real-${randomUUID()}`); + console.log(`Private evidence: ${local.out}`); + const controller = new AbortController(); + const stop = () => controller.abort(); + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + const timer = setTimeout(stop, 45 * 60_000); + try { + await run({ ...local, signal: controller.signal }, rounds, timeoutSeconds * 1000); + console.log(`PASS: ${resolve(local.out, 'report.json')}`); + } finally { + clearTimeout(timer); + process.removeListener('SIGINT', stop); + process.removeListener('SIGTERM', stop); + } +} + +void main().catch(error => { + console.error( + error instanceof CheckError + ? error.message + : 'Driver failed; inspect private evidence. No mutation was retried.' + ); + process.exitCode = 1; +}); diff --git a/services/cloud-agent-next/test/integration/sandbox-control.test.ts b/services/cloud-agent-next/test/integration/sandbox-control.test.ts index a0eb8bea2a..6830078265 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control.test.ts @@ -81,7 +81,10 @@ import { type PhysicalRecord, } from '../../src/sandbox-control/physical-lifecycle.js'; import type { ProviderAdapter, ProviderCreateIntent } from '../../src/sandbox-control/provider.js'; -import { applyReportedSessionState } from '../../src/sandbox-control/session-routes.js'; +import { + applyReportedSessionState, + attachRoute, +} from '../../src/sandbox-control/session-routes.js'; import { SESSION_DELIVERY_TIMEOUT_MS } from '../../src/sandbox-session/control-dispatch.js'; import { createVercelProviderAdapter, @@ -5527,6 +5530,12 @@ describe('SandboxControl durable remainder', () => { const stub = env.SANDBOX_CONTROL.getByName('sbx__control_routes'); await runInDurableObject(stub, async (instance, state) => { await instance.initializeOwner('owner_1'); + await instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); const route = await attachGrantedSession(instance, state, { sessionId: GRANT_SESSION_ID, kiloSessionId: ROOT_ID, @@ -6514,6 +6523,7 @@ describe('SandboxSession control-plane regressions', () => { { ...beforeDelivery.messages[1], state: 'accepted', + unresolvedDispatch: undefined, wrapperInstanceId: replacementFixture.wrapperInstanceId, }, ]); @@ -8638,12 +8648,16 @@ describe('SandboxControl terminal runtime coordination', () => { await runInDurableObject(control, async (instance, state) => { await instance.initializeOwner(input.ownerId); - await attachGrantedSession(instance, state, { + const attachment = { sessionId: input.sessionId, kiloSessionId: ROOT_ID, directory: '/workspace/terminal', ownerId: input.ownerId, - }); + }; + await seedGrant(instance, state, attachment); + const routes = await loadRouteTable(state.storage); + attachRoute(routes, attachment, input.ownerId); + await saveRouteTable(state.storage, routes); await expect(instance.validateTerminalAccess(input)).resolves.toEqual({ allowed: false, reason: 'runtime_not_running', @@ -8940,6 +8954,12 @@ describe('SandboxControl worktree routes', () => { const routes = await runInDurableObject(stub, async instance => { await instance.initializeOwner('owner_1'); + await instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); await Promise.all([ attachGrantedSession(instance, instance['ctx'], groupedRoute(GRANT_SESSION_ID, ROOT_ID)), attachGrantedSession( @@ -8965,6 +8985,12 @@ describe('SandboxControl worktree routes', () => { await runInDurableObject(stub, async instance => { await instance.initializeOwner('owner_1'); + await instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); await attachGrantedSession( instance, instance['ctx'], @@ -9008,6 +9034,12 @@ describe('SandboxControl worktree routes', () => { await runInDurableObject(stub, async (instance, state) => { await instance.initializeOwner('owner_1'); + await instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); await state.storage.put('session_routes', [ { sessionId: 'workspace_legacy', @@ -9140,6 +9172,12 @@ describe('SandboxControl targeted detach', () => { await runInDurableObject(stub, async (instance, state) => { await instance.initializeOwner('owner_1'); + await instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); await attachGrantedSession( instance, instance['ctx'], @@ -10075,7 +10113,12 @@ describe('SandboxSession root-owned terminal events', () => { const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); await runInDurableObject(control, async instance => { await instance.initializeOwner(ownerId); - await instance.claimCreate('grouped-terminal-intent'); + await instance.claimCreate( + 'grouped-terminal-intent', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); await attachGrantedSession(instance, instance['ctx'], groupedRoute(sessionId, root, ownerId)); await attachGrantedSession( instance, @@ -10909,6 +10952,12 @@ describe('SandboxSession targeted deletion', () => { const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); await runInDurableObject(control, async instance => { await instance.initializeOwner(ownerId); + await instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); await attachGrantedSession( instance, instance['ctx'], diff --git a/services/cloud-agent-next/test/unit/wrapper/worktree-credential-refresh.test.ts b/services/cloud-agent-next/test/unit/wrapper/worktree-credential-refresh.test.ts new file mode 100644 index 0000000000..70a07612d8 --- /dev/null +++ b/services/cloud-agent-next/test/unit/wrapper/worktree-credential-refresh.test.ts @@ -0,0 +1,805 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs/promises'; +import { createWrapperKiloClient, type WrapperKiloClient } from '../../../wrapper/src/kilo-api.js'; +import { applySessionAttach } from '../../../wrapper/src/control/apply-attach.js'; +import { + createWorktreeKiloRuntimes, + type WorktreeKiloAuth, + type WorktreeKiloRuntimes, +} from '../../../wrapper/src/control/worktree-runtime.js'; +import { + directoryForSession, + resetSessionDirectoryState, +} from '../../../wrapper/src/control/session-directories.js'; +import { resetDirectoryOperationState } from '../../../wrapper/src/control/worktree-operations.js'; +import { + createSessionActivityRegistry, + handleControlRequest, + refreshHeartbeatPayload, + type HandlerDeps, +} from '../../../wrapper/src/control/sandbox-control-handlers.js'; +import { startSandboxControlEventFeed } from '../../../wrapper/src/control/sandbox-control-runtime.js'; +import type * as ControlRuntimeModule from '../../../wrapper/src/control/sandbox-control-runtime.js'; +import type * as UtilsModule from '../../../wrapper/src/utils.js'; + +vi.mock('node:fs/promises', () => ({ + default: { mkdir: vi.fn(async () => undefined), writeFile: vi.fn(async () => undefined) }, +})); +vi.mock('@kilocode/sdk', () => ({ createKiloClient: vi.fn(() => ({})) })); +vi.mock('../../../wrapper/src/kilo-api.js', () => ({ createWrapperKiloClient: vi.fn() })); +vi.mock('../../../wrapper/src/control/sandbox-control-runtime.js', async importOriginal => ({ + ...(await importOriginal()), + startSandboxControlEventFeed: vi.fn(async () => ({ isFresh: () => true })), +})); +vi.mock('../../../wrapper/src/restore-session.js', () => ({ + seedSessionIngestRegistration: vi.fn(async () => undefined), + restoreSession: vi.fn(), +})); +vi.mock('../../../wrapper/src/utils.js', async importOriginal => ({ + ...(await importOriginal()), + logToFile: vi.fn(), + git: vi.fn(), + runProcess: vi.fn(), + isTimeoutTermination: vi.fn(), +})); + +const identity = { + sessionId: 'workspace_first', + kiloSessionId: 'ses_first', + directory: '/workspace/test-refresh', +}; +const sibling = { ...identity, sessionId: 'workspace_sibling', kiloSessionId: 'ses_sibling' }; +const auth: WorktreeKiloAuth = { + scopeId: 'worktree_refresh', + token: 'real-kilo-original', + containmentEnabled: false, + organizationId: 'org-owner', + targets: { + backendBaseUrl: 'https://backend.example.test', + providerBaseUrl: 'https://provider.example.test', + sessionIngestBaseUrl: 'https://ingest.example.test', + }, +}; +const originalEnv = { GH_TOKEN: 'github-original', CUSTOM_VALUE: 'profile-value' }; +const registries: WorktreeKiloRuntimes[] = []; + +function fixture() { + const statuses = vi.fn(async () => ({})); + const createPty = vi.fn(); + const clients: WrapperKiloClient[] = []; + vi.mocked(createWrapperKiloClient).mockImplementation((_client, serverUrl) => { + const partial: Partial = { + serverUrl, + getSessionStatuses: statuses, + createPty, + }; + const client = partial as WrapperKiloClient; + clients.push(client); + return client; + }); + const close = vi.fn(); + const startServer = vi.fn< + NonNullable[0]['startServer']> + >(async () => { + const stopped = Promise.withResolvers(); + return { + url: `http://127.0.0.1:${10000 + clients.length}`, + stopped: stopped.promise, + close: () => { + close(); + stopped.resolve(); + }, + }; + }); + const onUnexpectedClose = vi.fn(); + const registry = createWorktreeKiloRuntimes({ + homeRoot: '/test-homes', + inheritedEnv: {}, + startServer, + onUnexpectedClose, + }); + registries.push(registry); + async function attach( + requestedAuth = auth, + env = originalEnv, + session = identity, + canRefreshCredentials: (() => boolean) | undefined = () => true + ) { + const attachment = registry.attach(session, requestedAuth, env, canRefreshCredentials); + try { + const runtime = await attachment.ready; + attachment.commit(); + return runtime; + } finally { + attachment.release(); + } + } + return { registry, attach, startServer, close, statuses, createPty, clients, onUnexpectedClose }; +} + +beforeEach(() => { + vi.clearAllMocks(); + resetSessionDirectoryState(); + resetDirectoryOperationState(); + vi.stubGlobal( + 'fetch', + vi.fn(async () => Response.json([])) + ); +}); + +afterEach(() => { + for (const registry of registries.splice(0)) registry.shutdown(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe('direct worktree credential refresh', () => { + it('refreshes SCM and Kilo tokens for idle roots without replacing logical runtime identity', async () => { + const f = fixture(); + const first = await f.attach(); + const second = await f.attach(auth, originalEnv, sibling); + const originalClient = first.kiloClient; + const rotated = { ...auth, token: 'real-kilo-renewed' }; + const env = { ...originalEnv, GH_TOKEN: 'github-renewed' }; + + const refreshed = await f.attach(rotated, env); + + expect(refreshed).toBe(first); + expect(refreshed).toBe(second); + expect(refreshed.signal.aborted).toBe(false); + expect(refreshed.kiloClient).not.toBe(originalClient); + expect(refreshed.env).toMatchObject({ + ...env, + KILOCODE_TOKEN: rotated.token, + KILOCODE_ORGANIZATION_ID: auth.organizationId, + }); + expect(JSON.parse(refreshed.env.KILO_CONFIG_CONTENT).provider.kilo.options.kilocodeToken).toBe( + rotated.token + ); + expect(f.startServer).toHaveBeenCalledTimes(2); + expect(f.close).toHaveBeenCalledTimes(1); + expect(fs.writeFile).toHaveBeenLastCalledWith( + expect.stringContaining('/kilo/auth.json'), + JSON.stringify({ kilo: { type: 'api', key: rotated.token } }), + { mode: 0o600 } + ); + expect(directoryForSession(sibling.kiloSessionId)).toBe(identity.directory); + expect(f.registry.get(identity.directory)).toBe(refreshed); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + await f.attach(rotated, env, sibling); + expect(f.startServer).toHaveBeenCalledTimes(2); + }); + + it.each([ + 'wrapper-task', + 'kilo-status', + 'pty', + 'status-error', + 'pty-error', + 'pty-invalid', + 'missing-guard', + ] as const)('does not stop the runtime when refresh is unsafe: %s', async blocker => { + const f = fixture(); + const runtime = await f.attach(); + await f.attach(auth, originalEnv, sibling); + if (blocker === 'kilo-status') + f.statuses.mockResolvedValue({ [sibling.kiloSessionId]: { type: 'busy' } }); + if (blocker === 'pty') + vi.mocked(fetch).mockResolvedValue(Response.json([{ id: 'pty-active' }])); + if (blocker === 'status-error') f.statuses.mockRejectedValue(new Error('unavailable')); + if (blocker === 'pty-error') + vi.mocked(fetch).mockResolvedValue(new Response(null, { status: 503 })); + if (blocker === 'pty-invalid') vi.mocked(fetch).mockResolvedValue(Response.json({})); + const guard = blocker === 'missing-guard' ? undefined : () => blocker !== 'wrapper-task'; + const attempt = async () => { + const attachment = f.registry.attach( + identity, + auth, + { ...originalEnv, GH_TOKEN: 'github-renewed' }, + guard + ); + try { + await attachment.ready; + } finally { + attachment.release(); + } + }; + await expect(attempt()).rejects.toMatchObject({ + code: ['status-error', 'pty-error', 'pty-invalid'].includes(blocker) + ? 'not_ready' + : 'session_busy', + retryable: true, + }); + expect(f.close).not.toHaveBeenCalled(); + expect(f.startServer).toHaveBeenCalledTimes(1); + expect(runtime.signal.aborted).toBe(false); + expect(runtime.env.GH_TOKEN).toBe('github-original'); + expect(f.registry.get(identity.directory)).toBe(runtime); + expect(f.registry.isHealthy()).toBe(true); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + }); + + it('fences lookups during refresh and rechecks wrapper activity after the idle probe', async () => { + const f = fixture(); + const runtime = await f.attach(); + await f.attach(auth, originalEnv, sibling); + const queried = Promise.withResolvers(); + const release = Promise.withResolvers>(); + f.statuses.mockImplementation(() => { + queried.resolve(); + return release.promise; + }); + let idle = true; + const attachment = f.registry.attach( + identity, + auth, + { ...originalEnv, GH_TOKEN: 'github-renewed' }, + () => idle + ); + const result = attachment.ready.catch(error => error); + await queried.promise; + expect(f.registry.get(identity.directory)).toBeUndefined(); + expect(f.registry.isHealthy()).toBe(true); + expect(() => f.registry.attach(sibling, auth, originalEnv, () => true)).toThrow( + 'Worktree credentials are refreshing' + ); + idle = false; + release.resolve({}); + expect(await result).toMatchObject({ code: 'session_busy', retryable: true }); + attachment.release(); + expect(f.close).not.toHaveBeenCalled(); + expect(f.registry.get(identity.directory)).toBe(runtime); + idle = true; + await f.attach(auth, { ...originalEnv, GH_TOKEN: 'github-renewed' }); + expect(f.close).toHaveBeenCalledTimes(1); + }); + + it('reports session_busy for an attached sibling during a healthy credential refresh', async () => { + const f = fixture(); + const runtime = await f.attach(); + await f.attach(auth, originalEnv, sibling); + const queried = Promise.withResolvers(); + const release = Promise.withResolvers>(); + f.statuses.mockImplementationOnce(() => { + queried.resolve(); + return release.promise; + }); + const activity = createSessionActivityRegistry(); + activity.attach(identity.kiloSessionId); + activity.attach(sibling.kiloSessionId); + const deps: HandlerDeps = { + kiloRuntimes: f.registry, + version: 'test', + kiloReady: true, + sessions: [], + tasks: new Map(), + activity, + emitSessionEvent: vi.fn(), + retireRuntime: vi.fn(), + applyAttach: (session, payload, options) => + applySessionAttach(session, payload, { + ...options, + hasBootstrapMarker: async () => true, + sessionExists: async () => true, + }), + }; + const refreshing = handleControlRequest( + 'session.attach', + identity, + { kilo: auth, env: { ...originalEnv, GH_TOKEN: 'github-renewed' } }, + deps + ); + try { + await queried.promise; + expect(f.registry.get(identity.directory)).toBeUndefined(); + expect(f.registry.isHealthy()).toBe(true); + expect( + await handleControlRequest( + 'session.prompt', + sibling, + { + messageId: 'waiting-sibling', + turn: { type: 'prompt', prompt: 'continue' }, + agent: { mode: 'code', model: 'kilo/test' }, + }, + deps + ) + ).toMatchObject({ ok: false, error: { code: 'session_busy', retryable: true } }); + expect(deps.tasks.has(sibling.kiloSessionId)).toBe(false); + expect(deps.emitSessionEvent).not.toHaveBeenCalled(); + expect(runtime.signal.aborted).toBe(false); + } finally { + release.resolve({}); + await refreshing; + } + expect(await refreshing).toEqual({ ok: true, result: { attached: true } }); + expect(f.registry.get(identity.directory)).toBe(runtime); + expect(runtime.env.GH_TOKEN).toBe('github-renewed'); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + expect(deps.retireRuntime).not.toHaveBeenCalled(); + }); + + it.each(['stale', 'aborted', 'other-directory', 'execution', 'unattached', 'no-task'] as const)( + 'keeps unavailable prompt semantics instead of reporting refresh contention: %s', + async condition => { + const f = fixture(); + let fresh = true; + vi.mocked(startSandboxControlEventFeed).mockResolvedValueOnce({ isFresh: () => fresh }); + await f.attach(); + await f.attach(auth, originalEnv, sibling); + if (condition === 'stale') fresh = false; + else vi.spyOn(f.registry, 'get').mockReturnValue(undefined); + if (condition === 'unattached') f.registry.detach(sibling); + const controller = new AbortController(); + if (condition === 'aborted') controller.abort(); + const deps: HandlerDeps = { + kiloRuntimes: f.registry, + version: 'test', + kiloReady: true, + sessions: [], + tasks: new Map(), + emitSessionEvent: vi.fn(), + retireRuntime: vi.fn(), + }; + if (condition !== 'no-task') { + deps.tasks.set(identity.kiloSessionId, { + kind: condition === 'execution' ? 'execution' : 'preparation', + messageId: 'other-message', + session: { + ...identity, + directory: condition === 'other-directory' ? '/workspace/other' : identity.directory, + }, + controller, + signal: controller.signal, + done: Promise.resolve({ ok: true, result: {} }), + }); + } + expect( + await handleControlRequest( + 'session.prompt', + sibling, + { + messageId: 'waiting-sibling', + turn: { type: 'prompt', prompt: 'continue' }, + agent: { mode: 'code', model: 'kilo/test' }, + }, + deps + ) + ).toMatchObject({ ok: false, error: { code: 'not_ready', retryable: true } }); + expect(deps.tasks.has(sibling.kiloSessionId)).toBe(false); + expect(deps.emitSessionEvent).not.toHaveBeenCalled(); + expect(deps.retireRuntime).not.toHaveBeenCalled(); + } + ); + + it('waits for confirmed old process exit before launching the refreshed process', async () => { + const f = fixture(); + const stopped = Promise.withResolvers(); + f.startServer.mockImplementationOnce(async () => ({ + url: 'http://127.0.0.1:10000', + close: f.close, + stopped: stopped.promise, + })); + const runtime = await f.attach(); + const attachment = f.registry.attach( + identity, + auth, + { ...originalEnv, GH_TOKEN: 'github-renewed' }, + () => true + ); + await vi.waitFor(() => expect(f.close).toHaveBeenCalledTimes(1)); + expect(f.startServer).toHaveBeenCalledTimes(1); + expect(f.registry.get(identity.directory)).toBeUndefined(); + stopped.resolve(); + expect(await attachment.ready).toBe(runtime); + attachment.commit(); + attachment.release(); + expect(f.startServer).toHaveBeenCalledTimes(2); + }); + + it.each(['server-start', 'feed-start', 'feed-timeout', 'process-stop-timeout'] as const)( + 'requests bounded owner recovery after destructive refresh failure: %s', + async failure => { + const f = fixture(); + const stopped = Promise.withResolvers(); + if (failure === 'process-stop-timeout') { + f.startServer.mockImplementationOnce(async () => ({ + url: 'http://127.0.0.1:10000', + close: f.close, + stopped: stopped.promise, + })); + } + const runtime = await f.attach(); + await f.attach(auth, originalEnv, sibling); + if (failure === 'server-start') + f.startServer.mockRejectedValueOnce(new Error('private-startup-credential')); + if (failure === 'feed-start') + vi.mocked(startSandboxControlEventFeed).mockRejectedValueOnce( + new Error('private-feed-credential') + ); + if (failure === 'feed-timeout') + vi.mocked(startSandboxControlEventFeed).mockImplementationOnce(() => new Promise(() => {})); + vi.useFakeTimers(); + const outcome = f + .attach(auth, { ...originalEnv, GH_TOKEN: 'github-renewed' }) + .catch(error => error); + await vi.advanceTimersByTimeAsync(30_001); + + expect(await outcome).toMatchObject({ code: 'runtime_unhealthy', retryable: true }); + expect(runtime.signal.aborted).toBe(true); + expect(f.registry.get(identity.directory)).toBeUndefined(); + expect(f.registry.isHealthy()).toBe(false); + expect(f.onUnexpectedClose.mock.calls).toEqual([ + [{ directory: identity.directory, reason: 'credential_refresh_failed' }], + ]); + expect(f.startServer).toHaveBeenCalledTimes(failure === 'process-stop-timeout' ? 1 : 2); + stopped.resolve(); + f.registry.shutdown(); + await vi.advanceTimersByTimeAsync(60_000); + expect(f.onUnexpectedClose).toHaveBeenCalledTimes(1); + expect(() => f.registry.attach(sibling, auth, originalEnv, () => true)).toThrow( + 'Kilo worktrees are closed' + ); + } + ); + + it('does not report intentional shutdown during a pending destructive refresh as unexpected', async () => { + const f = fixture(); + const stopped = Promise.withResolvers(); + f.startServer.mockImplementationOnce(async () => ({ + url: 'http://127.0.0.1:10000', + close: f.close, + stopped: stopped.promise, + })); + await f.attach(); + const outcome = f + .attach(auth, { ...originalEnv, GH_TOKEN: 'github-renewed' }) + .catch(error => error); + await vi.waitFor(() => expect(f.close).toHaveBeenCalledTimes(1)); + f.registry.shutdown(); + expect(await outcome).toMatchObject({ code: 'runtime_unhealthy' }); + stopped.resolve(); + expect(f.startServer).toHaveBeenCalledTimes(1); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + }); + + it('keeps failed initial startup retryable without retiring a healthy owner', async () => { + const f = fixture(); + f.startServer.mockRejectedValueOnce(new Error('private-startup-credential')); + await expect(f.attach()).rejects.toMatchObject({ code: 'not_ready', retryable: true }); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + await f.attach(); + expect(f.registry.isHealthy()).toBe(true); + expect(f.startServer).toHaveBeenCalledTimes(2); + }); + + it('reports confirmed process exit immediately even if the feed still appears fresh', async () => { + const f = fixture(); + const stopped = Promise.withResolvers(); + f.startServer.mockImplementationOnce(async () => ({ + url: 'http://127.0.0.1:10000', + close: f.close, + stopped: stopped.promise, + })); + const runtime = await f.attach(); + stopped.resolve(); + await stopped.promise; + expect(runtime.signal.aborted).toBe(true); + expect(f.registry.get(identity.directory)).toBeUndefined(); + expect(f.registry.isHealthy()).toBe(false); + expect(f.onUnexpectedClose.mock.calls).toEqual([ + [{ directory: identity.directory, reason: 'process_exited' }], + ]); + }); + + it.each(['connection', 'http'] as const)( + 'classifies real SDK SSE startup %s errors without retaining private data', + async failure => { + const f = fixture(); + const runtime = await f.attach(); + const feed = vi.mocked(startSandboxControlEventFeed).mock.calls[0]?.[0]; + if (!feed) throw new Error('Missing worktree event feed'); + if (failure === 'connection') { + vi.mocked(fetch).mockRejectedValueOnce(new Error('private-connection-credential')); + } else { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(null, { status: 503, statusText: 'private-http-credential' }) + ); + } + const { stream } = await feed.open(feed.signal); + if (!stream) throw new Error('Missing SDK SSE stream'); + const error = await stream[Symbol.asyncIterator]() + .next() + .catch(error => error); + expect(error).toMatchObject({ + reason: 'feed_failed', + message: 'Kilo global event feed failed', + }); + expect(error).not.toHaveProperty('cause'); + expect(String(error)).not.toContain('private-'); + expect(JSON.stringify(error)).not.toContain('private-'); + expect(fetch).toHaveBeenCalledTimes(1); + expect(runtime.signal.aborted).toBe(false); + feed.onUnexpectedClose(error); + expect(f.onUnexpectedClose.mock.calls).toEqual([ + [{ directory: identity.directory, reason: 'feed_failed' }], + ]); + expect(runtime.signal.aborted).toBe(true); + } + ); + + it('ignores retired process feed failures but reports current feed errors without private data', async () => { + const f = fixture(); + const runtime = await f.attach(); + const oldFeed = vi.mocked(startSandboxControlEventFeed).mock.calls[0]?.[0]; + expect(oldFeed).toBeDefined(); + await f.attach(auth, { ...originalEnv, GH_TOKEN: 'github-renewed' }); + oldFeed?.onUnexpectedClose(new Error('private-old-feed-credential')); + expect(runtime.signal.aborted).toBe(false); + expect(f.registry.isHealthy()).toBe(true); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + const currentFeed = vi.mocked(startSandboxControlEventFeed).mock.calls[1]?.[0]; + expect(currentFeed).toBeDefined(); + currentFeed?.onUnexpectedClose(new Error('private-current-feed-credential')); + currentFeed?.onUnexpectedClose(new Error('private-duplicate-feed-credential')); + expect(f.onUnexpectedClose.mock.calls).toEqual([ + [{ directory: identity.directory, reason: 'feed_failed' }], + ]); + expect(runtime.signal.aborted).toBe(true); + }); + + it('does not expose a stale runtime before its watchdog fires', async () => { + const f = fixture(); + let fresh = true; + vi.mocked(startSandboxControlEventFeed).mockResolvedValueOnce({ isFresh: () => fresh }); + await f.attach(); + fresh = false; + expect(f.registry.get(identity.directory)).toBeUndefined(); + expect(f.registry.isHealthy()).toBe(false); + }); + + it('discards heartbeat statuses from a replaced process with the same logical runtime', async () => { + const f = fixture(); + const runtime = await f.attach(); + const status = + Promise.withResolvers>>(); + f.statuses.mockReturnValueOnce(status.promise); + const activity = createSessionActivityRegistry(); + activity.attach(identity.kiloSessionId); + const deps: HandlerDeps = { + kiloRuntimes: f.registry, + version: 'test', + kiloReady: true, + sessions: [], + tasks: new Map(), + activity, + emitSessionEvent: vi.fn(), + retireRuntime: vi.fn(), + }; + const heartbeat = refreshHeartbeatPayload(deps); + const oldClient = runtime.kiloClient; + await f.attach(auth, { ...originalEnv, GH_TOKEN: 'github-renewed' }); + expect(f.registry.get(identity.directory)).toBe(runtime); + expect(runtime.kiloClient).not.toBe(oldClient); + status.resolve({ [identity.kiloSessionId]: { type: 'busy' } }); + expect((await heartbeat).sessions).toEqual([ + expect.objectContaining({ kiloSessionId: identity.kiloSessionId, state: 'idle' }), + ]); + expect(f.onUnexpectedClose).not.toHaveBeenCalled(); + }); + + it('does not recycle while terminal creation is in flight', async () => { + const f = fixture(); + const runtime = await f.attach(); + const created = Promise.withResolvers>>(); + f.createPty.mockReturnValue(created.promise); + const pending = runtime.kiloClient.createPty({ + cwd: identity.directory, + title: 'Workspace terminal', + env: runtime.env, + }); + await expect( + f.attach(auth, { ...originalEnv, GH_TOKEN: 'github-renewed' }) + ).rejects.toMatchObject({ + code: 'session_busy', + retryable: true, + }); + expect(f.close).not.toHaveBeenCalled(); + created.resolve({ + id: 'pty-test', + title: 'Workspace terminal', + command: 'sh', + args: [], + cwd: identity.directory, + status: 'running', + pid: 1, + }); + await pending; + }); + + it('keeps contained alias changes unauthorized and preserves contained environment reuse', async () => { + const f = fixture(); + const contained = { ...auth, containmentEnabled: undefined }; + const runtime = await f.attach(contained); + await expect(f.attach({ ...contained, token: 'replacement-alias' })).rejects.toMatchObject({ + code: 'unauthorized', + retryable: false, + }); + expect(await f.attach(contained, { ...originalEnv, GH_TOKEN: 'different-profile-token' })).toBe( + runtime + ); + expect(runtime.env.GH_TOKEN).toBe(originalEnv.GH_TOKEN); + expect(f.startServer).toHaveBeenCalledTimes(1); + }); + + it.each([ + { ...auth, scopeId: 'other-worktree' }, + { ...auth, organizationId: 'other-org' }, + { ...auth, containmentEnabled: true }, + { ...auth, targets: { ...auth.targets, providerBaseUrl: 'https://other.example.test' } }, + ])('does not treat changed authorization context as token rotation', async changed => { + const f = fixture(); + await f.attach(); + await expect(f.attach(changed)).rejects.toMatchObject({ + code: 'unauthorized', + retryable: false, + }); + expect(f.close).not.toHaveBeenCalled(); + }); + + it('refreshes warm Git origin while preserving an explicit profile token without recycling', async () => { + const f = fixture(); + const runGit = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + const runSetup = vi.fn(); + const deps = { + kiloRuntimes: f.registry, + canRefreshCredentials: () => true, + hasBootstrapMarker: async () => true, + sessionExists: async () => true, + runGit, + runSetup, + }; + for (const token of ['github-original', 'github-renewed']) { + const result = await applySessionAttach( + identity, + { + kilo: auth, + git: { url: 'https://github.com/acme/repo.git', token, platform: 'github' }, + env: { GH_TOKEN: 'profile-override' }, + setupCommands: ['do-not-repeat'], + }, + deps + ); + expect(result).toEqual({ ok: true, result: { attached: true } }); + expect(runGit).toHaveBeenLastCalledWith( + ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/acme/repo.git`], + identity.directory, + expect.any(AbortSignal) + ); + } + expect(f.registry.get(identity.directory)?.env.GH_TOKEN).toBe('profile-override'); + expect(f.startServer).toHaveBeenCalledTimes(1); + expect(f.close).not.toHaveBeenCalled(); + expect(runSetup).not.toHaveBeenCalled(); + }); + + it('reattaches with fresh managed tokens after an active sibling becomes idle', async () => { + const f = fixture(); + const runtime = await f.attach(); + await f.attach(auth, originalEnv, sibling); + const runGit = vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })); + let idle = false; + const deps = { + kiloRuntimes: f.registry, + canRefreshCredentials: () => idle, + hasBootstrapMarker: async () => true, + sessionExists: async () => true, + runGit, + }; + const payload = { + kilo: { ...auth, token: 'real-kilo-renewed' }, + git: { url: 'https://github.com/acme/repo.git', token: 'github-renewed', platform: 'github' }, + env: { ...originalEnv, GH_TOKEN: 'github-renewed' }, + }; + expect(await applySessionAttach(identity, payload, deps)).toMatchObject({ + ok: false, + error: { code: 'session_busy', retryable: true }, + }); + expect(f.close).not.toHaveBeenCalled(); + expect(runGit).not.toHaveBeenCalled(); + idle = true; + expect(await applySessionAttach(identity, payload, deps)).toEqual({ + ok: true, + result: { attached: true }, + }); + expect(f.close).toHaveBeenCalledTimes(1); + expect(runtime.env.GH_TOKEN).toBe('github-renewed'); + expect(runtime.env.KILOCODE_TOKEN).toBe('real-kilo-renewed'); + expect(runGit).toHaveBeenCalledTimes(1); + expect(directoryForSession(sibling.kiloSessionId)).toBe(identity.directory); + }); + + it('wires handler task and activity guards without aborting active sibling work', async () => { + const f = fixture(); + const runtime = await f.attach(); + await f.attach(auth, originalEnv, sibling); + const activity = createSessionActivityRegistry(); + activity.attach(identity.kiloSessionId); + activity.attach(sibling.kiloSessionId); + activity.markActive(sibling.kiloSessionId); + const deps: HandlerDeps = { + kiloRuntimes: f.registry, + version: 'test', + kiloReady: true, + sessions: [], + tasks: new Map(), + activity, + emitSessionEvent: vi.fn(), + retireRuntime: vi.fn(), + applyAttach: (session, payload, options) => + applySessionAttach(session, payload, { + ...options, + hasBootstrapMarker: async () => true, + sessionExists: async () => true, + }), + }; + const controller = new AbortController(); + deps.tasks.set(sibling.kiloSessionId, { + kind: 'execution', + messageId: 'active-sibling-message', + session: sibling, + controller, + signal: controller.signal, + done: Promise.resolve({ ok: true, result: {} }), + }); + const payload = { kilo: auth, env: { ...originalEnv, GH_TOKEN: 'github-renewed' } }; + expect(await handleControlRequest('session.attach', identity, payload, deps)).toMatchObject({ + ok: false, + error: { code: 'session_busy', retryable: true }, + }); + expect(controller.signal.aborted).toBe(false); + expect(runtime.signal.aborted).toBe(false); + expect(f.close).not.toHaveBeenCalled(); + deps.tasks.delete(sibling.kiloSessionId); + expect(await handleControlRequest('session.attach', identity, payload, deps)).toMatchObject({ + ok: false, + error: { code: 'session_busy', retryable: true }, + }); + expect(f.close).not.toHaveBeenCalled(); + activity.observeEvent('session.idle', sibling.kiloSessionId, sibling.kiloSessionId, {}); + expect(await handleControlRequest('session.attach', identity, payload, deps)).toEqual({ + ok: true, + result: { attached: true }, + }); + expect(f.close).toHaveBeenCalledTimes(1); + expect(runtime.env.GH_TOKEN).toBe('github-renewed'); + expect(deps.retireRuntime).not.toHaveBeenCalled(); + }); + + it('fails attach instead of accepting stale Git auth when origin refresh fails', async () => { + const f = fixture(); + expect( + await applySessionAttach( + identity, + { + kilo: auth, + git: { + url: 'https://github.com/acme/repo.git', + token: 'github-renewed', + platform: 'github', + }, + env: originalEnv, + }, + { + kiloRuntimes: f.registry, + canRefreshCredentials: () => true, + hasBootstrapMarker: async () => true, + runGit: async () => ({ stdout: '', stderr: 'failed', exitCode: 1 }), + } + ) + ).toMatchObject({ + ok: false, + error: { message: 'Worktree Git credential refresh failed', retryable: true }, + }); + }); +}); diff --git a/services/cloud-agent-next/wrapper/src/control/apply-attach.ts b/services/cloud-agent-next/wrapper/src/control/apply-attach.ts index 49c419e886..c1ac5ac454 100644 --- a/services/cloud-agent-next/wrapper/src/control/apply-attach.ts +++ b/services/cloud-agent-next/wrapper/src/control/apply-attach.ts @@ -48,6 +48,7 @@ export type AttachPreparingEmitter = (event: PreparingEventDataV2) => void; export type ApplyAttachDeps = { kiloRuntimes?: WorktreeKiloRuntimes; + canRefreshCredentials?: () => boolean; signal?: AbortSignal; terminalRuntime?: Pick; mkdir?: (directory: string) => Promise; @@ -282,7 +283,12 @@ async function executeSessionAttach( const taskSignal = deps.signal ?? AbortSignal.timeout(SANDBOX_CONTROL_ATTACH_TIMEOUT_MS); try { taskSignal.throwIfAborted(); - attachment = deps.kiloRuntimes.attach(session, attach.kilo, attach.env); + attachment = deps.kiloRuntimes.attach( + session, + attach.kilo, + attach.env, + deps.canRefreshCredentials + ); const signal = AbortSignal.any([taskSignal, attachment.signal]); const { kiloClient, env } = await withTimeoutAndAbort(attachment.ready, { signal, @@ -388,6 +394,22 @@ async function executeSessionAttach( await writeBootstrapMarker(directory); signal.throwIfAborted(); } + if (attach.kilo.containmentEnabled === false && attach.git?.token) { + const refreshed = await runGit( + [ + 'remote', + 'set-url', + 'origin', + authenticatedGitUrl(attach.git.url, attach.git.token, attach.git.platform), + ], + directory, + signal + ); + signal.throwIfAborted(); + if (refreshed.exitCode !== 0) { + return fail('not_ready', 'Worktree Git credential refresh failed', true); + } + } } catch { return fail( 'not_ready', diff --git a/services/cloud-agent-next/wrapper/src/control/main.ts b/services/cloud-agent-next/wrapper/src/control/main.ts index f8eea20eee..1549832af7 100644 --- a/services/cloud-agent-next/wrapper/src/control/main.ts +++ b/services/cloud-agent-next/wrapper/src/control/main.ts @@ -1,3 +1,4 @@ +import type { SandboxHeartbeatPayload } from '../../../src/shared/sandbox-control-protocol.js'; import { WRAPPER_VERSION } from '../../../src/shared/wrapper-version.js'; import { logToFile } from '../utils.js'; import { @@ -29,6 +30,7 @@ function main(): void { const abort = new AbortController(); let control: ReturnType = null; let shuttingDown = false; + let heartbeatReason: SandboxHeartbeatPayload['kilo']['reason']; const kiloRuntimes = createWorktreeKiloRuntimes({ onEvent: (runtime, event) => { const identity = sessionEventIdentity({ @@ -54,7 +56,12 @@ function main(): void { throw new Error('Sandbox control event delivery failed'); } }, - onUnexpectedClose: () => shutdown(1, 'Kilo event feed is no longer healthy'), + onUnexpectedClose: failure => + shutdown( + 1, + `Kilo worktree failed reason=${failure.reason} directory=${failure.directory}`, + failure.reason + ), }); const terminalRuntime = controlConfig.SANDBOX_CONTROL_URL ? createControlTerminalRuntime({ @@ -89,11 +96,25 @@ function main(): void { onShutdown: () => shutdown(0, 'Sandbox shutting down'), }; - function shutdown(exitCode: number, reason: string): void { + function withHeartbeatReason(payload: SandboxHeartbeatPayload): SandboxHeartbeatPayload { + if (!payload.kilo.ready && heartbeatReason) payload.kilo.reason = heartbeatReason; + return payload; + } + + function shutdown( + exitCode: number, + reason: string, + diagnosticReason: NonNullable = 'shutdown' + ): void { if (shuttingDown) return; shuttingDown = true; + heartbeatReason = diagnosticReason; logToFile(`control-plane wrapper retiring: ${reason}`); - control?.sendEvent?.('sandbox.heartbeat', buildHeartbeatPayload(deps)); + try { + control?.sendEvent?.('sandbox.heartbeat', withHeartbeatReason(buildHeartbeatPayload(deps))); + } catch { + logToFile('control-plane final heartbeat delivery failed'); + } const stopped = cancelControlTasks(deps, reason, exitCode === 0 ? 'cancelled' : 'failed'); abort.abort(); terminalRuntime?.shutdown(); @@ -114,7 +135,7 @@ function main(): void { control = maybeStartSandboxControlClient(controlConfig, logToFile, { wrapperVersion: WRAPPER_VERSION, isReady: () => deps.kiloReady, - onDisconnected: () => shutdown(1, 'Sandbox control connection lost'), + onDisconnected: () => shutdown(1, 'Sandbox control connection lost', 'control_disconnected'), onRequest: (operation, session, payload) => handleControlRequest(operation, session, payload, { ...deps, @@ -127,11 +148,11 @@ function main(): void { rootKiloSessionId: session.kiloSessionId, }) ) { - shutdown(1, 'Preparation event delivery failed'); + shutdown(1, 'Preparation event delivery failed', 'control_disconnected'); } }, }), - getHeartbeatPayload: () => refreshHeartbeatPayload(deps), + getHeartbeatPayload: async () => withHeartbeatReason(await refreshHeartbeatPayload(deps)), }); logToFile(`control-plane wrapper ready callHome=${Boolean(control)}`); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts index cfc35b390f..5d65d71f15 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts @@ -2010,7 +2010,10 @@ describe('owned control execution', () => { { ...payload, messageId: 'msg_2' }, handlerDeps ) - ).toMatchObject({ ok: false }); + ).toMatchObject({ ok: false, error: { code: 'session_busy', retryable: true } }); + expect( + await handleControlRequest('session.attach', session, { kilo }, handlerDeps) + ).toMatchObject({ ok: false, error: { code: 'session_busy', retryable: true } }); expect(submissions).toBe(1); expect(events).toEqual([]); finished.resolve(completion()); @@ -3960,6 +3963,43 @@ describe('control interactions and sync', () => { }); }); +describe('control wrapper heartbeat source policy', () => { + const source = fs + .readFileSync(new URL('./main.ts', import.meta.url), 'utf8') + .replace(/\s+/g, ' '); + + it('carries only the typed diagnostic code on final and in-flight negative heartbeats', () => { + expect(source).toContain( + "diagnosticReason: NonNullable = 'shutdown'" + ); + expect(source).toContain( + 'onUnexpectedClose: failure => shutdown( 1, `Kilo worktree failed reason=${failure.reason} directory=${failure.directory}`, failure.reason )' + ); + expect(source).toContain( + "onDisconnected: () => shutdown(1, 'Sandbox control connection lost', 'control_disconnected')" + ); + expect(source).toContain( + 'if (!payload.kilo.ready && heartbeatReason) payload.kilo.reason = heartbeatReason;' + ); + expect(source).toContain( + 'getHeartbeatPayload: async () => withHeartbeatReason(await refreshHeartbeatPayload(deps))' + ); + }); + + it('sets the first diagnostic before sending the final heartbeat and cancelling tasks', () => { + const steps = [ + 'if (shuttingDown) return;', + 'shuttingDown = true;', + 'heartbeatReason = diagnosticReason;', + "control?.sendEvent?.('sandbox.heartbeat', withHeartbeatReason(buildHeartbeatPayload(deps)))", + 'const stopped = cancelControlTasks(deps, reason,', + 'abort.abort();', + ].map(step => source.indexOf(step)); + expect(steps.every(index => index >= 0)).toBe(true); + expect(steps).toEqual([...steps].sort((a, b) => a - b)); + }); +}); + describe('buildHeartbeatPayload', () => { it('synchronously includes advisory activity snapshots without polling Kilo', () => { let now = 100; diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts index 28ce347405..e3ea306c60 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.ts @@ -300,15 +300,17 @@ export async function refreshHeartbeatPayload(deps: HandlerDeps): Promise [root, activity.revision(root)])); + const kiloClient = runtime.kiloClient; try { const statuses = await withKiloRequestDeadline( - signal => runtime.kiloClient.getSessionStatuses(directory, signal), + signal => kiloClient.getSessionStatuses(directory, signal), deps.signal ? AbortSignal.any([deps.signal, runtime.signal]) : runtime.signal ); if ( runtime.signal.aborted || deps.signal?.aborted || - kiloRuntimes.get(directory) !== runtime + kiloRuntimes.get(directory) !== runtime || + runtime.kiloClient !== kiloClient ) return; activity.reconcile( @@ -611,7 +613,7 @@ async function handleAttach( return fail('protocol_error', 'Reserved control runtime environment variable', false); } if (deps.tasks.has(session.kiloSessionId)) { - return fail('not_ready', 'Session has work in progress', true); + return fail('session_busy', 'Session has work in progress', true); } const task = startSessionTask( @@ -622,6 +624,16 @@ async function handleAttach( const result = await (deps.applyAttach ?? applySessionAttach)(session, parsed.data, { kiloRuntimes: deps.kiloRuntimes, signal: owned.signal, + canRefreshCredentials: () => + !owned.signal.aborted && + ![...deps.tasks.values()].some( + task => task !== owned && task.session.directory === session.directory + ) && + !(deps.activity?.snapshots() ?? []).some( + snapshot => + snapshot.state !== 'idle' && + directoryForSession(snapshot.kiloSessionId) === session.directory + ), ...(deps.terminalRuntime ? { terminalRuntime: deps.terminalRuntime } : {}), ...(deps.emitPreparing ? { emitPreparing: deps.emitPreparing } : {}), }); @@ -721,7 +733,22 @@ function handlePrompt( deps: HandlerDeps ): ControlHandlerResult { const runtime = sessionKiloRuntime(session, deps); - if (!runtime) return missingKilo(); + if (!runtime) { + if ( + deps.kiloRuntimes?.isHealthy() && + directoryForSession(session.kiloSessionId) === session.directory && + rootForSession(session.kiloSessionId) === session.kiloSessionId && + [...deps.tasks.values()].some( + task => + task.kind === 'preparation' && + task.session.directory === session.directory && + !task.signal.aborted + ) + ) { + return fail('session_busy', 'Worktree has preparation in progress', true); + } + return missingKilo(); + } const parsed = sessionPromptPayloadSchema.safeParse(payload); if (!parsed.success) return fail('protocol_error', 'Invalid payload', false); const request = parsed.data; @@ -751,7 +778,7 @@ function handlePrompt( ) { return ok({ messageId: request.messageId, status: 'existing' }); } - return fail('not_ready', 'Session has work in progress', true); + return fail('session_busy', 'Session has work in progress', true); } startSessionTask( session, diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts index ef9fe7c616..2fd5bb279c 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.test.ts @@ -683,7 +683,10 @@ describe('startSandboxControlEventFeed', () => { expect(received).toHaveLength(1); expect(failures).toHaveLength(1); - expect(failures[0]).toEqual(new Error('Kilo global event feed ended')); + expect(failures[0]).toMatchObject({ + reason: 'feed_ended', + message: 'Kilo global event feed ended', + }); }); it('keeps silent work healthy on raw Kilo heartbeats and retires a frozen open feed', async () => { @@ -739,7 +742,13 @@ describe('startSandboxControlEventFeed', () => { watchdog(); watchdog(); expect(streamSignal?.aborted).toBe(true); - expect(failures).toEqual([new Error('Kilo global event feed stopped responding')]); + expect(abort.signal.aborted).toBe(false); + expect(failures).toEqual([ + expect.objectContaining({ + reason: 'feed_stale', + message: 'Kilo global event feed stopped responding', + }), + ]); } finally { abort.abort(); next.resolve(); @@ -764,11 +773,13 @@ describe('startSandboxControlEventFeed', () => { }, onUnexpectedClose: failed.resolve, }); - expect(await failed.promise).toEqual( - new Error('Kilo global event feed reconnected with a delivery gap') - ); + expect(await failed.promise).toMatchObject({ + reason: 'feed_reconnected', + message: 'Kilo global event feed reconnected with a delivery gap', + }); expect(received).toHaveLength(1); expect(feed.isFresh()).toBe(false); + expect(abort.signal.aborted).toBe(false); abort.abort(); }); diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts index 488c9850be..8d08766cc4 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-runtime.ts @@ -35,6 +35,15 @@ const HEARTBEAT_INTERVAL_MS = 30_000; export const KILO_FEED_FRESHNESS_TIMEOUT_MS = 30_000; export const KILO_CONTROL_REQUEST_TIMEOUT_MS = 10_000; +export class KiloEventFeedError extends Error { + constructor( + readonly reason: 'feed_stale' | 'feed_reconnected' | 'feed_ended' | 'feed_failed', + message: string + ) { + super(message); + } +} + export async function withKiloRequestDeadline( request: (signal: AbortSignal) => Promise, externalSignal?: AbortSignal @@ -111,7 +120,8 @@ export async function startSandboxControlEventFeed( options.onUnexpectedClose(error); }; const freshnessTimer = setInterval(() => { - if (!isFresh()) fail(new Error('Kilo global event feed stopped responding')); + if (!isFresh()) + fail(new KiloEventFeedError('feed_stale', 'Kilo global event feed stopped responding')); }, 10_000); freshnessTimer.unref(); signal.addEventListener('abort', () => clearInterval(freshnessTimer), { once: true }); @@ -123,7 +133,10 @@ export async function startSandboxControlEventFeed( const next = await iterator.next(); if (signal.aborted || next.done) return; if (isFeedConnectedEvent(next.value)) { - throw new Error('Kilo global event feed reconnected with a delivery gap'); + throw new KiloEventFeedError( + 'feed_reconnected', + 'Kilo global event feed reconnected with a delivery gap' + ); } lastEventAt = now(); yield next.value; @@ -134,7 +147,7 @@ export async function startSandboxControlEventFeed( } void options.consume(establishedFeed()).then( - () => fail(new Error('Kilo global event feed ended')), + () => fail(new KiloEventFeedError('feed_ended', 'Kilo global event feed ended')), error => fail(error) ); return { isFresh }; diff --git a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts index d6013ac6fd..5159576d64 100644 --- a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.test.ts @@ -130,6 +130,8 @@ function createKiloStub() { if (request.method === 'GET' && url.pathname === '/permission') { return Response.json(permissions); } + if (request.method === 'GET' && url.pathname === '/session/status') return Response.json({}); + if (request.method === 'GET' && url.pathname === '/pty') return Response.json([]); if (request.method === 'POST' && url.pathname.startsWith('/permission/')) { const id = decodeURIComponent(url.pathname.split('/')[2]); const index = permissions.findIndex(permission => permission.id === id); @@ -281,6 +283,28 @@ afterEach(async () => { }); describe('worktree Kilo environments', () => { + it.each([undefined, 'org-trusted'])( + 'uses only trusted organization attribution: %s', + organizationId => { + const env = buildWorktreeKiloEnvironment( + '/workspace/a', + '/home/a', + { ...auth, token: 'real-kilo-token', organizationId }, + { KILOCODE_ORGANIZATION_ID: 'profile-org' }, + { KILOCODE_ORGANIZATION_ID: 'inherited-org' } + ); + if (organizationId) expect(env.KILOCODE_ORGANIZATION_ID).toBe(organizationId); + else expect(env).not.toHaveProperty('KILOCODE_ORGANIZATION_ID'); + expect(JSON.parse(env.KILO_CONFIG_CONTENT).provider.kilo.options).toEqual({ + apiKey: 'real-kilo-token', + kilocodeToken: 'real-kilo-token', + baseURL: auth.targets.providerBaseUrl, + ...(organizationId ? { kilocodeOrganizationId: organizationId } : {}), + }); + expect(env.KILO_CONFIG_CONTENT).toBe(env.OPENCODE_CONFIG_CONTENT); + } + ); + it('retains only the four trusted Bitbucket metadata keys from the attachment', () => { const env = buildWorktreeKiloEnvironment( '/workspace/a', @@ -430,6 +454,130 @@ describe('worktree Kilo runtime registry', () => { ); }); + it('uses the real SDK timeout-free fetch for global SSE with runtime-owned cancellation', async () => { + const fetchSpy = spyOn(globalThis, 'fetch'); + const harness = createRegistry(); + try { + const runtime = await harness.registry.ensure(path.join(tmpDir, 'worktree-sse'), auth); + const sseCalls = fetchSpy.mock.calls.filter( + ([request]) => + request instanceof Request && new URL(request.url).pathname === '/global/event' + ); + expect(sseCalls).toHaveLength(1); + const [request, init] = sseCalls[0]; + if (!(request instanceof Request)) throw new Error('Expected SDK SSE request'); + expect(init).toMatchObject({ duplex: 'half', timeout: false }); + expect(new URL(request.url).searchParams.get('directory')).toBe(runtime.directory); + expect(request.signal.aborted).toBe(false); + expect(runtime.signal.aborted).toBe(false); + expect(harness.registry.isHealthy()).toBe(true); + harness.registry.shutdown(); + expect(request.signal.aborted).toBe(true); + expect(runtime.signal.aborted).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(harness.unexpectedCloses).toBe(0); + } finally { + harness.registry.shutdown(); + fetchSpy.mockRestore(); + } + }); + + it.each(['stream-error', 'feed-end', 'reconnect'] as const)( + 'classifies real SDK SSE %s without exposing private errors or reconnecting', + async failure => { + const connected = 'data: {"payload":{"type":"server.connected","properties":{}}}\n\n'; + const encoder = new TextEncoder(); + const opened = Promise.withResolvers>(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(connected)); + opened.resolve(controller); + }, + }), + { headers: { 'Content-Type': 'text/event-stream' } } + ); + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(response); + const failures: unknown[] = []; + const harness = createRegistry({ onUnexpectedClose: error => failures.push(error) }); + try { + const runtime = await harness.registry.ensure(path.join(tmpDir, 'worktree-sse'), auth); + const stream = await opened.promise; + if (failure === 'stream-error') stream.error(new Error('private-stream-credential')); + else if (failure === 'feed-end') stream.close(); + else stream.enqueue(encoder.encode(connected)); + await waitUntil(() => failures.length > 0); + expect(failures).toEqual([ + { + directory: runtime.directory, + reason: + failure === 'stream-error' + ? 'feed_failed' + : failure === 'feed-end' + ? 'feed_ended' + : 'feed_reconnected', + }, + ]); + expect(runtime.signal.aborted).toBe(true); + expect(harness.registry.isHealthy()).toBe(false); + expect(harness.registry.get(runtime.directory)).toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + } finally { + harness.registry.shutdown(); + fetchSpy.mockRestore(); + } + } + ); + + it('keeps the refreshed SDK event feed healthy after intentional old-process shutdown', async () => { + const received: string[] = []; + const harness = createRegistry({ + startServer: async () => { + const server = createKiloStub(); + servers.push(server); + const stopped = Promise.withResolvers(); + return { + url: server.url, + stopped: stopped.promise, + close: () => { + server.endFeeds(); + stopped.resolve(); + }, + }; + }, + onEvent: (_runtime, event) => received.push(event.type), + }); + const directAuth = { ...auth, containmentEnabled: false }; + const identity = rootIdentity(path.join(tmpDir, 'shared')); + const first = harness.registry.attach(identity, directAuth); + const runtime = await first.ready; + first.commit(); + first.release(); + const originalClient = runtime.kiloClient; + const refresh = harness.registry.attach( + identity, + { ...directAuth, token: 'rotated-token' }, + undefined, + () => true + ); + expect(await refresh.ready).toBe(runtime); + refresh.commit(); + refresh.release(); + expect(runtime.kiloClient).not.toBe(originalClient); + expect(servers).toHaveLength(2); + servers[1]?.emit({ payload: { type: 'server.heartbeat', properties: {} } }); + servers[1]?.emit({ payload: { type: 'session.updated', properties: {} } }); + await waitUntil(() => received.includes('session.updated')); + expect(runtime.signal.aborted).toBe(false); + expect(harness.registry.isHealthy()).toBe(true); + expect(harness.registry.get(identity.directory)).toBe(runtime); + expect(harness.unexpectedCloses).toBe(0); + servers[1]?.endFeeds(); + await waitUntil(() => harness.unexpectedCloses === 1); + expect(runtime.signal.aborted).toBe(true); + expect(harness.registry.isHealthy()).toBe(false); + }); + it('isolates different worktrees with separate servers, homes, auth files, and event clients', async () => { const events: Array<{ directory: string; scopeId: string; type: string }> = []; const { registry, launches } = createRegistry({ @@ -1033,6 +1181,7 @@ describe('worktree Kilo runtime registry', () => { const conflicts = [ { ...auth, scopeId: 'different-scope' }, { ...auth, token: 'different-token' }, + { ...auth, organizationId: 'different-organization' }, ...Object.keys(auth.targets).map(key => ({ ...auth, targets: { ...auth.targets, [key]: 'https://different.example.test' }, diff --git a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts index 722de06b23..7225959bb9 100644 --- a/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts +++ b/services/cloud-agent-next/wrapper/src/control/worktree-runtime.ts @@ -4,6 +4,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { createKiloClient } from '@kilocode/sdk'; +import { createKiloClient as createKiloEventClient } from '@kilocode/sdk/v2/client'; import { CONTROL_PLANE_SANDBOX_PERMISSION } from '../../../src/shared/control-plane-permission.js'; import type { ControlErrorCode, @@ -19,10 +20,19 @@ import { import { withTimeoutAndAbort } from '../utils.js'; import { unfilteredKiloEvents } from './feed.js'; import { forgetAttachedRoot, rememberAttachedRoot } from './session-directories.js'; -import { startSandboxControlEventFeed } from './sandbox-control-runtime.js'; +import { + KiloEventFeedError, + startSandboxControlEventFeed, + withKiloRequestDeadline, +} from './sandbox-control-runtime.js'; export type WorktreeKiloAuth = NonNullable; +type WorktreeKiloFailure = { + directory: string; + reason: KiloEventFeedError['reason'] | 'process_exited' | 'credential_refresh_failed'; +}; + export type WorktreeKiloRuntime = { readonly scopeId: string; readonly directory: string; @@ -42,7 +52,8 @@ export type WorktreeKiloRuntimes = { attach( identity: SessionRequestIdentity, kilo: WorktreeKiloAuth, - env?: Record + env?: Record, + canRefreshCredentials?: () => boolean ): WorktreeKiloAttachment; detach(identity: SessionRequestIdentity): boolean; deleteDirectory(directory: string): Promise; @@ -73,6 +84,9 @@ type RuntimeEntry = { abort: AbortController; roots: Set; runtime?: WorktreeKiloRuntime; + kiloClient?: WrapperKiloClient; + processAbort?: AbortController; + pendingPtys: number; feed?: Awaited>; starting?: Promise; stopped?: Promise; @@ -147,6 +161,7 @@ export function buildWorktreeKiloEnvironment( options: { apiKey: kilo.token, kilocodeToken: kilo.token, + ...(kilo.organizationId ? { kilocodeOrganizationId: kilo.organizationId } : {}), baseURL: kilo.targets.providerBaseUrl, }, }, @@ -166,6 +181,7 @@ export function buildWorktreeKiloEnvironment( KILO_DISABLE_AUTOUPDATE: 'true', KILO_DEBUG_SESSION_INGEST: '1', KILOCODE_TOKEN: kilo.token, + ...(kilo.organizationId ? { KILOCODE_ORGANIZATION_ID: kilo.organizationId } : {}), KILO_AUTH_CONTENT: JSON.stringify({ kilo: { type: 'api', key: kilo.token } }), KILOCODE_BACKEND_BASE_URL: kilo.targets.backendBaseUrl, KILO_API_URL: kilo.targets.backendBaseUrl, @@ -242,7 +258,9 @@ export async function startWorktreeKiloServer( function sameAuth(left: WorktreeKiloAuth, right: WorktreeKiloAuth): boolean { return ( left.scopeId === right.scopeId && - left.token === right.token && + (left.containmentEnabled !== false) === (right.containmentEnabled !== false) && + (left.containmentEnabled === false || left.token === right.token) && + left.organizationId === right.organizationId && left.targets.backendBaseUrl === right.targets.backendBaseUrl && left.targets.providerBaseUrl === right.targets.providerBaseUrl && left.targets.sessionIngestBaseUrl === right.targets.sessionIngestBaseUrl @@ -254,7 +272,7 @@ export function createWorktreeKiloRuntimes(options: { inheritedEnv?: NodeJS.ProcessEnv; startServer?: (options: ServerOptions) => Promise; onEvent?: (runtime: WorktreeKiloRuntime, event: WorktreeKiloEvent) => void; - onUnexpectedClose: () => void; + onUnexpectedClose: (failure: WorktreeKiloFailure) => void; }): WorktreeKiloRuntimes { const entries = new Map(); const directoriesByScope = new Map(); @@ -311,11 +329,26 @@ export function createWorktreeKiloRuntimes(options: { if (root.entry.roots.size === 0) void retire(root.entry); } + function failRuntime(entry: RuntimeEntry, reason: WorktreeKiloFailure['reason']): void { + if (entry.abort.signal.aborted) return; + entry.abort.abort(); + options.onUnexpectedClose({ directory: entry.directory, reason }); + } + async function start( entry: RuntimeEntry, retiring: Promise | undefined ): Promise { - const { abort } = entry; + const abort = new AbortController(); + entry.processAbort = abort; + const stopProcess = () => abort.abort(); + entry.abort.signal.addEventListener('abort', stopProcess, { once: true }); + abort.signal.addEventListener( + 'abort', + () => entry.abort.signal.removeEventListener('abort', stopProcess), + { once: true } + ); + if (entry.abort.signal.aborted) abort.abort(); let server: WorktreeKiloServerHandle | undefined; let serverClosed = false; const closeServer = (): void => { @@ -341,28 +374,70 @@ export function createWorktreeKiloRuntimes(options: { signal: abort.signal, }); entry.stopped = server.stopped; + void server.stopped?.then(() => { + if (!abort.signal.aborted) failRuntime(entry, 'process_exited'); + }); abort.signal.throwIfAborted(); const client = createKiloClient({ baseUrl: server.url, directory: entry.directory }); - const runtime: WorktreeKiloRuntime = { + const kiloClient = createWrapperKiloClient(client, server.url, entry.directory); + entry.kiloClient = { + ...kiloClient, + async createPty(options) { + if (entry.starting) { + throw new WorktreeKiloRuntimeError( + 'session_busy', + 'Worktree credentials are refreshing', + true + ); + } + entry.pendingPtys += 1; + try { + return await kiloClient.createPty(options); + } finally { + entry.pendingPtys -= 1; + } + }, + }; + const runtime: WorktreeKiloRuntime = entry.runtime ?? { scopeId: entry.kilo.scopeId, directory: entry.directory, - env: entry.env, - kiloClient: createWrapperKiloClient(client, server.url, entry.directory), - signal: abort.signal, + get env() { + return entry.env; + }, + get kiloClient() { + if (!entry.kiloClient) { + throw new WorktreeKiloRuntimeError('not_ready', 'Kilo worktree is not ready', true); + } + return entry.kiloClient; + }, + signal: entry.abort.signal, }; + const eventClient = createKiloEventClient({ + baseUrl: server.url, + directory: entry.directory, + }); entry.feed = await withTimeoutAndAbort( startSandboxControlEventFeed({ signal: abort.signal, - open: signal => client.global.event({ signal, sseMaxRetryAttempts: 1 }), + open: signal => + eventClient.global.event({ + signal, + sseMaxRetryAttempts: 1, + onSseError: () => { + if (!signal.aborted) { + throw new KiloEventFeedError('feed_failed', 'Kilo global event feed failed'); + } + }, + }), consume: async stream => { for await (const event of unfilteredKiloEvents(stream)) { if (abort.signal.aborted) return; options.onEvent?.(runtime, event); } }, - onUnexpectedClose: () => { - abort.abort(); - options.onUnexpectedClose(); + onUnexpectedClose: error => { + if (abort.signal.aborted) return; + failRuntime(entry, error instanceof KiloEventFeedError ? error.reason : 'feed_failed'); }, }), { @@ -376,7 +451,8 @@ export function createWorktreeKiloRuntimes(options: { entry.runtime = runtime; return runtime; } catch { - abort.abort(); + if (entry.runtime) failRuntime(entry, 'credential_refresh_failed'); + else entry.abort.abort(); closeServer(); throw new WorktreeKiloRuntimeError('not_ready', 'Kilo worktree failed to start', true); } finally { @@ -384,8 +460,63 @@ export function createWorktreeKiloRuntimes(options: { } } + async function refreshCredentials( + entry: RuntimeEntry, + kilo: WorktreeKiloAuth, + env: Record, + canRefreshCredentials: () => boolean + ): Promise { + const retry = () => + new WorktreeKiloRuntimeError( + 'session_busy', + 'Worktree credentials require an idle runtime', + true + ); + const client = entry.kiloClient; + if (!client || !entry.processAbort || !entry.stopped) { + throw new WorktreeKiloRuntimeError('not_ready', 'Worktree cannot refresh credentials', true); + } + if (entry.pendingPtys > 0 || !canRefreshCredentials()) throw retry(); + try { + const idle = await withKiloRequestDeadline(async signal => { + const statuses = await client.getSessionStatuses(entry.directory, signal); + if (Object.values(statuses).some(status => status.type !== 'idle')) return false; + const url = new URL('/pty', client.serverUrl); + url.searchParams.set('directory', entry.directory); + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error('Worktree PTY probe failed'); + const ptys: unknown = await response.json(); + if (!Array.isArray(ptys)) throw new Error('Worktree PTY probe failed'); + return ptys.length === 0; + }, entry.abort.signal); + if (!idle || entry.pendingPtys > 0 || !canRefreshCredentials()) throw retry(); + } catch (error) { + if (error instanceof WorktreeKiloRuntimeError) throw error; + throw new WorktreeKiloRuntimeError('not_ready', 'Worktree idle probe failed', true); + } + entry.processAbort.abort(); + try { + await withTimeoutAndAbort(entry.stopped, { + signal: entry.abort.signal, + timeoutMs: KILO_STARTUP_TIMEOUT_MS, + timeoutMessage: 'Kilo worktree credential refresh timed out', + abortMessage: 'Kilo worktree credential refresh cancelled', + }); + entry.kilo = { ...kilo, targets: { ...kilo.targets } }; + entry.env = env; + return await start(entry, undefined); + } catch { + failRuntime(entry, 'credential_refresh_failed'); + throw new WorktreeKiloRuntimeError( + 'runtime_unhealthy', + 'Kilo worktree credential refresh failed', + true + ); + } + } + return { - attach(identity, kilo, environment) { + attach(identity, kilo, environment, canRefreshCredentials) { if (closed) { throw new WorktreeKiloRuntimeError('not_ready', 'Kilo worktrees are closed', false); } @@ -411,7 +542,48 @@ export function createWorktreeKiloRuntimes(options: { ); } if (entry?.abort.signal.aborted) { - throw new WorktreeKiloRuntimeError('not_ready', 'Kilo worktree is closed', true); + throw new WorktreeKiloRuntimeError('runtime_unhealthy', 'Kilo worktree is closed', true); + } + if (entry?.runtime && entry.starting) { + throw new WorktreeKiloRuntimeError( + 'session_busy', + 'Worktree credentials are refreshing', + true + ); + } + if (entry && kilo.containmentEnabled === false) { + const env = buildWorktreeKiloEnvironment( + directory, + entry.env.HOME, + kilo, + environment, + options.inheritedEnv + ); + const currentEnv = entry.env; + const changed = Object.keys({ ...currentEnv, ...env }).some( + key => currentEnv[key] !== env[key] + ); + if (changed) { + if ( + !entry.runtime || + entry.starting || + !canRefreshCredentials || + !canRefreshCredentials() || + [...entry.roots].some(current => current.pending.size > 0) + ) { + throw new WorktreeKiloRuntimeError( + 'session_busy', + 'Worktree credentials require an idle runtime', + true + ); + } + const refreshing = entry; + entry.starting = Promise.resolve() + .then(() => refreshCredentials(refreshing, kilo, env, canRefreshCredentials)) + .finally(() => { + refreshing.starting = undefined; + }); + } } if (!entry) { const homeId = createHash('sha256') @@ -435,6 +607,7 @@ export function createWorktreeKiloRuntimes(options: { ), abort: new AbortController(), roots: new Set(), + pendingPtys: 0, }; const homes = homesByDirectory.get(directory) ?? new Set(); homes.add(home); @@ -458,9 +631,11 @@ export function createWorktreeKiloRuntimes(options: { const attempt = Symbol(); attachedRoot.pending.add(attempt); const signal = AbortSignal.any([attachedRoot.abort.signal, entry.abort.signal]); - const ready = entry.runtime - ? Promise.resolve(entry.runtime) - : (entry.starting ??= start(entry, previous?.retiring)); + const ready = + entry.starting ?? + (entry.runtime + ? Promise.resolve(entry.runtime) + : (entry.starting = start(entry, previous?.retiring))); return { ready, signal, @@ -500,8 +675,11 @@ export function createWorktreeKiloRuntimes(options: { homesByDirectory.delete(directory); }, get(directory) { - const runtime = entries.get(directory)?.runtime; - return !closed && runtime && !runtime.signal.aborted ? runtime : undefined; + const entry = entries.get(directory); + const runtime = entry?.starting ? undefined : entry?.runtime; + return !closed && runtime && !runtime.signal.aborted && entry?.feed?.isFresh() + ? runtime + : undefined; }, isHealthy() { return ( @@ -509,7 +687,8 @@ export function createWorktreeKiloRuntimes(options: { [...entries.values()].every( entry => entry.retiring !== undefined || - (!entry.abort.signal.aborted && (!entry.runtime || entry.feed?.isFresh() === true)) + (!entry.abort.signal.aborted && + (entry.starting !== undefined || !entry.runtime || entry.feed?.isFresh() === true)) ) ); }, From dddbeeeeedf2bb5ffb8487b5bd3ed6cc72ef2acb Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 10:28:42 +0200 Subject: [PATCH 09/14] fix(cloud-agent-next): restore control-plane session title generation --- .../test/unit/wrapper/kilo-api.test.ts | 17 +++++++++++------ .../cloud-agent-next/wrapper/src/kilo-api.ts | 2 +- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts b/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts index 4eac5b5f06..cf3d38bf53 100644 --- a/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts +++ b/services/cloud-agent-next/test/unit/wrapper/kilo-api.test.ts @@ -7,6 +7,7 @@ import { } from '../../../wrapper/src/kilo-api.js'; import { createKiloClient, type KiloClient as SDKClient } from '@kilocode/sdk'; import type { SessionCommandResponse, SessionPromptResponse } from '@kilocode/sdk/v2'; +import { isDefaultSessionTitle } from '@kilocode/session-ingest-contracts'; function createSdkClient(): SDKClient { return createKiloClient({ baseUrl: 'http://127.0.0.1:0' }); @@ -548,8 +549,10 @@ describe('createWrapperKiloClient session initialization', () => { vi.unstubAllGlobals(); }); - it('looks up an existing session in its attached directory without importing', async () => { - const fetchMock = vi.fn().mockResolvedValue(Response.json({ id: 'kilo_sess' })); + it('preserves an existing custom-titled session in its attached directory without importing', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(Response.json({ id: 'kilo_sess', title: 'My chosen title' })); vi.stubGlobal('fetch', fetchMock); const client = createWrapperKiloClient(createSdkClient(), 'http://127.0.0.1:0', '/'); @@ -562,7 +565,7 @@ describe('createWrapperKiloClient session initialization', () => { expect(new URL(request.url).searchParams.get('directory')).toBe(workspacePath); }); - it('imports only a missing session with directory in both query and body', async () => { + it('imports a missing session with an auto-title-eligible placeholder and scoped directory', async () => { const fetchMock = vi .fn() .mockResolvedValueOnce(Response.json({ message: 'missing' }, { status: 404 })) @@ -584,15 +587,17 @@ describe('createWrapperKiloClient session initialization', () => { const imported = requests[2]; expect(imported.method).toBe('POST'); expect(new URL(imported.url).pathname).toBe('/kilocode/session-import/session'); - await expect(imported.json()).resolves.toEqual({ + const body = await imported.json(); + expect(isDefaultSessionTitle(body.title)).toBe(true); + expect(body).toEqual({ id: 'kilo_sess', projectID: nativeProjectId, slug: 'kilo_sess', directory: workspacePath, - title: 'Cloud Agent', + title: 'New session - ' + new Date(body.timeCreated).toISOString(), version: '7.4.20', timeCreated: expect.any(Number), - timeUpdated: expect.any(Number), + timeUpdated: body.timeCreated, }); }); diff --git a/services/cloud-agent-next/wrapper/src/kilo-api.ts b/services/cloud-agent-next/wrapper/src/kilo-api.ts index dece921c1d..9badda8ced 100644 --- a/services/cloud-agent-next/wrapper/src/kilo-api.ts +++ b/services/cloud-agent-next/wrapper/src/kilo-api.ts @@ -418,7 +418,7 @@ export function createWrapperKiloClient( id: sessionId, projectID: project.id, slug: sessionId.slice(0, 24), - title: 'Cloud Agent', + title: 'New session - ' + new Date(now).toISOString(), version: '7.4.20', timeCreated: now, timeUpdated: now, From 776981a5d647eafc95244ca28f4746d4f95bd196 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 13:53:14 +0200 Subject: [PATCH 10/14] fix(cloud-agent-next): restore shared worktree branches and auto-commit --- .specs/cloud-agent-session.md | 17 +- .../cloud-agent-next/CloudAgentProvider.tsx | 9 +- .../CloudAgentWorkspaceTabs.tsx | 2 +- .../router/handlers/session-worktree.test.ts | 99 +-- .../src/router/handlers/session-worktree.ts | 4 +- .../src/sandbox-session/SandboxSession.ts | 49 +- .../src/session/session-prepare.test.ts | 625 ++++++++++++------ .../src/session/session-registration.ts | 21 +- services/cloud-agent-next/test/e2e/client.ts | 4 +- .../cloud-agent-next/test/e2e/lifecycle.ts | 4 +- .../test/integration/sandbox-control.test.ts | 568 ++++++++++------ .../test/unit/wrapper/auto-commit.test.ts | 207 +++--- .../wrapper/src/control/apply-attach.test.ts | 374 ++++++++--- .../wrapper/src/control/apply-attach.ts | 71 +- .../control/sandbox-control-handlers.test.ts | 11 +- 15 files changed, 1336 insertions(+), 729 deletions(-) diff --git a/.specs/cloud-agent-session.md b/.specs/cloud-agent-session.md index 03f48a2174..3e8b08f228 100644 --- a/.specs/cloud-agent-session.md +++ b/.specs/cloud-agent-session.md @@ -146,8 +146,14 @@ repository. 6. Each chat MUST keep its own streamed output, transcript, questions, permissions, stop control, and recovery. Stopping or answering one chat MUST NOT affect another chat in the worktree. -7. Grouped chats MUST NOT create platform-managed auto-commits. An agent MAY - still perform explicit Git operations during its chat. +7. Grouped chats MUST support platform-managed auto-commit and push after + successful turns, enabled by default for new worktrees and honoring explicit + opt-out. New sibling chats MUST inherit the source chat's auto-commit setting. + Platform-managed commit and push operations MUST be serialized per worktree. + Each commit captures the shared checkout and MAY include sibling-chat edits. + Rebuilt worktrees MUST restore their pushed working branch rather than restart + from the repository's default branch. An agent MAY still perform explicit Git + operations during its chat. 8. Deleting one chat MUST leave its siblings usable. Deleting the final chat MUST remove the worktree group from the session list. 9. Existing ungrouped and legacy chats MUST remain individual sidebar rows and @@ -228,6 +234,13 @@ The following use SHOULD and are not enforced today: ## Changelog +### 2026-09-01 -- Shared worktree auto-commit + +- Restored automatic commit and push for shared worktrees, enabled by default, + with explicit opt-out, inherited sibling settings, and serialized per-worktree + Git finalization. Commits are checkpoints of the shared checkout, not isolated + changes belonging to one chat. + ### 2026-08-26 -- Shared worktree pilot - Defined selectable worktree sidebar rows, live per-chat header tabs, diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx index 8a22138cf1..ba1b2add08 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -158,17 +158,12 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi api: { send: async input => { - const activeSession = managerRef.current - ? storeRef.current.get(managerRef.current.atoms.fetchedSessionData) - : null; - const autoCommit = - activeSession?.cloudAgentSessionId !== input.sessionId || !activeSession.worktreeId; if (organizationId) { return trpcClient.organizations.cloudAgentNext.sendMessage.mutate( { cloudAgentSessionId: input.sessionId, payload: input.payload, - autoCommit, + autoCommit: true, organizationId, messageId: input.messageId, attachments: input.attachments ?? input.images, @@ -180,7 +175,7 @@ export function CloudAgentProvider({ children, organizationId }: CloudAgentProvi { cloudAgentSessionId: input.sessionId, payload: input.payload, - autoCommit, + autoCommit: true, messageId: input.messageId, attachments: input.attachments ?? input.images, }, diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx index babea90250..229b93701f 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.tsx @@ -47,7 +47,7 @@ const tabTriggerClassName = 'h-full min-w-0 gap-1.5 px-2 text-xs hover:bg-accent/60 focus-visible:ring-offset-0 focus-visible:ring-inset data-[state=active]:border-0 data-[state=active]:bg-transparent data-[state=active]:shadow-none [@media(any-pointer:coarse)]:min-w-11'; const tabActionClassName = 'text-muted-foreground h-full w-8 shrink-0 rounded-none px-0 focus-visible:ring-inset [@media(any-pointer:coarse)]:w-11'; -const renameHint = 'Double-click to rename. Press F2 when focused.'; +const renameHint = 'Double-click to rename.'; export function CloudAgentWorkspaceTabs({ activeTabId, diff --git a/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts index 1cf94a29e7..7df3a84227 100644 --- a/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts @@ -131,7 +131,7 @@ function sourceMetadata(options?: { }, agent: { mode: 'code', model: 'test-model', appendSystemPrompt: 'Stay focused' }, profile: { envVars: { PROFILE_SETTING: 'preserved' }, setupCommands: ['pnpm install'] }, - finalization: { autoCommit: false, condenseOnComplete: true }, + finalization: { autoCommit: true, condenseOnComplete: true }, workspace: { sandboxId: SHARED_SANDBOX_ID, sandboxProvider: 'cloudflare', @@ -182,7 +182,6 @@ function destinationMetadata(source: SessionMetadata): SessionMetadata { auth: { kiloSessionId: DESTINATION_KILO_SESSION_ID, kilocodeToken: CURRENT_AUTH_TOKEN }, ...(repository ? { repository } : {}), workspace, - finalization: { ...source.finalization, autoCommit: false }, }; } @@ -497,13 +496,6 @@ describe('createWorktreeChat request validation and authorization', () => { workspace: { ...metadata.workspace, devcontainerRequested: true }, }), ], - [ - 'enabled auto-commit', - (metadata: SessionMetadata) => ({ - ...metadata, - finalization: { ...metadata.finalization, autoCommit: true }, - }), - ], [ 'repository mismatch', (metadata: SessionMetadata) => ({ @@ -529,6 +521,20 @@ describe('createWorktreeChat request validation and authorization', () => { }); describe('createWorktreeChat ownership, metadata, and control-plane routing', () => { + it.each([ + { autoCommit: true, condenseOnComplete: true }, + { autoCommit: false, condenseOnComplete: true }, + { condenseOnComplete: true }, + undefined, + ])('inherits source finalization without coercion: %j', async finalization => { + const metadata = { ...sourceMetadata(), finalization }; + const { caller, input, destinationStub } = fixture({ metadata }); + + await caller.createWorktreeChat(input); + + expect(destinationStub.registerSession.mock.calls[0]?.[0]?.finalization).toEqual(finalization); + }); + it('records canonical IDs and source routing while new worktree creation is disabled', async () => { const { caller, @@ -615,7 +621,7 @@ describe('createWorktreeChat ownership, metadata, and control-plane routing', () }, workspace: metadata.workspace, profile: metadata.profile, - finalization: { autoCommit: false, condenseOnComplete: true }, + finalization: { autoCommit: true, condenseOnComplete: true }, }); expect(sandboxSessionNamespace.idFromName).toHaveBeenCalledWith( `${USER_ID}:${DESTINATION_WORKSPACE_ID}` @@ -797,41 +803,48 @@ describe('createWorktreeChat registration rollback and unknown-outcome reconcili expect(markReconcilePendingMock).not.toHaveBeenCalled(); }); - it('keeps ambiguous registration ownership and reconciles existing metadata on the same-key retry', async () => { - const { caller, input, metadata, destinationStub } = fixture({ - ownershipResults: [[ownershipRow()], [ownershipRow()], [destinationOwnershipRow()]], - }); - destinationStub.registerSession.mockRejectedValueOnce( - new Error('registration outcome unknown') - ); - - await expect(caller.createWorktreeChat(input)).rejects.toThrow('registration outcome unknown'); - expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.anything(), { - rowId: LEDGER_ROW_ID, - }); - expect(deleteSessionForCloudAgentMock).not.toHaveBeenCalled(); - expect(settleOperationMock).not.toHaveBeenCalled(); + it.each([true, false, undefined])( + 'reconciles ambiguous registration without changing stored autoCommit=%s', + async autoCommit => { + const { caller, input, metadata, destinationStub } = fixture({ + ownershipResults: [[ownershipRow()], [ownershipRow()], [destinationOwnershipRow()]], + }); + destinationStub.registerSession.mockRejectedValueOnce( + new Error('registration outcome unknown') + ); + + await expect(caller.createWorktreeChat(input)).rejects.toThrow( + 'registration outcome unknown' + ); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.anything(), { + rowId: LEDGER_ROW_ID, + }); + expect(deleteSessionForCloudAgentMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); - const progress = recordOperationProgressMock.mock.calls[0]?.[2] as Record; - admitOperationMock.mockResolvedValueOnce({ - admission: 'duplicate_reconcile_pending', - row: ledgerRow({ status: 'reconcile_pending', canonical_result: progress }), - }); - destinationStub.getMetadata.mockResolvedValueOnce(destinationMetadata(metadata)); + const progress = recordOperationProgressMock.mock.calls[0]?.[2] as Record; + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: ledgerRow({ status: 'reconcile_pending', canonical_result: progress }), + }); + const registered = destinationMetadata(metadata); + registered.finalization = { ...registered.finalization, autoCommit }; + destinationStub.getMetadata.mockResolvedValueOnce(registered); - await expect(caller.createWorktreeChat(input)).resolves.toEqual({ - cloudAgentSessionId: DESTINATION_WORKSPACE_ID, - kiloSessionId: DESTINATION_KILO_SESSION_ID, - worktreeId: WORKTREE_ID, - replayed: true, - }); - expect(createSessionForCloudAgentMock).toHaveBeenCalledTimes(1); - expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); - expect(settleOperationMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ status: 'completed' }) - ); - }); + await expect(caller.createWorktreeChat(input)).resolves.toEqual({ + cloudAgentSessionId: DESTINATION_WORKSPACE_ID, + kiloSessionId: DESTINATION_KILO_SESSION_ID, + worktreeId: WORKTREE_ID, + replayed: true, + }); + expect(createSessionForCloudAgentMock).toHaveBeenCalledTimes(1); + expect(destinationStub.registerSession).toHaveBeenCalledTimes(1); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ status: 'completed' }) + ); + } + ); it('reads committed metadata before retrying a retryable registration transport failure', async () => { const { caller, input, metadata, destinationStub } = fixture(); diff --git a/services/cloud-agent-next/src/router/handlers/session-worktree.ts b/services/cloud-agent-next/src/router/handlers/session-worktree.ts index 0eeda93745..a70dcb26f8 100644 --- a/services/cloud-agent-next/src/router/handlers/session-worktree.ts +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.ts @@ -236,7 +236,6 @@ async function loadWorktreeSource( workspace.worktreeId !== worktreeId || workspace.workspacePath !== getWorktreeWorkspacePath(input.kilocodeOrganizationId, ctx.userId, worktreeId) || - metadata.finalization?.autoCommit !== false || (ownership.gitUrl !== null && normalizeGitUrl(ownership.gitUrl) !== canonicalRepositoryUrl(repository)) ) { @@ -315,7 +314,7 @@ function buildRegistrationInput( repository, workspace, ...(source.metadata.profile ? { profile: source.metadata.profile } : {}), - finalization: { ...source.metadata.finalization, autoCommit: false }, + ...(source.metadata.finalization ? { finalization: source.metadata.finalization } : {}), }; } @@ -335,7 +334,6 @@ function assertRegisteredMetadata( metadata.identity.orgId !== source.metadata.identity.orgId || metadata.identity.createdOnPlatform !== source.metadata.identity.createdOnPlatform || metadata.auth.kiloSessionId !== progress.kiloSessionId || - metadata.finalization?.autoCommit !== false || !workspace || workspace.worktreeId !== source.worktreeId || workspace.workspacePath !== source.workspace.workspacePath || diff --git a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts index 5ec62b297f..df65b5a00e 100644 --- a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts +++ b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts @@ -843,19 +843,18 @@ export class SandboxSession extends DurableObject { : undefined; const existing = this.terminalLifecycle.getStoredMetadata(); if (existing) { - const metadata = this.enforceGroupedFinalization(existing); try { - validateControlSessionOptions(metadata); + validateControlSessionOptions(existing); } catch (error) { return { success: false, error: error instanceof Error ? error.message : 'Unsupported session options', }; } - if (initialMessage && !metadata.initialMessage) { + if (initialMessage && !existing.initialMessage) { this.ctx.storage.kv.put( METADATA_KEY, - serializeSessionMetadata({ ...metadata, initialMessage }) + serializeSessionMetadata({ ...existing, initialMessage }) ); } return { success: true }; @@ -877,9 +876,6 @@ export class SandboxSession extends DurableObject { upstreamBranch: input.repository.upstreamBranch ?? input.repository.branch, } : input.repository; - const finalization = input.workspace?.worktreeId - ? { ...input.finalization, autoCommit: false } - : input.finalization; const metadata = parseSessionMetadata({ metadataSchemaVersion: 2, identity: input.identity, @@ -890,7 +886,7 @@ export class SandboxSession extends DurableObject { workspace: input.workspace ?? {}, ...(input.callback ? { callback: input.callback } : {}), ...(input.profile ? { profile: input.profile } : {}), - ...(finalization ? { finalization } : {}), + ...(input.finalization ? { finalization: input.finalization } : {}), lifecycle: { version: 1, timestamp: Date.now() }, }); if (this.deletedWorktreeId) return { success: false, error: 'worktree_deleting' }; @@ -1095,18 +1091,18 @@ export class SandboxSession extends DurableObject { origin: 'initial' | 'followup' ): Promise { const epoch = this.terminalLifecycle.captureEpoch(); - const storedMetadata = this.terminalLifecycle.getStoredMetadata(); - if (epoch === null || !storedMetadata || this.deletedWorktreeId) { + const metadata = this.terminalLifecycle.getStoredMetadata(); + if (epoch === null || !metadata || this.deletedWorktreeId) { return { success: false, code: 'NOT_FOUND', error: 'Session not found' }; } - const metadata = this.enforceGroupedFinalization(storedMetadata); - const replayInput = metadata.workspace?.worktreeId - ? { ...input, finalization: { ...input.finalization, autoCommit: false } } - : input; const admissionInput = metadata.workspace?.worktreeId ? { ...input, - finalization: { ...metadata.finalization, ...input.finalization, autoCommit: false }, + finalization: { + ...metadata.finalization, + ...input.finalization, + autoCommit: input.finalization?.autoCommit ?? metadata.finalization?.autoCommit ?? true, + }, } : input; const messageId = input.turn.messageId; @@ -1173,7 +1169,7 @@ export class SandboxSession extends DurableObject { latestMetadata.workspace?.worktreeId ? latestMetadata.finalization : undefined ); if ( - !matchesSessionMessageReplay(frozen, intent ?? replayInput) || + !matchesSessionMessageReplay(frozen, intent ?? input) || (intent && frozen.intent && (intent.agent.variant !== frozen.intent.agent.variant || @@ -1250,8 +1246,7 @@ export class SandboxSession extends DurableObject { options?: { allowCreate?: boolean } ): Promise { if (this.deletedWorktreeId) return; - const storedMetadata = this.terminalLifecycle.getStoredMetadata(); - const metadata = storedMetadata ? this.enforceGroupedFinalization(storedMetadata) : null; + const metadata = this.terminalLifecycle.getStoredMetadata(); const epoch = this.terminalLifecycle.captureEpoch(); const sandboxId = metadata?.workspace?.sandboxId; const kiloSessionId = metadata?.auth.kiloSessionId; @@ -1540,9 +1535,6 @@ export class SandboxSession extends DurableObject { this.terminalLifecycle.recordAttachment({ metadata, sandboxId, wrapperInstanceId, epoch }); recorder.finalize({ status: 'completed' }); phase = 'prompt'; - const finalization = metadata.workspace?.worktreeId - ? { ...intent.finalization, autoCommit: false } - : intent.finalization; await dispatch('prompt', async () => { const prompt = await wait(async () => controlRequestResult( @@ -1565,7 +1557,7 @@ export class SandboxSession extends DurableObject { ...(model !== undefined ? { model } : {}), ...(intent.agent.variant !== undefined ? { variant: intent.agent.variant } : {}), }, - ...(finalization ? { finalization } : {}), + ...(intent.finalization ? { finalization: intent.finalization } : {}), ...(attachments.length ? { attachments } : {}), }, }) @@ -1978,19 +1970,6 @@ export class SandboxSession extends DurableObject { ); } - private enforceGroupedFinalization(metadata: SessionMetadata): SessionMetadata { - if (this.deletedWorktreeId || this.terminalLifecycle.isBlocked()) return metadata; - if (!metadata.workspace?.worktreeId || metadata.finalization?.autoCommit === false) { - return metadata; - } - const updated = serializeSessionMetadata({ - ...metadata, - finalization: { ...metadata.finalization, autoCommit: false }, - }); - this.ctx.storage.kv.put(METADATA_KEY, updated); - return updated; - } - private async requestSessionOperation( operation: 'session.permission.resolve' | 'session.question.resolve', payload: unknown diff --git a/services/cloud-agent-next/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 200aae01e4..d6e7f8adb2 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -26,6 +26,7 @@ import { sessionCreateIntentFingerprint, SESSION_CREATE_ABANDON_AFTER_SECONDS, SESSION_CREATE_ABANDONED_OUTCOME_CODE, + SESSION_CREATE_FINALIZATION_VERSION_KEY, SESSION_CREATE_INTENT_FINGERPRINT_KEY, SESSION_CREATE_TOMBSTONED_IDS_KEY, SESSION_CREATE_WORKTREE_ENABLED_KEY, @@ -584,67 +585,74 @@ describe('createSessionWithLedger admission ladder', () => { } ); - it('derives the first browser worktree from its workspace ID before ownership and admission', async () => { - const doStub = makeDoStub(); - const ctx = makeContext(doStub); - ctx.env.CONTROL_PLANE_IDS = '*'; - ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; - generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); - const request = makeRequest({ - repository: { type: 'github', repo: 'acme/repo', branch: 'main' }, - finalization: { autoCommit: true, condenseOnComplete: true }, - options: { - operationKey: OPERATION_KEY, - createdOnPlatform: 'cloud-agent-web', - clientProvenance: 'browser', - }, - }); + it.each([ + { autoCommit: true, condenseOnComplete: true }, + { autoCommit: false, condenseOnComplete: true }, + { condenseOnComplete: true }, + {}, + undefined, + ])( + 'derives the first browser worktree while preserving requested finalization %j', + async finalization => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); + const request = makeRequest({ + repository: { type: 'github', repo: 'acme/repo', branch: 'main' }, + finalization, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + }); - const result = await runCreate(ctx, request); + const result = await runCreate(ctx, request); - expect(createCliSessionMock).toHaveBeenCalledWith( - KILO_SESSION_ID, - WORKSPACE_SESSION_ID, - USER_ID, - expect.any(Object), - undefined, - 'cloud-agent-web', - expect.any(String), - 'https://github.com/acme/repo', - undefined, - WORKTREE_ID, - { sandboxId: 'sb-test-123', provider: 'cloudflare' } - ); - expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( - expect.objectContaining({ - repository: expect.objectContaining({ branch: 'main' }), - finalization: { autoCommit: false, condenseOnComplete: true }, - workspace: expect.objectContaining({ - worktreeId: WORKTREE_ID, - workspacePath: `/workspace/${USER_ID}/worktrees/${WORKTREE_ID}`, - sandboxId: 'sb-test-123', - sandboxProvider: 'cloudflare', - }), - }) - ); - expect(recordOperationProgressMock).toHaveBeenNthCalledWith( - 1, - expect.any(Object), - ROW_ID, - expect.objectContaining({ + expect(createCliSessionMock).toHaveBeenCalledWith( + KILO_SESSION_ID, + WORKSPACE_SESSION_ID, + USER_ID, + expect.any(Object), + undefined, + 'cloud-agent-web', + expect.any(String), + 'https://github.com/acme/repo', + undefined, + WORKTREE_ID, + { sandboxId: 'sb-test-123', provider: 'cloudflare' } + ); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + repository: expect.objectContaining({ branch: 'main' }), + finalization, + workspace: expect.objectContaining({ + worktreeId: WORKTREE_ID, + workspacePath: `/workspace/${USER_ID}/worktrees/${WORKTREE_ID}`, + sandboxId: 'sb-test-123', + sandboxProvider: 'cloudflare', + }), + }) + ); + expect(recordOperationProgressMock).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + ROW_ID, + expect.objectContaining({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: true, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 2, + createIntentFingerprint: await sessionCreateIntentFingerprint(request), + }) + ); + expect(result).toEqual({ cloudAgentSessionId: WORKSPACE_SESSION_ID, - [SESSION_CREATE_WORKTREE_ENABLED_KEY]: true, - createIntentFingerprint: await sessionCreateIntentFingerprint({ - ...request, - finalization: { autoCommit: false, condenseOnComplete: true }, - }), - }) - ); - expect(result).toEqual({ - cloudAgentSessionId: WORKSPACE_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - }); - }); + kiloSessionId: KILO_SESSION_ID, + }); + } + ); it.each([undefined, '', 'another-user'])( 'keeps browser creates on the control plane without worktree enrollment: %s', @@ -684,16 +692,21 @@ describe('createSessionWithLedger admission ladder', () => { } ); - it.each([true, false])( - 'replays the original worktree decision after rollout changes from %s', - async worktreeEnabled => { + it.each([ + { worktreeEnabled: true, autoCommit: true }, + { worktreeEnabled: true, autoCommit: false }, + { worktreeEnabled: true, autoCommit: undefined }, + { worktreeEnabled: false, autoCommit: true }, + ])( + 'replays worktreeEnabled=$worktreeEnabled autoCommit=$autoCommit after rollout changes', + async ({ worktreeEnabled, autoCommit }) => { const doStub = makeDoStub(); const ctx = makeContext(doStub); ctx.env.CONTROL_PLANE_IDS = '*'; ctx.env.WORKTREE_CREATION_ENABLED_IDS = worktreeEnabled ? '*' : ''; generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); const request = makeRequest({ - finalization: { autoCommit: true }, + finalization: { autoCommit }, options: { operationKey: OPERATION_KEY, createdOnPlatform: 'cloud-agent-web', @@ -706,6 +719,7 @@ describe('createSessionWithLedger admission ladder', () => { ...recordOperationProgressMock.mock.calls.map(call => call[2]) ); expect(storedProgress[SESSION_CREATE_WORKTREE_ENABLED_KEY]).toBe(worktreeEnabled); + expect(storedProgress[SESSION_CREATE_FINALIZATION_VERSION_KEY]).toBe(2); ctx.env.WORKTREE_CREATION_ENABLED_IDS = worktreeEnabled ? '' : '*'; admitOperationMock.mockResolvedValueOnce({ admission: 'duplicate_settled', @@ -719,7 +733,7 @@ describe('createSessionWithLedger admission ladder', () => { expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( - expect.objectContaining({ finalization: { autoCommit: !worktreeEnabled } }) + expect.objectContaining({ finalization: { autoCommit } }) ); } ); @@ -748,45 +762,56 @@ describe('createSessionWithLedger admission ladder', () => { expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); }); - it('replays pre-flag browser creates with worktrees disabled and their original auto-commit policy', async () => { - const request = makeRequest({ - finalization: { autoCommit: true }, - options: { - operationKey: OPERATION_KEY, - createdOnPlatform: 'cloud-agent-web', - clientProvenance: 'browser', - }, - }); - const effectiveRequest = { ...request, finalization: { autoCommit: false } }; - admitOperationMock.mockResolvedValue({ - admission: 'duplicate_settled', - row: makeLedgerRow({ - status: 'completed', - canonical_result: { - cloudAgentSessionId: WORKSPACE_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: - await sessionCreateIntentFingerprint(effectiveRequest), + it.each([ + { worktreeEnabled: undefined, autoCommit: true }, + { worktreeEnabled: undefined, autoCommit: undefined }, + { worktreeEnabled: true, autoCommit: true }, + { worktreeEnabled: true, autoCommit: undefined }, + { worktreeEnabled: false, autoCommit: true }, + ])( + 'replays legacy worktreeEnabled=$worktreeEnabled autoCommit=$autoCommit after rollout changes', + async ({ worktreeEnabled, autoCommit }) => { + const request = makeRequest({ + finalization: { autoCommit }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', }, - }), - }); - const doStub = makeDoStub(); - const ctx = makeContext(doStub); - ctx.env.CONTROL_PLANE_IDS = '*'; + }); + const effectiveRequest = + worktreeEnabled === false ? request : { ...request, finalization: { autoCommit: false } }; + admitOperationMock.mockResolvedValue({ + admission: 'duplicate_settled', + row: makeLedgerRow({ + status: 'completed', + canonical_result: { + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + ...(worktreeEnabled !== undefined + ? { [SESSION_CREATE_WORKTREE_ENABLED_KEY]: worktreeEnabled } + : {}), + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: + await sessionCreateIntentFingerprint(effectiveRequest), + }, + }), + }); + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = ''; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = worktreeEnabled === false ? '*' : ''; - await expect(runCreate(ctx, request)).resolves.toEqual({ - cloudAgentSessionId: WORKSPACE_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - replayed: true, - }); - await expect(runCreate(ctx, effectiveRequest)).resolves.toEqual({ - cloudAgentSessionId: WORKSPACE_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - replayed: true, - }); - expect(createCliSessionMock).not.toHaveBeenCalled(); - expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); - }); + await expect(runCreate(ctx, request)).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + } + ); it('keeps organization worktrees scoped to arbitrary OAuth owner IDs', async () => { const organizationId = '33c8c114-791f-463c-9f7f-fe74b284cfcf'; @@ -821,7 +846,7 @@ describe('createSessionWithLedger admission ladder', () => { worktreeId: WORKTREE_ID, workspacePath: `/workspace/${organizationId}/oauth-google-1234/worktrees/${WORKTREE_ID}`, }), - finalization: { autoCommit: false }, + finalization: { autoCommit: true }, }) ); }); @@ -1247,6 +1272,7 @@ describe('createSessionWithLedger admission ladder', () => { kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 2, createIntentFingerprint: expect.any(String), }); }); @@ -2008,6 +2034,7 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 1, createIntentFingerprint: expect.any(String), }); expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { @@ -2106,85 +2133,228 @@ describe('createSessionWithLedger worktree rollout and ownership reconciliation' }; } - it('recovers committed ownership after worktrees are disabled without reallocating or duplicating the initial turn', async () => { - const input = request(); - const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); - const ctx = context(doStub); - let ownershipCommitted = false; - createCliSessionMock.mockImplementationOnce(async () => { - ownershipCommitted = true; - throw new Error('ownership response lost after commit'); - }); + it.each([true, false, undefined])( + 'recovers committed ownership with autoCommit=%s after rollout changes without changing the initial turn', + async autoCommit => { + const input = request({ finalization: { autoCommit, condenseOnComplete: true } }); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + let ownershipCommitted = false; + createCliSessionMock.mockImplementationOnce(async () => { + ownershipCommitted = true; + throw new Error('ownership response lost after commit'); + }); - await expect(runCreate(ctx, input)).rejects.toThrow('ownership response lost after commit'); - ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; + await expect(runCreate(ctx, input)).rejects.toThrow('ownership response lost after commit'); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; - expect(ownershipCommitted).toBe(true); - expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { rowId: ROW_ID }); - expect(settleOperationMock).not.toHaveBeenCalled(); - expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); - expect(deleteCliSessionMock).not.toHaveBeenCalled(); + expect(ownershipCommitted).toBe(true); + expect(markReconcilePendingMock).toHaveBeenCalledWith(expect.any(Object), { rowId: ROW_ID }); + expect(settleOperationMock).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); - const storedProgress = Object.assign( - {}, - ...recordOperationProgressMock.mock.calls.map(call => call[2]) + const storedProgress = Object.assign( + {}, + ...recordOperationProgressMock.mock.calls.map(call => call[2]) + ); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ status: 'reconcile_pending', canonical_result: storedProgress }), + }); + getPgDbMock.mockReturnValue(makeDb([[ownershipRow()], [{ email: 'test@example.com' }]])); + const initialTurn = input.initialTurn; + if (!initialTurn || initialTurn.type !== 'prompt') throw new Error('Expected a prompt'); + + const result = await runCreate(ctx, { + ...input, + initialTurn: { ...initialTurn, id: 'msg_retry_original_identity' }, + }); + + expect(result).toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); + expect(createCliSessionMock).toHaveBeenCalledTimes(1); + expect(generateSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); + expect(generateSandboxRoutingTargetMock).toHaveBeenCalledTimes(1); + expect(createSessionReportMock).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + identity: expect.objectContaining({ sessionId: WORKSPACE_SESSION_ID, userId: USER_ID }), + auth: expect.objectContaining({ kiloSessionId: KILO_SESSION_ID }), + finalization: { autoCommit, condenseOnComplete: true }, + workspace: expect.objectContaining({ + sandboxId, + sandboxProvider: 'cloudflare', + worktreeId: WORKTREE_ID, + workspacePath: `/workspace/${USER_ID}/worktrees/${WORKTREE_ID}`, + }), + message: { + initialTurn: expect.objectContaining({ + messageId: INITIAL_MESSAGE_ID, + prompt: 'Build the feature', + }), + }, + }) + ); + expect(settleOperationMock).toHaveBeenCalledTimes(1); + expect(settleOperationMock).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + rowId: ROW_ID, + status: 'completed', + canonicalResult: { + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + }, + }) + ); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + } + ); + + describe.each(['duplicate_settled', 'takeover', 'duplicate_reconcile_pending'] as const)( + '%s finalization identity', + admission => { + it.each([ + { autoCommit: false, changedAutoCommit: true }, + { autoCommit: true, changedAutoCommit: false }, + { autoCommit: undefined, changedAutoCommit: false }, + ])( + 'rejects changing new autoCommit=$autoCommit to $changedAutoCommit', + async ({ autoCommit, changedAutoCommit }) => { + const input = request({ finalization: { autoCommit } }); + const doStub = makeDoStub(); + const ctx = context(doStub); + await runCreate(ctx, input); + const storedProgress: Record = Object.assign( + {}, + ...recordOperationProgressMock.mock.calls.map(call => call[2]) + ); + vi.clearAllMocks(); + const db = makeDb([[ownershipRow()]]); + const select = vi.spyOn(db, 'select'); + getPgDbMock.mockReturnValue(db); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; + admitOperationMock.mockResolvedValueOnce({ + admission, + row: makeLedgerRow({ + status: admission === 'duplicate_settled' ? 'completed' : 'reconcile_pending', + canonical_result: storedProgress, + }), + }); + + await expect( + runCreate(ctx, { ...input, finalization: { autoCommit: changedAutoCommit } }) + ).rejects.toMatchObject({ + code: 'BAD_REQUEST', + message: 'session_creation_failed', + }); + + expect(select).not.toHaveBeenCalled(); + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.getMessageResult).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + } + ); + } + ); + + it('uses requested finalization when taking over before any allocation was recorded', async () => { + const input = request(); + const doStub = makeDoStub(); + admitOperationMock.mockResolvedValueOnce({ admission: 'takeover', row: makeLedgerRow() }); + + await runCreate(context(doStub), input); + + expect(recordOperationProgressMock).toHaveBeenNthCalledWith( + 1, + expect.any(Object), + ROW_ID, + expect.objectContaining({ + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 2, + [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint(input), + }) + ); + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( + expect.objectContaining({ + finalization: input.finalization, + workspace: expect.objectContaining({ worktreeId: WORKTREE_ID }), + }) ); + }); + + it.each([null, 0, 3, '2', false])( + 'rejects malformed persisted finalization version %j before effects', + async finalizationVersion => { + const input = request(); + const doStub = makeDoStub(); + const db = makeDb([[ownershipRow()]]); + const select = vi.spyOn(db, 'select'); + getPgDbMock.mockReturnValue(db); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input, { + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: finalizationVersion, + }), + }), + }); + + await expect(runCreate(context(doStub), input)).rejects.toMatchObject({ + code: 'CONFLICT', + message: 'creation_in_progress', + }); + + expect(select).not.toHaveBeenCalled(); + expect(doStub.getMetadata).not.toHaveBeenCalled(); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(settleOperationMock).not.toHaveBeenCalled(); + } + ); + + it('reconciles legacy persisted metadata without enabling auto-commit', async () => { + const input = request(); + const metadata = { + identity: { sessionId: WORKSPACE_SESSION_ID }, + finalization: { autoCommit: false, condenseOnComplete: true }, + }; + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(metadata) }); + const ctx = context(doStub); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; admitOperationMock.mockResolvedValueOnce({ admission: 'duplicate_reconcile_pending', - row: makeLedgerRow({ status: 'reconcile_pending', canonical_result: storedProgress }), + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: await canonicalProgress(input, { + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: true, + }), + }), }); getPgDbMock.mockReturnValue(makeDb([[ownershipRow()], [{ email: 'test@example.com' }]])); - const initialTurn = input.initialTurn; - if (!initialTurn || initialTurn.type !== 'prompt') throw new Error('Expected a prompt'); - - const result = await runCreate(ctx, { - ...input, - initialTurn: { ...initialTurn, id: 'msg_retry_original_identity' }, - }); - expect(result).toEqual({ + await expect(runCreate(ctx, input)).resolves.toEqual({ cloudAgentSessionId: WORKSPACE_SESSION_ID, kiloSessionId: KILO_SESSION_ID, replayed: true, }); - expect(createCliSessionMock).toHaveBeenCalledTimes(1); - expect(generateSessionIdMock).toHaveBeenCalledTimes(1); - expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); - expect(generateSandboxRoutingTargetMock).toHaveBeenCalledTimes(1); - expect(createSessionReportMock).toHaveBeenCalledTimes(1); - expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); - expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledWith( - expect.objectContaining({ - identity: expect.objectContaining({ sessionId: WORKSPACE_SESSION_ID, userId: USER_ID }), - auth: expect.objectContaining({ kiloSessionId: KILO_SESSION_ID }), - finalization: { autoCommit: false, condenseOnComplete: true }, - workspace: expect.objectContaining({ - sandboxId, - sandboxProvider: 'cloudflare', - worktreeId: WORKTREE_ID, - workspacePath: `/workspace/${USER_ID}/worktrees/${WORKTREE_ID}`, - }), - message: { - initialTurn: expect.objectContaining({ - messageId: INITIAL_MESSAGE_ID, - prompt: 'Build the feature', - }), - }, - }) - ); - expect(settleOperationMock).toHaveBeenCalledTimes(1); - expect(settleOperationMock).toHaveBeenCalledWith( - expect.any(Object), - expect.objectContaining({ - rowId: ROW_ID, - status: 'completed', - canonicalResult: { - cloudAgentSessionId: WORKSPACE_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - }, - }) - ); - expect(deleteCliSessionMock).not.toHaveBeenCalled(); + + expect(metadata.finalization.autoCommit).toBe(false); + expect(doStub.getMessageResult).toHaveBeenCalledExactlyOnceWith(INITIAL_MESSAGE_ID); + expect(doStub.createSessionWithInitialAdmission).not.toHaveBeenCalled(); + expect(doStub.registerSession).not.toHaveBeenCalled(); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(createCliSessionMock).not.toHaveBeenCalled(); }); it('rejects bot retries of a recorded browser worktree creation', async () => { @@ -2273,49 +2443,88 @@ describe('createSessionWithLedger worktree rollout and ownership reconciliation' expect(deleteCliSessionMock).not.toHaveBeenCalled(); }); - it('recovers pre-flag ownership progress with worktrees disabled and the original allocation', async () => { - const input = request(); - const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); - const ctx = context(doStub); - createCliSessionMock.mockRejectedValueOnce(new Error('ownership request interrupted')); - - await expect(runCreate(ctx, input)).rejects.toThrow('ownership request interrupted'); - ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; - expect(markReconcilePendingMock).toHaveBeenCalledTimes(1); - expect(settleOperationMock).not.toHaveBeenCalled(); - - admitOperationMock.mockResolvedValueOnce({ - admission: 'duplicate_reconcile_pending', - row: makeLedgerRow({ - status: 'reconcile_pending', - canonical_result: await canonicalProgress(input), - }), - }); - getPgDbMock.mockReturnValue(makeDb([[], [{ email: 'test@example.com' }]])); - createCliSessionMock.mockResolvedValueOnce({ - status: 'ready', - clone: { sessionId: KILO_SESSION_ID, copiedItemCount: 0 }, - }); + it.each([ + { worktreeEnabled: undefined, ownershipExists: false, autoCommit: true }, + { worktreeEnabled: undefined, ownershipExists: true, autoCommit: undefined }, + { worktreeEnabled: true, ownershipExists: false, autoCommit: undefined }, + { worktreeEnabled: true, ownershipExists: true, autoCommit: true }, + ])( + 'resumes legacy worktreeEnabled=$worktreeEnabled ownershipExists=$ownershipExists autoCommit=$autoCommit without metadata', + async ({ worktreeEnabled, ownershipExists, autoCommit }) => { + const input = request({ finalization: { autoCommit, condenseOnComplete: true } }); + const doStub = makeDoStub({ getMetadata: vi.fn().mockResolvedValue(null) }); + const ctx = context(doStub); + ctx.env.CONTROL_PLANE_IDS = ''; + ctx.env.WORKTREE_CREATION_ENABLED_IDS = ''; + const storedProgress = await canonicalProgress(input, { + ...(worktreeEnabled !== undefined + ? { [SESSION_CREATE_WORKTREE_ENABLED_KEY]: worktreeEnabled } + : {}), + }); + admitOperationMock.mockResolvedValueOnce({ + admission: 'duplicate_reconcile_pending', + row: makeLedgerRow({ + status: 'reconcile_pending', + canonical_result: storedProgress, + }), + }); + getPgDbMock.mockReturnValue( + makeDb([ownershipExists ? [ownershipRow()] : [], [{ email: 'test@example.com' }]]) + ); - await expect(runCreate(ctx, input)).resolves.toEqual({ - cloudAgentSessionId: WORKSPACE_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - replayed: true, - }); + await expect( + runCreate(ctx, { + ...input, + initialTurn: { + type: 'prompt', + prompt: 'Build the feature', + id: 'msg_retry_original_identity', + }, + }) + ).resolves.toEqual({ + cloudAgentSessionId: WORKSPACE_SESSION_ID, + kiloSessionId: KILO_SESSION_ID, + replayed: true, + }); - expect(createCliSessionMock).toHaveBeenCalledTimes(2); - for (const call of createCliSessionMock.mock.calls) { - expect(call[0]).toBe(KILO_SESSION_ID); - expect(call[1]).toBe(WORKSPACE_SESSION_ID); - expect(call[2]).toBe(USER_ID); - expect(call[9]).toBe(WORKTREE_ID); + expect(createCliSessionMock).toHaveBeenCalledTimes(ownershipExists ? 0 : 1); + if (!ownershipExists) { + expect(createCliSessionMock).toHaveBeenCalledWith( + KILO_SESSION_ID, + WORKSPACE_SESSION_ID, + USER_ID, + expect.any(Object), + undefined, + 'cloud-agent-web', + expect.any(String), + 'https://github.com/acme/repo', + undefined, + WORKTREE_ID, + { sandboxId, provider: 'cloudflare' } + ); + } + expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + finalization: { autoCommit: false, condenseOnComplete: true }, + message: { + initialTurn: { + type: 'prompt', + messageId: INITIAL_MESSAGE_ID, + prompt: 'Build the feature', + attachments: undefined, + }, + }, + workspace: expect.objectContaining({ worktreeId: WORKTREE_ID }), + }) + ); + expect(generateSessionIdMock).not.toHaveBeenCalled(); + expect(generateKiloSessionIdMock).not.toHaveBeenCalled(); + expect(generateSandboxRoutingTargetMock).not.toHaveBeenCalled(); + expect(recordOperationProgressMock).not.toHaveBeenCalled(); + expect(deleteCliSessionMock).not.toHaveBeenCalled(); + expect(storedProgress[SESSION_CREATE_FINALIZATION_VERSION_KEY]).toBeUndefined(); } - expect(generateSessionIdMock).toHaveBeenCalledTimes(1); - expect(generateKiloSessionIdMock).toHaveBeenCalledTimes(1); - expect(generateSandboxRoutingTargetMock).toHaveBeenCalledTimes(1); - expect(doStub.createSessionWithInitialAdmission).toHaveBeenCalledTimes(1); - expect(deleteCliSessionMock).not.toHaveBeenCalled(); - }); + ); it.each([ { @@ -2526,6 +2735,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 2, createIntentFingerprint: await sessionCreateIntentFingerprint(request), }); expect(result).toEqual({ @@ -3316,6 +3526,7 @@ describe('createSessionWithLedger clone allocation outcomes', () => { kiloSessionId: KILO_SESSION_ID, reportingCreatedAt, [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 2, createIntentFingerprint: await sessionCreateIntentFingerprint(cloneRequest()), }); expect(doStub.registerSession).toHaveBeenCalledWith( diff --git a/services/cloud-agent-next/src/session/session-registration.ts b/services/cloud-agent-next/src/session/session-registration.ts index 7413db388f..ec9a6671b3 100644 --- a/services/cloud-agent-next/src/session/session-registration.ts +++ b/services/cloud-agent-next/src/session/session-registration.ts @@ -206,6 +206,7 @@ type SessionCreationLedgerHooks = { db: WorkerDb; rowId: string; worktreeEnabled: boolean; + finalizationVersion: 1 | 2; /** Settle the row `failed` at the given stage. */ onFailure: (stage: SessionLedgerFailureStage, outcomeCode: string) => Promise; /** The DO RPC threw; the commit outcome is unknown. */ @@ -501,11 +502,21 @@ function worktreeEnabledForCreate( return sessionPlaneForNewOwner(ctx.env, owner) === 'control' && isWorktreeOwner(ctx.env, owner); } +function finalizationVersionForCreate(row: OperationLedgerRow): 1 | 2 { + const recorded = row.canonical_result?.[SESSION_CREATE_FINALIZATION_VERSION_KEY]; + if (recorded !== undefined) { + if (recorded !== 1 && recorded !== 2) throw creationInProgressError(); + return recorded; + } + return canonicalSessionIds(row) ? 1 : 2; +} + function effectiveSessionRegistrationInput( input: SessionRegistrationInput, - worktreeEnabled: boolean + worktreeEnabled: boolean, + finalizationVersion: 1 | 2 ): SessionRegistrationInput { - if (!worktreeEnabled) return input; + if (!worktreeEnabled || finalizationVersion === 2) return input; return { ...input, finalization: { ...input.finalization, autoCommit: false }, @@ -553,6 +564,7 @@ async function allocateNewSession( ...(initialTurn ? { initialMessageId: initialTurn.messageId } : {}), ...(reportingCreatedAt ? { reportingCreatedAt } : {}), [SESSION_CREATE_WORKTREE_ENABLED_KEY]: worktreeId !== undefined, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: ledger.finalizationVersion, [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint(input), }); } @@ -1228,6 +1240,7 @@ export async function startNewSession( */ export const SESSION_CREATE_INTENT_FINGERPRINT_KEY = 'createIntentFingerprint'; export const SESSION_CREATE_WORKTREE_ENABLED_KEY = 'worktreeEnabled'; +export const SESSION_CREATE_FINALIZATION_VERSION_KEY = 'finalizationVersion'; /** * Deterministic JSON serialization: object keys are sorted and undefined @@ -1445,7 +1458,8 @@ export async function createSessionWithLedger( }); const effectiveInput = effectiveSessionRegistrationInput( input, - worktreeEnabledForCreate(input, ctx, admission.row) + worktreeEnabledForCreate(input, ctx, admission.row), + finalizationVersionForCreate(admission.row) ); switch (admission.admission) { @@ -1521,6 +1535,7 @@ async function buildLedgerHooks( db, rowId: row.id, worktreeEnabled, + finalizationVersion: finalizationVersionForCreate(row), onFailure: (stage, outcomeCode) => bestEffortLedgerWrite(() => settleOperation(db, { diff --git a/services/cloud-agent-next/test/e2e/client.ts b/services/cloud-agent-next/test/e2e/client.ts index c4928a5c29..53fc37599c 100644 --- a/services/cloud-agent-next/test/e2e/client.ts +++ b/services/cloud-agent-next/test/e2e/client.ts @@ -302,7 +302,7 @@ export type WorktreeSessionResult = { export async function prepareBrowserSession( config: DriverConfig, - input: { prompt: string; operationKey?: string } + input: { prompt: string; operationKey?: string; autoCommit?: boolean } ): Promise { if (!config.internalApiSecret) { throw new Error('browser-equivalent prepareSession requires INTERNAL_API_SECRET'); @@ -320,7 +320,7 @@ export async function prepareBrowserSession( clientProvenance: 'browser', autoInitiate: true, operationKey: input.operationKey ?? randomUUID(), - autoCommit: true, + autoCommit: input.autoCommit ?? true, ...(config.kilocodeOrganizationId ? { kilocodeOrganizationId: config.kilocodeOrganizationId } : {}), diff --git a/services/cloud-agent-next/test/e2e/lifecycle.ts b/services/cloud-agent-next/test/e2e/lifecycle.ts index 6f58f494dd..b0bbe1ac56 100644 --- a/services/cloud-agent-next/test/e2e/lifecycle.ts +++ b/services/cloud-agent-next/test/e2e/lifecycle.ts @@ -658,6 +658,7 @@ export async function lifecycleWorktreeShared(args: LifecycleArgs): Promise { vi.restoreAllMocks(); }); - it('preserves repository branches and exact providers while forcing grouped auto-commit off', async () => { - const ownerId = 'user_grouped_registration'; - const sessionId = 'workspace_grouped_registration'; - const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); - - const metadata = await runInDurableObject(stub, async instance => { - const registered = await instance.registerSession( - groupedRegistration({ + it.each([true, false, undefined])( + 'preserves repository branches, exact providers, and grouped auto-commit %s on registration', + async autoCommit => { + const ownerId = 'user_grouped_registration'; + const sessionId = 'workspace_grouped_registration'; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + const registration = { + ...groupedRegistration({ ownerId, sessionId, kiloSessionId: 'kilo_grouped_registration', sandboxId: 'ses-acde1234', provider: 'vercel', - }) - ); - expect(registered.success).toBe(true); - return instance.getMetadata(); - }); + }), + finalization: { autoCommit, condenseOnComplete: true }, + }; - expect(metadata).toMatchObject({ - repository: { - type: 'github', - repo: 'Kilo-Org/cloud', - upstreamBranch: 'feature/shared-worktree', - }, - workspace: { - sandboxId: 'ses-acde1234', - sandboxProvider: 'vercel', - workspacePath: '/workspace/shared', - worktreeId: WORKTREE_ID, - }, - finalization: { autoCommit: false, condenseOnComplete: true }, - }); - }); + const metadata = await runInDurableObject(stub, async instance => { + expect(await instance.registerSession(registration)).toEqual({ success: true }); + const registered = await instance.getMetadata(); + expect( + await instance.registerSession({ + ...registration, + finalization: { autoCommit: !autoCommit, condenseOnComplete: false }, + }) + ).toEqual({ success: true }); + expect(await instance.getMetadata()).toEqual(registered); + return registered; + }); + + expect(metadata).toMatchObject({ + repository: { + type: 'github', + repo: 'Kilo-Org/cloud', + upstreamBranch: 'feature/shared-worktree', + }, + workspace: { + sandboxId: 'ses-acde1234', + sandboxProvider: 'vercel', + workspacePath: '/workspace/shared', + worktreeId: WORKTREE_ID, + }, + finalization: { autoCommit, condenseOnComplete: true }, + }); + } + ); it('persists canonical prompt identity and attachments during registration-only creation', async () => { const ownerId = 'user_grouped_registered_prompt'; @@ -9447,94 +9459,98 @@ describe('SandboxSession worktree admission', () => { expect(metadata?.finalization?.autoCommit).toBe(true); }); - it('forces grouped auto-commit off before first attach and prompt delivery', async () => { - const ownerId = 'user_grouped_initial'; - const sessionId = GRANT_SESSION_ID; - const targetSandboxId = 'usr-abcdef123401'; - const kiloSessionId = ROOT_ID; - const credential = generateSandboxCredential(); - const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); - await seedCredential(credential, targetSandboxId); - await runInDurableObject(control, async instance => { - await instance.initializeOwner(ownerId); - await seedRunningCloudflare(instance); - }); - await installProvider(control, cloudflareRef(targetSandboxId)); - - const ws = await connect(credential, targetSandboxId); - await completeHello(ws, 'hello-grouped-initial', { wrapperInstanceId: crypto.randomUUID() }); - await deliverWrapperEvent(control, 'sandbox.ready', { - kiloReady: true, - globalFeedAttached: true, - }); + it.each([true, false, undefined])( + 'resolves grouped auto-commit %s before first attach and prompt delivery', + async autoCommit => { + const ownerId = 'user_grouped_initial'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123401'; + const kiloSessionId = ROOT_ID; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); - const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); - const incomingAttach = nextMessage(ws); - const initialTurn = { - type: 'prompt', - messageId: INITIAL_MESSAGE_ID, - prompt: 'first grouped turn', - } as const; - const admitted = await runInDurableObject(session, instance => - instance.createSessionWithInitialAdmission({ - ...groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }), - message: { initialTurn }, - }) - ); - expect(admitted).toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); + const ws = await connect(credential, targetSandboxId); + await completeHello(ws, 'hello-grouped-initial', { wrapperInstanceId: crypto.randomUUID() }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); - const attach = JSON.parse(await incomingAttach) as WrapperRequest; - expect(attach).toMatchObject({ - operation: 'session.attach', - session: { sessionId, kiloSessionId, directory: '/workspace/shared' }, - payload: { branch: 'feature/shared-worktree' }, - }); - await runInDurableObject(session, async (instance, state) => { - const metadata = await instance.getMetadata(); - expect(metadata?.finalization?.autoCommit).toBe(false); - expect(metadata?.initialMessage).toEqual({ - id: INITIAL_MESSAGE_ID, + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + const incomingAttach = nextMessage(ws); + const initialTurn = { + type: 'prompt', + messageId: INITIAL_MESSAGE_ID, prompt: 'first grouped turn', - turn: { type: 'prompt', prompt: 'first grouped turn' }, + } as const; + const admitted = await runInDurableObject(session, instance => + instance.createSessionWithInitialAdmission({ + ...groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }), + finalization: { autoCommit, condenseOnComplete: true }, + message: { initialTurn }, + }) + ); + expect(admitted).toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); + + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + expect(attach).toMatchObject({ + operation: 'session.attach', + session: { sessionId, kiloSessionId, directory: '/workspace/shared' }, + payload: { branch: 'feature/shared-worktree' }, }); - expect(await state.storage.get('session_messages')).toEqual([ - expect.objectContaining({ - messageId: INITIAL_MESSAGE_ID, - intent: expect.objectContaining({ - turn: { - type: 'prompt', - messageId: INITIAL_MESSAGE_ID, - prompt: 'first grouped turn', - }, - agent: { mode: 'code', model: 'test-model' }, - finalization: { autoCommit: false, condenseOnComplete: true }, + await runInDurableObject(session, async (instance, state) => { + const metadata = await instance.getMetadata(); + expect(metadata?.finalization?.autoCommit).toBe(autoCommit); + expect(metadata?.initialMessage).toEqual({ + id: INITIAL_MESSAGE_ID, + prompt: 'first grouped turn', + turn: { type: 'prompt', prompt: 'first grouped turn' }, + }); + expect(await state.storage.get('session_messages')).toEqual([ + expect.objectContaining({ + messageId: INITIAL_MESSAGE_ID, + intent: expect.objectContaining({ + turn: { + type: 'prompt', + messageId: INITIAL_MESSAGE_ID, + prompt: 'first grouped turn', + }, + agent: { mode: 'code', model: 'test-model' }, + finalization: { autoCommit: autoCommit ?? true, condenseOnComplete: true }, + }), }), - }), - ]); - }); - await runInDurableObject(control, async instance => { - expect(await instance.listRoutes()).toEqual([ - expect.objectContaining({ sessionId, kiloSessionId, worktreeId: WORKTREE_ID }), - ]); - }); + ]); + }); + await runInDurableObject(control, async instance => { + expect(await instance.listRoutes()).toEqual([ + expect.objectContaining({ sessionId, kiloSessionId, worktreeId: WORKTREE_ID }), + ]); + }); - const incomingPrompt = nextMessage(ws); - respondToWrapperRequest(ws, attach, { attached: true }); - const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; - expect(prompt).toMatchObject({ - operation: 'session.prompt', - session: { sessionId, kiloSessionId }, - payload: { + const incomingPrompt = nextMessage(ws); + respondToWrapperRequest(ws, attach, { attached: true }); + const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; + expect(prompt).toMatchObject({ + operation: 'session.prompt', + session: { sessionId, kiloSessionId }, + payload: { + messageId: INITIAL_MESSAGE_ID, + finalization: { autoCommit: autoCommit ?? true, condenseOnComplete: true }, + }, + }); + respondToWrapperRequest(ws, prompt, { messageId: INITIAL_MESSAGE_ID, - finalization: { autoCommit: false, condenseOnComplete: true }, - }, - }); - respondToWrapperRequest(ws, prompt, { - messageId: INITIAL_MESSAGE_ID, - status: 'accepted', - }); - ws.close(); - }); + status: 'accepted', + }); + ws.close(); + } + ); it('persists and dispatches initial command turns with their agent and arguments', async () => { const ownerId = 'user_grouped_initial_command'; @@ -9590,7 +9606,7 @@ describe('SandboxSession worktree admission', () => { intent: { turn: initialTurn, agent: { mode: 'architect', model: 'kilo/command-model', variant: 'thinking' }, - finalization: { autoCommit: false, condenseOnComplete: true }, + finalization: { autoCommit: true, condenseOnComplete: true }, }, }), ]); @@ -9600,6 +9616,14 @@ describe('SandboxSession worktree admission', () => { messageId: INITIAL_MESSAGE_ID, } ); + for (const finalization of [{ autoCommit: false }, { condenseOnComplete: false }]) { + await expect( + instance.createSessionWithInitialAdmission({ ...registration, finalization }) + ).resolves.toMatchObject({ success: false, code: 'BAD_REQUEST' }); + } + await expect( + instance.createSessionWithInitialAdmission({ ...registration, finalization: undefined }) + ).resolves.toMatchObject({ success: true, messageId: INITIAL_MESSAGE_ID }); await expect( instance.createSessionWithInitialAdmission({ ...registration, @@ -9623,7 +9647,7 @@ describe('SandboxSession worktree admission', () => { messageId: INITIAL_MESSAGE_ID, turn: { type: 'command', command: 'compact', arguments: '--aggressive' }, agent: { mode: 'architect', model: 'command-model', variant: 'thinking' }, - finalization: { autoCommit: false, condenseOnComplete: true }, + finalization: { autoCommit: true, condenseOnComplete: true }, }, }); respondToWrapperRequest(wrapper, command, { @@ -9699,7 +9723,7 @@ describe('SandboxSession worktree admission', () => { attachments, }, agent: { mode: 'debug', model: 'kilo/override-model', variant: 'focused' }, - finalization: { autoCommit: false, condenseOnComplete: false }, + finalization: { autoCommit: true, condenseOnComplete: false }, }, }), ]); @@ -9722,7 +9746,7 @@ describe('SandboxSession worktree admission', () => { messageId: INITIAL_MESSAGE_ID, turn: { type: 'prompt', prompt: 'review the document' }, agent: { mode: 'debug', model: 'override-model', variant: 'focused' }, - finalization: { autoCommit: false, condenseOnComplete: false }, + finalization: { autoCommit: true, condenseOnComplete: false }, }, }); expect(prompt.payload.attachments).toHaveLength(1); @@ -9850,110 +9874,234 @@ describe('SandboxSession worktree admission', () => { }); }); - it('overrides a submitted grouped turn that requests auto-commit', async () => { - const ownerId = 'user_grouped_followup'; - const sessionId = 'workspace_grouped_followup'; - const targetSandboxId = 'usr-abcdef123402'; - const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); - await runInDurableObject(control, async instance => { - await instance.initializeOwner(ownerId); - await instance.claimCreate('grouped-followup-intent'); - }); - const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + it.each([ + { persistedAutoCommit: true, inheritedAutoCommit: true }, + { persistedAutoCommit: false, inheritedAutoCommit: false }, + { persistedAutoCommit: undefined, inheritedAutoCommit: true }, + ])( + 'inherits grouped auto-commit $persistedAutoCommit and permits explicit new-turn overrides', + async ({ persistedAutoCommit, inheritedAutoCommit }) => { + const ownerId = 'user_grouped_followup'; + const sessionId = 'workspace_grouped_followup'; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); - await runInDurableObject(stub, async (instance, state) => { - await instance.registerSession( - groupedRegistration({ - ownerId, - sessionId, - kiloSessionId: 'kilo_grouped_followup', - sandboxId: targetSandboxId, - }) - ); - const staleMetadata = await instance.getMetadata(); - if (!staleMetadata) throw new Error('Expected grouped session metadata'); - await state.storage.put('session_metadata', { - ...staleMetadata, - finalization: { ...staleMetadata.finalization, autoCommit: true }, - }); - const admitted = await instance.admitSubmittedMessage({ - userId: ownerId, - turn: { type: 'prompt', id: 'msg_grouped_followup', prompt: 'follow-up' }, - finalization: { autoCommit: true, condenseOnComplete: false }, - }); - expect(admitted).toMatchObject({ success: true, messageId: 'msg_grouped_followup' }); - expect(await state.storage.get('session_messages')).toEqual([ - expect.objectContaining({ - messageId: 'msg_grouped_followup', - intent: expect.objectContaining({ - finalization: { autoCommit: false, condenseOnComplete: false }, + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession({ + ...groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: 'kilo_grouped_followup', + sandboxId: 'usr-abcdef123402', }), - }), - ]); - expect((await instance.getMetadata())?.finalization?.autoCommit).toBe(false); - }); - }); + finalization: { autoCommit: persistedAutoCommit, condenseOnComplete: true }, + }); + const metadata = await instance.getMetadata(); + const blocker: SessionMessageRecord = { + messageId: 'msg_blocker', + state: 'accepted', + acceptedAt: Date.now(), + }; + state.storage.kv.put('session_messages', [blocker]); + const submissions = [ + { finalization: undefined, autoCommit: inheritedAutoCommit, condenseOnComplete: true }, + { + finalization: { autoCommit: undefined }, + autoCommit: inheritedAutoCommit, + condenseOnComplete: true, + }, + { + finalization: { condenseOnComplete: false }, + autoCommit: inheritedAutoCommit, + condenseOnComplete: false, + }, + { finalization: { autoCommit: true }, autoCommit: true, condenseOnComplete: true }, + { finalization: { autoCommit: false }, autoCommit: false, condenseOnComplete: true }, + ]; + const expectedMessages: SessionMessageRecord[] = [blocker]; + for (const [index, submission] of submissions.entries()) { + const messageId = `msg_grouped_followup_${index}`; + await expect( + instance.admitSubmittedMessage({ + userId: ownerId, + turn: { type: 'prompt', id: messageId, prompt: 'follow-up' }, + finalization: submission.finalization, + }) + ).resolves.toMatchObject({ success: true, messageId }); + expectedMessages.push( + createSessionMessageRecord({ + turn: { type: 'prompt', messageId, prompt: 'follow-up' }, + agent: { mode: 'code', model: 'test-model' }, + finalization: { + autoCommit: submission.autoCommit, + condenseOnComplete: submission.condenseOnComplete, + }, + }) + ); + expect(state.storage.kv.get('session_messages')).toEqual(expectedMessages); + expect(await instance.getMetadata()).toEqual(metadata); + } + }); + } + ); - it('dispatches older prompt-only queue records with their stored finalization fallback', async () => { - const ownerId = 'user_grouped_legacy_record'; - const sessionId = GRANT_SESSION_ID; - const targetSandboxId = 'usr-abcdef123414'; - const kiloSessionId = ROOT_ID; - const credential = generateSandboxCredential(); - const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); - await seedCredential(credential, targetSandboxId); - await runInDurableObject(control, async instance => { - await instance.initializeOwner(ownerId); - await seedRunningCloudflare(instance); - }); - await installProvider(control, cloudflareRef(targetSandboxId)); + it.each([ + { state: 'queued', autoCommit: true }, + { state: 'queued', autoCommit: false }, + { state: 'accepted', autoCommit: true }, + { state: 'accepted', autoCommit: false }, + ] as const)( + 'validates $state replays against frozen auto-commit $autoCommit rather than current metadata', + async ({ state: messageState, autoCommit }) => { + const ownerId = 'user_grouped_replay'; + const sessionId = 'workspace_grouped_replay'; + const stub = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); - const wrapper = await connect(credential, targetSandboxId); - await completeHello(wrapper, 'hello-grouped-legacy-record', { - wrapperInstanceId: crypto.randomUUID(), - }); - await deliverWrapperEvent(control, 'sandbox.ready', { - kiloReady: true, - globalFeedAttached: true, - }); + await runInDurableObject(stub, async (instance, state) => { + await instance.registerSession({ + ...groupedRegistration({ + ownerId, + sessionId, + kiloSessionId: 'kilo_grouped_replay', + sandboxId: 'usr-abcdef123402', + }), + finalization: { autoCommit: !autoCommit, condenseOnComplete: false }, + }); + const metadata = await instance.getMetadata(); + const messageId = 'msg_grouped_replay'; + const messages: SessionMessageRecord[] = [ + ...(messageState === 'queued' + ? [{ messageId: 'msg_blocker', state: 'accepted' as const, acceptedAt: Date.now() }] + : []), + { + ...createSessionMessageRecord({ + turn: { type: 'prompt', messageId, prompt: 'frozen turn' }, + agent: { mode: 'code', model: 'test-model' }, + finalization: { autoCommit, condenseOnComplete: true }, + }), + state: messageState, + ...(messageState === 'accepted' ? { acceptedAt: Date.now() } : {}), + }, + ]; + state.storage.kv.put('session_messages', messages); + const request: SubmittedSessionMessageRequest = { + userId: ownerId, + turn: { type: 'prompt', id: messageId, prompt: 'frozen turn' }, + }; + for (const finalization of [ + undefined, + { autoCommit: undefined }, + { autoCommit, condenseOnComplete: true }, + ]) { + await expect( + instance.admitSubmittedMessage({ ...request, finalization }) + ).resolves.toMatchObject({ success: true, messageId }); + } + for (const finalization of [{ autoCommit: !autoCommit }, { condenseOnComplete: false }]) { + await expect( + instance.admitSubmittedMessage({ ...request, finalization }) + ).resolves.toMatchObject({ success: false, code: 'BAD_REQUEST' }); + } + expect(state.storage.kv.get('session_messages')).toEqual(messages); + expect(await instance.getMetadata()).toEqual(metadata); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + } + ); - const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); - await runInDurableObject(session, async (instance, state) => { - await instance.registerSession( - groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }) - ); - await state.storage.put('session_messages', [ - { - messageId: 'msg_legacy_record', - state: 'queued', - prompt: 'recover an older prompt', - finalization: { autoCommit: true, condenseOnComplete: false }, - }, - ]); - }); + it.each([ + { format: 'legacy', stored: true, persisted: false, expected: true }, + { format: 'legacy', stored: false, persisted: true, expected: false }, + { format: 'legacy', stored: undefined, persisted: false, expected: false }, + { format: 'legacy', stored: undefined, persisted: true, expected: true }, + { format: 'frozen', stored: true, persisted: false, expected: true }, + { format: 'frozen', stored: false, persisted: true, expected: false }, + ] as const)( + 'dispatches $format queued auto-commit $stored with persisted $persisted without changing the frozen intent', + async ({ format, stored, persisted, expected }) => { + const ownerId = 'user_grouped_legacy_record'; + const sessionId = GRANT_SESSION_ID; + const targetSandboxId = 'usr-abcdef123414'; + const kiloSessionId = ROOT_ID; + const credential = generateSandboxCredential(); + const control = env.SANDBOX_CONTROL.getByName(targetSandboxId); + await seedCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await seedRunningCloudflare(instance); + }); + await installProvider(control, cloudflareRef(targetSandboxId)); - const incomingAttach = nextMessage(wrapper); - const dispatched = runInDurableObject(session, instance => instance.alarm()); - const attach = JSON.parse(await incomingAttach) as WrapperRequest; - const incomingPrompt = nextMessage(wrapper); - respondToWrapperRequest(wrapper, attach, { attached: true }); - const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; - expect(prompt).toMatchObject({ - operation: 'session.prompt', - payload: { - messageId: 'msg_legacy_record', - turn: { type: 'prompt', prompt: 'recover an older prompt' }, + const wrapper = await connect(credential, targetSandboxId); + await completeHello(wrapper, 'hello-grouped-legacy-record', { + wrapperInstanceId: crypto.randomUUID(), + }); + await deliverWrapperEvent(control, 'sandbox.ready', { + kiloReady: true, + globalFeedAttached: true, + }); + + const messageId = 'msg_legacy_record'; + const finalization = { + ...(stored !== undefined ? { autoCommit: stored } : {}), + condenseOnComplete: false, + }; + const frozen = createSessionMessageRecord({ + turn: { type: 'prompt', messageId, prompt: 'recover an older prompt' }, agent: { mode: 'code', model: 'test-model' }, - finalization: { autoCommit: false, condenseOnComplete: false }, - }, - }); - respondToWrapperRequest(wrapper, prompt, { - messageId: 'msg_legacy_record', - status: 'accepted', - }); - await expect(dispatched).resolves.toBeUndefined(); - wrapper.close(); - }); + finalization: { autoCommit: expected, condenseOnComplete: false }, + }); + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + await runInDurableObject(session, async (instance, state) => { + await instance.registerSession({ + ...groupedRegistration({ ownerId, sessionId, kiloSessionId, sandboxId: targetSandboxId }), + finalization: { autoCommit: persisted, condenseOnComplete: true }, + }); + state.storage.kv.put('session_messages', [ + format === 'frozen' + ? frozen + : { messageId, state: 'queued', prompt: 'recover an older prompt', finalization }, + ] satisfies SessionMessageRecord[]); + }); + + const incomingAttach = nextMessage(wrapper); + const dispatched = runInDurableObject(session, instance => instance.alarm()); + const attach = JSON.parse(await incomingAttach) as WrapperRequest; + await runInDurableObject(session, async (instance, state) => { + const metadata = await instance.getMetadata(); + if (!metadata) throw new Error('Expected grouped session metadata'); + expect(metadata.finalization).toEqual({ autoCommit: persisted, condenseOnComplete: true }); + expect(state.storage.kv.get('session_messages')).toEqual([expect.objectContaining(frozen)]); + state.storage.kv.put('session_metadata', { + ...metadata, + finalization: { autoCommit: !expected, condenseOnComplete: true }, + }); + }); + const incomingPrompt = nextMessage(wrapper); + respondToWrapperRequest(wrapper, attach, { attached: true }); + const prompt = JSON.parse(await incomingPrompt) as WrapperRequest; + expect(prompt).toMatchObject({ + operation: 'session.prompt', + payload: { + messageId, + turn: { type: 'prompt', prompt: 'recover an older prompt' }, + agent: frozen.intent.agent, + finalization: frozen.intent.finalization, + }, + }); + respondToWrapperRequest(wrapper, prompt, { messageId, status: 'accepted' }); + await expect(dispatched).resolves.toBeUndefined(); + await runInDurableObject(session, async (instance, state) => { + expect(state.storage.kv.get('session_messages')).toEqual([ + expect.objectContaining({ ...frozen, state: 'accepted' }), + ]); + expect((await instance.getMetadata())?.finalization).toEqual({ + autoCommit: !expected, + condenseOnComplete: true, + }); + }); + wrapper.close(); + } + ); }); describe('SandboxSession durable message lifecycle', () => { diff --git a/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts b/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts index eadd5960b1..a4bd5c218e 100644 --- a/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts +++ b/services/cloud-agent-next/test/unit/wrapper/auto-commit.test.ts @@ -601,105 +601,118 @@ describe('runAutoCommit', () => { } }); - it('serializes same-worktree auto-commit through push and skips a clean sibling', async () => { - const actual = await vi.importActual('../../../wrapper/src/utils.js'); - const root = await fs.mkdtemp(path.join(os.tmpdir(), 'shared-worktree-autocommit-')); - const workspacePath = path.join(root, 'workspace'); - const remote = path.join(root, 'remote.git'); - const home = path.join(root, 'home'); - const env = { PATH: process.env.PATH, HOME: home }; - const generating = Promise.withResolvers(); - const generated = Promise.withResolvers<{ message: string }>(); - const pushing = Promise.withResolvers(); - const pushed = Promise.withResolvers(); - const controllers = [new AbortController(), new AbortController()]; - const pending: ReturnType[] = []; - const first = createOpts({ - workspacePath, - env, - messageId: 'first', - signal: controllers[0].signal, - }); - const second = createOpts({ - workspacePath, - env, - messageId: 'second', - signal: controllers[1].signal, - }); - let secondSettled = false; - vi.mocked(first.opts.kiloClient.generateCommitMessage).mockImplementation(() => { - generating.resolve(); - return generated.promise; - }); - mockGetCurrentBranch.mockImplementation(actual.getCurrentBranch); - mockHasGitUpstream.mockImplementation(actual.hasGitUpstream); - mockGit.mockImplementation(async (args, options) => { - if (args[0] === 'push') { - pushing.resolve(); - await pushed.promise; - } - return actual.git(args, options); - }); - const git = async (args: string[]) => { - const result = await actual.git(args, { cwd: workspacePath, env, inheritEnv: false }); - expect(result.exitCode).toBe(0); - return result.stdout; - }; - try { - await fs.mkdir(workspacePath); - await fs.mkdir(home); - await git(['init', '--bare', remote]); - await git(['init', '--initial-branch=work']); - await git(['config', 'user.name', 'Test Agent']); - await git(['config', 'user.email', 'test@example.com']); - await git(['config', 'commit.gpgsign', 'false']); - await git(['remote', 'add', 'origin', remote]); - await fs.writeFile(path.join(workspacePath, 'result.txt'), 'shared changes\n'); - - const firstCommit = runAutoCommit(first.opts); - pending.push(firstCommit); - await generating.promise; - const secondCommit = runAutoCommit(second.opts).then(result => { - secondSettled = true; - return result; + it.each([false, true])( + 'serializes same-worktree auto-commit through push with new sibling edits=%s', + async siblingEdits => { + const actual = await vi.importActual('../../../wrapper/src/utils.js'); + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'shared-worktree-autocommit-')); + const workspacePath = path.join(root, 'workspace'); + const remote = path.join(root, 'remote.git'); + const home = path.join(root, 'home'); + const env = { PATH: process.env.PATH, HOME: home }; + const generating = Promise.withResolvers(); + const generated = Promise.withResolvers<{ message: string }>(); + const pushing = Promise.withResolvers(); + const pushed = Promise.withResolvers(); + const controllers = [new AbortController(), new AbortController()]; + const pending: ReturnType[] = []; + const first = createOpts({ + workspacePath, + env, + messageId: 'first', + signal: controllers[0].signal, }); - pending.push(secondCommit); - await new Promise(resolve => setImmediate(resolve)); - expect(mockGetCurrentBranch).toHaveBeenCalledTimes(1); - expect(second.events).toEqual([]); - expect(secondSettled).toBe(false); - - generated.resolve({ message: 'Commit shared changes' }); - await pushing.promise; - expect(await git(['status', '--porcelain'])).toBe(''); - expect(mockGetCurrentBranch).toHaveBeenCalledTimes(1); - expect(secondSettled).toBe(false); - pushed.resolve(); - expect(await firstCommit).toEqual({ success: true }); - expect(await secondCommit).toEqual({ success: true, skipped: true }); - expect(second.events).toEqual([ - expect.objectContaining({ - streamEventType: 'autocommit_completed', - data: expect.objectContaining({ success: true, skipped: true, messageId: 'second' }), - }), - ]); - expect(await git(['--git-dir', remote, 'show', 'refs/heads/work:result.txt'])).toBe( - 'shared changes\n' - ); - expect( - (await git(['--git-dir', remote, 'rev-list', '--count', 'refs/heads/work'])).trim() - ).toBe('1'); - } finally { - for (const controller of controllers) controller.abort(); - generated.resolve({ message: 'Commit shared changes' }); - pushed.resolve(); - await Promise.allSettled(pending); - mockGit.mockReset(); - mockGetCurrentBranch.mockReset(); - mockHasGitUpstream.mockReset(); - await fs.rm(root, { recursive: true, force: true }); + const second = createOpts({ + workspacePath, + env, + messageId: 'second', + signal: controllers[1].signal, + }); + let secondSettled = false; + vi.mocked(first.opts.kiloClient.generateCommitMessage).mockImplementation(() => { + generating.resolve(); + return generated.promise; + }); + mockGetCurrentBranch.mockImplementation(actual.getCurrentBranch); + mockHasGitUpstream.mockImplementation(actual.hasGitUpstream); + mockGit.mockImplementation(async (args, options) => { + if (args[0] === 'push') { + pushing.resolve(); + await pushed.promise; + } + return actual.git(args, options); + }); + const git = async (args: string[]) => { + const result = await actual.git(args, { cwd: workspacePath, env, inheritEnv: false }); + expect(result.exitCode).toBe(0); + return result.stdout; + }; + try { + await fs.mkdir(workspacePath); + await fs.mkdir(home); + await git(['init', '--bare', remote]); + await git(['init', '--initial-branch=work']); + await git(['config', 'user.name', 'Test Agent']); + await git(['config', 'user.email', 'test@example.com']); + await git(['config', 'commit.gpgsign', 'false']); + await git(['remote', 'add', 'origin', remote]); + await fs.writeFile(path.join(workspacePath, 'result.txt'), 'shared changes\n'); + + const firstCommit = runAutoCommit(first.opts); + pending.push(firstCommit); + await generating.promise; + const secondCommit = runAutoCommit(second.opts).then(result => { + secondSettled = true; + return result; + }); + pending.push(secondCommit); + await new Promise(resolve => setImmediate(resolve)); + expect(mockGetCurrentBranch).toHaveBeenCalledTimes(1); + expect(second.events).toEqual([]); + expect(secondSettled).toBe(false); + + generated.resolve({ message: 'Commit shared changes' }); + await pushing.promise; + expect(await git(['status', '--porcelain'])).toBe(''); + expect(mockGetCurrentBranch).toHaveBeenCalledTimes(1); + expect(secondSettled).toBe(false); + if (siblingEdits) { + await fs.writeFile(path.join(workspacePath, 'sibling.txt'), 'later sibling changes\n'); + } + pushed.resolve(); + expect(await firstCommit).toEqual({ success: true }); + expect(await secondCommit).toEqual( + siblingEdits ? { success: true } : { success: true, skipped: true } + ); + expect( + second.events.find(event => event.streamEventType === 'autocommit_completed')?.data + ).toMatchObject({ success: true, messageId: 'second' }); + if (siblingEdits) { + expect(await git(['--git-dir', remote, 'show', 'refs/heads/work:sibling.txt'])).toBe( + 'later sibling changes\n' + ); + } else { + expect(second.events).toHaveLength(1); + expect(second.events[0].data.skipped).toBe(true); + } + expect(await git(['--git-dir', remote, 'show', 'refs/heads/work:result.txt'])).toBe( + 'shared changes\n' + ); + expect( + (await git(['--git-dir', remote, 'rev-list', '--count', 'refs/heads/work'])).trim() + ).toBe(siblingEdits ? '2' : '1'); + } finally { + for (const controller of controllers) controller.abort(); + generated.resolve({ message: 'Commit shared changes' }); + pushed.resolve(); + await Promise.allSettled(pending); + mockGit.mockReset(); + mockGetCurrentBranch.mockReset(); + mockHasGitUpstream.mockReset(); + await fs.rm(root, { recursive: true, force: true }); + } } - }); + ); it('commits and pushes with the isolated worktree environment instead of wrapper credentials', async () => { const actual = await vi.importActual('../../../wrapper/src/utils.js'); diff --git a/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts b/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts index 573c632c0d..ddb0084951 100644 --- a/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/apply-attach.test.ts @@ -150,6 +150,185 @@ const noFs = { }; describe('applySessionAttach', () => { + describe('working branches', () => { + let repository: string; + let directory: string; + let deps: ApplyAttachDeps; + const payload = { kilo, git: { url: 'https://github.com/acme/demo.git' } }; + const runGit = (args: string[], cwd: string) => + runProcess('git', args, { + cwd, + env: { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }, + }); + const currentBranch = async () => + (await runGit(['branch', '--show-current'], directory)).stdout.trim(); + + beforeEach(async () => { + repository = path.join(homeRoot, 'repository'); + directory = path.join(homeRoot, 'checkout'); + fs.mkdirSync(repository); + expect((await runGit(['init', '--initial-branch=main'], repository)).exitCode).toBe(0); + expect( + ( + await runGit( + [ + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--allow-empty', + '-m', + 'Initial commit', + ], + repository + ) + ).exitCode + ).toBe(0); + expect((await runGit(['branch', 'feature/existing'], repository)).exitCode).toBe(0); + deps = { + kiloRuntimes: fakeKiloRuntimes(), + sessionExists: async () => true, + runGit: (args, cwd, signal) => + runProcess('git', args[0] === 'clone' ? ['clone', repository, args[2]] : args, { + cwd, + signal, + env: { GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' }, + }), + }; + }); + + it('creates a stable working branch before setup when no branch was requested', async () => { + const setupBranches: string[] = []; + const result = await applySessionAttach( + { ...session, directory }, + { ...payload, setupCommands: ['prepare'] }, + { + ...deps, + runSetup: async () => { + setupBranches.push(await currentBranch()); + return { stdout: '', stderr: '', exitCode: 0 }; + }, + } + ); + expect(result).toEqual({ ok: true, result: { attached: true } }); + expect(await currentBranch()).toBe('session/worktree_a'); + expect(setupBranches).toEqual(['session/worktree_a']); + }); + + it.each(['main', 'feature/existing'])( + 'honors the explicitly requested branch %s', + async branch => { + const result = await applySessionAttach( + { ...session, directory }, + { ...payload, branch }, + deps + ); + expect(result).toEqual({ ok: true, result: { attached: true } }); + expect(await currentBranch()).toBe(branch); + } + ); + + it('fails preparation rather than falling back to main for a missing requested branch', async () => { + let setupRan = false; + const result = await applySessionAttach( + { ...session, directory }, + { ...payload, branch: 'missing', setupCommands: ['prepare'] }, + { + ...deps, + runSetup: async () => { + setupRan = true; + return { stdout: '', stderr: '', exitCode: 0 }; + }, + } + ); + expect(result).toEqual({ + ok: false, + error: { code: 'not_ready', message: 'git checkout failed', retryable: true }, + }); + expect(setupRan).toBe(false); + }); + + it('preserves generated-branch commits when retrying failed setup', async () => { + let setupRuns = 0; + let commit = ''; + const retryDeps: ApplyAttachDeps = { + ...deps, + runSetup: async () => { + setupRuns += 1; + if (setupRuns === 1) { + expect( + (await runGit(['commit', '--allow-empty', '-m', 'Setup work'], directory)).exitCode + ).toBe(0); + commit = (await runGit(['rev-parse', 'HEAD'], directory)).stdout.trim(); + } + return { stdout: '', stderr: '', exitCode: setupRuns === 1 ? 1 : 0 }; + }, + }; + const attach = () => + applySessionAttach( + { ...session, directory }, + { ...payload, setupCommands: ['prepare'] }, + retryDeps + ); + expect((await attach()).ok).toBe(false); + expect((await attach()).ok).toBe(true); + expect(await currentBranch()).toBe('session/worktree_a'); + expect((await runGit(['rev-parse', 'HEAD'], directory)).stdout.trim()).toBe(commit); + }); + + it('does not switch a warm sibling away from a user-selected branch or discard edits', async () => { + expect((await applySessionAttach({ ...session, directory }, payload, deps)).ok).toBe(true); + expect((await runGit(['checkout', '-b', 'user-selected'], directory)).exitCode).toBe(0); + fs.writeFileSync(path.join(directory, 'work.txt'), 'shared work'); + expect((await applySessionAttach({ ...siblingSession, directory }, payload, deps)).ok).toBe( + true + ); + expect(await currentBranch()).toBe('user-selected'); + expect(fs.readFileSync(path.join(directory, 'work.txt'), 'utf8')).toBe('shared work'); + }); + + it('restores pushed working-branch commits when a sibling bootstraps a replacement checkout', async () => { + expect((await applySessionAttach({ ...session, directory }, payload, deps)).ok).toBe(true); + fs.writeFileSync(path.join(directory, 'saved.txt'), 'committed shared work'); + expect((await runGit(['add', 'saved.txt'], directory)).exitCode).toBe(0); + expect((await runGit(['commit', '-m', 'Save shared work'], directory)).exitCode).toBe(0); + expect( + (await runGit(['push', '-u', 'origin', 'session/worktree_a'], directory)).exitCode + ).toBe(0); + const commit = (await runGit(['rev-parse', 'HEAD'], directory)).stdout.trim(); + + directory = path.join(homeRoot, 'replacement'); + expect((await applySessionAttach({ ...siblingSession, directory }, payload, deps)).ok).toBe( + true + ); + expect(await currentBranch()).toBe('session/worktree_a'); + expect((await runGit(['rev-parse', 'HEAD'], directory)).stdout.trim()).toBe(commit); + expect( + ( + await runGit( + ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], + directory + ) + ).stdout.trim() + ).toBe('origin/session/worktree_a'); + expect(fs.readFileSync(path.join(directory, 'saved.txt'), 'utf8')).toBe( + 'committed shared work' + ); + }); + + it('uses the same working branch when a sibling bootstraps a replacement checkout', async () => { + expect((await applySessionAttach({ ...session, directory }, payload, deps)).ok).toBe(true); + const branch = await currentBranch(); + directory = path.join(homeRoot, 'replacement'); + expect((await applySessionAttach({ ...siblingSession, directory }, payload, deps)).ok).toBe( + true + ); + expect(await currentBranch()).toBe('session/worktree_a'); + expect(await currentBranch()).toBe(branch); + }); + }); + it('reuses setup-only workspaces without writing bootstrap state into their content', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'control-bootstrap-')); const directory = path.join(root, 'workspace'); @@ -274,6 +453,8 @@ describe('applySessionAttach', () => { ); expect(result.ok).toBe(true); expect(gitCalls).toEqual([ + ['show-ref', '--verify', '--quiet', 'refs/heads/session/worktree_a'], + ['checkout', 'session/worktree_a'], ['config', 'user.name', 'Kilo Code Cloud'], ['config', 'user.email', 'agent@kilocode.ai'], ]); @@ -417,99 +598,108 @@ describe('applySessionAttach', () => { expect(setupCalls).toEqual([]); }); - it('serializes sibling cold clone/setup but allows their Kilo restores to run independently', async () => { - const cloneStarted = Promise.withResolvers(); - const releaseClone = Promise.withResolvers(); - const setupStarted = Promise.withResolvers(); - const releaseSetup = Promise.withResolvers(); - const bothRestoring = Promise.withResolvers(); - const releaseRestore = Promise.withResolvers(); - let gitExists = false; - let bootstrapComplete = false; - let markerWrites = 0; - const gitOperations: string[] = []; - const setupCommands: string[] = []; - const restoredRoots: string[] = []; - const payload = { - kilo, - git: { url: 'https://github.com/acme/demo.git' }, - branch: 'main', - setupCommands: ['prepare'], - }; - const deps: ApplyAttachDeps = { - kiloRuntimes: fakeKiloRuntimes(), - mkdir: async () => {}, - hasGit: async () => gitExists, - hasBootstrapMarker: async () => bootstrapComplete, - writeBootstrapMarker: async () => { - markerWrites += 1; - bootstrapComplete = true; - }, - runGit: async args => { - gitOperations.push(args[0]); - if (args[0] === 'clone') { - cloneStarted.resolve(); - await releaseClone.promise; - gitExists = true; - } - return { stdout: '', stderr: '', exitCode: 0 }; - }, - runSetup: async command => { - setupCommands.push(command); - setupStarted.resolve(); - await releaseSetup.promise; - return { stdout: '', stderr: '', exitCode: 0 }; - }, - sessionExists: async () => false, - restoreSession: async id => { - restoredRoots.push(id); - if (restoredRoots.length === 2) bothRestoring.resolve(); - await releaseRestore.promise; - return { - ok: true, - downloaded: true, - imported: true, - diffs: { applied: 0, skipped: 0, total: 0 }, - }; - }, - }; - const attaches = [applySessionAttach(session, payload, deps)]; - try { - await cloneStarted.promise; - attaches.push( - applySessionAttach( - { ...session, sessionId: 'workspace_2', kiloSessionId: 'kilo_2' }, - payload, - deps - ) - ); - await Bun.sleep(0); - expect(gitOperations).toEqual(['clone']); - releaseClone.resolve(); - await setupStarted.promise; - await Bun.sleep(0); - expect(gitOperations).toEqual(['clone', 'checkout', 'config', 'config']); - expect(setupCommands).toEqual(['prepare']); - releaseSetup.resolve(); - await withTimeoutAndAbort(bothRestoring.promise, { - timeoutMs: 1_000, - timeoutMessage: 'Sibling restore was serialized with workspace preparation', - abortMessage: 'Sibling restore cancelled', - }); - releaseRestore.resolve(); - expect(await Promise.all(attaches)).toEqual([ - { ok: true, result: { attached: true } }, - { ok: true, result: { attached: true } }, - ]); - expect(markerWrites).toBe(1); - expect(restoredRoots.sort()).toEqual(['kilo_1', 'kilo_2']); - } finally { - releaseClone.resolve(); - releaseSetup.resolve(); - releaseRestore.resolve(); - await Promise.allSettled(attaches); + it.each([undefined, 'main'])( + 'serializes sibling cold clone/setup but allows independent Kilo restores (branch: %s)', + async branch => { + const cloneStarted = Promise.withResolvers(); + const releaseClone = Promise.withResolvers(); + const setupStarted = Promise.withResolvers(); + const releaseSetup = Promise.withResolvers(); + const bothRestoring = Promise.withResolvers(); + const releaseRestore = Promise.withResolvers(); + let gitExists = false; + let bootstrapComplete = false; + let markerWrites = 0; + const gitOperations: string[] = []; + const setupCommands: string[] = []; + const restoredRoots: string[] = []; + const payload = { + kilo, + git: { url: 'https://github.com/acme/demo.git' }, + branch, + setupCommands: ['prepare'], + }; + const deps: ApplyAttachDeps = { + kiloRuntimes: fakeKiloRuntimes(), + mkdir: async () => {}, + hasGit: async () => gitExists, + hasBootstrapMarker: async () => bootstrapComplete, + writeBootstrapMarker: async () => { + markerWrites += 1; + bootstrapComplete = true; + }, + runGit: async args => { + gitOperations.push(args[0]); + if (args[0] === 'clone') { + cloneStarted.resolve(); + await releaseClone.promise; + gitExists = true; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }, + runSetup: async command => { + setupCommands.push(command); + setupStarted.resolve(); + await releaseSetup.promise; + return { stdout: '', stderr: '', exitCode: 0 }; + }, + sessionExists: async () => false, + restoreSession: async id => { + restoredRoots.push(id); + if (restoredRoots.length === 2) bothRestoring.resolve(); + await releaseRestore.promise; + return { + ok: true, + downloaded: true, + imported: true, + diffs: { applied: 0, skipped: 0, total: 0 }, + }; + }, + }; + const attaches = [applySessionAttach(session, payload, deps)]; + try { + await cloneStarted.promise; + attaches.push( + applySessionAttach( + { ...session, sessionId: 'workspace_2', kiloSessionId: 'kilo_2' }, + payload, + deps + ) + ); + await Bun.sleep(0); + expect(gitOperations).toEqual(['clone']); + releaseClone.resolve(); + await setupStarted.promise; + await Bun.sleep(0); + expect(gitOperations).toEqual([ + 'clone', + ...(branch ? [] : ['show-ref']), + 'checkout', + 'config', + 'config', + ]); + expect(setupCommands).toEqual(['prepare']); + releaseSetup.resolve(); + await withTimeoutAndAbort(bothRestoring.promise, { + timeoutMs: 1_000, + timeoutMessage: 'Sibling restore was serialized with workspace preparation', + abortMessage: 'Sibling restore cancelled', + }); + releaseRestore.resolve(); + expect(await Promise.all(attaches)).toEqual([ + { ok: true, result: { attached: true } }, + { ok: true, result: { attached: true } }, + ]); + expect(markerWrites).toBe(1); + expect(restoredRoots.sort()).toEqual(['kilo_1', 'kilo_2']); + } finally { + releaseClone.resolve(); + releaseSetup.resolve(); + releaseRestore.resolve(); + await Promise.allSettled(attaches); + } } - }); + ); it('releases failed cold setup so a waiting sibling can retry and warm attaches skip it', async () => { const started = Promise.withResolvers(); diff --git a/services/cloud-agent-next/wrapper/src/control/apply-attach.ts b/services/cloud-agent-next/wrapper/src/control/apply-attach.ts index c1ac5ac454..fef5707dd3 100644 --- a/services/cloud-agent-next/wrapper/src/control/apply-attach.ts +++ b/services/cloud-agent-next/wrapper/src/control/apply-attach.ts @@ -323,37 +323,62 @@ async function executeSessionAttach( if (attach.git) { const needsClone = !(await hasGit(directory)); signal.throwIfAborted(); - if (needsClone || attach.branch) { - const cloneStepId = 'phase:cloning'; - progress.start('cloning', cloneStepId, 'Cloning repository…'); - if (needsClone) { - progress.progress('cloning', cloneStepId, 'Cloning repository…'); - const cloneUrl = authenticatedGitUrl( - attach.git.url, - attach.git.token, - attach.git.platform - ); - const cloned = await runGit(['clone', cloneUrl, directory], undefined, signal); - signal.throwIfAborted(); - if (cloned.exitCode !== 0) { - progress.fail('cloning', cloneStepId, 'git clone failed'); - return fail('not_ready', 'git clone failed', true); - } + const cloneStepId = 'phase:cloning'; + progress.start('cloning', cloneStepId, 'Cloning repository…'); + if (needsClone) { + progress.progress('cloning', cloneStepId, 'Cloning repository…'); + const cloneUrl = authenticatedGitUrl( + attach.git.url, + attach.git.token, + attach.git.platform + ); + const cloned = await runGit(['clone', cloneUrl, directory], undefined, signal); + signal.throwIfAborted(); + if (cloned.exitCode !== 0) { + progress.fail('cloning', cloneStepId, 'git clone failed'); + return fail('not_ready', 'git clone failed', true); } - if (attach.branch) { - const checked = await runGit( - ['checkout', '-B', attach.branch, `origin/${attach.branch}`], + } + const branch = attach.branch ?? `session/${attach.kilo.scopeId}`; + let checkoutArgs = ['checkout', '-B', branch, `origin/${branch}`]; + if (!attach.branch) { + const existingBranch = await runGit( + ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], + directory, + signal + ); + signal.throwIfAborted(); + if (existingBranch.exitCode !== 0 && existingBranch.exitCode !== 1) { + progress.fail('cloning', cloneStepId, 'git branch lookup failed'); + return fail('not_ready', 'git branch lookup failed', true); + } + checkoutArgs = ['checkout', branch]; + if (existingBranch.exitCode === 1) { + const remoteBranch = await runGit( + ['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branch}`], directory, signal ); signal.throwIfAborted(); - if (checked.exitCode !== 0) { - progress.fail('cloning', cloneStepId, 'git checkout failed'); - return fail('not_ready', 'git checkout failed', true); + if (remoteBranch.exitCode !== 0 && remoteBranch.exitCode !== 1) { + progress.fail('cloning', cloneStepId, 'git branch lookup failed'); + return fail('not_ready', 'git branch lookup failed', true); } + checkoutArgs = [ + 'checkout', + '-b', + branch, + ...(remoteBranch.exitCode === 0 ? ['--track', `origin/${branch}`] : []), + ]; } - progress.complete('cloning', cloneStepId); } + const checked = await runGit(checkoutArgs, directory, signal); + signal.throwIfAborted(); + if (checked.exitCode !== 0) { + progress.fail('cloning', cloneStepId, 'git checkout failed'); + return fail('not_ready', 'git checkout failed', true); + } + progress.complete('cloning', cloneStepId); await configureWorkspaceGitAuthor( directory, (args, options) => runGit(args, options?.cwd, options?.signal), diff --git a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts index 5d65d71f15..fcbca22409 100644 --- a/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts +++ b/services/cloud-agent-next/wrapper/src/control/sandbox-control-handlers.test.ts @@ -2575,8 +2575,13 @@ describe('control finalization and compact', () => { ).toEqual({ ok: true, result: { messageId: 'msg_1', status: 'accepted' } }); await waitForTasks(handlerDeps); const committed = events.find(event => event.type === 'autocommit_completed')?.properties; + const branch = editRepository ? `session/${kilo.scopeId}` : 'work'; + expect((await git(['branch', '--show-current'], workspace)).trim()).toBe( + `session/${kilo.scopeId}` + ); + expect((await git(['rev-list', '--count', 'refs/heads/work'], remote)).trim()).toBe('1'); if (editRepository) { - expect(await git(['show', 'refs/heads/work:result.txt'], remote)).toBe( + expect(await git(['show', `refs/heads/${branch}:result.txt`], remote)).toBe( 'normal control turn' ); expect((await git(['log', '-1', '--format=%an/%ae'], workspace)).trim()).toBe( @@ -2591,9 +2596,9 @@ describe('control finalization and compact', () => { expect(committed).toMatchObject({ success: true, skipped: true }); } expect( - (await git(['ls-tree', '-r', '--name-only', 'refs/heads/work'], remote)).trim() + (await git(['ls-tree', '-r', '--name-only', `refs/heads/${branch}`], remote)).trim() ).toBe(editRepository ? 'initial.txt\nresult.txt' : 'initial.txt'); - expect((await git(['rev-list', '--count', 'refs/heads/work'], remote)).trim()).toBe( + expect((await git(['rev-list', '--count', `refs/heads/${branch}`], remote)).trim()).toBe( editRepository ? '2' : '1' ); expect(await git(['status', '--porcelain'], workspace)).toBe(''); From 30c708803e48b35b2ad7fc19a59742c5eea01216 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 14:01:20 +0200 Subject: [PATCH 11/14] test(session-ingest): deduplicate rebased user connection binding --- services/session-ingest/wrangler.test.jsonc | 4 ---- 1 file changed, 4 deletions(-) diff --git a/services/session-ingest/wrangler.test.jsonc b/services/session-ingest/wrangler.test.jsonc index bdb2db8c5e..bb5172a556 100644 --- a/services/session-ingest/wrangler.test.jsonc +++ b/services/session-ingest/wrangler.test.jsonc @@ -32,10 +32,6 @@ "name": "CONNECTION_TICKET_DO", "class_name": "ConnectionTicketDO", }, - { - "name": "USER_CONNECTION_DO", - "class_name": "UserConnectionDO", - }, ], }, From 291cd0ab4953f257e5a8a9fd0588bd5c6ef6db0e Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 15:57:29 +0200 Subject: [PATCH 12/14] test(mobile): align rebased stored session fixtures --- .../src/components/agents/session-list-content.mounted.test.tsx | 1 + apps/mobile/src/components/agents/session-row.mounted.test.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx b/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx index c0f2cb9614..1801c8f49d 100644 --- a/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-list-content.mounted.test.tsx @@ -119,6 +119,7 @@ function session(id: string): StoredSession { title: `${id} title`, organization_id: 'org-1', cloud_agent_session_id: null, + cloud_agent_worktree_id: null, parent_session_id: null, created_on_platform: 'cli', git_url: null, diff --git a/apps/mobile/src/components/agents/session-row.mounted.test.tsx b/apps/mobile/src/components/agents/session-row.mounted.test.tsx index f116e1f840..1515c5cdcd 100644 --- a/apps/mobile/src/components/agents/session-row.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-row.mounted.test.tsx @@ -85,6 +85,7 @@ const session: StoredSession = { title: 'Fix login bug', organization_id: 'org-1', cloud_agent_session_id: null, + cloud_agent_worktree_id: null, parent_session_id: null, created_on_platform: 'cli', git_url: null, From 2f43e06680e7156581eb6f47f80da8741cf626bd Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 15:57:30 +0200 Subject: [PATCH 13/14] fix(cloud-agent-next): preserve retry isolation until dispatch --- .../src/sandbox-session/SandboxSession.ts | 12 +- .../session-message-queue.test.ts | 123 ++++++++++++++++++ 2 files changed, 126 insertions(+), 9 deletions(-) diff --git a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts index df65b5a00e..dd930f4a71 100644 --- a/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts +++ b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts @@ -1320,7 +1320,9 @@ export class SandboxSession extends DurableObject { if (!isCurrent()) return; this.saveMessages( this.loadMessages().map(message => - message.messageId === messageId ? { ...message, unresolvedDispatch } : message + message.messageId === messageId + ? { ...message, unresolvedDispatch, deliveryRetryScope: undefined } + : message ), epoch ); @@ -1347,14 +1349,6 @@ export class SandboxSession extends DurableObject { return; } if (this.pendingRuntimeCleanup()) return; - if (queued.deliveryRetryScope !== undefined) { - this.saveMessages( - this.loadMessages().map(message => - message.messageId === messageId ? { ...message, deliveryRetryScope: undefined } : message - ), - epoch - ); - } const intent = queued.intent; const model = dispatchedKilocodeModelId(intent?.agent.model); const control = sandboxControlRpc(this.env, sandboxId); diff --git a/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts b/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts index 0d202518c2..6ebf2babc3 100644 --- a/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts +++ b/services/cloud-agent-next/src/sandbox-session/session-message-queue.test.ts @@ -1818,6 +1818,129 @@ describe('SandboxSession orchestration', () => { expect(writer.record('writer')?.state).toBe('completed'); }); + it.each(['ensureReady', 'attachSession'] as const)( + 'preserves the accepted sibling when a busy retry is cancelled during %s', + async stage => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + const ready = { + physical: 'running', + connection: 'ready', + wrapperInstanceId: RUNTIME_ID, + attachment: { + ...ATTACHMENT, + kilo: { ...ATTACHMENT.kilo, containmentEnabled: false }, + }, + } satisfies ControlStatus; + writer.control.ensureReady.mockResolvedValue(ready); + delegateRequest(writer, operation, async () => controlFailure(true, 'session_busy')); + await sibling.admit('waiting'); + await sibling.flush(); + expect(sibling.record('waiting')).toMatchObject({ + state: 'queued', + deliveryRetryScope: 'message', + wrapperInstanceId: RUNTIME_ID, + }); + expect(sibling.record('waiting')?.unresolvedDispatch).toBeUndefined(); + sibling.reload(); + writer.control.ensureReady.mockClear(); + writer.control.attachSession.mockClear(); + writer.control.request.mockClear(); + const pending = deferred(); + if (stage === 'ensureReady') { + writer.control.ensureReady.mockImplementationOnce(async () => { + await pending.promise; + return ready; + }); + } else { + writer.control.attachSession.mockImplementationOnce(async () => { + await pending.promise; + return {}; + }); + } + const retry = sibling.fireAlarm(); + await sibling.flush(); + try { + expect(writer.control.ensureReady).toHaveBeenCalledOnce(); + expect(writer.control.attachSession).toHaveBeenCalledTimes( + stage === 'attachSession' ? 1 : 0 + ); + expect(writer.control.request).not.toHaveBeenCalled(); + expect(sibling.record('waiting')?.unresolvedDispatch).toBeUndefined(); + await expect(sibling.session.interruptExecution()).resolves.toEqual({ success: true }); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + expect(writer.record('writer')?.state).toBe('accepted'); + expect(writer.terminalEvents()).toHaveLength(0); + expect(sibling.record('waiting')?.state).toBe('cancelled'); + expect(sibling.terminalEvents()).toHaveLength(1); + expect(await sibling.session.isSandboxCleanupScheduled()).toBe(false); + } finally { + pending.resolve(undefined); + await retry; + await sibling.flush(); + } + await sibling.fireAlarm(); + expect(writer.control.request).not.toHaveBeenCalled(); + expect(writer.control.quarantineRuntime).not.toHaveBeenCalled(); + expect(sibling.record('waiting')?.state).toBe('cancelled'); + expect(sibling.terminalEvents()).toHaveLength(1); + expect(writer.record('writer')?.state).toBe('accepted'); + await writer.outcome('writer', 'completed'); + expect(writer.record('writer')?.state).toBe('completed'); + } + ); + + it('quarantines a busy retry interrupted during wrapper dispatch', async () => { + const { writer, sibling } = sharedSessions(); + await writer.admit('writer'); + await writer.flush(); + delegateRequest(writer, operation, async () => controlFailure(true, 'session_busy')); + await sibling.admit('waiting'); + await sibling.flush(); + expect(sibling.record('waiting')?.deliveryRetryScope).toBe('message'); + const pending = deferred(); + delegateRequest(writer, operation, () => pending.promise); + writer.control.request.mockClear(); + sibling.reload(); + const retry = sibling.fireAlarm(); + await sibling.flush(); + try { + expect(writer.control.request).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ operation, expectedWrapperInstanceId: RUNTIME_ID }) + ); + expect(sibling.record('waiting')).toMatchObject({ + state: 'queued', + unresolvedDispatch: true, + wrapperInstanceId: RUNTIME_ID, + }); + expect(sibling.record('waiting')?.deliveryRetryScope).toBeUndefined(); + await expect(sibling.session.interruptExecution()).resolves.toEqual({ success: true }); + expect(writer.control.quarantineRuntime).toHaveBeenCalledExactlyOnceWith({ + ownerId: writer.metadata.identity.userId, + sessionId: sibling.metadata.identity.sessionId, + wrapperInstanceId: RUNTIME_ID, + reason: 'preparation_interrupted', + }); + expect(sibling.record('waiting')?.state).toBe('cancelled'); + expect(writer.record('writer')?.state).toBe('failed'); + } finally { + pending.resolve( + controlResponse( + operation === 'session.attach' + ? { attached: true } + : { messageId: 'waiting', status: 'accepted' } + ) + ); + await retry; + await sibling.flush(); + } + await sibling.fireAlarm(); + expect(writer.control.request).toHaveBeenCalledOnce(); + expect(sibling.record('waiting')?.state).toBe('cancelled'); + expect(sibling.terminalEvents()).toHaveLength(1); + }); + it.each(['stop', 'expiry'] as const)( 'retains unresolved dispatch ownership through a lost acknowledgement, busy replay, and %s', async action => { From 75bae74a021f04f55912fd99b65a7147e5ae4fc2 Mon Sep 17 00:00:00 2001 From: Evgeny Shurakov Date: Tue, 1 Sep 2026 17:11:53 +0200 Subject: [PATCH 14/14] test(web): align workspace tab rename hint expectation --- .../cloud-agent-next/CloudAgentWorkspaceTabs.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts index fb649b8167..d65314e596 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts @@ -513,9 +513,7 @@ describe('CloudAgentWorkspaceTabs', () => { const tab = findButtonMarkup(html, 'First worktree chat'); expect(tab.includes('aria-keyshortcuts="F2"')).toBe(canRename); - expect( - tab.includes('aria-description="Double-click to rename. Press F2 when focused."') - ).toBe(canRename); + expect(tab.includes('aria-description="Double-click to rename."')).toBe(canRename); expect(html).not.toContain('>Rename<'); expect(html).not.toContain('Delete session'); expect(html).not.toContain('Session actions for');