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,312 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user