Fix persistence

This commit is contained in:
Siddharth Ganesan
2026-04-09 15:56:06 -07:00
parent 3ef87e55a3
commit 2d2f7828c9
17 changed files with 1427 additions and 22 deletions

View File

@@ -0,0 +1,130 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
const ENV_URLS: Record<string, string | undefined> = {
dev: env.MOTHERSHIP_DEV_URL,
staging: env.MOTHERSHIP_STAGING_URL,
prod: env.MOTHERSHIP_PROD_URL,
}
function getMothershipUrl(environment: string): string | null {
return ENV_URLS[environment] ?? null
}
/**
* Proxy to the mothership admin API.
*
* Query params:
* env - "dev" | "staging" | "prod"
* endpoint - the admin endpoint path, e.g. "requests", "licenses", "traces"
*
* The request body (for POST) is forwarded as-is. Additional query params
* (e.g. requestId for GET /traces) are forwarded.
*/
export async function POST(req: NextRequest) {
const session = await getSession()
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
if (!adminKey) {
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
}
const { searchParams } = new URL(req.url)
const environment = searchParams.get('env') || 'dev'
const endpoint = searchParams.get('endpoint')
if (!endpoint) {
return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 })
}
const baseUrl = getMothershipUrl(environment)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
{ status: 400 }
)
}
const targetUrl = `${baseUrl}/api/admin/${endpoint}`
try {
const body = await req.text()
const upstream = await fetch(targetUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': adminKey,
},
...(body ? { body } : {}),
})
const data = await upstream.json()
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{
error: `Failed to reach mothership (${environment}): ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 502 }
)
}
}
export async function GET(req: NextRequest) {
const session = await getSession()
if (!session?.user || session.user.role !== 'admin') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const adminKey = env.MOTHERSHIP_API_ADMIN_KEY
if (!adminKey) {
return NextResponse.json({ error: 'MOTHERSHIP_API_ADMIN_KEY not configured' }, { status: 500 })
}
const { searchParams } = new URL(req.url)
const environment = searchParams.get('env') || 'dev'
const endpoint = searchParams.get('endpoint')
if (!endpoint) {
return NextResponse.json({ error: 'endpoint query param required' }, { status: 400 })
}
const baseUrl = getMothershipUrl(environment)
if (!baseUrl) {
return NextResponse.json(
{ error: `No URL configured for environment: ${environment}` },
{ status: 400 }
)
}
const forwardParams = new URLSearchParams()
searchParams.forEach((value, key) => {
if (key !== 'env' && key !== 'endpoint') {
forwardParams.set(key, value)
}
})
const qs = forwardParams.toString()
const targetUrl = `${baseUrl}/api/admin/${endpoint}${qs ? `?${qs}` : ''}`
try {
const upstream = await fetch(targetUrl, {
method: 'GET',
headers: { 'x-api-key': adminKey },
})
const data = await upstream.json()
return NextResponse.json(data, { status: upstream.status })
} catch (error) {
return NextResponse.json(
{
error: `Failed to reach mothership (${environment}): ${error instanceof Error ? error.message : 'Unknown error'}`,
},
{ status: 502 }
)
}
}

View File

@@ -13,6 +13,7 @@ import {
readEvents,
SSE_RESPONSE_HEADERS,
} from '@/lib/copilot/request/session'
import { toStreamBatchEvent } from '@/lib/copilot/request/session/types'
export const maxDuration = 3600
@@ -113,11 +114,7 @@ export async function GET(request: NextRequest) {
if (batchMode) {
const afterSeq = afterCursor || '0'
const events = await readEvents(streamId, afterSeq)
const batchEvents = events.map((envelope) => ({
eventId: envelope.seq,
streamId: envelope.stream.streamId,
event: envelope,
}))
const batchEvents = events.map(toStreamBatchEvent)
logger.info('[Resume] Batch response', {
streamId,
afterCursor: afterSeq,

View File

@@ -12,6 +12,7 @@ import {
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
import { readEvents } from '@/lib/copilot/request/session/buffer'
import { type StreamBatchEvent, toStreamBatchEvent } from '@/lib/copilot/request/session/types'
import { taskPubSub } from '@/lib/copilot/tasks'
import { captureServerEvent } from '@/lib/posthog/server'
@@ -47,7 +48,7 @@ export async function GET(
}
let streamSnapshot: {
events: unknown[]
events: StreamBatchEvent[]
status: string
} | null = null
@@ -56,7 +57,7 @@ export async function GET(
const events = await readEvents(chat.conversationId, '0')
streamSnapshot = {
events: events || [],
events: events.map(toStreamBatchEvent),
status: events.length > 0 ? 'active' : 'unknown',
}
} catch (error) {

View File

@@ -35,6 +35,7 @@ import {
ToolSearchToolRegex,
WorkspaceFile,
} from '@/lib/copilot/generated/tool-catalog-v1'
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
import {
extractResourcesFromToolResult,
isResourceToolName,
@@ -148,12 +149,6 @@ type StreamingFilePreview = {
content: string
}
type StreamBatchEvent = {
eventId: number
streamId: string
event: Record<string, unknown>
}
type StreamBatchResponse = {
success: boolean
events: StreamBatchEvent[]

View File

@@ -142,6 +142,13 @@ const Admin = dynamic(
import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then((m) => m.Admin),
{ loading: () => <AdminSkeleton /> }
)
const Mothership = dynamic(
() =>
import('@/app/workspace/[workspaceId]/settings/components/mothership/mothership').then(
(m) => m.Mothership
),
{ loading: () => <SettingsSectionSkeleton /> }
)
const RecentlyDeleted = dynamic(
() =>
import(
@@ -175,7 +182,9 @@ export function SettingsPage({ section }: SettingsPageProps) {
? 'general'
: section === 'admin' && !sessionLoading && !isAdminRole
? 'general'
: section
: section === 'mothership' && !sessionLoading && !isAdminRole
? 'general'
: section
const label =
allNavigationItems.find((item) => item.id === effectiveSection)?.label ?? effectiveSection
@@ -207,6 +216,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'inbox' && <Inbox />}
{effectiveSection === 'recently-deleted' && <RecentlyDeleted />}
{effectiveSection === 'admin' && <Admin />}
{effectiveSection === 'mothership' && <Mothership />}
</div>
)
}

View File

@@ -0,0 +1,908 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { Badge, Button, Input as EmcnInput, Label, Skeleton } from '@/components/emcn'
import { cn } from '@/lib/core/utils/cn'
import {
type MothershipEnv,
useGenerateLicense,
useMothershipEnterpriseStats,
useMothershipLicenses,
useMothershipRequests,
useMothershipTrace,
useMothershipUserBreakdown,
} from '@/hooks/queries/mothership-admin'
type Tab = 'overview' | 'licenses' | 'enterprise' | 'traces'
const TABS: { id: Tab; label: string }[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'licenses', label: 'Licenses' },
{ id: 'enterprise', label: 'Enterprise' },
{ id: 'traces', label: 'Traces' },
]
const ENV_OPTIONS: { id: MothershipEnv; label: string }[] = [
{ id: 'dev', label: 'Dev' },
{ id: 'staging', label: 'Staging' },
{ id: 'prod', label: 'Prod' },
]
function defaultTimeRange() {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 7)
return {
start: start.toISOString().slice(0, 16),
end: end.toISOString().slice(0, 16),
}
}
function toRFC3339(local: string) {
if (!local) return ''
return new Date(local).toISOString()
}
function formatCost(cost: number) {
return `$${cost.toFixed(4)}`
}
function formatDate(d: string | null | undefined) {
if (!d) return '—'
return new Date(d).toLocaleString()
}
function Divider() {
return <div className='h-px bg-[var(--border-secondary)]' />
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return <p className='font-medium text-[var(--text-primary)] text-sm'>{children}</p>
}
export function Mothership() {
const [activeTab, setActiveTab] = useState<Tab>('overview')
const [environment, setEnvironment] = useState<MothershipEnv>('dev')
const defaults = useMemo(() => defaultTimeRange(), [])
const [start, setStart] = useState(defaults.start)
const [end, setEnd] = useState(defaults.end)
return (
<div className='flex h-full flex-col gap-5'>
{/* Environment selector */}
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-sm'>Environment</Label>
<div className='flex gap-1'>
{ENV_OPTIONS.map((opt) => (
<button
key={opt.id}
type='button'
onClick={() => setEnvironment(opt.id)}
className={cn(
'rounded-md px-3 py-1 font-medium text-sm transition-colors',
environment === opt.id
? 'bg-[var(--surface-hover)] text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
)}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Tab bar */}
<div className='flex gap-1 border-[var(--border-secondary)] border-b pb-px'>
{TABS.map((tab) => (
<button
key={tab.id}
type='button'
onClick={() => setActiveTab(tab.id)}
className={cn(
'relative px-3 py-2 font-medium text-sm transition-colors',
activeTab === tab.id
? 'text-[var(--text-primary)]'
: 'text-[var(--text-tertiary)] hover-hover:hover:text-[var(--text-secondary)]'
)}
>
{tab.label}
{activeTab === tab.id && (
<span className='absolute right-0 bottom-0 left-0 h-[2px] bg-[var(--text-primary)]' />
)}
</button>
))}
</div>
{/* Time range (shared across tabs) */}
<div className='flex items-center gap-3'>
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-caption'>From</Label>
<EmcnInput
type='datetime-local'
value={start}
onChange={(e) => setStart(e.target.value)}
className='h-[30px] text-caption'
/>
</div>
<div className='flex items-center gap-2'>
<Label className='text-[var(--text-secondary)] text-caption'>To</Label>
<EmcnInput
type='datetime-local'
value={end}
onChange={(e) => setEnd(e.target.value)}
className='h-[30px] text-caption'
/>
</div>
</div>
<Divider />
{activeTab === 'overview' && (
<OverviewTab environment={environment} start={toRFC3339(start)} end={toRFC3339(end)} />
)}
{activeTab === 'licenses' && <LicensesTab environment={environment} />}
{activeTab === 'enterprise' && (
<EnterpriseTab environment={environment} start={toRFC3339(start)} end={toRFC3339(end)} />
)}
{activeTab === 'traces' && <TracesTab environment={environment} />}
</div>
)
}
/* ─── Overview Tab ─── */
function OverviewTab({
environment,
start,
end,
}: {
environment: MothershipEnv
start: string
end: string
}) {
const { data: breakdown, isLoading: breakdownLoading } = useMothershipUserBreakdown(
environment,
start,
end
)
const { data: requests, isLoading: requestsLoading } = useMothershipRequests(
environment,
start,
end
)
return (
<div className='flex flex-col gap-5'>
{/* Summary cards */}
<div className='grid grid-cols-4 gap-3'>
<StatCard
label='Total Requests'
value={breakdown?.total_requests}
loading={breakdownLoading}
/>
<StatCard label='Unique Users' value={breakdown?.total_users} loading={breakdownLoading} />
<StatCard
label='Total Cost'
value={
breakdown?.users
? formatCost(
breakdown.users.reduce(
(s: number, u: { total_cost: number }) => s + u.total_cost,
0
)
)
: undefined
}
loading={breakdownLoading}
/>
<StatCard
label='Avg Cost/Request'
value={
breakdown?.total_requests && breakdown.users
? formatCost(
breakdown.users.reduce(
(s: number, u: { total_cost: number }) => s + u.total_cost,
0
) / breakdown.total_requests
)
: undefined
}
loading={breakdownLoading}
/>
</div>
{/* User breakdown */}
<SectionLabel>User Breakdown</SectionLabel>
{breakdownLoading && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-[36px] w-full rounded-md' />
))}
</div>
)}
{breakdown?.users && (
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='flex-1'>User ID</span>
<span className='w-[100px] text-right'>Requests</span>
<span className='w-[100px] text-right'>Cost</span>
<span className='w-[160px] text-right'>Last Request</span>
</div>
{breakdown.users.map(
(u: {
user_id: string
request_count: number
total_cost: number
last_request: string
}) => (
<div
key={u.user_id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
>
<span className='flex-1 truncate font-mono text-[12px] text-[var(--text-primary)]'>
{u.user_id}
</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{u.request_count}
</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{formatCost(u.total_cost)}
</span>
<span className='w-[160px] text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(u.last_request)}
</span>
</div>
)
)}
</div>
)}
{/* Recent requests */}
<Divider />
<SectionLabel>Recent Requests ({requests?.count ?? '…'})</SectionLabel>
{requestsLoading && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className='h-[36px] w-full rounded-md' />
))}
</div>
)}
{requests?.requests && (
<div className='max-h-[400px] overflow-auto'>
<div className='flex flex-col gap-0.5'>
<div className='sticky top-0 z-10 flex items-center gap-3 border-[var(--border-secondary)] border-b bg-[var(--surface-1)] px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='w-[180px]'>Request ID</span>
<span className='w-[80px]'>Model</span>
<span className='w-[80px] text-right'>Duration</span>
<span className='w-[80px] text-right'>Cost</span>
<span className='w-[60px] text-right'>Tools</span>
<span className='w-[70px] text-right'>Status</span>
<span className='flex-1 text-right'>Time</span>
</div>
{requests.requests
.slice(0, 100)
.map(
(r: {
request_id: string
model: string
duration_ms: number
billed_total_cost: number
tool_call_count: number
error: boolean
aborted: boolean
created_at: string
}) => (
<div
key={r.request_id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-1.5 text-small last:border-b-0'
>
<span className='w-[180px] truncate font-mono text-[11px] text-[var(--text-primary)]'>
{r.request_id ?? '—'}
</span>
<span className='w-[80px] truncate text-[var(--text-secondary)] text-caption'>
{(r.model ?? '').replace('claude-', '')}
</span>
<span className='w-[80px] text-right text-[var(--text-secondary)] text-caption'>
{r.duration_ms ? `${(r.duration_ms / 1000).toFixed(1)}s` : '—'}
</span>
<span className='w-[80px] text-right text-[var(--text-secondary)] text-caption'>
{formatCost(r.billed_total_cost ?? 0)}
</span>
<span className='w-[60px] text-right text-[var(--text-secondary)] text-caption'>
{r.tool_call_count ?? 0}
</span>
<span className='w-[70px] text-right'>
{r.error ? (
<Badge variant='red'>Error</Badge>
) : r.aborted ? (
<Badge variant='yellow'>Abort</Badge>
) : (
<Badge variant='green'>OK</Badge>
)}
</span>
<span className='flex-1 text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(r.created_at)}
</span>
</div>
)
)}
</div>
</div>
)}
</div>
)
}
/* ─── Licenses Tab ─── */
function LicensesTab({ environment }: { environment: MothershipEnv }) {
const { data, isLoading, refetch } = useMothershipLicenses(environment)
const generateLicense = useGenerateLicense(environment)
const [newName, setNewName] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [generatedKey, setGeneratedKey] = useState<string | null>(null)
const handleGenerate = useCallback(() => {
if (!newName.trim()) return
generateLicense.mutate(
{
name: newName.trim(),
...(newExpiry ? { expirationDate: newExpiry } : {}),
},
{
onSuccess: (result) => {
setGeneratedKey(result.license_key)
setNewName('')
setNewExpiry('')
refetch()
},
}
)
}, [newName, newExpiry, generateLicense, refetch])
return (
<div className='flex flex-col gap-5'>
<SectionLabel>Generate License</SectionLabel>
<div className='flex items-end gap-2'>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-secondary)] text-caption'>Enterprise Name</Label>
<EmcnInput
value={newName}
onChange={(e) => {
setNewName(e.target.value)
setGeneratedKey(null)
}}
placeholder='e.g. Acme Corp'
className='h-[32px] w-[200px]'
/>
</div>
<div className='flex flex-col gap-1'>
<Label className='text-[var(--text-secondary)] text-caption'>Expiration (optional)</Label>
<EmcnInput
type='date'
value={newExpiry}
onChange={(e) => setNewExpiry(e.target.value)}
className='h-[32px] w-[160px]'
/>
</div>
<Button
variant='primary'
className='h-[32px]'
onClick={handleGenerate}
disabled={generateLicense.isPending || !newName.trim()}
>
{generateLicense.isPending ? 'Generating...' : 'Generate'}
</Button>
</div>
{generatedKey && (
<div className='rounded-md border border-[var(--border-secondary)] bg-[var(--surface-hover)] p-3'>
<p className='mb-1 text-[var(--text-secondary)] text-caption'>
License key (only shown once):
</p>
<code className='block break-all font-mono text-[12px] text-[var(--text-primary)]'>
{generatedKey}
</code>
</div>
)}
{generateLicense.error && (
<p className='text-[var(--text-error)] text-small'>{generateLicense.error.message}</p>
)}
<Divider />
<SectionLabel>All Licenses</SectionLabel>
{isLoading && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className='h-[40px] w-full rounded-md' />
))}
</div>
)}
{data?.licenses && (
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='flex-1'>Name</span>
<span className='w-[100px] text-right'>Validations</span>
<span className='w-[140px] text-right'>Expiration</span>
<span className='w-[140px] text-right'>Created</span>
</div>
{data.licenses.length === 0 && (
<div className='py-4 text-center text-[var(--text-tertiary)] text-small'>
No licenses found.
</div>
)}
{data.licenses.map(
(lic: {
id: string
name: string
count: number
expiration_date?: string
created_at: string
}) => (
<div
key={lic.id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
>
<span className='flex-1 text-[var(--text-primary)]'>{lic.name}</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{lic.count}
</span>
<span className='w-[140px] text-right text-[var(--text-tertiary)] text-caption'>
{lic.expiration_date ? formatDate(lic.expiration_date) : 'Never'}
</span>
<span className='w-[140px] text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(lic.created_at)}
</span>
</div>
)
)}
</div>
)}
</div>
)
}
/* ─── Enterprise Tab ─── */
function EnterpriseTab({
environment,
start,
end,
}: {
environment: MothershipEnv
start: string
end: string
}) {
const [customerType, setCustomerType] = useState('')
const [searchInput, setSearchInput] = useState('')
const { data, isLoading, error } = useMothershipEnterpriseStats(
environment,
customerType,
start,
end
)
const handleSearch = () => {
setCustomerType(searchInput.trim())
}
return (
<div className='flex flex-col gap-5'>
<div className='flex items-center gap-2'>
<EmcnInput
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder='Enter customer type (e.g. enterprise name)...'
/>
<Button variant='primary' onClick={handleSearch} disabled={!searchInput.trim()}>
Search
</Button>
</div>
{error && <p className='text-[var(--text-error)] text-small'>{error.message}</p>}
{isLoading && customerType && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-[60px] w-full rounded-md' />
))}
</div>
)}
{data && (
<>
<div className='grid grid-cols-4 gap-3'>
<StatCard label='Total Requests' value={data.total_requests} />
<StatCard label='Unique Users' value={data.unique_users} />
<StatCard label='Total Cost' value={formatCost(data.total_cost ?? 0)} />
<StatCard
label='Total Tokens'
value={(
(data.total_input_tokens ?? 0) + (data.total_output_tokens ?? 0)
).toLocaleString()}
/>
</div>
{data.top_models && (
<>
<Divider />
<SectionLabel>Top Models</SectionLabel>
<div className='flex flex-wrap gap-2'>
{data.top_models.map((m: { model: string; count: number }) => (
<Badge key={m.model} variant='gray'>
{m.model} ({m.count})
</Badge>
))}
</div>
</>
)}
{data.users && (
<>
<Divider />
<SectionLabel>User Breakdown</SectionLabel>
<div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-[var(--text-tertiary)] text-caption'>
<span className='flex-1'>User ID</span>
<span className='w-[100px] text-right'>Requests</span>
<span className='w-[100px] text-right'>Cost</span>
<span className='w-[160px] text-right'>Last Request</span>
</div>
{data.users.map(
(u: {
user_id: string
request_count: number
total_cost: number
last_request: string
}) => (
<div
key={u.user_id}
className='flex items-center gap-3 border-[var(--border-secondary)] border-b px-3 py-2 text-small last:border-b-0'
>
<span className='flex-1 truncate font-mono text-[12px] text-[var(--text-primary)]'>
{u.user_id}
</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{u.request_count}
</span>
<span className='w-[100px] text-right text-[var(--text-secondary)]'>
{formatCost(u.total_cost)}
</span>
<span className='w-[160px] text-right text-[var(--text-tertiary)] text-caption'>
{formatDate(u.last_request)}
</span>
</div>
)
)}
</div>
</>
)}
</>
)}
</div>
)
}
/* ─── Traces Tab ─── */
function TracesTab({ environment }: { environment: MothershipEnv }) {
const [requestIdInput, setRequestIdInput] = useState('')
const [activeRequestId, setActiveRequestId] = useState('')
const { data: trace, isLoading, error } = useMothershipTrace(environment, activeRequestId)
const handleLookup = () => {
setActiveRequestId(requestIdInput.trim())
}
return (
<div className='flex flex-col gap-5'>
<div className='flex items-center gap-2'>
<EmcnInput
value={requestIdInput}
onChange={(e) => setRequestIdInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleLookup()}
placeholder='Paste a request ID (go_trace_id)...'
className='font-mono text-[13px]'
/>
<Button variant='primary' onClick={handleLookup} disabled={!requestIdInput.trim()}>
Lookup
</Button>
</div>
{error && <p className='text-[var(--text-error)] text-small'>{error.message}</p>}
{isLoading && activeRequestId && (
<div className='flex flex-col gap-2'>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className='h-[50px] w-full rounded-md' />
))}
</div>
)}
{trace && <TraceDetail trace={trace} />}
</div>
)
}
/* ─── Trace Detail ─── */
interface TraceSpan {
name: string
kind?: string
startMs: number
endMs?: number
durationMs?: number
status: string
parentName?: string
source?: string
attributes?: Record<string, unknown>
}
interface TraceData {
id: string
simRequestId: string
goTraceId: string
streamId?: string
chatId?: string
userId?: string
startMs: number
endMs: number
durationMs: number
outcome: string
spans: TraceSpan[]
model?: string
provider?: string
mode?: string
source?: string
message?: string
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
rawTotalCost?: number
billedTotalCost?: number
toolCallCount?: number
error?: boolean
aborted?: boolean
errorMsg?: string
}
function TraceDetail({ trace }: { trace: TraceData }) {
const rootSpans = trace.spans.filter((s) => !s.parentName)
const childMap = new Map<string, TraceSpan[]>()
for (const span of trace.spans) {
if (span.parentName) {
const existing = childMap.get(span.parentName) || []
existing.push(span)
childMap.set(span.parentName, existing)
}
}
return (
<div className='flex flex-col gap-4'>
{/* Trace metadata */}
<div className='grid grid-cols-2 gap-x-6 gap-y-2 rounded-md border border-[var(--border-secondary)] p-4'>
<MetaRow label='Go Trace ID' value={trace.goTraceId} mono />
<MetaRow label='Sim Request ID' value={trace.simRequestId} mono />
<MetaRow label='Outcome'>
<Badge
variant={
trace.outcome === 'success'
? 'green'
: trace.outcome === 'cancelled'
? 'yellow'
: 'red'
}
>
{trace.outcome}
</Badge>
</MetaRow>
<MetaRow label='Duration' value={`${(trace.durationMs / 1000).toFixed(2)}s`} />
<MetaRow label='Model' value={trace.model || '—'} />
<MetaRow label='Provider' value={trace.provider || '—'} />
<MetaRow label='Source' value={trace.source || '—'} />
<MetaRow label='Mode' value={trace.mode || '—'} />
{trace.userId && <MetaRow label='User ID' value={trace.userId} mono />}
{trace.chatId && <MetaRow label='Chat ID' value={trace.chatId} mono />}
<MetaRow
label='Tokens'
value={`${(trace.inputTokens ?? 0).toLocaleString()} in / ${(trace.outputTokens ?? 0).toLocaleString()} out`}
/>
<MetaRow label='Billed Cost' value={formatCost(trace.billedTotalCost ?? 0)} />
{trace.toolCallCount != null && trace.toolCallCount > 0 && (
<MetaRow label='Tool Calls' value={String(trace.toolCallCount)} />
)}
{trace.message && (
<div className='col-span-2'>
<MetaRow label='Message' value={trace.message} />
</div>
)}
{trace.errorMsg && (
<div className='col-span-2'>
<MetaRow label='Error'>
<span className='text-[var(--text-error)]'>{trace.errorMsg}</span>
</MetaRow>
</div>
)}
</div>
{/* Span tree */}
<SectionLabel>Spans ({trace.spans.length})</SectionLabel>
<div className='flex flex-col gap-1'>
{rootSpans
.sort((a, b) => a.startMs - b.startMs)
.map((span) => (
<SpanNode
key={span.name + span.startMs}
span={span}
childMap={childMap}
traceStartMs={trace.startMs}
traceDurationMs={trace.durationMs}
depth={0}
/>
))}
</div>
</div>
)
}
function SpanNode({
span,
childMap,
traceStartMs,
traceDurationMs,
depth,
}: {
span: TraceSpan
childMap: Map<string, TraceSpan[]>
traceStartMs: number
traceDurationMs: number
depth: number
}) {
const [expanded, setExpanded] = useState(depth < 2)
const children = childMap.get(span.name) || []
const hasChildren = children.length > 0
const durationMs = span.durationMs ?? (span.endMs ? span.endMs - span.startMs : 0)
const offsetPct =
traceDurationMs > 0 ? ((span.startMs - traceStartMs) / traceDurationMs) * 100 : 0
const widthPct = traceDurationMs > 0 ? (durationMs / traceDurationMs) * 100 : 0
const statusColor =
span.status === 'ok'
? 'bg-emerald-500/70'
: span.status === 'error'
? 'bg-red-500/70'
: span.status === 'cancelled'
? 'bg-yellow-500/70'
: 'bg-[var(--text-tertiary)]'
const attrs = span.attributes || {}
const attrEntries = Object.entries(attrs).filter(
([, v]) => v !== null && v !== undefined && v !== ''
)
return (
<div style={{ marginLeft: depth * 16 }}>
<button
type='button'
onClick={() => setExpanded((e) => !e)}
className='flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover-hover:hover:bg-[var(--surface-hover)]'
>
{hasChildren ? (
<span className='w-[14px] text-center text-[10px] text-[var(--text-tertiary)]'>
{expanded ? '▼' : '▶'}
</span>
) : (
<span className='w-[14px]' />
)}
<span className='min-w-0 flex-1'>
<span className='block truncate text-[13px] text-[var(--text-primary)]'>{span.name}</span>
{/* Waterfall bar */}
<span className='mt-0.5 block h-[4px] w-full rounded-full bg-[var(--border-secondary)]'>
<span
className={cn('block h-full rounded-full', statusColor)}
style={{
marginLeft: `${Math.max(0, Math.min(offsetPct, 100))}%`,
width: `${Math.max(0.5, Math.min(widthPct, 100 - offsetPct))}%`,
}}
/>
</span>
</span>
<Badge variant={span.source === 'go' ? 'blue' : 'gray'} className='shrink-0'>
{span.source || '?'}
</Badge>
<span className='w-[70px] shrink-0 text-right font-mono text-[11px] text-[var(--text-secondary)]'>
{durationMs >= 1000 ? `${(durationMs / 1000).toFixed(2)}s` : `${durationMs}ms`}
</span>
</button>
{expanded && attrEntries.length > 0 && (
<div
className='mb-1 ml-[30px] rounded border border-[var(--border-secondary)] bg-[var(--surface-hover)] px-3 py-2'
style={{ marginLeft: 30 + depth * 16 }}
>
{attrEntries.map(([key, val]) => (
<div key={key} className='flex gap-2 py-0.5 text-[11px]'>
<span className='shrink-0 text-[var(--text-tertiary)]'>{key}:</span>
<span className='min-w-0 break-all text-[var(--text-secondary)]'>
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
</span>
</div>
))}
</div>
)}
{expanded &&
children
.sort((a, b) => a.startMs - b.startMs)
.map((child) => (
<SpanNode
key={child.name + child.startMs}
span={child}
childMap={childMap}
traceStartMs={traceStartMs}
traceDurationMs={traceDurationMs}
depth={depth + 1}
/>
))}
</div>
)
}
/* ─── Shared components ─── */
function StatCard({
label,
value,
loading,
}: {
label: string
value?: string | number
loading?: boolean
}) {
return (
<div className='rounded-md border border-[var(--border-secondary)] p-3'>
<p className='text-[var(--text-tertiary)] text-caption'>{label}</p>
{loading ? (
<Skeleton className='mt-1 h-[24px] w-[80px] rounded-sm' />
) : (
<p className='mt-1 font-medium text-[18px] text-[var(--text-primary)]'>{value ?? '—'}</p>
)}
</div>
)
}
function MetaRow({
label,
value,
mono,
children,
}: {
label: string
value?: string
mono?: boolean
children?: React.ReactNode
}) {
return (
<div className='flex items-baseline gap-2'>
<span className='shrink-0 text-[var(--text-tertiary)] text-caption'>{label}</span>
{children || (
<span
className={cn(
'min-w-0 break-all text-[13px] text-[var(--text-primary)]',
mono && 'font-mono text-[12px]'
)}
>
{value}
</span>
)}
</div>
)
}

View File

@@ -38,6 +38,7 @@ export type SettingsSection =
| 'workflow-mcp-servers'
| 'inbox'
| 'admin'
| 'mothership'
| 'recently-deleted'
export type NavigationSection =
@@ -169,4 +170,11 @@ export const allNavigationItems: NavigationItem[] = [
section: 'superuser',
requiresAdminRole: true,
},
{
id: 'mothership',
label: 'Mothership',
icon: Server,
section: 'superuser',
requiresAdminRole: true,
},
]

View File

@@ -0,0 +1,131 @@
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query'
export type MothershipEnv = 'dev' | 'staging' | 'prod'
const BASE = '/api/admin/mothership'
async function mothershipPost(
endpoint: string,
environment: MothershipEnv,
body?: Record<string, unknown>
) {
const res = await fetch(`${BASE}?env=${environment}&endpoint=${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
...(body ? { body: JSON.stringify(body) } : {}),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.message || err.error || `Request failed (${res.status})`)
}
return res.json()
}
async function mothershipGet(
endpoint: string,
environment: MothershipEnv,
params?: Record<string, string>
) {
const qs = new URLSearchParams({ env: environment, endpoint, ...params })
const res = await fetch(`${BASE}?${qs.toString()}`, { method: 'GET' })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.message || err.error || `Request failed (${res.status})`)
}
return res.json()
}
export const mothershipKeys = {
all: ['mothership-admin'] as const,
requests: (env: MothershipEnv, start: string, end: string, userId?: string) =>
[...mothershipKeys.all, 'requests', env, start, end, userId] as const,
userBreakdown: (env: MothershipEnv, start: string, end: string) =>
[...mothershipKeys.all, 'user-breakdown', env, start, end] as const,
licenses: (env: MothershipEnv) => [...mothershipKeys.all, 'licenses', env] as const,
licenseDetails: (env: MothershipEnv, id?: string, name?: string) =>
[...mothershipKeys.all, 'license-details', env, id, name] as const,
enterpriseStats: (env: MothershipEnv, customerType: string, start: string, end: string) =>
[...mothershipKeys.all, 'enterprise-stats', env, customerType, start, end] as const,
trace: (env: MothershipEnv, requestId: string) =>
[...mothershipKeys.all, 'trace', env, requestId] as const,
}
export function useMothershipRequests(
environment: MothershipEnv,
start: string,
end: string,
userId?: string
) {
return useQuery({
queryKey: mothershipKeys.requests(environment, start, end, userId),
queryFn: () =>
mothershipPost('requests', environment, {
start,
end,
...(userId ? { userId } : {}),
}),
enabled: !!start && !!end,
placeholderData: keepPreviousData,
})
}
export function useMothershipUserBreakdown(environment: MothershipEnv, start: string, end: string) {
return useQuery({
queryKey: mothershipKeys.userBreakdown(environment, start, end),
queryFn: () => mothershipPost('user-breakdown', environment, { start, end }),
enabled: !!start && !!end,
placeholderData: keepPreviousData,
})
}
export function useMothershipLicenses(environment: MothershipEnv) {
return useQuery({
queryKey: mothershipKeys.licenses(environment),
queryFn: () => mothershipGet('licenses', environment),
})
}
export function useMothershipLicenseDetails(
environment: MothershipEnv,
id?: string,
name?: string
) {
return useQuery({
queryKey: mothershipKeys.licenseDetails(environment, id, name),
queryFn: () =>
mothershipPost('licenses/details', environment, {
...(id ? { id } : {}),
...(name ? { name } : {}),
}),
enabled: !!(id || name),
})
}
export function useGenerateLicense(environment: MothershipEnv) {
return useMutation({
mutationFn: (params: { name: string; expirationDate?: string }) =>
mothershipPost('licenses/generate', environment, params),
})
}
export function useMothershipEnterpriseStats(
environment: MothershipEnv,
customerType: string,
start: string,
end: string
) {
return useQuery({
queryKey: mothershipKeys.enterpriseStats(environment, customerType, start, end),
queryFn: () => mothershipPost('enterprise-stats', environment, { customerType, start, end }),
enabled: !!customerType && !!start && !!end,
placeholderData: keepPreviousData,
})
}
export function useMothershipTrace(environment: MothershipEnv, requestId: string) {
return useQuery({
queryKey: mothershipKeys.trace(environment, requestId),
queryFn: () => mothershipGet('traces', environment, { requestId }),
enabled: !!requestId,
})
}

