mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-30 02:46:25 +02:00
strengthen repo verification and runtime coverage
Add clearer app docs plus targeted desktop, CLI, web, and worker tests so cross-surface regressions are caught earlier and the repo is easier to navigate.
This commit is contained in:
parent
2133d7226f
commit
4239f9f1ef
63 changed files with 3678 additions and 764 deletions
45
apps/cli/README.md
Normal file
45
apps/cli/README.md
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Rowboat CLI And Local Runtime
|
||||
|
||||
`apps/cli` contains the npm-distributed `@rowboatlabs/rowboatx` package and the local HTTP runtime used by the newer frontend.
|
||||
|
||||
## What Lives Here
|
||||
|
||||
- Hono server for runs, messages, permissions, and SSE streaming
|
||||
- Model and MCP configuration repositories under `~/.rowboat`
|
||||
- Workflow import and export helpers
|
||||
- Packaged CLI entrypoint in `bin/app.js`
|
||||
|
||||
## Local Development
|
||||
|
||||
Install and build:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Run the local server:
|
||||
|
||||
```bash
|
||||
npm run server
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
- `npm run build` - compile TypeScript into `dist/`
|
||||
- `npm run lint` - run CLI lint checks
|
||||
- `npm run typecheck` - run TypeScript checks without emitting
|
||||
- `npm run server` - start the local Hono runtime
|
||||
- `npm run verify` - run lint, typecheck, and tests together
|
||||
- `npm run migrate-agents` - run bundled agent migration script
|
||||
|
||||
## Data Location
|
||||
|
||||
The CLI/runtime stores configuration and runtime state in `~/.rowboat` by default.
|
||||
|
||||
## Related Surfaces
|
||||
|
||||
- `apps/rowboatx` provides the newer frontend that talks to this runtime
|
||||
- `apps/x` has its own local-first desktop runtime and is the primary desktop product
|
||||
|
||||
See the root `ARCHITECTURE.md` for the repo-level map.
|
||||
37
apps/cli/eslint.config.mjs
Normal file
37
apps/cli/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist/**"]),
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"no-case-declarations": "off",
|
||||
"no-useless-escape": "off",
|
||||
"prefer-const": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/**/*.mjs", "bin/**/*.js"],
|
||||
extends: [js.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
1373
apps/cli/package-lock.json
generated
1373
apps/cli/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,8 +4,11 @@
|
|||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"lint": "eslint src test bin",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "npm run build && node ./test/run-tests.mjs",
|
||||
"build": "rm -rf dist && tsc",
|
||||
"verify": "npm run lint && npm run typecheck && npm test",
|
||||
"server": "node dist/server.js",
|
||||
"migrate-agents": "node dist/scripts/migrate-agents.js"
|
||||
},
|
||||
|
|
@ -21,8 +24,12 @@
|
|||
"license": "Apache-2.0",
|
||||
"description": "",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@types/node": "^24.9.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"eslint": "^9.39.2",
|
||||
"globals": "^16.5.0",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import { glob } from "node:fs/promises";
|
||||
import path from "path";
|
||||
import z from "zod";
|
||||
import { Agent } from "./agents.js";
|
||||
|
|
@ -19,11 +18,32 @@ export interface IAgentsRepo {
|
|||
export class FSAgentsRepo implements IAgentsRepo {
|
||||
private readonly agentsDir = path.join(WorkDir, "agents");
|
||||
|
||||
private async listMarkdownFiles(dir: string, prefix: string = ""): Promise<string[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = prefix ? path.posix.join(prefix, entry.name) : entry.name;
|
||||
const absolutePath = path.join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...await this.listMarkdownFiles(absolutePath, relativePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
results.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async list(): Promise<z.infer<typeof Agent>[]> {
|
||||
const result: z.infer<typeof Agent>[] = [];
|
||||
|
||||
// list all md files in workdir/agents/
|
||||
const matches = await Array.fromAsync(glob("**/*.md", { cwd: this.agentsDir }));
|
||||
const matches = await this.listMarkdownFiles(this.agentsDir);
|
||||
for (const file of matches) {
|
||||
try {
|
||||
const agent = await this.parseAgentMd(path.join(this.agentsDir, file));
|
||||
|
|
@ -79,16 +99,20 @@ export class FSAgentsRepo implements IAgentsRepo {
|
|||
async create(agent: z.infer<typeof Agent>): Promise<void> {
|
||||
const { instructions, ...rest } = agent;
|
||||
const contents = `---\n${stringify(rest)}\n---\n${instructions}`;
|
||||
await fs.writeFile(path.join(this.agentsDir, `${agent.name}.md`), contents);
|
||||
const filePath = path.join(this.agentsDir, `${agent.name}.md`);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents);
|
||||
}
|
||||
|
||||
async update(id: string, agent: z.infer<typeof UpdateAgentSchema>): Promise<void> {
|
||||
const { instructions, ...rest } = agent;
|
||||
const contents = `---\n${stringify(rest)}\n---\n${instructions}`;
|
||||
await fs.writeFile(path.join(this.agentsDir, `${id}.md`), contents);
|
||||
const filePath = path.join(this.agentsDir, `${id}.md`);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents);
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await fs.unlink(path.join(this.agentsDir, `${id}.md`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { Agent } from "./agents/agents.js";
|
|||
import { McpServerConfig, McpServerDefinition } from "./mcp/schema.js";
|
||||
import { Example } from "./entities/example.js";
|
||||
import { z } from "zod";
|
||||
import { Flavor } from "./models/models.js";
|
||||
import { Flavor } from "./models/schema.js";
|
||||
import { examples } from "./examples/index.js";
|
||||
import container from "./di/container.js";
|
||||
import { IModelConfigRepo } from "./models/repo.js";
|
||||
|
|
|
|||
|
|
@ -2,14 +2,29 @@ import path from "path";
|
|||
import fs from "fs";
|
||||
import { homedir } from "os";
|
||||
|
||||
// Resolve app root relative to compiled file location (dist/...)
|
||||
export const WorkDir = path.join(homedir(), ".rowboat");
|
||||
function resolveWorkDir(): string {
|
||||
const configured = process.env.ROWBOAT_WORKDIR;
|
||||
if (!configured) {
|
||||
return path.join(homedir(), ".rowboat");
|
||||
}
|
||||
|
||||
const expanded = configured === "~"
|
||||
? homedir()
|
||||
: (configured.startsWith("~/") || configured.startsWith("~\\"))
|
||||
? path.join(homedir(), configured.slice(2))
|
||||
: configured;
|
||||
|
||||
return path.resolve(expanded);
|
||||
}
|
||||
|
||||
export const WorkDir = resolveWorkDir();
|
||||
|
||||
function ensureDirs() {
|
||||
const ensure = (p: string) => { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); };
|
||||
ensure(WorkDir);
|
||||
ensure(path.join(WorkDir, "agents"));
|
||||
ensure(path.join(WorkDir, "config"));
|
||||
ensure(path.join(WorkDir, "runs"));
|
||||
}
|
||||
|
||||
ensureDirs();
|
||||
ensureDirs();
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ export interface IMcpConfigRepo {
|
|||
|
||||
export class FSMcpConfigRepo implements IMcpConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "mcp.json");
|
||||
private readonly initPromise: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
this.initPromise = this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
|
|
@ -25,18 +26,25 @@ export class FSMcpConfigRepo implements IMcpConfigRepo {
|
|||
}
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
await this.initPromise;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof McpServerConfig>> {
|
||||
await this.ensureInitialized();
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return McpServerConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
async upsert(serverName: string, config: z.infer<typeof McpServerDefinition>): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
const conf = await this.getConfig();
|
||||
conf.mcpServers[serverName] = config;
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
}
|
||||
|
||||
async delete(serverName: string): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
const conf = await this.getConfig();
|
||||
delete conf.mcpServers[serverName];
|
||||
await fs.writeFile(this.configPath, JSON.stringify(conf, null, 2));
|
||||
|
|
|
|||
|
|
@ -8,33 +8,9 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
|||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { IModelConfigRepo } from "./repo.js";
|
||||
import container from "../di/container.js";
|
||||
import z from "zod";
|
||||
import { Flavor, Provider, ModelConfig } from "./schema.js";
|
||||
|
||||
export const Flavor = z.enum([
|
||||
"rowboat [free]",
|
||||
"aigateway",
|
||||
"anthropic",
|
||||
"google",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openrouter",
|
||||
]);
|
||||
|
||||
export const Provider = z.object({
|
||||
flavor: Flavor,
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ModelConfig = z.object({
|
||||
providers: z.record(z.string(), Provider),
|
||||
defaults: z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
});
|
||||
export { Flavor, Provider, ModelConfig };
|
||||
|
||||
const providerMap: Record<string, ProviderV2> = {};
|
||||
|
||||
|
|
@ -116,4 +92,4 @@ export async function getProvider(name: string = ""): Promise<ProviderV2> {
|
|||
throw new Error(`Provider ${name} not found`);
|
||||
}
|
||||
return providerMap[name];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ModelConfig, Provider } from "./models.js";
|
||||
import { ModelConfig, Provider } from "./schema.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
|
@ -25,9 +25,10 @@ const defaultConfig: z.infer<typeof ModelConfig> = {
|
|||
|
||||
export class FSModelConfigRepo implements IModelConfigRepo {
|
||||
private readonly configPath = path.join(WorkDir, "config", "models.json");
|
||||
private readonly initPromise: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this.ensureDefaultConfig();
|
||||
this.initPromise = this.ensureDefaultConfig();
|
||||
}
|
||||
|
||||
private async ensureDefaultConfig(): Promise<void> {
|
||||
|
|
@ -38,12 +39,18 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
}
|
||||
}
|
||||
|
||||
private async ensureInitialized(): Promise<void> {
|
||||
await this.initPromise;
|
||||
}
|
||||
|
||||
async getConfig(): Promise<z.infer<typeof ModelConfig>> {
|
||||
await this.ensureInitialized();
|
||||
const config = await fs.readFile(this.configPath, "utf8");
|
||||
return ModelConfig.parse(JSON.parse(config));
|
||||
}
|
||||
|
||||
private async setConfig(config: z.infer<typeof ModelConfig>): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
await fs.writeFile(this.configPath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
|
|
@ -67,4 +74,4 @@ export class FSModelConfigRepo implements IModelConfigRepo {
|
|||
};
|
||||
await this.setConfig(conf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
apps/cli/src/models/schema.ts
Normal file
27
apps/cli/src/models/schema.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import z from "zod";
|
||||
|
||||
export const Flavor = z.enum([
|
||||
"rowboat [free]",
|
||||
"aigateway",
|
||||
"anthropic",
|
||||
"google",
|
||||
"ollama",
|
||||
"openai",
|
||||
"openai-compatible",
|
||||
"openrouter",
|
||||
]);
|
||||
|
||||
export const Provider = z.object({
|
||||
flavor: Flavor,
|
||||
apiKey: z.string().optional(),
|
||||
baseURL: z.string().optional(),
|
||||
headers: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
export const ModelConfig = z.object({
|
||||
providers: z.record(z.string(), Provider),
|
||||
defaults: z.object({
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}),
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { Run } from "./runs.js";
|
||||
import { Run } from "./schema.js";
|
||||
import z from "zod";
|
||||
import { IMonotonicallyIncreasingIdGenerator } from "../application/lib/id-gen.js";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
|
|
@ -37,6 +37,7 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
}
|
||||
|
||||
async appendEvents(runId: string, events: z.infer<typeof RunEvent>[]): Promise<void> {
|
||||
await fsp.mkdir(path.join(WorkDir, 'runs'), { recursive: true });
|
||||
await fsp.appendFile(
|
||||
path.join(WorkDir, 'runs', `${runId}.jsonl`),
|
||||
events.map(event => JSON.stringify(event)).join("\n") + "\n"
|
||||
|
|
@ -141,4 +142,4 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
...(nextCursor ? { nextCursor } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { AskHumanResponseEvent, RunEvent, ToolPermissionResponseEvent } from "..
|
|||
import { CreateRunOptions, IRunsRepo } from "./repo.js";
|
||||
import { IAgentRuntime } from "../agents/runtime.js";
|
||||
import { IBus } from "../application/lib/bus.js";
|
||||
import { Run } from "./schema.js";
|
||||
|
||||
export const ToolPermissionAuthorizePayload = ToolPermissionResponseEvent.pick({
|
||||
subflow: true,
|
||||
|
|
@ -18,12 +19,7 @@ export const AskHumanResponsePayload = AskHumanResponseEvent.pick({
|
|||
response: true,
|
||||
});
|
||||
|
||||
export const Run = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
agentId: z.string(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
export { Run };
|
||||
|
||||
export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise<z.infer<typeof Run>> {
|
||||
const repo = container.resolve<IRunsRepo>('runsRepo');
|
||||
|
|
@ -67,4 +63,4 @@ export async function replyToHumanInputRequest(runId: string, ev: z.infer<typeof
|
|||
|
||||
export async function stop(runId: string): Promise<void> {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
10
apps/cli/src/runs/schema.ts
Normal file
10
apps/cli/src/runs/schema.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import z from "zod";
|
||||
|
||||
import { RunEvent } from "../entities/run-events.js";
|
||||
|
||||
export const Run = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime(),
|
||||
agentId: z.string(),
|
||||
log: z.array(RunEvent),
|
||||
});
|
||||
|
|
@ -4,193 +4,208 @@ import { streamSSE } from 'hono/streaming'
|
|||
import { describeRoute, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import z from 'zod';
|
||||
import container from './di/container.js';
|
||||
import { executeTool, listServers, listTools } from "./mcp/mcp.js";
|
||||
import { ListToolsResponse, McpServerDefinition, McpServerList } from "./mcp/schema.js";
|
||||
import { IMcpConfigRepo } from './mcp/repo.js';
|
||||
import { IModelConfigRepo } from './models/repo.js';
|
||||
import { ModelConfig, Provider } from "./models/models.js";
|
||||
import { IAgentsRepo } from "./agents/repo.js";
|
||||
import { Agent } from "./agents/agents.js";
|
||||
import { AskHumanResponsePayload, authorizePermission, createMessage, createRun, replyToHumanInputRequest, Run, stop, ToolPermissionAuthorizePayload } from './runs/runs.js';
|
||||
import { IRunsRepo, CreateRunOptions, ListRunsResponse } from './runs/repo.js';
|
||||
import { IBus } from './application/lib/bus.js';
|
||||
import { cors } from 'hono/cors';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
let id = 0;
|
||||
export interface ServerDependencies {
|
||||
createMessage(runId: string, message: string): Promise<string>;
|
||||
authorizePermission(runId: string, payload: z.infer<typeof ToolPermissionAuthorizePayload>): Promise<void>;
|
||||
replyToHumanInputRequest(runId: string, payload: z.infer<typeof AskHumanResponsePayload>): Promise<void>;
|
||||
stop(runId: string): Promise<void>;
|
||||
subscribeToEvents(listener: (event: unknown) => Promise<void>): Promise<() => void>;
|
||||
}
|
||||
|
||||
const routes = new Hono()
|
||||
.post(
|
||||
'/runs/:runId/messages/new',
|
||||
describeRoute({
|
||||
summary: 'Create a new message',
|
||||
description: 'Create a new message',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Message created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
messageId: z.string(),
|
||||
})),
|
||||
const defaultDependencies: ServerDependencies = {
|
||||
createMessage,
|
||||
authorizePermission,
|
||||
replyToHumanInputRequest,
|
||||
stop,
|
||||
subscribeToEvents: async (listener) => {
|
||||
const bus = container.resolve<IBus>('bus');
|
||||
return bus.subscribe('*', listener);
|
||||
},
|
||||
};
|
||||
|
||||
export function createApp(deps: ServerDependencies = defaultDependencies): Hono {
|
||||
const routes = new Hono()
|
||||
.post(
|
||||
'/runs/:runId/messages/new',
|
||||
describeRoute({
|
||||
summary: 'Create a new message',
|
||||
description: 'Create a new message',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Message created',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
messageId: z.string(),
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', z.object({
|
||||
message: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const messageId = await createMessage(c.req.valid('param').runId, c.req.valid('json').message);
|
||||
return c.json({
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/permissions/authorize',
|
||||
describeRoute({
|
||||
summary: 'Authorize permission',
|
||||
description: 'Authorize a permission',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Permission authorized',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', ToolPermissionAuthorizePayload),
|
||||
async (c) => {
|
||||
const response = await authorizePermission(
|
||||
c.req.valid('param').runId,
|
||||
c.req.valid('json')
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/human-input-requests/:requestId/reply',
|
||||
describeRoute({
|
||||
summary: 'Reply to human input request',
|
||||
description: 'Reply to a human input request',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Human input request replied',
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', AskHumanResponsePayload),
|
||||
async (c) => {
|
||||
const response = await replyToHumanInputRequest(
|
||||
c.req.valid('param').runId,
|
||||
c.req.valid('json')
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/stop',
|
||||
describeRoute({
|
||||
summary: 'Stop run',
|
||||
description: 'Stop a run',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run stopped',
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const response = await stop(c.req.valid('param').runId);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/stream',
|
||||
describeRoute({
|
||||
summary: 'Subscribe to run events',
|
||||
description: 'Subscribe to run events',
|
||||
}),
|
||||
async (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
const bus = container.resolve<IBus>('bus');
|
||||
|
||||
let id = 0;
|
||||
let unsub: (() => void) | null = null;
|
||||
let aborted = false;
|
||||
|
||||
stream.onAbort(() => {
|
||||
aborted = true;
|
||||
if (unsub) {
|
||||
unsub();
|
||||
}
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', z.object({
|
||||
message: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
const messageId = await deps.createMessage(c.req.valid('param').runId, c.req.valid('json').message);
|
||||
return c.json({
|
||||
messageId,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/permissions/authorize',
|
||||
describeRoute({
|
||||
summary: 'Authorize permission',
|
||||
description: 'Authorize a permission',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Permission authorized',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: resolver(z.object({
|
||||
success: z.literal(true),
|
||||
})),
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', ToolPermissionAuthorizePayload),
|
||||
async (c) => {
|
||||
await deps.authorizePermission(
|
||||
c.req.valid('param').runId,
|
||||
c.req.valid('json')
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/human-input-requests/:requestId/reply',
|
||||
describeRoute({
|
||||
summary: 'Reply to human input request',
|
||||
description: 'Reply to a human input request',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Human input request replied',
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
validator('json', AskHumanResponsePayload),
|
||||
async (c) => {
|
||||
await deps.replyToHumanInputRequest(
|
||||
c.req.valid('param').runId,
|
||||
c.req.valid('json')
|
||||
);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/runs/:runId/stop',
|
||||
describeRoute({
|
||||
summary: 'Stop run',
|
||||
description: 'Stop a run',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'Run stopped',
|
||||
},
|
||||
},
|
||||
}),
|
||||
validator('param', z.object({
|
||||
runId: z.string(),
|
||||
})),
|
||||
async (c) => {
|
||||
await deps.stop(c.req.valid('param').runId);
|
||||
return c.json({
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/stream',
|
||||
describeRoute({
|
||||
summary: 'Subscribe to run events',
|
||||
description: 'Subscribe to run events',
|
||||
}),
|
||||
async (c) => {
|
||||
return streamSSE(c, async (stream) => {
|
||||
let eventId = 0;
|
||||
let unsub: (() => void) | null = null;
|
||||
let aborted = false;
|
||||
|
||||
// Subscribe to your bus
|
||||
unsub = await bus.subscribe('*', async (event) => {
|
||||
if (aborted) return;
|
||||
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: "message",
|
||||
id: String(id++),
|
||||
stream.onAbort(() => {
|
||||
aborted = true;
|
||||
if (unsub) {
|
||||
unsub();
|
||||
}
|
||||
});
|
||||
|
||||
unsub = await deps.subscribeToEvents(async (event) => {
|
||||
if (aborted) return;
|
||||
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
event: "message",
|
||||
id: String(eventId++),
|
||||
});
|
||||
});
|
||||
|
||||
while (!aborted) {
|
||||
await stream.sleep(1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Keep the function alive until the client disconnects
|
||||
while (!aborted) {
|
||||
await stream.sleep(1000); // any interval is fine
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
;
|
||||
|
||||
const app = new Hono()
|
||||
.use("/*", cors())
|
||||
.route("/", routes)
|
||||
.get(
|
||||
"/openapi.json",
|
||||
openAPIRouteHandler(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Hono",
|
||||
version: "1.0.0",
|
||||
description: "RowboatX API",
|
||||
return new Hono()
|
||||
.use("/*", cors())
|
||||
.route("/", routes)
|
||||
.get(
|
||||
"/openapi.json",
|
||||
openAPIRouteHandler(routes, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: "Hono",
|
||||
version: "1.0.0",
|
||||
description: "RowboatX API",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// export default app;
|
||||
export const app = createApp();
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port: Number(process.env.PORT) || 3000,
|
||||
});
|
||||
export function startServer(port: number = Number(process.env.PORT) || 3000): void {
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
});
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
||||
|
||||
if (isMain) {
|
||||
startServer();
|
||||
}
|
||||
|
||||
// GET /skills
|
||||
// POST /skills/new
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { createParser } from "eventsource-parser";
|
|||
import { Agent } from "../agents/agents.js";
|
||||
import { AskHumanResponsePayload, Run, ToolPermissionAuthorizePayload } from "../runs/runs.js";
|
||||
import { ListRunsResponse } from "../runs/repo.js";
|
||||
import { ModelConfig } from "../models/models.js";
|
||||
import { ModelConfig } from "../models/schema.js";
|
||||
import { RunEvent } from "../entities/run-events.js";
|
||||
import z from "zod";
|
||||
|
||||
|
|
|
|||
83
apps/cli/test/repos.test.mjs
Normal file
83
apps/cli/test/repos.test.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { WorkDir } from "../dist/config/config.js";
|
||||
import { FSModelConfigRepo } from "../dist/models/repo.js";
|
||||
import { FSMcpConfigRepo } from "../dist/mcp/repo.js";
|
||||
import { FSAgentsRepo } from "../dist/agents/repo.js";
|
||||
import { FSRunsRepo } from "../dist/runs/repo.js";
|
||||
|
||||
test("uses ROWBOAT_WORKDIR override and eagerly creates expected directories", async () => {
|
||||
assert.equal(WorkDir, process.env.ROWBOAT_WORKDIR);
|
||||
|
||||
for (const dirName of ["agents", "config", "runs"]) {
|
||||
const stats = await fs.stat(path.join(WorkDir, dirName));
|
||||
assert.equal(stats.isDirectory(), true);
|
||||
}
|
||||
});
|
||||
|
||||
test("FSModelConfigRepo returns defaults on a fresh workspace", async () => {
|
||||
const repo = new FSModelConfigRepo();
|
||||
const config = await repo.getConfig();
|
||||
|
||||
assert.equal(config.defaults.provider, "openai");
|
||||
assert.equal(config.defaults.model, "gpt-5.1");
|
||||
assert.equal(config.providers.openai?.flavor, "openai");
|
||||
});
|
||||
|
||||
test("FSMcpConfigRepo returns an empty config on a fresh workspace", async () => {
|
||||
const repo = new FSMcpConfigRepo();
|
||||
const config = await repo.getConfig();
|
||||
|
||||
assert.deepEqual(config, { mcpServers: {} });
|
||||
});
|
||||
|
||||
test("FSAgentsRepo can create and read nested agent files", async () => {
|
||||
const repo = new FSAgentsRepo();
|
||||
await repo.create({
|
||||
name: "team/copilot",
|
||||
description: "Team helper",
|
||||
provider: "openai",
|
||||
model: "gpt-5.1",
|
||||
instructions: "Be helpful.",
|
||||
});
|
||||
|
||||
const fetched = await repo.fetch("team/copilot");
|
||||
assert.equal(fetched.name, "team/copilot");
|
||||
assert.equal(fetched.description, "Team helper");
|
||||
assert.equal(fetched.instructions, "Be helpful.");
|
||||
});
|
||||
|
||||
test("FSRunsRepo creates, fetches, and lists runs", async () => {
|
||||
let nextId = 0;
|
||||
const repo = new FSRunsRepo({
|
||||
idGenerator: {
|
||||
next: async () => `run-${++nextId}`,
|
||||
},
|
||||
});
|
||||
|
||||
const first = await repo.create({ agentId: "copilot" });
|
||||
await repo.appendEvents(first.id, [{
|
||||
type: "message",
|
||||
runId: first.id,
|
||||
subflow: [],
|
||||
messageId: "msg-1",
|
||||
message: {
|
||||
role: "user",
|
||||
content: "hello",
|
||||
},
|
||||
}]);
|
||||
|
||||
const second = await repo.create({ agentId: "planner" });
|
||||
|
||||
const fetched = await repo.fetch(first.id);
|
||||
assert.equal(fetched.id, first.id);
|
||||
assert.equal(fetched.agentId, "copilot");
|
||||
assert.equal(fetched.log.length, 2);
|
||||
assert.equal(fetched.log[1].type, "message");
|
||||
|
||||
const listed = await repo.list();
|
||||
assert.deepEqual(listed.runs.map((run) => run.id), [second.id, first.id]);
|
||||
});
|
||||
31
apps/cli/test/run-tests.mjs
Normal file
31
apps/cli/test/run-tests.mjs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageDir = path.resolve(__dirname, "..");
|
||||
const tempRoot = await mkdtemp(path.join(tmpdir(), "rowboat-cli-test-"));
|
||||
const testWorkDir = path.join(tempRoot, "workspace");
|
||||
|
||||
try {
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ["--test", "./test/repos.test.mjs", "./test/server.test.mjs"], {
|
||||
cwd: packageDir,
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
ROWBOAT_WORKDIR: testWorkDir,
|
||||
},
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("exit", (code) => resolve(code ?? 1));
|
||||
});
|
||||
|
||||
process.exitCode = Number(exitCode);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
131
apps/cli/test/server.test.mjs
Normal file
131
apps/cli/test/server.test.mjs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createApp } from "../dist/server.js";
|
||||
|
||||
test("message endpoint creates a message and returns its id", async () => {
|
||||
const calls = [];
|
||||
const app = createApp({
|
||||
createMessage: async (runId, message) => {
|
||||
calls.push({ runId, message });
|
||||
return "msg-123";
|
||||
},
|
||||
authorizePermission: async () => {},
|
||||
replyToHumanInputRequest: async () => {},
|
||||
stop: async () => {},
|
||||
subscribeToEvents: async () => () => {},
|
||||
});
|
||||
|
||||
const response = await app.request("/runs/run-1/messages/new", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ message: "hello" }),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { messageId: "msg-123" });
|
||||
assert.deepEqual(calls, [{ runId: "run-1", message: "hello" }]);
|
||||
});
|
||||
|
||||
test("permission endpoint validates payload and calls dependency", async () => {
|
||||
const calls = [];
|
||||
const app = createApp({
|
||||
createMessage: async () => "unused",
|
||||
authorizePermission: async (runId, payload) => {
|
||||
calls.push({ runId, payload });
|
||||
},
|
||||
replyToHumanInputRequest: async () => {},
|
||||
stop: async () => {},
|
||||
subscribeToEvents: async () => () => {},
|
||||
});
|
||||
|
||||
const response = await app.request("/runs/run-2/permissions/authorize", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
subflow: ["child"],
|
||||
toolCallId: "tool-1",
|
||||
response: "approve",
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), { success: true });
|
||||
assert.deepEqual(calls, [{
|
||||
runId: "run-2",
|
||||
payload: {
|
||||
subflow: ["child"],
|
||||
toolCallId: "tool-1",
|
||||
response: "approve",
|
||||
},
|
||||
}]);
|
||||
});
|
||||
|
||||
test("invalid message payload returns a validation error", async () => {
|
||||
const app = createApp({
|
||||
createMessage: async () => "unused",
|
||||
authorizePermission: async () => {},
|
||||
replyToHumanInputRequest: async () => {},
|
||||
stop: async () => {},
|
||||
subscribeToEvents: async () => () => {},
|
||||
});
|
||||
|
||||
const response = await app.request("/runs/run-1/messages/new", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
});
|
||||
|
||||
test("openapi endpoint is exposed", async () => {
|
||||
const app = createApp({
|
||||
createMessage: async () => "unused",
|
||||
authorizePermission: async () => {},
|
||||
replyToHumanInputRequest: async () => {},
|
||||
stop: async () => {},
|
||||
subscribeToEvents: async () => () => {},
|
||||
});
|
||||
|
||||
const response = await app.request("/openapi.json");
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.info.title, "Hono");
|
||||
assert.ok(body.paths["/runs/{runId}/messages/new"]);
|
||||
});
|
||||
|
||||
test("stream endpoint emits SSE payloads and unsubscribes on cancel", async () => {
|
||||
let listener;
|
||||
let unsubscribed = false;
|
||||
const app = createApp({
|
||||
createMessage: async () => "unused",
|
||||
authorizePermission: async () => {},
|
||||
replyToHumanInputRequest: async () => {},
|
||||
stop: async () => {},
|
||||
subscribeToEvents: async (fn) => {
|
||||
listener = fn;
|
||||
return () => {
|
||||
unsubscribed = true;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.request("/stream");
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("content-type"), "text/event-stream");
|
||||
|
||||
await listener({ type: "message", data: { hello: "world" } });
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunk = await reader.read();
|
||||
const text = new TextDecoder().decode(chunk.value);
|
||||
|
||||
assert.match(text, /event: message/);
|
||||
assert.match(text, /"hello":"world"/);
|
||||
|
||||
await reader.cancel();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(unsubscribed, true);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue