chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useMothershipQueueStore } from '@/stores/mothership-queue/store'
|
||||
import type { QueuedMothershipMessage } from '@/stores/mothership-queue/types'
|
||||
|
||||
const message = (id: string, content = `content-${id}`): QueuedMothershipMessage => ({
|
||||
id,
|
||||
content,
|
||||
})
|
||||
|
||||
describe('useMothershipQueueStore', () => {
|
||||
beforeEach(() => {
|
||||
useMothershipQueueStore.getState().reset()
|
||||
})
|
||||
|
||||
describe('enqueue / remove', () => {
|
||||
it('appends to the chat bucket', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m2'))
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']?.map((m) => m.id)).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps buckets isolated per chat', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-B', message('n1'))
|
||||
const state = useMothershipQueueStore.getState()
|
||||
expect(state.queues['chat-A']?.map((m) => m.id)).toEqual(['m1'])
|
||||
expect(state.queues['chat-B']?.map((m) => m.id)).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('removes the chat bucket entirely when the last message is removed', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().remove('chat-A', 'm1')
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears editing when the editing message is removed', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
|
||||
useMothershipQueueStore.getState().remove('chat-A', 'm1')
|
||||
expect(useMothershipQueueStore.getState().editing['chat-A']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves editing when a different message is removed', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m2'))
|
||||
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
|
||||
useMothershipQueueStore.getState().remove('chat-A', 'm2')
|
||||
expect(useMothershipQueueStore.getState().editing['chat-A']).toBe('m1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertAt', () => {
|
||||
it('inserts at the requested index', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m3'))
|
||||
useMothershipQueueStore.getState().insertAt('chat-A', 1, message('m2'))
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']?.map((m) => m.id)).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
'm3',
|
||||
])
|
||||
})
|
||||
|
||||
it('clamps an out-of-range index to the end', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().insertAt('chat-A', 99, message('m2'))
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']?.map((m) => m.id)).toEqual([
|
||||
'm1',
|
||||
'm2',
|
||||
])
|
||||
})
|
||||
|
||||
it('ignores duplicate ids', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().insertAt('chat-A', 0, message('m1'))
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']?.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('replaceAt', () => {
|
||||
it('overwrites content while preserving id and index', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1', 'orig-1'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m2', 'orig-2'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m3', 'orig-3'))
|
||||
|
||||
useMothershipQueueStore.getState().replaceAt('chat-A', 'm2', { content: 'edited-2' })
|
||||
|
||||
const queue = useMothershipQueueStore.getState().queues['chat-A']
|
||||
expect(queue?.map((m) => m.id)).toEqual(['m1', 'm2', 'm3'])
|
||||
expect(queue?.[1]?.content).toBe('edited-2')
|
||||
})
|
||||
|
||||
it('is a no-op when the id is no longer in the queue', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
const before = useMothershipQueueStore.getState().queues['chat-A']
|
||||
useMothershipQueueStore.getState().replaceAt('chat-A', 'missing', { content: 'x' })
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']).toBe(before)
|
||||
})
|
||||
|
||||
it('strips queuedSendHandoff on edit so a fresh handoff is minted at send time', () => {
|
||||
const original: QueuedMothershipMessage = {
|
||||
id: 'm1',
|
||||
content: 'orig',
|
||||
queuedSendHandoff: { id: 'm1', supersededStreamId: 'stream-x' },
|
||||
}
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', original)
|
||||
useMothershipQueueStore.getState().replaceAt('chat-A', 'm1', { content: 'edited' })
|
||||
const replaced = useMothershipQueueStore.getState().queues['chat-A']?.[0]
|
||||
expect(replaced?.queuedSendHandoff).toBeUndefined()
|
||||
expect(replaced?.content).toBe('edited')
|
||||
})
|
||||
})
|
||||
|
||||
describe('migrate', () => {
|
||||
it('moves both queue and editing from sentinel to resolved chatId', () => {
|
||||
const pendingKey = 'pending::abc'
|
||||
useMothershipQueueStore.getState().enqueue(pendingKey, message('m1'))
|
||||
useMothershipQueueStore.getState().setEditing(pendingKey, 'm1')
|
||||
useMothershipQueueStore.getState().migrate(pendingKey, 'chat-X')
|
||||
const state = useMothershipQueueStore.getState()
|
||||
expect(state.queues[pendingKey]).toBeUndefined()
|
||||
expect(state.editing[pendingKey]).toBeUndefined()
|
||||
expect(state.queues['chat-X']?.map((m) => m.id)).toEqual(['m1'])
|
||||
expect(state.editing['chat-X']).toBe('m1')
|
||||
})
|
||||
|
||||
it('is a no-op when source and target are the same', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
const before = useMothershipQueueStore.getState().queues['chat-A']
|
||||
useMothershipQueueStore.getState().migrate('chat-A', 'chat-A')
|
||||
expect(useMothershipQueueStore.getState().queues['chat-A']).toBe(before)
|
||||
})
|
||||
|
||||
it('is a no-op when the source bucket is empty', () => {
|
||||
const before = useMothershipQueueStore.getState().queues
|
||||
useMothershipQueueStore.getState().migrate('nope', 'chat-X')
|
||||
expect(useMothershipQueueStore.getState().queues).toBe(before)
|
||||
})
|
||||
|
||||
it('merges into an existing destination bucket instead of overwriting', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-X', message('existing-1'))
|
||||
useMothershipQueueStore.getState().enqueue('chat-X', message('existing-2'))
|
||||
useMothershipQueueStore.getState().enqueue('pending::abc', message('pending-1'))
|
||||
useMothershipQueueStore.getState().migrate('pending::abc', 'chat-X')
|
||||
expect(useMothershipQueueStore.getState().queues['chat-X']?.map((m) => m.id)).toEqual([
|
||||
'existing-1',
|
||||
'existing-2',
|
||||
'pending-1',
|
||||
])
|
||||
expect(useMothershipQueueStore.getState().queues['pending::abc']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearChat', () => {
|
||||
it('drops queue and editing for the chat', () => {
|
||||
useMothershipQueueStore.getState().enqueue('chat-A', message('m1'))
|
||||
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
|
||||
useMothershipQueueStore.getState().clearChat('chat-A')
|
||||
const state = useMothershipQueueStore.getState()
|
||||
expect(state.queues['chat-A']).toBeUndefined()
|
||||
expect(state.editing['chat-A']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('setEditing', () => {
|
||||
it('stores and clears the editing id', () => {
|
||||
useMothershipQueueStore.getState().setEditing('chat-A', 'm1')
|
||||
expect(useMothershipQueueStore.getState().editing['chat-A']).toBe('m1')
|
||||
useMothershipQueueStore.getState().setEditing('chat-A', null)
|
||||
expect(useMothershipQueueStore.getState().editing['chat-A']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { create } from 'zustand'
|
||||
import { createJSONStorage, devtools, persist } from 'zustand/middleware'
|
||||
import type { MothershipQueueState, QueuedMothershipMessage } from '@/stores/mothership-queue/types'
|
||||
|
||||
const logger = createLogger('MothershipQueueStore')
|
||||
|
||||
/**
|
||||
* Per-tab sessionStorage adapter — no-ops on SSR and tolerates quota errors.
|
||||
*
|
||||
* We persist to sessionStorage (not localStorage like `mothership-drafts`)
|
||||
* because the queue auto-drains on rehydrate: tab close should not fire those
|
||||
* sends days later.
|
||||
*/
|
||||
const sessionStorageAdapter = {
|
||||
getItem: (name: string): string | null => {
|
||||
if (typeof sessionStorage === 'undefined') return null
|
||||
try {
|
||||
return sessionStorage.getItem(name)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to read mothership queue from sessionStorage', toError(error))
|
||||
return null
|
||||
}
|
||||
},
|
||||
setItem: (name: string, value: string): void => {
|
||||
if (typeof sessionStorage === 'undefined') return
|
||||
try {
|
||||
sessionStorage.setItem(name, value)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to persist mothership queue to sessionStorage', toError(error))
|
||||
}
|
||||
},
|
||||
removeItem: (name: string): void => {
|
||||
if (typeof sessionStorage === 'undefined') return
|
||||
try {
|
||||
sessionStorage.removeItem(name)
|
||||
} catch (error) {
|
||||
logger.warn('Failed to remove mothership queue from sessionStorage', toError(error))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
queues: {} as Record<string, QueuedMothershipMessage[]>,
|
||||
editing: {} as Record<string, string>,
|
||||
}
|
||||
|
||||
const omitKey = <V>(record: Record<string, V>, key: string): Record<string, V> => {
|
||||
if (!(key in record)) return record
|
||||
const { [key]: _removed, ...rest } = record
|
||||
return rest
|
||||
}
|
||||
|
||||
const setQueueForChat = (
|
||||
queues: Record<string, QueuedMothershipMessage[]>,
|
||||
chatKey: string,
|
||||
next: QueuedMothershipMessage[]
|
||||
): Record<string, QueuedMothershipMessage[]> =>
|
||||
next.length === 0 ? omitKey(queues, chatKey) : { ...queues, [chatKey]: next }
|
||||
|
||||
// Drop the volatile `queuedSendHandoff` from the persisted snapshot — its
|
||||
// stream reference is meaningless after reload; the dispatcher mints a fresh
|
||||
// one at send time if needed.
|
||||
const stripVolatile = (message: QueuedMothershipMessage): QueuedMothershipMessage => {
|
||||
if (!message.queuedSendHandoff) return message
|
||||
const { queuedSendHandoff: _drop, ...rest } = message
|
||||
return rest
|
||||
}
|
||||
|
||||
export const useMothershipQueueStore = create<MothershipQueueState>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
|
||||
enqueue: (chatKey, message) =>
|
||||
set((state) => ({
|
||||
queues: setQueueForChat(state.queues, chatKey, [
|
||||
...(state.queues[chatKey] ?? []),
|
||||
message,
|
||||
]),
|
||||
})),
|
||||
|
||||
insertAt: (chatKey, index, message) =>
|
||||
set((state) => {
|
||||
const current = state.queues[chatKey] ?? []
|
||||
if (current.some((m) => m.id === message.id)) return state
|
||||
const next = [...current]
|
||||
next.splice(Math.max(0, Math.min(index, next.length)), 0, message)
|
||||
return { queues: setQueueForChat(state.queues, chatKey, next) }
|
||||
}),
|
||||
|
||||
replaceAt: (chatKey, id, patch) =>
|
||||
set((state) => {
|
||||
const current = state.queues[chatKey] ?? []
|
||||
const index = current.findIndex((m) => m.id === id)
|
||||
if (index === -1) return state
|
||||
const next = [...current]
|
||||
// Strip `queuedSendHandoff` — references the stream active at
|
||||
// original enqueue time; the dispatcher mints a fresh one at send.
|
||||
const { queuedSendHandoff: _stale, ...rest } = next[index]
|
||||
next[index] = {
|
||||
...rest,
|
||||
content: patch.content,
|
||||
fileAttachments: patch.fileAttachments,
|
||||
contexts: patch.contexts,
|
||||
}
|
||||
return { queues: setQueueForChat(state.queues, chatKey, next) }
|
||||
}),
|
||||
|
||||
remove: (chatKey, id) =>
|
||||
set((state) => {
|
||||
const current = state.queues[chatKey] ?? []
|
||||
const next = current.filter((m) => m.id !== id)
|
||||
const wasEditingThis = state.editing[chatKey] === id
|
||||
if (next.length === current.length) {
|
||||
return wasEditingThis ? { editing: omitKey(state.editing, chatKey) } : state
|
||||
}
|
||||
return {
|
||||
queues: setQueueForChat(state.queues, chatKey, next),
|
||||
...(wasEditingThis ? { editing: omitKey(state.editing, chatKey) } : {}),
|
||||
}
|
||||
}),
|
||||
|
||||
setEditing: (chatKey, id) =>
|
||||
set((state) => ({
|
||||
editing:
|
||||
id === null ? omitKey(state.editing, chatKey) : { ...state.editing, [chatKey]: id },
|
||||
})),
|
||||
|
||||
migrate: (fromKey, toKey) =>
|
||||
set((state) => {
|
||||
if (fromKey === toKey) return state
|
||||
const fromQueue = state.queues[fromKey]
|
||||
const fromEditing = state.editing[fromKey]
|
||||
if (!fromQueue && fromEditing === undefined) return state
|
||||
|
||||
const queues = omitKey(state.queues, fromKey)
|
||||
if (fromQueue && fromQueue.length > 0) {
|
||||
// Merge defensively in case a stale bucket survived in
|
||||
// sessionStorage. FIFO: existing first, then the resolved stream.
|
||||
const existing = state.queues[toKey] ?? []
|
||||
queues[toKey] = [...existing, ...fromQueue]
|
||||
}
|
||||
const editing = omitKey(state.editing, fromKey)
|
||||
if (fromEditing !== undefined) {
|
||||
editing[toKey] = fromEditing
|
||||
}
|
||||
return { queues, editing }
|
||||
}),
|
||||
|
||||
clearChat: (chatKey) =>
|
||||
set((state) => ({
|
||||
queues: omitKey(state.queues, chatKey),
|
||||
editing: omitKey(state.editing, chatKey),
|
||||
})),
|
||||
|
||||
reset: () => set(initialState),
|
||||
}),
|
||||
{
|
||||
name: 'mothership-queue',
|
||||
storage: createJSONStorage(() => sessionStorageAdapter),
|
||||
// `editing` is intentionally omitted — the composer that holds the
|
||||
// edit text is component-local and empty after reload, so a persisted
|
||||
// editing flag would render an in-edit row with nothing bound.
|
||||
partialize: (state) => ({
|
||||
queues: Object.fromEntries(
|
||||
Object.entries(state.queues).map(([key, messages]) => [
|
||||
key,
|
||||
messages.map(stripVolatile),
|
||||
])
|
||||
),
|
||||
}),
|
||||
}
|
||||
),
|
||||
{ name: 'mothership-queue-store' }
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { QueuedMessage } from '@/app/workspace/[workspaceId]/home/types'
|
||||
|
||||
// Volatile — lets the dispatcher claim an in-flight stream's slot. Not persisted.
|
||||
export interface QueuedSendHandoffSeed {
|
||||
id: string
|
||||
chatId?: string
|
||||
supersededStreamId: string | null
|
||||
userMessageId?: string
|
||||
}
|
||||
|
||||
export type QueuedMothershipMessage = QueuedMessage & {
|
||||
queuedSendHandoff?: QueuedSendHandoffSeed
|
||||
}
|
||||
|
||||
// Mutable fields an in-place edit overwrites; id and index are preserved by `replaceAt`.
|
||||
export type QueuedMessageEditPatch = Pick<QueuedMessage, 'content' | 'fileAttachments' | 'contexts'>
|
||||
|
||||
export interface MothershipQueueState {
|
||||
queues: Record<string, QueuedMothershipMessage[]>
|
||||
editing: Record<string, string>
|
||||
|
||||
enqueue: (chatKey: string, message: QueuedMothershipMessage) => void
|
||||
insertAt: (chatKey: string, index: number, message: QueuedMothershipMessage) => void
|
||||
replaceAt: (chatKey: string, id: string, patch: QueuedMessageEditPatch) => void
|
||||
remove: (chatKey: string, id: string) => void
|
||||
setEditing: (chatKey: string, id: string | null) => void
|
||||
migrate: (fromKey: string, toKey: string) => void
|
||||
clearChat: (chatKey: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
Reference in New Issue
Block a user