Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d66d69d
feat(files): restore Project file browser and Mothership integration
mzxchandra Oct 14, 2026
ecffcbb
fix(files): resolve CI audits and test types after restacking on staging
mzxchandra Oct 15, 2026
7ed2422
fix(files): gate Project sandbox callbacks like workspace callbacks
mzxchandra Oct 15, 2026
b2af99a
fix(projects): keep Project chat tabs behind the rollout flag
mzxchandra Oct 15, 2026
b7dcf2a
refactor(files): drop the Project files UI from the Chat transport PR
mzxchandra Oct 15, 2026
1dfafa3
fix(files): redact Project file text like workspace file text
mzxchandra Oct 15, 2026
a3b3cc2
Merge remote-tracking branch 'origin/staging' into codex/project-file…
mzxchandra Oct 15, 2026
6555b7e
fix(sim-cli): keep unpublished groups in the Mothership command inven…
mzxchandra Oct 15, 2026
f605f52
fix(files): address Cubic and Greptile review findings
mzxchandra Oct 15, 2026
999c1a3
Merge remote-tracking branch 'origin/staging' into codex/project-file…
mzxchandra Oct 15, 2026
6fce1ac
fix(files): address the second review round
mzxchandra Oct 16, 2026
a859c47
Merge remote-tracking branch 'origin/staging' into codex/project-file…
mzxchandra Oct 16, 2026
d5fb05f
fix(files): choose a generated Project document's read by its rendere…
mzxchandra Oct 16, 2026
51e4904
Merge remote-tracking branch 'origin/staging' into rebase/product-v2
mzxchandra Oct 16, 2026
1ec4ca1
fix(mothership): close review gaps in Project file transport
mzxchandra Oct 16, 2026
7651d14
Merge remote-tracking branch 'origin/staging' into rebase/product-v2
mzxchandra Oct 16, 2026
9b4c668
fix(mothership): match chat tabs within the chat's workspace
mzxchandra Oct 16, 2026
a6a8d0a
fix(mothership): bound Project name checks and keep equal owners
mzxchandra Oct 16, 2026
63d3531
Merge remote-tracking branch 'origin/staging' into rebase/product-v2
mzxchandra Oct 16, 2026
571269e
Merge remote-tracking branch 'origin/staging' into rebase/product-v2
mzxchandra Oct 16, 2026
5606ae9
Merge staging into codex/project-files-product-v2
Oct 16, 2026
1d3c571
fix(project-files): harden owner hints, grep provenance, and tab iden…
waleedlatif1 Oct 16, 2026
1ce1192
fix(project-files): refuse secret-bearing raw Project downloads witho…
waleedlatif1 Oct 16, 2026
d261cf9
test(project-files): pin refusals and cover live owner-hint, tab-key …
waleedlatif1 Oct 16, 2026
d87613f
refactor(project-files): move owner routing and admission behind the …
waleedlatif1 Oct 16, 2026
a08d06d
refactor(mothership): share chat tab keys, CLI session setup, and fil…
waleedlatif1 Oct 16, 2026
cdc78c6
perf(project-files): skip the folder tree for grep reads and check hi…
waleedlatif1 Oct 16, 2026
8a3fb00
test(mothership): drive stale-tab and owner-hint failures through the…
waleedlatif1 Oct 16, 2026
1de1cfb
test(project-files): pin upload cancellation during provenance and ne…
waleedlatif1 Oct 16, 2026
7dd15b1
fix(project-files): recheck cancellation before completing a workbenc…
waleedlatif1 Oct 16, 2026
b21ab82
fix(mothership): persist new-chat tabs in attachment order
waleedlatif1 Oct 16, 2026
608a728
fix(mothership): store the recovery authoring marker only while Proje…
waleedlatif1 Oct 16, 2026
daf2eb6
fix(mothership): tag an unlabeled file selection as a bare mention
waleedlatif1 Oct 16, 2026
2913b56
test(project-files): give new suites their own reports and drop an un…
waleedlatif1 Oct 16, 2026
428b6db
refactor(files): return readSourceBuffer's TSDoc to its function
waleedlatif1 Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions apps/sim/app/api/copilot/chat/resources/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import {
} from '@/lib/api/contracts/copilot'
import {
defineInternalJsonRoute,
internalJsonPresenters,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { changeChatResources } from '@/lib/mothership/chat/application/change-resources'
import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation'
import type { MothershipResource } from '@/lib/mothership/resources/types'

const policy = {
auth: internalSessionAuth,
Expand All @@ -20,7 +21,10 @@ const policy = {
}),
errorPolicy: internalOrchestrationErrorPolicy,
useCase: changeChatResources,
present: internalJsonPresenters.withSuccess,
present: ({ resources }: { resources: MothershipResource[] }) => ({
success: true as const,
resources: resources.map(presentChatResourceForBrowser),
}),
}

