From fd85eacf2a3d448da8190302db95db27c615e4af Mon Sep 17 00:00:00 2001 From: Noa Flaherty Date: Sun, 10 May 2026 22:06:29 -0400 Subject: [PATCH] feat(cli): migrate sequence + routes to thin IPC wrappers Co-Authored-By: Claude Opus 4.6 (1M context) --- assistant/src/cli/COMMAND_INVENTORY.md | 4 +- assistant/src/cli/commands/routes.ts | 489 ++++--------- assistant/src/cli/commands/sequence.ts | 676 ++++++++---------- assistant/src/runtime/routes/index.ts | 4 + .../src/runtime/routes/sequence-routes.ts | 291 ++++++++ .../src/runtime/routes/user-routes-cli.ts | 243 +++++++ 6 files changed, 1009 insertions(+), 698 deletions(-) create mode 100644 assistant/src/runtime/routes/sequence-routes.ts create mode 100644 assistant/src/runtime/routes/user-routes-cli.ts diff --git a/assistant/src/cli/COMMAND_INVENTORY.md b/assistant/src/cli/COMMAND_INVENTORY.md index 69c9e768822..240fcf95e09 100644 --- a/assistant/src/cli/COMMAND_INVENTORY.md +++ b/assistant/src/cli/COMMAND_INVENTORY.md @@ -52,8 +52,8 @@ Run `bun run lint:inventory` to validate. | `image-generation` | `generate` | `ipc` | `image_generation_generate` | `THIN` | | | `inference` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | | `inference-session` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | -| `routes` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | -| `sequence` | _(pending migration)_ | `ipc` | _(pending migration)_ | `LEGACY` | | +| `routes` | `list`, `inspect` | `ipc` | `user_routes_list`, `user_routes_inspect` | `THIN` | | +| `sequence` | `list`, `get`, `pause`, `resume`, `cancel-enrollment`, `stats`, `guardrails show`, `guardrails set` | `ipc` | `sequence_list`, `sequence_get`, `sequence_pause`, `sequence_resume`, `sequence_cancel_enrollment`, `sequence_stats`, `sequence_guardrails_show`, `sequence_guardrails_set` | `THIN` | | | `skills` | `list`, `inspect`, `search`, `install`, `uninstall`, `add` | `ipc` | `listSkills`, `skillsLocalInspect`, `searchSkills`, `installSkill`, `deleteSkill` | `THIN` | `install` and `search` use multi-call composition (§3.3) | | `stt` | `transcribe` | `ipc` | `stt_transcribe_file` | `THIN` | | | `tts` | `synthesize` | `ipc` | `tts_synthesize_cli` | `THIN` | | diff --git a/assistant/src/cli/commands/routes.ts b/assistant/src/cli/commands/routes.ts index 0a1adce3807..397c6eb275c 100644 --- a/assistant/src/cli/commands/routes.ts +++ b/assistant/src/cli/commands/routes.ts @@ -1,169 +1,59 @@ -import { existsSync, readdirSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; +/** + * CLI command group: `assistant routes` + * + * Thin IPC wrapper — filesystem scanning logic lives in user-routes-cli.ts. + */ import type { Command } from "commander"; -import { getConfig } from "../../config/loader.js"; -import { getPublicBaseUrl } from "../../inbound/public-ingress-urls.js"; -import { getWorkspaceRoutesDir } from "../../util/platform.js"; +import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js"; +import { registerCommand } from "../lib/register-command.js"; import { log } from "../logger.js"; -/** HTTP methods that can be exported from a handler module. */ -const HTTP_METHODS = [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "HEAD", - "OPTIONS", -] as const; - -type HttpMethod = (typeof HTTP_METHODS)[number]; - -/** Supported file extensions for handler modules. */ -const HANDLER_EXTENSIONS = [".ts", ".js"] as const; - -type HandlerExtension = (typeof HANDLER_EXTENSIONS)[number]; +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- interface DiscoveredRoute { - /** Route path relative to /x/ prefix (e.g. "my-app/status"). */ routePath: string; - /** Absolute path to the handler file. */ + methods: string[]; + description: string | null; filePath: string; - /** HTTP methods exported by the handler module. */ - methods: HttpMethod[]; - /** Optional description exported by the handler module. */ - description?: string; - /** File size in bytes. */ - fileSize: number; - /** Last modified time as ISO string. */ - modifiedAt: string; -} - -/** - * Load a handler module and extract its exported HTTP methods and description. - */ -async function inspectModule( - filePath: string, -): Promise<{ methods: HttpMethod[]; description?: string }> { - const stat = statSync(filePath); - const mod = (await import(`${filePath}?t=${stat.mtimeMs}`)) as Record< - string, - unknown - >; - - const methods: HttpMethod[] = []; - for (const method of HTTP_METHODS) { - if (typeof mod[method] === "function") { - methods.push(method); - } - } - - const description = - typeof mod.description === "string" ? mod.description : undefined; - - return { methods, description }; + publicUrl: string | null; } -/** - * Recursively scan the routes directory for handler files (.ts, .js). - * Returns discovered routes sorted alphabetically by route path. - */ -async function discoverRoutes(routesDir: string): Promise { - if (!existsSync(routesDir)) { - return []; - } - - const routes: DiscoveredRoute[] = []; - - function scanDir(dir: string): void { - const entries = readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = join(dir, entry.name); - if (entry.isDirectory()) { - scanDir(fullPath); - } else if (entry.isFile()) { - const ext = HANDLER_EXTENSIONS.find((e) => entry.name.endsWith(e)) as - | HandlerExtension - | undefined; - if (!ext) continue; - - const relativePath = relative(routesDir, fullPath); - const withoutExt = relativePath.slice(0, -ext.length); - - // Convert filesystem path to route path: - // - Strip /index suffix for index file convention - // - Replace backslashes with forward slashes (Windows compat) - let routePath = withoutExt.replace(/\\/g, "/"); - if (routePath.endsWith("/index")) { - routePath = routePath.slice(0, -"/index".length); - } else if (routePath === "index") { - routePath = ""; - } - - routes.push({ - routePath, - filePath: fullPath, - methods: [], - description: undefined, - fileSize: 0, - modifiedAt: "", - }); - } - } - } - - scanDir(routesDir); - - // Load each module to detect exported methods and description - for (const route of routes) { - try { - const stat = statSync(route.filePath); - route.fileSize = stat.size; - route.modifiedAt = stat.mtime.toISOString(); - - const { methods, description } = await inspectModule(route.filePath); - route.methods = methods; - route.description = description; - } catch { - // If a module fails to load, keep it in the list with empty methods - } - } - - return routes.sort((a, b) => a.routePath.localeCompare(b.routePath)); +interface InspectedRoute { + routePath: string; + methods: string[]; + description: string | null; + filePath: string; + publicUrl: string | null; + fileSize: number; + modifiedAt: string; } -/** - * Try to resolve the public base URL for building full endpoint URLs. - * Returns null if no public URL is configured (non-fatal for CLI display). - */ -function tryGetPublicBaseUrl(): string | null { - try { - const config = getConfig(); - return getPublicBaseUrl(config); - } catch { - return null; - } -} +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- -/** - * Format a list of HTTP methods for display, abbreviating DELETE to DEL. - */ -function formatMethods(methods: HttpMethod[]): string { +function formatMethods(methods: string[]): string { return methods.map((m) => (m === "DELETE" ? "DEL" : m)).join(","); } +// --------------------------------------------------------------------------- +// Command registration +// --------------------------------------------------------------------------- + export function registerRoutesCommand(program: Command): void { - const routes = program - .command("routes") - .description( + registerCommand(program, { + name: "routes", + transport: "ipc", + description: "Manage user-defined authenticated HTTP route handlers under /x/*", - ); - - routes.addHelpText( - "after", - ` + build: (routes) => { + routes.addHelpText( + "after", + ` User-defined routes let you expose custom HTTP endpoints by dropping handler files into /workspace/routes/. Each file exports named HTTP method functions (GET, POST, etc.) and becomes reachable at /x/. @@ -179,117 +69,103 @@ Examples: $ assistant routes list $ assistant routes list --json $ assistant routes inspect my-dashboard-api/submit`, - ); - - routes - .command("list") - .description("List all user-defined route handlers and their public URLs") - .option("--json", "Machine-readable JSON output") - .addHelpText( - "after", - ` + ); + + routes + .command("list") + .description( + "List all user-defined route handlers and their public URLs", + ) + .option("--json", "Machine-readable JSON output") + .addHelpText( + "after", + ` Scans /workspace/routes/ for handler files (.ts, .js) and displays the route path, exported HTTP methods, optional description, and file location. Examples: $ assistant routes list $ assistant routes list --json`, - ) - .action(async (opts: { json?: boolean }) => { - try { - const routesDir = getWorkspaceRoutesDir(); - const discovered = await discoverRoutes(routesDir); - - if (opts.json) { - const publicBase = tryGetPublicBaseUrl(); - const items = discovered.map((r) => ({ - routePath: `/x/${r.routePath}`, - methods: r.methods, - description: r.description ?? null, - filePath: relative(routesDir, r.filePath), - publicUrl: publicBase ? `${publicBase}/x/${r.routePath}` : null, - })); - console.log(JSON.stringify({ ok: true, routes: items })); - return; - } - - if (discovered.length === 0) { - log.info("No route handlers found in /workspace/routes/."); - log.info( - "Create a .ts or .js file exporting named HTTP method functions (GET, POST, etc.).", + ) + .action(async (opts: { json?: boolean }) => { + const r = await cliIpcCall<{ routes: DiscoveredRoute[] }>( + "user_routes_list", ); - return; - } + if (!r.ok) return exitFromIpcResult(r); - const publicBase = tryGetPublicBaseUrl(); + const discovered = r.result!.routes; - log.info(""); - // Table header - const routeCol = "ROUTE PATH"; - const methodsCol = "METHODS"; - const descCol = "DESCRIPTION"; - const fileCol = "FILE"; + if (opts.json) { + console.log(JSON.stringify({ ok: true, routes: discovered })); + return; + } - // Calculate column widths - const routeWidth = Math.max( - routeCol.length, - ...discovered.map((r) => `/x/${r.routePath}`.length), - ); - const methodsWidth = Math.max( - methodsCol.length, - ...discovered.map((r) => formatMethods(r.methods).length), - ); - const descWidth = Math.max( - descCol.length, - ...discovered.map((r) => (r.description ?? "").length), - ); + if (discovered.length === 0) { + log.info("No route handlers found in /workspace/routes/."); + log.info( + "Create a .ts or .js file exporting named HTTP method functions (GET, POST, etc.).", + ); + return; + } - const header = [ - routeCol.padEnd(routeWidth), - methodsCol.padEnd(methodsWidth), - descCol.padEnd(descWidth), - fileCol, - ].join(" "); + log.info(""); + const routeCol = "ROUTE PATH"; + const methodsCol = "METHODS"; + const descCol = "DESCRIPTION"; + const fileCol = "FILE"; - log.info(` ${header}`); + const routeWidth = Math.max( + routeCol.length, + ...discovered.map((r) => r.routePath.length), + ); + const methodsWidth = Math.max( + methodsCol.length, + ...discovered.map((r) => formatMethods(r.methods).length), + ); + const descWidth = Math.max( + descCol.length, + ...discovered.map((r) => (r.description ?? "").length), + ); - for (const route of discovered) { - const cols = [ - `/x/${route.routePath}`.padEnd(routeWidth), - formatMethods(route.methods).padEnd(methodsWidth), - (route.description ?? "").padEnd(descWidth), - `routes/${relative(routesDir, route.filePath)}`, + const header = [ + routeCol.padEnd(routeWidth), + methodsCol.padEnd(methodsWidth), + descCol.padEnd(descWidth), + fileCol, ].join(" "); - log.info(` ${cols}`); - } - log.info(""); - const countLabel = discovered.length === 1 ? "route" : "routes"; - const summary = `${discovered.length} ${countLabel}`; - if (publicBase) { - log.info(` ${summary} • Public base: ${publicBase}`); - } else { - log.info(` ${summary}`); - } - log.info(""); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (opts.json) { - console.log(JSON.stringify({ ok: false, error: msg })); - } else { - log.error(`Error: ${msg}`); - } - process.exitCode = 1; - } - }); + log.info(` ${header}`); - routes - .command("inspect ") - .description("Show details of a specific user-defined route handler") - .option("--json", "Machine-readable JSON output") - .addHelpText( - "after", - ` + for (const route of discovered) { + const cols = [ + route.routePath.padEnd(routeWidth), + formatMethods(route.methods).padEnd(methodsWidth), + (route.description ?? "").padEnd(descWidth), + `routes/${route.filePath}`, + ].join(" "); + log.info(` ${cols}`); + } + + log.info(""); + const countLabel = discovered.length === 1 ? "route" : "routes"; + const summary = `${discovered.length} ${countLabel}`; + const firstPublicUrl = discovered.find((r) => r.publicUrl)?.publicUrl; + if (firstPublicUrl) { + const publicBase = firstPublicUrl.replace(/\/x\/.*$/, ""); + log.info(` ${summary} • Public base: ${publicBase}`); + } else { + log.info(` ${summary}`); + } + log.info(""); + }); + + routes + .command("inspect ") + .description("Show details of a specific user-defined route handler") + .option("--json", "Machine-readable JSON output") + .addHelpText( + "after", + ` Arguments: path Route path relative to /x/ (e.g. "my-dashboard-api/submit"). Do not include the /x/ prefix. @@ -300,103 +176,44 @@ public URL, file size, and last modified time. Examples: $ assistant routes inspect my-dashboard-api/submit $ assistant routes inspect items --json`, - ) - .action(async (routePath: string, opts: { json?: boolean }) => { - try { - const routesDir = getWorkspaceRoutesDir(); - const filePath = resolveHandlerFile(routesDir, routePath); - - if (!filePath) { - const msg = `No handler file found for route path "${routePath}"`; - if (opts.json) { - console.log(JSON.stringify({ ok: false, error: msg })); - } else { - log.error(msg); - log.info("Expected file at one of:"); - for (const ext of HANDLER_EXTENSIONS) { - log.info(` ${join(routesDir, `${routePath}${ext}`)}`); - log.info(` ${join(routesDir, routePath, `index${ext}`)}`); + ) + .action(async (routePath: string, opts: { json?: boolean }) => { + const r = await cliIpcCall<{ route: InspectedRoute }>( + "user_routes_inspect", + { path: routePath }, + ); + if (!r.ok) { + if (opts.json) { + console.log(JSON.stringify({ ok: false, error: r.error })); + process.exitCode = 1; + return; } + return exitFromIpcResult(r); } - process.exitCode = 1; - return; - } - - const stat = statSync(filePath); - const { methods, description } = await inspectModule(filePath); - const publicBase = tryGetPublicBaseUrl(); - const publicUrl = publicBase ? `${publicBase}/x/${routePath}` : null; - - if (opts.json) { - console.log( - JSON.stringify({ - ok: true, - route: { - routePath: `/x/${routePath}`, - methods, - description: description ?? null, - filePath, - publicUrl, - fileSize: stat.size, - modifiedAt: stat.mtime.toISOString(), - }, - }), - ); - return; - } - - log.info(""); - log.info(` Route: /x/${routePath}`); - log.info( - ` Methods: ${methods.join(", ") || "(none)"} (detected from named exports)`, - ); - if (description) { - log.info(` Description: ${description}`); - } - log.info(` File: ${filePath}`); - if (publicUrl) { - log.info(` Public URL: ${publicUrl}`); - } - log.info(` File Size: ${stat.size} bytes`); - log.info(` Modified: ${stat.mtime.toISOString()}`); - log.info(""); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (opts.json) { - console.log(JSON.stringify({ ok: false, error: msg })); - } else { - log.error(`Error: ${msg}`); - } - process.exitCode = 1; - } - }); -} - -/** - * Resolve a route path to a handler file on disk. - * Mirrors the resolution logic from UserRouteDispatcher. - */ -function resolveHandlerFile( - routesDir: string, - routePath: string, -): string | null { - const basePath = join(routesDir, routePath); - // Direct file match - for (const ext of HANDLER_EXTENSIONS) { - const candidate = `${basePath}${ext}`; - if (existsSync(candidate)) { - return candidate; - } - } + const route = r.result!.route; - // Index file convention - for (const ext of HANDLER_EXTENSIONS) { - const candidate = join(basePath, `index${ext}`); - if (existsSync(candidate)) { - return candidate; - } - } + if (opts.json) { + console.log(JSON.stringify({ ok: true, route })); + return; + } - return null; + log.info(""); + log.info(` Route: ${route.routePath}`); + log.info( + ` Methods: ${route.methods.join(", ") || "(none)"} (detected from named exports)`, + ); + if (route.description) { + log.info(` Description: ${route.description}`); + } + log.info(` File: ${route.filePath}`); + if (route.publicUrl) { + log.info(` Public URL: ${route.publicUrl}`); + } + log.info(` File Size: ${route.fileSize} bytes`); + log.info(` Modified: ${route.modifiedAt}`); + log.info(""); + }); + }, + }); } diff --git a/assistant/src/cli/commands/sequence.ts b/assistant/src/cli/commands/sequence.ts index dba09436a69..0a4c6af3a05 100644 --- a/assistant/src/cli/commands/sequence.ts +++ b/assistant/src/cli/commands/sequence.ts @@ -1,49 +1,18 @@ /** * CLI command group: `assistant sequence` * - * Manage email sequences — list, inspect, pause, resume, and view stats. + * Thin IPC wrapper — all logic lives in sequence-routes.ts. */ -import { Command } from "commander"; - -import { getDb } from "../../memory/db-connection.js"; -import { - getGuardrailConfig, - setGuardrailConfig, -} from "../../sequence/guardrails.js"; -import { - countActiveEnrollments, - exitEnrollment, - getSequence, - listEnrollments, - listSequences, - updateSequence, -} from "../../sequence/store.js"; +import type { Command } from "commander"; + +import { cliIpcCall, exitFromIpcResult } from "../../ipc/cli-client.js"; +import { registerCommand } from "../lib/register-command.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function output(data: unknown, json: boolean): void { - process.stdout.write( - json ? JSON.stringify(data) + "\n" : JSON.stringify(data, null, 2) + "\n", - ); -} - -function exitError(message: string): void { - output({ ok: false, error: message }, true); - process.exitCode = 1; -} - -function getJson(cmd: Command): boolean { - let c: Command | null = cmd; - while (c) { - if ((c.opts() as { json?: boolean }).json) return true; - c = c.parent; - } - return false; -} - function formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) return `${seconds}s`; @@ -55,19 +24,45 @@ function formatDuration(ms: number): string { return `${days}d ${hours % 24}h`; } +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface SequenceSummary { + id: string; + name: string; + status: string; + steps: { index: number; subjectTemplate: string; delaySeconds: number; requireApproval?: boolean }[]; + activeEnrollments: number; + description?: string; + channel?: string; + exitOnReply?: boolean; +} + +interface GuardrailConfig { + dailySendCap: number; + perSequenceHourlyRate: number; + minimumStepDelaySec: number; + maxActiveEnrollments: number; + duplicateEnrollmentCheck: boolean; + cooldownPeriodMs: number; +} + // --------------------------------------------------------------------------- // Command registration // --------------------------------------------------------------------------- export function registerSequenceCommand(program: Command): void { - const seqCmd = program - .command("sequence") - .description("Manage email sequences") - .option("--json", "Machine-readable JSON output"); - - seqCmd.addHelpText( - "after", - ` + registerCommand(program, { + name: "sequence", + transport: "ipc", + description: "Manage email sequences", + build: (seqCmd) => { + seqCmd.option("--json", "Machine-readable JSON output"); + + seqCmd.addHelpText( + "after", + ` Email sequences are automated multi-step email campaigns. Each sequence contains ordered steps with configurable delays, subject/body templates, and optional approval gates. Contacts are enrolled into a sequence and @@ -85,16 +80,16 @@ Examples: $ assistant sequence get seq_abc123 $ assistant sequence pause seq_abc123 $ assistant sequence stats`, - ); - - // ── list ────────────────────────────────────────────────────────── - seqCmd - .command("list") - .description("List all sequences") - .option("--status ", "Filter by status (active, paused, archived)") - .addHelpText( - "after", - ` + ); + + // ── list ────────────────────────────────────────────────────── + seqCmd + .command("list") + .description("List all sequences") + .option("--status ", "Filter by status (active, paused, archived)") + .addHelpText( + "after", + ` Lists all sequences with summary info: name, ID, status, step count, and active enrollment count. @@ -105,42 +100,46 @@ Examples: $ assistant sequence list $ assistant sequence list --status active $ assistant sequence list --status paused --json`, - ) - .action((opts: { status?: string }, cmd: Command) => { - getDb(); - const json = getJson(cmd); - const filter = opts.status - ? { status: opts.status as "active" | "paused" | "archived" } - : undefined; - const seqs = listSequences(filter); - - if (json) { - output({ ok: true, sequences: seqs }, true); - return; - } - - if (seqs.length === 0) { - process.stdout.write("No sequences found.\n"); - return; - } - - process.stdout.write(`${seqs.length} sequence(s):\n\n`); - for (const seq of seqs) { - const active = countActiveEnrollments(seq.id); - process.stdout.write( - ` ${seq.name} (${seq.id}) — ${seq.status}, ${seq.steps.length} steps, ${active} active\n`, - ); - } - process.stdout.write("\n"); - }); - - // ── get ──────────────────────────────────────────────────────────── - seqCmd - .command("get ") - .description("Get sequence details with enrollment stats") - .addHelpText( - "after", - ` + ) + .action(async (opts: { status?: string }) => { + const json = resolveJson(seqCmd); + const params: Record = {}; + if (opts.status) params.status = opts.status; + + const r = await cliIpcCall<{ sequences: SequenceSummary[] }>( + "sequence_list", + params, + ); + if (!r.ok) return exitFromIpcResult(r); + + const seqs = r.result!.sequences; + + if (json) { + console.log(JSON.stringify({ ok: true, sequences: seqs })); + return; + } + + if (seqs.length === 0) { + process.stdout.write("No sequences found.\n"); + return; + } + + process.stdout.write(`${seqs.length} sequence(s):\n\n`); + for (const seq of seqs) { + process.stdout.write( + ` ${seq.name} (${seq.id}) — ${seq.status}, ${seq.steps.length} steps, ${seq.activeEnrollments} active\n`, + ); + } + process.stdout.write("\n"); + }); + + // ── get ──────────────────────────────────────────────────────── + seqCmd + .command("get ") + .description("Get sequence details with enrollment stats") + .addHelpText( + "after", + ` Arguments: id The sequence ID (e.g. seq_abc123). Run 'assistant sequence list' to find IDs. @@ -151,69 +150,54 @@ breakdown by status (active, paused, completed, replied, cancelled, failed). Examples: $ assistant sequence get seq_abc123 $ assistant sequence get seq_abc123 --json`, - ) - .action((id: string, _opts: Record, cmd: Command) => { - getDb(); - const json = getJson(cmd); - const seq = getSequence(id); - if (!seq) return exitError(`Sequence not found: ${id}`); - - const enrollments = listEnrollments({ sequenceId: id }); - const statusCounts = enrollments.reduce( - (acc, e) => { - acc[e.status] = (acc[e.status] || 0) + 1; - return acc; - }, - {} as Record, - ); + ) + .action(async (id: string) => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ + sequence: SequenceSummary; + enrollments: { total: number; byStatus: Record }; + }>("sequence_get", { id }); + if (!r.ok) return exitFromIpcResult(r); + + const { sequence: seq, enrollments } = r.result!; + + if (json) { + console.log(JSON.stringify({ ok: true, sequence: seq, enrollments })); + return; + } + + process.stdout.write(` Name: ${seq.name}\n`); + process.stdout.write(` ID: ${seq.id}\n`); + process.stdout.write(` Status: ${seq.status}\n`); + if (seq.channel) process.stdout.write(` Channel: ${seq.channel}\n`); + if (seq.description) + process.stdout.write(` Description: ${seq.description}\n`); + process.stdout.write(` Exit on reply: ${seq.exitOnReply}\n`); + process.stdout.write(` Active: ${seq.activeEnrollments} enrollment(s)\n\n`); + + process.stdout.write(` Steps (${seq.steps.length}):\n`); + for (const step of seq.steps) { + const delay = formatDuration(step.delaySeconds * 1000); + const approval = step.requireApproval ? " [approval required]" : ""; + process.stdout.write( + ` ${step.index + 1}. "${step.subjectTemplate}" — delay: ${delay}${approval}\n`, + ); + } - if (json) { - output( - { - ok: true, - sequence: seq, - enrollments: { total: enrollments.length, byStatus: statusCounts }, - }, - true, - ); - return; - } - - const active = countActiveEnrollments(id); - process.stdout.write(` Name: ${seq.name}\n`); - process.stdout.write(` ID: ${seq.id}\n`); - process.stdout.write(` Status: ${seq.status}\n`); - process.stdout.write(` Channel: ${seq.channel}\n`); - if (seq.description) - process.stdout.write(` Description: ${seq.description}\n`); - process.stdout.write(` Exit on reply: ${seq.exitOnReply}\n`); - process.stdout.write(` Active: ${active} enrollment(s)\n\n`); - - process.stdout.write(` Steps (${seq.steps.length}):\n`); - for (const step of seq.steps) { - const delay = formatDuration(step.delaySeconds * 1000); - const approval = step.requireApproval ? " [approval required]" : ""; - process.stdout.write( - ` ${step.index + 1}. "${ - step.subjectTemplate - }" — delay: ${delay}${approval}\n`, - ); - } - - process.stdout.write(`\n Enrollments: ${enrollments.length} total\n`); - for (const [status, count] of Object.entries(statusCounts)) { - process.stdout.write(` ${status}: ${count}\n`); - } - process.stdout.write("\n"); - }); - - // ── pause ────────────────────────────────────────────────────────── - seqCmd - .command("pause ") - .description("Pause a sequence") - .addHelpText( - "after", - ` + process.stdout.write(`\n Enrollments: ${enrollments.total} total\n`); + for (const [status, count] of Object.entries(enrollments.byStatus)) { + process.stdout.write(` ${status}: ${count}\n`); + } + process.stdout.write("\n"); + }); + + // ── pause ────────────────────────────────────────────────────── + seqCmd + .command("pause ") + .description("Pause a sequence") + .addHelpText( + "after", + ` Arguments: id The sequence ID to pause (e.g. seq_abc123). Run 'assistant sequence list' to find IDs. @@ -224,27 +208,26 @@ until the sequence is resumed. No-op if the sequence is already paused. Examples: $ assistant sequence pause seq_abc123 $ assistant sequence pause seq_abc123 --json`, - ) - .action((id: string, _opts: Record, cmd: Command) => { - getDb(); - const json = getJson(cmd); - const seq = getSequence(id); - if (!seq) return exitError(`Sequence not found: ${id}`); - if (seq.status === "paused") { - output({ ok: true, message: "Sequence is already paused." }, json); - return; - } - updateSequence(id, { status: "paused" }); - output({ ok: true, message: `Sequence "${seq.name}" paused.` }, json); - }); - - // ── resume ───────────────────────────────────────────────────────── - seqCmd - .command("resume ") - .description("Resume a paused sequence") - .addHelpText( - "after", - ` + ) + .action(async (id: string) => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ message: string }>("sequence_pause", { id }); + if (!r.ok) return exitFromIpcResult(r); + + if (json) { + console.log(JSON.stringify({ ok: true, message: r.result!.message })); + } else { + process.stdout.write(r.result!.message + "\n"); + } + }); + + // ── resume ───────────────────────────────────────────────────── + seqCmd + .command("resume ") + .description("Resume a paused sequence") + .addHelpText( + "after", + ` Arguments: id The sequence ID to resume (e.g. seq_abc123). Run 'assistant sequence list' to find IDs. @@ -254,27 +237,26 @@ active enrollments. No-op if the sequence is already active. Examples: $ assistant sequence resume seq_abc123 $ assistant sequence resume seq_abc123 --json`, - ) - .action((id: string, _opts: Record, cmd: Command) => { - getDb(); - const json = getJson(cmd); - const seq = getSequence(id); - if (!seq) return exitError(`Sequence not found: ${id}`); - if (seq.status === "active") { - output({ ok: true, message: "Sequence is already active." }, json); - return; - } - updateSequence(id, { status: "active" }); - output({ ok: true, message: `Sequence "${seq.name}" resumed.` }, json); - }); - - // ── cancel-enrollment ────────────────────────────────────────────── - seqCmd - .command("cancel-enrollment ") - .description("Cancel a specific enrollment") - .addHelpText( - "after", - ` + ) + .action(async (id: string) => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ message: string }>("sequence_resume", { id }); + if (!r.ok) return exitFromIpcResult(r); + + if (json) { + console.log(JSON.stringify({ ok: true, message: r.result!.message })); + } else { + process.stdout.write(r.result!.message + "\n"); + } + }); + + // ── cancel-enrollment ────────────────────────────────────────── + seqCmd + .command("cancel-enrollment ") + .description("Cancel a specific enrollment") + .addHelpText( + "after", + ` Arguments: enrollmentId The enrollment ID to cancel (e.g. enr_xyz789). Run 'assistant sequence get ' to see enrollment IDs for a sequence. @@ -287,72 +269,70 @@ other enrollments. Examples: $ assistant sequence cancel-enrollment enr_xyz789 $ assistant sequence cancel-enrollment enr_xyz789 --json`, - ) - .action( - (enrollmentId: string, _opts: Record, cmd: Command) => { - getDb(); - const json = getJson(cmd); - exitEnrollment(enrollmentId, "cancelled"); - output( - { ok: true, message: `Enrollment ${enrollmentId} cancelled.` }, - json, - ); - }, - ); - - // ── stats ────────────────────────────────────────────────────────── - seqCmd - .command("stats") - .description("Overall sequence stats") - .addHelpText( - "after", - ` + ) + .action(async (enrollmentId: string) => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ message: string }>( + "sequence_cancel_enrollment", + { enrollmentId }, + ); + if (!r.ok) return exitFromIpcResult(r); + + if (json) { + console.log(JSON.stringify({ ok: true, message: r.result!.message })); + } else { + process.stdout.write(r.result!.message + "\n"); + } + }); + + // ── stats ────────────────────────────────────────────────────── + seqCmd + .command("stats") + .description("Overall sequence stats") + .addHelpText( + "after", + ` Returns aggregate statistics across all sequences: total and active sequence counts, total and active enrollment counts. Examples: $ assistant sequence stats $ assistant sequence stats --json`, - ) - .action((_opts: Record, cmd: Command) => { - getDb(); - const json = getJson(cmd); - const seqs = listSequences(); - const activeSeqs = seqs.filter((s) => s.status === "active").length; - const allEnrollments = listEnrollments(); - const activeEnrollments = allEnrollments.filter( - (e) => e.status === "active", - ).length; - - const stats = { - totalSequences: seqs.length, - activeSequences: activeSeqs, - totalEnrollments: allEnrollments.length, - activeEnrollments, - }; - - if (json) { - output({ ok: true, ...stats }, true); - return; - } - - process.stdout.write(`Sequence Stats:\n`); - process.stdout.write( - ` Sequences: ${stats.totalSequences} total, ${stats.activeSequences} active\n`, - ); - process.stdout.write( - ` Enrollments: ${stats.totalEnrollments} total, ${stats.activeEnrollments} active\n\n`, - ); - }); - - // ── guardrails ───────────────────────────────────────────────────── - const guardrailsCmd = seqCmd - .command("guardrails") - .description("View or update guardrail settings"); + ) + .action(async () => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ + totalSequences: number; + activeSequences: number; + totalEnrollments: number; + activeEnrollments: number; + }>("sequence_stats"); + if (!r.ok) return exitFromIpcResult(r); + + const stats = r.result!; + + if (json) { + console.log(JSON.stringify({ ok: true, ...stats })); + return; + } - guardrailsCmd.addHelpText( - "after", - ` + process.stdout.write(`Sequence Stats:\n`); + process.stdout.write( + ` Sequences: ${stats.totalSequences} total, ${stats.activeSequences} active\n`, + ); + process.stdout.write( + ` Enrollments: ${stats.totalEnrollments} total, ${stats.activeEnrollments} active\n\n`, + ); + }); + + // ── guardrails ───────────────────────────────────────────────── + const guardrailsCmd = seqCmd + .command("guardrails") + .description("View or update guardrail settings"); + + guardrailsCmd.addHelpText( + "after", + ` Guardrails are sequence-specific safety limits that prevent excessive sending and protect deliverability. They enforce daily send caps, per-sequence hourly rate limits, minimum delays between steps, maximum concurrent active @@ -362,14 +342,14 @@ Examples: $ assistant sequence guardrails show $ assistant sequence guardrails set dailySendCap 200 $ assistant sequence guardrails set cooldown_days 7`, - ); - - guardrailsCmd - .command("show") - .description("Show current guardrail configuration") - .addHelpText( - "after", - ` + ); + + guardrailsCmd + .command("show") + .description("Show current guardrail configuration") + .addHelpText( + "after", + ` Displays the current guardrail configuration with all safety limits: Daily send cap Max emails sent per day across all sequences @@ -382,39 +362,46 @@ Displays the current guardrail configuration with all safety limits: Examples: $ assistant sequence guardrails show $ assistant sequence guardrails show --json`, - ) - .action((_opts: Record, cmd: Command) => { - const json = getJson(cmd); - const cfg = getGuardrailConfig(); - if (json) { - output({ ok: true, config: cfg }, true); - return; - } - process.stdout.write("Guardrail Configuration:\n"); - process.stdout.write(` Daily send cap: ${cfg.dailySendCap}\n`); - process.stdout.write( - ` Hourly rate (per-seq): ${cfg.perSequenceHourlyRate}\n`, - ); - process.stdout.write( - ` Min step delay: ${cfg.minimumStepDelaySec}s\n`, - ); - process.stdout.write( - ` Max active enrollments: ${cfg.maxActiveEnrollments}\n`, - ); - process.stdout.write( - ` Duplicate check: ${cfg.duplicateEnrollmentCheck}\n`, - ); - process.stdout.write( - ` Cooldown period: ${formatDuration(cfg.cooldownPeriodMs)}\n\n`, - ); - }); - - guardrailsCmd - .command("set ") - .description("Update a guardrail setting") - .addHelpText( - "after", - ` + ) + .action(async () => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ config: GuardrailConfig }>( + "sequence_guardrails_show", + ); + if (!r.ok) return exitFromIpcResult(r); + + const cfg = r.result!.config; + + if (json) { + console.log(JSON.stringify({ ok: true, config: cfg })); + return; + } + + process.stdout.write("Guardrail Configuration:\n"); + process.stdout.write(` Daily send cap: ${cfg.dailySendCap}\n`); + process.stdout.write( + ` Hourly rate (per-seq): ${cfg.perSequenceHourlyRate}\n`, + ); + process.stdout.write( + ` Min step delay: ${cfg.minimumStepDelaySec}s\n`, + ); + process.stdout.write( + ` Max active enrollments: ${cfg.maxActiveEnrollments}\n`, + ); + process.stdout.write( + ` Duplicate check: ${cfg.duplicateEnrollmentCheck}\n`, + ); + process.stdout.write( + ` Cooldown period: ${formatDuration(cfg.cooldownPeriodMs)}\n\n`, + ); + }); + + guardrailsCmd + .command("set ") + .description("Update a guardrail setting") + .addHelpText( + "after", + ` Arguments: key The guardrail setting name (see valid keys below) value The new value (numeric for limits/caps, true/false for booleans) @@ -433,71 +420,40 @@ Examples: $ assistant sequence guardrails set hourly_rate 50 $ assistant sequence guardrails set duplicate_check true $ assistant sequence guardrails set cooldown_days 7`, - ) - .action( - ( - key: string, - value: string, - _opts: Record, - cmd: Command, - ) => { - const json = getJson(cmd); - const numVal = Number(value); - const boolVal = - value === "true" ? true : value === "false" ? false : undefined; - - const patch: Partial> = {}; - switch (key) { - case "dailySendCap": - case "daily_send_cap": - if (!Number.isFinite(numVal)) - return exitError(`Invalid numeric value for ${key}: ${value}`); - patch.dailySendCap = numVal; - break; - case "perSequenceHourlyRate": - case "hourly_rate": - if (!Number.isFinite(numVal)) - return exitError(`Invalid numeric value for ${key}: ${value}`); - patch.perSequenceHourlyRate = numVal; - break; - case "minimumStepDelaySec": - case "min_delay": - if (!Number.isFinite(numVal)) - return exitError(`Invalid numeric value for ${key}: ${value}`); - patch.minimumStepDelaySec = numVal; - break; - case "maxActiveEnrollments": - case "max_enrollments": - if (!Number.isFinite(numVal)) - return exitError(`Invalid numeric value for ${key}: ${value}`); - patch.maxActiveEnrollments = numVal; - break; - case "duplicateEnrollmentCheck": - case "duplicate_check": - if (boolVal === undefined) - return exitError("Value must be true or false"); - patch.duplicateEnrollmentCheck = boolVal; - break; - case "cooldownPeriodMs": - if (!Number.isFinite(numVal)) - return exitError(`Invalid numeric value for ${key}: ${value}`); - patch.cooldownPeriodMs = numVal; - break; - case "cooldown_days": { - if (!Number.isFinite(numVal)) - return exitError(`Invalid numeric value for ${key}: ${value}`); - patch.cooldownPeriodMs = numVal * 24 * 60 * 60 * 1000; - break; + ) + .action(async (key: string, value: string) => { + const json = resolveJson(seqCmd); + const r = await cliIpcCall<{ message: string; config: GuardrailConfig }>( + "sequence_guardrails_set", + { key, value }, + ); + if (!r.ok) return exitFromIpcResult(r); + + if (json) { + console.log( + JSON.stringify({ + ok: true, + message: r.result!.message, + config: r.result!.config, + }), + ); + } else { + process.stdout.write(r.result!.message + "\n"); } - default: - return exitError(`Unknown guardrail key: ${key}`); - } - - const updated = setGuardrailConfig(patch); - output( - { ok: true, message: `Updated ${key} = ${value}`, config: updated }, - json, - ); - }, - ); + }); + }, + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveJson(cmd: Command): boolean { + let c: Command | null = cmd; + while (c) { + if ((c.opts() as { json?: boolean }).json) return true; + c = c.parent; + } + return false; } diff --git a/assistant/src/runtime/routes/index.ts b/assistant/src/runtime/routes/index.ts index 729393bcd1f..1245d1f87ee 100644 --- a/assistant/src/runtime/routes/index.ts +++ b/assistant/src/runtime/routes/index.ts @@ -90,6 +90,7 @@ import { ROUTES as PS_ROUTES } from "./ps-routes.js"; import { ROUTES as RECORDING_ROUTES } from "./recording-routes.js"; import { ROUTES as RENAME_CONVERSATION_ROUTES } from "./rename-conversation-routes.js"; import { ROUTES as SCHEDULE_ROUTES } from "./schedule-routes.js"; +import { ROUTES as SEQUENCE_ROUTES } from "./sequence-routes.js"; import { ROUTES as SECRET_ROUTES } from "./secret-routes.js"; import { ROUTES as SETTINGS_ROUTES } from "./settings-routes.js"; import { ROUTES as SKILL_ROUTES } from "./skills-routes.js"; @@ -107,6 +108,7 @@ import type { RouteDefinition } from "./types.js"; import { ROUTES as UI_REQUEST_ROUTES } from "./ui-request-routes.js"; import { ROUTES as UPGRADE_BROADCAST_ROUTES } from "./upgrade-broadcast-routes.js"; import { ROUTES as USAGE_ROUTES } from "./usage-routes.js"; +import { ROUTES as USER_ROUTES_CLI } from "./user-routes-cli.js"; import { ROUTES as USER_ROUTES } from "./user-routes.js"; import { ROUTES as WAKE_CONVERSATION_ROUTES } from "./wake-conversation-routes.js"; import { ROUTES as WATCHER_ROUTES } from "./watcher-routes.js"; @@ -222,6 +224,8 @@ export const ROUTES: RouteDefinition[] = [ ...WORKSPACE_COMMIT_ROUTES, ...WAKE_CONVERSATION_ROUTES, ...WORKSPACE_ROUTES, + ...SEQUENCE_ROUTES, + ...USER_ROUTES_CLI, // User-defined routes under /x/* — MUST be last so built-in routes // always take priority over the catch-all pattern. diff --git a/assistant/src/runtime/routes/sequence-routes.ts b/assistant/src/runtime/routes/sequence-routes.ts new file mode 100644 index 00000000000..01980af02f0 --- /dev/null +++ b/assistant/src/runtime/routes/sequence-routes.ts @@ -0,0 +1,291 @@ +/** + * Transport-agnostic routes for email sequence management. + * + * Handles listing, inspecting, pausing, resuming sequences, + * cancelling enrollments, viewing stats, and managing guardrails. + */ + +import { z } from "zod"; + +import { getDb } from "../../memory/db-connection.js"; +import { + getGuardrailConfig, + setGuardrailConfig, +} from "../../sequence/guardrails.js"; +import { + countActiveEnrollments, + exitEnrollment, + getSequence, + listEnrollments, + listSequences, + updateSequence, +} from "../../sequence/store.js"; +import { BadRequestError, NotFoundError } from "./errors.js"; +import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; + +// ── Schemas ───────────────────────────────────────────────────────── + +const SequenceListParams = z + .object({ + status: z.enum(["active", "paused", "archived"]).optional(), + }) + .strict(); + +const SequenceIdParams = z + .object({ + id: z.string().min(1), + }) + .strict(); + +const CancelEnrollmentParams = z + .object({ + enrollmentId: z.string().min(1), + }) + .strict(); + +const GuardrailSetParams = z + .object({ + key: z.string().min(1), + value: z.string().min(1), + }) + .strict(); + +// ── Handlers ──────────────────────────────────────────────────────── + +function handleSequenceList({ body = {} }: RouteHandlerArgs) { + getDb(); + const { status } = SequenceListParams.parse(body); + const filter = status ? { status } : undefined; + const seqs = listSequences(filter); + + const sequences = seqs.map((seq) => ({ + ...seq, + activeEnrollments: countActiveEnrollments(seq.id), + })); + + return { ok: true, sequences }; +} + +function handleSequenceGet({ body = {} }: RouteHandlerArgs) { + getDb(); + const { id } = SequenceIdParams.parse(body); + const seq = getSequence(id); + if (!seq) throw new NotFoundError(`Sequence not found: ${id}`); + + const enrollments = listEnrollments({ sequenceId: id }); + const statusCounts = enrollments.reduce( + (acc, e) => { + acc[e.status] = (acc[e.status] || 0) + 1; + return acc; + }, + {} as Record, + ); + + return { + ok: true, + sequence: { ...seq, activeEnrollments: statusCounts["active"] ?? 0 }, + enrollments: { total: enrollments.length, byStatus: statusCounts }, + }; +} + +function handleSequencePause({ body = {} }: RouteHandlerArgs) { + getDb(); + const { id } = SequenceIdParams.parse(body); + const seq = getSequence(id); + if (!seq) throw new NotFoundError(`Sequence not found: ${id}`); + if (seq.status === "paused") { + return { ok: true, message: "Sequence is already paused." }; + } + updateSequence(id, { status: "paused" }); + return { ok: true, message: `Sequence "${seq.name}" paused.` }; +} + +function handleSequenceResume({ body = {} }: RouteHandlerArgs) { + getDb(); + const { id } = SequenceIdParams.parse(body); + const seq = getSequence(id); + if (!seq) throw new NotFoundError(`Sequence not found: ${id}`); + if (seq.status === "active") { + return { ok: true, message: "Sequence is already active." }; + } + updateSequence(id, { status: "active" }); + return { ok: true, message: `Sequence "${seq.name}" resumed.` }; +} + +function handleCancelEnrollment({ body = {} }: RouteHandlerArgs) { + getDb(); + const { enrollmentId } = CancelEnrollmentParams.parse(body); + exitEnrollment(enrollmentId, "cancelled"); + return { ok: true, message: `Enrollment ${enrollmentId} cancelled.` }; +} + +function handleSequenceStats() { + getDb(); + const seqs = listSequences(); + const activeSeqs = seqs.filter((s) => s.status === "active").length; + const allEnrollments = listEnrollments(); + const activeEnrollments = allEnrollments.filter( + (e) => e.status === "active", + ).length; + + return { + ok: true, + totalSequences: seqs.length, + activeSequences: activeSeqs, + totalEnrollments: allEnrollments.length, + activeEnrollments, + }; +} + +function handleGuardrailsShow() { + const cfg = getGuardrailConfig(); + return { ok: true, config: cfg }; +} + +function handleGuardrailsSet({ body = {} }: RouteHandlerArgs) { + const { key, value } = GuardrailSetParams.parse(body); + const numVal = Number(value); + const boolVal = + value === "true" ? true : value === "false" ? false : undefined; + + const patch: Partial> = {}; + switch (key) { + case "dailySendCap": + case "daily_send_cap": + if (!Number.isFinite(numVal)) + throw new BadRequestError(`Invalid numeric value for ${key}: ${value}`); + patch.dailySendCap = numVal; + break; + case "perSequenceHourlyRate": + case "hourly_rate": + if (!Number.isFinite(numVal)) + throw new BadRequestError(`Invalid numeric value for ${key}: ${value}`); + patch.perSequenceHourlyRate = numVal; + break; + case "minimumStepDelaySec": + case "min_delay": + if (!Number.isFinite(numVal)) + throw new BadRequestError(`Invalid numeric value for ${key}: ${value}`); + patch.minimumStepDelaySec = numVal; + break; + case "maxActiveEnrollments": + case "max_enrollments": + if (!Number.isFinite(numVal)) + throw new BadRequestError(`Invalid numeric value for ${key}: ${value}`); + patch.maxActiveEnrollments = numVal; + break; + case "duplicateEnrollmentCheck": + case "duplicate_check": + if (boolVal === undefined) + throw new BadRequestError("Value must be true or false"); + patch.duplicateEnrollmentCheck = boolVal; + break; + case "cooldownPeriodMs": + if (!Number.isFinite(numVal)) + throw new BadRequestError(`Invalid numeric value for ${key}: ${value}`); + patch.cooldownPeriodMs = numVal; + break; + case "cooldown_days": { + if (!Number.isFinite(numVal)) + throw new BadRequestError(`Invalid numeric value for ${key}: ${value}`); + patch.cooldownPeriodMs = numVal * 24 * 60 * 60 * 1000; + break; + } + default: + throw new BadRequestError(`Unknown guardrail key: ${key}`); + } + + const updated = setGuardrailConfig(patch); + return { ok: true, message: `Updated ${key} = ${value}`, config: updated }; +} + +// ── Route definitions ─────────────────────────────────────────────── + +export const ROUTES: RouteDefinition[] = [ + { + operationId: "sequence_list", + method: "POST", + endpoint: "sequences/list", + handler: handleSequenceList, + summary: "List sequences", + description: + "List all sequences, optionally filtered by status (active, paused, archived).", + tags: ["sequences"], + requestBody: SequenceListParams, + }, + { + operationId: "sequence_get", + method: "POST", + endpoint: "sequences/get", + handler: handleSequenceGet, + summary: "Get sequence details", + description: + "Get sequence details with enrollment stats, including step-by-step breakdown and enrollment status counts.", + tags: ["sequences"], + requestBody: SequenceIdParams, + }, + { + operationId: "sequence_pause", + method: "POST", + endpoint: "sequences/pause", + handler: handleSequencePause, + summary: "Pause a sequence", + description: + "Pause a sequence, halting all scheduled step deliveries. No-op if already paused.", + tags: ["sequences"], + requestBody: SequenceIdParams, + }, + { + operationId: "sequence_resume", + method: "POST", + endpoint: "sequences/resume", + handler: handleSequenceResume, + summary: "Resume a paused sequence", + description: + "Resume a paused sequence, re-enabling scheduled step deliveries. No-op if already active.", + tags: ["sequences"], + requestBody: SequenceIdParams, + }, + { + operationId: "sequence_cancel_enrollment", + method: "POST", + endpoint: "sequences/cancel-enrollment", + handler: handleCancelEnrollment, + summary: "Cancel a specific enrollment", + description: + "Cancel a specific enrollment, stopping all future step deliveries for that contact.", + tags: ["sequences"], + requestBody: CancelEnrollmentParams, + }, + { + operationId: "sequence_stats", + method: "GET", + endpoint: "sequences/stats", + handler: handleSequenceStats, + summary: "Overall sequence stats", + description: + "Returns aggregate statistics: total/active sequence counts and total/active enrollment counts.", + tags: ["sequences"], + }, + { + operationId: "sequence_guardrails_show", + method: "GET", + endpoint: "sequences/guardrails", + handler: handleGuardrailsShow, + summary: "Show guardrail configuration", + description: + "Display the current guardrail configuration: daily send cap, hourly rate, step delay, max enrollments, duplicate check, and cooldown period.", + tags: ["sequences"], + }, + { + operationId: "sequence_guardrails_set", + method: "POST", + endpoint: "sequences/guardrails", + handler: handleGuardrailsSet, + summary: "Update a guardrail setting", + description: + "Update a single guardrail setting by key. Valid keys: dailySendCap, perSequenceHourlyRate, minimumStepDelaySec, maxActiveEnrollments, duplicateEnrollmentCheck, cooldownPeriodMs, cooldown_days.", + tags: ["sequences"], + requestBody: GuardrailSetParams, + }, +]; diff --git a/assistant/src/runtime/routes/user-routes-cli.ts b/assistant/src/runtime/routes/user-routes-cli.ts new file mode 100644 index 00000000000..9e0beb53778 --- /dev/null +++ b/assistant/src/runtime/routes/user-routes-cli.ts @@ -0,0 +1,243 @@ +/** + * Transport-agnostic routes for inspecting user-defined route handlers. + * + * These complement the dispatch routes in user-routes.ts by exposing + * discovery and inspection endpoints for CLI consumption. The filesystem + * scanning logic that was previously in the CLI command is now here. + */ + +import { existsSync, readdirSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { z } from "zod"; + +import { getConfig } from "../../config/loader.js"; +import { getPublicBaseUrl } from "../../inbound/public-ingress-urls.js"; +import { getWorkspaceRoutesDir } from "../../util/platform.js"; +import { NotFoundError } from "./errors.js"; +import type { RouteDefinition, RouteHandlerArgs } from "./types.js"; + +// ── Constants ─────────────────────────────────────────────────────── + +const HTTP_METHODS = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS", +] as const; + +type HttpMethod = (typeof HTTP_METHODS)[number]; + +const HANDLER_EXTENSIONS = [".ts", ".js"] as const; + +type HandlerExtension = (typeof HANDLER_EXTENSIONS)[number]; + +// ── Schemas ───────────────────────────────────────────────────────── + +const InspectParams = z + .object({ + path: z.string().min(1), + }) + .strict(); + +// ── Helpers ───────────────────────────────────────────────────────── + +interface DiscoveredRoute { + routePath: string; + filePath: string; + methods: HttpMethod[]; + description?: string; + fileSize: number; + modifiedAt: string; +} + +async function inspectModule( + filePath: string, +): Promise<{ methods: HttpMethod[]; description?: string }> { + const stat = statSync(filePath); + const mod = (await import(`${filePath}?t=${stat.mtimeMs}`)) as Record< + string, + unknown + >; + + const methods: HttpMethod[] = []; + for (const method of HTTP_METHODS) { + if (typeof mod[method] === "function") { + methods.push(method); + } + } + + const description = + typeof mod.description === "string" ? mod.description : undefined; + + return { methods, description }; +} + +async function discoverRoutes(routesDir: string): Promise { + if (!existsSync(routesDir)) { + return []; + } + + const routes: DiscoveredRoute[] = []; + + function scanDir(dir: string): void { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + scanDir(fullPath); + } else if (entry.isFile()) { + const ext = HANDLER_EXTENSIONS.find((e) => entry.name.endsWith(e)) as + | HandlerExtension + | undefined; + if (!ext) continue; + + const relativePath = relative(routesDir, fullPath); + const withoutExt = relativePath.slice(0, -ext.length); + + let routePath = withoutExt.replace(/\\/g, "/"); + if (routePath.endsWith("/index")) { + routePath = routePath.slice(0, -"/index".length); + } else if (routePath === "index") { + routePath = ""; + } + + routes.push({ + routePath, + filePath: fullPath, + methods: [], + description: undefined, + fileSize: 0, + modifiedAt: "", + }); + } + } + } + + scanDir(routesDir); + + for (const route of routes) { + try { + const stat = statSync(route.filePath); + route.fileSize = stat.size; + route.modifiedAt = stat.mtime.toISOString(); + + const { methods, description } = await inspectModule(route.filePath); + route.methods = methods; + route.description = description; + } catch { + // If a module fails to load, keep it with empty methods + } + } + + return routes.sort((a, b) => a.routePath.localeCompare(b.routePath)); +} + +function tryGetPublicBaseUrl(): string | null { + try { + const config = getConfig(); + return getPublicBaseUrl(config); + } catch { + return null; + } +} + +function resolveHandlerFile( + routesDir: string, + routePath: string, +): string | null { + const basePath = join(routesDir, routePath); + + for (const ext of HANDLER_EXTENSIONS) { + const candidate = `${basePath}${ext}`; + if (existsSync(candidate)) { + return candidate; + } + } + + for (const ext of HANDLER_EXTENSIONS) { + const candidate = join(basePath, `index${ext}`); + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +// ── Handlers ──────────────────────────────────────────────────────── + +async function handleUserRoutesList() { + const routesDir = getWorkspaceRoutesDir(); + const discovered = await discoverRoutes(routesDir); + const publicBase = tryGetPublicBaseUrl(); + + const routes = discovered.map((r) => ({ + routePath: `/x/${r.routePath}`, + methods: r.methods, + description: r.description ?? null, + filePath: relative(routesDir, r.filePath), + publicUrl: publicBase ? `${publicBase}/x/${r.routePath}` : null, + })); + + return { ok: true, routes }; +} + +async function handleUserRoutesInspect({ body = {} }: RouteHandlerArgs) { + const { path: routePath } = InspectParams.parse(body); + const routesDir = getWorkspaceRoutesDir(); + const filePath = resolveHandlerFile(routesDir, routePath); + + if (!filePath) { + throw new NotFoundError( + `No handler file found for route path "${routePath}". Run 'assistant routes list' to see available routes.`, + ); + } + + const stat = statSync(filePath); + const { methods, description } = await inspectModule(filePath); + const publicBase = tryGetPublicBaseUrl(); + const publicUrl = publicBase ? `${publicBase}/x/${routePath}` : null; + + return { + ok: true, + route: { + routePath: `/x/${routePath}`, + methods, + description: description ?? null, + filePath, + publicUrl, + fileSize: stat.size, + modifiedAt: stat.mtime.toISOString(), + }, + }; +} + +// ── Route definitions ─────────────────────────────────────────────── + +export const ROUTES: RouteDefinition[] = [ + { + operationId: "user_routes_list", + method: "GET", + endpoint: "user-routes/list", + handler: handleUserRoutesList, + summary: "List user-defined route handlers", + description: + "Scan workspace routes directory for handler files and return discovered routes with methods and public URLs.", + tags: ["user-routes"], + }, + { + operationId: "user_routes_inspect", + method: "POST", + endpoint: "user-routes/inspect", + handler: handleUserRoutesInspect, + summary: "Inspect a user-defined route handler", + description: + "Load a specific handler file and return its exported methods, description, file path, public URL, and metadata.", + tags: ["user-routes"], + requestBody: InspectParams, + }, +];