Skip to content

Commit c159690

Browse files
committed
tags fixes
1 parent 8e10846 commit c159690

9 files changed

Lines changed: 242 additions & 197 deletions

File tree

apps/sim/app/api/knowledge/[id]/documents/[documentId]/tag-definitions/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
createOrUpdateTagDefinitionsBulk,
1212
deleteAllTagDefinitions,
1313
getDocumentTagDefinitions,
14+
KnowledgeTagProvenanceConflictError,
1415
} from '@/lib/knowledge/tags/service'
1516
import type { BulkTagDefinitionsData } from '@/lib/knowledge/tags/types'
1617
import { checkDocumentAccess, checkDocumentWriteAccess } from '@/app/api/knowledge/utils'
@@ -198,6 +199,9 @@ export const DELETE = withRouteHandler(
198199
data: { deleted: deletedCount },
199200
})
200201
} catch (error) {
202+
if (error instanceof KnowledgeTagProvenanceConflictError) {
203+
return NextResponse.json({ error: error.message }, { status: 409 })
204+
}
201205
logger.error(`[${requestId}] Error with tag definitions operation`, error)
202206
return NextResponse.json({ error: 'Failed to process tag definitions' }, { status: 500 })
203207
}

apps/sim/app/api/knowledge/[id]/tag-definitions/[tagId]/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import { deleteTagDefinitionContract } from '@/lib/api/contracts/knowledge'
55
import { parseRequest } from '@/lib/api/server'
66
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8-
import { deleteTagDefinition } from '@/lib/knowledge/tags/service'
8+
import {
9+
deleteTagDefinition,
10+
KnowledgeTagProvenanceConflictError,
11+
} from '@/lib/knowledge/tags/service'
912
import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils'
1013

1114
export const dynamic = 'force-dynamic'
@@ -45,6 +48,9 @@ export const DELETE = withRouteHandler(
4548
message: `Tag definition "${deletedTag.displayName}" deleted successfully`,
4649
})
4750
} catch (error) {
51+
if (error instanceof KnowledgeTagProvenanceConflictError) {
52+
return NextResponse.json({ error: error.message }, { status: 409 })
53+
}
4854
logger.error(`[${requestId}] Error deleting tag definition`, error)
4955
return NextResponse.json({ error: 'Failed to delete tag definition' }, { status: 500 })
5056
}

apps/sim/app/api/knowledge/secret-provenance.test.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ function createHeaderlessRequest(payload: Record<string, unknown>): NextRequest
3434
}
3535

3636
describe('knowledge write secret provenance', () => {
37-
it('does not track durable provenance for a headerless external chunk write', () => {
37+
it('classifies a headerless external chunk write as exact-empty', () => {
3838
const payload = { content: 'manual content' }
3939

4040
const result = resolveKnowledgeWriteSecretProvenance({
@@ -46,13 +46,15 @@ describe('knowledge write secret provenance', () => {
4646
selectionKeys: ['chunk-content'],
4747
})
4848

49-
expect(result).toEqual({ success: true })
49+
expect(result).toEqual({
50+
success: true,
51+
provenances: [{ status: 'exact', entries: [] }],
52+
})
5053
})
5154

52-
it('does not track durable provenance for a headerless external document write', () => {
55+
it('classifies a headerless external document write as exact-empty', () => {
5356
const payload = {
5457
filename: 'manual.txt',
55-
documentTagsData: JSON.stringify([{ tagName: 'source', tagValue: 'manual' }]),
5658
}
5759

5860
const result = resolveKnowledgeDocumentWriteSecretProvenance({
@@ -64,7 +66,16 @@ describe('knowledge write secret provenance', () => {
6466
documents: [payload],
6567
})
6668

67-
expect(result).toEqual({ success: true })
69+
expect(result).toEqual({
70+
success: true,
71+
provenances: [
72+
{
73+
filename: { status: 'exact', entries: [] },
74+
content: { status: 'exact', entries: [] },
75+
tags: [],
76+
},
77+
],
78+
})
6879
})
6980

7081
it('does not track durable provenance for a legacy headerless internal write', () => {

apps/sim/app/api/knowledge/secret-provenance.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
createDurableSecretProvenanceRegistry,
55
type DurableSecretProvenance,
66
durableSecretProvenanceFromPrivateBundle,
7+
EXACT_EMPTY_DURABLE_SECRET_PROVENANCE,
78
} from '@/lib/execution/durable-secret-provenance'
89
import {
910
inspectPrivateSecretProvenanceRequest,
@@ -48,7 +49,12 @@ export function resolveKnowledgeWriteSecretProvenance(options: {
4849
const { request } = options
4950
const inspection = inspectPrivateSecretProvenanceRequest(request.headers, options.payload)
5051
if (inspection.status === 'unsupported') {
51-
return { success: true }
52+
return options.authType === AuthType.INTERNAL_JWT
53+
? { success: true }
54+
: {
55+
success: true,
56+
provenances: options.selectionKeys.map(() => EXACT_EMPTY_DURABLE_SECRET_PROVENANCE),
57+
}
5258
}
5359
if (inspection.status !== 'verified' || options.authType !== AuthType.INTERNAL_JWT) {
5460
return { success: false, response: invalidKnowledgeProvenanceResponse() }

apps/sim/lib/knowledge/documents/document-processor-secret-provenance.test.ts

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ const {
77
mockDownloadFileFromUrl,
88
mockGenerateInternalToken,
99
mockGetInternalApiBaseUrl,
10-
mockIsModelSafeWorkspaceFileKey,
10+
mockParseBuffer,
1111
} = vi.hoisted(() => ({
1212
mockDownloadFileFromUrl: vi.fn(),
1313
mockGenerateInternalToken: vi.fn(),
1414
mockGetInternalApiBaseUrl: vi.fn(),
15-
mockIsModelSafeWorkspaceFileKey: vi.fn(),
15+
mockParseBuffer: vi.fn(),
1616
}))
1717

1818
vi.mock('@/lib/auth/internal', () => ({
@@ -24,10 +24,8 @@ vi.mock('@/lib/core/utils/urls', async (importOriginal) => ({
2424
getInternalApiBaseUrl: mockGetInternalApiBaseUrl,
2525
}))
2626

27-
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({
28-
isModelSafeWorkspaceFileKey: mockIsModelSafeWorkspaceFileKey,
29-
MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE:
30-
'File cannot be sent to a model because its secret provenance is unavailable',
27+
vi.mock('@/lib/file-parsers', () => ({
28+
parseBuffer: mockParseBuffer,
3129
}))
3230

3331
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
@@ -38,6 +36,7 @@ import { env } from '@/lib/core/config/env'
3836
import { RESOLVED_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
3937
import { processDocument } from '@/lib/knowledge/documents/document-processor'
4038
import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance'
39+
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
4140

4241
describe('knowledge document model-input provenance', () => {
4342
beforeEach(() => {
@@ -57,27 +56,38 @@ describe('knowledge document model-input provenance', () => {
5756
vi.unstubAllGlobals()
5857
})
5958

60-
it('rejects an unsafe workspace file before downloading or parsing its bytes', async () => {
61-
mockIsModelSafeWorkspaceFileKey.mockResolvedValue(false)
59+
it('parses tracked workspace-file bytes locally without treating parsing as model egress', async () => {
60+
const registry = new ResolvedSecretTraceRegistry([
61+
{ name: 'TOKEN', plaintext: 'tracked-secret', encryptedValue: 'encrypted-token' },
62+
])
63+
registry.recordResolved('TOKEN', 'tracked-secret')
64+
mockDownloadFileFromUrl.mockResolvedValue(
65+
Buffer.from('Locally parsed content containing tracked-secret.')
66+
)
67+
mockParseBuffer.mockResolvedValue({
68+
content: 'Locally parsed content containing tracked-secret.',
69+
metadata: {},
70+
})
71+
const fetchMock = vi.fn()
72+
vi.stubGlobal('fetch', fetchMock)
6273

63-
await expect(
74+
const processed = await runWithKnowledgeModelInputProvenance(registry, () =>
6475
processDocument(
65-
'/api/files/serve/workspace/workspace-1/opaque.txt?context=workspace',
66-
'opaque.txt',
76+
'/api/files/serve/workspace/workspace-1/tracked.txt?context=workspace',
77+
'tracked.txt',
6778
'text/plain',
6879
1024,
6980
200,
70-
100,
81+
1,
7182
'user-1',
7283
'workspace-1'
7384
)
74-
).rejects.toThrow('secret provenance is unavailable')
75-
76-
expect(mockIsModelSafeWorkspaceFileKey).toHaveBeenCalledWith(
77-
'workspace/workspace-1/opaque.txt',
78-
{ workspaceId: 'workspace-1' }
7985
)
80-
expect(mockDownloadFileFromUrl).not.toHaveBeenCalled()
86+
87+
expect(processed.metadata.processingMethod).toBe('file-parser')
88+
expect(processed.chunks.map((chunk) => chunk.text).join('\n')).toContain('tracked-secret')
89+
expect(mockDownloadFileFromUrl).toHaveBeenCalledOnce()
90+
expect(fetchMock).not.toHaveBeenCalled()
8191
})
8292

8393
it('rejects secret-bearing opaque document bytes before external OCR', async () => {

apps/sim/lib/knowledge/documents/document-processor.ts

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,7 @@ import {
2525
getKnowledgeOpaqueModelInputRegistry,
2626
} from '@/lib/knowledge/model-input-provenance'
2727
import { StorageService } from '@/lib/uploads'
28-
import {
29-
isModelSafeWorkspaceFileKey,
30-
MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE,
31-
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
32-
import { extractStorageKey, isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
28+
import { isInternalFileUrl } from '@/lib/uploads/utils/file-utils'
3329
import { downloadFileFromUrl } from '@/lib/uploads/utils/file-utils.server'
3430
import { MAX_FILE_SIZE } from '@/lib/uploads/utils/validation'
3531
import { mistralParserTool } from '@/tools/mistral/parser'
@@ -58,20 +54,6 @@ type OCRPage = {
5854

5955
const MISTRAL_MAX_PAGES = 1000
6056

61-
async function assertDocumentFileModelSafe(
62-
fileUrl: string,
63-
workspaceId?: string | null
64-
): Promise<void> {
65-
if (!isInternalFileUrl(fileUrl)) return
66-
67-
const path = new URL(fileUrl, 'http://localhost').pathname
68-
const key = extractStorageKey(path)
69-
const safe = await isModelSafeWorkspaceFileKey(key, workspaceId ? { workspaceId } : {})
70-
if (!safe) {
71-
throw new Error(MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE)
72-
}
73-
}
74-
7557
async function getPdfPageCount(buffer: Buffer): Promise<number> {
7658
try {
7759
const { getDocumentProxy } = await import('unpdf')
@@ -209,7 +191,6 @@ export async function processDocument(
209191
logger.info('Processing document', { mimeType })
210192

211193
try {
212-
await assertDocumentFileModelSafe(fileUrl, workspaceId)
213194
const parseResult = await parseDocument(fileUrl, filename, mimeType, userId, workspaceId)
214195
const { content, processingMethod } = parseResult
215196
const cloudUrl = 'cloudUrl' in parseResult ? parseResult.cloudUrl : undefined

apps/sim/lib/knowledge/secret-provenance.ts

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -166,41 +166,10 @@ export function rebindKnowledgeDocumentSecretProvenance(
166166
provenance: DurableSecretProvenance,
167167
previousSource: KnowledgeDocumentSourceValue,
168168
nextSource: KnowledgeDocumentSourceValue
169-
): DurableSecretProvenance {
170-
return rebindKnowledgeDocumentSecretProvenanceFields(
171-
provenance,
172-
previousSource,
173-
nextSource,
174-
new Set()
175-
)
176-
}
177-
178-
/** Rebinds unchanged source fields while dropping provenance for metadata fields being cleared. */
179-
export function rebindKnowledgeDocumentSecretProvenanceAfterMetadataClear(
180-
provenance: DurableSecretProvenance,
181-
previousSource: KnowledgeDocumentSourceValue,
182-
nextSource: KnowledgeDocumentSourceValue,
183-
clearedFields: ReadonlySet<KnowledgeDocumentMetadataField>
184-
): DurableSecretProvenance {
185-
return rebindKnowledgeDocumentSecretProvenanceFields(
186-
provenance,
187-
previousSource,
188-
nextSource,
189-
clearedFields
190-
)
191-
}
192-
193-
function rebindKnowledgeDocumentSecretProvenanceFields(
194-
provenance: DurableSecretProvenance,
195-
previousSource: KnowledgeDocumentSourceValue,
196-
nextSource: KnowledgeDocumentSourceValue,
197-
excludedFields: ReadonlySet<KnowledgeDocumentMetadataField>
198169
): DurableSecretProvenance {
199170
if (provenance.status === 'unknown') return provenance
200171
if (provenance.entries.some((entry) => !entry.sourceValueHash)) return { status: 'unknown' }
201-
const rebound = KNOWLEDGE_DOCUMENT_METADATA_FIELDS.filter(
202-
(field) => !excludedFields.has(field)
203-
).map((field) =>
172+
const rebound = KNOWLEDGE_DOCUMENT_METADATA_FIELDS.map((field) =>
204173
bindKnowledgeDocumentFieldSecretProvenance(
205174
filterDurableSecretProvenanceBySourceValues(provenance, [
206175
createKnowledgeDocumentFieldBinding(field, previousSource[field]),
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { document, embedding, knowledgeBase, knowledgeBaseTagDefinitions } from '@sim/db/schema'
5+
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
import {
8+
deleteAllTagDefinitions,
9+
deleteTagDefinition,
10+
KnowledgeTagProvenanceConflictError,
11+
} from '@/lib/knowledge/tags/service'
12+
13+
const KNOWLEDGE_BASE_ID = 'knowledge-base-1'
14+
const TAG_DEFINITION = {
15+
id: 'tag-definition-1',
16+
knowledgeBaseId: KNOWLEDGE_BASE_ID,
17+
tagSlot: 'tag1',
18+
displayName: 'Classification',
19+
}
20+
21+
function queueSingleTagDeletion(conflict: boolean): void {
22+
queueTableRows(knowledgeBase, [{ id: KNOWLEDGE_BASE_ID }])
23+
queueTableRows(knowledgeBaseTagDefinitions, [TAG_DEFINITION])
24+
queueTableRows(document, conflict ? [{ id: 'document-1' }] : [])
25+
}
26+
27+
describe('knowledge tag deletion provenance', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks()
30+
resetDbChainMock()
31+
})
32+
33+
it('uses one bounded transaction and preserves the established bulk cleanup path', async () => {
34+
queueSingleTagDeletion(false)
35+
36+
await expect(
37+
deleteTagDefinition(KNOWLEDGE_BASE_ID, TAG_DEFINITION.id, 'request-1')
38+
).resolves.toEqual({ tagSlot: 'tag1', displayName: 'Classification' })
39+
40+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
41+
expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1)
42+
expect(JSON.stringify(dbChainMockFns.execute.mock.calls[0]?.[0])).toContain('statement_timeout')
43+
44+
const selectedTables = dbChainMockFns.from.mock.calls.map(([table]) => table)
45+
expect(selectedTables).toEqual([knowledgeBase, knowledgeBaseTagDefinitions, document])
46+
expect(dbChainMockFns.for).toHaveBeenCalledWith('update', { of: document })
47+
expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([embedding, document])
48+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ tag1: null })
49+
expect(dbChainMockFns.delete).toHaveBeenCalledWith(knowledgeBaseTagDefinitions)
50+
})
51+
52+
it('rejects uncertain or nonempty tracked provenance before mutating data', async () => {
53+
queueSingleTagDeletion(true)
54+
55+
await expect(
56+
deleteTagDefinition(KNOWLEDGE_BASE_ID, TAG_DEFINITION.id, 'request-1')
57+
).rejects.toBeInstanceOf(KnowledgeTagProvenanceConflictError)
58+
59+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
60+
expect(dbChainMockFns.delete).not.toHaveBeenCalled()
61+
})
62+
63+
it('clears every bounded tag slot through the same guarded mutation path', async () => {
64+
queueTableRows(knowledgeBase, [{ id: KNOWLEDGE_BASE_ID }])
65+
queueTableRows(knowledgeBaseTagDefinitions, [
66+
{ id: 'tag-definition-1', tagSlot: 'tag1' },
67+
{ id: 'tag-definition-2', tagSlot: 'number1' },
68+
])
69+
queueTableRows(document, [])
70+
71+
await expect(deleteAllTagDefinitions(KNOWLEDGE_BASE_ID, 'request-1')).resolves.toBe(2)
72+
73+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
74+
expect(dbChainMockFns.execute).toHaveBeenCalledTimes(1)
75+
expect(dbChainMockFns.set).toHaveBeenCalledWith({ tag1: null, number1: null })
76+
})
77+
})

0 commit comments

Comments
 (0)