feat(cowork-service): scaffold Fastify bridge + seed clawcowork feature flags (H.1 + H.2)
H.1: Product registration - Added 12 clawcowork feature flags to platform-service flags/seed.ts (sandbox, plugins, mcp, scheduling, computer_use, parallel_agents, marketplace, wasm, llm_multi_model, audit, platform_auth, dispatch_api) H.2: cowork-service scaffold (services/cowork-service/) - @lysnrai/cowork-service on port 4009, productId clawcowork - createServiceApp + startService from @bytelyst/fastify-core - Modules: health (dependency check), tasks (submit/list/get/cancel) - Zod-validated config, Swagger, readiness endpoint - 8 tests passing (1 bootstrap + 7 task routes), typecheck clean
This commit is contained in:
parent
af605a6e7d
commit
a87c533fd3
11548
pnpm-lock.yaml
generated
11548
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
30
services/cowork-service/package.json
Normal file
30
services/cowork-service/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@lysnrai/cowork-service",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Cowork Service — Fastify bridge between Tauri desktop / clients and Rust agent runtime + platform-service",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run --pool forks",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
"@bytelyst/fastify-core": "workspace:*",
|
||||
"@fastify/cors": "^10.0.2",
|
||||
"fastify": "^5.2.1",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.12.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
29
services/cowork-service/src/lib/config.ts
Normal file
29
services/cowork-service/src/lib/config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Cowork Service configuration — Zod-validated environment variables.
|
||||
*
|
||||
* Port: 4009 (default)
|
||||
* Product: clawcowork
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(4009),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
CORS_ORIGIN: z.string().optional(),
|
||||
SERVICE_NAME: z.string().default('cowork-service'),
|
||||
|
||||
// Platform-service connection (for auth, flags, audit, etc.)
|
||||
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),
|
||||
PRODUCT_ID: z.string().default('clawcowork'),
|
||||
|
||||
// Rust runtime IPC — path to the cowork-orchestrator binary
|
||||
RUST_RUNTIME_BIN: z.string().default('cowork-orchestrator'),
|
||||
RUST_RUNTIME_TIMEOUT_MS: z.coerce.number().default(300_000),
|
||||
|
||||
// Anthropic (passed through to Rust runtime)
|
||||
ANTHROPIC_API_KEY: z.string().optional(),
|
||||
});
|
||||
|
||||
export const config = envSchema.parse(process.env);
|
||||
42
services/cowork-service/src/modules/health/routes.ts
Normal file
42
services/cowork-service/src/modules/health/routes.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Health & dependency check routes for cowork-service.
|
||||
*
|
||||
* GET /api/health/dependencies — checks platform-service + Rust runtime reachability.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { config } from '../../lib/config.js';
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
app.get('/api/health/dependencies', async (_req, reply) => {
|
||||
const checks: Record<string, { status: string; latencyMs?: number; error?: string }> = {};
|
||||
|
||||
// Check platform-service
|
||||
const psStart = Date.now();
|
||||
try {
|
||||
const res = await fetch(`${config.PLATFORM_SERVICE_URL}/health`, {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
});
|
||||
checks['platform-service'] = {
|
||||
status: res.ok ? 'ok' : 'degraded',
|
||||
latencyMs: Date.now() - psStart,
|
||||
};
|
||||
} catch (err) {
|
||||
checks['platform-service'] = {
|
||||
status: 'unreachable',
|
||||
latencyMs: Date.now() - psStart,
|
||||
error: err instanceof Error ? err.message : 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
const allOk = Object.values(checks).every(c => c.status === 'ok');
|
||||
reply.code(allOk ? 200 : 503);
|
||||
return {
|
||||
status: allOk ? 'ok' : 'degraded',
|
||||
service: config.SERVICE_NAME,
|
||||
productId: config.PRODUCT_ID,
|
||||
checks,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
107
services/cowork-service/src/modules/tasks/routes.test.ts
Normal file
107
services/cowork-service/src/modules/tasks/routes.test.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
|
||||
import { createServiceApp, type FastifyApp } from '@bytelyst/fastify-core';
|
||||
import { taskRoutes } from './routes.js';
|
||||
|
||||
let app: FastifyApp;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createServiceApp({
|
||||
name: 'cowork-test',
|
||||
version: '0.0.1',
|
||||
logger: false,
|
||||
});
|
||||
await app.register(taskRoutes);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('task routes', () => {
|
||||
it('POST /api/tasks — submits a task and returns 201', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
payload: {
|
||||
goal: 'List all files and create a summary',
|
||||
folder: '/tmp/test-folder',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.id).toMatch(/^task_/);
|
||||
expect(body.productId).toBe('clawcowork');
|
||||
expect(body.goal).toBe('List all files and create a summary');
|
||||
expect(body.folder).toBe('/tmp/test-folder');
|
||||
expect(body.status).toBe('pending');
|
||||
expect(body.model).toBe('claude-sonnet-4-20250514');
|
||||
});
|
||||
|
||||
it('POST /api/tasks — rejects empty goal', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
payload: { goal: '', folder: '/tmp' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('GET /api/tasks — lists submitted tasks', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/tasks' });
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.payload);
|
||||
expect(body.tasks).toBeInstanceOf(Array);
|
||||
expect(body.tasks.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('GET /api/tasks/:id — returns a specific task', async () => {
|
||||
// Submit first
|
||||
const submitRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
payload: { goal: 'Get task test', folder: '/tmp' },
|
||||
});
|
||||
const { id } = JSON.parse(submitRes.payload);
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/tasks/${id}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(JSON.parse(res.payload).id).toBe(id);
|
||||
});
|
||||
|
||||
it('GET /api/tasks/:id — returns 404 for unknown task', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/tasks/nonexistent' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /api/tasks/:id/cancel — cancels a pending task', async () => {
|
||||
const submitRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
payload: { goal: 'Cancel me', folder: '/tmp' },
|
||||
});
|
||||
const { id } = JSON.parse(submitRes.payload);
|
||||
|
||||
const cancelRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/tasks/${id}/cancel`,
|
||||
});
|
||||
expect(cancelRes.statusCode).toBe(200);
|
||||
expect(JSON.parse(cancelRes.payload).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('POST /api/tasks/:id/cancel — rejects cancel on already cancelled task', async () => {
|
||||
const submitRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/tasks',
|
||||
payload: { goal: 'Double cancel', folder: '/tmp' },
|
||||
});
|
||||
const { id } = JSON.parse(submitRes.payload);
|
||||
|
||||
await app.inject({ method: 'POST', url: `/api/tasks/${id}/cancel` });
|
||||
const res = await app.inject({ method: 'POST', url: `/api/tasks/${id}/cancel` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
85
services/cowork-service/src/modules/tasks/routes.ts
Normal file
85
services/cowork-service/src/modules/tasks/routes.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Task management routes — proxies to Rust agent runtime via IPC.
|
||||
*
|
||||
* POST /api/tasks — submit a new task
|
||||
* GET /api/tasks — list all tasks
|
||||
* GET /api/tasks/:id — get task status
|
||||
* POST /api/tasks/:id/cancel — cancel a running task
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { SubmitTaskSchema, type TaskResponse, type TaskStatus } from './types.js';
|
||||
import { config } from '../../lib/config.js';
|
||||
|
||||
// In-memory task store (will be replaced with IPC bridge to Rust runtime)
|
||||
const tasks = new Map<string, TaskResponse>();
|
||||
let nextId = 1;
|
||||
|
||||
function generateTaskId(): string {
|
||||
return `task_${Date.now()}_${nextId++}`;
|
||||
}
|
||||
|
||||
export async function taskRoutes(app: FastifyInstance) {
|
||||
// Submit a new task
|
||||
app.post('/api/tasks', async (req, reply) => {
|
||||
const parsed = SubmitTaskSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; '));
|
||||
}
|
||||
|
||||
const input = parsed.data;
|
||||
const now = new Date().toISOString();
|
||||
const task: TaskResponse = {
|
||||
id: generateTaskId(),
|
||||
productId: config.PRODUCT_ID,
|
||||
goal: input.goal,
|
||||
folder: input.folder,
|
||||
model: input.model,
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
tasks.set(task.id, task);
|
||||
req.log.info({ taskId: task.id, goal: input.goal }, 'task submitted');
|
||||
|
||||
// TODO(H.2): Spawn Rust runtime child process and send IPC submit_task
|
||||
// For now, tasks stay in 'pending' state until IPC bridge is wired.
|
||||
|
||||
reply.code(201);
|
||||
return task;
|
||||
});
|
||||
|
||||
// List all tasks
|
||||
app.get('/api/tasks', async () => {
|
||||
return { tasks: Array.from(tasks.values()) };
|
||||
});
|
||||
|
||||
// Get single task
|
||||
app.get('/api/tasks/:id', async req => {
|
||||
const { id } = req.params as { id: string };
|
||||
const task = tasks.get(id);
|
||||
if (!task) throw new NotFoundError('Task not found');
|
||||
return task;
|
||||
});
|
||||
|
||||
// Cancel a task
|
||||
app.post('/api/tasks/:id/cancel', async req => {
|
||||
const { id } = req.params as { id: string };
|
||||
const task = tasks.get(id);
|
||||
if (!task) throw new NotFoundError('Task not found');
|
||||
|
||||
if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') {
|
||||
throw new BadRequestError(`Cannot cancel task in '${task.status}' state`);
|
||||
}
|
||||
|
||||
task.status = 'cancelled' as TaskStatus;
|
||||
task.updatedAt = new Date().toISOString();
|
||||
req.log.info({ taskId: id }, 'task cancelled');
|
||||
|
||||
// TODO(H.2): Send IPC cancel_task to Rust runtime
|
||||
|
||||
return task;
|
||||
});
|
||||
}
|
||||
61
services/cowork-service/src/modules/tasks/types.ts
Normal file
61
services/cowork-service/src/modules/tasks/types.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Task submission and status types for cowork-service.
|
||||
*
|
||||
* These mirror the Rust DispatchState types and are used for
|
||||
* the REST API that proxies to the Rust agent runtime via IPC.
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Request schemas ──
|
||||
|
||||
export const SubmitTaskSchema = z.object({
|
||||
goal: z.string().min(1).max(10_000),
|
||||
folder: z.string().min(1).max(1024),
|
||||
model: z.string().max(128).default('claude-sonnet-4-20250514'),
|
||||
plugins: z.array(z.string().max(128)).max(50).default([]),
|
||||
contextFiles: z.array(z.string().max(1024)).max(100).default([]),
|
||||
budget: z
|
||||
.object({
|
||||
maxCostUsd: z.number().min(0).max(1000).default(5),
|
||||
warnThreshold: z.number().min(0).max(1).default(0.8),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type SubmitTaskInput = z.infer<typeof SubmitTaskSchema>;
|
||||
|
||||
// ── Response types ──
|
||||
|
||||
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
|
||||
export interface TaskResponse {
|
||||
id: string;
|
||||
productId: string;
|
||||
goal: string;
|
||||
folder: string;
|
||||
model: string;
|
||||
status: TaskStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
result?: string;
|
||||
error?: string;
|
||||
costUsd?: number;
|
||||
tokenCount?: number;
|
||||
}
|
||||
|
||||
// ── IPC message types (JSON-RPC to Rust runtime) ──
|
||||
|
||||
export interface IpcRequest {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
method: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: number;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
52
services/cowork-service/src/server.test.ts
Normal file
52
services/cowork-service/src/server.test.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
const createServiceAppMock = vi.fn();
|
||||
const startServiceMock = vi.fn(async () => undefined);
|
||||
|
||||
const appMock = {
|
||||
register: vi.fn(async () => undefined),
|
||||
inject: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('@bytelyst/fastify-core', () => ({
|
||||
createServiceApp: createServiceAppMock,
|
||||
startService: startServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('./modules/health/routes.js', () => ({ healthRoutes: vi.fn() }));
|
||||
vi.mock('./modules/tasks/routes.js', () => ({ taskRoutes: vi.fn() }));
|
||||
vi.mock('./lib/config.js', () => ({
|
||||
config: {
|
||||
PORT: 4009,
|
||||
HOST: '0.0.0.0',
|
||||
CORS_ORIGIN: undefined,
|
||||
SERVICE_NAME: 'cowork-service',
|
||||
PRODUCT_ID: 'clawcowork',
|
||||
PLATFORM_SERVICE_URL: 'http://localhost:4003',
|
||||
RUST_RUNTIME_BIN: 'cowork-orchestrator',
|
||||
RUST_RUNTIME_TIMEOUT_MS: 300_000,
|
||||
},
|
||||
}));
|
||||
|
||||
describe('cowork-service bootstrap', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
createServiceAppMock.mockResolvedValue(appMock);
|
||||
appMock.register.mockReset();
|
||||
appMock.register.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('creates app, registers routes, and starts on port 4009', async () => {
|
||||
await import('./server.js');
|
||||
|
||||
expect(createServiceAppMock).toHaveBeenCalledOnce();
|
||||
const opts = createServiceAppMock.mock.calls[0][0];
|
||||
expect(opts.name).toBe('cowork-service');
|
||||
expect(opts.version).toBe('0.1.0');
|
||||
expect(opts.readiness).toBe(true);
|
||||
|
||||
expect(appMock.register).toHaveBeenCalledTimes(2);
|
||||
expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' });
|
||||
});
|
||||
});
|
||||
33
services/cowork-service/src/server.ts
Normal file
33
services/cowork-service/src/server.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Cowork Service — Fastify server entry point.
|
||||
*
|
||||
* Bridge between Tauri desktop / external clients and the Rust agent runtime.
|
||||
* Connects to platform-service for auth, flags, audit, and billing.
|
||||
*
|
||||
* Port: 4009 (configurable via PORT env var).
|
||||
* Product: clawcowork
|
||||
*/
|
||||
|
||||
import { createServiceApp, startService } from '@bytelyst/fastify-core';
|
||||
import { healthRoutes } from './modules/health/routes.js';
|
||||
import { taskRoutes } from './modules/tasks/routes.js';
|
||||
import { config } from './lib/config.js';
|
||||
|
||||
const app = await createServiceApp({
|
||||
name: config.SERVICE_NAME,
|
||||
version: '0.1.0',
|
||||
description: 'Fastify bridge — Tauri desktop ↔ Rust agent runtime ↔ platform-service',
|
||||
corsOrigin: config.CORS_ORIGIN,
|
||||
swagger: {
|
||||
title: 'Cowork Service',
|
||||
description: 'REST API for Claw Cowork agent task management',
|
||||
port: config.PORT,
|
||||
},
|
||||
readiness: true,
|
||||
});
|
||||
|
||||
// Register route modules
|
||||
await app.register(healthRoutes);
|
||||
await app.register(taskRoutes);
|
||||
|
||||
await startService(app, { port: config.PORT, host: config.HOST });
|
||||
9
services/cowork-service/tsconfig.json
Normal file
9
services/cowork-service/tsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
8
services/cowork-service/vitest.config.ts
Normal file
8
services/cowork-service/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
passWithNoTests: true,
|
||||
pool: 'forks',
|
||||
},
|
||||
});
|
||||
@ -353,6 +353,92 @@ const PRODUCT_FLAGS: Record<string, FlagSeedDef[]> = {
|
||||
percentage: 100,
|
||||
},
|
||||
],
|
||||
clawcowork: [
|
||||
{
|
||||
key: 'sandbox_enabled',
|
||||
enabled: true,
|
||||
description: 'Docker sandbox for agent task execution',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'plugins_enabled',
|
||||
enabled: true,
|
||||
description: 'Plugin system (load, execute, manage plugins)',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'mcp_connectors_enabled',
|
||||
enabled: true,
|
||||
description: 'MCP connector integration (Google Drive, Gmail, Slack, etc.)',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'scheduling_enabled',
|
||||
enabled: true,
|
||||
description: 'Task scheduler (cron, interval, file-watch triggers)',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'computer_use_enabled',
|
||||
enabled: false,
|
||||
description: 'Computer Use — screenshot, click, type automation (requires explicit opt-in)',
|
||||
platforms: ['macos'],
|
||||
percentage: 0,
|
||||
},
|
||||
{
|
||||
key: 'parallel_agents_enabled',
|
||||
enabled: true,
|
||||
description: 'Parallel sub-agent execution for complex tasks',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'marketplace_enabled',
|
||||
enabled: true,
|
||||
description: 'Plugin marketplace (browse, install, update)',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'wasm_plugins_enabled',
|
||||
enabled: false,
|
||||
description: 'WASM plugin runtime via wasmtime/wasmer (experimental)',
|
||||
platforms: ['macos', 'linux'],
|
||||
percentage: 0,
|
||||
},
|
||||
{
|
||||
key: 'llm_multi_model_enabled',
|
||||
enabled: false,
|
||||
description: 'Multi-model LLM routing (planner/executor/reviewer role assignment)',
|
||||
platforms: [],
|
||||
percentage: 0,
|
||||
},
|
||||
{
|
||||
key: 'audit_logging_enabled',
|
||||
enabled: true,
|
||||
description: 'Agent action audit trail',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
{
|
||||
key: 'platform_auth_required',
|
||||
enabled: false,
|
||||
description: 'Require platform JWT auth — when false, offline API key fallback allowed',
|
||||
platforms: [],
|
||||
percentage: 0,
|
||||
},
|
||||
{
|
||||
key: 'dispatch_api_enabled',
|
||||
enabled: true,
|
||||
description: 'Remote task dispatch REST API + WebSocket streaming',
|
||||
platforms: ['macos', 'linux', 'windows'],
|
||||
percentage: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ─── Seed function ───────────────────────────────────────────────────────────
|
||||
|
||||
Loading…
Reference in New Issue
Block a user