Files
simstudioai--sim/apps/sim/lib/copilot/tools/server/files/file-intent-store.ts
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

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
}