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:
nocxcloud-oss 2026-04-15 19:10:41 +08:00
parent 2133d7226f
commit 4239f9f1ef
63 changed files with 3678 additions and 764 deletions

45
apps/cli/README.md Normal file
View 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.

View 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",
},
},
]);

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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`));
}
}
}

View file

@ -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";

View file

@ -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();

View file

@ -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));

View file

@ -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];
}
}

View file

@ -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);
}
}
}

View 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(),
}),
});

View file

@ -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 } : {}),
};
}
}
}

View file

@ -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');
}
}

View 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),
});

View file

@ -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

View file

@ -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";

View 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]);
});

View 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 });
}

View 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);
});