feat(cowork-service): scaffold Fastify bridge + seed clawcowork feature flags (H.1 + H.2)

H.1: Product registration
- Added 12 clawcowork feature flags to platform-service flags/seed.ts
  (sandbox, plugins, mcp, scheduling, computer_use, parallel_agents,
   marketplace, wasm, llm_multi_model, audit, platform_auth, dispatch_api)

H.2: cowork-service scaffold (services/cowork-service/)
- @lysnrai/cowork-service on port 4009, productId clawcowork
- createServiceApp + startService from @bytelyst/fastify-core
- Modules: health (dependency check), tasks (submit/list/get/cancel)
- Zod-validated config, Swagger, readiness endpoint
- 8 tests passing (1 bootstrap + 7 task routes), typecheck clean
This commit is contained in:
saravanakumardb1 2026-04-02 20:39:22 -07:00
parent af605a6e7d
commit a87c533fd3
12 changed files with 9114 additions and 2976 deletions

11548
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,30 @@
{
"name": "@lysnrai/cowork-service",
"version": "0.1.0",
"private": true,
"description": "Cowork Service — Fastify bridge between Tauri desktop / clients and Rust agent runtime + platform-service",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"test": "vitest run --pool forks",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"@bytelyst/config": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-core": "workspace:*",
"@fastify/cors": "^10.0.2",
"fastify": "^5.2.1",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

View File

@ -0,0 +1,29 @@
/**
* Cowork Service configuration Zod-validated environment variables.
*
* Port: 4009 (default)
* Product: clawcowork
*/
import { z } from 'zod';
const envSchema = z.object({
PORT: z.coerce.number().default(4009),
HOST: z.string().default('0.0.0.0'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
CORS_ORIGIN: z.string().optional(),
SERVICE_NAME: z.string().default('cowork-service'),
// Platform-service connection (for auth, flags, audit, etc.)
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),
PRODUCT_ID: z.string().default('clawcowork'),
// 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),
// Anthropic (passed through to Rust runtime)
ANTHROPIC_API_KEY: z.string().optional(),
});
export const config = envSchema.parse(process.env);

View File

@ -0,0 +1,42 @@
/**
* Health & dependency check routes for cowork-service.
*
* GET /api/health/dependencies checks platform-service + Rust runtime reachability.
*/
import type { FastifyInstance } from 'fastify';
import { config } from '../../lib/config.js';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health/dependencies', async (_req, reply) => {
const checks: Record<string, { status: string; latencyMs?: number; error?: string }> = {};
// Check platform-service
const psStart = Date.now();
try {
const res = await fetch(`${config.PLATFORM_SERVICE_URL}/health`, {
signal: AbortSignal.timeout(5_000),
});
checks['platform-service'] = {
status: res.ok ? 'ok' : 'degraded',
latencyMs: Date.now() - psStart,
};
} catch (err) {
checks['platform-service'] = {
status: 'unreachable',
latencyMs: Date.now() - psStart,
error: err instanceof Error ? err.message : 'unknown',
};
}
const allOk = Object.values(checks).every(c => c.status === 'ok');
reply.code(allOk ? 200 : 503);
return {
status: allOk ? 'ok' : 'degraded',
service: config.SERVICE_NAME,
productId: config.PRODUCT_ID,
checks,
timestamp: new Date().toISOString(),
};
});
}

View File

@ -0,0 +1,107 @@
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
import { taskRoutes } from './routes.js';
let app: FastifyApp;
beforeAll(async () => {
app = await createServiceApp({
name: 'cowork-test',
version: '0.0.1',
logger: false,
});
await app.register(taskRoutes);
});
afterAll(async () => {
await app.close();
});
describe('task routes', () => {
it('POST /api/tasks — submits a task and returns 201', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/tasks',
payload: {
goal: 'List all files and create a summary',
folder: '/tmp/test-folder',
},
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.payload);
expect(body.id).toMatch(/^task_/);
expect(body.productId).toBe('clawcowork');
expect(body.goal).toBe('List all files and create a summary');
expect(body.folder).toBe('/tmp/test-folder');
expect(body.status).toBe('pending');
expect(body.model).toBe('claude-sonnet-4-20250514');
});
it('POST /api/tasks — rejects empty goal', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/tasks',
payload: { goal: '', folder: '/tmp' },
});
expect(res.statusCode).toBe(400);
});
it('GET /api/tasks — lists submitted tasks', async () => {
const res = await app.inject({ method: 'GET', url: '/api/tasks' });
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.payload);
expect(body.tasks).toBeInstanceOf(Array);
expect(body.tasks.length).toBeGreaterThanOrEqual(1);
});
it('GET /api/tasks/:id — returns a specific task', async () => {
// Submit first
const submitRes = await app.inject({
method: 'POST',
url: '/api/tasks',
payload: { goal: 'Get task test', folder: '/tmp' },
});
const { id } = JSON.parse(submitRes.payload);
const res = await app.inject({ method: 'GET', url: `/api/tasks/${id}` });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.payload).id).toBe(id);
});
it('GET /api/tasks/:id — returns 404 for unknown task', async () => {
const res = await app.inject({ method: 'GET', url: '/api/tasks/nonexistent' });
expect(res.statusCode).toBe(404);
});
it('POST /api/tasks/:id/cancel — cancels a pending task', async () => {
const submitRes = await app.inject({
method: 'POST',
url: '/api/tasks',
payload: { goal: 'Cancel me', folder: '/tmp' },
});
const { id } = JSON.parse(submitRes.payload);
const cancelRes = await app.inject({
method: 'POST',
url: `/api/tasks/${id}/cancel`,
});
expect(cancelRes.statusCode).toBe(200);
expect(JSON.parse(cancelRes.payload).status).toBe('cancelled');
});
it('POST /api/tasks/:id/cancel — rejects cancel on already cancelled task', async () => {
const submitRes = await app.inject({
method: 'POST',
url: '/api/tasks',
payload: { goal: 'Double cancel', folder: '/tmp' },
});
const { id } = JSON.parse(submitRes.payload);
await app.inject({ method: 'POST', url: `/api/tasks/${id}/cancel` });
const res = await app.inject({ method: 'POST', url: `/api/tasks/${id}/cancel` });
expect(res.statusCode).toBe(400);
});
});

