d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
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
313 lines
9.2 KiB
TypeScript
313 lines
9.2 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { toError } from '@sim/utils/errors'
|
|
import { sleep } from '@sim/utils/helpers'
|
|
import { getRedisClient } from '@/lib/core/config/redis'
|
|
import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
|
|
|
|
export type PendingFileIntent = {
|
|
operation: 'append' | 'update' | 'patch'
|
|
fileId: string
|
|
workspaceId: string
|
|
userId: string
|
|
chatId?: string
|
|
messageId?: string
|
|
// The invoking file subagent's channel id (its outer tool_use id). Lets
|
|
// edit_content consume the intent for ITS OWN file subagent instead of the
|
|
// latest in the message, so two file agents writing concurrently never cross
|
|
// their content into each other's file.
|
|
channelId?: string
|
|
fileRecord: WorkspaceFileRecord
|
|
existingContent?: string
|
|
edit?: {
|
|
strategy: string
|
|
search?: string
|
|
replaceAll?: boolean
|
|
mode?: string
|
|
occurrence?: number
|
|
before_anchor?: string
|
|
after_anchor?: string
|
|
anchor?: string
|
|
start_anchor?: string
|
|
end_anchor?: string
|
|
}
|
|
contentType?: string
|
|
title?: string
|
|
createdAt: number
|
|
}
|
|
|
|
export type FileIntentScope = {
|
|
chatId?: string
|
|
messageId?: string
|
|
// When set, consumeLatestFileIntent only considers intents from this subagent
|
|
// channel — the key to isolating concurrent file subagents. Omitted by callers
|
|
// that intentionally span the whole message (e.g. clearIntentsForWorkspace).
|
|
channelId?: string
|
|
}
|
|
|
|
const logger = createLogger('FileIntentStore')
|
|
|
|
const INTENT_TTL_MS = 60 * 60 * 1000
|
|
const INTENT_TTL_SECONDS = INTENT_TTL_MS / 1000
|
|
const REDIS_KEY_PREFIX = 'mothership_file_intent:'
|
|
const RETRY_DELAYS_MS = [0, 50, 150] as const
|
|
const memoryStore = new Map<string, PendingFileIntent>()
|
|
|
|
function buildKey(workspaceId: string, fileId: string): string {
|
|
return `${workspaceId}:${fileId}`
|
|
}
|
|
|
|
function getWorkspaceRedisKey(workspaceId: string): string {
|
|
return `${REDIS_KEY_PREFIX}${workspaceId}`
|
|
}
|
|
|
|
function scopeMatches(intent: PendingFileIntent, scope?: FileIntentScope): boolean {
|
|
return intent.chatId === scope?.chatId && intent.messageId === scope?.messageId
|
|
}
|
|
|
|
// Channel filter for consume: when a scope carries a channelId, only the
|
|
// matching file subagent's intent qualifies. No channelId => message-wide
|
|
// (legacy / main-agent) behavior. Deliberately separate from scopeMatches so
|
|
// clearIntentsForWorkspace keeps clearing every channel in a message.
|
|
function channelMatches(intent: PendingFileIntent, scope?: FileIntentScope): boolean {
|
|
return !scope?.channelId || intent.channelId === scope.channelId
|
|
}
|
|
|
|
function buildScopedField(fileId: string, scope?: FileIntentScope): string {
|
|
return `${scope?.chatId ?? ''}:${scope?.messageId ?? ''}:${fileId}`
|
|
}
|
|
|
|
function cleanupStale(): void {
|
|
const now = Date.now()
|
|
for (const [key, intent] of memoryStore) {
|
|
if (now - intent.createdAt > INTENT_TTL_MS) {
|
|
memoryStore.delete(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function withRedisRetry<T>(
|
|
operation: string,
|
|
workspaceId: string,
|
|
work: (redis: NonNullable<ReturnType<typeof getRedisClient>>) => Promise<T>
|
|
): Promise<T> {
|
|
const redis = getRedisClient()
|
|
if (!redis) {
|
|
throw new Error('Redis client unavailable')
|
|
}
|
|
|
|
let lastError: unknown
|
|
for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) {
|
|
const delay = RETRY_DELAYS_MS[attempt]
|
|
if (delay > 0) {
|
|
await sleep(delay)
|
|
}
|
|
|
|
try {
|
|
return await work(redis)
|
|
} catch (error) {
|
|
lastError = error
|
|
logger.warn('Redis file intent operation failed', {
|
|
operation,
|
|
workspaceId,
|
|
attempt: attempt + 1,
|
|
error: toError(error).message,
|
|
})
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error(`${operation} failed`)
|
|
}
|
|
|
|
function isStale(intent: PendingFileIntent): boolean {
|
|
return Date.now() - intent.createdAt > INTENT_TTL_MS
|
|
}
|
|
|
|
function parseIntent(raw: string | null | undefined): PendingFileIntent | undefined {
|
|
if (!raw) return undefined
|
|
try {
|
|
const parsed = JSON.parse(raw) as PendingFileIntent
|
|
return isStale(parsed) ? undefined : parsed
|
|
} catch (error) {
|
|
logger.warn('Failed to parse file intent', {
|
|
error: toError(error).message,
|
|
})
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
export async function storeFileIntent(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
intent: PendingFileIntent
|
|
): Promise<void> {
|
|
const redis = getRedisClient()
|
|
if (!redis) {
|
|
cleanupStale()
|
|
memoryStore.set(buildKey(workspaceId, buildScopedField(fileId, intent)), intent)
|
|
return
|
|
}
|
|
|
|
await withRedisRetry('store_file_intent', workspaceId, async (client) => {
|
|
const key = getWorkspaceRedisKey(workspaceId)
|
|
const pipeline = client.pipeline()
|
|
pipeline.hset(key, buildScopedField(fileId, intent), JSON.stringify(intent))
|
|
pipeline.expire(key, INTENT_TTL_SECONDS)
|
|
await pipeline.exec()
|
|
})
|
|
}
|
|
|
|
async function consumeFileIntent(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
scope?: FileIntentScope
|
|
): Promise<PendingFileIntent | undefined> {
|
|
const redis = getRedisClient()
|
|
if (!redis) {
|
|
const key = buildKey(workspaceId, buildScopedField(fileId, scope))
|
|
const intent = memoryStore.get(key)
|
|
if (intent) {
|
|
memoryStore.delete(key)
|
|
}
|
|
return intent
|
|
}
|
|
|
|
const raw = await withRedisRetry('consume_file_intent', workspaceId, async (client) => {
|
|
const key = getWorkspaceRedisKey(workspaceId)
|
|
const field = buildScopedField(fileId, scope)
|
|
const value = await client.hget(key, field)
|
|
if (value !== null) {
|
|
await client.hdel(key, field)
|
|
}
|
|
return value
|
|
})
|
|
return parseIntent(raw)
|
|
}
|
|
|
|
export async function peekFileIntent(
|
|
workspaceId: string,
|
|
fileId: string,
|
|
scope?: FileIntentScope
|
|
): Promise<PendingFileIntent | undefined> {
|
|
const redis = getRedisClient()
|
|
if (!redis) {
|
|
cleanupStale()
|
|
return memoryStore.get(buildKey(workspaceId, buildScopedField(fileId, scope)))
|
|
}
|
|
|
|
const raw = await withRedisRetry('peek_file_intent', workspaceId, async (client) => {
|
|
const key = getWorkspaceRedisKey(workspaceId)
|
|
return client.hget(key, buildScopedField(fileId, scope))
|
|
})
|
|
const intent = parseIntent(raw)
|
|
if (!intent && raw !== null) {
|
|
await withRedisRetry('clear_stale_file_intent', workspaceId, async (client) => {
|
|
await client.hdel(getWorkspaceRedisKey(workspaceId), buildScopedField(fileId, scope))
|
|
})
|
|
}
|
|
return intent
|
|
}
|
|
|
|
export async function consumeLatestFileIntent(
|
|
workspaceId: string,
|
|
scope?: FileIntentScope
|
|
): Promise<PendingFileIntent | undefined> {
|
|
const redis = getRedisClient()
|
|
if (!redis) {
|
|
cleanupStale()
|
|
let latest: PendingFileIntent | undefined
|
|
let latestKey: string | undefined
|
|
for (const [key, intent] of memoryStore) {
|
|
if (
|
|
intent.workspaceId === workspaceId &&
|
|
scopeMatches(intent, scope) &&
|
|
channelMatches(intent, scope)
|
|
) {
|
|
if (!latest || intent.createdAt > latest.createdAt) {
|
|
latest = intent
|
|
latestKey = key
|
|
}
|
|
}
|
|
}
|
|
if (latestKey) {
|
|
memoryStore.delete(latestKey)
|
|
}
|
|
return latest
|
|
}
|
|
|
|
const entries = await withRedisRetry('read_workspace_file_intents', workspaceId, async (client) =>
|
|
client.hgetall(getWorkspaceRedisKey(workspaceId))
|
|
)
|
|
let latest: PendingFileIntent | undefined
|
|
let latestField: string | undefined
|
|
const staleFields: string[] = []
|
|
for (const [field, raw] of Object.entries(entries)) {
|
|
const parsed = parseIntent(raw)
|
|
if (!parsed) {
|
|
staleFields.push(field)
|
|
continue
|
|
}
|
|
if (!scopeMatches(parsed, scope) || !channelMatches(parsed, scope)) {
|
|
continue
|
|
}
|
|
if (!latest || parsed.createdAt > latest.createdAt) {
|
|
latest = parsed
|
|
latestField = field
|
|
}
|
|
}
|
|
|
|
const fieldsToDelete = latestField ? [...staleFields, latestField] : staleFields
|
|
if (fieldsToDelete.length > 0) {
|
|
await withRedisRetry('delete_workspace_file_intents', workspaceId, async (client) => {
|
|
await client.hdel(getWorkspaceRedisKey(workspaceId), ...fieldsToDelete)
|
|
})
|
|
}
|
|
return latest
|
|
}
|
|
|
|
export async function clearIntentsForWorkspace(
|
|
workspaceId: string,
|
|
scope?: FileIntentScope
|
|
): Promise<number> {
|
|
const redis = getRedisClient()
|
|
if (!redis) {
|
|
let cleared = 0
|
|
for (const [key, intent] of memoryStore) {
|
|
if (intent.workspaceId === workspaceId && (!scope || scopeMatches(intent, scope))) {
|
|
memoryStore.delete(key)
|
|
cleared++
|
|
}
|
|
}
|
|
return cleared
|
|
}
|
|
|
|
const key = getWorkspaceRedisKey(workspaceId)
|
|
if (!scope) {
|
|
const count = await withRedisRetry(
|
|
'count_workspace_file_intents',
|
|
workspaceId,
|
|
async (client) => client.hlen(key)
|
|
)
|
|
await withRedisRetry('clear_workspace_file_intents', workspaceId, async (client) => {
|
|
await client.del(key)
|
|
})
|
|
return count
|
|
}
|
|
|
|
const entries = await withRedisRetry('read_workspace_file_intents', workspaceId, async (client) =>
|
|
client.hgetall(key)
|
|
)
|
|
const fieldsToDelete: string[] = []
|
|
for (const [field, raw] of Object.entries(entries)) {
|
|
const parsed = parseIntent(raw)
|
|
if (parsed && scopeMatches(parsed, scope)) {
|
|
fieldsToDelete.push(field)
|
|
}
|
|
}
|
|
if (fieldsToDelete.length > 0) {
|
|
await withRedisRetry('clear_scoped_file_intents', workspaceId, async (client) => {
|
|
await client.hdel(key, ...fieldsToDelete)
|
|
})
|
|
}
|
|
return fieldsToDelete.length
|
|
}
|