diff --git a/services/cowork-service/src/modules/plugins/routes.test.ts b/services/cowork-service/src/modules/plugins/routes.test.ts new file mode 100644 index 00000000..9034f218 --- /dev/null +++ b/services/cowork-service/src/modules/plugins/routes.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; +import { pluginRoutes } from './routes.js'; +import { setIpcBridge } from '../../lib/ipc-bridge.js'; + +vi.mock('../../lib/product-config.js', () => ({ + PRODUCT_ID: 'clawcowork', + productConfig: { + backendPort: 4009, + }, +})); + +vi.mock('../../lib/request-context.js', () => ({ + getUserId: vi.fn(() => 'demo-user'), +})); + +let app: ReturnType; +const call = vi.fn(); + +beforeEach(async () => { + setIpcBridge({ + isRunning: true, + call, + } as any); + + app = Fastify({ logger: false }); + app.decorateRequest('jwtPayload', null); + await app.register(pluginRoutes); + call.mockReset(); +}); + +afterEach(async () => { + setIpcBridge(null); + await app.close(); +}); + +describe('plugin routes', () => { + it('lists plugins via IPC', async () => { + call.mockResolvedValue({ + result: { plugins: [{ name: 'alpha', version: '1.0.0' }] }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/plugins' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).plugins).toHaveLength(1); + expect(call).toHaveBeenCalledWith( + 'list_plugins', + expect.objectContaining({ + auth: expect.objectContaining({ userId: 'demo-user' }), + }) + ); + }); + + it('returns a single plugin via IPC', async () => { + call.mockResolvedValue({ + result: { plugin: { name: 'alpha', version: '1.0.0' } }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/plugins/alpha' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).plugin.name).toBe('alpha'); + }); + + it('maps missing plugin to 404', async () => { + call.mockResolvedValue({ + error: { code: -32001, message: 'not found' }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/plugins/missing' }); + expect(res.statusCode).toBe(404); + }); + + it('installs plugin via IPC', async () => { + call.mockResolvedValue({ + result: { installed: true, pluginId: 'alpha' }, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/plugins/install', + payload: { pluginId: 'alpha' }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).installed).toBe(true); + expect(call).toHaveBeenCalledWith( + 'install_plugin', + expect.objectContaining({ + pluginId: 'alpha', + }) + ); + }); + + it('surfaces install errors', async () => { + call.mockResolvedValue({ + error: { code: -32603, message: 'install failed' }, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/plugins/install', + payload: { pluginId: 'alpha' }, + }); + + expect(res.statusCode).toBe(400); + }); + + it('uninstalls plugin via IPC', async () => { + call.mockResolvedValue({ + result: { deleted: true, pluginId: 'alpha' }, + }); + + const res = await app.inject({ method: 'DELETE', url: '/api/plugins/alpha' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).deleted).toBe(true); + }); +}); diff --git a/services/cowork-service/src/modules/plugins/routes.ts b/services/cowork-service/src/modules/plugins/routes.ts new file mode 100644 index 00000000..a45b1a0b --- /dev/null +++ b/services/cowork-service/src/modules/plugins/routes.ts @@ -0,0 +1,74 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { BadRequestError, NotFoundError } from '@bytelyst/errors'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getUserId } from '../../lib/request-context.js'; +import { getIpcBridge } from '../../lib/ipc-bridge.js'; +import { InstallPluginSchema, PluginIdParamsSchema } from './types.js'; + +function buildAuth(req: FastifyRequest): Record { + const userId = getUserId(req); + const role = (req.jwtPayload as Record | undefined)?.role ?? 'user'; + return { userId, role, productId: PRODUCT_ID, isPlatformAuth: !!req.jwtPayload }; +} + +export async function pluginRoutes(app: FastifyInstance) { + const bridge = getIpcBridge(); + + app.get('/api/plugins', async req => { + if (!bridge.isRunning) { + return { plugins: [] }; + } + + const resp = await bridge.call('list_plugins', { auth: buildAuth(req) }); + if (resp.error) throw new BadRequestError(resp.error.message); + return resp.result; + }); + + app.get('/api/plugins/:id', async req => { + const parsed = PluginIdParamsSchema.safeParse(req.params); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new NotFoundError('Plugin not found'); + + const resp = await bridge.call('get_plugin', { + auth: buildAuth(req), + pluginId: parsed.data.id, + }); + if (resp.error) { + if (resp.error.code === -32001) throw new NotFoundError('Plugin not found'); + throw new BadRequestError(resp.error.message); + } + return resp.result; + }); + + app.post('/api/plugins/install', async req => { + const parsed = InstallPluginSchema.safeParse(req.body); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new BadRequestError('IPC bridge unavailable'); + + const resp = await bridge.call('install_plugin', { + auth: buildAuth(req), + pluginId: parsed.data.pluginId, + }); + if (resp.error) throw new BadRequestError(resp.error.message); + return resp.result; + }); + + app.delete('/api/plugins/:id', async req => { + const parsed = PluginIdParamsSchema.safeParse(req.params); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new NotFoundError('Plugin not found'); + + const resp = await bridge.call('uninstall_plugin', { + auth: buildAuth(req), + pluginId: parsed.data.id, + }); + if (resp.error) { + if (resp.error.code === -32001) throw new NotFoundError('Plugin not found'); + throw new BadRequestError(resp.error.message); + } + return resp.result; + }); +} diff --git a/services/cowork-service/src/modules/plugins/types.ts b/services/cowork-service/src/modules/plugins/types.ts new file mode 100644 index 00000000..ed07e495 --- /dev/null +++ b/services/cowork-service/src/modules/plugins/types.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const PluginIdParamsSchema = z.object({ + id: z.string().min(1).max(256), +}); + +export const InstallPluginSchema = z.object({ + pluginId: z.string().min(1).max(256), +}); diff --git a/services/cowork-service/src/modules/schedule/routes.test.ts b/services/cowork-service/src/modules/schedule/routes.test.ts new file mode 100644 index 00000000..ec3d4883 --- /dev/null +++ b/services/cowork-service/src/modules/schedule/routes.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; +import { scheduleRoutes } from './routes.js'; +import { setIpcBridge } from '../../lib/ipc-bridge.js'; + +vi.mock('../../lib/product-config.js', () => ({ + PRODUCT_ID: 'clawcowork', + productConfig: { + backendPort: 4009, + }, +})); + +vi.mock('../../lib/request-context.js', () => ({ + getUserId: vi.fn(() => 'demo-user'), +})); + +let app: ReturnType; +const call = vi.fn(); + +beforeEach(async () => { + setIpcBridge({ + isRunning: true, + call, + } as any); + + app = Fastify({ logger: false }); + app.decorateRequest('jwtPayload', null); + await app.register(scheduleRoutes); + call.mockReset(); +}); + +afterEach(async () => { + setIpcBridge(null); + await app.close(); +}); + +describe('schedule routes', () => { + it('lists schedules via IPC', async () => { + call.mockResolvedValue({ + result: { schedules: [{ id: 'sched-1', name: 'Daily sync' }] }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/schedule' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).schedules).toHaveLength(1); + }); + + it('creates schedule via IPC', async () => { + call.mockResolvedValue({ + result: { schedule: { id: 'sched-1', name: 'Daily sync' } }, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/schedule', + payload: { + name: 'Daily sync', + goal: 'Refresh the workspace', + folder: '/tmp/demo', + schedule: { type: 'interval', seconds: 3600 }, + }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).schedule.id).toBe('sched-1'); + expect(call).toHaveBeenCalledWith( + 'create_schedule', + expect.objectContaining({ + name: 'Daily sync', + }) + ); + }); + + it('updates schedule via IPC', async () => { + call.mockResolvedValue({ + result: { schedule: { id: 'sched-1', enabled: false } }, + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/schedule/sched-1', + payload: { enabled: false }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).schedule.enabled).toBe(false); + }); + + it('deletes schedule via IPC', async () => { + call.mockResolvedValue({ + result: { deleted: true, scheduleId: 'sched-1' }, + }); + + const res = await app.inject({ method: 'DELETE', url: '/api/schedule/sched-1' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).deleted).toBe(true); + }); + + it('pauses schedule via IPC', async () => { + call.mockResolvedValue({ + result: { schedule: { id: 'sched-1', enabled: false } }, + }); + + const res = await app.inject({ + method: 'POST', + url: '/api/schedule/sched-1/pause', + payload: { paused: true }, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).schedule.enabled).toBe(false); + expect(call).toHaveBeenCalledWith( + 'pause_schedule', + expect.objectContaining({ + scheduleId: 'sched-1', + paused: true, + }) + ); + }); + + it('maps missing schedules to 404', async () => { + call.mockResolvedValue({ + error: { code: -32001, message: 'not found' }, + }); + + const res = await app.inject({ + method: 'PATCH', + url: '/api/schedule/missing', + payload: { enabled: false }, + }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/services/cowork-service/src/modules/schedule/routes.ts b/services/cowork-service/src/modules/schedule/routes.ts new file mode 100644 index 00000000..fcd643ff --- /dev/null +++ b/services/cowork-service/src/modules/schedule/routes.ts @@ -0,0 +1,102 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { BadRequestError, NotFoundError } from '@bytelyst/errors'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getUserId } from '../../lib/request-context.js'; +import { getIpcBridge } from '../../lib/ipc-bridge.js'; +import { + CreateScheduleSchema, + PauseScheduleSchema, + ScheduleIdParamsSchema, + UpdateScheduleSchema, +} from './types.js'; + +function buildAuth(req: FastifyRequest): Record { + const userId = getUserId(req); + const role = (req.jwtPayload as Record | undefined)?.role ?? 'user'; + return { userId, role, productId: PRODUCT_ID, isPlatformAuth: !!req.jwtPayload }; +} + +function throwScheduleError(error: { code?: number; message: string }): never { + if (error.code === -32001) throw new NotFoundError('Schedule not found'); + throw new BadRequestError(error.message); +} + +export async function scheduleRoutes(app: FastifyInstance) { + const bridge = getIpcBridge(); + + app.get('/api/schedule', async req => { + if (!bridge.isRunning) { + return { schedules: [] }; + } + + const resp = await bridge.call('list_schedules', { auth: buildAuth(req) }); + if (resp.error) throwScheduleError(resp.error); + return resp.result; + }); + + app.post('/api/schedule', async req => { + const parsed = CreateScheduleSchema.safeParse(req.body); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new BadRequestError('IPC bridge unavailable'); + + const resp = await bridge.call('create_schedule', { + auth: buildAuth(req), + ...parsed.data, + }); + if (resp.error) throwScheduleError(resp.error); + return resp.result; + }); + + app.patch('/api/schedule/:id', async req => { + const params = ScheduleIdParamsSchema.safeParse(req.params); + if (!params.success) + throw new BadRequestError(params.error.issues.map(issue => issue.message).join('; ')); + + const body = UpdateScheduleSchema.safeParse(req.body); + if (!body.success) + throw new BadRequestError(body.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new NotFoundError('Schedule not found'); + + const resp = await bridge.call('update_schedule', { + auth: buildAuth(req), + scheduleId: params.data.id, + ...body.data, + }); + if (resp.error) throwScheduleError(resp.error); + return resp.result; + }); + + app.delete('/api/schedule/:id', async req => { + const parsed = ScheduleIdParamsSchema.safeParse(req.params); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new NotFoundError('Schedule not found'); + + const resp = await bridge.call('delete_schedule', { + auth: buildAuth(req), + scheduleId: parsed.data.id, + }); + if (resp.error) throwScheduleError(resp.error); + return resp.result; + }); + + app.post('/api/schedule/:id/pause', async req => { + const params = ScheduleIdParamsSchema.safeParse(req.params); + if (!params.success) + throw new BadRequestError(params.error.issues.map(issue => issue.message).join('; ')); + + const body = PauseScheduleSchema.safeParse(req.body ?? {}); + if (!body.success) + throw new BadRequestError(body.error.issues.map(issue => issue.message).join('; ')); + if (!bridge.isRunning) throw new NotFoundError('Schedule not found'); + + const resp = await bridge.call('pause_schedule', { + auth: buildAuth(req), + scheduleId: params.data.id, + paused: body.data.paused ?? true, + }); + if (resp.error) throwScheduleError(resp.error); + return resp.result; + }); +} diff --git a/services/cowork-service/src/modules/schedule/types.ts b/services/cowork-service/src/modules/schedule/types.ts new file mode 100644 index 00000000..7278e30e --- /dev/null +++ b/services/cowork-service/src/modules/schedule/types.ts @@ -0,0 +1,57 @@ +import { z } from 'zod'; + +const OnceScheduleSchema = z.object({ + type: z.literal('once'), + at: z.number().int().nonnegative(), +}); + +const IntervalScheduleSchema = z.object({ + type: z.literal('interval'), + seconds: z.number().int().positive(), +}); + +const CronScheduleSchema = z.object({ + type: z.literal('cron'), + expression: z.string().min(1).max(256), +}); + +const OnChangeScheduleSchema = z.object({ + type: z.literal('onchange'), + watch_patterns: z.array(z.string().min(1).max(512)).min(1), +}); + +export const ScheduleSchema = z.discriminatedUnion('type', [ + OnceScheduleSchema, + IntervalScheduleSchema, + CronScheduleSchema, + OnChangeScheduleSchema, +]); + +export const ScheduleIdParamsSchema = z.object({ + id: z.string().min(1).max(256), +}); + +export const CreateScheduleSchema = z.object({ + name: z.string().min(1).max(256), + goal: z.string().min(1).max(10_000), + folder: z.string().min(1).max(2048), + model: z.string().min(1).max(256).optional(), + schedule: ScheduleSchema, +}); + +export const UpdateScheduleSchema = z + .object({ + name: z.string().min(1).max(256).optional(), + goal: z.string().min(1).max(10_000).optional(), + folder: z.string().min(1).max(2048).optional(), + model: z.string().min(1).max(256).optional(), + enabled: z.boolean().optional(), + schedule: ScheduleSchema.optional(), + }) + .refine(value => Object.keys(value).length > 0, { + message: 'at least one field must be provided', + }); + +export const PauseScheduleSchema = z.object({ + paused: z.boolean().optional(), +}); diff --git a/services/cowork-service/src/modules/sessions/routes.test.ts b/services/cowork-service/src/modules/sessions/routes.test.ts new file mode 100644 index 00000000..1a839c5d --- /dev/null +++ b/services/cowork-service/src/modules/sessions/routes.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import Fastify from 'fastify'; +import { sessionRoutes } from './routes.js'; +import { setIpcBridge } from '../../lib/ipc-bridge.js'; + +vi.mock('../../lib/product-config.js', () => ({ + PRODUCT_ID: 'clawcowork', + productConfig: { + backendPort: 4009, + }, +})); + +vi.mock('../../lib/request-context.js', () => ({ + getUserId: vi.fn(() => 'demo-user'), +})); + +let app: ReturnType; +const call = vi.fn(); + +beforeEach(async () => { + setIpcBridge({ + isRunning: true, + call, + } as any); + + app = Fastify({ logger: false }); + app.decorateRequest('jwtPayload', null); + await app.register(sessionRoutes); + call.mockReset(); +}); + +afterEach(async () => { + setIpcBridge(null); + await app.close(); +}); + +describe('sessions routes', () => { + it('lists sessions via IPC', async () => { + call.mockResolvedValue({ + result: { sessions: [{ id: 'sess-1', messageCount: 3 }] }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/sessions' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).sessions).toHaveLength(1); + expect(call).toHaveBeenCalledWith( + 'list_sessions', + expect.objectContaining({ + auth: expect.objectContaining({ userId: 'demo-user' }), + }) + ); + }); + + it('returns a single session via IPC', async () => { + call.mockResolvedValue({ + result: { session: { id: 'sess-1', messages: [] } }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/sessions/sess-1' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).session.id).toBe('sess-1'); + }); + + it('maps not found errors for session fetch', async () => { + call.mockResolvedValue({ + error: { code: -32001, message: 'not found' }, + }); + + const res = await app.inject({ method: 'GET', url: '/api/sessions/missing' }); + expect(res.statusCode).toBe(404); + }); + + it('deletes a session via IPC', async () => { + call.mockResolvedValue({ + result: { deleted: true, sessionId: 'sess-1' }, + }); + + const res = await app.inject({ method: 'DELETE', url: '/api/sessions/sess-1' }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.payload).deleted).toBe(true); + }); +}); diff --git a/services/cowork-service/src/modules/sessions/routes.ts b/services/cowork-service/src/modules/sessions/routes.ts new file mode 100644 index 00000000..2e47c028 --- /dev/null +++ b/services/cowork-service/src/modules/sessions/routes.ts @@ -0,0 +1,64 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { BadRequestError, NotFoundError } from '@bytelyst/errors'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getUserId } from '../../lib/request-context.js'; +import { getIpcBridge } from '../../lib/ipc-bridge.js'; +import { SessionIdParamsSchema } from './types.js'; + +function buildAuth(req: FastifyRequest): Record { + const userId = getUserId(req); + const role = (req.jwtPayload as Record | undefined)?.role ?? 'user'; + return { userId, role, productId: PRODUCT_ID, isPlatformAuth: !!req.jwtPayload }; +} + +export async function sessionRoutes(app: FastifyInstance) { + const bridge = getIpcBridge(); + + app.get('/api/sessions', async req => { + if (!bridge.isRunning) { + return { sessions: [] }; + } + + const resp = await bridge.call('list_sessions', { auth: buildAuth(req) }); + if (resp.error) throw new BadRequestError(resp.error.message); + return resp.result; + }); + + app.get('/api/sessions/:id', async req => { + const parsed = SessionIdParamsSchema.safeParse(req.params); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + + if (!bridge.isRunning) throw new NotFoundError('Session not found'); + + const resp = await bridge.call('get_session', { + auth: buildAuth(req), + sessionId: parsed.data.id, + }); + + if (resp.error) { + if (resp.error.code === -32001) throw new NotFoundError('Session not found'); + throw new BadRequestError(resp.error.message); + } + return resp.result; + }); + + app.delete('/api/sessions/:id', async req => { + const parsed = SessionIdParamsSchema.safeParse(req.params); + if (!parsed.success) + throw new BadRequestError(parsed.error.issues.map(issue => issue.message).join('; ')); + + if (!bridge.isRunning) throw new NotFoundError('Session not found'); + + const resp = await bridge.call('delete_session', { + auth: buildAuth(req), + sessionId: parsed.data.id, + }); + + if (resp.error) { + if (resp.error.code === -32001) throw new NotFoundError('Session not found'); + throw new BadRequestError(resp.error.message); + } + return resp.result; + }); +} diff --git a/services/cowork-service/src/modules/sessions/types.ts b/services/cowork-service/src/modules/sessions/types.ts new file mode 100644 index 00000000..c88418cb --- /dev/null +++ b/services/cowork-service/src/modules/sessions/types.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const SessionIdParamsSchema = z.object({ + id: z.string().min(1).max(256), +}); + +export type SessionIdParams = z.infer; diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index 1e93f846..03f64057 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -53,7 +53,9 @@ vi.mock('./lib/request-context.js', () => ({ vi.mock('./lib/ipc-bridge.js', () => ({ getIpcBridge: vi.fn(() => ({ isRunning: false, - start: vi.fn(async () => { throw new Error('no binary in test'); }), + start: vi.fn(async () => { + throw new Error('no binary in test'); + }), shutdown: vi.fn(async () => undefined), onIncomingRequest: vi.fn(), })), @@ -77,6 +79,9 @@ 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() })); vi.mock('./modules/marketplace/routes.js', () => ({ marketplaceRoutes: vi.fn() })); +vi.mock('./modules/sessions/routes.js', () => ({ sessionRoutes: vi.fn() })); +vi.mock('./modules/plugins/routes.js', () => ({ pluginRoutes: vi.fn() })); +vi.mock('./modules/schedule/routes.js', () => ({ scheduleRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -96,9 +101,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // health + task + llm + audit + usage + notifications + extraction + marketplace = 8 register calls + 1 JWT + // health + task + llm + audit + usage + notifications + extraction + marketplace + sessions + plugins + schedule = 11 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(8); + expect(appMock.register).toHaveBeenCalledTimes(11); 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 1bc236cd..21d7aad7 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -30,6 +30,9 @@ import { usageRoutes } from './modules/usage/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; import { extractionRoutes } from './modules/extraction/routes.js'; import { marketplaceRoutes } from './modules/marketplace/routes.js'; +import { sessionRoutes } from './modules/sessions/routes.js'; +import { pluginRoutes } from './modules/plugins/routes.js'; +import { scheduleRoutes } from './modules/schedule/routes.js'; import type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -64,6 +67,9 @@ await app.register(usageRoutes); await app.register(notificationRoutes); await app.register(extractionRoutes); await app.register(marketplaceRoutes); +await app.register(sessionRoutes); +await app.register(pluginRoutes); +await app.register(scheduleRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({ @@ -84,12 +90,14 @@ try { // Initialize LLM router (best-effort — works without API keys in dev) // Set OLLAMA_MODELS=model1,model2 to add local Ollama as a provider. -const ollamaModels = config.OLLAMA_MODELS?.split(',').map(s => s.trim()).filter(Boolean); +const ollamaModels = config.OLLAMA_MODELS?.split(',') + .map(s => s.trim()) + .filter(Boolean); try { const llm = initLlmRouter({ ollamaModels, ollamaBaseUrl: config.OLLAMA_URL, - onTelemetry: (entry) => app.log.debug({ llmTelemetry: entry }, 'llm-router event'), + onTelemetry: entry => app.log.debug({ llmTelemetry: entry }, 'llm-router event'), }); app.log.info({ providers: llm.getProviders() }, 'LLM router initialized'); } catch (err) { @@ -109,7 +117,10 @@ bridge.onIncomingRequest(async (method, params) => { const startMs = Date.now(); const result = await getLlmRouter().chat({ - messages: messages.map(m => ({ role: m.role as 'system' | 'user' | 'assistant', content: m.content })), + messages: messages.map(m => ({ + role: m.role as 'system' | 'user' | 'assistant', + content: m.content, + })), model: (params.model as string) || undefined, temperature: (params.temperature as number) ?? undefined, max_tokens: (params.max_tokens as number) ?? undefined, @@ -118,23 +129,28 @@ bridge.onIncomingRequest(async (method, params) => { // Record spend for budget tracking (best-effort) const costUsd = (result as unknown as Record).costUsd as number | undefined; if (costUsd && params.auth && params.taskId) { - bridge.recordSpend( - result.model, - 0, // token counts not always available from router - 0, - costUsd, - params.auth as Record, - params.taskId as string, - ).catch((err) => app.log.warn({ err }, 'Failed to record LLM spend')); + bridge + .recordSpend( + result.model, + 0, // token counts not always available from router + 0, + costUsd, + params.auth as Record, + params.taskId as string + ) + .catch(err => app.log.warn({ err }, 'Failed to record LLM spend')); } - app.log.info({ - method: 'intercept_llm', - provider: result.provider, - model: result.model, - latencyMs: Date.now() - startMs, - attempts: result.attempts, - }, 'LLM interception completed'); + app.log.info( + { + method: 'intercept_llm', + provider: result.provider, + model: result.model, + latencyMs: Date.now() - startMs, + attempts: result.attempts, + }, + 'LLM interception completed' + ); return { response: result.response,