View File

@ -0,0 +1,85 @@
/**
* Task management routes proxies to Rust agent runtime via IPC.
*
* POST /api/tasks submit a new task
* GET /api/tasks list all tasks
* GET /api/tasks/:id get task status
* POST /api/tasks/:id/cancel cancel a running task
*/
import type { FastifyInstance } from 'fastify';
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import { SubmitTaskSchema, type TaskResponse, type TaskStatus } from './types.js';
import { config } from '../../lib/config.js';
// In-memory task store (will be replaced with IPC bridge to Rust runtime)
const tasks = new Map<string, TaskResponse>();
let nextId = 1;
function generateTaskId(): string {
return `task_${Date.now()}_${nextId++}`;
}
export async function taskRoutes(app: FastifyInstance) {
// Submit a new task
app.post('/api/tasks', async (req, reply) => {
const parsed = SubmitTaskSchema.safeParse(req.body);
if (!parsed.success) {
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
}
const input = parsed.data;
const now = new Date().toISOString();
const task: TaskResponse = {
id: generateTaskId(),
productId: config.PRODUCT_ID,
goal: input.goal,
folder: input.folder,
model: input.model,
status: 'pending',
createdAt: now,
updatedAt: now,
};
tasks.set(task.id, task);
req.log.info({ taskId: task.id, goal: input.goal }, 'task submitted');
// TODO(H.2): Spawn Rust runtime child process and send IPC submit_task
// For now, tasks stay in 'pending' state until IPC bridge is wired.
reply.code(201);
return task;
});
// List all tasks
app.get('/api/tasks', async () => {
return { tasks: Array.from(tasks.values()) };
});
// Get single task
app.get('/api/tasks/:id', async req => {
const { id } = req.params as { id: string };
const task = tasks.get(id);
if (!task) throw new NotFoundError('Task not found');
return task;
});
// Cancel a task
app.post('/api/tasks/:id/cancel', async req => {
const { id } = req.params as { id: string };
const task = tasks.get(id);
if (!task) throw new NotFoundError('Task not found');
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
throw new BadRequestError(`Cannot cancel task in '${task.status}' state`);
}
task.status = 'cancelled' as TaskStatus;
task.updatedAt = new Date().toISOString();
req.log.info({ taskId: id }, 'task cancelled');
// TODO(H.2): Send IPC cancel_task to Rust runtime
return task;
});
}

View File

@ -0,0 +1,61 @@
/**
* Task submission and status types for cowork-service.
*
* These mirror the Rust DispatchState types and are used for
* the REST API that proxies to the Rust agent runtime via IPC.
*/
import { z } from 'zod';
// ── Request schemas ──
export const SubmitTaskSchema = z.object({
goal: z.string().min(1).max(10_000),
folder: z.string().min(1).max(1024),
model: z.string().max(128).default('claude-sonnet-4-20250514'),
plugins: z.array(z.string().max(128)).max(50).default([]),
contextFiles: z.array(z.string().max(1024)).max(100).default([]),
budget: z
.object({
maxCostUsd: z.number().min(0).max(1000).default(5),
warnThreshold: z.number().min(0).max(1).default(0.8),
})
.optional(),
});
export type SubmitTaskInput = z.infer<typeof SubmitTaskSchema>;
// ── Response types ──
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export interface TaskResponse {
id: string;
productId: string;
goal: string;
folder: string;
model: string;
status: TaskStatus;
createdAt: string;
updatedAt: string;
result?: string;
error?: string;
costUsd?: number;
tokenCount?: number;
}
// ── IPC message types (JSON-RPC to Rust runtime) ──
export interface IpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params: Record<string, unknown>;
}
export interface IpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}

