diff --git a/.env.production.example b/.env.production.example index 6e2eadb0..5b16f60f 100644 --- a/.env.production.example +++ b/.env.production.example @@ -68,6 +68,14 @@ IFCCONVERT_BINARY=IfcConvert BUILDINGSMART_VALIDATE_URL=http://buildingsmart-validate:8080 BUILDINGSMART_VALIDATE_OPERATION_PATH=/api/validate BUILDINGSMART_VALIDATE_TOKEN=REPLACE_IF_ENABLED +IFCDB_AGENT_URL=http://ifcdb-agent:8080 +IFCDB_AGENT_VERSION=v1.0.9 +IFCDB_AGENT_HEALTH_PATH=/health +IFCDB_AGENT_INDEX_PATH=/api/v1/ifcdb/index +IFCDB_AGENT_QUERY_PATH=/api/v1/ifcdb/query +IFCDB_AGENT_EXPORT_PATH=/api/v1/ifcdb/export +IFCDB_AGENT_CLASH_PATH=/api/v1/ifcdb/clash +IFCDB_AGENT_QUANTITY_PATH=/api/v1/ifcdb/quantity OCCT_WORKER_URL=http://occt-worker:8080 CADQUERY_WORKER_URL=http://cadquery-worker:8080 FREECAD_WORKER_URL=http://freecad-worker:8080 diff --git a/03-frontend/components/ModuleFileExplorer.tsx b/03-frontend/components/ModuleFileExplorer.tsx index e1352ef5..2575f48e 100644 --- a/03-frontend/components/ModuleFileExplorer.tsx +++ b/03-frontend/components/ModuleFileExplorer.tsx @@ -31,6 +31,7 @@ import { FileOperationDialog, type FileDialogMode, type FileDialogPayload } from import { FilePreviewDrawer } from '@/components/FilePreviewDrawer'; import { LocalFileUploader } from '@/components/LocalFileUploader'; import { moduleBackendAdapter, type ModuleBackendSnapshot } from '@/lib/module-backend-adapter'; +import { isBackendModuleFileId, moduleFileApiClient } from '@/lib/module-file-api-client'; import { architokenOpenFileEventName, architokenPendingOpenFileKey, @@ -63,6 +64,19 @@ const statusLabels: Record = { archived: '已归档', }; +function isBackendBackedNode(node: ModuleFileNode | null): boolean { + return Boolean( + node && (node.source === 'backend' || isBackendModuleFileId(node.id)), + ); +} + +function backendErrorSummary(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + return '后端 CDE 请求失败'; +} + export function ModuleFileExplorer({ spec, onAudit, @@ -104,6 +118,42 @@ export function ModuleFileExplorer({ setSnapshot(moduleBackendAdapter.snapshot(spec.id)); }, [spec.id]); + useEffect(() => { + let cancelled = false; + + async function hydrateBackendFiles() { + try { + const payload = await moduleFileApiClient.listModuleFiles(spec.id, { + limit: 500, + }); + if (cancelled || payload.files.length === 0) { + return; + } + + const result = moduleBackendAdapter.replaceModuleFilesFromBackend( + spec.id, + payload.files, + ); + if (cancelled || result.count === 0) { + return; + } + + onAudit?.(result.auditEvent); + setActionMessage(`已同步后端 CDE 文件 ${result.count} 项。`); + setSnapshot(moduleBackendAdapter.snapshot(spec.id)); + } catch { + // Backend CDE sync is authoritative in production, but local dev and + // isolated UI tests still use the session adapter as a fallback. + } + } + + void hydrateBackendFiles(); + + return () => { + cancelled = true; + }; + }, [onAudit, spec.id]); + useEffect(() => { let cancelled = false; @@ -416,17 +466,46 @@ export function ModuleFileExplorer({ async function confirmDialog(payload: FileDialogPayload) { const parentId = dialogTarget?.type === 'folder' ? dialogTarget.id : currentFolderId; const name = payload.name?.trim(); + const parentIsBackendWritable = + parentId === rootId || isBackendModuleFileId(parentId); if (dialogMode === 'new') { - const result = moduleBackendAdapter.createFile({ - moduleId: spec.id, - parentId, - name: name || (payload.nodeType === 'file' ? '新建文件.md' : '新建文件夹'), - type: payload.nodeType ?? 'folder', - }); - setSelectedNodeId(result.node.id); - setActionMessage(`已新建 ${result.node.type === 'folder' ? '文件夹' : '文件'}: ${result.node.name}`); - record(result.auditEvent); + const nodeType = payload.nodeType ?? 'folder'; + const nodeName = name || (nodeType === 'file' ? '新建文件.md' : '新建文件夹'); + let handled = false; + if (parentIsBackendWritable) { + try { + const backendNode = await moduleFileApiClient.createModuleFile({ + moduleId: spec.id, + parentId, + name: nodeName, + kind: nodeType, + owner: '当前用户', + tags: [nodeType, 'frontend-cde'], + }); + const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode); + setSelectedNodeId(result.node.id); + setActionMessage(`已写入后端 CDE 并新建 ${result.node.type === 'folder' ? '文件夹' : '文件'}: ${result.node.name}`); + record(result.auditEvent); + handled = true; + } catch (error) { + if (isBackendModuleFileId(parentId)) { + setActionMessage(`新建未写入后端 CDE: ${backendErrorSummary(error)}`); + handled = true; + } + } + } + if (!handled) { + const result = moduleBackendAdapter.createFile({ + moduleId: spec.id, + parentId, + name: nodeName, + type: nodeType, + }); + setSelectedNodeId(result.node.id); + setActionMessage(`已新建 ${result.node.type === 'folder' ? '文件夹' : '文件'}: ${result.node.name}`); + record(result.auditEvent); + } } if (dialogMode === 'upload') { if (payload.file) { @@ -436,22 +515,97 @@ export function ModuleFileExplorer({ } } if (dialogMode === 'move' && dialogTarget && payload.targetParentId) { - const result = moduleBackendAdapter.moveFile(dialogTarget.id, payload.targetParentId); - setSelectedNodeId(result.node.id); - setActionMessage(`已移动: ${result.node.name}`); - record(result.auditEvent); + const targetIsBackendWritable = + payload.targetParentId === rootId || + isBackendModuleFileId(payload.targetParentId); + let handled = false; + if (isBackendBackedNode(dialogTarget) && targetIsBackendWritable) { + try { + const backendNode = await moduleFileApiClient.moveModuleFile( + dialogTarget.id, + { + moduleId: spec.id, + targetParentId: payload.targetParentId, + actor: 'FileExplorer', + }, + ); + const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode); + setSelectedNodeId(result.node.id); + setActionMessage(`已移动并同步后端 CDE: ${result.node.name}`); + record(result.auditEvent); + handled = true; + } catch (error) { + setActionMessage(`移动未写入后端 CDE: ${backendErrorSummary(error)}`); + handled = true; + } + } else if (isBackendBackedNode(dialogTarget)) { + setActionMessage('移动未执行: 目标目录不是后端 CDE 节点。'); + handled = true; + } + if (!handled) { + const result = moduleBackendAdapter.moveFile(dialogTarget.id, payload.targetParentId); + setSelectedNodeId(result.node.id); + setActionMessage(`已移动: ${result.node.name}`); + record(result.auditEvent); + } } if (dialogMode === 'rename' && dialogTarget && name) { - const result = moduleBackendAdapter.renameFile(dialogTarget.id, name); - setSelectedNodeId(result.node.id); - setActionMessage(`已重命名为: ${result.node.name}`); - record(result.auditEvent); + let handled = false; + if (isBackendBackedNode(dialogTarget)) { + try { + const backendNode = await moduleFileApiClient.updateModuleFile( + dialogTarget.id, + { name }, + ); + const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode); + setSelectedNodeId(result.node.id); + setActionMessage(`已重命名并同步后端 CDE: ${result.node.name}`); + record(result.auditEvent); + handled = true; + } catch (error) { + setActionMessage(`重命名未写入后端 CDE: ${backendErrorSummary(error)}`); + handled = true; + } + } + if (!handled) { + const result = moduleBackendAdapter.renameFile(dialogTarget.id, name); + setSelectedNodeId(result.node.id); + setActionMessage(`已重命名为: ${result.node.name}`); + record(result.auditEvent); + } } if (dialogMode === 'share' && dialogTarget) { - const result = moduleBackendAdapter.shareFile(dialogTarget.id); - setLastShareLink(result.link); - setActionMessage(`分享链接已生成: ${result.link.fileName}`); - record(result.auditEvent); + let handled = false; + if (isBackendBackedNode(dialogTarget)) { + try { + const share = await moduleFileApiClient.shareModuleFile( + dialogTarget.id, + ['read', 'share'], + 'FileExplorer', + ); + const backendNode = await moduleFileApiClient.getModuleFile(dialogTarget.id); + const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode); + setLastShareLink({ + id: `backend-share-${share.fileId}`, + fileId: share.fileId, + fileName: result.node.name, + url: share.shareUrl, + createdAt: new Date().toISOString(), + }); + setActionMessage(`后端 CDE 分享链接已生成: ${result.node.name}`); + record(result.auditEvent); + handled = true; + } catch (error) { + setActionMessage(`分享未写入后端 CDE: ${backendErrorSummary(error)}`); + handled = true; + } + } + if (!handled) { + const result = moduleBackendAdapter.shareFile(dialogTarget.id); + setLastShareLink(result.link); + setActionMessage(`分享链接已生成: ${result.link.fileName}`); + record(result.auditEvent); + } } if (dialogMode === 'delete' && dialogTarget) { if (dialogTarget.localFileId) { @@ -463,18 +617,38 @@ export function ModuleFileExplorer({ throw new Error(`Delete failed: ${response.status}`); } } - const result = moduleBackendAdapter.deleteFile(dialogTarget.id); - setSelectedNodeId((current) => (current === dialogTarget.id ? null : current)); - if (previewNode?.id === dialogTarget.id) { - setPreviewNode(null); - setFullView(false); + let handled = false; + if (isBackendBackedNode(dialogTarget)) { + try { + const backendNode = await moduleFileApiClient.trashModuleFile(dialogTarget.id); + const result = moduleBackendAdapter.upsertModuleFileFromBackend(backendNode); + setSelectedNodeId((current) => (current === dialogTarget.id ? null : current)); + if (previewNode?.id === dialogTarget.id) { + setPreviewNode(null); + setFullView(false); + } + setActionMessage(`${result.node.name} 已移入后端 CDE 回收站。`); + record(result.auditEvent); + handled = true; + } catch (error) { + setActionMessage(`删除未写入后端 CDE: ${backendErrorSummary(error)}`); + handled = true; + } + } + if (!handled) { + const result = moduleBackendAdapter.deleteFile(dialogTarget.id); + setSelectedNodeId((current) => (current === dialogTarget.id ? null : current)); + if (previewNode?.id === dialogTarget.id) { + setPreviewNode(null); + setFullView(false); + } + setActionMessage( + dialogTarget.localFileId + ? `${result.node.name} 已从本地运行索引和当前目录删除。` + : `${result.node.name} 已移入回收站。`, + ); + record(result.auditEvent); } - setActionMessage( - dialogTarget.localFileId - ? `${result.node.name} 已从本地运行索引和当前目录删除。` - : `${result.node.name} 已移入回收站。`, - ); - record(result.auditEvent); } setDialogMode(null); diff --git a/03-frontend/lib/adapter-source-registry.ts b/03-frontend/lib/adapter-source-registry.ts index ddc31394..c07ab4b5 100644 --- a/03-frontend/lib/adapter-source-registry.ts +++ b/03-frontend/lib/adapter-source-registry.ts @@ -1759,7 +1759,7 @@ export const adapterSourceRegistry = [ 'selected_external_process', 'active', 'LGPL/GPL depending on build', - ['.mp4', '.webm', '.mov', '.mkv', '.wav', '.mp3', '.flac', '.ogg', '.webp', '.gif'], + ['.mp4', '.webm', '.mov', '.mkv', '.avi', '.wav', '.mp3', '.m4a', '.flac', '.ogg', '.webp', '.gif'], ['import', 'export'], 'Selected media transcode engine through external-process worker isolation.', ), @@ -1862,7 +1862,7 @@ export const formatAdapterRequirements = [ ), requirement( 'video', - ['.mp4', '.webm', '.mov', '.mkv'], + ['.mp4', '.webm', '.mov', '.mkv', '.avi'], ['browser video viewer', 'transcode worker', 'video generation provider'], ['ffmpeg', 'blender', 'blender-mcp'], 'direct_browser', diff --git a/03-frontend/lib/file-type-registry.test.ts b/03-frontend/lib/file-type-registry.test.ts index 18552c89..b49b41c4 100644 --- a/03-frontend/lib/file-type-registry.test.ts +++ b/03-frontend/lib/file-type-registry.test.ts @@ -15,12 +15,60 @@ import { } from './file-type-registry'; describe('file type registry', () => { + const userRequestedNativeAndLightweightExtensions = [ + '.dxf', + '.dwg', + '.rvt', + '.stel', + '.stl', + '.iges', + '.igs', + '.ifc', + '.skp', + '.3dm', + '.usd', + '.gltf', + '.glb', + '.obj', + '.fbx', + '.docx', + '.doc', + '.xlsx', + '.xls', + '.pptx', + '.ppt', + '.mp3', + '.wav', + '.m4a', + '.flac', + '.mp4', + '.mkv', + '.mov', + '.avi', + '.jpg', + '.jpeg', + '.png', + '.webp', + '.gif', + '.pdf', + ] as const; + it('registers every requested extension', () => { for (const extension of requestedFileTypeExtensions) { expect(fileTypeForExtension(extension), extension).toBeDefined(); } }); + it('covers the requested native, lightweight, Office, media, image, and PDF display set', () => { + for (const extension of userRequestedNativeAndLightweightExtensions) { + const entry = fileTypeForExtension(extension); + expect(entry, extension).toBeDefined(); + expect(entry?.viewerKind, extension).not.toBe('unknown'); + expect(entry?.stages.store.status, extension).toBe('ready'); + expect(entry?.stages.preview.adapter, extension).toBeTruthy(); + } + }); + it('registers every requested exact file name', () => { for (const fileName of requestedExactFileTypeNames) { expect(fileTypeForFileName(fileName), fileName).toBeDefined(); @@ -72,6 +120,9 @@ describe('file type registry', () => { expect(fileTypeForExtension('.dwg')?.productionRoute).toBe( 'licensed_adapter_required', ); + expect(fileTypeForExtension('.stel')?.productionRoute).toBe( + 'licensed_adapter_required', + ); expect(fileTypeForExtension('.dxf')?.logicalType).toBe('cad.2d'); expect(fileTypeForExtension('.dxf')?.productionRoute).toBe('ready'); expect(stageRouteForFileName('drawing.dxf', 'preview')?.adapter).toBe( diff --git a/03-frontend/lib/file-type-registry.ts b/03-frontend/lib/file-type-registry.ts index b1f9b6cc..0a06cdd0 100644 --- a/03-frontend/lib/file-type-registry.ts +++ b/03-frontend/lib/file-type-registry.ts @@ -88,8 +88,11 @@ export const fileProcessingStages = [ export const requestedFileTypeExtensions = [ '.pdf', '.docx', + '.doc', '.xlsx', + '.xls', '.pptx', + '.ppt', '.csv', '.tsv', '.ifc', @@ -122,6 +125,7 @@ export const requestedFileTypeExtensions = [ '.fbx', '.dae', '.stl', + '.stel', '.ply', '.gltf', '.glb', @@ -138,11 +142,19 @@ export const requestedFileTypeExtensions = [ '.tiff', '.geotiff', '.jpg', + '.jpeg', '.png', '.webp', + '.gif', '.svg', + '.mp3', + '.wav', + '.m4a', + '.flac', '.mp4', + '.mkv', '.mov', + '.avi', '.webm', '.mpp', '.vsdx', @@ -803,6 +815,19 @@ export const fileTypeRegistry = [ productionRoute: 'adapter_required', }, ), + fileType( + 'cad-stel-unverified', + 'cad.exchange', + 'STEL source adapter boundary', + ['.stel'], + { + mimeType: 'application/octet-stream', + viewerKind: 'engineering', + adapters: ['licensed enterprise STEL adapter'], + stages: licensedVendor, + productionRoute: 'licensed_adapter_required', + }, + ), fileType( 'cad-mesh', 'scan.mesh', @@ -846,7 +871,7 @@ export const fileTypeRegistry = [ 'media-image', 'media.image', 'Image asset', - ['.tif', '.tiff', '.geotiff', '.jpg', '.jpeg', '.png', '.webp', '.svg'], + ['.tif', '.tiff', '.geotiff', '.jpg', '.jpeg', '.png', '.webp', '.gif', '.svg'], { mimeType: 'image/png', viewerKind: 'image', @@ -866,7 +891,7 @@ export const fileTypeRegistry = [ 'media-video', 'media.video', 'Video asset', - ['.mp4', '.mov', '.webm', '.mkv'], + ['.mp4', '.mov', '.webm', '.mkv', '.avi'], { mimeType: 'video/mp4', viewerKind: 'video', diff --git a/03-frontend/lib/module-backend-adapter.ts b/03-frontend/lib/module-backend-adapter.ts index 7897055d..98693020 100644 --- a/03-frontend/lib/module-backend-adapter.ts +++ b/03-frontend/lib/module-backend-adapter.ts @@ -59,6 +59,17 @@ export interface CreateTransactionInput { export interface ModuleBackendAdapter { snapshot(moduleId?: ModuleId): ModuleBackendSnapshot; + replaceModuleFilesFromBackend( + moduleId: ModuleId, + nodes: ModuleFileNode[], + ): { + count: number; + auditEvent: ModuleAuditEvent; + }; + upsertModuleFileFromBackend(node: ModuleFileNode): { + node: ModuleFileNode; + auditEvent: ModuleAuditEvent; + }; listFiles(moduleId: ModuleId, parentId: string): ModuleFileNode[]; listUploadedFiles(moduleId: ModuleId): LocalFileMetadata[]; openFile(fileId: string): { @@ -186,6 +197,7 @@ function mimeForName(name: string, type: ModuleFileNodeKind): string { '.las': 'application/octet-stream', '.m4a': 'audio/mp4', '.md': 'text/markdown', + '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', @@ -257,6 +269,84 @@ export class SessionModuleBackendAdapter implements ModuleBackendAdapter { }; } + replaceModuleFilesFromBackend( + moduleId: ModuleId, + nodes: ModuleFileNode[], + ): { + count: number; + auditEvent: ModuleAuditEvent; + } { + const moduleNodes = nodes.filter((node) => node.moduleId === moduleId); + const auditEvent = makeAudit( + 'BackendModuleFileApiClient', + `同步后端 CDE 文件节点 ${moduleNodes.length} 个`, + ); + if (moduleNodes.length === 0) { + return { count: 0, auditEvent }; + } + + const rootId = getModuleRootId(moduleId); + const root = + this.files.find((file) => file.id === rootId) ?? + createInitialModuleFileNodes().find((file) => file.id === rootId); + const normalizedRoot = root + ? { + ...root, + updatedAt: auditEvent.at, + auditTrail: [auditEvent, ...root.auditTrail].slice(0, 12), + } + : null; + const incomingIds = new Set(moduleNodes.map((node) => node.id)); + const preservedLocalUploads = this.files.filter( + (file) => + file.moduleId === moduleId && + file.source === 'local_upload' && + !incomingIds.has(file.id), + ); + const serverNodes = moduleNodes + .filter((node) => node.id !== rootId) + .map((node) => ({ + ...node, + auditTrail: [auditEvent, ...node.auditTrail].slice(0, 12), + })); + + this.files = [ + ...this.files.filter((file) => file.moduleId !== moduleId), + ...(normalizedRoot ? [normalizedRoot] : []), + ...serverNodes, + ...preservedLocalUploads, + ]; + this.auditEvents = [auditEvent, ...this.auditEvents].slice(0, 80); + return { count: serverNodes.length, auditEvent }; + } + + upsertModuleFileFromBackend(node: ModuleFileNode): { + node: ModuleFileNode; + auditEvent: ModuleAuditEvent; + } { + const auditEvent = makeAudit( + 'BackendModuleFileApiClient', + `更新后端 CDE 文件节点 ${node.name}`, + ); + const backendNode: ModuleFileNode = { + ...node, + source: 'backend', + auditTrail: [auditEvent, ...node.auditTrail].slice(0, 12), + }; + const rootId = getModuleRootId(node.moduleId); + const hasRoot = this.files.some((file) => file.id === rootId); + const root = hasRoot + ? [] + : createInitialModuleFileNodes().filter((file) => file.id === rootId); + this.files = [ + ...this.files.filter((file) => file.id !== node.id), + ...root, + backendNode, + ]; + this.auditEvents = [auditEvent, ...this.auditEvents].slice(0, 80); + return { node: backendNode, auditEvent }; + } + listUploadedFiles(moduleId: ModuleId): LocalFileMetadata[] { return this.uploadedFiles.filter((file) => file.moduleId === moduleId); } diff --git a/03-frontend/lib/module-file-api-client.test.ts b/03-frontend/lib/module-file-api-client.test.ts new file mode 100644 index 00000000..83606efe --- /dev/null +++ b/03-frontend/lib/module-file-api-client.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + createModuleFile, + isBackendModuleFileId, + listModuleFiles, + mapBackendModuleFileNode, + moveModuleFile, + toBackendParentId, + type BackendModuleFileNode, +} from './module-file-api-client'; +import { getModuleRootId } from './module-file-system'; + +const backendFolder: BackendModuleFileNode = { + id: '11111111-1111-4111-8111-111111111111', + moduleId: 'marketing_service', + parentId: null, + name: '客户线索', + kind: 'folder', + status: 'active', + metadata: { + sizeBytes: 0, + mimeType: null, + checksum: null, + version: 1, + owner: '客户经理', + tags: ['lead'], + createdAt: '2026-05-16T01:00:00Z', + updatedAt: '2026-05-16T01:00:00Z', + }, +}; + +const backendFile: BackendModuleFileNode = { + id: '22222222-2222-4222-8222-222222222222', + moduleId: 'marketing_service', + parentId: backendFolder.id, + name: '客户线索表.xlsx', + kind: 'file', + status: 'shared', + metadata: { + sizeBytes: 4096, + mimeType: null, + checksum: 'sha256:abc', + version: 3, + owner: '商务经理', + tags: ['excel'], + createdAt: '2026-05-16T01:01:00Z', + updatedAt: '2026-05-16T01:02:00Z', + }, +}; + +describe('module file api client', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('maps backend CDE nodes into frontend module file nodes', () => { + const folder = mapBackendModuleFileNode(backendFolder); + const file = mapBackendModuleFileNode(backendFile); + + expect(folder.parentId).toBe(getModuleRootId('marketing_service')); + expect(folder.mimeType).toBe('inode/directory'); + expect(folder.source).toBe('backend'); + expect(file.parentId).toBe(backendFolder.id); + expect(file.mimeType).toBe( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + expect(file.status).toBe('shared'); + expect(file.version).toBe('v3.0'); + expect(file.permissions).toEqual(['read', 'share']); + expect(file.checksum).toBe('sha256:abc'); + }); + + it('lists module files through the backend API and maps the response', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void input; + void init; + return new Response( + JSON.stringify({ + files: [backendFolder, backendFile], + total: 2, + pageInfo: { hasNextPage: false }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ); + }); + vi.stubGlobal('fetch', fetchMock); + + const response = await listModuleFiles('marketing_service', { + parentId: getModuleRootId('marketing_service'), + kind: 'file', + limit: 10, + }); + + expect(response.total).toBe(2); + expect(response.files.map((file) => file.id)).toEqual([ + backendFolder.id, + backendFile.id, + ]); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'http://localhost:8080/v1/modules/marketing_service/files?kind=file&limit=10', + ); + }); + + it('normalizes frontend root ids and rejects non-backend parents for writes', async () => { + expect(isBackendModuleFileId(backendFolder.id)).toBe(true); + expect(isBackendModuleFileId('marketing_service-root')).toBe(false); + expect(toBackendParentId('marketing_service', getModuleRootId('marketing_service'))).toBeNull(); + expect(toBackendParentId('marketing_service', backendFolder.id)).toBe( + backendFolder.id, + ); + + await expect( + createModuleFile({ + moduleId: 'marketing_service', + parentId: 'marketing_service-seeded-folder', + name: '线索.md', + kind: 'file', + }), + ).rejects.toThrow(/non-backend parent/u); + }); + + it('posts create and move operations with backend parent ids', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + void init; + const url = String(input); + const payload = url.includes('/move') + ? { ...backendFile, parentId: null, metadata: { ...backendFile.metadata, version: 4 } } + : backendFile; + return new Response(JSON.stringify(payload), { + status: url.includes('/modules/') ? 201 : 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + vi.stubGlobal('fetch', fetchMock); + + await createModuleFile({ + moduleId: 'marketing_service', + parentId: backendFolder.id, + name: '客户线索表.xlsx', + kind: 'file', + owner: '商务经理', + tags: ['excel'], + }); + await moveModuleFile(backendFile.id, { + moduleId: 'marketing_service', + targetParentId: getModuleRootId('marketing_service'), + actor: 'tester', + }); + + const createBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)); + const moveBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)); + expect(createBody).toMatchObject({ + name: '客户线索表.xlsx', + kind: 'file', + parentId: backendFolder.id, + owner: '商务经理', + tags: ['excel'], + }); + expect(createBody.mimeType).toBe( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + expect(moveBody).toEqual({ + targetParentId: null, + actor: 'tester', + }); + }); +}); diff --git a/03-frontend/lib/module-file-api-client.ts b/03-frontend/lib/module-file-api-client.ts new file mode 100644 index 00000000..52b47f3e --- /dev/null +++ b/03-frontend/lib/module-file-api-client.ts @@ -0,0 +1,382 @@ +// Backend CDE file API client for module workbench runtime. +// License: Apache-2.0 + +import { backendRequest, buildQuery } from './backend-api'; +import { + getModuleMimeTypeForName, + getModuleRootId, + type ModuleAuditEvent, + type ModuleFileNode, + type ModuleFileNodeKind, + type ModuleFileStatus, +} from './module-file-system'; +import type { ModuleId } from './module-registry'; + +export type BackendModuleFileKind = 'folder' | 'file'; +export type BackendModuleFileStatus = + | 'draft' + | 'uploaded' + | 'active' + | 'shared' + | 'soft_deleted' + | 'archived'; + +export interface BackendModuleFileMetadata { + sizeBytes: number; + mimeType: string | null; + checksum: string | null; + version: number; + owner: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +export interface BackendModuleFileNode { + id: string; + moduleId: string; + parentId: string | null; + name: string; + kind: BackendModuleFileKind; + status: BackendModuleFileStatus; + metadata: BackendModuleFileMetadata; +} + +export interface BackendPageInfo { + nextCursor?: string | null; + hasNextPage?: boolean; +} + +export interface BackendModuleFileListResponse { + files: BackendModuleFileNode[]; + total: number; + pageInfo?: BackendPageInfo; +} + +export interface ModuleFileListOptions { + parentId?: string | null; + status?: BackendModuleFileStatus; + kind?: BackendModuleFileKind; + limit?: number; + cursor?: string; +} + +export interface CreateBackendModuleFileInput { + moduleId: ModuleId; + parentId?: string | null; + name: string; + kind: BackendModuleFileKind; + mimeType?: string | null; + sizeBytes?: number; + owner?: string; + tags?: string[]; + content?: string; +} + +export interface UpdateBackendModuleFileInput { + name?: string; + owner?: string; + tags?: string[]; + mimeType?: string; +} + +export interface MoveBackendModuleFileInput { + moduleId: ModuleId; + targetParentId?: string | null; + actor?: string; +} + +export interface CopyBackendModuleFileInput { + targetModuleId?: ModuleId; + targetParentId?: string | null; + name?: string; + actor?: string; +} + +export interface BackendShareFileResponse { + fileId: string; + shareUrl: string; + permissions: string[]; + expiresAt: string | null; +} + +export interface BackendFileContentResponse { + fileId: string; + content: string; + contentType: string | null; + updatedAt: string; +} + +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; + +const statusMap = { + draft: 'pending_approval', + uploaded: 'uploaded', + active: 'active', + shared: 'shared', + soft_deleted: 'soft_deleted', + archived: 'archived', +} satisfies Record; + +export function isBackendModuleFileId(fileId: string | null | undefined): boolean { + return Boolean(fileId && uuidPattern.test(fileId)); +} + +export function toBackendParentId( + moduleId: ModuleId, + parentId: string | null | undefined, +): string | null | undefined { + if (!parentId || parentId === getModuleRootId(moduleId)) { + return null; + } + return isBackendModuleFileId(parentId) ? parentId : undefined; +} + +function requireBackendParentId( + moduleId: ModuleId, + parentId: string | null | undefined, +): string | null { + const backendParentId = toBackendParentId(moduleId, parentId); + if (backendParentId === undefined) { + throw new Error( + `Cannot persist module file under non-backend parent ${parentId}`, + ); + } + return backendParentId; +} + +function toModuleFileKind(kind: BackendModuleFileKind): ModuleFileNodeKind { + return kind === 'folder' ? 'folder' : 'file'; +} + +function backendAuditEvent( + node: BackendModuleFileNode, + summary: string, +): ModuleAuditEvent { + const updatedAt = node.metadata.updatedAt || new Date().toISOString(); + return { + id: `backend-cde-${node.id}-${updatedAt}`, + at: updatedAt, + actor: 'BackendModuleFileApiClient', + summary, + }; +} + +export function mapBackendModuleFileNode( + node: BackendModuleFileNode, +): ModuleFileNode { + const moduleId = node.moduleId as ModuleId; + const type = toModuleFileKind(node.kind); + const auditEvent = backendAuditEvent(node, `同步后端 CDE 文件 ${node.name}`); + const mapped: ModuleFileNode = { + id: node.id, + name: node.name, + type, + moduleId, + parentId: node.parentId ?? getModuleRootId(moduleId), + size: node.metadata.sizeBytes, + mimeType: node.metadata.mimeType ?? getModuleMimeTypeForName(node.name, type), + status: statusMap[node.status], + version: `v${node.metadata.version}.0`, + owner: node.metadata.owner, + updatedAt: node.metadata.updatedAt, + tags: node.metadata.tags, + permissions: + node.status === 'shared' + ? ['read', 'share'] + : ['read', 'write', 'share', 'approve'], + auditTrail: [auditEvent], + source: 'backend', + }; + if (node.metadata.checksum) { + mapped.checksum = node.metadata.checksum; + } + return mapped; +} + +export async function listModuleFiles( + moduleId: ModuleId, + options: ModuleFileListOptions = {}, +): Promise<{ + files: ModuleFileNode[]; + total: number; + pageInfo?: BackendPageInfo; +}> { + const query = buildQuery({ + parentId: toBackendParentId(moduleId, options.parentId), + status: options.status, + kind: options.kind, + limit: options.limit, + cursor: options.cursor, + }); + const response = await backendRequest( + `/v1/modules/${encodeURIComponent(moduleId)}/files${query}`, + { cache: 'no-store' }, + ); + const result: { + files: ModuleFileNode[]; + total: number; + pageInfo?: BackendPageInfo; + } = { + files: response.files.map(mapBackendModuleFileNode), + total: response.total, + }; + if (response.pageInfo) { + result.pageInfo = response.pageInfo; + } + return result; +} + +export async function createModuleFile( + input: CreateBackendModuleFileInput, +): Promise { + const node = await backendRequest( + `/v1/modules/${encodeURIComponent(input.moduleId)}/files`, + { + method: 'POST', + body: JSON.stringify({ + name: input.name, + kind: input.kind, + parentId: requireBackendParentId(input.moduleId, input.parentId), + mimeType: + input.mimeType ?? getModuleMimeTypeForName(input.name, input.kind), + sizeBytes: input.sizeBytes, + owner: input.owner, + tags: input.tags, + content: input.content, + }), + }, + ); + return mapBackendModuleFileNode(node); +} + +export async function getModuleFile(fileId: string): Promise { + const node = await backendRequest( + `/v1/files/${encodeURIComponent(fileId)}`, + { cache: 'no-store' }, + ); + return mapBackendModuleFileNode(node); +} + +export async function updateModuleFile( + fileId: string, + input: UpdateBackendModuleFileInput, +): Promise { + const node = await backendRequest( + `/v1/files/${encodeURIComponent(fileId)}`, + { + method: 'PATCH', + body: JSON.stringify(input), + }, + ); + return mapBackendModuleFileNode(node); +} + +export async function moveModuleFile( + fileId: string, + input: MoveBackendModuleFileInput, +): Promise { + const node = await backendRequest( + `/v1/files/${encodeURIComponent(fileId)}/move`, + { + method: 'POST', + body: JSON.stringify({ + targetParentId: requireBackendParentId( + input.moduleId, + input.targetParentId, + ), + actor: input.actor, + }), + }, + ); + return mapBackendModuleFileNode(node); +} + +export async function copyModuleFile( + fileId: string, + input: CopyBackendModuleFileInput, +): Promise { + const moduleId = input.targetModuleId; + const node = await backendRequest( + `/v1/files/${encodeURIComponent(fileId)}/copy`, + { + method: 'POST', + body: JSON.stringify({ + targetModuleId: moduleId, + targetParentId: moduleId + ? requireBackendParentId(moduleId, input.targetParentId) + : undefined, + name: input.name, + actor: input.actor, + }), + }, + ); + return mapBackendModuleFileNode(node); +} + +export async function shareModuleFile( + fileId: string, + permissions: string[] = ['read'], + actor = 'frontend-file-explorer', +): Promise { + return backendRequest( + `/v1/files/${encodeURIComponent(fileId)}/share`, + { + method: 'POST', + body: JSON.stringify({ + permissions, + actor, + }), + }, + ); +} + +export async function trashModuleFile(fileId: string): Promise { + const node = await backendRequest( + `/v1/files/${encodeURIComponent(fileId)}/trash`, + { method: 'POST' }, + ); + return mapBackendModuleFileNode(node); +} + +export async function getModuleFileContent( + fileId: string, +): Promise { + return backendRequest( + `/v1/files/${encodeURIComponent(fileId)}/content`, + { cache: 'no-store' }, + ); +} + +export async function updateModuleFileContent( + fileId: string, + content: string, + contentType?: string, + actor = 'frontend-file-explorer', +): Promise { + return backendRequest( + `/v1/files/${encodeURIComponent(fileId)}/content`, + { + method: 'PUT', + body: JSON.stringify({ + content, + contentType, + actor, + }), + }, + ); +} + +export const moduleFileApiClient = { + listModuleFiles, + createModuleFile, + getModuleFile, + updateModuleFile, + moveModuleFile, + copyModuleFile, + shareModuleFile, + trashModuleFile, + getModuleFileContent, + updateModuleFileContent, +}; diff --git a/03-frontend/lib/module-file-system.ts b/03-frontend/lib/module-file-system.ts index 7e8211d6..d8ea590d 100644 --- a/03-frontend/lib/module-file-system.ts +++ b/03-frontend/lib/module-file-system.ts @@ -46,7 +46,7 @@ export interface ModuleFileNode { tags: string[]; permissions: string[]; auditTrail: ModuleAuditEvent[]; - source?: 'seed' | 'session' | 'local_upload'; + source?: 'seed' | 'session' | 'backend' | 'local_upload'; localFileId?: string; localFile?: LocalFileMetadata; viewerKind?: LocalFileViewerKind; @@ -353,7 +353,7 @@ const standardLibraryStandardCategories: Record = { ], }; -const mimeByExtension: Record = { +export const moduleMimeByExtension: Record = { '.3dm': 'model/vnd.3dm', '.aac': 'audio/aac', '.bcf': 'application/bcf', @@ -381,6 +381,7 @@ const mimeByExtension: Record = { '.las': 'application/octet-stream', '.m4a': 'audio/mp4', '.md': 'text/markdown', + '.avi': 'video/x-msvideo', '.mkv': 'video/x-matroska', '.mov': 'video/quicktime', '.mp3': 'audio/mpeg', @@ -418,6 +419,23 @@ const mimeByExtension: Record = { '.zip': 'application/zip', }; +export function getModuleMimeTypeForName( + name: string, + type: ModuleFileNodeKind = 'file', +): string { + if (type === 'folder') { + return 'inode/directory'; + } + const dotIndex = name.lastIndexOf('.'); + if (dotIndex < 0) { + return 'application/octet-stream'; + } + return ( + moduleMimeByExtension[name.slice(dotIndex).toLowerCase()] ?? + 'application/octet-stream' + ); +} + export function getModuleRootId(moduleId: ModuleId): string { return `${moduleId}-root`; } @@ -561,7 +579,7 @@ export function createInitialModuleFileNodes(): ModuleFileNode[] { parentId: categoryId, size: 480_000 + categoryIndex * 63_000 + fileIndex * 127_000, mimeType: - mimeByExtension[extension] ?? 'application/octet-stream', + moduleMimeByExtension[extension] ?? 'application/octet-stream', status: fileIndex === 0 ? 'active' : 'uploaded', owner: spec.zhName, tags: [ @@ -590,7 +608,8 @@ export function createInitialModuleFileNodes(): ModuleFileNode[] { moduleId, parentId: folderId, size: 360_000 + folderIndex * 81_000 + fileIndex * 142_000, - mimeType: mimeByExtension[extension] ?? 'application/octet-stream', + mimeType: + moduleMimeByExtension[extension] ?? 'application/octet-stream', status: fileIndex === 0 ? 'active' : 'uploaded', owner: spec.zhName, tags: [folderName, spec.track, extension.replace('.', '')], diff --git a/04-backend/harness-core/src/bin/gateway.rs b/04-backend/harness-core/src/bin/gateway.rs index 9f352d54..794eab52 100644 --- a/04-backend/harness-core/src/bin/gateway.rs +++ b/04-backend/harness-core/src/bin/gateway.rs @@ -887,6 +887,7 @@ async fn validate_gateway_database_schema(pool: &PgPool) -> Result<()> { "asset_files", "object_store_bindings", "conversion_jobs", + "module_files", "runtime_executions", "audit_events", ] { @@ -1986,9 +1987,26 @@ fn header_value(headers: &HeaderMap, name: &str) -> Option { async fn list_module_files_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(module_id): Path, Query(query): Query, ) -> Result> { + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput::default(), + )?; + if let Some(pool) = state.db_pool.as_deref() { + let page = + postgres_runtime_store::list_module_files(pool, &context, &module_id, &query).await?; + return Ok(Json(ModuleFileListResponse { + total: page.items.len(), + files: page.items, + page_info: page.page_info, + })); + } let page = state.files.list_module_files(&module_id, &query)?; Ok(Json(ModuleFileListResponse { total: page.items.len(), @@ -1999,88 +2017,235 @@ async fn list_module_files_handler( async fn create_module_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(module_id): Path, Json(req): Json, ) -> Result<(StatusCode, Json)> { + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput { + actor: req.owner.clone(), + ..RequestContextInput::default() + }, + )?; + if let Some(pool) = state.db_pool.as_deref() { + let file = + postgres_runtime_store::create_module_file(pool, &context, &module_id, req).await?; + return Ok((StatusCode::CREATED, Json(file))); + } let file = state.files.create_file(&module_id, req)?; Ok((StatusCode::CREATED, Json(file))) } async fn get_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput::default(), + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::get_module_file(pool, &context, file_id) + .await + .map(Json); + } state.files.get_file(file_id).map(Json) } async fn update_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, Json(req): Json, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput { + actor: req.owner.clone(), + ..RequestContextInput::default() + }, + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::update_module_file(pool, &context, file_id, req) + .await + .map(Json); + } state.files.update_file(file_id, req).map(Json) } async fn get_file_metadata_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput::default(), + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::module_file_metadata(pool, &context, file_id) + .await + .map(Json); + } state.files.metadata(file_id).map(Json) } async fn get_file_content_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput::default(), + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::module_file_content(pool, &context, file_id) + .await + .map(Json); + } state.files.content(file_id).map(Json) } async fn update_file_content_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, Json(req): Json, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput { + actor: req.actor.clone(), + ..RequestContextInput::default() + }, + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::update_module_file_content(pool, &context, file_id, req) + .await + .map(Json); + } state.files.update_content(file_id, req).map(Json) } async fn move_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, Json(req): Json, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput { + actor: req.actor.clone(), + ..RequestContextInput::default() + }, + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::move_module_file(pool, &context, file_id, req) + .await + .map(Json); + } state.files.move_file(file_id, req).map(Json) } async fn copy_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, Json(req): Json, ) -> Result<(StatusCode, Json)> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput { + actor: req.actor.clone(), + ..RequestContextInput::default() + }, + )?; + if let Some(pool) = state.db_pool.as_deref() { + let file = postgres_runtime_store::copy_module_file(pool, &context, file_id, req).await?; + return Ok((StatusCode::CREATED, Json(file))); + } let file = state.files.copy_file(file_id, req)?; Ok((StatusCode::CREATED, Json(file))) } async fn share_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, Json(req): Json, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput { + actor: req.actor.clone(), + ..RequestContextInput::default() + }, + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::share_module_file(pool, &context, file_id, req) + .await + .map(Json); + } state.files.share_file(file_id, req).map(Json) } async fn trash_file_handler( State(state): State, + headers: HeaderMap, + RawQuery(raw_query): RawQuery, Path(file_id): Path, ) -> Result> { let file_id = parse_uuid(&file_id, "file_id")?; + let context = request_context( + &state, + &headers, + raw_query.as_deref(), + RequestContextInput::default(), + )?; + if let Some(pool) = state.db_pool.as_deref() { + return postgres_runtime_store::trash_module_file(pool, &context, file_id) + .await + .map(Json); + } state.files.trash_file(file_id).map(Json) } diff --git a/04-backend/harness-core/src/durable_store.rs b/04-backend/harness-core/src/durable_store.rs index 27dbdbf2..0bbe584b 100644 --- a/04-backend/harness-core/src/durable_store.rs +++ b/04-backend/harness-core/src/durable_store.rs @@ -54,6 +54,7 @@ pub const PHASE7_TABLES: &[&str] = &[ "asset_files", "object_store_bindings", "conversion_jobs", + "module_files", "runtime_executions", "audit_events", ]; @@ -131,6 +132,7 @@ mod tests { "asset_files", "object_store_bindings", "conversion_jobs", + "module_files", "runtime_executions", "audit_events", ] { diff --git a/04-backend/harness-core/src/file_runtime_registry.rs b/04-backend/harness-core/src/file_runtime_registry.rs index 1a7541c1..c609b441 100644 --- a/04-backend/harness-core/src/file_runtime_registry.rs +++ b/04-backend/harness-core/src/file_runtime_registry.rs @@ -55,9 +55,9 @@ pub struct FileRuntimeRoute { /// User-requested high-priority backend source extensions. pub const REQUESTED_ENGINE_EXTENSIONS: &[&str] = &[ - "dxf", "dwg", "rvt", "stel", "stl", "iges", "igs", "ifc", "skp", "3dm", "usd", "gltf", "obj", - "fbx", "docx", "doc", "xlsx", "xls", "pptx", "ppt", "mp3", "wav", "m4a", "flac", "mp4", "mkv", - "mov", "avi", "jpg", "png", "webp", "gif", "pdf", + "dxf", "dwg", "rvt", "stel", "stl", "iges", "igs", "ifc", "skp", "3dm", "usd", "gltf", "glb", + "obj", "fbx", "docx", "doc", "xlsx", "xls", "pptx", "ppt", "mp3", "wav", "m4a", "flac", "mp4", + "mkv", "mov", "avi", "jpg", "jpeg", "png", "webp", "gif", "pdf", ]; /// Return the canonical backend file runtime registry. @@ -533,6 +533,28 @@ mod tests { } } + #[test] + fn registry_covers_user_requested_native_and_lightweight_formats() { + let user_requested_extensions = [ + "dxf", "dwg", "rvt", "stel", "stl", "iges", "igs", "ifc", "skp", "3dm", "usd", "gltf", + "glb", "obj", "fbx", "docx", "doc", "xlsx", "xls", "pptx", "ppt", "mp3", "wav", "m4a", + "flac", "mp4", "mkv", "mov", "avi", "jpg", "jpeg", "png", "webp", "gif", "pdf", + ]; + + for extension in user_requested_extensions { + let route = route_for_extension(extension) + .unwrap_or_else(|| panic!("missing runtime route for {extension}")); + assert!( + route.source_required, + "route for {extension} must bind real source bytes" + ); + assert!( + !route.default_adapter.is_empty(), + "route for {extension} must declare an adapter boundary" + ); + } + } + #[test] fn private_formats_fail_closed_to_licensed_adapters() { for extension in ["dwg", "rvt", "skp", "3dm", "stel"] { diff --git a/04-backend/harness-core/src/postgres_runtime_store.rs b/04-backend/harness-core/src/postgres_runtime_store.rs index 424fabbb..5b0b8e3d 100644 --- a/04-backend/harness-core/src/postgres_runtime_store.rs +++ b/04-backend/harness-core/src/postgres_runtime_store.rs @@ -20,7 +20,13 @@ use crate::{ durable_store::DurableRecordMetadata, error::{HarnessError, Result}, module_audit::{AuditEvent, AuditEventInput, AuditEventKind, AuditEventQuery}, + module_files::{ + CopyFileRequest, CreateModuleFileRequest, FileContentResponse, FileListQuery, + ModuleFileKind, ModuleFileMetadata, ModuleFileNode, ModuleFileStatus, MoveFileRequest, + ShareFileRequest, ShareFileResponse, UpdateFileContentRequest, UpdateModuleFileRequest, + }, module_pagination::{ListPage, paginate}, + module_registry::normalize_module_id, runtime_context::{PermissionGuard, RequestContext, RuntimePermission, assert_runtime_scope}, runtime_execution::{ AiActionPlan, CreateAiRuntimeDraftRequest, RuntimeExecutionApprovalRequest, @@ -120,6 +126,28 @@ pub async fn ensure_phase7_runtime_schema(pool: &PgPool) -> Result<()> { created_by TEXT ); + CREATE TABLE IF NOT EXISTS module_files ( + id UUID PRIMARY KEY, + tenant_id TEXT NOT NULL, + project_id TEXT NOT NULL, + file_id UUID NOT NULL UNIQUE, + module_id TEXT NOT NULL, + parent_id UUID, + name TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL, + size_bytes BIGINT NOT NULL, + mime_type TEXT, + checksum TEXT, + version INTEGER NOT NULL, + owner TEXT NOT NULL, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + content TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + created_by TEXT + ); + CREATE TABLE IF NOT EXISTS runtime_executions ( id UUID PRIMARY KEY, tenant_id TEXT NOT NULL, @@ -157,6 +185,8 @@ pub async fn ensure_phase7_runtime_schema(pool: &PgPool) -> Result<()> { CREATE INDEX IF NOT EXISTS idx_asset_files_asset ON asset_files(asset_id, created_at); CREATE INDEX IF NOT EXISTS idx_object_bindings_file ON object_store_bindings(asset_file_id); CREATE INDEX IF NOT EXISTS idx_conversion_jobs_scope ON conversion_jobs(tenant_id, project_id, created_at, job_id); + CREATE INDEX IF NOT EXISTS idx_module_files_scope ON module_files(tenant_id, project_id, module_id, parent_id, updated_at); + CREATE INDEX IF NOT EXISTS idx_module_files_file_id ON module_files(file_id); CREATE INDEX IF NOT EXISTS idx_runtime_executions_scope ON runtime_executions(tenant_id, project_id, created_at, execution_id); CREATE INDEX IF NOT EXISTS idx_audit_events_filters ON audit_events(module_id, target_type, target_id, created_at); ", @@ -175,11 +205,484 @@ pub const fn phase7_runtime_tables() -> &'static [&'static str] { "asset_files", "object_store_bindings", "conversion_jobs", + "module_files", "runtime_executions", "audit_events", ] } +/// List module CDE files visible to a context from `PostgreSQL`. +/// +/// # Errors +/// Returns permission, pagination, enum, or database errors. +pub async fn list_module_files( + pool: &PgPool, + context: &RequestContext, + module_id: &str, + query: &FileListQuery, +) -> Result> { + PermissionGuard::ensure(context, RuntimePermission::ArtifactRead)?; + let module_id = normalize_module_id(module_id) + .ok_or_else(|| HarnessError::NotFound(format!("module_id={module_id}")))?; + let rows = sqlx::query_as::<_, ModuleFileRow>( + r" + SELECT id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + FROM module_files + WHERE tenant_id = $1 AND project_id = $2 AND module_id = $3 + ORDER BY kind ASC, name ASC, file_id ASC + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(module_id.as_str()) + .fetch_all(pool) + .await?; + let mut items = rows + .into_iter() + .map(ModuleFileNode::try_from) + .collect::>>()?; + items.retain(|file| { + query + .parent_id + .is_none_or(|parent_id| file.parent_id == Some(parent_id)) + }); + items.retain(|file| query.status.is_none_or(|status| file.status == status)); + items.retain(|file| query.kind.is_none_or(|kind| file.kind == kind)); + paginate(&items, query.limit, query.cursor.as_deref()) +} + +/// Create one module CDE file or folder in `PostgreSQL`. +/// +/// # Errors +/// Returns permission, validation, parent, or database errors. +pub async fn create_module_file( + pool: &PgPool, + context: &RequestContext, + module_id: &str, + req: CreateModuleFileRequest, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + let module_id = normalize_module_id(module_id) + .ok_or_else(|| HarnessError::NotFound(format!("module_id={module_id}")))?; + validate_required("file name", &req.name)?; + validate_module_file_parent(pool, context, module_id.as_str(), req.parent_id).await?; + + let now = Utc::now(); + let file_id = Uuid::new_v4(); + let owner = req.owner.unwrap_or_else(|| context.actor.clone()); + let tags = req.tags.unwrap_or_default(); + let file_content = req.content.unwrap_or_default(); + let row = ModuleFileRow { + id: Uuid::new_v4(), + tenant_id: context.tenant_id.clone(), + project_id: context.project_id.clone(), + file_id, + module_id: module_id.as_str().to_owned(), + parent_id: req.parent_id, + name: req.name, + kind: enum_to_db(req.kind)?, + status: enum_to_db(ModuleFileStatus::Active)?, + size_bytes: i64::try_from(req.size_bytes.unwrap_or(0)).map_err(|_| { + HarnessError::InvalidInput("file size does not fit signed database column".to_owned()) + })?, + mime_type: req.mime_type, + checksum: None, + version: 1, + owner, + tags: serde_json::to_string(&tags)?, + content: file_content, + created_at: now, + updated_at: now, + created_by: Some(context.actor.clone()), + }; + let mut tx = pool.begin().await?; + insert_module_file_row(&mut tx, &row).await?; + append_module_file_audit_tx( + &mut tx, + context, + &row, + AuditEventKind::FileCreated, + "module file created", + ) + .await?; + tx.commit().await?; + ModuleFileNode::try_from(row) +} + +/// Read one module CDE file visible to a context. +/// +/// # Errors +/// Returns permission, scope, not-found, enum, or database errors. +pub async fn get_module_file( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactRead)?; + get_module_file_row(pool, context, file_id) + .await + .and_then(ModuleFileNode::try_from) +} + +/// Update module CDE file metadata. +/// +/// # Errors +/// Returns permission, validation, scope, or database errors. +pub async fn update_module_file( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, + req: UpdateModuleFileRequest, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + if let Some(name) = req.name.as_deref() { + validate_required("file name", name)?; + } + let tags = req.tags.as_ref().map(serde_json::to_string).transpose()?; + let row = sqlx::query_as::<_, ModuleFileRow>( + r" + UPDATE module_files + SET name = COALESCE($4, name), + owner = COALESCE($5, owner), + tags = COALESCE($6::jsonb, tags), + mime_type = COALESCE($7, mime_type), + version = version + 1, + updated_at = $8 + WHERE tenant_id = $1 AND project_id = $2 AND file_id = $3 + RETURNING id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(file_id) + .bind(req.name) + .bind(req.owner) + .bind(tags) + .bind(req.mime_type) + .bind(Utc::now()) + .fetch_optional(pool) + .await? + .ok_or_else(|| HarnessError::NotFound(format!("file_id={file_id}")))?; + append_audit_event( + pool, + context, + module_file_audit_input( + context, + &row, + AuditEventKind::FileUpdated, + "module file updated", + ), + ) + .await?; + ModuleFileNode::try_from(row) +} + +/// Return metadata for one module CDE file. +/// +/// # Errors +/// Returns permission, scope, not-found, enum, or database errors. +pub async fn module_file_metadata( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, +) -> Result { + get_module_file(pool, context, file_id) + .await + .map(|file| file.metadata) +} + +/// Read small development content for one module CDE file. +/// +/// # Errors +/// Returns permission, scope, not-found, folder, enum, or database errors. +pub async fn module_file_content( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactRead)?; + let row = get_module_file_row(pool, context, file_id).await?; + if enum_from_db::(&row.kind, "module_file.kind")? != ModuleFileKind::File { + return Err(HarnessError::InvalidInput( + "folder nodes do not have content".to_owned(), + )); + } + Ok(FileContentResponse { + file_id, + content: row.content, + content_type: row.mime_type, + updated_at: row.updated_at, + }) +} + +/// Replace content for one module CDE file. +/// +/// # Errors +/// Returns permission, validation, scope, not-found, enum, or database errors. +pub async fn update_module_file_content( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, + req: UpdateFileContentRequest, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + let existing = get_module_file_row(pool, context, file_id).await?; + if enum_from_db::(&existing.kind, "module_file.kind")? != ModuleFileKind::File { + return Err(HarnessError::InvalidInput( + "folder nodes do not have content".to_owned(), + )); + } + let updated_at = Utc::now(); + let content_size = i64::try_from(req.content.len()).map_err(|_| { + HarnessError::InvalidInput("file content is too large for database metadata".to_owned()) + })?; + let row = sqlx::query_as::<_, ModuleFileRow>( + r" + UPDATE module_files + SET content = $4, + size_bytes = $5, + mime_type = COALESCE($6, mime_type), + version = version + 1, + updated_at = $7 + WHERE tenant_id = $1 AND project_id = $2 AND file_id = $3 + RETURNING id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(file_id) + .bind(req.content) + .bind(content_size) + .bind(req.content_type) + .bind(updated_at) + .fetch_optional(pool) + .await? + .ok_or_else(|| HarnessError::NotFound(format!("file_id={file_id}")))?; + append_audit_event( + pool, + context, + module_file_audit_input( + context, + &row, + AuditEventKind::FileContentUpdated, + "module file content updated", + ), + ) + .await?; + Ok(FileContentResponse { + file_id, + content: row.content, + content_type: row.mime_type, + updated_at: row.updated_at, + }) +} + +/// Move one module CDE file. +/// +/// # Errors +/// Returns permission, parent, scope, not-found, enum, or database errors. +pub async fn move_module_file( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, + req: MoveFileRequest, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + let existing = get_module_file_row(pool, context, file_id).await?; + validate_module_file_parent(pool, context, &existing.module_id, req.target_parent_id).await?; + let row = sqlx::query_as::<_, ModuleFileRow>( + r" + UPDATE module_files + SET parent_id = $4, + version = version + 1, + updated_at = $5 + WHERE tenant_id = $1 AND project_id = $2 AND file_id = $3 + RETURNING id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(file_id) + .bind(req.target_parent_id) + .bind(Utc::now()) + .fetch_optional(pool) + .await? + .ok_or_else(|| HarnessError::NotFound(format!("file_id={file_id}")))?; + append_audit_event( + pool, + context, + module_file_audit_input( + context, + &row, + AuditEventKind::FileMoved, + "module file moved", + ), + ) + .await?; + ModuleFileNode::try_from(row) +} + +/// Copy one module CDE file. +/// +/// # Errors +/// Returns permission, parent, scope, not-found, enum, or database errors. +pub async fn copy_module_file( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, + req: CopyFileRequest, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + let source = get_module_file_row(pool, context, file_id).await?; + let target_module_id = match req.target_module_id.as_deref() { + Some(module_id) => normalize_module_id(module_id) + .ok_or_else(|| HarnessError::NotFound(format!("module_id={module_id}")))?, + None => normalize_module_id(&source.module_id) + .ok_or_else(|| HarnessError::NotFound(source.module_id.clone()))?, + }; + validate_module_file_parent( + pool, + context, + target_module_id.as_str(), + req.target_parent_id, + ) + .await?; + let now = Utc::now(); + let row = ModuleFileRow { + id: Uuid::new_v4(), + tenant_id: context.tenant_id.clone(), + project_id: context.project_id.clone(), + file_id: Uuid::new_v4(), + module_id: target_module_id.as_str().to_owned(), + parent_id: req.target_parent_id, + name: req.name.unwrap_or_else(|| format!("{} copy", source.name)), + kind: source.kind, + status: enum_to_db(ModuleFileStatus::Active)?, + size_bytes: source.size_bytes, + mime_type: source.mime_type, + checksum: source.checksum, + version: 1, + owner: source.owner, + tags: source.tags, + content: source.content, + created_at: now, + updated_at: now, + created_by: Some(context.actor.clone()), + }; + let mut tx = pool.begin().await?; + insert_module_file_row(&mut tx, &row).await?; + append_module_file_audit_tx( + &mut tx, + context, + &row, + AuditEventKind::FileCopied, + "module file copied", + ) + .await?; + tx.commit().await?; + ModuleFileNode::try_from(row) +} + +/// Share one module CDE file. +/// +/// # Errors +/// Returns permission, scope, not-found, enum, or database errors. +pub async fn share_module_file( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, + req: ShareFileRequest, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + let row = sqlx::query_as::<_, ModuleFileRow>( + r" + UPDATE module_files + SET status = $4, + updated_at = $5 + WHERE tenant_id = $1 AND project_id = $2 AND file_id = $3 + RETURNING id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(file_id) + .bind(enum_to_db(ModuleFileStatus::Shared)?) + .bind(Utc::now()) + .fetch_optional(pool) + .await? + .ok_or_else(|| HarnessError::NotFound(format!("file_id={file_id}")))?; + append_audit_event( + pool, + context, + module_file_audit_input( + context, + &row, + AuditEventKind::FileShared, + "module file shared", + ), + ) + .await?; + Ok(ShareFileResponse { + file_id, + share_url: format!("/v1/files/{file_id}/shared/{}", Uuid::new_v4()), + permissions: req.permissions, + expires_at: req.expires_at, + }) +} + +/// Soft-delete one module CDE file. +/// +/// # Errors +/// Returns permission, scope, not-found, enum, or database errors. +pub async fn trash_module_file( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, +) -> Result { + PermissionGuard::ensure(context, RuntimePermission::ArtifactWrite)?; + let row = sqlx::query_as::<_, ModuleFileRow>( + r" + UPDATE module_files + SET status = $4, + updated_at = $5 + WHERE tenant_id = $1 AND project_id = $2 AND file_id = $3 + RETURNING id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(file_id) + .bind(enum_to_db(ModuleFileStatus::SoftDeleted)?) + .bind(Utc::now()) + .fetch_optional(pool) + .await? + .ok_or_else(|| HarnessError::NotFound(format!("file_id={file_id}")))?; + append_audit_event( + pool, + context, + module_file_audit_input( + context, + &row, + AuditEventKind::FileTrashed, + "module file trashed", + ), + ) + .await?; + ModuleFileNode::try_from(row) +} + /// Create an asset and its first version in `PostgreSQL`. /// /// # Errors @@ -1606,6 +2109,194 @@ fn object_store_url(bucket: &str, key: &str) -> String { format!("{endpoint}/{bucket}/{key}") } +async fn get_module_file_row( + pool: &PgPool, + context: &RequestContext, + file_id: Uuid, +) -> Result { + sqlx::query_as::<_, ModuleFileRow>( + r" + SELECT id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags::text AS tags, content, created_at, updated_at, created_by + FROM module_files + WHERE tenant_id = $1 AND project_id = $2 AND file_id = $3 + ", + ) + .bind(&context.tenant_id) + .bind(&context.project_id) + .bind(file_id) + .fetch_optional(pool) + .await? + .ok_or_else(|| HarnessError::NotFound(format!("file_id={file_id}"))) +} + +async fn validate_module_file_parent( + pool: &PgPool, + context: &RequestContext, + module_id: &str, + parent_id: Option, +) -> Result<()> { + let Some(parent_id) = parent_id else { + return Ok(()); + }; + let parent = get_module_file_row(pool, context, parent_id).await?; + if parent.module_id != module_id { + return Err(HarnessError::InvalidInput( + "parent folder must be in the same module".to_owned(), + )); + } + if enum_from_db::(&parent.kind, "module_file.kind")? != ModuleFileKind::Folder { + return Err(HarnessError::InvalidInput( + "parent must be a folder".to_owned(), + )); + } + Ok(()) +} + +async fn insert_module_file_row( + tx: &mut Transaction<'_, Postgres>, + row: &ModuleFileRow, +) -> Result<()> { + sqlx::query( + r" + INSERT INTO module_files + (id, tenant_id, project_id, file_id, module_id, parent_id, name, + kind, status, size_bytes, mime_type, checksum, version, owner, + tags, content, created_at, updated_at, created_by) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, + $15::jsonb, $16, $17, $18, $19) + ", + ) + .bind(row.id) + .bind(&row.tenant_id) + .bind(&row.project_id) + .bind(row.file_id) + .bind(&row.module_id) + .bind(row.parent_id) + .bind(&row.name) + .bind(&row.kind) + .bind(&row.status) + .bind(row.size_bytes) + .bind(&row.mime_type) + .bind(&row.checksum) + .bind(row.version) + .bind(&row.owner) + .bind(&row.tags) + .bind(&row.content) + .bind(row.created_at) + .bind(row.updated_at) + .bind(&row.created_by) + .execute(&mut **tx) + .await?; + Ok(()) +} + +fn module_file_audit_input( + context: &RequestContext, + row: &ModuleFileRow, + action: AuditEventKind, + summary: &str, +) -> AuditEventInput { + AuditEventInput { + module_id: row.module_id.clone(), + actor: context.actor.clone(), + action, + target_type: "file".to_owned(), + target_id: row.file_id.to_string(), + summary: summary.to_owned(), + metadata: json!({ + "name": row.name, + "kind": row.kind, + "status": row.status, + }), + } +} + +async fn append_module_file_audit_tx( + tx: &mut Transaction<'_, Postgres>, + context: &RequestContext, + row: &ModuleFileRow, + action: AuditEventKind, + summary: &str, +) -> Result { + append_audit_event_tx( + tx, + context, + module_file_audit_input(context, row, action, summary), + ) + .await +} + +#[derive(Debug, FromRow)] +struct ModuleFileRow { + id: Uuid, + tenant_id: String, + project_id: String, + file_id: Uuid, + module_id: String, + parent_id: Option, + name: String, + kind: String, + status: String, + size_bytes: i64, + mime_type: Option, + checksum: Option, + version: i32, + owner: String, + tags: String, + content: String, + created_at: DateTime, + updated_at: DateTime, + created_by: Option, +} + +impl TryFrom for ModuleFileNode { + type Error = HarnessError; + + fn try_from(row: ModuleFileRow) -> Result { + let tags = json_from_db(&row.tags, "module_file.tags")?; + let tags = tags + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item.as_str().map(ToOwned::to_owned)) + .collect::>() + }) + .unwrap_or_default(); + Ok(Self { + id: row.file_id, + module_id: row.module_id, + parent_id: row.parent_id, + name: row.name, + kind: enum_from_db::(&row.kind, "module_file.kind")?, + status: enum_from_db::(&row.status, "module_file.status")?, + metadata: ModuleFileMetadata { + size_bytes: u64::try_from(row.size_bytes).map_err(|_| { + HarnessError::InvalidInput(format!( + "invalid module file size {}", + row.size_bytes + )) + })?, + mime_type: row.mime_type, + checksum: row.checksum, + version: u32::try_from(row.version).map_err(|_| { + HarnessError::InvalidInput(format!( + "invalid module file version {}", + row.version + )) + })?, + owner: row.owner, + tags, + created_at: row.created_at, + updated_at: row.updated_at, + }, + }) + } +} + #[derive(Debug, FromRow)] struct AssetRow { id: Uuid, @@ -1897,6 +2588,7 @@ mod tests { use crate::{ asset_registry::{AssetKind, AssetStatus, ConversionOperation}, module_audit::AuditEventKind, + module_files::{ModuleFileKind, ModuleFileStatus}, runtime_execution::{RuntimeExecutionKind, RuntimeExecutionStatus}, }; @@ -1910,6 +2602,7 @@ mod tests { "asset_files", "object_store_bindings", "conversion_jobs", + "module_files", "runtime_executions", "audit_events", ] { @@ -1928,6 +2621,14 @@ mod tests { enum_to_db(ConversionOperation::OpenbimValidate).expect("conversion op"), "openbim_validate" ); + assert_eq!( + enum_to_db(ModuleFileKind::Folder).expect("module file kind"), + "folder" + ); + assert_eq!( + enum_to_db(ModuleFileStatus::SoftDeleted).expect("module file status"), + "soft_deleted" + ); assert_eq!( enum_to_db(RuntimeExecutionKind::AiCommandDraft).expect("runtime kind"), "ai_command_draft" diff --git a/04-backend/migration/src/m20260501000001_phase7_durable_runtime.rs b/04-backend/migration/src/m20260501000001_phase7_durable_runtime.rs index cfabc083..a4fe0276 100644 --- a/04-backend/migration/src/m20260501000001_phase7_durable_runtime.rs +++ b/04-backend/migration/src/m20260501000001_phase7_durable_runtime.rs @@ -220,6 +220,34 @@ impl MigrationTrait for Migration { ) .await?; + manager + .create_table( + Table::create() + .table(ModuleFiles::Table) + .if_not_exists() + .col(uuid_pk(ModuleFiles::Id)) + .col(uuid_col(ModuleFiles::TenantId)) + .col(uuid_col(ModuleFiles::ProjectId)) + .col(uuid_col(ModuleFiles::FileId)) + .col(text(ModuleFiles::ModuleId)) + .col(nullable_uuid_col(ModuleFiles::ParentId)) + .col(text(ModuleFiles::Name)) + .col(text(ModuleFiles::Kind)) + .col(text(ModuleFiles::Status)) + .col(big_integer_col(ModuleFiles::SizeBytes)) + .col(nullable_text(ModuleFiles::MimeType)) + .col(nullable_text(ModuleFiles::Checksum)) + .col(integer_col(ModuleFiles::Version)) + .col(text(ModuleFiles::Owner)) + .col(jsonb(ModuleFiles::Tags)) + .col(text(ModuleFiles::Content)) + .col(timestamp(ModuleFiles::CreatedAt)) + .col(timestamp(ModuleFiles::UpdatedAt)) + .col(nullable_text(ModuleFiles::CreatedBy)) + .to_owned(), + ) + .await?; + manager .create_table( Table::create() @@ -260,6 +288,14 @@ impl MigrationTrait for Migration { .to_owned(), ) .await?; + manager + .drop_table( + Table::drop() + .table(ModuleFiles::Table) + .if_exists() + .to_owned(), + ) + .await?; manager .drop_table( Table::drop() @@ -323,6 +359,13 @@ where ColumnDef::new(name).uuid().not_null().to_owned() } +fn nullable_uuid_col(name: T) -> ColumnDef +where + T: IntoIden, +{ + ColumnDef::new(name).uuid().null().to_owned() +} + fn text(name: T) -> ColumnDef where T: IntoIden, @@ -508,6 +551,30 @@ enum RuntimeExecutions { CreatedBy, } +#[derive(DeriveIden)] +enum ModuleFiles { + Table, + Id, + TenantId, + ProjectId, + FileId, + ModuleId, + ParentId, + Name, + Kind, + Status, + SizeBytes, + MimeType, + Checksum, + Version, + Owner, + Tags, + Content, + CreatedAt, + UpdatedAt, + CreatedBy, +} + #[derive(DeriveIden)] enum AuditEvents { Table, diff --git a/04-backend/scripts/smoke-production-readiness-all.sh b/04-backend/scripts/smoke-production-readiness-all.sh new file mode 100755 index 00000000..c9d02a19 --- /dev/null +++ b/04-backend/scripts/smoke-production-readiness-all.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "${REPO_ROOT}" + +export UV_CACHE_DIR="${UV_CACHE_DIR:-/tmp/architoken-uv-cache}" + +trap 'printf "smoke-production-readiness-all failed at line %s\n" "${LINENO}" >&2' ERR + +python3 tools/production_readiness_contract.py +python3 -m unittest \ + tools/test_production_readiness_contract.py \ + tools/test_phase8_load_evidence.py \ + tools/test_phase8_runtime_cluster_validation.py \ + tools/test_phase8_merge_load_evidence.py \ + tools/test_phase8_prometheus_snapshot.py + +( + cd 03-frontend + bun run typecheck + bun run lint + bun run test + bun run build +) + +( + cd 04-backend + cargo test --workspace + cargo clippy --workspace --all-targets -- -D warnings +) + +( + cd 04-backend/agent-orchestrator + if command -v uv >/dev/null 2>&1; then + uv run --extra dev pytest --cov=architoken_agent --cov-report=xml + else + PYTHONPATH=src python3 -m pytest + fi +) + +( + cd 06-workers + if python3 -c 'import pytest' >/dev/null 2>&1; then + python3 -m pytest + elif command -v uv >/dev/null 2>&1; then + PYTHONPATH=. uv run --no-project --with pytest pytest tests + else + python3 -m pytest + fi +) + +04-backend/scripts/smoke-phase8-production-readiness.sh +git diff --check + +printf 'ArchIToken repository production readiness gate passed\n' diff --git a/README.md b/README.md index a33bf108..c54b3451 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,22 @@ ArchIToken's first production target is a 520 ㎡ three-storey heavy-steel villa --- +## Production Readiness + +The full repository gate is: + +```bash +04-backend/scripts/smoke-production-readiness-all.sh +``` + +It runs the deterministic repository contract, frontend type/lint/test/build, +Rust tests and clippy, Python worker and agent tests, Phase 8 production +readiness smoke, and `git diff --check`. A release is not production-ready +until this passes and the environment-specific production smoke/load evidence +also passes against real deployed dependencies. + +--- + ## Contributing See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Read the Constitution first and open an RFC before touching core architecture. During active development, fast-moving ecosystems such as LangChain, LangGraph, OpenAI and Anthropic may use bounded compatible ranges; release, CI, deployment and production artifacts must remain reproducible through lockfiles, constraints files, image digests or explicit release tags. diff --git a/docs/25_PHASE8_PRODUCTION_GO_LIVE_100K_RUNBOOK.md b/docs/25_PHASE8_PRODUCTION_GO_LIVE_100K_RUNBOOK.md index e1d2a04b..0162f535 100644 --- a/docs/25_PHASE8_PRODUCTION_GO_LIVE_100K_RUNBOOK.md +++ b/docs/25_PHASE8_PRODUCTION_GO_LIVE_100K_RUNBOOK.md @@ -87,6 +87,7 @@ This runbook defines the first-day go-live baseline for 100,000 concurrent onlin Run the gates in order: ```bash +04-backend/scripts/smoke-production-readiness-all.sh 04-backend/scripts/smoke-phase8-production-readiness.sh 04-backend/scripts/smoke-phase8-realtime-readiness.sh 04-backend/scripts/certify-phase8-100k.sh smoke diff --git a/docs/ADAPTER_SOURCE_MAP.md b/docs/ADAPTER_SOURCE_MAP.md index ae25a18f..55915f24 100644 --- a/docs/ADAPTER_SOURCE_MAP.md +++ b/docs/ADAPTER_SOURCE_MAP.md @@ -267,7 +267,7 @@ Decision meanings: | --------------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | | PDF | `.pdf`, `.pdfa` | Browser PDF.js viewer from uploaded bytes; Docling/MinerU/MarkItDown/PaddleOCR for structure; Stirling-PDF/MuPDF/PDFium for edits/derivatives | Docling, MinerU, Stirling, MuPDF, OCR workers wired; native deps required | | Images | `.png`, `.jpg`, `.jpeg`, `.svg`, `.webp`, `.gif`, `.heic` | Browser image viewer from uploaded bytes; OpenCV metadata worker; ImageMagick/FFmpeg derivatives; PaddleOCR when configured | OpenCV/ImageMagick/PaddleOCR workers wired; native deps required | -| Video | `.mp4`, `.webm`, `.mov`, `.mkv` | Browser video viewer from uploaded bytes; FFmpeg transcode/reconstruction worker for derivatives | FFmpeg worker wired; native deps required | +| Video | `.mp4`, `.webm`, `.mov`, `.mkv`, `.avi` | Browser video viewer from uploaded bytes; FFmpeg transcode/reconstruction worker for derivatives | FFmpeg worker wired; native deps required | | Voice/audio | `.wav`, `.mp3`, `.m4a`, `.flac`, `.ogg`, `.aac` | Browser audio viewer from uploaded bytes; FFmpeg transcode; ASR/TTS/generation provider routes | FFmpeg worker wired; generation provider still external | | Office docs | `.docx`, `.doc`, `.odt`, `.rtf`, `.xlsx`, `.xls`, `.xlsm`, `.xlsb`, `.ods`, `.pptx`, `.ppt`, `.odp` | Backend-native Office parsing/editing route through LibreOffice/Collabora/ONLYOFFICE/Univer/Excelize, plus Docling/MarkItDown structure extraction; PDF is only an optional derivative, never a substitute for Office support | LibreOffice, Docling, MarkItDown workers wired; online editing service pending | | Open BIM | `.ifc`, `.ifczip`, `.idm`, `.ids`, `.bcf`, `.bcfzip` | IDM exchange manifest; IFC preview through web-ifc; IfcOpenShell worker; bSDD enrichment; IDS and buildingSMART Validate; BCF package parser | IFC/IDM/bSDD/BCF/IDS/Validate worker routes wired; native/service deps required | diff --git a/docs/FILE_TYPE_REGISTRY.md b/docs/FILE_TYPE_REGISTRY.md index 674981c8..c0465ce9 100644 --- a/docs/FILE_TYPE_REGISTRY.md +++ b/docs/FILE_TYPE_REGISTRY.md @@ -35,4 +35,4 @@ RVT/RFA, DWG, DGN, Navisworks, SketchUp, Rhino/Grasshopper, Tekla, SolidWorks, C The Vitest contract in `03-frontend/lib/file-type-registry.test.ts` verifies that every requested extension, exact file name, and logical file type is represented, and that every registry entry has all seven processing stages. -The Rust contract in `04-backend/harness-core/src/file_runtime_registry.rs` verifies that the priority backend engine set is mapped: DXF, DWG, RVT, STEL, STL, IGES/IGS, IFC, SKP, 3DM, USD, glTF, OBJ, FBX, Office, media, image, and PDF. Proprietary formats must route to explicit licensed adapters; unsupported native geometry must fail closed with source-file lightweight viewing instead of redraw or fake geometry. +The Rust contract in `04-backend/harness-core/src/file_runtime_registry.rs` verifies that the priority backend engine set is mapped: DXF, DWG, RVT, STEL, STL, IGES/IGS, IFC, SKP, 3DM, USD, glTF/GLB, OBJ, FBX, Office legacy/OOXML, audio, video, image, and PDF. Proprietary formats must route to explicit licensed adapters; unsupported native geometry must fail closed with source-file lightweight viewing instead of redraw or fake geometry. diff --git a/tools/production_readiness_contract.py b/tools/production_readiness_contract.py new file mode 100755 index 00000000..df3b4f83 --- /dev/null +++ b/tools/production_readiness_contract.py @@ -0,0 +1,507 @@ +#!/usr/bin/env python3 +"""Repository-level production readiness contract for ArchIToken. + +This is the fast, deterministic gate that runs before environment-specific +smoke tests. It checks that the repository still matches the production +architecture contract: one module registry, no retired active identities, no +legacy construction module ids, no generated artifacts tracked by Git, and a +complete production environment template. +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from pathlib import Path + + +ACTIVE_MODULE_IDS = [ + "marketing_service", + "planning_management", + "concept_design", + "standard_library", + "detailed_design", + "quantity_costing", + "material_logistics", + "production_manufacturing", + "construction_management", + "digital_twin", + "digital_archive", + "finance_hr", + "ai_center", + "settings_center", +] + +REQUIRED_PRODUCTION_ENV = [ + "ARCHITOKEN_PROFILE", + "DATABASE_URL", + "ARCHITOKEN_DATABASE__URL", + "NATS_URL", + "TEMPORAL_ADDRESS", + "S3_ENDPOINT", + "S3_PUBLIC_ENDPOINT", + "S3_ACCESS_KEY", + "S3_SECRET_KEY", + "S3_BUCKET", + "ARCHITOKEN_OBSERVABILITY__OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "ARCHITOKEN_AUTH__JWT_SECRET", + "ARCHITOKEN_AUTH__JWT_ISSUER", + "ARCHITOKEN_GENERATION__PROVIDER", + "ARCHITOKEN_GENERATION__TEXT_TO_BIM_URL", + "ARCHITOKEN_PHASE8_PGBOUNCER_REQUIRED", + "ARCHITOKEN_PHASE8_OBJECT_STORE_REQUIRED", + "ARCHITOKEN_PHASE8_OTEL_REQUIRED", + "ARCHITOKEN_PHASE8_MAX_REQUEST_BODY_BYTES", + "ARCHITOKEN_PHASE8_MAX_UPLOAD_BYTES", + "ARCHITOKEN_PHASE8_API_RPS_LIMIT", + "ARCHITOKEN_PHASE8_TENANT_RPS_LIMIT", + "ARCHITOKEN_PHASE8_ACTOR_RPS_LIMIT", + "ARCHITOKEN_PHASE8_MAX_CONCURRENT_UPLOADS_PER_TENANT", + "ARCHITOKEN_PHASE8_MAX_CONCURRENT_CONVERSION_JOBS_PER_TENANT", + "ARCHITOKEN_PHASE8_DB_POOL_MAX_CONNECTIONS", + "ARCHITOKEN_WORKER_SUBJECT", + "ARCHITOKEN_WORKER_RESULT_SUBJECT", + "ARCHITOKEN_WORKER_RESULT_TOKEN", + "IFCDB_AGENT_URL", + "IFCDB_AGENT_VERSION", +] + +REQUIRED_FILE_EXTENSIONS = { + "dxf", + "dwg", + "rvt", + "stel", + "stl", + "iges", + "igs", + "ifc", + "skp", + "3dm", + "usd", + "gltf", + "glb", + "obj", + "fbx", + "docx", + "doc", + "xlsx", + "xls", + "pptx", + "ppt", + "mp3", + "wav", + "m4a", + "flac", + "mp4", + "mkv", + "mov", + "avi", + "jpg", + "jpeg", + "png", + "webp", + "gif", + "pdf", +} + +LEGACY_CONSTRUCTION_PATTERNS = [ + re.compile(r"construction_supervision"), + re.compile(r"ConstructionSupervision"), + re.compile(r"CONSTRUCTION_SUPERVISION"), + re.compile(r"施工监理"), + re.compile(r"legacyConstruction"), + re.compile(r"construction_\s*['\"]?\+\s*['\"]?supervision"), + re.compile(r"construction_\$\{\s*['\"]supervision['\"]\s*\}"), + re.compile(r"concat!\(\s*\"construction_\"\s*,\s*\"supervision\"\s*\)"), +] + +LEGACY_SCAN_EXCLUDE_PATHS = { + "tools/production_readiness_contract.py", + "tools/test_production_readiness_contract.py", +} + +RETIRED_TRACKED_PATHS = { + "CLAUDE.md", + "docs/ZED-CLAUDE-CODE-SETUP.md", +} + +RETIRED_TRACKED_PREFIXES = ( + ".claude/", +) + +FORBIDDEN_TRACKED_PARTS = ( + "/node_modules/", + "/.next/", + "/target/", + "/coverage.xml", + "/cache.db", + "/test-results/", + "/playwright-report/", +) + +SHELL_SCRIPTS = [ + "04-backend/scripts/smoke-all.sh", + "04-backend/scripts/smoke-production-local.sh", + "04-backend/scripts/smoke-production-readiness-all.sh", + "04-backend/scripts/smoke-phase8-production-readiness.sh", + "04-backend/scripts/smoke-phase8-scale.sh", + "04-backend/scripts/smoke-phase8-realtime-readiness.sh", + "04-backend/scripts/certify-phase8-100k.sh", + "04-backend/scripts/validate-phase8-load-evidence.sh", + "04-backend/scripts/guard-proprietary-runtime.sh", +] + + +@dataclass(frozen=True) +class CheckResult: + name: str + errors: list[str] + + +def run_git(root: Path, args: Sequence[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", *args], + cwd=root, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + + +def tracked_files(root: Path) -> list[str]: + result = run_git(root, ["ls-files"]) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "git ls-files failed") + return [line for line in result.stdout.splitlines() if line.strip()] + + +def read_text(root: Path, path: str) -> str: + return (root / path).read_text(encoding="utf-8", errors="ignore") + + +def extract_string_list_after_marker(text: str, marker: str) -> list[str]: + marker_index = text.find(marker) + if marker_index < 0: + return [] + assignment_index = text.find("=", marker_index) + search_index = assignment_index if assignment_index >= 0 else marker_index + start = min( + index + for index in [ + text.find("[", search_index), + text.find("(", search_index), + ] + if index >= 0 + ) + opener = text[start] + closer = "]" if opener == "[" else ")" + depth = 0 + for index in range(start, len(text)): + char = text[index] + if char == opener: + depth += 1 + elif char == closer: + depth -= 1 + if depth == 0: + block = text[start : index + 1] + return re.findall(r"[\"'](\.?[a-z0-9_]+)[\"']", block) + return [] + + +def extract_openapi_module_ids(text: str) -> list[str]: + match = re.search( + r"\n\s+ModuleId:\n(?:.*\n){0,8}?\s+enum:\n(?P(?:\s+-\s+[a-z0-9_]+\n)+)", + text, + ) + if not match: + return [] + return [line.split("-", 1)[1].strip() for line in match.group("items").splitlines()] + + +def extract_env_keys(text: str) -> dict[str, str]: + keys: dict[str, str] = {} + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + keys[key.strip()] = value.strip() + return keys + + +def check_module_registries(root: Path) -> CheckResult: + sources = { + "frontend": extract_string_list_after_marker( + read_text(root, "03-frontend/lib/module-registry.ts"), + "export const activeModuleIds", + ), + "rust": extract_string_list_after_marker( + read_text(root, "04-backend/harness-core/src/module_registry.rs"), + "pub const ACTIVE_MODULE_IDS", + ), + "python": extract_string_list_after_marker( + read_text(root, "04-backend/agent-orchestrator/src/architoken_agent/state.py"), + "ACTIVE_MODULE_IDS", + ), + "openapi": extract_openapi_module_ids(read_text(root, "04-backend/openapi.yaml")), + } + errors: list[str] = [] + for source, ids in sources.items(): + if ids != ACTIVE_MODULE_IDS: + errors.append(f"{source} module ids drifted: {ids}") + + shared_dir = root / "04-backend/shared/src/modules" + shared_modules = sorted( + path.stem for path in shared_dir.glob("*.rs") if path.stem != "mod" + ) + if shared_modules != sorted(ACTIVE_MODULE_IDS): + errors.append(f"shared module files drifted: {shared_modules}") + + prompt_dir = root / "04-backend/agent-orchestrator/prompts" + prompt_modules = sorted(path.name for path in prompt_dir.iterdir() if path.is_dir()) + if prompt_modules != sorted(ACTIVE_MODULE_IDS): + errors.append(f"agent prompt dirs drifted: {prompt_modules}") + + return CheckResult("module registries", errors) + + +def check_legacy_construction_names(root: Path, files: list[str]) -> CheckResult: + errors: list[str] = [] + for path in files: + if path in LEGACY_SCAN_EXCLUDE_PATHS: + continue + if path.startswith("04-backend/target/"): + continue + text = read_text(root, path) + for pattern in LEGACY_CONSTRUCTION_PATTERNS: + if pattern.search(text): + errors.append(f"{path}: forbidden legacy construction marker {pattern.pattern}") + return CheckResult("legacy construction naming", errors) + + +def check_retired_identity_files(files: list[str]) -> CheckResult: + errors = [ + path + for path in files + if path in RETIRED_TRACKED_PATHS + or any(path.startswith(prefix) for prefix in RETIRED_TRACKED_PREFIXES) + ] + return CheckResult("retired active identity files", errors) + + +def check_generated_artifacts(files: list[str]) -> CheckResult: + normalized = [f"/{path}" for path in files] + errors = [ + path[1:] + for path in normalized + if any(forbidden in path for forbidden in FORBIDDEN_TRACKED_PARTS) + ] + return CheckResult("generated artifacts not tracked", errors) + + +def check_production_env(root: Path) -> CheckResult: + env = extract_env_keys(read_text(root, ".env.production.example")) + errors = [f"missing {key}" for key in REQUIRED_PRODUCTION_ENV if key not in env] + if env.get("ARCHITOKEN_PROFILE") != "production": + errors.append("ARCHITOKEN_PROFILE must be production in .env.production.example") + if env.get("IFCDB_AGENT_VERSION") not in {"v1.0.9", "1.0.9"}: + errors.append("IFCDB_AGENT_VERSION must pin v1.0.9") + for key in [ + "ARCHITOKEN_PHASE8_PGBOUNCER_REQUIRED", + "ARCHITOKEN_PHASE8_OBJECT_STORE_REQUIRED", + "ARCHITOKEN_PHASE8_OTEL_REQUIRED", + ]: + if env.get(key) != "true": + errors.append(f"{key} must be true") + return CheckResult("production environment template", errors) + + +def check_file_runtime_alignment(root: Path) -> CheckResult: + frontend = { + item.lstrip(".") + for item in extract_string_list_after_marker( + read_text(root, "03-frontend/lib/file-type-registry.ts"), + "export const requestedFileTypeExtensions", + ) + } + backend = set( + extract_string_list_after_marker( + read_text(root, "04-backend/harness-core/src/file_runtime_registry.rs"), + "pub const REQUESTED_ENGINE_EXTENSIONS", + ) + ) + errors: list[str] = [] + missing_frontend = sorted(REQUIRED_FILE_EXTENSIONS - frontend) + missing_backend = sorted(REQUIRED_FILE_EXTENSIONS - backend) + if missing_frontend: + errors.append(f"frontend requested extensions missing: {missing_frontend}") + if missing_backend: + errors.append(f"backend runtime extensions missing: {missing_backend}") + if backend - frontend: + errors.append(f"backend extensions absent from frontend registry request: {sorted(backend - frontend)}") + return CheckResult("file runtime alignment", errors) + + +def check_frontend_backend_cde_bridge(root: Path) -> CheckResult: + errors: list[str] = [] + required_sources = { + "03-frontend/lib/module-file-api-client.ts": [ + "backendRequest", + "mapBackendModuleFileNode", + "listModuleFiles", + "createModuleFile", + "updateModuleFile", + "moveModuleFile", + "shareModuleFile", + "trashModuleFile", + ], + "03-frontend/lib/module-backend-adapter.ts": [ + "replaceModuleFilesFromBackend", + "upsertModuleFileFromBackend", + "BackendModuleFileApiClient", + ], + "03-frontend/components/ModuleFileExplorer.tsx": [ + "moduleFileApiClient", + "replaceModuleFilesFromBackend", + "upsertModuleFileFromBackend", + ], + "03-frontend/lib/module-file-api-client.test.ts": [ + "maps backend CDE nodes", + "posts create and move operations", + ], + } + for path, markers in required_sources.items(): + source_path = root / path + if not source_path.exists(): + errors.append(f"missing {path}") + continue + text = read_text(root, path) + for marker in markers: + if marker not in text: + errors.append(f"{path}: missing {marker}") + return CheckResult("frontend backend CDE bridge", errors) + + +def check_backend_cde_persistence(root: Path) -> CheckResult: + errors: list[str] = [] + required_sources = { + "04-backend/harness-core/src/postgres_runtime_store.rs": [ + "CREATE TABLE IF NOT EXISTS module_files", + "pub async fn list_module_files", + "pub async fn create_module_file", + "pub async fn update_module_file", + "pub async fn move_module_file", + "pub async fn share_module_file", + "pub async fn trash_module_file", + ], + "04-backend/harness-core/src/bin/gateway.rs": [ + "postgres_runtime_store::list_module_files", + "postgres_runtime_store::create_module_file", + "postgres_runtime_store::update_module_file", + "postgres_runtime_store::move_module_file", + "postgres_runtime_store::trash_module_file", + "\"module_files\"", + ], + "04-backend/harness-core/src/durable_store.rs": ["\"module_files\""], + "04-backend/migration/src/m20260501000001_phase7_durable_runtime.rs": [ + "ModuleFiles::Table", + "ModuleFiles::FileId", + ], + } + for path, markers in required_sources.items(): + source_path = root / path + if not source_path.exists(): + errors.append(f"missing {path}") + continue + text = read_text(root, path) + for marker in markers: + if marker not in text: + errors.append(f"{path}: missing {marker}") + return CheckResult("backend CDE persistence", errors) + + +def check_shell_scripts(root: Path) -> CheckResult: + errors: list[str] = [] + for script in SHELL_SCRIPTS: + if not (root / script).exists(): + errors.append(f"missing script {script}") + continue + result = subprocess.run( + ["bash", "-n", script], + cwd=root, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + if result.returncode != 0: + errors.append(f"{script}: {result.stderr.strip()}") + return CheckResult("shell script syntax", errors) + + +def check_worktree(root: Path) -> CheckResult: + result = run_git(root, ["status", "--porcelain"]) + if result.returncode != 0: + return CheckResult("strict worktree", [result.stderr.strip()]) + errors = result.stdout.splitlines() + return CheckResult("strict worktree", errors) + + +def run_checks(root: Path, *, strict_worktree: bool = False) -> list[CheckResult]: + files = tracked_files(root) + checks: list[Callable[[], CheckResult]] = [ + lambda: check_module_registries(root), + lambda: check_legacy_construction_names(root, files), + lambda: check_retired_identity_files(files), + lambda: check_generated_artifacts(files), + lambda: check_production_env(root), + lambda: check_file_runtime_alignment(root), + lambda: check_frontend_backend_cde_bridge(root), + lambda: check_backend_cde_persistence(root), + lambda: check_shell_scripts(root), + ] + if strict_worktree: + checks.append(lambda: check_worktree(root)) + return [check() for check in checks] + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[1], + help="Repository root. Defaults to the parent of tools/.", + ) + parser.add_argument( + "--strict-worktree", + action="store_true", + help="Also fail when Git has uncommitted changes.", + ) + args = parser.parse_args(argv) + root = args.repo_root.resolve() + + results = run_checks(root, strict_worktree=args.strict_worktree) + failed = False + for result in results: + if result.errors: + failed = True + print(f"[fail] {result.name}", file=sys.stderr) + for error in result.errors: + print(f" - {error}", file=sys.stderr) + else: + print(f"[ok] {result.name}") + if failed: + return 1 + print("ArchIToken production readiness contract passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_production_readiness_contract.py b/tools/test_production_readiness_contract.py new file mode 100755 index 00000000..be7e1de9 --- /dev/null +++ b/tools/test_production_readiness_contract.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Tests for the repository-level production readiness contract.""" + +from __future__ import annotations + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from production_readiness_contract import ( # noqa: E402 + ACTIVE_MODULE_IDS, + REQUIRED_FILE_EXTENSIONS, + check_backend_cde_persistence, + check_file_runtime_alignment, + check_frontend_backend_cde_bridge, + check_legacy_construction_names, + check_module_registries, + check_production_env, +) + + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +class ProductionReadinessContractTests(unittest.TestCase): + def test_module_registries_match_active_contract(self) -> None: + result = check_module_registries(REPO_ROOT) + + self.assertEqual(result.errors, []) + self.assertEqual(len(ACTIVE_MODULE_IDS), 14) + + def test_production_env_template_contains_required_gates(self) -> None: + result = check_production_env(REPO_ROOT) + + self.assertEqual(result.errors, []) + + def test_file_runtime_alignment_covers_requested_formats(self) -> None: + result = check_file_runtime_alignment(REPO_ROOT) + + self.assertEqual(result.errors, []) + self.assertIn("glb", REQUIRED_FILE_EXTENSIONS) + self.assertIn("jpeg", REQUIRED_FILE_EXTENSIONS) + + def test_frontend_backend_cde_bridge_is_enforced(self) -> None: + result = check_frontend_backend_cde_bridge(REPO_ROOT) + + self.assertEqual(result.errors, []) + + def test_backend_cde_persistence_is_enforced(self) -> None: + result = check_backend_cde_persistence(REPO_ROOT) + + self.assertEqual(result.errors, []) + + def test_legacy_construction_scan_catches_obfuscated_alias(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "fixture.ts" + legacy = "const legacy = `construction_${'" + "supervision" + "'}`;\n" + path.write_text(legacy, encoding="utf-8") + + result = check_legacy_construction_names(root, ["fixture.ts"]) + + self.assertEqual(len(result.errors), 1) + self.assertIn("forbidden legacy construction marker", result.errors[0]) + + +if __name__ == "__main__": + unittest.main()