View File

@@ -1,6 +1,7 @@
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { PersistedMessage } from '@/lib/copilot/chat/persisted-message'
import { normalizeMessage } from '@/lib/copilot/chat/persisted-message'
import type { StreamBatchEvent } from '@/lib/copilot/request/session/types'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
export interface TaskMetadata {
@@ -17,7 +18,7 @@ export interface TaskChatHistory {
messages: PersistedMessage[]
activeStreamId: string | null
resources: MothershipResource[]
streamSnapshot?: { events: unknown[]; status: string } | null
streamSnapshot?: { events: StreamBatchEvent[]; status: string } | null
}
export const taskKeys = {

View File

@@ -0,0 +1,102 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { selectLimit, selectWhere, selectFrom, select, updateWhere, updateSet, update } = vi.hoisted(
() => {
const selectLimit = vi.fn()
const selectWhere = vi.fn(() => ({ limit: selectLimit }))
const selectFrom = vi.fn(() => ({ where: selectWhere }))
const select = vi.fn(() => ({ from: selectFrom }))
const updateWhere = vi.fn()
const updateSet = vi.fn(() => ({ where: updateWhere }))
const update = vi.fn(() => ({ set: updateSet }))
return {
selectLimit,
selectWhere,
selectFrom,
select,
updateWhere,
updateSet,
update,
}
}
)
vi.mock('@sim/db', () => ({
db: {
select,
update,
},
}))
import { finalizeAssistantTurn } from './terminal-state'
describe('finalizeAssistantTurn', () => {
beforeEach(() => {
vi.clearAllMocks()
updateWhere.mockResolvedValue(undefined)
})
it('appends the assistant message when the user turn is still last', async () => {
selectLimit.mockResolvedValue([
{
messages: [{ id: 'user-1', role: 'user', content: 'hello' }],
},
])
await finalizeAssistantTurn({
chatId: 'chat-1',
userMessageId: 'user-1',
assistantMessage: {
id: 'assistant-1',
role: 'assistant',
content: 'hi',
timestamp: '2024-01-01T00:00:00.000Z',
},
})
expect(updateSet).toHaveBeenCalledWith(
expect.objectContaining({
updatedAt: expect.any(Date),
conversationId: expect.anything(),
messages: expect.anything(),
})
)
})
it('only clears the active stream marker when a response is already persisted', async () => {
selectLimit.mockResolvedValue([
{
messages: [
{ id: 'user-1', role: 'user', content: 'hello' },
{ id: 'assistant-1', role: 'assistant', content: 'partial' },
],
},
])
await finalizeAssistantTurn({
chatId: 'chat-1',
userMessageId: 'user-1',
assistantMessage: {
id: 'assistant-2',
role: 'assistant',
content: 'final',
timestamp: '2024-01-01T00:00:00.000Z',
},
})
const updateArg = updateSet.mock.calls[0]?.[0] as Record<string, unknown>
expect(updateArg).toEqual(
expect.objectContaining({
updatedAt: expect.any(Date),
conversationId: expect.anything(),
})
)
expect(Object.hasOwn(updateArg, 'messages')).toBe(false)
})
})

View File

@@ -10,6 +10,7 @@ const {
createRunSegment,
updateRunStatus,
resetBuffer,
scheduleBufferCleanup,
allocateCursor,
appendEvent,
cleanupAbortMarker,
@@ -20,6 +21,7 @@ const {
createRunSegment: vi.fn(),
updateRunStatus: vi.fn(),
resetBuffer: vi.fn(),
scheduleBufferCleanup: vi.fn(),
allocateCursor: vi.fn(),
appendEvent: vi.fn(),
cleanupAbortMarker: vi.fn(),
@@ -27,7 +29,7 @@ const {
releasePendingChatStream: vi.fn(),
}))
vi.mock('@/lib/copilot/request/lifecycle/continue', () => ({
vi.mock('@/lib/copilot/request/lifecycle/run', () => ({
runCopilotLifecycle,
}))
@@ -40,6 +42,7 @@ let mockPublisherController: ReadableStreamDefaultController | null = null
vi.mock('@/lib/copilot/request/session', () => ({
resetBuffer,
scheduleBufferCleanup,
allocateCursor,
appendEvent,
cleanupAbortMarker,
@@ -107,6 +110,7 @@ describe('createSSEStream terminal error handling', () => {
beforeEach(() => {
vi.clearAllMocks()
resetBuffer.mockResolvedValue(undefined)
scheduleBufferCleanup.mockResolvedValue(undefined)
allocateCursor
.mockResolvedValueOnce({ seq: 1, cursor: '1' })
.mockResolvedValueOnce({ seq: 2, cursor: '2' })
@@ -149,6 +153,7 @@ describe('createSSEStream terminal error handling', () => {
type: MothershipStreamV1EventType.error,
})
)
expect(scheduleBufferCleanup).toHaveBeenCalledWith('stream-1')
})
it('writes the thrown terminal error event before close for replay durability', async () => {
@@ -175,5 +180,6 @@ describe('createSSEStream terminal error handling', () => {
type: MothershipStreamV1EventType.error,
})
)
expect(scheduleBufferCleanup).toHaveBeenCalledWith('stream-1')
})
})

View File

@@ -18,6 +18,7 @@ import {
releasePendingChatStream,
resetBuffer,
StreamWriter,
scheduleBufferCleanup,
startAbortPoller,
unregisterActiveStream,
} from '@/lib/copilot/request/session'
@@ -205,6 +206,7 @@ export function createSSEStream(params: StreamingOrchestrationParams): ReadableS
if (chatId) {
await releasePendingChatStream(chatId, streamId)
}
await scheduleBufferCleanup(streamId)
await cleanupAbortMarker(streamId)
const trace = collector.build({

View File

@@ -106,7 +106,13 @@ vi.mock('@/lib/core/config/redis', () => ({
getRedisClient: () => mockRedis,
}))
import { allocateCursor, appendEvent, readEvents } from '@/lib/copilot/request/session/buffer'
import {
allocateCursor,
appendEvent,
clearBuffer,
readEvents,
scheduleBufferCleanup,
} from '@/lib/copilot/request/session/buffer'
describe('mothership-stream-outbox', () => {
beforeEach(() => {
@@ -114,7 +120,7 @@ describe('mothership-stream-outbox', () => {
vi.clearAllMocks()
})
it.concurrent('replays envelopes after a given cursor', async () => {
it('replays envelopes after a given cursor', async () => {
const firstCursor = await allocateCursor('stream-1')
const secondCursor = await allocateCursor('stream-1')
@@ -145,4 +151,66 @@ describe('mothership-stream-outbox', () => {
const replayed = await readEvents('stream-1', '1')
expect(replayed.map((entry) => entry.payload.text)).toEqual(['world'])
})
it('does not trim active stream history while appending events', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
expect(mockRedis.zremrangebyrank).not.toHaveBeenCalled()
})
it('clears persisted stream state during teardown cleanup', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
expect((await readEvents('stream-1', '0')).length).toBe(1)
await clearBuffer('stream-1')
expect(await readEvents('stream-1', '0')).toEqual([])
})
it('shortens completed stream retention without deleting replay data immediately', async () => {
const cursor = await allocateCursor('stream-1')
await appendEvent(
createEvent({
streamId: 'stream-1',
cursor: cursor.cursor,
seq: cursor.seq,
requestId: 'req-1',
type: MothershipStreamV1EventType.text,
payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' },
})
)
await scheduleBufferCleanup('stream-1', 30)
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:events', 30)
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:seq', 30)
expect(mockRedis.expire).toHaveBeenCalledWith('mothership_stream:stream-1:abort', 30)
expect((await readEvents('stream-1', '0')).map((entry) => entry.payload.text)).toEqual([
'hello',
])
})
})

View File

@@ -7,6 +7,7 @@ const logger = createLogger('SessionBuffer')
const STREAM_OUTBOX_PREFIX = 'mothership_stream:'
const DEFAULT_TTL_SECONDS = 60 * 60
const DEFAULT_COMPLETED_TTL_SECONDS = 5 * 60
const DEFAULT_EVENT_LIMIT = 5_000
const RETRY_DELAYS_MS = [0, 50, 150] as const
@@ -97,11 +98,36 @@ export async function allocateCursor(streamId: string): Promise<{
}
export async function resetBuffer(streamId: string): Promise<void> {
await withRedisRetry({ operation: 'reset_outbox', streamId }, async (redis) => {
await clearBuffer(streamId, 'reset_outbox')
}
export async function clearBuffer(streamId: string, operation = 'clear_outbox'): Promise<void> {
await withRedisRetry({ operation, streamId }, async (redis) => {
await redis.del(getEventsKey(streamId), getSeqKey(streamId), getAbortKey(streamId))
})
}
export async function scheduleBufferCleanup(
streamId: string,
ttlSeconds = DEFAULT_COMPLETED_TTL_SECONDS
): Promise<void> {
try {
await withRedisRetry({ operation: 'schedule_outbox_cleanup', streamId }, async (redis) => {
const pipeline = redis.pipeline()
pipeline.expire(getEventsKey(streamId), ttlSeconds)
pipeline.expire(getSeqKey(streamId), ttlSeconds)
pipeline.expire(getAbortKey(streamId), ttlSeconds)
await pipeline.exec()
})
} catch (error) {
logger.warn('Failed to shorten stream buffer TTL during cleanup', {
streamId,
ttlSeconds,
error: error instanceof Error ? error.message : String(error),
})
}
}
export async function appendEvents(
envelopes: MothershipStreamV1EventEnvelope[]
): Promise<MothershipStreamV1EventEnvelope[]> {
@@ -123,9 +149,6 @@ export async function appendEvents(
pipeline.zadd(key, ...(zaddArgs as [number, string, ...Array<number | string>]))
pipeline.expire(key, config.ttlSeconds)
pipeline.set(seqKey, String(envelopes[envelopes.length - 1].seq), 'EX', config.ttlSeconds)
if (config.eventLimit > 0) {
pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1)
}
await pipeline.exec()
})

View File

@@ -14,12 +14,14 @@ export {
appendEvent,
appendEvents,
clearAbortMarker,
clearBuffer,
getLatestSeq,
getOldestSeq,
hasAbortMarker,
InvalidCursorError,
readEvents,
resetBuffer,
scheduleBufferCleanup,
writeAbortMarker,
} from './buffer'
export { createEvent, eventToStreamEvent, isEventRecord, TOOL_CALL_STATUS } from './event'

View File

@@ -1,4 +1,5 @@
import type {
MothershipStreamV1EventEnvelope,
MothershipStreamV1EventType,
MothershipStreamV1StreamScope,
} from '@/lib/copilot/generated/mothership-stream-v1'
@@ -8,3 +9,17 @@ export interface StreamEvent {
payload: Record<string, unknown>
scope?: MothershipStreamV1StreamScope
}
export interface StreamBatchEvent {
eventId: number
streamId: string
event: MothershipStreamV1EventEnvelope
}
export function toStreamBatchEvent(envelope: MothershipStreamV1EventEnvelope): StreamBatchEvent {
return {
eventId: envelope.seq,
streamId: envelope.stream.streamId,
event: envelope,
}
}

View File

@@ -144,6 +144,12 @@ export const env = createEnv({
// Admin API
ADMIN_API_KEY: z.string().min(32).optional(), // Admin API key for self-hosted GitOps access (generate with: openssl rand -hex 32)
// Mothership Admin
MOTHERSHIP_API_ADMIN_KEY: z.string().min(1).optional(), // Admin API key for mothership/copilot admin endpoints
MOTHERSHIP_DEV_URL: z.string().url().optional(), // Mothership dev environment URL
MOTHERSHIP_STAGING_URL: z.string().url().optional(), // Mothership staging environment URL
MOTHERSHIP_PROD_URL: z.string().url().optional(), // Mothership production environment URL
// Infrastructure & Deployment
NEXT_RUNTIME: z.string().optional(), // Next.js runtime environment
DOCKER_BUILD: z.boolean().optional(), // Flag indicating Docker build environment