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:
parent
a87c533fd3
commit
19674c7ef7
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 |
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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
11456
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
17
services/cowork-service/src/lib/auth.ts
Normal file
17
services/cowork-service/src/lib/auth.ts
Normal 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 };
|
||||
@ -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);
|
||||
|
||||
28
services/cowork-service/src/lib/feature-flags.ts
Normal file
28
services/cowork-service/src/lib/feature-flags.ts
Normal 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;
|
||||
181
services/cowork-service/src/lib/ipc-bridge.test.ts
Normal file
181
services/cowork-service/src/lib/ipc-bridge.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
260
services/cowork-service/src/lib/ipc-bridge.ts
Normal file
260
services/cowork-service/src/lib/ipc-bridge.ts
Normal 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;
|
||||
}
|
||||
17
services/cowork-service/src/lib/product-config.ts
Normal file
17
services/cowork-service/src/lib/product-config.ts
Normal 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;
|
||||
23
services/cowork-service/src/lib/request-context.ts
Normal file
23
services/cowork-service/src/lib/request-context.ts
Normal 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');
|
||||
}
|
||||
8
services/cowork-service/src/lib/telemetry.ts
Normal file
8
services/cowork-service/src/lib/telemetry.ts
Normal 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;
|
||||
@ -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(),
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user