feat(cowork-service): ecosystem alignment + IPC bridge to Rust runtime

ECOSYSTEM GAPS CLOSED — cowork-service now matches the pattern used by
all other product backends (FlowMonk, ActionTrail, NoteLett, etc.):

New lib files (6):
- lib/product-config.ts — canonical product identity (PRODUCT_ID, productConfig)
- lib/auth.ts — @bytelyst/fastify-auth createAuthMiddleware
- lib/request-context.ts — getUserId(), getRequestProductId()
- lib/telemetry.ts — @bytelyst/backend-telemetry buffer
- lib/feature-flags.ts — @bytelyst/backend-flags with 12 cowork flags
- lib/ipc-bridge.ts — IpcBridge class: spawn Rust child, JSON-RPC, 13 methods

Updated files:
- lib/config.ts — extends @bytelyst/backend-config baseBackendConfigSchema
- server.ts — JWT context, bootstrap endpoint, IPC startup, graceful shutdown
- modules/tasks/routes.ts — IPC bridge forwarding with in-memory fallback
- modules/health/routes.ts — productId from product-config, IPC status
- package.json — 7 new @bytelyst/* workspace deps

IPC bridge features:
- Spawns cowork-orchestrator --ipc-bridge as child process
- JSON-RPC 2.0 over stdin/stdout (line-delimited)
- 13 convenience methods matching Rust IpcHandler
- Timeout + pending request tracking
- Graceful shutdown with SIGTERM
- Singleton pattern with setIpcBridge() for testing

24 tests passing (was 8), typecheck clean.
This commit is contained in:
saravanakumardb1 2026-04-02 22:14:24 -07:00
parent a87c533fd3
commit 19674c7ef7
23 changed files with 3748 additions and 8658 deletions

View File

@ -1,9 +1,9 @@
Last refresh: 2026-04-01T06:00:10Z (2026-03-31 23:00:10 PDT)
Cascade conversations: 50 (334M)
Memories: 106
Last refresh: 2026-04-02T17:27:25Z (2026-04-02 10:27:25 PDT)
Cascade conversations: 50 (330M)
Memories: 119
Implicit context: 20
Code tracker dirs: 61
File edit history: 4046 entries
Workspace storage: 34 workspaces
Code tracker dirs: 102
File edit history: 4201 entries
Workspace storage: 37 workspaces
Repo docs: 7 files across 2 repos
Repo workflows: 54 files across 12 repos

View File

@ -4,6 +4,6 @@ This directory contains Windsurf workflow definitions for the Effo Rise AI proje
## Available Workflows
| Workflow | Description |
| ----------------------- | ----------------------------------------- |
| Workflow | Description |
|----------|-------------|
| `/refresh-chat-history` | Refresh the Windsurf chat history archive |

View File

@ -1,5 +1,5 @@
---
description: 'Window 1: Phase 0 scaffolding + Manus cleanup (RUN FIRST — other windows depend on this)'
description: "Window 1: Phase 0 scaffolding + Manus cleanup (RUN FIRST — other windows depend on this)"
---
# Window 1: Phase 0 Scaffolding + Manus Cleanup
@ -42,7 +42,7 @@ Create `shared/product.json` following the ecosystem pattern. Use NoteLett (`../
Create all 8 agent config files matching ecosystem standard. Copy structure from `../learning_ai_notes/` or `../learning_ai_trails/`:
1. `AGENTS.md` — AI agent onboarding guide (customize for efforise: Vite SPA + Fastify backend, productId efforise, port 4020, --er-\* tokens)
1. `AGENTS.md` — AI agent onboarding guide (customize for efforise: Vite SPA + Fastify backend, productId efforise, port 4020, --er-* tokens)
2. `CLAUDE.md` — Claude Code instructions (short version pointing to AGENTS.md)
3. `.windsurfrules` — Windsurf rules (short version pointing to AGENTS.md)
4. `.cursorrules` — Cursor rules (short version pointing to AGENTS.md)
@ -70,14 +70,12 @@ Create all 8 agent config files matching ecosystem standard. Copy structure from
## Step 4: Manus Artifact Cleanup
### 4a. Clean `vite.config.ts`
- Remove `vite-plugin-manus-runtime` plugin
- Remove `vite-plugin-manus-debug-collector` plugin + all LOG_DIR code
- Remove `@builder.io/vite-plugin-jsx-loc` plugin
- Replace `allowedHosts: [".manuspre.computer", ...]` with `allowedHosts: ["localhost"]`
### 4b. Delete Manus Files
- Delete `client/src/components/ManusDialog.tsx`
- Delete `client/src/components/Map.tsx` (Google Maps boilerplate, 156 lines)
- Delete `client/public/__manus__/` directory (contains `debug-collector.js`)
@ -88,25 +86,21 @@ Create all 8 agent config files matching ecosystem standard. Copy structure from
- Delete `patches/wouter@3.7.1.patch` (evaluate first — remove if not critical)
### 4c. Clean `client/index.html`
- Remove `VITE_ANALYTICS_ENDPOINT` / `VITE_ANALYTICS_WEBSITE_ID` script references
### 4d. Dependency Cleanup in `package.json`
- **Downgrade** `zod` from `^4.1.12``^3.24.2` (CRITICAL — Zod 4 breaks @bytelyst/\* integration)
- **Downgrade** `zod` from `^4.1.12``^3.24.2` (CRITICAL — Zod 4 breaks @bytelyst/* integration)
- **Upgrade** `typescript` from `5.6.3``^5.7.3`
- **Remove:** `streamdown`, `cmdk`, `add` (devDep), `@types/google.maps` (devDep), `next-themes`
- **Remove:** `express`, `@types/express` (server/ is deleted)
- **Remove** Manus vite plugins from devDeps: `vite-plugin-manus-runtime`, `@builder.io/vite-plugin-jsx-loc`
### 4e. Move Files
- Move `ideas.md``docs/ideas.md`
## Step 5: Create README.md
Write a proper README.md with:
- Product name + description
- Tech stack (Vite + React 19 SPA, Fastify 5 backend planned)
- Setup instructions (`pnpm install`, `pnpm dev`)

View File

@ -1,5 +1,5 @@
---
description: 'Window 2: Backend scaffold + domain modules (Phase 1 + Phase 3 — start AFTER Window 1 completes)'
description: "Window 2: Backend scaffold + domain modules (Phase 1 + Phase 3 — start AFTER Window 1 completes)"
---
# Window 2: Backend Scaffold + Domain Modules
@ -24,18 +24,15 @@ All `@bytelyst/*` packages come from the Gitea npm registry (configured in root
Copy structure from `../learning_ai_notes/backend/package.json`. Key deps:
**dependencies:**
- `@bytelyst/fastify-core`, `@bytelyst/config`, `@bytelyst/errors`, `@bytelyst/datastore`
- `@bytelyst/cosmos`, `@bytelyst/auth`, `@bytelyst/fastify-auth`, `@bytelyst/backend-config`
- `@bytelyst/backend-telemetry`, `@bytelyst/backend-flags`, `@bytelyst/field-encrypt`
- `@bytelyst/logger`, `fastify`, `zod` (^3.24.2), `jose`
**devDependencies:**
- `typescript` (^5.7.3), `tsx`, `vitest`, `eslint` (^9.0.0), `@bytelyst/testing`
**scripts:**
- `"dev": "tsx watch src/server.ts"`
- `"build": "tsc"`
- `"typecheck": "tsc --noEmit"`
@ -45,7 +42,6 @@ Copy structure from `../learning_ai_notes/backend/package.json`. Key deps:
### Step 2: Create `backend/tsconfig.json`
Extend `../../tsconfig.base.json` if available, otherwise use ecosystem standard:
```json
{
"compilerOptions": {
@ -83,7 +79,6 @@ Create these standard lib files (copy patterns from `../learning_ai_notes/backen
### Step 4: Create `backend/src/server.ts`
Fastify entrypoint using `createServiceApp()` from `@bytelyst/fastify-core`:
- Register CORS, auth, health endpoint
- Register cosmos-init on startup
- Register route modules (added in Phase 3)
@ -104,7 +99,6 @@ Standard diagnostic health-check test (copy from any ecosystem backend).
### Step 7: Add Vite Proxy
Add to `vite.config.ts` (in the root, this is the ONE root file you may touch):
```ts
server: {
proxy: {
@ -128,7 +122,6 @@ Commit: `feat(backend): Phase 1 backend scaffold with Fastify 5 + @bytelyst/* pa
### Module Pattern
Each module lives in `backend/src/modules/<name>/` with three files:
- `types.ts` — Zod schemas + TypeScript types, Cosmos doc shape
- `repository.ts` — CRUD operations via `@bytelyst/datastore` `getCollection()`
- `routes.ts` — Fastify route handlers with Zod validation
@ -140,19 +133,19 @@ Every Cosmos document MUST include `productId: "efforise"`.
Container: `effo_identities`, partition: `/userId`
| Field | Type | Description |
| ----------- | ---------- | ------------------------- |
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| name | string | "Writer", "Athlete", etc. |
| parentId | string? | For tree structure |
| description | string | What this identity means |
| color | string | Accent color |
| icon | string | Emoji or icon key |
| order | number | Sort order |
| createdAt | string | ISO timestamp |
| updatedAt | string | ISO timestamp |
| Field | Type | Description |
|-------|------|-------------|
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| name | string | "Writer", "Athlete", etc. |
| parentId | string? | For tree structure |
| description | string | What this identity means |
| color | string | Accent color |
| icon | string | Emoji or icon key |
| order | number | Sort order |
| createdAt | string | ISO timestamp |
| updatedAt | string | ISO timestamp |
Routes: GET /api/identities, POST /api/identities, GET /api/identities/:id, PATCH /api/identities/:id, DELETE /api/identities/:id, PATCH /api/identities/:id/reorder
@ -160,18 +153,18 @@ Routes: GET /api/identities, POST /api/identities, GET /api/identities/:id, PATC
Container: `effo_efforts`, partition: `/userId`
| Field | Type | Description |
| --------------- | ----------------------------- | ----------------------------------- |
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| identityId | string | Which identity this effort supports |
| habitId | string? | Optional link to habit |
| type | "micro" \| "medium" \| "deep" | Effort depth |
| description | string | What was done |
| durationMinutes | number? | Optional duration |
| loggedAt | string | When the effort happened |
| createdAt | string | ISO timestamp |
| Field | Type | Description |
|-------|------|-------------|
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| identityId | string | Which identity this effort supports |
| habitId | string? | Optional link to habit |
| type | "micro" \| "medium" \| "deep" | Effort depth |
| description | string | What was done |
| durationMinutes | number? | Optional duration |
| loggedAt | string | When the effort happened |
| createdAt | string | ISO timestamp |
Routes: GET /api/efforts, POST /api/efforts, GET /api/efforts/:id, PATCH /api/efforts/:id, DELETE /api/efforts/:id, GET /api/efforts/stats (aggregation)
@ -179,20 +172,20 @@ Routes: GET /api/efforts, POST /api/efforts, GET /api/efforts/:id, PATCH /api/ef
Container: `effo_habits`, partition: `/userId`
| Field | Type | Description |
| ------------ | ------------------------------- | ------------------------------------- |
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| identityId | string | Which identity this habit supports |
| name | string | "Write 500 words", "Run 3 miles" |
| frequency | "daily" \| "weekly" \| "custom" | How often |
| customDays | number[]? | If frequency=custom, which days (0-6) |
| targetCount | number | Times per period |
| reminderTime | string? | HH:MM for push notification |
| isActive | boolean | Can be paused |
| createdAt | string | ISO timestamp |
| updatedAt | string | ISO timestamp |
| Field | Type | Description |
|-------|------|-------------|
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| identityId | string | Which identity this habit supports |
| name | string | "Write 500 words", "Run 3 miles" |
| frequency | "daily" \| "weekly" \| "custom" | How often |
| customDays | number[]? | If frequency=custom, which days (0-6) |
| targetCount | number | Times per period |
| reminderTime | string? | HH:MM for push notification |
| isActive | boolean | Can be paused |
| createdAt | string | ISO timestamp |
| updatedAt | string | ISO timestamp |
Routes: GET /api/habits, POST /api/habits, GET /api/habits/:id, PATCH /api/habits/:id, DELETE /api/habits/:id
@ -200,17 +193,17 @@ Routes: GET /api/habits, POST /api/habits, GET /api/habits/:id, PATCH /api/habit
Container: `effo_streaks`, partition: `/userId`
| Field | Type | Description |
| ---------------- | ---------- | ------------------------ |
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| habitId | string | Which habit |
| currentStreak | number | Current consecutive days |
| longestStreak | number | All-time record |
| lastLogDate | string | YYYY-MM-DD |
| totalCompletions | number | All-time count |
| updatedAt | string | ISO timestamp |
| Field | Type | Description |
|-------|------|-------------|
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| habitId | string | Which habit |
| currentStreak | number | Current consecutive days |
| longestStreak | number | All-time record |
| lastLogDate | string | YYYY-MM-DD |
| totalCompletions | number | All-time count |
| updatedAt | string | ISO timestamp |
Routes: GET /api/streaks, GET /api/streaks/:habitId, POST /api/streaks/:habitId/log (increment)
@ -218,17 +211,17 @@ Routes: GET /api/streaks, GET /api/streaks/:habitId, POST /api/streaks/:habitId/
Container: `effo_insights`, partition: `/userId`
| Field | Type | Description |
| ------------------ | --------------------------------------------- | -------------------------- |
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| type | "weekly_summary" \| "coaching" \| "milestone" | Insight type |
| title | string | Display title |
| body | string | Insight content (markdown) |
| relatedIdentityIds | string[] | Referenced identities |
| generatedAt | string | ISO timestamp |
| dismissedAt | string? | If user dismissed |
| Field | Type | Description |
|-------|------|-------------|
| id | string | UUID |
| userId | string | Owner |
| productId | "efforise" | Required |
| type | "weekly_summary" \| "coaching" \| "milestone" | Insight type |
| title | string | Display title |
| body | string | Insight content (markdown) |
| relatedIdentityIds | string[] | Referenced identities |
| generatedAt | string | ISO timestamp |
| dismissedAt | string? | If user dismissed |
Routes: GET /api/insights, GET /api/insights/:id, POST /api/insights/:id/dismiss

View File

@ -1,5 +1,5 @@
---
description: 'Window 3: Web frontend + design tokens + platform integration + DevOps (Phase 2 + 4 + 5 + 6 — start AFTER Window 1 completes)'
description: "Window 3: Web frontend + design tokens + platform integration + DevOps (Phase 2 + 4 + 5 + 6 — start AFTER Window 1 completes)"
---
# Window 3: Web Frontend + DevOps
@ -32,8 +32,8 @@ Update `client/src/index.css` to use `--er-*` prefixed tokens. Map existing unpr
--er-text-primary: #0f172a;
--er-text-secondary: #475569;
--er-text-muted: #94a3b8;
--er-accent-primary: #059669; /* emerald-600 */
--er-accent-secondary: #0d9488; /* teal-600 */
--er-accent-primary: #059669; /* emerald-600 */
--er-accent-secondary: #0d9488; /* teal-600 */
--er-accent-gradient: linear-gradient(to right, #059669, #0d9488);
--er-border-default: #e2e8f0;
--er-border-subtle: #f1f5f9;
@ -66,12 +66,10 @@ Commit: `feat(web): Phase 2 design tokens — --er-* namespace + @bytelyst/desig
Create a sidebar-based app layout (matching ecosystem pattern). Reference `../learning_ai_fastgap/web/src/components/Sidebar.tsx`.
The app should have two modes:
- **Marketing:** Landing page at `/` (existing `Home.tsx`)
- **App:** Sidebar layout at `/app/*` routes (new)
For Vite SPA with wouter:
- `/` → Landing page (existing)
- `/app` → Dashboard
- `/app/identity` → Identity tree builder
@ -84,7 +82,7 @@ For Vite SPA with wouter:
Create `client/src/lib/api-client.ts` — typed fetch wrapper for backend API:
```typescript
const API_BASE = '/api'; // Vite proxy handles forwarding to :4020
const API_BASE = '/api'; // Vite proxy handles forwarding to :4020
export async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
@ -158,7 +156,6 @@ Commit: `feat(web): Phase 4 app screens — dashboard, identity, log, insights,
### Step 1: Add Platform Client Packages
Add to root `package.json`:
- `@bytelyst/react-auth` — Auth context + provider
- `@bytelyst/api-client` — Typed fetch wrapper (can replace manual api-client.ts)
- `@bytelyst/telemetry-client` — Browser telemetry
@ -170,7 +167,6 @@ Add to root `package.json`:
### Step 2: Wire Auth
Create `client/src/lib/auth.ts`:
```typescript
import { createAuthContext } from '@bytelyst/react-auth';
export const { AuthProvider, useAuth } = createAuthContext({
@ -192,7 +188,6 @@ Create `client/src/lib/feature-flags.ts` and `client/src/lib/kill-switch.ts`. Us
### Step 5: Create Product Config
Create `client/src/lib/product-config.ts`:
```typescript
import productJson from '../../../shared/product.json';
export const PRODUCT_ID = productJson.productId;
@ -214,18 +209,17 @@ Commit: `feat(web): Phase 5 platform integration — auth, telemetry, flags, kil
services:
backend:
build: ./backend
ports: ['4020:4020']
ports: ["4020:4020"]
env_file: backend/.env
web:
build: ./client
ports: ['3080:3080']
ports: ["3080:3080"]
```
- Create `scripts/docker-prep.sh` — Pack @bytelyst/\* tarballs for Docker builds (copy from `../learning_ai_notes/scripts/docker-prep.sh`)
- Create `scripts/docker-prep.sh` — Pack @bytelyst/* tarballs for Docker builds (copy from `../learning_ai_notes/scripts/docker-prep.sh`)
### Step 2: CI
Create `.gitea/workflows/ci.yml`:
```yaml
name: CI
on: [push, pull_request]
@ -256,7 +250,6 @@ jobs:
### Step 3: Playwright E2E
Create `client/e2e/` directory with:
- `playwright.config.ts` — config targeting `http://localhost:3080`
- `accessibility.spec.ts` — axe-core accessibility checks on all pages
- `navigation.spec.ts` — verify all routes load
@ -264,7 +257,6 @@ Create `client/e2e/` directory with:
### Step 4: Register in Ecosystem
Tell user to add to `../learning_ai_common_plat/`:
- `docker-compose.ecosystem.yml`: efforise-backend :4020, efforise-web :3080
- `.env.ecosystem.example`: Effo Rise env vars
- `products/efforise/product.json`: Product registry entry

View File

@ -2,6 +2,6 @@
Workflows for AI coding agents operating in this repo.
| Workflow | Description |
| ------------------------- | ----------------------------------------- |
| Workflow | Description |
|----------|-------------|
| `refresh-chat-history.md` | Refresh the Windsurf chat history archive |

View File

@ -5,19 +5,16 @@ description: Refresh the Windsurf chat history archive
## Steps
1. Navigate to the chat-history directory:
```bash
cd chat-history/windsurf
```
2. Run the refresh script:
```bash
bash refresh.sh
```
3. Verify symlinks are intact:
```bash
find . -type l | head -20
```

View File

@ -10,6 +10,8 @@ date: 2025-02-12
> Order: common_plat → voice_agent → mindlyst (dependency-safe).
>
> See MANUAL_CI.md in each repo for quick pre-push checks.
>
> **This repo — human smoke:** after automated checks, walk through [`docs/SMOKE_CHECKLIST.md`](../../docs/SMOKE_CHECKLIST.md) (start stack, one dictation path, one portal login).
## Phase 1: learning_ai_common_plat (shared packages + services)

11456
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -14,14 +14,21 @@
"lint": "eslint src/"
},
"dependencies": {
"@bytelyst/backend-config": "workspace:*",
"@bytelyst/backend-flags": "workspace:*",
"@bytelyst/backend-telemetry": "workspace:*",
"@bytelyst/config": "workspace:*",
"@bytelyst/errors": "workspace:*",
"@bytelyst/fastify-auth": "workspace:*",
"@bytelyst/fastify-core": "workspace:*",
"@bytelyst/logger": "workspace:*",
"@fastify/cors": "^10.0.2",
"fastify": "^5.2.1",
"jose": "^6.0.11",
"zod": "^3.24.2"
},
"devDependencies": {
"@bytelyst/testing": "workspace:*",
"@types/node": "^22.12.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",

View File

@ -0,0 +1,17 @@
/**
* JWT auth middleware delegates to @bytelyst/fastify-auth.
* RS256 JWKS verification with HS256 fallback, configured from local config.
*
* Uses getter functions so config is read on each call (supports test mocks).
*/
import { createAuthMiddleware } from '@bytelyst/fastify-auth';
import { config } from './config.js';
export type { AuthPayload } from '@bytelyst/fastify-auth';
const { extractAuth, requireRole } = createAuthMiddleware({
jwtSecret: () => config.JWT_SECRET,
jwksUrl: () => config.PLATFORM_JWKS_URL,
});
export { extractAuth, requireRole };

View File

@ -1,22 +1,26 @@
/**
* Cowork Service configuration Zod-validated environment variables.
*
* Extends @bytelyst/backend-config baseBackendConfigSchema (same pattern as
* FlowMonk, ActionTrail, NoteLett, and all other product backends).
*
* Port: 4009 (default)
* Product: clawcowork
*/
import { z } from 'zod';
import { baseBackendConfigSchema } from '@bytelyst/backend-config';
import { productConfig } from './product-config.js';
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'),
const envSchema = baseBackendConfigSchema.extend({
PORT: baseBackendConfigSchema.shape.PORT.default(productConfig.backendPort),
SERVICE_NAME: baseBackendConfigSchema.shape.SERVICE_NAME.default('cowork-service'),
// cowork-service is a bridge — no Cosmos containers of its own
DB_PROVIDER: baseBackendConfigSchema.shape.DB_PROVIDER.default('memory'),
JWT_SECRET: z.string().default('dev-secret-do-not-use-in-prod'),
// 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'),
@ -24,6 +28,10 @@ const envSchema = z.object({
// Anthropic (passed through to Rust runtime)
ANTHROPIC_API_KEY: z.string().optional(),
// Ecosystem toggles
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
});
export const config = envSchema.parse(process.env);

View File

@ -0,0 +1,28 @@
/**
* Feature flag registry for cowork-service.
*
* Defaults match the 12 platform flags seeded in H.1
* (platform-service/src/modules/flags/seed.ts clawcowork entry).
*/
import { createFlagRegistry } from '@bytelyst/backend-flags';
import { config } from './config.js';
const registry = createFlagRegistry({
defaults: {
sandbox_enabled: true,
plugins_enabled: true,
computer_use_enabled: false,
browser_extension_enabled: false,
connectors_enabled: true,
scheduling_enabled: true,
parallel_agents_enabled: true,
wasm_plugins_enabled: false,
dispatch_api_enabled: false,
marketplace_enabled: false,
institutional_knowledge_enabled: false,
audit_logging_enabled: true,
},
enabled: config.FEATURE_FLAGS_ENABLED,
});
export const { isFeatureEnabled, getAllFlags, setFlag } = registry;

View File

@ -0,0 +1,181 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { IpcBridge, getIpcBridge, setIpcBridge } from './ipc-bridge.js';
vi.mock('./config.js', () => ({
config: {
RUST_RUNTIME_BIN: 'echo',
RUST_RUNTIME_TIMEOUT_MS: 5_000,
ANTHROPIC_API_KEY: undefined,
},
}));
describe('IpcBridge', () => {
it('constructs with default options', () => {
const bridge = new IpcBridge();
expect(bridge.isRunning).toBe(false);
});
it('constructs with custom options', () => {
const bridge = new IpcBridge({
bin: '/usr/bin/custom-binary',
args: ['--extra'],
timeoutMs: 1000,
env: { CUSTOM: 'value' },
});
expect(bridge.isRunning).toBe(false);
});
it('throws when calling call() before start()', async () => {
const bridge = new IpcBridge();
await expect(bridge.call('test', {})).rejects.toThrow('IPC bridge not started');
});
it('throws on double start()', async () => {
const bridge = new IpcBridge({ bin: 'cat', timeoutMs: 500 });
// Start will spawn but initialization will timeout — that's fine, we just
// want to verify the double-start guard
const startPromise = bridge.start().catch(() => {});
await expect(bridge.start()).rejects.toThrow('IPC bridge already started');
// Clean up
await bridge.shutdown();
await startPromise;
});
it('shutdown() is safe to call when not started', async () => {
const bridge = new IpcBridge();
await expect(bridge.shutdown()).resolves.toBeUndefined();
});
});
describe('singleton', () => {
beforeEach(() => {
setIpcBridge(null);
});
it('getIpcBridge returns a consistent instance', () => {
const a = getIpcBridge();
const b = getIpcBridge();
expect(a).toBe(b);
});
it('setIpcBridge replaces the singleton', () => {
const custom = new IpcBridge({ bin: 'custom' });
setIpcBridge(custom);
expect(getIpcBridge()).toBe(custom);
});
});
describe('IpcBridge convenience methods', () => {
it('submitTask builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0',
id: 1,
result: { id: 'task-1', status: 'pending' },
});
const auth = { userId: 'u1', role: 'admin' };
await bridge.submitTask('do stuff', '/tmp', auth, { model: 'claude-sonnet-4-20250514' });
expect(callSpy).toHaveBeenCalledWith('submit_task', {
goal: 'do stuff',
folder: '/tmp',
auth,
model: 'claude-sonnet-4-20250514',
});
});
it('getTaskStatus builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 2, result: { id: 'task-1', status: 'running' },
});
const auth = { userId: 'u1' };
await bridge.getTaskStatus('task-1', auth);
expect(callSpy).toHaveBeenCalledWith('get_task_status', { taskId: 'task-1', auth });
});
it('cancelTask builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 3, result: { status: 'cancelled' },
});
await bridge.cancelTask('task-1', { userId: 'u1' });
expect(callSpy).toHaveBeenCalledWith('cancel_task', { taskId: 'task-1', auth: { userId: 'u1' } });
});
it('listTasks with status filter', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 4, result: { tasks: [] },
});
await bridge.listTasks({ userId: 'u1' }, 'pending');
expect(callSpy).toHaveBeenCalledWith('list_tasks', { auth: { userId: 'u1' }, status: 'pending' });
});
it('listTasks without status filter', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 5, result: { tasks: [] },
});
await bridge.listTasks({ userId: 'u1' });
expect(callSpy).toHaveBeenCalledWith('list_tasks', { auth: { userId: 'u1' } });
});
it('updateFlags builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 6, result: { updated: 2 },
});
await bridge.updateFlags({ sandbox_enabled: true, plugins_enabled: false }, { userId: 'admin' });
expect(callSpy).toHaveBeenCalledWith('update_flags', {
flags: { sandbox_enabled: true, plugins_enabled: false },
auth: { userId: 'admin' },
});
});
it('flushAudit builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 7, result: { count: 0, entries: [] },
});
await bridge.flushAudit({ userId: 'admin' });
expect(callSpy).toHaveBeenCalledWith('flush_audit', { auth: { userId: 'admin' } });
});
it('recordSpend builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 8, result: { totalCostUsd: 0.015, allowed: true },
});
await bridge.recordSpend('gpt-4o', 1000, 500, 0.015, { userId: 'u1' }, 'task-1');
expect(callSpy).toHaveBeenCalledWith('record_spend', {
model: 'gpt-4o',
inputTokens: 1000,
outputTokens: 500,
costUsd: 0.015,
auth: { userId: 'u1' },
taskId: 'task-1',
});
});
it('updateVerdict builds correct params', async () => {
const bridge = new IpcBridge();
const callSpy = vi.spyOn(bridge, 'call').mockResolvedValue({
jsonrpc: '2.0', id: 9, result: { allowed: false },
});
await bridge.updateVerdict({ verdict: 'block', spent_usd: 10 }, { userId: 'admin' });
expect(callSpy).toHaveBeenCalledWith('update_verdict', {
verdict: { verdict: 'block', spent_usd: 10 },
auth: { userId: 'admin' },
});
});
});