View File

@ -0,0 +1,52 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
const createServiceAppMock = vi.fn();
const startServiceMock = vi.fn(async () => undefined);
const appMock = {
register: vi.fn(async () => undefined),
inject: vi.fn(),
};
vi.mock('@bytelyst/fastify-core', () => ({
createServiceApp: createServiceAppMock,
startService: startServiceMock,
}));
vi.mock('./modules/health/routes.js', () => ({ healthRoutes: vi.fn() }));
vi.mock('./modules/tasks/routes.js', () => ({ taskRoutes: vi.fn() }));
vi.mock('./lib/config.js', () => ({
config: {
PORT: 4009,
HOST: '0.0.0.0',
CORS_ORIGIN: undefined,
SERVICE_NAME: 'cowork-service',
PRODUCT_ID: 'clawcowork',
PLATFORM_SERVICE_URL: 'http://localhost:4003',
RUST_RUNTIME_BIN: 'cowork-orchestrator',
RUST_RUNTIME_TIMEOUT_MS: 300_000,
},
}));
describe('cowork-service bootstrap', () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
createServiceAppMock.mockResolvedValue(appMock);
appMock.register.mockReset();
appMock.register.mockResolvedValue(undefined);
});
it('creates app, registers routes, and starts on port 4009', async () => {
await import('./server.js');
expect(createServiceAppMock).toHaveBeenCalledOnce();
const opts = createServiceAppMock.mock.calls[0][0];
expect(opts.name).toBe('cowork-service');
expect(opts.version).toBe('0.1.0');
expect(opts.readiness).toBe(true);
expect(appMock.register).toHaveBeenCalledTimes(2);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
});
});

View File

@ -0,0 +1,33 @@
/**
* Cowork Service Fastify server entry point.
*
* Bridge between Tauri desktop / external clients and the Rust agent runtime.
* Connects to platform-service for auth, flags, audit, and billing.
*
* Port: 4009 (configurable via PORT env var).
* Product: clawcowork
*/
import { createServiceApp, startService } from '@bytelyst/fastify-core';
import { healthRoutes } from './modules/health/routes.js';
import { taskRoutes } from './modules/tasks/routes.js';
import { config } from './lib/config.js';
const app = await createServiceApp({
name: config.SERVICE_NAME,
version: '0.1.0',
description: 'Fastify bridge — Tauri desktop ↔ Rust agent runtime ↔ platform-service',
corsOrigin: config.CORS_ORIGIN,
swagger: {
title: 'Cowork Service',
description: 'REST API for Claw Cowork agent task management',
port: config.PORT,
},
readiness: true,
});
// Register route modules
await app.register(healthRoutes);
await app.register(taskRoutes);
await startService(app, { port: config.PORT, host: config.HOST });

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
passWithNoTests: true,
pool: 'forks',
},
});

View File

@ -353,6 +353,92 @@ const PRODUCT_FLAGS: Record<string, FlagSeedDef[]> = {
percentage: 100,
},
],
clawcowork: [
{
key: 'sandbox_enabled',
enabled: true,
description: 'Docker sandbox for agent task execution',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'plugins_enabled',
enabled: true,
description: 'Plugin system (load, execute, manage plugins)',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'mcp_connectors_enabled',
enabled: true,
description: 'MCP connector integration (Google Drive, Gmail, Slack, etc.)',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'scheduling_enabled',
enabled: true,
description: 'Task scheduler (cron, interval, file-watch triggers)',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'computer_use_enabled',
enabled: false,
description: 'Computer Use — screenshot, click, type automation (requires explicit opt-in)',
platforms: ['macos'],
percentage: 0,
},
{
key: 'parallel_agents_enabled',
enabled: true,
description: 'Parallel sub-agent execution for complex tasks',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'marketplace_enabled',
enabled: true,
description: 'Plugin marketplace (browse, install, update)',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'wasm_plugins_enabled',
enabled: false,
description: 'WASM plugin runtime via wasmtime/wasmer (experimental)',
platforms: ['macos', 'linux'],
percentage: 0,
},
{
key: 'llm_multi_model_enabled',
enabled: false,
description: 'Multi-model LLM routing (planner/executor/reviewer role assignment)',
platforms: [],
percentage: 0,
},
{
key: 'audit_logging_enabled',
enabled: true,
description: 'Agent action audit trail',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
{
key: 'platform_auth_required',
enabled: false,
description: 'Require platform JWT auth — when false, offline API key fallback allowed',
platforms: [],
percentage: 0,
},
{
key: 'dispatch_api_enabled',
enabled: true,
description: 'Remote task dispatch REST API + WebSocket streaming',
platforms: ['macos', 'linux', 'windows'],
percentage: 100,
},
],
};
// ─── Seed function ───────────────────────────────────────────────────────────