export const POST = defineInternalJsonRoute({
Expand Down Expand Up @@ -53,7 +57,14 @@ export const DELETE = defineInternalJsonRoute({
chatId: body.chatId,
change: {
kind: 'remove' as const,
resources: [{ type: body.resourceType, id: body.resourceId, workspaceId: body.workspaceId }],
resources: [
{
type: body.resourceType,
id: body.resourceId,
workspaceId: body.workspaceId,
owner: body.owner,
},
],
},
}),
})
5 changes: 4 additions & 1 deletion apps/sim/app/api/mothership/chats/[chatId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
createInternalServerErrorResponse,
createUnauthorizedResponse,
} from '@/lib/mothership/request/http'
import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation'
import { captureServerEvent } from '@/lib/posthog/server'

const logger = createLogger('MothershipChatAPI')
Expand Down Expand Up @@ -107,7 +108,9 @@ export const GET = withRouteHandler(
mode: chat.mode,
messages: effectiveMessages,
activeStreamId: liveStreamId,
resources: Array.isArray(chat.resources) ? chat.resources : [],
resources: Array.isArray(chat.resources)
? chat.resources.map(presentChatResourceForBrowser)
: [],
effort: chat.effort,
olderCursor: chat.olderCursor,
windowStartCursor: chat.windowStartCursor,
Expand Down
35 changes: 17 additions & 18 deletions apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,18 @@ import {
v2RateLimiterModuleMock,
v2RouteMocks,
} from '@sim/testing'
import {
readWorkspaceFileMetadataMock,
readWorkspaceFileMetadataMockFns,
} from '@sim/testing/mocks/read-workspace-file-metadata.mock'
import { usersQueriesMock, usersQueriesMockFns } from '@sim/testing/mocks/users-queries.mock'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const mocks = vi.hoisted(() => ({
readMetadata: vi.fn(),
}))

vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({
readWorkspaceFileMetadataWithVersion: {
operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' },
execute: mocks.readMetadata,
},
}))
vi.mock(
'@/lib/workspace-files/application/read-workspace-file-metadata',
() => readWorkspaceFileMetadataMock
)

vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock)
vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock)
Expand All @@ -30,6 +28,7 @@ import { OrchestrationError } from '@/lib/core/orchestration/types'
import { GET } from '@/app/api/v2/files/[fileId]/metadata/route'

const { mockGetUserEmailsByIds } = usersQueriesMockFns
const readMetadata = readWorkspaceFileMetadataMockFns.mockReadWorkspaceFileMetadataWithVersion

const WORKSPACE_ID = 'workspace-1'
const FILE_ID = 'wf_1'
Expand Down Expand Up @@ -89,15 +88,15 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
v2RouteMocks.authenticate.mockResolvedValue(auth)
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
mocks.readMetadata.mockResolvedValue({
readMetadata.mockResolvedValue({
file: { ...buildRecord(), currentVersion: 3 },
share: SHARE,
})
mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']]))
})

it('conceals cross-workspace authorization as not found', async () => {
mocks.readMetadata.mockRejectedValue(new NoWorkspaceAccessError())
readMetadata.mockRejectedValue(new NoWorkspaceAccessError())

const response = await callGet(`workspaceId=${WORKSPACE_ID}`)

Expand All @@ -106,35 +105,35 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
})