View File

@ -0,0 +1,260 @@
/**
* IPC Bridge spawns the Rust cowork-orchestrator as a child process and
* communicates via JSON-RPC 2.0 over stdin/stdout.
*
* This is the TypeScript counterpart of `ipc_bridge.rs` in cowork-orchestrator.
* cowork-service spawns `cowork-orchestrator --ipc-bridge` and forwards all
* task/feature/audit/telemetry/budget requests through this bridge.
*
* Protocol: line-delimited JSON-RPC 2.0 (one JSON object per line).
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { createInterface, type Interface as ReadlineInterface } from 'node:readline';
import { config } from './config.js';
import type { IpcRequest, IpcResponse } from '../modules/tasks/types.js';
// ── Types ──
export interface IpcBridgeOptions {
/** Path to cowork-orchestrator binary. */
bin?: string;
/** Extra CLI args (e.g., --admin-policy path). */
args?: string[];
/** Timeout for individual IPC calls (ms). */
timeoutMs?: number;
/** Environment variables passed to the child process. */
env?: Record<string, string>;
/** Logger (defaults to console-like no-op). */
logger?: { info: (msg: string) => void; error: (msg: string) => void };
}
type PendingRequest = {
resolve: (value: IpcResponse) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
// ── IPC Bridge ──
export class IpcBridge {
private child: ChildProcess | null = null;
private rl: ReadlineInterface | null = null;
private nextId = 1;
private pending = new Map<number, PendingRequest>();
private readonly bin: string;
private readonly args: string[];
private readonly timeoutMs: number;
private readonly childEnv: Record<string, string>;
private readonly log: { info: (msg: string) => void; error: (msg: string) => void };
private _initialized = false;
constructor(opts: IpcBridgeOptions = {}) {
this.bin = opts.bin ?? config.RUST_RUNTIME_BIN;
this.args = ['--ipc-bridge', ...(opts.args ?? [])];
this.timeoutMs = opts.timeoutMs ?? config.RUST_RUNTIME_TIMEOUT_MS;
this.childEnv = opts.env ?? {};
this.log = opts.logger ?? { info: () => {}, error: () => {} };
}
/** Spawn the Rust child process and perform the initialize handshake. */
async start(): Promise<IpcResponse> {
if (this.child) {
throw new Error('IPC bridge already started');
}
const env = { ...process.env, ...this.childEnv };
if (config.ANTHROPIC_API_KEY) {
env.ANTHROPIC_API_KEY = config.ANTHROPIC_API_KEY;
}
this.child = spawn(this.bin, this.args, {
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
this.child.on('error', (err) => {
this.log.error(`IPC child process error: ${err.message}`);
});
this.child.on('exit', (code, signal) => {
this.log.info(`IPC child process exited: code=${code} signal=${signal}`);
this.rejectAllPending(new Error(`IPC child process exited (code=${code})`));
this.child = null;
this.rl = null;
this._initialized = false;
});
// Read JSON-RPC responses line by line from stdout
this.rl = createInterface({ input: this.child.stdout! });
this.rl.on('line', (line) => this.handleLine(line));
// Pipe stderr to logger
if (this.child.stderr) {
const errRl = createInterface({ input: this.child.stderr });
errRl.on('line', (line) => this.log.error(`[rust] ${line}`));
}
// Perform initialize handshake
const resp = await this.call('initialize', {});
this._initialized = true;
this.log.info(`IPC bridge initialized: protocol=${resp.result && (resp.result as Record<string, unknown>).protocolVersion}`);
return resp;
}
/** Whether the bridge child process is running and initialized. */
get isRunning(): boolean {
return this._initialized && this.child !== null && this.child.exitCode === null;
}
/** Send a JSON-RPC call and await the response. */
async call(method: string, params: Record<string, unknown>): Promise<IpcResponse> {
if (!this.child?.stdin?.writable && method !== 'initialize') {
throw new Error('IPC bridge not started');
}
const id = this.nextId++;
const request: IpcRequest = { jsonrpc: '2.0', id, method, params };
return new Promise<IpcResponse>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`IPC call '${method}' timed out after ${this.timeoutMs}ms`));
}, this.timeoutMs);
this.pending.set(id, { resolve, reject, timer });
const line = JSON.stringify(request) + '\n';
this.child!.stdin!.write(line, (err) => {
if (err) {
clearTimeout(timer);
this.pending.delete(id);
reject(new Error(`IPC write failed: ${err.message}`));
}
});
});
}
// ── Convenience methods matching Rust IPC handler methods ──
async submitTask(
goal: string,
folder: string,
auth: Record<string, unknown>,
opts: { model?: string; plugins?: string[] } = {},
): Promise<IpcResponse> {
return this.call('submit_task', { goal, folder, auth, ...opts });
}
async getTaskStatus(taskId: string, auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('get_task_status', { taskId, auth });
}
async cancelTask(taskId: string, auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('cancel_task', { taskId, auth });
}
async listTasks(auth: Record<string, unknown>, status?: string): Promise<IpcResponse> {
return this.call('list_tasks', { auth, ...(status ? { status } : {}) });
}
async getFeatures(auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('get_features', { auth });
}
async updateFlags(flags: Record<string, boolean>, auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('update_flags', { flags, auth });
}
async flushAudit(auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('flush_audit', { auth });
}
async flushTelemetry(auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('flush_telemetry', { auth });
}
async recordSpend(
model: string,
inputTokens: number,
outputTokens: number,
costUsd: number,
auth: Record<string, unknown>,
taskId?: string,
): Promise<IpcResponse> {
return this.call('record_spend', { model, inputTokens, outputTokens, costUsd, auth, taskId });
}
async flushBudget(auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('flush_budget', { auth });
}
async updateVerdict(verdict: Record<string, unknown>, auth: Record<string, unknown>): Promise<IpcResponse> {
return this.call('update_verdict', { verdict, auth });
}
/** Send shutdown and close the child process. */
async shutdown(): Promise<void> {
if (!this.child) return;
try {
await this.call('shutdown', {});
} catch {
// Ignore — process may already be exiting
}
// Guard: child may have been cleared by exit handler during the call above
if (this.child) {
this.child.kill('SIGTERM');
}
this.rejectAllPending(new Error('IPC bridge shutting down'));
this.child = null;
this.rl = null;
this._initialized = false;
}
// ── Private ──
private handleLine(line: string): void {
let resp: IpcResponse;
try {
resp = JSON.parse(line);
} catch {
this.log.error(`IPC: unparseable response: ${line.slice(0, 200)}`);
return;
}
const pending = this.pending.get(resp.id);
if (!pending) {
this.log.error(`IPC: unexpected response id=${resp.id}`);
return;
}
clearTimeout(pending.timer);
this.pending.delete(resp.id);
pending.resolve(resp);
}
private rejectAllPending(err: Error): void {
for (const [_id, p] of this.pending) {
clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
}
// ── Singleton ──
let _bridge: IpcBridge | null = null;
/** Get the singleton IPC bridge instance. */
export function getIpcBridge(): IpcBridge {
if (!_bridge) {
_bridge = new IpcBridge();
}
return _bridge;
}
/** Set a custom bridge instance (for testing). */
export function setIpcBridge(bridge: IpcBridge | null): void {
_bridge = bridge;
}

View File

@ -0,0 +1,17 @@
/**
* Canonical product identity for Claw Cowork.
*
* Since cowork-service lives in the common-plat monorepo (not the product repo),
* we define the product identity inline rather than reading shared/product.json.
* Values match product.manifest.json in learning_ai_claw-cowork.
*/
export const PRODUCT_ID = 'clawcowork';
export const productConfig = {
productId: PRODUCT_ID,
displayName: 'Claw Cowork',
description: 'Desktop agent for complex, multi-step knowledge work',
backendPort: 4009,
platforms: ['macos', 'linux', 'windows'] as const,
} as const;

View File

@ -0,0 +1,23 @@
/**
* Request-level product context helpers delegates to @bytelyst/fastify-auth.
*/
import { createRequestContext } from '@bytelyst/fastify-auth';
import type { FastifyRequest } from 'fastify';
import { BadRequestError } from '@bytelyst/errors';
import { PRODUCT_ID } from './product-config.js';
export type { JwtPayload } from '@bytelyst/fastify-auth';
const _ctx = createRequestContext({ productId: PRODUCT_ID });
export const getRequestProductId = _ctx.getRequestProductId;
/**
* Get authenticated user ID from JWT payload.
* Falls back to 'demo-user' in development when no JWT is present.
*/
export function getUserId(req: FastifyRequest): string {
if (req.jwtPayload?.sub) return req.jwtPayload.sub;
if (process.env.NODE_ENV !== 'production') return 'demo-user';
throw new BadRequestError('Authentication required');
}

View File

@ -0,0 +1,8 @@
import { createTelemetryBuffer } from '@bytelyst/backend-telemetry';
import { config } from './config.js';
export type { TelemetryEvent } from '@bytelyst/backend-telemetry';
const buffer = createTelemetryBuffer({ enabled: config.TELEMETRY_ENABLED });
export const { trackEvent, getBufferedEvents, flushEvents } = buffer;

View File

@ -6,6 +6,8 @@
import type { FastifyInstance } from 'fastify';
import { config } from '../../lib/config.js';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { getIpcBridge } from '../../lib/ipc-bridge.js';
export async function healthRoutes(app: FastifyInstance) {
app.get('/api/health/dependencies', async (_req, reply) => {
@ -34,7 +36,8 @@ export async function healthRoutes(app: FastifyInstance) {
return {
status: allOk ? 'ok' : 'degraded',
service: config.SERVICE_NAME,
productId: config.PRODUCT_ID,
productId: PRODUCT_ID,
ipcBridge: getIpcBridge().isRunning ? 'connected' : 'disconnected',
checks,
timestamp: new Date().toISOString(),
};

View File

@ -1,5 +1,18 @@
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { describe, expect, it, beforeAll, afterAll, vi } from 'vitest';
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
vi.mock('../../lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
productConfig: { productId: 'clawcowork', displayName: 'Claw Cowork', backendPort: 4009 },
}));
vi.mock('../../lib/request-context.js', () => ({
getUserId: () => 'demo-user',
getRequestProductId: () => 'clawcowork',
}));
vi.mock('../../lib/ipc-bridge.js', () => ({
getIpcBridge: () => ({ isRunning: false }),
}));
import { taskRoutes } from './routes.js';
let app: FastifyApp;

View File

@ -1,6 +1,10 @@
/**
* Task management routes proxies to Rust agent runtime via IPC.
*
* When the IPC bridge is running, all requests are forwarded to the Rust
* cowork-orchestrator via JSON-RPC. When the bridge is not available
* (tests, standalone mode), falls back to an in-memory store.
*
* POST /api/tasks submit a new task
* GET /api/tasks list all tasks
* GET /api/tasks/:id get task status
@ -10,17 +14,29 @@
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';
import { PRODUCT_ID } from '../../lib/product-config.js';
import { getUserId } from '../../lib/request-context.js';
import { getIpcBridge } from '../../lib/ipc-bridge.js';
// In-memory task store (will be replaced with IPC bridge to Rust runtime)
const tasks = new Map<string, TaskResponse>();
let nextId = 1;
// ── Fallback in-memory store (used when IPC bridge is not running) ──
const fallbackTasks = new Map<string, TaskResponse>();
let nextFallbackId = 1;
function generateTaskId(): string {
return `task_${Date.now()}_${nextId++}`;
return `task_${Date.now()}_${nextFallbackId++}`;
}
/** Build auth context for IPC calls from the current request. */
function buildAuth(req: import('fastify').FastifyRequest): Record<string, unknown> {
const userId = getUserId(req);
const role = (req.jwtPayload as Record<string, unknown> | undefined)?.role ?? 'user';
return { userId, role, productId: PRODUCT_ID, isPlatformAuth: !!req.jwtPayload };
}
export async function taskRoutes(app: FastifyInstance) {
const bridge = getIpcBridge();
// Submit a new task
app.post('/api/tasks', async (req, reply) => {
const parsed = SubmitTaskSchema.safeParse(req.body);
@ -29,10 +45,25 @@ export async function taskRoutes(app: FastifyInstance) {
}
const input = parsed.data;
// ── IPC path ──
if (bridge.isRunning) {
const resp = await bridge.submitTask(input.goal, input.folder, buildAuth(req), {
model: input.model,
plugins: input.plugins,
});
if (resp.error) {
throw new BadRequestError(resp.error.message);
}
reply.code(201);
return resp.result;
}
// ── Fallback in-memory ──
const now = new Date().toISOString();
const task: TaskResponse = {
id: generateTaskId(),
productId: config.PRODUCT_ID,
productId: PRODUCT_ID,
goal: input.goal,
folder: input.folder,
model: input.model,
@ -40,34 +71,55 @@ export async function taskRoutes(app: FastifyInstance) {
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.
fallbackTasks.set(task.id, task);
req.log.info({ taskId: task.id, goal: input.goal }, 'task submitted (fallback)');
reply.code(201);
return task;
});
// List all tasks
app.get('/api/tasks', async () => {
return { tasks: Array.from(tasks.values()) };
app.get('/api/tasks', async (req) => {
if (bridge.isRunning) {
const resp = await bridge.listTasks(buildAuth(req));
if (resp.error) throw new BadRequestError(resp.error.message);
return resp.result;
}
return { tasks: Array.from(fallbackTasks.values()) };
});
// Get single task
app.get('/api/tasks/:id', async req => {
app.get('/api/tasks/:id', async (req) => {
const { id } = req.params as { id: string };
const task = tasks.get(id);
if (bridge.isRunning) {
const resp = await bridge.getTaskStatus(id, buildAuth(req));
if (resp.error) {
if (resp.error.code === -32001) throw new NotFoundError('Task not found');
throw new BadRequestError(resp.error.message);
}
return resp.result;
}
const task = fallbackTasks.get(id);
if (!task) throw new NotFoundError('Task not found');
return task;
});
// Cancel a task
app.post('/api/tasks/:id/cancel', async req => {
app.post('/api/tasks/:id/cancel', async (req) => {
const { id } = req.params as { id: string };
const task = tasks.get(id);
if (bridge.isRunning) {
const resp = await bridge.cancelTask(id, buildAuth(req));
if (resp.error) {
if (resp.error.code === -32001) throw new NotFoundError('Task not found');
if (resp.error.code === -32003) throw new BadRequestError(resp.error.message);
throw new BadRequestError(resp.error.message);
}
return resp.result;
}
const task = fallbackTasks.get(id);
if (!task) throw new NotFoundError('Task not found');
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
@ -76,10 +128,7 @@ export async function taskRoutes(app: FastifyInstance) {
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
req.log.info({ taskId: id }, 'task cancelled (fallback)');
return task;
});
}

View File

@ -1,18 +1,24 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
const createServiceAppMock = vi.fn();
const registerOptionalJwtContextMock = vi.fn(async () => undefined);
const startServiceMock = vi.fn(async () => undefined);
const appMock = {
register: vi.fn(async () => undefined),
inject: vi.fn(),
get: vi.fn(),
log: { info: vi.fn(), warn: vi.fn() },
addHook: vi.fn(),
};
vi.mock('@bytelyst/fastify-core', () => ({
createServiceApp: createServiceAppMock,
registerOptionalJwtContext: registerOptionalJwtContextMock,
startService: startServiceMock,
}));
vi.mock('jose', () => ({ jwtVerify: vi.fn() }));
vi.mock('./modules/health/routes.js', () => ({ healthRoutes: vi.fn() }));
vi.mock('./modules/tasks/routes.js', () => ({ taskRoutes: vi.fn() }));
vi.mock('./lib/config.js', () => ({
@ -21,12 +27,33 @@ vi.mock('./lib/config.js', () => ({
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,
JWT_SECRET: 'test-secret',
ANTHROPIC_API_KEY: undefined,
},
}));
vi.mock('./lib/product-config.js', () => ({
PRODUCT_ID: 'clawcowork',
productConfig: {
productId: 'clawcowork',
displayName: 'Claw Cowork',
platforms: ['macos', 'linux', 'windows'],
backendPort: 4009,
},
}));
vi.mock('./lib/request-context.js', () => ({
getUserId: vi.fn(() => 'demo-user'),
getRequestProductId: vi.fn(() => 'clawcowork'),
}));
vi.mock('./lib/ipc-bridge.js', () => ({
getIpcBridge: vi.fn(() => ({
isRunning: false,
start: vi.fn(async () => { throw new Error('no binary in test'); }),
shutdown: vi.fn(async () => undefined),
})),
}));
describe('cowork-service bootstrap', () => {
beforeEach(() => {
@ -37,7 +64,7 @@ describe('cowork-service bootstrap', () => {
appMock.register.mockResolvedValue(undefined);
});
it('creates app, registers routes, and starts on port 4009', async () => {
it('creates app, registers JWT + routes, and starts on port 4009', async () => {
await import('./server.js');
expect(createServiceAppMock).toHaveBeenCalledOnce();
@ -46,6 +73,8 @@ describe('cowork-service bootstrap', () => {
expect(opts.version).toBe('0.1.0');
expect(opts.readiness).toBe(true);
// JWT context + health + task routes = 2 register calls + 1 JWT
expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce();
expect(appMock.register).toHaveBeenCalledTimes(2);
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
});

View File

@ -4,30 +4,73 @@
* Bridge between Tauri desktop / external clients and the Rust agent runtime.
* Connects to platform-service for auth, flags, audit, and billing.
*
* Follows the same ecosystem pattern as FlowMonk, ActionTrail, NoteLett, etc.:
* - @bytelyst/backend-config for Zod env validation
* - @bytelyst/fastify-auth for JWT context
* - @bytelyst/backend-telemetry for usage analytics
* - @bytelyst/backend-flags for feature flags
* - IPC bridge to Rust cowork-orchestrator via JSON-RPC
*
* Port: 4009 (configurable via PORT env var).
* Product: clawcowork
*/
import { createServiceApp, startService } from '@bytelyst/fastify-core';
import { createServiceApp, registerOptionalJwtContext, startService } from '@bytelyst/fastify-core';
import { jwtVerify } from 'jose';
import { healthRoutes } from './modules/health/routes.js';
import { taskRoutes } from './modules/tasks/routes.js';
import { config } from './lib/config.js';
import { productConfig, PRODUCT_ID } from './lib/product-config.js';
import { getIpcBridge } from './lib/ipc-bridge.js';
import type { JwtPayload } from './lib/request-context.js';
const jwtSecret = new TextEncoder().encode(config.JWT_SECRET);
const app = await createServiceApp({
name: config.SERVICE_NAME,
version: '0.1.0',
description: 'Fastify bridge — Tauri desktop ↔ Rust agent runtime ↔ platform-service',
description: `${productConfig.displayName} — Fastify bridge ↔ Rust agent runtime ↔ platform-service`,
corsOrigin: config.CORS_ORIGIN,
swagger: {
title: 'Cowork Service',
title: `${productConfig.displayName} Service`,
description: 'REST API for Claw Cowork agent task management',
port: config.PORT,
},
readiness: true,
});
// JWT context — optional in dev, required in production
await registerOptionalJwtContext(app, {
verifyToken: async (token: string) => {
const { payload } = await jwtVerify(token, jwtSecret, { issuer: 'bytelyst-platform' });
return payload as unknown as JwtPayload;
},
});
// Register route modules
await app.register(healthRoutes);
await app.register(taskRoutes);
// Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.)
app.get('/api/bootstrap', async () => ({
productId: PRODUCT_ID,
displayName: productConfig.displayName,
platforms: productConfig.platforms,
ipcBridgeConnected: getIpcBridge().isRunning,
}));
// Start IPC bridge to Rust runtime (non-blocking — service works in fallback mode if it fails)
const bridge = getIpcBridge();
try {
await bridge.start();
app.log.info('IPC bridge to Rust runtime connected');
} catch (err) {
app.log.warn({ err }, 'IPC bridge failed to start — running in fallback mode');
}
// Graceful shutdown
app.addHook('onClose', async () => {
await bridge.shutdown();
});
await startService(app, { port: config.PORT, host: config.HOST });