diff --git a/.specs/cloud-agent-session.md b/.specs/cloud-agent-session.md index ae2e66b3f4..3e8b08f228 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,78 @@ 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 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 + 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 +189,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 +213,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 +234,21 @@ 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, + 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/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/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, 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..a2cc39d4de 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,16 @@ 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 SidebarForegroundSessionStatus, + 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 +42,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,12 +54,21 @@ 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[]; + foregroundSession?: SidebarForegroundSessionStatus | null; searchQuery?: string; onSearchChange?: (query: string) => void; platformFilter?: string[]; @@ -206,7 +163,7 @@ function SessionRow({ <> {session.prompt} - + {isDeleting ? ( @@ -229,7 +186,7 @@ function SessionRow({ ) : ( @@ -239,15 +196,17 @@ function SessionRow({ {!isDeleting && (onDeleteSession || onStartRename) && ( @@ -288,6 +247,278 @@ function SessionRow({ ); } +function WorktreeGroupRow({ + group, + currentSessionId, + selectedWorktreeId, + onOpenSession, + onCreateWorktreeChat, + creatingWorktreeSourceSessionId, + onRenameWorktree, + onDeleteWorktree, + isDeleting, + activeSessionStatuses, + foregroundSession, +}: { + 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; + foregroundSession?: SidebarForegroundSessionStatus | null; +}) { + 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, + group.details?.sessions, + foregroundSession + ); + 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,12 +553,21 @@ 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 = [], + foregroundSession, searchQuery = '', onSearchChange, platformFilter, @@ -377,6 +617,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 +635,7 @@ export function ChatSidebar({ } onMobileSheetOpenChange?.(false); }, - [chatPath, pathname, router, onMobileSheetOpenChange] + [chatPath, pathname, router, onOpenSession, onMobileSheetOpenChange] ); const toggleSearch = useCallback(() => { @@ -401,7 +647,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 +662,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 +888,26 @@ 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..ba1b2add08 100644 --- a/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudAgentProvider.tsx @@ -311,6 +311,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..d65314e596 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/CloudAgentWorkspaceTabs.test.ts @@ -0,0 +1,651 @@ +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."')).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..229b93701f 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.'; + 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..320a077beb 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,45 @@ 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); + 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({}); - }, [sessionId]); + } + + 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 +623,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 +703,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 +771,7 @@ export default function CloudChatPage({ key={tab.id} terminalId={tab.id} active={active} - sessionId={sessionId} + sessionId={tab.cloudAgentSessionId} organizationId={organizationId} onStatusChange={handleTerminalStatusChange} /> @@ -897,7 +999,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 +1044,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,135 @@ 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 foregroundSession = useMemo( + () => + currentSessionId && foregroundSessionStatus !== null + ? { sessionId: currentSessionId, status: foregroundSessionStatus } + : null, + [currentSessionId, foregroundSessionStatus] + ); + 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 +398,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 +550,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,25 +597,115 @@ 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..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 @@ -1,15 +1,82 @@ -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, + patchSidebarWorktreeSessionStatus, 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 +95,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 +200,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 +288,685 @@ 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, + sessions: [first, latest], + }, + } 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, + sessions: [visible], + }; + 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, + sessions: [visible, hidden], + }, + }); + 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, + }); + }); + + 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', () => { it('matches by session_id substring', () => { const session = makeDbSession('abc-123-def', '2026-01-01T00:00:00.000Z'); @@ -219,3 +1035,339 @@ 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('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 }, + { 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, + sessions: [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..be274a0dc3 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,22 @@ 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 type { inferRouterOutputs } from '@trpc/server'; +import type { RootRouter } from '@/routers/root-router'; +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 +30,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 +51,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 +67,366 @@ 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; + }); +} + +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 = { + 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: 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 sessionsById.values()) { + const activeStatus = activeSessionStatuses.get(session.sessionId); + if (activeStatus !== undefined) isLive = true; + 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 + : status === 'busy' || status === 'retry' + ? 1 + : 0; + if (priority <= selectedPriority) continue; + selectedPriority = priority; + selectedStatus = status; + selectedStatusUpdatedAt = + 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; /** @@ -61,11 +434,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 +447,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 +464,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 +509,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 +619,8 @@ type UseSidebarSessionsOptions = { type UseSidebarSessionsReturn = { sessions: StoredSession[]; + cachedSessions: StoredSession[]; + worktreeDetails: Record; isLoading: boolean; refetchSessions: () => void; renameSessionLocally: (sessionId: string, newTitle: string) => void; @@ -256,6 +635,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 +719,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 +732,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 +756,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, @@ -400,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) => { @@ -411,10 +817,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 }; @@ -463,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) { @@ -490,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 => @@ -532,15 +952,64 @@ export function useSidebarSessions(options?: UseSidebarSessionsOptions): UseSide setDbSessions, queryClient, queryReconciler, + trpc, ]); - 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, + sessions: worktree.sessions, + 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 +1022,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..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 @@ -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,20 @@ 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, + sessions: sessions.map(session => ({ + sessionId: session.session_id, + sessionStatus: session.status, + sessionStatusUpdatedAt: session.status_updated_at, + })), + }, + }, + }); queryClient.setQueryData(['unrelated'], { cliSessions: sessions }); const context = { @@ -90,6 +109,7 @@ function createFixture() { searchKeys, activeKey, recentKey, + worktreeDetailsKey, seedSessions, }; } @@ -206,14 +226,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..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,13 +1,177 @@ +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, closeTerminalTab, createWorkspaceTabsState, + getWorkspaceTabScope, resetWorkspaceTabs, selectWorkspaceTab, 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({ @@ -17,22 +181,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 +207,259 @@ 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('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'); + 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..ffd127f20c 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,216 @@ 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_12345678-1234-4234-9234-123456789abc'; + 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', + 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', + 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'], + }, + { + id: 'prt_text', + type: 'text', + text: `Read ${privateDirectory}/src/app.ts and src/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: { + 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({ + info: { + editorContext: { + visibleFiles: [publicDirectory], + openTabs: [publicDirectory], + activeFile: publicDirectory, + }, + summary: { diffs: [{ file: publicDirectory }] }, + }, + }); + 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 = { + 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 +436,85 @@ describe('cli-sessions-v2-router', () => { describe('getSessionMessagesPage', () => { const sessionId = 'ses_messages_page_test_1234'; + const privateDirectory = + '/srv/runtime/private/worktrees/worktree_12345678-1234-4234-9234-123456789abc'; 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'], + }, + { + 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`, + }, + ], + }; + } + beforeEach(async () => { const { fetchSessionMessagesPage: imported } = jest.requireMock( '@/lib/session-ingest-client' @@ -292,6 +584,92 @@ 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)); + const message = privateAssistantMessage(); + fetchSessionMessagesPage.mockResolvedValueOnce({ + kiloSessionId: sessionId, + history: { + messages: [message], + nextCursor: 'opaque-next-cursor', + omittedItemCount: 2, + }, + }); + 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'] }, + { + 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: '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 () => { + 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.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 }); + } + ); + it('forwards the continuation cursor to the client', async () => { fetchSessionMessagesPage.mockResolvedValueOnce({ kiloSessionId: sessionId, @@ -753,6 +1131,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..2621d58e1f 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,9 @@ import { import { DEFAULT_KILO_SDK_MESSAGE_PAGE_SIZE, MAX_KILO_SDK_MESSAGE_HISTORY_PAGE_SIZE, + cloudAgentWorktreeIdSchema, + isDefaultSessionTitle, + projectPrivateWorktreePaths, validateKiloSdkMessagesCursor, } from '@kilocode/session-ingest-contracts'; import { baseGetSessionNextOutputSchema } from './cloud-agent-next-schemas'; @@ -77,6 +84,75 @@ 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, + metadata: readonly unknown[] +): T { + const directory = `/cloud-agent/sessions/${kiloSessionId}`; + const projected = projectPrivateWorktreePaths(value, metadata, directory); + projectGroupedTranscriptPaths(projected, directory); + 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 +420,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 +532,44 @@ 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; + }) + | 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 +641,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 +663,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 +831,242 @@ 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, activityRows] = 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) + ), + 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); + 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, + 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 }; + }), + + 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({ createdAt: cli_sessions_v2.created_at }) + .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 + ) + ) + .orderBy(asc(cli_sessions_v2.created_at)) + .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, + created_at: session.createdAt, + }) + .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 +1079,7 @@ export const cliSessionsV2Router = createTRPCRouter({ sharedOnly, createdOnPlatform, organizationId, + worktreeId, gitUrl, updatedSince, version, @@ -737,6 +1091,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 +1170,7 @@ export const cliSessionsV2Router = createTRPCRouter({ orderBy, createdOnPlatform, organizationId, + worktreeId, includeChildren, sharedOnly, gitUrl, @@ -826,6 +1184,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 +1217,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 +1375,19 @@ 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.info, + ...snapshot.messages.map(message => message.info), + ]) + : snapshot; } catch (error) { console.error( `Failed to fetch messages for session ${input.session_id}:`, @@ -1092,7 +1470,18 @@ export const cliSessionsV2Router = createTRPCRouter({ }); } - return { ...result, watermarkEventId }; + return { + ...(session.cloud_agent_worktree_id + ? projectGroupedSessionTranscript( + result, + input.session_id, + result.history && 'messages' in result.history + ? result.history.messages.map(message => message.info) + : [] + ) + : result), + watermarkEventId, + }; }), /** @@ -1113,6 +1502,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 +1618,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 +1978,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..cdedcab6a8 --- /dev/null +++ b/apps/web/src/routers/cli-sessions-v2-worktree.test.ts @@ -0,0 +1,1497 @@ +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 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, + 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: mockBatchReviewDecisionFetch, +})); + +jest.mock('@/lib/cloud-agent/session-events', () => ({ + notifyCliSessionRenamed: jest.fn(), +})); + +jest.mock('next/server', () => ({ + after: (callback: () => void | Promise) => { + afterCallbacks.push(callback); + }, +})); + +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(); + afterCallbacks.length = 0; + 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, + 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) + .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', + 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( + 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'); + 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}`])( + '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]).toMatchObject({ + 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]).toMatchObject({ + 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.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( + ['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, + sessions: [{ sessionId: SESSION_ID, sessionStatus: null, sessionStatusUpdatedAt: null }], + }, + }); + expect( + ( + await caller.worktreeDetails({ + worktreeIds: [WORKTREE_ID, orgWorktreeId], + organizationId: ORGANIZATION_ID, + }) + ).worktrees + ).toEqual({ + [orgWorktreeId]: expect.objectContaining({ + 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.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) + .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..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}$/); @@ -17,3 +18,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..36fa90b613 100644 --- a/packages/session-ingest-contracts/src/index.ts +++ b/packages/session-ingest-contracts/src/index.ts @@ -1,2 +1,10 @@ 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$/; + +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/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/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/.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/kilo-facade/public-sdk-projection.test.ts b/services/cloud-agent-next/src/kilo-facade/public-sdk-projection.test.ts index 3223056068..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( @@ -71,6 +111,142 @@ 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`, + }, + 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([ + { + 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`, + }, + 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 294099f1f2..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, @@ -171,9 +172,14 @@ export function projectPublicStoredMessage( message: KiloSdkStoredMessage, kiloSessionId: string ): KiloSdkStoredMessage { + const sanitized = projectPrivateWorktreePaths( + message, + [message.info], + publicCloudAgentDirectory(kiloSessionId) + ); 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..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 @@ -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,8 +23,8 @@ 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'; import { withDORetry } from '../utils/do-retry.js'; import { resolveSessionStub } from '../sandbox-session/session-stub.js'; @@ -70,6 +73,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 & { @@ -832,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; @@ -946,6 +950,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 +1241,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..c02a22cf6b 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'; @@ -38,7 +42,6 @@ import { type EventSourceId, type EventId, type SessionId, - type UserId, } from '../types/ids.js'; import type { ExecutionMetadata, @@ -1115,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, @@ -1663,6 +1666,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; @@ -2599,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 ba145e76bb..c5d2c6475a 100644 --- a/services/cloud-agent-next/src/persistence/SandboxControl.ts +++ b/services/cloud-agent-next/src/persistence/SandboxControl.ts @@ -1,9 +1,30 @@ 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'; 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 { @@ -52,6 +73,7 @@ import { observe, recordStopAttempt, sameAllocation, + getWorktreeCredentialContainment, WORKTREE_CREDENTIAL_CONTAINMENT, type CredentialContainmentRequirements, type ObserveResult, @@ -121,6 +143,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 +177,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 +257,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 +291,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 +348,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 +449,10 @@ export class SandboxControl extends DurableObject { } async request(input: SandboxControlOutboundRequest): Promise { + await this.assertRequestWorktreeAdmission(input); + 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 +485,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,20 +503,38 @@ 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'); }); } + await this.assertRequestWorktreeAdmission(input); if (!isCurrent()) throw new Error('Sandbox wrapper runtime changed'); return this.socketHandler.sendRequest(input); } @@ -516,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); @@ -565,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'); } @@ -575,15 +659,20 @@ 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'); } } + 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, @@ -628,6 +717,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 +734,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 +758,67 @@ 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; + const requiredContainment = getWorktreeCredentialContainment( + requiresContainmentSandbox(metadata) + ); + 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, 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); + selected = await this.acquirePhysical(acquisition, requiredContainment, worktreeId); } else if (step === 'observe-unknown') { await this.observeCurrentProvider(selected.physical); - selected = await this.acquirePhysical(acquisition); + selected = await this.acquirePhysical(acquisition, requiredContainment, worktreeId); } } if (selected.action === 'wait') return this.statusForPhysical(selected.physical); @@ -718,11 +839,12 @@ 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, allocationName, - WORKTREE_CREDENTIAL_CONTAINMENT + requiredContainment ); creating = true; return claimed; @@ -735,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(); @@ -794,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( @@ -813,6 +938,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 +964,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 +1007,11 @@ export class SandboxControl extends DurableObject { return { ...status, attachment }; } - private async acquirePhysical(acquisition: SandboxAcquisition): Promise<{ + private async acquirePhysical( + acquisition: SandboxAcquisition, + requiredContainment: CredentialContainmentRequirements, + worktreeId?: string + ): Promise<{ physical: PhysicalRecord; action: 'create' | 'reuse' | 'wait'; }> { @@ -888,17 +1019,18 @@ 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 ( + 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' }; @@ -1024,9 +1156,19 @@ 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); + 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); + return { value: attached, changed: attached.changed }; + }); if (result.changed) { await this.appendLog( routeTransition(Date.now(), 'attach', input.sessionId, input.kiloSessionId) @@ -1037,41 +1179,333 @@ 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({ preserveAcquisitionReceipts: true }); + 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 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 || + (worktreeId && this.deletingWorktrees.has(worktreeId)) + ) { + throw new Error('worktree_deleting'); } - return { existed: result.existed }; } async listRoutes(): Promise { @@ -1096,14 +1530,20 @@ 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({ - 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(), @@ -1137,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 ); } @@ -1145,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 }; } @@ -1232,6 +1678,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 +1688,15 @@ 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); + this.provider = this.createProviderAdapter('vercel', next); } await this.persistPhysicalState(current, next, 'demand'); return next; @@ -1427,7 +1883,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, @@ -1437,9 +1893,14 @@ export class SandboxControl extends DurableObject { DIAGNOSTIC_BUNDLE_KEY, PROVIDER_KIND_KEY, BILLING_INPUT_KEY, - ACQUISITION_RECEIPTS_KEY, CREDENTIAL_POLICY_DIRTY_KEY, + PROVIDER_LOCATOR_KEY, + ...(options?.preserveAcquisitionReceipts ? [] : [ACQUISITION_RECEIPTS_KEY]), ]); + this.vercelLocator = undefined; + this.activeConnection = null; + this.readyConnectionId = null; + this.kiloReady = false; } private withCredentialUpdate(operation: () => Promise): Promise { @@ -1474,6 +1935,7 @@ export class SandboxControl extends DurableObject { ) { throw new Error('Session credential ownership mismatch'); } + this.assertWorktreeAdmission(metadata.workspace?.worktreeId); return metadata; } @@ -1495,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'); } @@ -1552,9 +2022,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 +2066,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 } : {}), @@ -1644,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 @@ -1738,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) ); } @@ -1752,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; @@ -1834,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; } @@ -2172,10 +2672,18 @@ 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' }; } - if (!this.matchesContainment(physical, WORKTREE_CREDENTIAL_CONTAINMENT)) { + if (!this.matchesWorktreeContainment(physical)) { return { allowed: false, reason: 'credential_containment_unavailable' }; } const grant = grants.find( @@ -2192,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' }; @@ -2420,6 +2936,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..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, @@ -313,6 +323,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..63f1f55353 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(), @@ -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/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-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/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..7df3a84227 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.test.ts @@ -0,0 +1,951 @@ +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: true, 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, + }; +} + +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 ?? '*', + WORKTREE_CREATION_ENABLED_IDS: '', + 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 }, + }), + ], + [ + '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.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, + 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: true, 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.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 }), + }); + 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' }) + ); + } + ); + + 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..a70dcb26f8 --- /dev/null +++ b/services/cloud-agent-next/src/router/handlers/session-worktree.ts @@ -0,0 +1,598 @@ +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) || + (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 } : {}), + ...(source.metadata.finalization ? { finalization: source.metadata.finalization } : {}), + }; +} + +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 || + !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..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 @@ -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,14 @@ import { type CloudflareSandboxHandle, } from './cloudflare-provider.js'; import { DEADLINE_MS } from './deadlines.js'; -import { WORKTREE_CREDENTIAL_CONTAINMENT } from './physical-lifecycle.js'; +import { + beginStop, + confirmStopped, + getWorktreeCredentialContainment, + recordStopAttempt, + WORKTREE_CREDENTIAL_CONTAINMENT, + type PhysicalRecord, +} from './physical-lifecycle.js'; import { deriveSandboxAllocationId } from '../sandbox-id.js'; const PROVIDER_REF = encodeCloudflareProviderRef({ @@ -54,6 +62,160 @@ 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 => { + 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); @@ -225,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', @@ -616,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, @@ -670,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([ @@ -690,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/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.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/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/lifecycle.test.ts b/services/cloud-agent-next/src/sandbox-control/lifecycle.test.ts index 702dbb887a..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; } = {} @@ -144,6 +148,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 @@ -210,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', @@ -245,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() }, }) @@ -262,6 +289,7 @@ async function harness( .mockResolvedValue({ type: 'response', requestId: 'request_1', ok: true }); const socket = { getConnectionIdentity: () => connection, + hasHandshakenSocket: () => connection !== null, closeAll: vi.fn(() => { connection = null; }), @@ -294,6 +322,8 @@ async function harness( records, env, namespace, + namespaces, + issueKiloSessionCapability, get transactionActive() { return transactionActive; }, @@ -370,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({ @@ -563,6 +881,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); }); @@ -943,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(); @@ -1099,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(); @@ -1161,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(); @@ -1382,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 }>(); @@ -1712,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({ @@ -2459,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 7d0daaf3d2..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,10 +1,13 @@ 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'; import type { Env, GitTokenService } from '../types.js'; import { createControlPlaneCredential, parseControlPlaneCredential } from './managed-credential.js'; import { buildControlNetworkPolicy, + isContainedSessionCredentialGrant, prepareSessionCredentials, removeSessionCredentialMembership, resolveSessionCredential, @@ -71,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}`; @@ -111,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(), @@ -144,7 +165,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, @@ -176,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, @@ -192,6 +213,8 @@ function prepare( existing, now, }); + if (!isContainedSessionCredentialGrant(result.grant)) throw new Error('Expected contained grant'); + return { ...result, grant: result.grant }; } function resolve( @@ -202,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, @@ -211,7 +234,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 +273,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 +535,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' ), ], [ @@ -891,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()); @@ -1050,7 +1716,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 +1764,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 +1834,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-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/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/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-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..7e9088db8d --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.test.ts @@ -0,0 +1,210 @@ +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('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({ + ...(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..dafea50b44 --- /dev/null +++ b/services/cloud-agent-next/src/sandbox-control/worktree-ownership.ts @@ -0,0 +1,92 @@ +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; + } + 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/SandboxSession.ts b/services/cloud-agent-next/src/sandbox-session/SandboxSession.ts index 209757890f..dd930f4a71 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, @@ -75,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, @@ -113,6 +130,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 +151,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 +211,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 +264,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 +286,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 +312,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 +405,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 +481,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(); } @@ -462,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( @@ -563,7 +659,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 +667,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 +686,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 +835,30 @@ 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) { + try { + validateControlSessionOptions(existing); + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unsupported session options', + }; + } + if (initialMessage && !existing.initialMessage) { + this.ctx.storage.kv.put( + METADATA_KEY, + serializeSessionMetadata({ ...existing, initialMessage }) + ); + } + return { success: true }; + } try { validateControlSessionOptions(input); } catch (error) { @@ -643,12 +882,14 @@ export class SandboxSession extends DurableObject { 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 } : {}), 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 +900,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 +912,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 +953,7 @@ export class SandboxSession extends DurableObject { } return this.queueAndDispatch( { - turn: input.message.initialTurn, + turn: initialTurn, agent: input.agent, finalization: input.finalization, }, @@ -738,6 +1002,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 +1044,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; @@ -821,15 +1092,28 @@ export class SandboxSession extends DurableObject { ): Promise { const epoch = this.terminalLifecycle.captureEpoch(); const metadata = this.terminalLifecycle.getStoredMetadata(); - if (epoch === null || !metadata) { + if (epoch === null || !metadata || this.deletedWorktreeId) { return { success: false, code: 'NOT_FOUND', error: 'Session not found' }; } + const admissionInput = metadata.workspace?.worktreeId + ? { + ...input, + finalization: { + ...metadata.finalization, + ...input.finalization, + autoCommit: input.finalization?.autoCommit ?? metadata.finalization?.autoCommit ?? true, + }, + } + : 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,7 +1163,11 @@ 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) || (intent && @@ -904,7 +1192,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 +1222,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,6 +1245,7 @@ export class SandboxSession extends DurableObject { messageId: string, options?: { allowCreate?: boolean } ): Promise { + if (this.deletedWorktreeId) return; const metadata = this.terminalLifecycle.getStoredMetadata(); const epoch = this.terminalLifecycle.captureEpoch(); const sandboxId = metadata?.workspace?.sandboxId; @@ -963,7 +1260,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 @@ -990,15 +1291,61 @@ 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, deliveryRetryScope: undefined } + : 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; @@ -1061,6 +1408,9 @@ export class SandboxSession extends DurableObject { sessionId, provider, ...(acquisition ? { acquisition } : { allowCreate }), + ...(metadata.workspace?.worktreeId + ? { worktreeId: metadata.workspace.worktreeId } + : {}), billing: buildSandboxBillingInput( metadata, sandboxId, @@ -1117,7 +1467,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'); @@ -1142,25 +1495,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; @@ -1173,41 +1529,43 @@ export class SandboxSession extends DurableObject { this.terminalLifecycle.recordAttachment({ metadata, sandboxId, wrapperInstanceId, epoch }); recorder.finalize({ status: 'completed' }); phase = 'prompt'; - 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 } : {}), + }, + ...(intent.finalization ? { finalization: intent.finalization } : {}), + ...(attachments.length ? { attachments } : {}), }, - ...(intent.finalization ? { finalization: intent.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); @@ -1221,7 +1579,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, @@ -1234,6 +1594,7 @@ export class SandboxSession extends DurableObject { } private async compensateSessionAttachment(metadata: SessionMetadata): Promise { + if (this.deletedWorktreeId) return; try { await withTimeout( this.terminalLifecycle.cleanupSession(metadata, []), @@ -1268,16 +1629,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; @@ -1289,7 +1658,8 @@ export class SandboxSession extends DurableObject { : phase === 'attach' ? 'attach_exhausted' : 'environment_failed', - wrapperInstanceId + wrapperInstanceId, + scope ); } @@ -1353,7 +1723,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(); @@ -1366,6 +1737,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(); @@ -1456,6 +1833,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 +1846,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 +1919,51 @@ 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 async requestSessionOperation( operation: 'session.permission.resolve' | 'session.question.resolve', payload: unknown @@ -1596,13 +2042,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-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/control-plane-ingest.test.ts b/services/cloud-agent-next/src/sandbox-session/control-plane-ingest.test.ts index 32642fb0b3..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 @@ -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,9 +110,17 @@ 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(); + expect(request.headers.get(cloudAgentSessionScopeHeaders.trustedLineage)).toBeNull(); expect(await request.json()).toEqual({ data: [{ type: 'message', data: { id: 'msg_1' } }] }); }); @@ -70,6 +148,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'); @@ -80,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({ @@ -87,6 +174,32 @@ 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(requests[0].headers.get(cloudAgentSessionScopeHeaders.trustedLineage)).toBe('1'); + 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 +223,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..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 @@ -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', @@ -76,9 +132,12 @@ export async function publishControlPlaneSessionIngest(params: { cloudAgentSessionScopeHeaders.protocolVersion, cloudAgentSessionScopeProtocolVersion ); + if (lineage) headers.set(cloudAgentSessionScopeHeaders.trustedLineage, '1'); } 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 +147,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 +168,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..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 @@ -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, + }, + }, ]); }); }); @@ -740,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: { @@ -752,7 +913,7 @@ const ATTACHMENT: SessionAttachPayload = { sessionIngestBaseUrl: 'https://ingest.example.test', }, }, -}; +} satisfies SessionAttachPayload; type Control = ReturnType; type ControlStatus = Awaited>; @@ -771,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[] = []; @@ -829,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: () => [], @@ -875,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', @@ -898,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: () => { @@ -947,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 } }, @@ -1354,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'); @@ -1367,6 +1537,7 @@ describe('SandboxSession orchestration', () => { it.each([ 'permanent response', + 'unhealthy response', 'malformed response', 'unmarked exception', 'overloaded exception', @@ -1378,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 { @@ -1390,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( @@ -1402,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( @@ -1425,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); @@ -1437,6 +1626,470 @@ 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(['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 => { + 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 => { @@ -2385,6 +3038,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 => { @@ -2569,6 +3338,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 +3346,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 +3512,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..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 @@ -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'; @@ -32,6 +33,8 @@ type SessionMessageLifecycle = { acceptedAt?: number; lastActivityAt?: number; deliveryDeadlineAt?: number; + deliveryRetryScope?: 'message' | 'runtime'; + unresolvedDispatch?: true; wrapperInstanceId?: string; terminalAt?: number; failedReason?: string; @@ -45,14 +48,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 +170,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 +183,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) } @@ -278,6 +292,7 @@ export function applyMessageOutcome( ? { ...item, state: outcome.status, + unresolvedDispatch: undefined, acceptedAt: item.acceptedAt ?? now, terminalAt: now, ...(outcome.reason ? { failedReason: outcome.reason } : {}), @@ -292,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 ); } @@ -312,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/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-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-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/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/src/session/session-prepare.test.ts b/services/cloud-agent-next/src/session/session-prepare.test.ts index 33f15ed149..d6e7f8adb2 100644 --- a/services/cloud-agent-next/src/session/session-prepare.test.ts +++ b/services/cloud-agent-next/src/session/session-prepare.test.ts @@ -20,13 +20,16 @@ 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, 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, type SessionRegistrationContext, } from './session-registration.js'; import { prepareInputToSessionCreateRequest } from '../router/handlers/session-prepare.js'; @@ -123,7 +126,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 +261,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(); @@ -336,8 +384,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([ { @@ -374,7 +422,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, + }), }) ); } @@ -382,7 +435,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); @@ -393,15 +446,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, }, }), }) @@ -500,6 +555,416 @@ 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.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); + + 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, + kiloSessionId: KILO_SESSION_ID, + }); + } + ); + + 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([ + { 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 }, + 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); + expect(storedProgress[SESSION_CREATE_FINALIZATION_VERSION_KEY]).toBe(2); + 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 } }) + ); + } + ); + + 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.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 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, + }); + 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'; + 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; + ctx.env.WORKTREE_CREATION_ENABLED_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: true }, + }) + ); + }); + + 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 = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_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 = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_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 = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_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 legacy browser requests ungrouped even with worktree enrollment', async () => { + const doStub = makeDoStub(); + const ctx = makeContext(doStub); + ctx.env.WORKTREE_CREATION_ENABLED_IDS = '*'; + + await runCreate( + ctx, + 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()); @@ -806,6 +1271,8 @@ describe('createSessionWithLedger admission ladder', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, kiloSessionId: KILO_SESSION_ID, initialMessageId: INITIAL_MESSAGE_ID, + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: false, + [SESSION_CREATE_FINALIZATION_VERSION_KEY]: 2, createIntentFingerprint: expect.any(String), }); }); @@ -1566,6 +2033,8 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, 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), { @@ -1597,59 +2066,654 @@ describe('createSessionWithLedger takeover reconciliation ladder', () => { }); }); -describe('createSessionWithLedger changed-intent rejection', () => { +describe('createSessionWithLedger worktree rollout and ownership reconciliation', () => { + const sandboxId = 'ses-0123456789abcdef'; + const sharedSandboxId = 'usr-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + beforeEach(() => { vi.clearAllMocks(); getPgDbMock.mockReturnValue(makeDb([[{ email: 'test@example.com' }]])); - generateSessionIdMock.mockReturnValue(CLOUD_AGENT_SESSION_ID); + generateSessionIdMock.mockReturnValue(WORKSPACE_SESSION_ID); generateKiloSessionIdMock.mockReturnValue(KILO_SESSION_ID); - generateSandboxRoutingTargetMock.mockResolvedValue({ - kind: 'isolated', - sandboxId: 'sb-test-123', - }); - admitOperationMock.mockResolvedValue({ - admission: 'admitted', - row: makeLedgerRow({}), - }); + 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); }); - const ORIGINAL_OPTIONS = { - operationKey: OPERATION_KEY, - kilocodeOrganizationId: 'org-abc', - }; - - function originalRequest(): SessionCreateRequest { + function request(overrides: Partial = {}): SessionCreateRequest { return makeRequest({ - initialTurn: { type: 'prompt', prompt: 'Build the feature' }, - options: ORIGINAL_OPTIONS, + finalization: { autoCommit: true, condenseOnComplete: true }, + options: { + operationKey: OPERATION_KEY, + createdOnPlatform: 'cloud-agent-web', + clientProvenance: 'browser', + }, + ...overrides, }); } - /** Completed ledger row whose canonical result holds the request's intent fingerprint. */ - async function completedRowFor(request: SessionCreateRequest): Promise { - return makeLedgerRow({ - status: 'completed', - canonical_result: { - cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, - kiloSessionId: KILO_SESSION_ID, - initialMessageId: INITIAL_MESSAGE_ID, + function context(doStub: ReturnType): SessionRegistrationContext { + const ctx = makeContext(doStub); + ctx.env.CONTROL_PLANE_IDS = '*'; + ctx.env.WORKTREE_CREATION_ENABLED_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.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 = ''; + + 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, 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: await canonicalProgress(input, { + [SESSION_CREATE_WORKTREE_ENABLED_KEY]: true, + }), + }), + }); + 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(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 () => { + 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.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, + 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(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(); + } + ); + + 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(); + getPgDbMock.mockReturnValue(makeDb([[{ email: 'test@example.com' }]])); + generateSessionIdMock.mockReturnValue(CLOUD_AGENT_SESSION_ID); + generateKiloSessionIdMock.mockReturnValue(KILO_SESSION_ID); + generateSandboxRoutingTargetMock.mockResolvedValue({ + kind: 'isolated', + sandboxId: 'sb-test-123', + }); + admitOperationMock.mockResolvedValue({ + admission: 'admitted', + row: makeLedgerRow({ organization_id: ORIGINAL_OPTIONS.kilocodeOrganizationId }), + }); + settleOperationMock.mockResolvedValue({ settled: true }); + markReconcilePendingMock.mockResolvedValue({}); + recordOperationProgressMock.mockResolvedValue(undefined); + }); + + const ORIGINAL_OPTIONS = { + operationKey: OPERATION_KEY, + kilocodeOrganizationId: 'org-abc', + }; + + function originalRequest(): SessionCreateRequest { + return makeRequest({ + initialTurn: { type: 'prompt', prompt: 'Build the feature' }, + options: ORIGINAL_OPTIONS, + }); + } + + /** Completed ledger row whose canonical result holds the request's intent fingerprint. */ + 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, + initialMessageId: INITIAL_MESSAGE_ID, [SESSION_CREATE_INTENT_FINGERPRINT_KEY]: await sessionCreateIntentFingerprint(request), }, }); } /** 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(); @@ -1670,6 +2734,8 @@ describe('createSessionWithLedger changed-intent rejection', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, 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({ @@ -1698,6 +2764,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 +2794,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { name: string; admission?: 'duplicate_settled' | 'takeover' | 'duplicate_reconcile_pending'; original?: SessionCreateRequest; + identityConflict?: boolean; retry: SessionCreateRequest; }> = [ { @@ -1748,6 +2836,7 @@ describe('createSessionWithLedger changed-intent rejection', () => { }, { name: 'the organization', + identityConflict: true, retry: makeRequest({ options: { operationKey: OPERATION_KEY, kilocodeOrganizationId: 'org-xyz' }, }), @@ -1892,7 +2981,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 +2993,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 +3086,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, @@ -2434,6 +3525,8 @@ describe('createSessionWithLedger clone allocation outcomes', () => { cloudAgentSessionId: CLOUD_AGENT_SESSION_ID, 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 0fbe85d242..ec9a6671b3 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'; @@ -42,7 +46,8 @@ 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, 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. @@ -199,6 +205,8 @@ type SessionLedgerFailureStage = 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. */ @@ -221,6 +229,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; @@ -417,7 +445,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; @@ -429,6 +461,68 @@ 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 + ); +} + +function worktreeEnabledForCreate( + input: SessionRegistrationInput, + ctx: SessionRegistrationContext, + 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 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, + finalizationVersion: 1 | 2 +): SessionRegistrationInput { + if (!worktreeEnabled || finalizationVersion === 2) return input; + return { + ...input, + finalization: { ...input.finalization, autoCommit: false }, + }; +} + async function allocateNewSession( input: SessionRegistrationInput, ctx: SessionRegistrationContext, @@ -446,6 +540,12 @@ async function allocateNewSession( input.clone && !initialTurn && cloudAgentSessionId.startsWith('agent_') ? new Date().toISOString() : undefined; + const worktreeId = + ledger?.worktreeEnabled && cloudAgentSessionId.startsWith('workspace_') + ? cloudAgentWorktreeIdSchema.parse( + `worktree_${cloudAgentSessionId.slice('workspace_'.length)}` + ) + : undefined; const createdOnPlatform = input.options?.createdOnPlatform ?? 'cloud-agent'; try { @@ -463,6 +563,8 @@ async function allocateNewSession( kiloSessionId, ...(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), }); } @@ -564,7 +666,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 +675,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 +691,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 +750,7 @@ async function allocateNewSession( sandboxId, sandboxRoute, sandboxProvider, + ...(worktreeId ? { worktreeId } : {}), initialTurn, reportingCreatedAt, credentialContainment, @@ -666,7 +777,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 +785,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 +802,7 @@ function rebuildCloneAllocation( .datetime({ offset: true }) .optional() .safeParse(canonical.reportingCreatedAt); + const worktreeCreate = worktreeEnabledForCreate(input, ctx, row); if ( !reportingCreatedAt.success || @@ -705,6 +817,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 +862,7 @@ function rebuildCloneAllocation( sandboxId: sandboxId as SandboxId, sandboxRoute: route, sandboxProvider, + ...(worktreeId ? { worktreeId } : {}), initialTurn, reportingCreatedAt: !initialTurn && cloudAgentSessionId.startsWith('agent_') @@ -811,6 +938,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 +1201,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'); @@ -1102,6 +1239,8 @@ 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'; +export const SESSION_CREATE_FINALIZATION_VERSION_KEY = 'finalizationVersion'; /** * Deterministic JSON serialization: object keys are sorted and undefined @@ -1260,6 +1399,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, @@ -1310,18 +1450,29 @@ export async function createSessionWithLedger( taxonomy: 'safe-retry', leaseSeconds: SESSION_CREATE_LEDGER_LEASE_SECONDS, }); + assertSessionOperationIdentity(admission.row, { + userId: ctx.userId, + intent: 'create_cloud', + organizationId: input.options?.kilocodeOrganizationId, + resourceKey: null, + }); + const effectiveInput = effectiveSessionRegistrationInput( + input, + worktreeEnabledForCreate(input, ctx, admission.row), + finalizationVersionForCreate(admission.row) + ); 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); } } @@ -1376,12 +1527,15 @@ 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, + finalizationVersion: finalizationVersionForCreate(row), onFailure: (stage, outcomeCode) => bestEffortLedgerWrite(() => settleOperation(db, { @@ -1457,9 +1611,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 +1747,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 +1760,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 +1893,22 @@ async function reconcileLedgerCreate( return executeLedgerCreate(input, ctx, options, db, row, 'takeover'); } + const worktreeAllocation = worktreeEnabledForCreate(input, ctx, row) + ? 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 +1920,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 +1942,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..0e779c3b77 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', @@ -55,6 +61,8 @@ export const controlErrorCodes = [ 'unknown_operation', 'handshake_required', 'not_ready', + 'session_busy', + 'runtime_unhealthy', 'idempotency_conflict', ] as const; @@ -155,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( @@ -193,6 +213,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(), @@ -202,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/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/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/README.md b/services/cloud-agent-next/test/e2e/README.md index a31e83ecd9..bf3330510e 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`. @@ -28,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 @@ -56,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 @@ -65,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 @@ -92,6 +128,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 +257,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. | @@ -246,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/client.ts b/services/cloud-agent-next/test/e2e/client.ts index d9433ecc91..53fc37599c 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; autoCommit?: boolean } +): 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: input.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..b0bbe1ac56 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,927 @@ 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, + autoCommit: false, + }); + 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, + autoCommit: false, + }); + 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=explicit-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 +1226,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/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/e2e/run.ts b/services/cloud-agent-next/test/e2e/run.ts index 0060d8549e..7167afafd0 100644 --- a/services/cloud-agent-next/test/e2e/run.ts +++ b/services/cloud-agent-next/test/e2e/run.ts @@ -20,11 +20,12 @@ * 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'; 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)), '../..'); @@ -148,11 +149,23 @@ 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', }); 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/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..bd0d8cb444 100644 --- a/services/cloud-agent-next/test/integration/sandbox-control.test.ts +++ b/services/cloud-agent-next/test/integration/sandbox-control.test.ts @@ -8,12 +8,21 @@ 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, } 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, @@ -52,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, @@ -71,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, @@ -95,18 +108,29 @@ 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'); + }, +})); + 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 +148,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 +193,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 +270,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 +1286,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 +2462,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 +2512,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 +2576,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 +2610,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 +2623,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 +2643,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 +2656,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 +2672,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 +2719,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 +3369,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 +3742,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 +5054,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 +5333,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({ @@ -5349,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, @@ -5717,7 +5904,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 +6096,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 +6185,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 +6207,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 +6286,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 +6376,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 +6401,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 +6479,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,10 +6519,11 @@ 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', + unresolvedDispatch: undefined, wrapperInstanceId: replacementFixture.wrapperInstanceId, }, ]); @@ -6378,7 +6576,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 +6593,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 +6619,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 +6630,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 +6660,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 +6676,7 @@ describe('SandboxSession control-plane regressions', () => { message: { initialTurn: { type: 'command', - messageId: 'msg_early', + messageId: 'msg_ffffffffffff00000000000004', command: 'review', arguments: '--all', }, @@ -6488,9 +6693,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 +6705,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 +6726,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 +6761,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 +6784,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 +6816,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 +6879,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 +6902,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 +6913,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 +6932,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 +6970,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 +6983,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 +7001,7 @@ describe('SandboxSession control-plane regressions', () => { ).toMatchObject([ { data: { - messageId: 'msg_unhealthy', + messageId: 'msg_ffffffffffff00000000000007', accepted: true, delivery: 'sent', reason: 'kilo_unhealthy', @@ -6843,7 +7067,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 +7114,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 +7136,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 +7178,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 +7199,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 +7230,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 +7247,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 +7287,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 +7802,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 +7816,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 +7851,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 }); @@ -7693,151 +7925,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, + 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' }); + }); + ws.send( + JSON.stringify({ + type: 'event', + event: 'sandbox.heartbeat', + payload: { state: 'active', - idleForMs: 0, - })), - }, - }) - ); - await vi.waitFor(async () => { - await runInDurableObject(control, async (_instance, state) => { - expect((await loadDeadlines(state.storage)).idleStop).toBeUndefined(); + 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 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; + const lifecycleRequests: { + operation: string; session: { sessionId: string; kiloSessionId: string; directory: string }; - }; - if (request.operation === 'session.abort') { - abortRequests.push({ operation: request.operation, session: request.session }); + }[] = []; + 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: { status: 'aborted' }, + result: + request.operation === 'session.abort' ? { status: 'aborted' } : { detached: true }, }) ); - return; - } - if (request.operation !== 'session.detach') return; - ws.send( - JSON.stringify({ - type: 'response', - requestId: request.requestId, - ok: true, - result: { 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(); + 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' }, + })) ); - }); - - 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', - }, - }, - ]); - expect(provider.stop).not.toHaveBeenCalled(); - expect(provider.create).not.toHaveBeenCalled(); - ws.close(); - }); + 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; @@ -7859,7 +8090,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 +8207,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 +8601,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 +8634,7 @@ describe('SandboxControl terminal runtime coordination', () => { reason: 'session_not_attached', }); }); + await detached; socket.close(); }); @@ -8271,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', @@ -8566,3 +8947,2402 @@ 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 instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + 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 instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + 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 instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + 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 instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + 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.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', + }), + finalization: { autoCommit, 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'; + 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.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 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 }), + 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' }, + }); + 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 }), + ]); + }); + + 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, + 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: true, condenseOnComplete: true }, + }, + }), + ]); + await expect(instance.createSessionWithInitialAdmission(registration)).resolves.toMatchObject( + { + success: true, + 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, + 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: true, 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: true, 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: true, 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.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: 'usr-abcdef123402', + }), + 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.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}`); + + 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(); + }); + } + ); + + 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 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: 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', () => { + 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', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + 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 replays only its questions and permissions afterward', 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, 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', + ]); + expect(events[1]?.data).toMatchObject({ + cloudStatus: { type: 'ready' }, + 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, + 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('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'; + 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 instance.claimCreate( + 'intent_routes', + false, + undefined, + WORKTREE_CREDENTIAL_CONTAINMENT + ); + 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('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); + await seedRunningCredential(credential, targetSandboxId); + await runInDurableObject(control, async instance => { + await instance.initializeOwner(ownerId); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(sessionId, kiloSessionId, ownerId) + ); + await attachGrantedSession( + instance, + instance['ctx'], + groupedRoute(siblingSessionId, siblingKiloSessionId, ownerId) + ); + }); + const session = env.SANDBOX_SESSION.getByName(`${ownerId}:${sessionId}`); + 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: 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', { + 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 }), + }) + ); + }); + + try { + await expect(caller.deleteSession({ sessionId })).rejects.toMatchObject({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to clean up session metadata', + }); + 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 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(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/test/integration/sandbox-terminal.test.ts b/services/cloud-agent-next/test/integration/sandbox-terminal.test.ts index ce74e7cbe1..41b61df1f4 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, @@ -468,6 +469,7 @@ async function createFixture(ownerId?: string, organizationId?: string): Promise }); }); + const messageId = createMessageId(); const admission = await runInDurableObject(sessionStub, instance => instance.createSessionWithInitialAdmission({ identity: sessionIdentity, @@ -475,14 +477,14 @@ 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, 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/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..d0f4d027c2 --- /dev/null +++ b/services/cloud-agent-next/test/integration/worktree-deletion.test.ts @@ -0,0 +1,1723 @@ +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, + 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'; + +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, wrapperInstanceId?: 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, + ...(wrapperInstanceId ? { wrapperInstanceId } : {}), + }, + }) + ); + 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.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); + 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/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/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/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/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": "", 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..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 @@ -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 }); }); @@ -92,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'); @@ -162,6 +399,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( @@ -181,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'], ]); @@ -291,7 +565,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 +574,7 @@ describe('applySessionAttach', () => { kilo, git: { url: 'https://github.com/acme/demo.git' }, setupCommands: ['pnpm install'], + branch: 'feature/shared-worktree', }, { kiloRuntimes: fakeKiloRuntimes(), @@ -323,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(); @@ -519,6 +803,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 +1429,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..fef5707dd3 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; @@ -47,6 +48,7 @@ export type AttachPreparingEmitter = (event: PreparingEventDataV2) => void; export type ApplyAttachDeps = { kiloRuntimes?: WorktreeKiloRuntimes; + canRefreshCredentials?: () => boolean; signal?: AbortSignal; terminalRuntime?: Pick; mkdir?: (directory: string) => Promise; @@ -152,7 +154,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 +253,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); @@ -265,7 +283,12 @@ export async function applySessionAttach( 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, @@ -300,37 +323,62 @@ export async function applySessionAttach( 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); + } + } + 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); } - if (attach.branch) { - const checked = await runGit( - ['checkout', '-B', attach.branch, `origin/${attach.branch}`], + 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), @@ -371,6 +419,22 @@ export async function applySessionAttach( 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/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..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 { @@ -7,16 +8,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'; @@ -33,18 +30,22 @@ 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) => { - 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', @@ -55,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({ @@ -72,6 +78,7 @@ function main(): void { }, sessions: [], tasks: new Map(), + activity: createSessionActivityRegistry(), signal: abort.signal, ...(terminalRuntime ? { terminalRuntime } : {}), emitSessionEvent: (session, payload) => { @@ -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: () => buildHeartbeatPayload(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 f3e0d43c48..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 @@ -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,7 +956,123 @@ describe('handleControlRequest', () => { expect(calls).toHaveLength(before); }); - it('detaches a root without stopping its worktree runtime or sibling roots', async () => { + 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('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); + 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('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(); @@ -889,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', @@ -1195,105 +1421,658 @@ 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, - { - ...promptPayload, - turn: { type: 'command', command: 'review', arguments: '' }, - }, - handlerDeps + const preparationInput = { ...input, sessionIds: [first.kiloSessionId, second.kiloSessionId] }; + const started = new Map( + [first, second, sibling].map(item => [item.kiloSessionId, Promise.withResolvers()]) ); - expect(accepted).toMatchObject({ ok: true }); + 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( + { + 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 }), + }, + first + ); + 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, 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()); + 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([ @@ -1796,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( @@ -1812,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(''); @@ -2708,73 +3492,255 @@ describe('control interactions and sync', () => { tool: { messageID: 'assistant_1', callID: 'tool_2' }, }; - 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' }; - const childPermission = { ...permission, id: 'child_permission', sessionID: 'grandchild' }; - const siblingQuestion = { ...question, id: 'sibling_question', sessionID: 'sibling' }; - const siblingPermission = { ...permission, id: 'sibling_permission', sessionID: 'sibling' }; - rememberAttachedRoot(session.kiloSessionId, session.directory); - rememberChildSession({ - childId: 'child', - parentId: session.kiloSessionId, - directory: childDirectory, - }); - rememberChildSession({ childId: 'grandchild', parentId: 'child', directory: childDirectory }); - rememberAttachedRoot('sibling', session.directory); - const replies: unknown[] = []; - const queried = new Set(); + 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 => { - if (directory) queried.add(directory); - return directory === childDirectory - ? [childQuestion] - : [question, siblingQuestion, { ...question, id: 'unmapped', sessionID: 'unmapped' }]; - }, - getPermissions: async directory => { - if (directory) queried.add(directory); - return directory === childDirectory ? [childPermission] : [permission, siblingPermission]; + 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) => { - replies.push(args); + answered.push(args); return true; }, rejectQuestion: async (...args) => { - replies.push(args); - return true; - }, - answerPermission: async (...args) => { - replies.push(args); + rejected.push(args); return true; }, }), }); - expect(await handleControlRequest('session.sync', session, {}, handlerDeps)).toEqual({ - ok: true, - result: { - status: { type: 'idle' }, - questions: [question, childQuestion], - permissions: [permission, childPermission], - }, - }); - expect(queried).toEqual(new Set([session.directory, childDirectory])); expect( await handleControlRequest( 'session.question.resolve', session, - { action: 'answer', questionId: 'child_question', answers: [['Cloud']] }, + { action: 'answer', questionId: 'question_root', answers: [['yes']] }, handlerDeps ) - ).toMatchObject({ ok: true }); + ).toEqual({ ok: true, result: { success: true } }); expect( await handleControlRequest( 'session.question.resolve', session, - { action: 'reject', questionId: 'child_question' }, + { action: 'reject', questionId: 'question_child' }, handlerDeps ) - ).toMatchObject({ ok: true }); + ).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' }; + const childPermission = { ...permission, id: 'child_permission', sessionID: 'grandchild' }; + const siblingQuestion = { ...question, id: 'sibling_question', sessionID: 'sibling' }; + const siblingPermission = { ...permission, id: 'sibling_permission', sessionID: 'sibling' }; + rememberAttachedRoot(session.kiloSessionId, session.directory); + rememberChildSession({ + childId: 'child', + parentId: session.kiloSessionId, + directory: childDirectory, + }); + rememberChildSession({ childId: 'grandchild', parentId: 'child', directory: childDirectory }); + rememberAttachedRoot('sibling', session.directory); + const replies: unknown[] = []; + 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 + ? [childQuestion] + : [question, siblingQuestion, { ...question, id: 'unmapped', sessionID: 'unmapped' }]; + }, + getPermissions: async directory => { + if (directory) queried.add(directory); + return directory === childDirectory ? [childPermission] : [permission, siblingPermission]; + }, + answerQuestion: async (...args) => { + replies.push(args); + return true; + }, + rejectQuestion: async (...args) => { + replies.push(args); + return true; + }, + answerPermission: async (...args) => { + replies.push(args); + return true; + }, + }), + }); + expect(await handleControlRequest('session.sync', session, {}, handlerDeps)).toEqual({ + ok: true, + result: { + status: { type: 'busy' }, + questions: [question, { ...childQuestion, rootKiloSessionId: session.kiloSessionId }], + permissions: [permission, { ...childPermission, rootKiloSessionId: session.kiloSessionId }], + }, + }); + expect(queried).toEqual(new Set([session.directory, childDirectory])); + expect( + await handleControlRequest( + 'session.question.resolve', + session, + { action: 'answer', questionId: 'child_question', answers: [['Cloud']] }, + handlerDeps + ) + ).toMatchObject({ ok: true }); + expect( + await handleControlRequest( + 'session.question.resolve', + session, + { action: 'reject', questionId: 'child_question' }, + handlerDeps + ) + ).toMatchObject({ ok: true }); expect( await handleControlRequest( 'session.permission.resolve', @@ -3002,7 +3968,149 @@ 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; + 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 +4157,652 @@ 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); + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const reads: unknown[] = []; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async ( + directory, + signal + ): ReturnType => { + reads.push(directory); + expect(signal?.aborted).toBe(false); + 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]); + 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('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); + const sibling = { ...session, sessionId: 'ses_2', kiloSessionId: 'kilo_2' }; + const handlerDeps = deps({ + activity, + kiloClient: fakeKilo({ + getSessionStatuses: async () => { + throw new Error('status unavailable'); + }, + }), + }); + 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([ + { kiloSessionId: 'kilo_1', state: 'active', idleForMs: 80, waitingOn: 'model' }, + { kiloSessionId: 'kilo_2', state: 'finalizing', idleForMs: 80, waitingOn: 'finalizing' }, + ]); + }); + + 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 => { + 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' } }, [ + 'root_a', + 'root_b', + ]); + 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..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 @@ -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,89 @@ 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)])); + const kiloClient = runtime.kiloClient; + try { + const statuses = await withKiloRequestDeadline( + 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 || + runtime.kiloClient !== kiloClient + ) + 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 +346,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 +374,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 +421,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 +488,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 +505,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); @@ -348,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( @@ -359,12 +624,23 @@ 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 } : {}), }); if (owned.signal.aborted) { return fail('not_ready', 'Session attachment cancelled', true); } + if (result.ok) deps.activity?.attach(session.kiloSessionId); return result; } ); @@ -386,9 +662,14 @@ 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); + 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; @@ -452,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; @@ -482,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, @@ -838,8 +1134,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..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 @@ -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([]); @@ -458,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 () => { @@ -514,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(); @@ -539,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 fd345a2209..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 }; @@ -153,12 +166,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 +183,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 +222,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..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 @@ -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'; @@ -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({ @@ -864,6 +1012,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 +1100,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 +1115,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 +1129,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 +1169,7 @@ describe('worktree Kilo runtime registry', () => { expect(harness.unexpectedCloses).toBe(0); } finally { release.resolve(); + stopped.resolve(); await oldResult; } }); @@ -996,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' }, @@ -1043,8 +1229,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 +1291,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 +1766,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 +1926,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..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, @@ -18,10 +19,20 @@ import { } from '../kilo-api.js'; import { withTimeoutAndAbort } from '../utils.js'; import { unfilteredKiloEvents } from './feed.js'; -import { startSandboxControlEventFeed } from './sandbox-control-runtime.js'; +import { forgetAttachedRoot, rememberAttachedRoot } from './session-directories.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; @@ -41,9 +52,11 @@ export type WorktreeKiloRuntimes = { attach( identity: SessionRequestIdentity, kilo: WorktreeKiloAuth, - env?: Record + env?: Record, + canRefreshCredentials?: () => boolean ): WorktreeKiloAttachment; detach(identity: SessionRequestIdentity): boolean; + deleteDirectory(directory: string): Promise; get(directory: string): WorktreeKiloRuntime | undefined; isHealthy(): boolean; shutdown(): void; @@ -62,6 +75,8 @@ type ServerOptions = { timeoutMs?: number; }; +type WorktreeKiloServerHandle = KiloServerHandle & { stopped?: Promise }; + type RuntimeEntry = { kilo: WorktreeKiloAuth; directory: string; @@ -69,8 +84,12 @@ type RuntimeEntry = { abort: AbortController; roots: Set; runtime?: WorktreeKiloRuntime; + kiloClient?: WrapperKiloClient; + processAbort?: AbortController; + pendingPtys: number; feed?: Awaited>; starting?: Promise; + stopped?: Promise; retiring?: Promise; }; @@ -142,6 +161,7 @@ export function buildWorktreeKiloEnvironment( options: { apiKey: kilo.token, kilocodeToken: kilo.token, + ...(kilo.organizationId ? { kilocodeOrganizationId: kilo.organizationId } : {}), baseURL: kilo.targets.providerBaseUrl, }, }, @@ -161,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, @@ -171,13 +192,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 +247,10 @@ export async function startWorktreeKiloServer(options: ServerOptions): Promise Promise; + 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(); const roots = new Map(); + const homesByDirectory = new Map>(); + const deletedDirectories = new Set(); let closed = false; function findRoot(identity: SessionRequestIdentity): RootAttachment | undefined { @@ -270,13 +301,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,22 +317,39 @@ 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); + } + + 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; - let server: KiloServerHandle | undefined; + 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 => { if (!server || serverClosed) return; @@ -323,28 +373,71 @@ export function createWorktreeKiloRuntimes(options: { env: entry.env, 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'); }, }), { @@ -358,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 { @@ -366,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); } @@ -375,6 +524,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); @@ -390,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') @@ -414,7 +607,11 @@ export function createWorktreeKiloRuntimes(options: { ), abort: new AbortController(), roots: new Set(), + pendingPtys: 0, }; + const homes = homesByDirectory.get(directory) ?? new Set(); + homes.add(home); + homesByDirectory.set(directory, homes); entries.set(directory, entry); directoriesByScope.set(kilo.scopeId, directory); } @@ -428,14 +625,17 @@ export function createWorktreeKiloRuntimes(options: { }; roots.set(identity.kiloSessionId, root); entry.roots.add(root); + rememberAttachedRoot(identity.kiloSessionId, directory); } const attachedRoot = root; 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, @@ -456,9 +656,30 @@ 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; + 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 ( @@ -466,17 +687,16 @@ 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)) ) ); }, 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..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 } }, @@ -540,6 +569,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/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, 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/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/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..a897aaa461 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); @@ -570,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 abc4240e55..58f58fd821 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,11 +134,18 @@ 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 = hasCloudAgentSessionScope || currentRow.cloudAgentSessionId != null; + if (currentRow.cloudAgentSessionId != null) { + delete updates.created_on_platform; + } const statusChange = status === undefined @@ -300,7 +309,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); @@ -336,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') || @@ -358,6 +369,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 +518,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..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 @@ -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,24 +168,22 @@ describe('Cloud Agent session scope routes', () => { expect(db.transaction).not.toHaveBeenCalled(); }); - it('atomically heals the root and creates a session-scoped child', async () => { - const child = persistedRow(); - const { db, insertedValues, updateSets } = makeDb( + it('rejects a late descendant registration when its locked root belongs to a deleting worktree', async () => { + const { db, insertedValues } = makeDb( [ [ { sessionId: rootSessionId, - organizationId: child.organization_id, - cloudAgentSessionScopeId: null, + organizationId: null, + cloudAgentSessionScopeId: cloudAgentSessionId, + worktreeId: 'worktree_11111111-1111-4111-8111-111111111111', }, ], + [{ started: '2026-08-27 01:00:00+00' }], ], - [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', @@ -185,22 +192,332 @@ describe('Cloud Agent session scope routes', () => { }), env ); + expect(response.status).toBe(404); + expect(insertedValues).toEqual([]); + expect(getSessionAccessCacheDO).not.toHaveBeenCalled(); + }); - expect(response.status).toBe(200); - expect(updateSets).toContainEqual({ cloud_agent_session_scope_id: cloudAgentSessionId }); - expect(insertedValues).toContainEqual( - expect.objectContaining({ - session_id: childSessionId, + 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, + }, + ], + ], + [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, + }); + } + ); + + 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, + cloud_agent_worktree_id: worktreeId, + }; + const { db, updateSets } = makeDb( + [ + [ + { + sessionId: rootSessionId, + organizationId, + 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(), + [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, + }); + } + ); + + 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' }], + ['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(), + [cloudAgentSessionScopeHeaders.trustedLineage]: '1', + }, + 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 () => { @@ -232,7 +549,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..57cc7ce0ce 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,47 @@ cloudAgentSessionScopeApi.post('/session', zodJsonValidator(createScopedSessionS .limit(1) .for('update'); + if (!existing || existing.cloud_agent_session_id !== null) { + 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 && + (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 +232,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 +281,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..cc7e25bb50 --- /dev/null +++ b/services/session-ingest/src/services/worktree-deletion.test.ts @@ -0,0 +1,1077 @@ +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'; +import type { SessionEventPayload } from '../types/user-connection-protocol'; + +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 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')}`; +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: createdAt, + 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; + createdAt?: string; + gitUrl?: string; + gitBranch?: string; + createdOnPlatform?: string; +}; + +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[] }[] = []; + 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); + 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')) { + 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 => + 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 minimumCreatedAt !== 'string' || + Date.parse(row.createdAt ?? createdAt) >= Date.parse(minimumCreatedAt)) && + (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, + 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 && + !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: (column: unknown) => { + orderedColumn = column; + return 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: (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, + 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, + }) + ), + }; + }, + }; + let commitError: Error | undefined; + const db = { + ...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 + ? { + 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 }); + 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, + worktrees, + members, + allocations, + events, + sqlQueries, + candidateQueries, + cleared, + snapshots, + snapshotReads, + notifications, + pendingNotifications, + executionContext, + failCommit: (error?: Error) => { + commitError = error; + }, + denyMembership: () => { + membershipAllowed = false; + }, + fail: (id?: string) => { + failure = id; + }, + }; +} + +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({ + 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.worktrees[0].created_at = '2026-07-01 01:00:00.123+00'; + f.members.push({ + sessionId: kiloId(1), + cloudAgentSessionId: null, + cloudAgentSessionScopeId: null, + organizationId: null, + 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); + 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('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}`; + 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(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(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 () => { + 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]); + expect(f.notifications).toEqual([]); + 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..52a9dac8b8 --- /dev/null +++ b/services/session-ingest/src/services/worktree-deletion.ts @@ -0,0 +1,875 @@ +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 { + 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 { notifyUserSessionEvent } from '../session-events'; +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, + createdAt: cli_sessions_v2.created_at, + }) + .from(cli_sessions_v2) + .where(eq(cli_sessions_v2.cloud_agent_worktree_id, params.worktreeId)) + .orderBy(cli_sessions_v2.created_at); + if ( + owners.some( + row => + row.userId !== params.kiloUserId || row.organizationId !== (params.organizationId ?? null) + ) + ) { + throw new Error(WORKTREE_ACCESS_DENIED); + } + 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 }); + } + 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), + gte(cli_sessions_v2.created_at, worktree.created_at), + 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, + executionContext?: ExecutionContext +): 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' + ); + } + const { deletedSessionIds, deletedSessions } = await db.transaction(async tx => { + const current = await lockWorktree(tx, params); + if (current.deletion_completed_at !== null) + 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))) + 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'); + } + 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({ + 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 { 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-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..1366190209 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,35 @@ 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), + this.ctx + ); + } + + 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 +208,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 +306,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 +315,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 +375,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( @@ -467,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), }); @@ -569,6 +624,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(); + }); + }); +});