it('leaves an archived file unreachable when scope is omitted', async () => {
mocks.readMetadata.mockImplementation(archivedFileUseCase)
readMetadata.mockImplementation(archivedFileUseCase)

const response = await callGet(`workspaceId=${WORKSPACE_ID}`)

expect(response.status).toBe(404)
expect((await response.json()).error.code).toBe('NOT_FOUND')
expect(mocks.readMetadata).toHaveBeenCalledWith(
expect(readMetadata).toHaveBeenCalledWith(
expect.objectContaining({
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false },
})
)
})

it('leaves an archived file unreachable under an explicit scope=active', async () => {
mocks.readMetadata.mockImplementation(archivedFileUseCase)
readMetadata.mockImplementation(archivedFileUseCase)

const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=active`)

expect(response.status).toBe(404)
expect((await response.json()).error.code).toBe('NOT_FOUND')
expect(mocks.readMetadata).toHaveBeenCalledWith(
expect(readMetadata).toHaveBeenCalledWith(
expect.objectContaining({
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false },
})
)
})

it('returns archived metadata when scope=archived opts into the archived set', async () => {
mocks.readMetadata.mockImplementation(archivedFileUseCase)
readMetadata.mockImplementation(archivedFileUseCase)

const response = await callGet(`workspaceId=${WORKSPACE_ID}&scope=archived`)

Expand All @@ -157,7 +156,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => {
revision: expect.any(String),
},
})
expect(mocks.readMetadata).toHaveBeenCalledWith(
expect(readMetadata).toHaveBeenCalledWith(
expect.objectContaining({
principal: auth.principal,
input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: true },
Expand Down
12 changes: 2 additions & 10 deletions apps/sim/app/api/v2/projects/[projectId]/files/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ import {
v2OrchestrationErrorPolicy,
v2RateLimits,
} from '@/lib/api/server/routes'
import { toCreateProjectFileInput } from '@/lib/projects/files/api'
import { toV2ProjectFile } from '@/lib/projects/files/api/presenters'
import { createProjectFile, listProjectFiles } from '@/lib/projects/files/application'
import { projectFileOperations } from '@/lib/projects/files/application/operations'
import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file'
import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration'
import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response'

Expand Down Expand Up @@ -68,15 +68,7 @@ export const POST = defineV2JsonRoute({
rateLimit: v2RateLimits.publicApi,
errorPolicy: v2OrchestrationErrorPolicy,
parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES },
mapInput: ({ params, body }) => ({
projectId: params.projectId,
name: body.name,
contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)),
content: body.content,
encoding: body.encoding,
folderPath: body.folderPath ?? '/',
exactName: true,
}),
mapInput: ({ params, body }) => toCreateProjectFileInput(params.projectId, body),
useCase: createProjectFile,
present: ({ file }) => ({ data: toV2ProjectFile(file) }),
})
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ export const CHAT_CONTEXT_KIND_REGISTRY: Record<ChatContextKind, ChatContextKind
label: 'File folder',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
project: {
label: 'Project',
renderIcon: ({ className }) => <FolderIcon className={className} />,
},
workspace: {
label: 'Workspace',
renderIcon: ({ className }) => <Workspaces className={className} />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,34 @@ describe('organization resource stream', () => {
})
})

describe('owner-addressed effects on a legacy tab', () => {
const legacyTab: MothershipResource = { type: 'file', id: 'f', title: 'notes' }
const owner = { entityType: 'workspace' as const, entityId: 'ws' }

function applyOwnerEffect(op: 'upsert' | 'remove') {
const deps = makeStreamLoopDeps({ workspaceId: 'ws', resourcesRef: ref([legacyTab]) })
handleResourceEvent(
{ deps } as StreamLoopContext,
{
...removeEvent('file', 'f'),
payload: { op, effectId: `effect:${op}`, resource: { type: 'file', id: 'f', owner } },
} as ResourceEvent
)
const updater = (deps.setResources as ReturnType<typeof vi.fn>).mock.calls[0][0] as (
current: MothershipResource[]
) => MothershipResource[]
return updater([legacyTab])
}

it('closes the tab when the removal names its workspace owner', () => {
expect(applyOwnerEffect('remove')).toEqual([])
})

it('updates the tab in place instead of opening a duplicate', () => {
expect(applyOwnerEffect('upsert')).toHaveLength(1)
})
})

it('seeds only fresh matching search effects and never seeds replayed or foreign evidence', () => {
const deps = makeStreamLoopDeps({
workspaceId: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { FilePreviewSession } from '@/lib/mothership/request/session'
import type { PersistedStreamEventEnvelope } from '@/lib/mothership/request/session/contract'
import { searchResourceMatchesOwner } from '@/lib/mothership/resources/search'
import {
getChatResourceKey,
chatResourceKeyFor,
getChatResourceSelectionId,
mergeChatResource,
} from '@/lib/mothership/resources/types'
Expand Down Expand Up @@ -102,6 +102,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
} = ctx.deps
const onResourceEvent = onResourceEventRef.current
const payload = parsed.payload
const keyOf = chatResourceKeyFor(chatWorkspaceId)
if (payload.op === 'upsert' && payload.readOnly) return
if (payload.resource.type === 'settings') {
const settings = payload.resource
Expand Down Expand Up @@ -135,9 +136,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
search,
}
if (payload.op === 'remove') {
setResources((current) =>
current.filter((item) => getChatResourceKey(item) !== getChatResourceKey(resource))
)
setResources((current) => current.filter((item) => keyOf(item) !== keyOf(resource)))
return
}
const scopeKey =
Expand Down Expand Up @@ -181,9 +180,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
if (ctx.deps.citedSourcesEnabled) return
if (payload.effectId)
setResources((current) => {
const found = current.find(
(item) => getChatResourceKey(item) === getChatResourceKey(resource)
)
const found = current.find((item) => keyOf(item) === keyOf(resource))
return found
? current.map((item) => (item === found ? mergeChatResource(item, resource) : item))
: [...current, resource]
Expand Down Expand Up @@ -257,7 +254,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
const resourceType = resource.type
if (payload.effectId) {
applyResourceEffect(ctx, (current) =>
current.filter((item) => getChatResourceKey(item) !== getChatResourceKey(resource))
current.filter((item) => keyOf(item) !== keyOf(resource))
)
ctx.deps.setActiveResourceId((current) =>
current === getChatResourceSelectionId(resource) ? null : current
Expand Down Expand Up @@ -308,13 +305,11 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
!shouldAutoActivatePreviewSession(previewForResource)))
const wasAdded =
shouldSuppressFileResourceActivation || payload.effectId
? !resourcesRef.current.some((r) => getChatResourceKey(r) === getChatResourceKey(resource))
? !resourcesRef.current.some((r) => keyOf(r) === keyOf(resource))
: addResource(resourceUpdate)
if (payload.effectId) {
applyResourceEffect(ctx, (current) => {
const previous = current.find(
(item) => getChatResourceKey(item) === getChatResourceKey(resource)
)
const previous = current.find((item) => keyOf(item) === keyOf(resource))
const next = {
...previous,
...resource,
Expand All @@ -326,9 +321,7 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
})
} else if (shouldSuppressFileResourceActivation && wasAdded) {
setResources((current) =>
current.some((r) => getChatResourceKey(r) === getChatResourceKey(resource))
? current
: [...current, resource]
current.some((r) => keyOf(r) === keyOf(resource)) ? current : [...current, resource]
)
}
if (completedPreviewHandoff && isCompletedPreviewHandoffCurrent) {
Expand All @@ -341,14 +334,8 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
// report "added", while only the first updater actually inserted — the
// updater is idempotent, so it simply runs every time.
setResources((current) =>
current.some(
(r) => getChatResourceKey(r) === getChatResourceKey(resource) && r.viewId !== pinnedViewId
)
? current.map((r) =>
getChatResourceKey(r) === getChatResourceKey(resource)
? { ...r, viewId: pinnedViewId }
: r
)
current.some((r) => keyOf(r) === keyOf(resource) && r.viewId !== pinnedViewId)
? current.map((r) => (keyOf(r) === keyOf(resource) ? { ...r, viewId: pinnedViewId } : r))
: current
)
// Consumed by the embedded table once its views list carries the view —
Expand Down
Loading
Loading