diff --git a/services/cowork-service/src/lib/config.ts b/services/cowork-service/src/lib/config.ts index f3ef2a93..0398999d 100644 --- a/services/cowork-service/src/lib/config.ts +++ b/services/cowork-service/src/lib/config.ts @@ -22,6 +22,9 @@ const envSchema = baseBackendConfigSchema.extend({ // Platform-service connection (for auth, flags, audit, etc.) PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'), + // Extraction-service connection (for AI-powered document extraction) + EXTRACTION_SERVICE_URL: z.string().default('http://localhost:4005'), + // Rust runtime IPC — path to the cowork-orchestrator binary RUST_RUNTIME_BIN: z.string().default('cowork-orchestrator'), RUST_RUNTIME_TIMEOUT_MS: z.coerce.number().default(300_000), diff --git a/services/cowork-service/src/modules/extraction/routes.ts b/services/cowork-service/src/modules/extraction/routes.ts new file mode 100644 index 00000000..63849e09 --- /dev/null +++ b/services/cowork-service/src/modules/extraction/routes.ts @@ -0,0 +1,62 @@ +/** + * Extraction proxy routes — forward to extraction-service (port 4005). + * + * Hybrid approach: the Rust skills server handles local document processing + * (PDF, xlsx, docx, pptx) inside the Docker sandbox, while extraction-service + * provides AI-powered entity extraction and text analysis. + * + * This proxy lets the Tauri desktop access extraction-service via cowork-service. + */ + +import type { FastifyInstance } from 'fastify'; +import { config } from '../../lib/config.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; + +export async function extractionRoutes(app: FastifyInstance) { + const extractionUrl = config.EXTRACTION_SERVICE_URL; + + // POST /api/extract — proxy to extraction-service POST /extract + app.post('/api/extract', async (req, reply) => { + try { + const res = await fetch(`${extractionUrl}/extract`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + body: JSON.stringify(req.body), + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Extraction service returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy extraction request'); + reply.code(502); + return { error: 'Extraction service unavailable' }; + } + }); + + // GET /api/extract/models — list available extraction models + app.get('/api/extract/models', async (req, reply) => { + try { + const res = await fetch(`${extractionUrl}/extract/models`, { + headers: { + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Extraction service returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy extraction models'); + reply.code(502); + return { error: 'Extraction service unavailable' }; + } + }); +} diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index ee231b02..070b03a4 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -34,6 +34,7 @@ vi.mock('./lib/config.js', () => ({ ANTHROPIC_API_KEY: undefined, OLLAMA_URL: 'http://localhost:11434/v1', OLLAMA_MODELS: undefined, + EXTRACTION_SERVICE_URL: 'http://localhost:4005', }, })); vi.mock('./lib/product-config.js', () => ({ @@ -73,6 +74,7 @@ vi.mock('./modules/llm/routes.js', () => ({ llmRoutes: vi.fn() })); vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() })); vi.mock('./modules/usage/routes.js', () => ({ usageRoutes: vi.fn() })); vi.mock('./modules/notifications/routes.js', () => ({ notificationRoutes: vi.fn() })); +vi.mock('./modules/extraction/routes.js', () => ({ extractionRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -92,9 +94,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // health + task + llm + audit + usage + notifications = 6 register calls + 1 JWT + // health + task + llm + audit + usage + notifications + extraction = 7 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(6); + expect(appMock.register).toHaveBeenCalledTimes(7); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' }); }); }); diff --git a/services/cowork-service/src/server.ts b/services/cowork-service/src/server.ts index 193aaeb4..3e2ae64a 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -28,6 +28,7 @@ import { llmRoutes } from './modules/llm/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; import { usageRoutes } from './modules/usage/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; +import { extractionRoutes } from './modules/extraction/routes.js'; import type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -60,6 +61,7 @@ await app.register(llmRoutes); await app.register(auditRoutes); await app.register(usageRoutes); await app.register(notificationRoutes); +await app.register(extractionRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({