- docs(FN-2562): document extracted terminal and session-diff registrars - fix(FN-2562): complete Step 5 — restore lint green - feat(FN-2562): complete Step 3 — extract session diff registrar - feat(FN-2562): complete Step 2 — create terminal route registrar - feat(FN-2562): complete Step 1 — extract diff-base helper module
3951 lines
140 KiB
TypeScript
3951 lines
140 KiB
TypeScript
import { Router, type Request, type Response, type NextFunction } from "express";
|
|
|
|
// Extend Express Request to include rawBody property set by webhook middleware
|
|
declare module "express" {
|
|
interface Request {
|
|
rawBody?: Buffer;
|
|
}
|
|
}
|
|
import multer from "multer";
|
|
import { resolve, sep, join, isAbsolute } from "node:path";
|
|
import * as nodeFs from "node:fs";
|
|
|
|
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
|
|
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
|
import type { ServerOptions } from "./server.js";
|
|
import { terminalSessionManager } from "./terminal.js";
|
|
import { getTerminalService } from "./terminal-service.js";
|
|
import { verifyWebhookSignature } from "./github-webhooks.js";
|
|
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
|
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
|
import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js";
|
|
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
|
|
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
|
|
import { writeSSEEvent } from "./sse-buffer.js";
|
|
import {
|
|
ApiError,
|
|
badRequest,
|
|
conflict,
|
|
internalError,
|
|
notFound,
|
|
rateLimited,
|
|
rethrowAsApiError,
|
|
sendErrorResponse,
|
|
unauthorized,
|
|
} from "./api-error.js";
|
|
import { resolvePluginManifest } from "./plugin-routes.js";
|
|
import { createSessionDiagnostics } from "./ai-session-diagnostics.js";
|
|
import { createApiRoutesContext } from "./routes/context.js";
|
|
import { registerTaskWorkflowRoutes } from "./routes/register-task-workflow-routes.js";
|
|
import { registerPlanningSubtaskRoutes } from "./routes/register-planning-subtask-routes.js";
|
|
import { registerChatRoutes } from "./routes/register-chat-routes.js";
|
|
import { registerSettingsMemoryRoutes } from "./routes/register-settings-memory-routes.js";
|
|
import { registerMessagingScriptRoutes } from "./routes/register-messaging-scripts.js";
|
|
import { registerGitGitHubRoutes } from "./routes/register-git-github.js";
|
|
import { registerFileWorkspaceRoutes } from "./routes/register-file-workspace-routes.js";
|
|
import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-projects-nodes.js";
|
|
import { registerProjectRoutes } from "./routes/register-project-routes.js";
|
|
import { registerNodeRoutes } from "./routes/register-node-routes.js";
|
|
import { registerSettingsSyncRoutes } from "./routes/register-settings-sync-routes.js";
|
|
import { registerMeshRoutes } from "./routes/register-mesh-routes.js";
|
|
import { registerDiscoveryRoutes } from "./routes/register-discovery-routes.js";
|
|
import { registerSettingsSyncInboundRoutes } from "./routes/register-settings-sync-inbound-routes.js";
|
|
import { registerAgentCoreListCreateRoutes, registerAgentCoreRoutes } from "./routes/register-agent-core-routes.js";
|
|
import { registerAgentRuntimeRoutes } from "./routes/register-agent-runtime-routes.js";
|
|
import { registerAgentReflectionRatingRoutes } from "./routes/register-agent-reflection-rating-routes.js";
|
|
import { registerAgentImportExportRoutes, registerAgentGenerationRoutes } from "./routes/register-agent-import-export-generation-routes.js";
|
|
import { registerAgentSkillsRoutes } from "./routes/register-agent-skills-routes.js";
|
|
import { registerPluginsAutomationRoutes } from "./routes/register-plugins-automation.js";
|
|
import { registerProxyRoutes } from "./routes/register-proxy.js";
|
|
import { registerModelRoutes } from "./routes/register-model-routes.js";
|
|
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
|
|
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
|
|
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
|
|
import { registerTerminalRoutes } from "./routes/register-terminal-routes.js";
|
|
import { registerSessionDiffRoutes } from "./routes/register-session-diff-routes.js";
|
|
import { runGitCommand } from "./routes/resolve-diff-base.js";
|
|
|
|
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500;
|
|
|
|
/**
|
|
* Compatibility export surface:
|
|
* `createApiRoutes`, `AuthStorageLike`, `ModelRegistryLike`,
|
|
* `__resetBatchImportRateLimiter`, and `__setCreateFnAgentForRefine`
|
|
* intentionally remain exported from this file for existing tests/importers.
|
|
*/
|
|
export { __resetBatchImportRateLimiter } from "./routes/register-git-github.js";
|
|
|
|
/**
|
|
* Minimal interface matching pi-coding-agent's ModelRegistry API surface
|
|
* used by the models route. Avoids a direct dependency on the pi-coding-agent package.
|
|
*/
|
|
export interface ModelRegistryLike {
|
|
/** Reload models from disk to pick up changes. */
|
|
refresh(): void;
|
|
/** Get models that have auth configured. */
|
|
getAvailable(): Array<{ id: string; name: string; provider: string; reasoning: boolean; contextWindow: number }>;
|
|
}
|
|
|
|
/**
|
|
* Minimal interface matching pi-coding-agent's AuthStorage API surface
|
|
* used by the auth routes. Avoids a direct dependency on the pi-coding-agent package.
|
|
*/
|
|
export interface AuthStorageLike {
|
|
reload(): void;
|
|
getOAuthProviders(): Array<{ id: string; name: string }>;
|
|
hasAuth(provider: string): boolean;
|
|
login(
|
|
providerId: string,
|
|
callbacks: {
|
|
onAuth: (info: { url: string; instructions?: string }) => void;
|
|
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
|
onProgress?: (message: string) => void;
|
|
signal?: AbortSignal;
|
|
},
|
|
): Promise<void>;
|
|
logout(provider: string): void;
|
|
/** Get providers that accept API keys (non-OAuth). Returns provider id and name. */
|
|
getApiKeyProviders?(): Array<{ id: string; name: string }>;
|
|
/** Save an API key for a provider. Creates or overwrites the existing key. */
|
|
setApiKey?(providerId: string, apiKey: string): void;
|
|
/** Remove the stored API key for a provider. No-op if not set. */
|
|
clearApiKey?(providerId: string): void;
|
|
/** Check if a provider has an API key configured. */
|
|
hasApiKey?(providerId: string): boolean;
|
|
/** Get the configured API key for usage providers. */
|
|
getApiKey?(providerId: string): string | null | undefined | Promise<string | null | undefined>;
|
|
/** Get raw stored credentials for usage providers. */
|
|
get?(providerId: string): { type?: string; key?: string } | null | undefined;
|
|
}
|
|
|
|
/**
|
|
* Extended session interface for workflow step refinement.
|
|
* The AgentSession from @mariozechner/pi-coding-agent has on() and prompt() methods
|
|
* but the local AgentSession type is minimal.
|
|
*/
|
|
interface RefineAgentSession {
|
|
on(event: "text", listener: (delta: string) => void): void;
|
|
prompt(text: string): Promise<void>;
|
|
dispose(): void;
|
|
}
|
|
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
|
});
|
|
|
|
function readJsonObject(path: string): Record<string, unknown> {
|
|
if (!nodeFs.existsSync(path)) {
|
|
return {};
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(nodeFs.readFileSync(path, "utf-8"));
|
|
return parsed && typeof parsed === "object" ? parsed as Record<string, unknown> : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function hasPackageManagerSettings(settings: Record<string, unknown>): boolean {
|
|
return Array.isArray(settings.packages) || Array.isArray(settings.npmCommand);
|
|
}
|
|
|
|
function getPiPackageManagerAgentDir(): string {
|
|
const fusionAgentDir = getFusionAgentDir();
|
|
const legacyAgentDir = getLegacyPiAgentDir();
|
|
const fusionSettings = readJsonObject(join(fusionAgentDir, "settings.json"));
|
|
const legacySettings = readJsonObject(join(legacyAgentDir, "settings.json"));
|
|
|
|
if (hasPackageManagerSettings(fusionSettings) || !nodeFs.existsSync(legacyAgentDir)) {
|
|
return fusionAgentDir;
|
|
}
|
|
if (hasPackageManagerSettings(legacySettings)) {
|
|
return legacyAgentDir;
|
|
}
|
|
return nodeFs.existsSync(fusionAgentDir) ? fusionAgentDir : legacyAgentDir;
|
|
}
|
|
|
|
function packageExtensionName(extensionPath: string, source: string): string {
|
|
const base = resolve(extensionPath).split(sep).pop()?.replace(/\.(ts|js)$/i, "") || source;
|
|
if (base !== "index") {
|
|
return base;
|
|
}
|
|
return source.replace(/^(npm:|git:)/, "").split(/[/:@#]/).filter(Boolean).pop() || base;
|
|
}
|
|
|
|
async function discoverDashboardPiExtensions(cwd: string): Promise<PiExtensionSettings> {
|
|
const settings = discoverPiExtensions(cwd);
|
|
const disabled = new Set(settings.disabledIds.map((id) => resolve(id)));
|
|
const byPath = new Map(settings.extensions.map((entry) => [entry.id, entry]));
|
|
|
|
try {
|
|
const { DefaultPackageManager } = await import("@mariozechner/pi-coding-agent");
|
|
const agentDir = getPiPackageManagerAgentDir();
|
|
const legacyGlobalSettings = readJsonObject(join(getLegacyPiAgentDir(), "settings.json"));
|
|
const fusionGlobalSettings = readJsonObject(join(getFusionAgentDir(), "settings.json"));
|
|
const globalSettings = { ...legacyGlobalSettings, ...fusionGlobalSettings };
|
|
const projectSettings = readJsonObject(join(cwd, ".fusion", "settings.json"));
|
|
const mergedSettings = { ...globalSettings, ...projectSettings };
|
|
const packageManager = new DefaultPackageManager({
|
|
cwd,
|
|
agentDir,
|
|
settingsManager: {
|
|
getGlobalSettings: () => structuredClone(globalSettings),
|
|
getProjectSettings: () => structuredClone(projectSettings),
|
|
getNpmCommand: () => Array.isArray(mergedSettings.npmCommand)
|
|
? [...mergedSettings.npmCommand]
|
|
: undefined,
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- settingsManager shape varies across pi-coding-agent versions; typed as any per upstream API
|
|
} as any,
|
|
});
|
|
const resolved = await packageManager.resolve(async () => "skip");
|
|
|
|
for (const extension of resolved.extensions) {
|
|
const id = resolve(extension.path);
|
|
const source = extension.metadata?.source || "package";
|
|
byPath.set(id, {
|
|
id,
|
|
name: packageExtensionName(id, source),
|
|
path: id,
|
|
source: "package",
|
|
enabled: extension.enabled && !disabled.has(id),
|
|
} satisfies PiExtensionEntry);
|
|
}
|
|
} catch {
|
|
// Filesystem-discovered extensions are still useful if package resolution fails.
|
|
}
|
|
|
|
return {
|
|
...settings,
|
|
extensions: [...byPath.values()].sort((a, b) => a.name.localeCompare(b.name) || a.path.localeCompare(b.path)),
|
|
};
|
|
}
|
|
|
|
// Dynamic import fallback for @fusion/engine with injectable override for tests.
|
|
let createFnAgentForRefine: typeof import("@fusion/engine").createFnAgent | undefined;
|
|
|
|
/** @internal Inject a mock createFnAgent function for workflow-step refine route tests. */
|
|
export function __setCreateFnAgentForRefine(mock: typeof createFnAgentForRefine): void {
|
|
createFnAgentForRefine = mock;
|
|
}
|
|
|
|
// Default system prompt for workflow step refinement (fallback when overrides unavailable)
|
|
|
|
let resolveWorkflowStepRefinePrompt: (key: string, overrides?: Record<string, string | null>) => string = () => DEFAULT_WORKFLOW_STEP_REFINE_PROMPT;
|
|
let promptOverridesReady = false;
|
|
|
|
async function initPromptOverrides() {
|
|
if (promptOverridesReady) return;
|
|
try {
|
|
const core = await import("@fusion/core");
|
|
resolveWorkflowStepRefinePrompt = (key: string, overrides?: Record<string, string | null>) =>
|
|
core.resolvePrompt(key as keyof typeof core.PROMPT_KEY_CATALOG, overrides);
|
|
promptOverridesReady = true;
|
|
} catch {
|
|
resolveWorkflowStepRefinePrompt = () => DEFAULT_WORKFLOW_STEP_REFINE_PROMPT;
|
|
promptOverridesReady = true;
|
|
}
|
|
}
|
|
|
|
// Initialize on module load
|
|
initPromptOverrides();
|
|
|
|
/** Default system prompt for workflow step refinement */
|
|
const DEFAULT_WORKFLOW_STEP_REFINE_PROMPT = `You are an expert at creating detailed agent prompts for workflow steps.
|
|
|
|
A workflow step is a quality gate that runs after a task is implemented but before it's marked complete.
|
|
|
|
Given a rough description, create a detailed prompt that an AI agent can follow to execute this workflow step.
|
|
|
|
The prompt should:
|
|
1. Define the purpose clearly
|
|
2. Specify what files/context to examine
|
|
3. List specific criteria to check
|
|
4. Describe what "success" looks like
|
|
5. Include guidance on handling common edge cases
|
|
|
|
Output ONLY the prompt text (no markdown, no explanations).`;
|
|
|
|
function validateOptionalModelField(value: unknown, name: string): string | undefined {
|
|
if (value === undefined || value === null) return undefined;
|
|
if (typeof value !== "string") {
|
|
throw new Error(`${name} must be a string`);
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : undefined;
|
|
}
|
|
|
|
function normalizeModelSelectionPair(provider: string | undefined, modelId: string | undefined) {
|
|
if (!provider || !modelId) {
|
|
return { provider: undefined, modelId: undefined };
|
|
}
|
|
|
|
return { provider, modelId };
|
|
}
|
|
|
|
function assertConsistentOptionalPair(
|
|
provider: unknown,
|
|
modelId: unknown,
|
|
pairName: string,
|
|
): { provider?: string; modelId?: string } {
|
|
const normalizedProvider = validateOptionalModelField(provider, `${pairName} provider`);
|
|
const normalizedModelId = validateOptionalModelField(modelId, `${pairName} modelId`);
|
|
|
|
if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) {
|
|
throw new Error(`${pairName} must include both provider and modelId or neither`);
|
|
}
|
|
|
|
return {
|
|
provider: normalizedProvider,
|
|
modelId: normalizedModelId,
|
|
};
|
|
}
|
|
|
|
export { resolveDiffBase, type ResolveDiffBaseTaskInput, runGitCommand } from "./routes/resolve-diff-base.js";
|
|
|
|
function slugifyPresetName(name: string): string {
|
|
const slug = name
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^[-_]+|[-_]+$/g, "")
|
|
.slice(0, 32);
|
|
return slug || "preset";
|
|
}
|
|
|
|
function validateModelPresets(value: unknown): ModelPreset[] | undefined {
|
|
if (value === undefined) return undefined;
|
|
if (!Array.isArray(value)) {
|
|
throw new Error("modelPresets must be an array");
|
|
}
|
|
|
|
const seenIds = new Set<string>();
|
|
|
|
return value.map((preset, index) => {
|
|
if (!preset || typeof preset !== "object") {
|
|
throw new Error(`modelPresets[${index}] must be an object`);
|
|
}
|
|
|
|
const candidate = preset as Record<string, unknown>;
|
|
const rawId = validateOptionalModelField(candidate.id, `modelPresets[${index}].id`);
|
|
const name = validateOptionalModelField(candidate.name, `modelPresets[${index}].name`);
|
|
|
|
if (!name) {
|
|
throw new Error(`modelPresets[${index}].name is required`);
|
|
}
|
|
|
|
// Auto-generate ID from name when not provided
|
|
let id = rawId || slugifyPresetName(name);
|
|
|
|
// If the explicit ID collides, fall back to the slugified name
|
|
if (seenIds.has(id)) {
|
|
const slugId = slugifyPresetName(name);
|
|
if (!seenIds.has(slugId)) {
|
|
id = slugId;
|
|
} else {
|
|
// Both explicit ID and slug collide — append -1, -2, etc.
|
|
const maxBase = 30;
|
|
let idx = 1;
|
|
while (seenIds.has(id) && idx < 100) {
|
|
const suffix = `-${idx}`;
|
|
id = `${slugId.slice(0, maxBase - suffix.length)}${suffix}`;
|
|
idx++;
|
|
}
|
|
}
|
|
}
|
|
seenIds.add(id);
|
|
|
|
const executor = assertConsistentOptionalPair(
|
|
candidate.executorProvider,
|
|
candidate.executorModelId,
|
|
`modelPresets[${index}].executor`,
|
|
);
|
|
const validator = assertConsistentOptionalPair(
|
|
candidate.validatorProvider,
|
|
candidate.validatorModelId,
|
|
`modelPresets[${index}].validator`,
|
|
);
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
executorProvider: executor.provider,
|
|
executorModelId: executor.modelId,
|
|
validatorProvider: validator.provider,
|
|
validatorModelId: validator.modelId,
|
|
};
|
|
});
|
|
}
|
|
|
|
function sanitizeOverlapIgnorePaths(value: unknown): string[] | undefined {
|
|
if (value === undefined || value === null) return undefined;
|
|
if (!Array.isArray(value)) {
|
|
throw badRequest("overlapIgnorePaths must be an array of project-relative paths");
|
|
}
|
|
|
|
const normalized = value.map((entry, index) => {
|
|
if (typeof entry !== "string") {
|
|
throw badRequest(`overlapIgnorePaths[${index}] must be a string`);
|
|
}
|
|
|
|
const trimmed = entry.trim().replaceAll("\\", "/");
|
|
if (!trimmed) {
|
|
throw badRequest(`overlapIgnorePaths[${index}] cannot be empty`);
|
|
}
|
|
|
|
if (isAbsolute(trimmed) || /^[a-zA-Z]:\//.test(trimmed)) {
|
|
throw badRequest(`overlapIgnorePaths[${index}] must be a project-relative path`);
|
|
}
|
|
|
|
if (/^\.{1,2}(\/|$)/.test(trimmed) || /(^|\/)\.\.(\/|$)/.test(trimmed)) {
|
|
throw badRequest(`overlapIgnorePaths[${index}] cannot include '..' traversal`);
|
|
}
|
|
|
|
return trimmed;
|
|
});
|
|
|
|
return [...new Set(normalized)];
|
|
}
|
|
|
|
// ── Run-Audit Timeline Types & Helpers ─────────────────────────────────────
|
|
|
|
/** Valid domain filters for run-audit queries. */
|
|
export type RunAuditDomainFilter = "database" | "git" | "filesystem";
|
|
|
|
/** Filter options for run-audit queries. */
|
|
export interface RunAuditQueryFilters {
|
|
/** Filter by task ID */
|
|
taskId?: string;
|
|
/** Filter by domain category */
|
|
domain?: RunAuditDomainFilter;
|
|
/** Start of time range (inclusive, ISO-8601) */
|
|
startTime?: string;
|
|
/** End of time range (inclusive, ISO-8601) */
|
|
endTime?: string;
|
|
/** Maximum number of events to return */
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* Normalized run-audit event for UI consumption.
|
|
* Provides stable, user-friendly field names.
|
|
*/
|
|
export interface NormalizedRunAuditEvent {
|
|
/** Unique event identifier */
|
|
id: string;
|
|
/** ISO-8601 timestamp when the event occurred */
|
|
timestamp: string;
|
|
/** Task ID associated with this event (if applicable) */
|
|
taskId?: string;
|
|
/** Domain category: database, git, or filesystem */
|
|
domain: "database" | "git" | "filesystem";
|
|
/** Type of mutation (e.g., "task:update", "git:commit", "file:write") */
|
|
mutationType: string;
|
|
/** Target of the mutation (e.g., task ID, file path, branch name) */
|
|
target: string;
|
|
/** Human-readable summary of the mutation */
|
|
summary: string;
|
|
/** Structured metadata about the mutation */
|
|
metadata?: Record<string, unknown>;
|
|
}
|
|
|
|
/**
|
|
* Unified timeline entry that can represent either an audit event or an agent log entry.
|
|
* Used for correlated timeline views.
|
|
*/
|
|
export interface TimelineEntry {
|
|
/** ISO-8601 timestamp when the entry occurred */
|
|
timestamp: string;
|
|
/** Entry type discriminator */
|
|
type: "audit" | "log";
|
|
/** Stable sort key to ensure deterministic ordering for identical timestamps */
|
|
sortKey: string;
|
|
/** Normalized audit event (when type is "audit") */
|
|
audit?: NormalizedRunAuditEvent;
|
|
/** Agent log entry (when type is "log") */
|
|
log?: import("@fusion/core").AgentLogEntry;
|
|
}
|
|
|
|
/**
|
|
* Response shape for GET /api/agents/:id/runs/:runId/audit
|
|
*/
|
|
export interface RunAuditResponse {
|
|
/** The run ID these events belong to */
|
|
runId: string;
|
|
/** Normalized audit events */
|
|
events: NormalizedRunAuditEvent[];
|
|
/** Filter metadata */
|
|
filters: {
|
|
taskId?: string;
|
|
domain?: RunAuditDomainFilter;
|
|
startTime?: string;
|
|
endTime?: string;
|
|
};
|
|
/** Total count of events matching filters */
|
|
totalCount: number;
|
|
/** Whether there are more events (when limit was applied) */
|
|
hasMore: boolean;
|
|
}
|
|
|
|
/**
|
|
* Response shape for GET /api/agents/:id/runs/:runId/timeline
|
|
*/
|
|
export interface RunTimelineResponse {
|
|
/** Run metadata */
|
|
run: {
|
|
id: string;
|
|
agentId: string;
|
|
startedAt: string;
|
|
endedAt?: string;
|
|
status: string;
|
|
taskId?: string;
|
|
};
|
|
/** Grouped audit events by domain */
|
|
auditByDomain: {
|
|
database: NormalizedRunAuditEvent[];
|
|
git: NormalizedRunAuditEvent[];
|
|
filesystem: NormalizedRunAuditEvent[];
|
|
};
|
|
/** Count metadata */
|
|
counts: {
|
|
auditEvents: number;
|
|
logEntries: number;
|
|
};
|
|
/** Merged and deterministically sorted timeline */
|
|
timeline: TimelineEntry[];
|
|
}
|
|
|
|
/**
|
|
* Parse and validate run-audit query filters from request query params.
|
|
* Throws ApiError with 400 for invalid values.
|
|
*/
|
|
function parseRunAuditFilters(query: Record<string, unknown>): RunAuditQueryFilters {
|
|
const filters: RunAuditQueryFilters = {};
|
|
|
|
// Parse taskId
|
|
if (query.taskId !== undefined) {
|
|
if (typeof query.taskId !== "string" || !query.taskId.trim()) {
|
|
throw new ApiError(400, "taskId must be a non-empty string");
|
|
}
|
|
filters.taskId = query.taskId.trim();
|
|
}
|
|
|
|
// Parse domain
|
|
if (query.domain !== undefined) {
|
|
if (typeof query.domain !== "string") {
|
|
throw new ApiError(400, "domain must be a string");
|
|
}
|
|
const domain = query.domain.toLowerCase();
|
|
if (domain !== "database" && domain !== "git" && domain !== "filesystem") {
|
|
throw new ApiError(400, "domain must be one of: database, git, filesystem");
|
|
}
|
|
filters.domain = domain as RunAuditDomainFilter;
|
|
}
|
|
|
|
// Parse startTime
|
|
if (query.startTime !== undefined) {
|
|
if (typeof query.startTime !== "string" || !query.startTime.trim()) {
|
|
throw new ApiError(400, "startTime must be a non-empty ISO-8601 string");
|
|
}
|
|
const date = new Date(query.startTime);
|
|
if (isNaN(date.getTime())) {
|
|
throw new ApiError(400, "startTime must be a valid ISO-8601 date string");
|
|
}
|
|
filters.startTime = query.startTime.trim();
|
|
}
|
|
|
|
// Parse endTime
|
|
if (query.endTime !== undefined) {
|
|
if (typeof query.endTime !== "string" || !query.endTime.trim()) {
|
|
throw new ApiError(400, "endTime must be a non-empty ISO-8601 string");
|
|
}
|
|
const date = new Date(query.endTime);
|
|
if (isNaN(date.getTime())) {
|
|
throw new ApiError(400, "endTime must be a valid ISO-8601 date string");
|
|
}
|
|
filters.endTime = query.endTime.trim();
|
|
}
|
|
|
|
// Validate time range consistency
|
|
if (filters.startTime && filters.endTime) {
|
|
const start = new Date(filters.startTime);
|
|
const end = new Date(filters.endTime);
|
|
if (start > end) {
|
|
throw new ApiError(400, "startTime must be before or equal to endTime");
|
|
}
|
|
}
|
|
|
|
// Parse limit
|
|
if (query.limit !== undefined) {
|
|
const limitStr = typeof query.limit === "string" ? query.limit : String(query.limit);
|
|
const limit = parseInt(limitStr, 10);
|
|
if (!Number.isFinite(limit) || limit < 1) {
|
|
throw new ApiError(400, "limit must be a positive integer");
|
|
}
|
|
filters.limit = Math.min(limit, 1000); // Cap at 1000
|
|
}
|
|
|
|
return filters;
|
|
}
|
|
|
|
/**
|
|
* Normalize a raw RunAuditEvent to a NormalizedRunAuditEvent for UI consumption.
|
|
*/
|
|
function normalizeRunAuditEvent(event: import("@fusion/core").RunAuditEvent): NormalizedRunAuditEvent {
|
|
// Generate a human-readable summary based on domain and mutation type
|
|
const summary = generateAuditSummary(event.domain, event.mutationType, event.target, event.metadata);
|
|
|
|
return {
|
|
id: event.id,
|
|
timestamp: event.timestamp,
|
|
taskId: event.taskId,
|
|
domain: event.domain,
|
|
mutationType: event.mutationType,
|
|
target: event.target,
|
|
summary,
|
|
metadata: event.metadata,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generate a human-readable summary for an audit event.
|
|
*/
|
|
function generateAuditSummary(
|
|
domain: string,
|
|
mutationType: string,
|
|
target: string,
|
|
_metadata?: Record<string, unknown>,
|
|
): string {
|
|
const parts: string[] = [];
|
|
|
|
// Add domain prefix
|
|
switch (domain) {
|
|
case "database":
|
|
parts.push("DB");
|
|
break;
|
|
case "git":
|
|
parts.push("Git");
|
|
break;
|
|
case "filesystem":
|
|
parts.push("FS");
|
|
break;
|
|
default:
|
|
parts.push(domain);
|
|
}
|
|
|
|
// Add mutation action
|
|
const action = mutationType.split(":").pop() ?? mutationType;
|
|
parts.push(action);
|
|
|
|
// Add target context
|
|
if (target) {
|
|
// Truncate long targets for readability
|
|
const displayTarget = target.length > 50 ? `${target.slice(0, 47)}...` : target;
|
|
parts.push(`(${displayTarget})`);
|
|
}
|
|
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/**
|
|
* Sort comparator for timeline entries with deterministic tie-breaking.
|
|
* Primary sort: timestamp ascending
|
|
* Tie-breaker: sortKey ascending (which incorporates type and event ID)
|
|
*/
|
|
function compareTimelineEntries(a: TimelineEntry, b: TimelineEntry): number {
|
|
const timeA = new Date(a.timestamp).getTime();
|
|
const timeB = new Date(b.timestamp).getTime();
|
|
|
|
if (timeA !== timeB) {
|
|
return timeA - timeB;
|
|
}
|
|
|
|
// Deterministic tie-breaker: sortKey ascending
|
|
// This ensures consistent ordering when timestamps are identical
|
|
return a.sortKey.localeCompare(b.sortKey);
|
|
}
|
|
|
|
/**
|
|
* Create a stable sort key for a timeline entry.
|
|
* Format: "{type_prefix}_{timestamp_ms}_{entry_id}"
|
|
* The type prefix ensures audit events and log entries don't conflict.
|
|
* The timestamp in ms ensures microsecond precision.
|
|
* The entry ID provides final tie-breaking.
|
|
*/
|
|
function createTimelineSortKey(
|
|
type: "audit" | "log",
|
|
timestamp: string,
|
|
id: string,
|
|
): string {
|
|
const ms = new Date(timestamp).getTime();
|
|
const typePrefix = type === "audit" ? "A" : "L";
|
|
// Use a sanitized ID that won't interfere with sorting
|
|
const sanitizedId = id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
return `${typePrefix}_${String(ms).padStart(16, "0")}_${sanitizedId}`;
|
|
}
|
|
|
|
/**
|
|
* Convert an audit event to a timeline entry.
|
|
*/
|
|
function auditEventToTimelineEntry(event: import("@fusion/core").RunAuditEvent): TimelineEntry {
|
|
const normalized = normalizeRunAuditEvent(event);
|
|
return {
|
|
timestamp: event.timestamp,
|
|
type: "audit",
|
|
sortKey: createTimelineSortKey("audit", event.timestamp, event.id),
|
|
audit: normalized,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Convert an agent log entry to a timeline entry.
|
|
*/
|
|
function logEntryToTimelineEntry(entry: import("@fusion/core").AgentLogEntry): TimelineEntry {
|
|
// Use timestamp as the unique sort key for log entries (AgentLogEntry has no id field)
|
|
return {
|
|
timestamp: entry.timestamp,
|
|
type: "log",
|
|
sortKey: createTimelineSortKey("log", entry.timestamp, entry.timestamp),
|
|
log: entry,
|
|
};
|
|
}
|
|
|
|
function runExcerptToAgentLogs(run: import("@fusion/core").AgentHeartbeatRun): import("@fusion/core").AgentLogEntry[] {
|
|
const entries: import("@fusion/core").AgentLogEntry[] = [];
|
|
const taskId = typeof run.contextSnapshot?.taskId === "string" ? run.contextSnapshot.taskId : "agent-run";
|
|
|
|
if (run.stdoutExcerpt?.trim()) {
|
|
entries.push({
|
|
timestamp: run.endedAt ?? run.startedAt,
|
|
taskId,
|
|
type: "text",
|
|
text: run.stdoutExcerpt,
|
|
});
|
|
}
|
|
|
|
if (run.stderrExcerpt?.trim()) {
|
|
entries.push({
|
|
timestamp: run.endedAt ?? run.startedAt,
|
|
taskId,
|
|
type: "tool_error",
|
|
text: "stderr",
|
|
detail: run.stderrExcerpt,
|
|
});
|
|
}
|
|
|
|
if (run.resultJson && Object.keys(run.resultJson).length > 0 && entries.length === 0) {
|
|
entries.push({
|
|
timestamp: run.endedAt ?? run.startedAt,
|
|
taskId,
|
|
type: "text",
|
|
text: JSON.stringify(run.resultJson, null, 2),
|
|
});
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function trimTaskDetailActivityLog<T extends Task>(task: T): T {
|
|
if (!Array.isArray(task.log) || task.log.length <= TASK_DETAIL_ACTIVITY_LOG_LIMIT) {
|
|
return task;
|
|
}
|
|
|
|
return {
|
|
...task,
|
|
log: task.log.slice(-TASK_DETAIL_ACTIVITY_LOG_LIMIT),
|
|
activityLogTotal: task.log.length,
|
|
activityLogTruncatedCount: task.log.length - TASK_DETAIL_ACTIVITY_LOG_LIMIT,
|
|
} as T;
|
|
}
|
|
|
|
function parseLastEventId(req: Request): number | undefined {
|
|
const rawHeader = req.headers["last-event-id"];
|
|
const rawQuery = req.query.lastEventId;
|
|
|
|
const raw = Array.isArray(rawHeader)
|
|
? rawHeader[0]
|
|
: (typeof rawHeader === "string" ? rawHeader : Array.isArray(rawQuery) ? rawQuery[0] : rawQuery);
|
|
|
|
if (raw === undefined || raw === null) return undefined;
|
|
|
|
const parsed = Number.parseInt(String(raw), 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function replayBufferedSSE(
|
|
res: Response,
|
|
bufferedEvents: Array<{ id: number; event: string; data: string }>,
|
|
): boolean {
|
|
for (const bufferedEvent of bufferedEvents) {
|
|
if (!writeSSEEvent(res, bufferedEvent.event, bufferedEvent.data, bufferedEvent.id)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function checkSessionLock(
|
|
sessionId: string,
|
|
tabId: string | undefined,
|
|
store: AiSessionStore | undefined,
|
|
): { allowed: true } | { allowed: false; currentHolder: string | null } {
|
|
if (!tabId || !store) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
const result = store.acquireLock(sessionId, tabId);
|
|
if (result.acquired) {
|
|
return { allowed: true };
|
|
}
|
|
|
|
return { allowed: false, currentHolder: result.currentHolder };
|
|
}
|
|
|
|
/**
|
|
* Public API route entrypoint used by server.ts.
|
|
*
|
|
* `createApiRoutes()` is intentionally an orchestrator: it builds shared
|
|
* request/project context once, mounts registrar modules (including integrated
|
|
* router registrars) in precedence-safe order, and keeps compatibility exports
|
|
* in this module stable for tests and
|
|
* downstream consumers (`resolveDiffBase`, `__resetBatchImportRateLimiter`,
|
|
* `__setCreateFnAgentForRefine`, `AuthStorageLike`, `ModelRegistryLike`).
|
|
*/
|
|
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
|
|
const {
|
|
router,
|
|
runtimeLogger,
|
|
planningLogger,
|
|
proxyLogger,
|
|
chatLogger,
|
|
prioritizeProjectsForCurrentDirectory,
|
|
getProjectIdFromRequest,
|
|
getScopedStore,
|
|
getProjectContext,
|
|
emitRemoteRouteDiagnostic,
|
|
emitAuthSyncAuditLog,
|
|
proxyToRemoteNode,
|
|
parseScopeParam,
|
|
resolveAutomationStore,
|
|
resolveRoutineStore,
|
|
resolveRoutineRunner,
|
|
} = createApiRoutesContext(store, options);
|
|
const summarizeDiagnostics = createSessionDiagnostics("ai-summarize");
|
|
|
|
// Registrar mount order is part of the API contract. Keep specific routes
|
|
// before generic parameter/wildcard routes to preserve Express precedence.
|
|
// Proxy registrar must remain last so explicit /proxy handlers stay ahead
|
|
// of the fallback /proxy/:nodeId/{*splat} wildcard route.
|
|
const routeContext = {
|
|
router,
|
|
store,
|
|
options,
|
|
runtimeLogger,
|
|
planningLogger,
|
|
proxyLogger,
|
|
chatLogger,
|
|
prioritizeProjectsForCurrentDirectory,
|
|
getProjectIdFromRequest,
|
|
getScopedStore,
|
|
getProjectContext,
|
|
emitRemoteRouteDiagnostic,
|
|
emitAuthSyncAuditLog,
|
|
proxyToRemoteNode,
|
|
parseScopeParam,
|
|
resolveAutomationStore,
|
|
resolveRoutineStore,
|
|
resolveRoutineRunner,
|
|
rethrowAsApiError,
|
|
};
|
|
|
|
// Get GitHub token from options or env
|
|
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
|
const aiSessionStore = options?.aiSessionStore;
|
|
|
|
// Registrar mount order is precedence-sensitive and mirrors
|
|
// .fusion/tasks/FN-2541/route-order-blueprint.md.
|
|
// Keep this sequence stable unless route-matching invariants are re-audited.
|
|
registerSettingsMemoryRoutes(routeContext, {
|
|
githubToken,
|
|
validateModelPresets,
|
|
sanitizeOverlapIgnorePaths,
|
|
discoverDashboardPiExtensions,
|
|
});
|
|
registerTaskWorkflowRoutes(routeContext, {
|
|
runtimeLogger,
|
|
upload,
|
|
taskDetailActivityLogLimit: TASK_DETAIL_ACTIVITY_LOG_LIMIT,
|
|
validateOptionalModelField,
|
|
normalizeModelSelectionPair,
|
|
runGitCommand,
|
|
trimTaskDetailActivityLog,
|
|
triggerCommentWakeForAssignedAgent: (...args) => triggerCommentWakeForAssignedAgent(...args),
|
|
});
|
|
registerPlanningSubtaskRoutes(routeContext, {
|
|
store,
|
|
aiSessionStore,
|
|
checkSessionLock,
|
|
parseLastEventId,
|
|
replayBufferedSSE,
|
|
});
|
|
registerChatRoutes(routeContext, {
|
|
parseLastEventId,
|
|
validateOptionalModelField,
|
|
});
|
|
registerMessagingScriptRoutes(routeContext);
|
|
registerGitGitHubRoutes(routeContext);
|
|
registerSessionDiffRoutes(router, { getProjectContext });
|
|
registerFileWorkspaceRoutes(routeContext);
|
|
registerAgentsProjectsNodesRoutes(routeContext);
|
|
registerPluginsAutomationRoutes(routeContext);
|
|
registerProxyRoutes(routeContext);
|
|
|
|
// HeartbeatMonitor for triggering agent execution runs
|
|
const heartbeatMonitor = options?.heartbeatMonitor;
|
|
const hasHeartbeatExecutor = Boolean(heartbeatMonitor);
|
|
|
|
/**
|
|
* Check whether the heartbeatMonitor is bound to the same project as scopedStore.
|
|
* Returns false when the monitor's rootDir is set and differs from the store's root.
|
|
* Returns true when rootDir is not exposed (backward compatible) or paths match.
|
|
*/
|
|
function isHeartbeatMonitorForProject(scopedStore: TaskStore): boolean {
|
|
if (!heartbeatMonitor?.rootDir) return true; // no rootDir exposed — assume compatible
|
|
try {
|
|
const monitorRoot = resolve(heartbeatMonitor.rootDir);
|
|
const storeRoot = resolve(scopedStore.getRootDir());
|
|
return monitorRoot === storeRoot;
|
|
} catch {
|
|
return true; // path resolution failure — assume compatible
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Trigger a heartbeat wake for an assigned agent based on a comment event.
|
|
*
|
|
* UTILITY PATH: This function is on the heartbeat control-plane lane and is
|
|
* independent of task-lane saturation. It must NOT be gated on maxConcurrent,
|
|
* semaphore state, or queue depth.
|
|
*
|
|
* Skip reasons (these are normal operation, not saturation gates):
|
|
* - No HeartbeatMonitor available (heartbeat executor not configured)
|
|
* - No agent assigned to the task
|
|
* - HeartbeatMonitor is bound to a different project
|
|
* - Agent's responseMode is not "immediate" (non-immediate mode skips on-demand wakes)
|
|
* - Agent already has an active heartbeat run (prevents duplicate runs)
|
|
*/
|
|
const triggerCommentWakeForAssignedAgent = async (
|
|
scopedStore: TaskStore,
|
|
task: Task,
|
|
wake: {
|
|
triggeringCommentType: "steering" | "task" | "pr";
|
|
triggeringCommentIds?: string[];
|
|
triggerDetail: string;
|
|
},
|
|
): Promise<void> => {
|
|
// Skip: no HeartbeatMonitor available
|
|
if (!hasHeartbeatExecutor || !heartbeatMonitor || !task.assignedAgentId) {
|
|
return;
|
|
}
|
|
|
|
// Guard: heartbeatMonitor is bound to a specific project root directory.
|
|
// Skip the wake when the scoped store belongs to a different project.
|
|
if (!isHeartbeatMonitorForProject(scopedStore)) {
|
|
return;
|
|
}
|
|
|
|
const { AgentStore } = await import("@fusion/core");
|
|
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
|
await agentStore.init();
|
|
|
|
const assignedAgent = await agentStore.getAgent(task.assignedAgentId);
|
|
// Skip: agent not found
|
|
if (!assignedAgent) {
|
|
return;
|
|
}
|
|
|
|
// Skip: agent's responseMode is not "immediate" (non-immediate mode skips on-demand wakes)
|
|
const responseMode = (assignedAgent.runtimeConfig as { messageResponseMode?: string } | undefined)?.messageResponseMode;
|
|
if (responseMode !== "immediate") {
|
|
return;
|
|
}
|
|
|
|
// Skip: agent already has an active heartbeat run (prevents duplicate runs)
|
|
const activeRun = await agentStore.getActiveHeartbeatRun(assignedAgent.id);
|
|
if (activeRun) {
|
|
return;
|
|
}
|
|
|
|
const triggeringCommentIds = wake.triggeringCommentIds?.filter((id) => typeof id === "string" && id.length > 0);
|
|
const contextSnapshot: Record<string, unknown> = {
|
|
wakeReason: "on_demand",
|
|
triggerDetail: wake.triggerDetail,
|
|
taskId: task.id,
|
|
...(triggeringCommentIds?.length ? { triggeringCommentIds } : {}),
|
|
triggeringCommentType: wake.triggeringCommentType,
|
|
};
|
|
|
|
await heartbeatMonitor.executeHeartbeat({
|
|
agentId: assignedAgent.id,
|
|
source: "on_demand",
|
|
triggerDetail: wake.triggerDetail,
|
|
taskId: task.id,
|
|
triggeringCommentIds,
|
|
triggeringCommentType: wake.triggeringCommentType,
|
|
contextSnapshot,
|
|
});
|
|
};
|
|
|
|
// Scheduler config (includes persisted settings — only needs maxConcurrent/maxTriageConcurrent/maxWorktrees)
|
|
router.get("/config", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const settings = await scopedStore.getSettingsFast();
|
|
res.json({
|
|
maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2,
|
|
maxTriageConcurrent: settings.maxTriageConcurrent ?? settings.maxConcurrent ?? 2,
|
|
maxWorktrees: settings.maxWorktrees ?? 4,
|
|
rootDir: scopedStore.getRootDir(),
|
|
});
|
|
} catch {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxTriageConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() });
|
|
}
|
|
});
|
|
|
|
router.get("/pi-settings", async (_req, res) => {
|
|
try {
|
|
const { SettingsManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
|
const agentDir = getAgentDir();
|
|
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
|
|
const packages = settingsManager.getPackages();
|
|
const extensions = settingsManager.getExtensionPaths();
|
|
const skills = settingsManager.getSkillPaths();
|
|
const prompts = settingsManager.getPromptTemplatePaths();
|
|
const themes = settingsManager.getThemePaths();
|
|
res.json({ packages, extensions, skills, prompts, themes });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/pi-settings
|
|
* Updates the user's global pi extension settings in ~/.pi/agent/settings.json.
|
|
* Accepts partial updates: only provided fields are updated.
|
|
* Body: { packages?: PackageSource[], extensions?: string[], skills?: string[], prompts?: string[], themes?: string[] }
|
|
*/
|
|
router.put("/pi-settings", async (req, res) => {
|
|
try {
|
|
const { packages, extensions, skills, prompts, themes } = req.body as {
|
|
packages?: unknown;
|
|
extensions?: unknown;
|
|
skills?: unknown;
|
|
prompts?: unknown;
|
|
themes?: unknown;
|
|
};
|
|
|
|
// Validate that at least one field is provided
|
|
if (packages === undefined && extensions === undefined && skills === undefined && prompts === undefined && themes === undefined) {
|
|
throw badRequest("At least one setting field must be provided (packages, extensions, skills, prompts, or themes)");
|
|
}
|
|
|
|
const { SettingsManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
|
const agentDir = getAgentDir();
|
|
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
|
|
|
|
if (packages !== undefined) {
|
|
if (!Array.isArray(packages)) {
|
|
throw badRequest("packages must be an array");
|
|
}
|
|
settingsManager.setPackages(packages as string[]);
|
|
}
|
|
if (extensions !== undefined) {
|
|
if (!Array.isArray(extensions)) {
|
|
throw badRequest("extensions must be an array of strings");
|
|
}
|
|
settingsManager.setExtensionPaths(extensions as string[]);
|
|
}
|
|
if (skills !== undefined) {
|
|
if (!Array.isArray(skills)) {
|
|
throw badRequest("skills must be an array of strings");
|
|
}
|
|
settingsManager.setSkillPaths(skills as string[]);
|
|
}
|
|
if (prompts !== undefined) {
|
|
if (!Array.isArray(prompts)) {
|
|
throw badRequest("prompts must be an array of strings");
|
|
}
|
|
settingsManager.setPromptTemplatePaths(prompts as string[]);
|
|
}
|
|
if (themes !== undefined) {
|
|
if (!Array.isArray(themes)) {
|
|
throw badRequest("themes must be an array of strings");
|
|
}
|
|
settingsManager.setThemePaths(themes as string[]);
|
|
}
|
|
|
|
await settingsManager.flush();
|
|
res.json({ success: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/pi-settings/packages
|
|
* Installs a new pi package source and adds it to the global settings.
|
|
* Body: { source: string }
|
|
*/
|
|
router.post("/pi-settings/packages", async (req, res) => {
|
|
try {
|
|
const { source } = req.body as { source?: unknown };
|
|
if (typeof source !== "string" || !source.trim()) {
|
|
throw badRequest("source must be a non-empty string");
|
|
}
|
|
|
|
const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
|
const agentDir = getAgentDir();
|
|
const cwd = process.cwd();
|
|
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
|
|
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
|
|
await packageManager.install(source.trim());
|
|
const added = packageManager.addSourceToSettings(source.trim());
|
|
if (!added) {
|
|
// Already in settings (setPackages deduplicates), treat as success
|
|
res.json({ success: true });
|
|
return;
|
|
}
|
|
|
|
await settingsManager.flush();
|
|
res.json({ success: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/pi-settings/reinstall-fusion
|
|
* Reinstalls Fusion's bundled pi package and ensures it remains configured in global settings.
|
|
*/
|
|
router.post("/pi-settings/reinstall-fusion", async (_req, res) => {
|
|
try {
|
|
const source = "npm:@runfusion/fusion";
|
|
const { SettingsManager, DefaultPackageManager, getAgentDir } = await import("@mariozechner/pi-coding-agent");
|
|
const agentDir = getAgentDir();
|
|
const cwd = process.cwd();
|
|
const settingsManager = SettingsManager.create(process.cwd(), agentDir);
|
|
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
|
|
await packageManager.install(source);
|
|
const added = packageManager.addSourceToSettings(source);
|
|
if (added) {
|
|
await settingsManager.flush();
|
|
}
|
|
|
|
res.json({ success: true, source });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/settings/test-ntfy
|
|
* Send a test notification to verify ntfy configuration.
|
|
* Returns: { success: true } on success, { error: string } on failure.
|
|
*/
|
|
router.get("/executor/stats", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const settings = await scopedStore.getSettings();
|
|
|
|
// Get the most recent activity timestamp from the activity log
|
|
let lastActivityAt: string | undefined;
|
|
try {
|
|
const activityLog = await scopedStore.getActivityLog({ limit: 1 });
|
|
if (activityLog.length > 0) {
|
|
lastActivityAt = activityLog[0].timestamp;
|
|
}
|
|
} catch {
|
|
// If we can't get activity log, that's OK - just leave lastActivityAt undefined
|
|
}
|
|
|
|
res.json({
|
|
globalPause: settings.globalPause ?? false,
|
|
enginePaused: settings.enginePaused ?? false,
|
|
maxConcurrent: settings.maxConcurrent ?? 2,
|
|
lastActivityAt,
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Backup Routes ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/backups
|
|
* List all database backups with metadata.
|
|
*/
|
|
router.get("/backups", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { createBackupManager } = await import("@fusion/core");
|
|
const settings = await scopedStore.getSettings();
|
|
const manager = createBackupManager(scopedStore.getFusionDir(), settings);
|
|
const backups = await manager.listBackups();
|
|
|
|
// Calculate total size
|
|
const totalSize = backups.reduce((sum, b) => sum + b.size, 0);
|
|
|
|
res.json({
|
|
backups,
|
|
count: backups.length,
|
|
totalSize,
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err, "Failed to list backups");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/backups
|
|
* Create a new database backup immediately.
|
|
*/
|
|
router.post("/backups", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { runBackupCommand } = await import("@fusion/core");
|
|
const settings = await scopedStore.getSettings();
|
|
const result = await runBackupCommand(scopedStore.getFusionDir(), settings);
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
backupPath: result.backupPath,
|
|
output: result.output,
|
|
deletedCount: result.deletedCount,
|
|
});
|
|
} else {
|
|
throw new ApiError(500, result.output);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err, "Failed to create backup");
|
|
}
|
|
});
|
|
|
|
// Models
|
|
registerModelRoutes(routeContext);
|
|
|
|
// ---------- Auth routes ----------
|
|
registerAuthRoutes(routeContext);
|
|
|
|
registerTerminalRoutes(router, {
|
|
getProjectContext,
|
|
terminalSessionManager,
|
|
getTerminalService,
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/refine-text
|
|
* AI-powered text refinement for task descriptions.
|
|
* Body: { text: string, type: string }
|
|
* Returns: { refined: string }
|
|
*
|
|
* Refinement types: clarify, add-details, expand, simplify
|
|
* Rate limited: 10 requests per hour per IP
|
|
*/
|
|
router.post("/ai/refine-text", async (req, res) => {
|
|
try {
|
|
const { text, type } = req.body;
|
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
|
|
|
// Get scoped store and settings for prompt overrides
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
const settings = await scopedStore.getSettings();
|
|
|
|
const {
|
|
validateRefineRequest,
|
|
checkRateLimit,
|
|
getRateLimitResetTime,
|
|
refineText,
|
|
RateLimitError: _RateLimitError3,
|
|
ValidationError,
|
|
InvalidTypeError,
|
|
AiServiceError: _AiServiceError,
|
|
} = await import("./ai-refine.js");
|
|
|
|
// Check rate limit first
|
|
if (!checkRateLimit(ip)) {
|
|
const resetTime = getRateLimitResetTime(ip);
|
|
throw rateLimited(`Rate limit exceeded. Maximum 10 refinement requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`);
|
|
}
|
|
|
|
// Validate request body
|
|
let validated;
|
|
try {
|
|
validated = validateRefineRequest(text, type);
|
|
} catch (err) {
|
|
if (err instanceof ValidationError) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
}
|
|
if (err instanceof InvalidTypeError) {
|
|
throw new ApiError(422, err instanceof Error ? err.message : String(err));
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Process refinement with prompt overrides
|
|
const refined = await refineText(
|
|
validated.text,
|
|
validated.type,
|
|
rootDir,
|
|
settings.promptOverrides,
|
|
);
|
|
res.json({ refined });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
// Check error by name since error classes are from dynamic import
|
|
if (err instanceof Error && err.name === "RateLimitError") {
|
|
throw rateLimited(err.message);
|
|
} else if (err instanceof Error && err.name === "AiServiceError") {
|
|
rethrowAsApiError(err, "AI service error");
|
|
} else {
|
|
rethrowAsApiError(err, "Failed to refine text");
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai/summarize-title
|
|
* AI-powered title generation from task descriptions.
|
|
* Body: { description: string, provider?: string, modelId?: string }
|
|
* Returns: { title: string }
|
|
*
|
|
* Generates a concise title (≤60 characters) from descriptions longer than 200 characters.
|
|
* Rate limited: 10 requests per hour per IP
|
|
*/
|
|
router.post("/ai/summarize-title", async (req, res) => {
|
|
try {
|
|
const { description, provider, modelId } = req.body;
|
|
const ip = req.ip || req.socket.remoteAddress || "unknown";
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const rootDir = scopedStore.getRootDir();
|
|
|
|
const {
|
|
checkRateLimit,
|
|
getRateLimitResetTime,
|
|
summarizeTitle,
|
|
validateDescription,
|
|
MIN_DESCRIPTION_LENGTH,
|
|
MAX_DESCRIPTION_LENGTH: _MAX_DESCRIPTION_LENGTH,
|
|
RateLimitError: _RateLimitError4,
|
|
ValidationError: _ValidationError2,
|
|
AiServiceError: _AiServiceError2,
|
|
} = await import("@fusion/core");
|
|
|
|
// Optional debug tracing for summarize flows.
|
|
if (process.env.FUSION_DEBUG_AI) {
|
|
summarizeDiagnostics.info("Summarize title request", {
|
|
ip,
|
|
descriptionLength: typeof description === "string" ? description.length : 0,
|
|
operation: "summarize-title-request",
|
|
});
|
|
}
|
|
|
|
// Check rate limit first
|
|
if (!checkRateLimit(ip)) {
|
|
const resetTime = getRateLimitResetTime(ip);
|
|
throw rateLimited(`Rate limit exceeded. Maximum 10 summarization requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`);
|
|
}
|
|
|
|
// Validate request body
|
|
try {
|
|
validateDescription(description);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if (err instanceof Error && err.name === "ValidationError") {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// Resolve model selection hierarchy for summarization:
|
|
// 1. Request body provider+modelId (request override)
|
|
// 2. Project titleSummarizer override (titleSummarizerProvider + titleSummarizerModelId)
|
|
// 3. Global summarization lane (titleSummarizerGlobalProvider + titleSummarizerGlobalModelId)
|
|
// 4. Default pair (defaultProvider + defaultModelId)
|
|
// 5. Automatic model resolution (no explicit model)
|
|
const settings = await scopedStore.getSettings();
|
|
|
|
const resolvedProvider =
|
|
(provider && modelId ? provider : undefined) ||
|
|
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) ||
|
|
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalProvider : undefined) ||
|
|
(settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined);
|
|
|
|
const resolvedModelId =
|
|
(provider && modelId ? modelId : undefined) ||
|
|
(settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) ||
|
|
(settings.titleSummarizerGlobalProvider && settings.titleSummarizerGlobalModelId ? settings.titleSummarizerGlobalModelId : undefined) ||
|
|
(settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined);
|
|
|
|
if (process.env.FUSION_DEBUG_AI) {
|
|
summarizeDiagnostics.info("Summarize title model resolved", {
|
|
provider: resolvedProvider ?? "auto",
|
|
modelId: resolvedModelId ?? "auto",
|
|
operation: "summarize-title-model-resolution",
|
|
});
|
|
}
|
|
|
|
// Process summarization
|
|
const title = await summarizeTitle(description, rootDir, resolvedProvider, resolvedModelId);
|
|
|
|
if (!title) {
|
|
throw badRequest(`Description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`);
|
|
}
|
|
|
|
res.json({ title });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
// Check error by name since error classes are from dynamic import
|
|
if (err instanceof Error && err.name === "RateLimitError") {
|
|
throw rateLimited(err.message);
|
|
} else if (err instanceof Error && err.name === "AiServiceError") {
|
|
throw new ApiError(503, err.message || "AI service temporarily unavailable");
|
|
} else if (err instanceof Error && err.name === "ValidationError") {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
summarizeDiagnostics.errorFromException("Unexpected summarize title error", err, {
|
|
operation: "summarize-title",
|
|
});
|
|
rethrowAsApiError(err, "Failed to generate title");
|
|
}
|
|
}
|
|
});
|
|
|
|
registerUsageRoutes(routeContext);
|
|
|
|
// ── Automation / Scheduled Task Routes ────────────────────────────
|
|
//
|
|
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
|
|
// - When scope=global: Operations target the global automation store
|
|
// - When scope=project: Operations target project-scoped automations (filtered by scope)
|
|
// - When scope is omitted: Legacy default behavior (global store, backward compatible)
|
|
//
|
|
// Error codes:
|
|
// - 400: Invalid scope value or validation failure
|
|
// - 404: Schedule not found
|
|
// - 503: Automation store unavailable
|
|
|
|
// GET /automations — list all scheduled tasks (optionally filtered by scope)
|
|
router.get("/automations", async (req: Request, res: Response) => {
|
|
// Return empty array when no store available (legacy backward-compatible behavior)
|
|
if (!options?.automationStore) {
|
|
return res.json([]);
|
|
}
|
|
|
|
try {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
// Get all schedules and filter by scope if specified
|
|
// When scope is omitted, return all schedules (legacy behavior)
|
|
const allSchedules = await automationStore.listSchedules();
|
|
if (scope) {
|
|
const filteredSchedules = allSchedules.filter((s) => s.scope === scope);
|
|
res.json(filteredSchedules);
|
|
} else {
|
|
res.json(allSchedules);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /automations — create a new schedule (with optional scope)
|
|
router.post("/automations", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body;
|
|
|
|
// Validation
|
|
if (!name?.trim()) {
|
|
throw badRequest("Name is required");
|
|
}
|
|
const hasSteps = Array.isArray(steps) && steps.length > 0;
|
|
if (!hasSteps && !command?.trim()) {
|
|
throw badRequest("Command is required when no steps are provided");
|
|
}
|
|
const validTypes = ["hourly", "daily", "weekly", "monthly", "custom", "every15Minutes", "every30Minutes", "every2Hours", "every6Hours", "every12Hours", "weekdays"];
|
|
if (!scheduleType || !validTypes.includes(scheduleType)) {
|
|
throw badRequest(`Invalid schedule type. Must be one of: ${validTypes.join(", ")}`);
|
|
}
|
|
if (scheduleType === "custom") {
|
|
if (!cronExpression?.trim()) {
|
|
throw badRequest("Cron expression is required for custom schedule type");
|
|
}
|
|
if (!AutomationStore.isValidCron(cronExpression)) {
|
|
throw badRequest(`Invalid cron expression: "${cronExpression}"`);
|
|
}
|
|
}
|
|
// Validate steps if provided
|
|
if (hasSteps) {
|
|
const stepErr = validateAutomationSteps(steps);
|
|
if (stepErr) {
|
|
throw badRequest(stepErr);
|
|
}
|
|
}
|
|
|
|
// Determine scope for the new schedule
|
|
// Default to "project" for backward compatibility when scope is omitted
|
|
const scheduleScope = scope ?? "project";
|
|
|
|
const schedule = await automationStore.createSchedule({
|
|
name,
|
|
description,
|
|
scheduleType: scheduleType as ScheduleType,
|
|
cronExpression,
|
|
command: command ?? "",
|
|
enabled,
|
|
timeoutMs,
|
|
steps: hasSteps ? steps : undefined,
|
|
scope: scheduleScope,
|
|
});
|
|
res.status(201).json(schedule);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// GET /automations/:id — get a single schedule
|
|
router.get("/automations/:id", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const schedule = await automationStore.getSchedule(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
|
|
if (scope && schedule.scope !== scope) {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
|
|
res.json(schedule);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// PATCH /automations/:id — update a schedule
|
|
router.patch("/automations/:id", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
|
|
// by fetching it first (can't filter in update without scope support in store)
|
|
if (scope) {
|
|
const existing = await automationStore.getSchedule(id);
|
|
if (existing.scope !== scope) {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
}
|
|
|
|
const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body;
|
|
|
|
// Validate cron if switching to custom
|
|
if (scheduleType === "custom" && cronExpression) {
|
|
if (!AutomationStore.isValidCron(cronExpression)) {
|
|
throw badRequest(`Invalid cron expression: "${cronExpression}"`);
|
|
}
|
|
}
|
|
|
|
// Validate steps if provided
|
|
if (Array.isArray(steps) && steps.length > 0) {
|
|
const stepErr = validateAutomationSteps(steps);
|
|
if (stepErr) {
|
|
throw badRequest(stepErr);
|
|
}
|
|
}
|
|
|
|
const schedule = await automationStore.updateSchedule(id, {
|
|
name,
|
|
description,
|
|
scheduleType,
|
|
cronExpression,
|
|
command,
|
|
enabled,
|
|
timeoutMs,
|
|
steps: steps !== undefined ? steps : undefined,
|
|
});
|
|
res.json(schedule);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty") || (err instanceof Error ? err.message : String(err)).includes("Invalid cron")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// DELETE /automations/:id — delete a schedule
|
|
router.delete("/automations/:id", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
|
|
if (scope) {
|
|
const existing = await automationStore.getSchedule(id);
|
|
if (existing.scope !== scope) {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
}
|
|
|
|
const deleted = await automationStore.deleteSchedule(id);
|
|
res.json(deleted);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /automations/:id/run — trigger a manual run
|
|
router.post("/automations/:id/run", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const schedule = await automationStore.getSchedule(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
|
|
if (scope && schedule.scope !== scope) {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
|
|
const startedAt = new Date().toISOString();
|
|
let result: import("@fusion/core").AutomationRunResult;
|
|
|
|
if (schedule.steps && schedule.steps.length > 0) {
|
|
// Multi-step execution
|
|
result = await executeScheduleSteps(schedule, startedAt);
|
|
} else {
|
|
// Legacy single-command execution
|
|
result = await executeSingleCommand(schedule.command, schedule.timeoutMs, startedAt);
|
|
}
|
|
|
|
// Record the result
|
|
const updated = await automationStore.recordRun(schedule.id, result);
|
|
res.json({ schedule: updated, result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /automations/:id/toggle — toggle enabled/disabled
|
|
router.post("/automations/:id/toggle", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const schedule = await automationStore.getSchedule(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
|
|
if (scope && schedule.scope !== scope) {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
|
|
const updated = await automationStore.updateSchedule(id, {
|
|
enabled: !schedule.enabled,
|
|
});
|
|
res.json(updated);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /automations/:id/steps/reorder — reorder steps
|
|
router.post("/automations/:id/steps/reorder", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const automationStore = resolveAutomationStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
// Scope isolation: if scope is specified, verify the schedule belongs to that scope
|
|
if (scope) {
|
|
const existing = await automationStore.getSchedule(id);
|
|
if (existing.scope !== scope) {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
}
|
|
|
|
const { stepIds } = req.body;
|
|
if (!Array.isArray(stepIds)) {
|
|
throw badRequest("stepIds must be an array");
|
|
}
|
|
const schedule = await automationStore.reorderSteps(id, stepIds);
|
|
res.json(schedule);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Schedule not found");
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("mismatch") || (err instanceof Error ? err.message : String(err)).includes("Unknown step") || (err instanceof Error ? err.message : String(err)).includes("no steps")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Routine Routes ──────────────────────────────────────────────────
|
|
//
|
|
// Scope-aware endpoints: Accept `scope=global|project` query param or body field.
|
|
// - When scope=global: Operations target the global routine store
|
|
// - When scope=project: Operations target project-scoped routines (filtered by scope)
|
|
// - When scope is omitted: Legacy default behavior (global store, backward compatible)
|
|
//
|
|
// Error codes:
|
|
// - 400: Invalid scope value or validation failure
|
|
// - 401: Webhook signature verification failed
|
|
// - 403: Webhook disabled/forbidden
|
|
// - 404: Routine not found
|
|
// - 503: Routine store or runner unavailable
|
|
|
|
// GET /routines — list all routines (optionally filtered by scope)
|
|
router.get("/routines", async (req: Request, res: Response) => {
|
|
// Return empty array when no store available (legacy backward-compatible behavior)
|
|
if (!options?.routineStore) {
|
|
return res.json([]);
|
|
}
|
|
|
|
try {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
|
|
// Get all routines and filter by scope if specified
|
|
// When scope is omitted, return all routines (legacy behavior)
|
|
const allRoutines = await routineStore.listRoutines();
|
|
if (scope) {
|
|
const filteredRoutines = allRoutines.filter((r) => r.scope === scope);
|
|
res.json(filteredRoutines);
|
|
} else {
|
|
res.json(allRoutines);
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /routines — create a new routine (with optional scope)
|
|
router.post("/routines", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
|
|
try {
|
|
const { name, agentId, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body;
|
|
|
|
// Validation
|
|
if (!name?.trim()) {
|
|
throw badRequest("Name is required");
|
|
}
|
|
if (!trigger) {
|
|
throw badRequest("Trigger is required");
|
|
}
|
|
if (!trigger.type) {
|
|
throw badRequest("Trigger must have a type field");
|
|
}
|
|
const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"];
|
|
if (!validTriggerTypes.includes(trigger.type)) {
|
|
throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`);
|
|
}
|
|
if (trigger.type === "cron") {
|
|
if (!trigger.cronExpression?.trim()) {
|
|
throw badRequest("Cron expression is required for cron trigger");
|
|
}
|
|
if (!RoutineStore.isValidCron(trigger.cronExpression)) {
|
|
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
|
|
}
|
|
}
|
|
if (trigger.type === "webhook") {
|
|
// Require an HMAC secret so the webhook endpoint authenticates callers
|
|
// via signed payloads. Without this, anyone who can reach the server
|
|
// and knows the routine id could trigger execution by sending an empty
|
|
// POST to /routines/:id/webhook.
|
|
if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) {
|
|
throw badRequest(
|
|
"Webhook trigger requires a secret of at least 16 characters for HMAC signature verification",
|
|
);
|
|
}
|
|
}
|
|
const hasSteps = Array.isArray(steps) && steps.length > 0;
|
|
const hasCommand = typeof command === "string" && command.trim().length > 0;
|
|
if (hasSteps) {
|
|
const stepErr = validateAutomationSteps(steps);
|
|
if (stepErr) {
|
|
throw badRequest(stepErr);
|
|
}
|
|
}
|
|
if (catchUpPolicy !== undefined) {
|
|
const validCatchUpPolicies: Array<"run" | "skip" | "run_one"> = ["run", "skip", "run_one"];
|
|
if (!validCatchUpPolicies.includes(catchUpPolicy)) {
|
|
throw badRequest(`Invalid catchUpPolicy. Must be one of: ${validCatchUpPolicies.join(", ")}`);
|
|
}
|
|
}
|
|
if (executionPolicy !== undefined) {
|
|
const validExecutionPolicies: Array<"parallel" | "queue" | "reject"> = ["parallel", "queue", "reject"];
|
|
if (!validExecutionPolicies.includes(executionPolicy)) {
|
|
throw badRequest(`Invalid executionPolicy. Must be one of: ${validExecutionPolicies.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
// Determine scope for the new routine
|
|
// Default to "project" for backward compatibility when scope is omitted
|
|
const routineScope = scope ?? "project";
|
|
|
|
const routine = await routineStore.createRoutine({
|
|
name: name.trim(),
|
|
agentId: typeof agentId === "string" ? agentId.trim() : "",
|
|
description,
|
|
trigger,
|
|
command: hasCommand ? command : undefined,
|
|
steps: hasSteps ? steps : undefined,
|
|
timeoutMs,
|
|
catchUpPolicy,
|
|
executionPolicy,
|
|
enabled,
|
|
scope: routineScope,
|
|
});
|
|
res.status(201).json(routine);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// GET /routines/:id — get a single routine
|
|
router.get("/routines/:id", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const routine = await routineStore.getRoutine(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the routine belongs to that scope
|
|
if (scope && routine.scope !== scope) {
|
|
throw notFound("Routine not found");
|
|
}
|
|
|
|
res.json(routine);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// PATCH /routines/:id — update a routine
|
|
router.patch("/routines/:id", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
// Scope isolation: if scope is specified, verify the routine belongs to that scope
|
|
if (scope) {
|
|
const existing = await routineStore.getRoutine(id);
|
|
if (existing.scope !== scope) {
|
|
throw notFound("Routine not found");
|
|
}
|
|
}
|
|
|
|
const { name, description, trigger, command, steps, timeoutMs, catchUpPolicy, executionPolicy, enabled } = req.body;
|
|
|
|
// Validate name if provided
|
|
if (name !== undefined && !name.trim()) {
|
|
throw badRequest("Name cannot be empty");
|
|
}
|
|
|
|
// Validate trigger if provided
|
|
if (trigger !== undefined) {
|
|
if (trigger.type) {
|
|
const validTriggerTypes: RoutineTriggerType[] = ["cron", "webhook", "api", "manual"];
|
|
if (!validTriggerTypes.includes(trigger.type)) {
|
|
throw badRequest(`Invalid trigger type. Must be one of: ${validTriggerTypes.join(", ")}`);
|
|
}
|
|
if (trigger.type === "cron" && trigger.cronExpression) {
|
|
if (!RoutineStore.isValidCron(trigger.cronExpression)) {
|
|
throw badRequest(`Invalid cron expression: "${trigger.cronExpression}"`);
|
|
}
|
|
}
|
|
if (trigger.type === "webhook") {
|
|
if (typeof trigger.secret !== "string" || trigger.secret.trim().length < 16) {
|
|
throw badRequest(
|
|
"Webhook trigger requires a secret of at least 16 characters for HMAC signature verification",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (Array.isArray(steps) && steps.length > 0) {
|
|
const stepErr = validateAutomationSteps(steps);
|
|
if (stepErr) {
|
|
throw badRequest(stepErr);
|
|
}
|
|
}
|
|
|
|
const routine = await routineStore.updateRoutine(id, {
|
|
name: name !== undefined ? name.trim() : undefined,
|
|
description,
|
|
trigger,
|
|
command: command !== undefined ? command : undefined,
|
|
steps: steps !== undefined ? steps : undefined,
|
|
timeoutMs,
|
|
catchUpPolicy,
|
|
executionPolicy,
|
|
enabled,
|
|
});
|
|
res.json(routine);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("cannot be empty") || (err instanceof Error ? err.message : String(err)).includes("Invalid cron")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// DELETE /routines/:id — delete a routine
|
|
router.delete("/routines/:id", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
|
|
// Scope isolation: if scope is specified, verify the routine belongs to that scope
|
|
if (scope) {
|
|
const existing = await routineStore.getRoutine(id);
|
|
if (existing.scope !== scope) {
|
|
throw notFound("Routine not found");
|
|
}
|
|
}
|
|
|
|
const deleted = await routineStore.deleteRoutine(id);
|
|
res.json(deleted);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /routines/:id/run — manual trigger (backward-compatible alias for /trigger)
|
|
router.post("/routines/:id/run", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
const routineRunner = resolveRoutineRunner(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const routine = await routineStore.getRoutine(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the routine belongs to that scope
|
|
if (scope && routine.scope !== scope) {
|
|
throw notFound("Routine not found");
|
|
}
|
|
|
|
// Validate routine is enabled
|
|
if (!routine.enabled) {
|
|
throw badRequest("Routine is disabled");
|
|
}
|
|
|
|
// Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution)
|
|
const result = await routineRunner.triggerManual(id);
|
|
const updated = await routineStore.getRoutine(id);
|
|
res.json({ routine: updated, result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /routines/:id/trigger — canonical manual trigger (uses RoutineRunner)
|
|
// POST /routines/:id/run is a backward-compatible alias with identical behavior
|
|
router.post("/routines/:id/trigger", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
const routineRunner = resolveRoutineRunner(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const routine = await routineStore.getRoutine(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the routine belongs to that scope
|
|
if (scope && routine.scope !== scope) {
|
|
throw notFound("Routine not found");
|
|
}
|
|
|
|
// Validate routine is enabled
|
|
if (!routine.enabled) {
|
|
throw badRequest("Routine is disabled");
|
|
}
|
|
|
|
// Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution)
|
|
const result = await routineRunner.triggerManual(id);
|
|
const updated = await routineStore.getRoutine(id);
|
|
res.json({ routine: updated, result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// GET /routines/:id/runs — get execution history
|
|
router.get("/routines/:id/runs", async (req: Request, res: Response) => {
|
|
const scope = parseScopeParam(req);
|
|
const routineStore = resolveRoutineStore(req, scope);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const routine = await routineStore.getRoutine(id);
|
|
|
|
// Scope isolation: if scope is specified, verify the routine belongs to that scope
|
|
if (scope && routine.scope !== scope) {
|
|
throw notFound("Routine not found");
|
|
}
|
|
|
|
res.json(routine.runHistory);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// POST /routines/:id/webhook — incoming webhook trigger
|
|
// Note: Webhook routes do NOT use scope params from the request - webhooks are triggered
|
|
// externally and the routine's own scope determines which store to use.
|
|
// The webhook URL should include the scope implicitly via the routine ID.
|
|
router.post("/routines/:id/webhook", async (req: Request, res: Response) => {
|
|
// Webhook triggers don't accept scope params from the request
|
|
// The routine's scope field determines which store to use
|
|
const routineStore = resolveRoutineStore(req, undefined);
|
|
const routineRunner = resolveRoutineRunner(req, undefined);
|
|
|
|
try {
|
|
const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id;
|
|
const routine = await routineStore.getRoutine(id);
|
|
|
|
// Validate this is a webhook-type routine
|
|
if (!isWebhookTrigger(routine.trigger)) {
|
|
throw badRequest("Routine is not configured for webhook triggers");
|
|
}
|
|
|
|
// Validate routine is enabled
|
|
if (!routine.enabled) {
|
|
throw badRequest("Routine is disabled");
|
|
}
|
|
|
|
// Get raw body for HMAC verification
|
|
const rawBody = req.rawBody;
|
|
const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined;
|
|
|
|
// A webhook routine without a secret is treated as a misconfiguration
|
|
// and refused. New routines require a secret at create time (see POST
|
|
// /routines), but legacy routines persisted before that validation was
|
|
// added could still reach this branch without one.
|
|
if (!routine.trigger.secret) {
|
|
throw new ApiError(
|
|
401,
|
|
"Webhook trigger is not configured with a secret; set routine.trigger.secret before use",
|
|
);
|
|
}
|
|
if (!rawBody) {
|
|
throw badRequest("Raw body not available for signature verification");
|
|
}
|
|
if (!signatureHeader) {
|
|
throw new ApiError(401, "Missing signature header");
|
|
}
|
|
const verification = verifyWebhookSignature(rawBody, signatureHeader, routine.trigger.secret);
|
|
if (!verification.valid) {
|
|
throw new ApiError(401, verification.error ?? "Invalid signature");
|
|
}
|
|
|
|
// Execute via RoutineRunner (persistence handled by RoutineRunner.completeRoutineExecution)
|
|
const payload = req.body;
|
|
const result = await routineRunner.triggerWebhook(id, payload, signatureHeader);
|
|
const updated = await routineStore.getRoutine(id);
|
|
res.json({ routine: updated, result });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw notFound("Routine not found");
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Activity Log Routes ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/activity
|
|
* Get activity log entries.
|
|
* Query params: limit (default 100, max 1000), since (ISO timestamp), type (event type filter)
|
|
* Returns: ActivityLogEntry[] sorted newest first
|
|
*/
|
|
router.get("/activity", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const limitParam = req.query.limit;
|
|
const sinceParam = req.query.since;
|
|
const typeParam = req.query.type;
|
|
|
|
// Parse and validate limit
|
|
let limit: number | undefined;
|
|
if (limitParam !== undefined) {
|
|
const parsed = Number.parseInt(limitParam as string, 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
throw badRequest("limit must be a non-negative integer");
|
|
}
|
|
limit = Math.min(parsed, 1000); // Max 1000
|
|
}
|
|
|
|
// Validate type if provided
|
|
const validTypes = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged", "task:failed", "settings:updated"];
|
|
if (typeParam !== undefined && !validTypes.includes(typeParam as string)) {
|
|
throw badRequest(`Invalid type. Must be one of: ${validTypes.join(", ")}`);
|
|
}
|
|
|
|
const options: { limit?: number; since?: string; type?: ActivityEventType } = {
|
|
limit,
|
|
since: sinceParam as string | undefined,
|
|
type: typeParam as ActivityEventType | undefined,
|
|
};
|
|
|
|
const entries = await scopedStore.getActivityLog(options);
|
|
res.json(entries);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/activity
|
|
* Clear all activity log entries (maintenance endpoint).
|
|
* Returns: { success: true }
|
|
*/
|
|
router.delete("/activity", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
await scopedStore.clearActivityLog();
|
|
res.json({ success: true });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Workflow Step Routes ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/workflow-steps
|
|
* List all workflow step definitions.
|
|
* Returns: WorkflowStep[]
|
|
*/
|
|
router.get("/workflow-steps", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const steps = await scopedStore.listWorkflowSteps();
|
|
res.json(steps);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/workflow-steps
|
|
* Create a new workflow step.
|
|
* Body: { name: string, description: string, mode?: "prompt"|"script", prompt?: string, scriptName?: string, enabled?: boolean, modelProvider?: string, modelId?: string }
|
|
* Returns: WorkflowStep
|
|
*/
|
|
router.post("/workflow-steps", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { name, description, mode, phase, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
|
|
|
|
if (!name || typeof name !== "string" || !name.trim()) {
|
|
throw badRequest("name is required");
|
|
}
|
|
if (!description || typeof description !== "string" || !description.trim()) {
|
|
throw badRequest("description is required");
|
|
}
|
|
|
|
// Validate mode
|
|
const resolvedMode: "prompt" | "script" = mode || "prompt";
|
|
if (resolvedMode !== "prompt" && resolvedMode !== "script") {
|
|
throw badRequest("mode must be 'prompt' or 'script'");
|
|
}
|
|
|
|
// Validate phase
|
|
if (phase !== undefined && phase !== "pre-merge" && phase !== "post-merge") {
|
|
throw badRequest("phase must be 'pre-merge' or 'post-merge'");
|
|
}
|
|
|
|
if (prompt !== undefined && typeof prompt !== "string") {
|
|
throw badRequest("prompt must be a string");
|
|
}
|
|
if (toolMode !== undefined && toolMode !== "readonly" && toolMode !== "coding") {
|
|
throw badRequest("toolMode must be 'readonly' or 'coding'");
|
|
}
|
|
if (scriptName !== undefined && typeof scriptName !== "string") {
|
|
throw badRequest("scriptName must be a string");
|
|
}
|
|
if (enabled !== undefined && typeof enabled !== "boolean") {
|
|
throw badRequest("enabled must be a boolean");
|
|
}
|
|
if (defaultOn !== undefined && typeof defaultOn !== "boolean") {
|
|
throw badRequest("defaultOn must be a boolean");
|
|
}
|
|
|
|
// Validate script mode: scriptName must reference a named script in settings
|
|
if (resolvedMode === "script") {
|
|
if (!scriptName?.trim()) {
|
|
throw badRequest("scriptName is required when mode is 'script'");
|
|
}
|
|
const settings = await scopedStore.getSettings();
|
|
const scripts = settings.scripts || {};
|
|
if (!(scriptName.trim() in scripts)) {
|
|
throw badRequest(`Script '${scriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}`);
|
|
}
|
|
}
|
|
|
|
// Validate model override pair (only relevant for prompt mode)
|
|
const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model");
|
|
|
|
// Check for name conflicts
|
|
const existing = await scopedStore.listWorkflowSteps();
|
|
if (existing.some((ws) => ws.name.toLowerCase() === name.trim().toLowerCase())) {
|
|
throw conflict(`A workflow step named '${name.trim()}' already exists`);
|
|
}
|
|
|
|
const step = await scopedStore.createWorkflowStep({
|
|
name: name.trim(),
|
|
description: description.trim(),
|
|
mode: resolvedMode,
|
|
phase,
|
|
prompt: prompt?.trim(),
|
|
toolMode,
|
|
scriptName: scriptName?.trim(),
|
|
enabled,
|
|
defaultOn: defaultOn === true,
|
|
modelProvider: modelPair.provider,
|
|
modelId: modelPair.modelId,
|
|
});
|
|
res.status(201).json(step);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const status = typeof (err instanceof Error ? err.message : String(err)) === "string" && ((err instanceof Error ? err.message : String(err)).includes("must include both provider and modelId") || (err instanceof Error ? err.message : String(err)).includes("Script mode requires")) ? 400 : 500;
|
|
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PATCH /api/workflow-steps/:id
|
|
* Update a workflow step.
|
|
* Body: Partial<{ name, description, mode, prompt, scriptName, enabled, modelProvider, modelId }>
|
|
* Returns: WorkflowStep
|
|
*/
|
|
router.patch("/workflow-steps/:id", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { name, description, mode, phase, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body;
|
|
|
|
const updates: Record<string, unknown> = {};
|
|
if (name !== undefined) {
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
throw badRequest("name must be a non-empty string");
|
|
}
|
|
updates.name = name.trim();
|
|
}
|
|
if (description !== undefined) {
|
|
if (typeof description !== "string" || !description.trim()) {
|
|
throw badRequest("description must be a non-empty string");
|
|
}
|
|
updates.description = description.trim();
|
|
}
|
|
if (mode !== undefined) {
|
|
if (mode !== "prompt" && mode !== "script") {
|
|
throw badRequest("mode must be 'prompt' or 'script'");
|
|
}
|
|
updates.mode = mode;
|
|
}
|
|
if (phase !== undefined) {
|
|
if (phase !== "pre-merge" && phase !== "post-merge") {
|
|
throw badRequest("phase must be 'pre-merge' or 'post-merge'");
|
|
}
|
|
updates.phase = phase;
|
|
}
|
|
if (prompt !== undefined) {
|
|
if (typeof prompt !== "string") {
|
|
throw badRequest("prompt must be a string");
|
|
}
|
|
updates.prompt = prompt;
|
|
}
|
|
if (toolMode !== undefined) {
|
|
if (toolMode !== "readonly" && toolMode !== "coding") {
|
|
throw badRequest("toolMode must be 'readonly' or 'coding'");
|
|
}
|
|
updates.toolMode = toolMode;
|
|
}
|
|
if (scriptName !== undefined) {
|
|
if (typeof scriptName !== "string") {
|
|
throw badRequest("scriptName must be a string");
|
|
}
|
|
updates.scriptName = scriptName;
|
|
}
|
|
if (enabled !== undefined) {
|
|
if (typeof enabled !== "boolean") {
|
|
throw badRequest("enabled must be a boolean");
|
|
}
|
|
updates.enabled = enabled;
|
|
}
|
|
if (defaultOn !== undefined) {
|
|
if (typeof defaultOn !== "boolean") {
|
|
throw badRequest("defaultOn must be a boolean");
|
|
}
|
|
updates.defaultOn = defaultOn;
|
|
}
|
|
|
|
// Validate script-mode requirements against the resulting state (existing + updates)
|
|
// This catches cases where an existing script-mode step has its scriptName updated
|
|
// without the mode field being explicitly sent.
|
|
const existingStep = await scopedStore.getWorkflowStep(req.params.id);
|
|
const resultingMode: string | undefined = updates.mode !== undefined ? (updates.mode as string) : existingStep?.mode;
|
|
const resultingScriptName: string | undefined = updates.scriptName !== undefined ? (updates.scriptName as string) : existingStep?.scriptName;
|
|
|
|
if (resultingMode === "script") {
|
|
if (!resultingScriptName?.trim()) {
|
|
throw badRequest("scriptName is required when mode is 'script'");
|
|
}
|
|
const settings = await scopedStore.getSettings();
|
|
const scripts = settings.scripts || {};
|
|
if (!(resultingScriptName.trim() in scripts)) {
|
|
throw badRequest(`Script '${resultingScriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}`);
|
|
}
|
|
}
|
|
|
|
// Validate and apply model override pair
|
|
if (modelProvider !== undefined || modelId !== undefined) {
|
|
const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model");
|
|
updates.modelProvider = modelPair.provider;
|
|
updates.modelId = modelPair.modelId;
|
|
}
|
|
|
|
const step = await scopedStore.updateWorkflowStep(req.params.id, updates);
|
|
res.json(step);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
const status = typeof (err instanceof Error ? err.message : String(err)) === "string" && ((err instanceof Error ? err.message : String(err)).includes("must include both provider and modelId") || (err instanceof Error ? err.message : String(err)).includes("Script mode requires")) ? 400 : 500;
|
|
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/workflow-steps/:id
|
|
* Delete a workflow step.
|
|
* Returns: 204 No Content
|
|
*/
|
|
router.delete("/workflow-steps/:id", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
await scopedStore.deleteWorkflowStep(req.params.id);
|
|
res.status(204).send();
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(err instanceof Error ? err.message : String(err));
|
|
} else {
|
|
rethrowAsApiError(err);
|
|
}
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/workflow-steps/:id/refine
|
|
* Use AI to refine the workflow step's description into a detailed agent prompt.
|
|
* Only available for prompt-mode steps.
|
|
* Returns: { prompt: string, workflowStep: WorkflowStep }
|
|
*/
|
|
router.post("/workflow-steps/:id/refine", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const step = await scopedStore.getWorkflowStep(req.params.id);
|
|
if (!step) {
|
|
throw notFound(`Workflow step '${req.params.id}' not found`);
|
|
}
|
|
|
|
if (step.mode === "script") {
|
|
throw badRequest("Cannot refine prompt for script-mode workflow steps");
|
|
}
|
|
|
|
if (!step.description?.trim()) {
|
|
throw badRequest("Workflow step has no description to refine");
|
|
}
|
|
|
|
// Use AI to refine the description into a detailed agent prompt
|
|
let refinedPrompt: string;
|
|
try {
|
|
let createFnAgent = createFnAgentForRefine;
|
|
if (!createFnAgent) {
|
|
// Dynamic import to avoid resolution issues in tests
|
|
const engineModule = "@fusion/engine";
|
|
const engine = await import(/* @vite-ignore */ engineModule);
|
|
createFnAgent = engine.createFnAgent;
|
|
}
|
|
|
|
const settings = await scopedStore.getSettings();
|
|
|
|
// Resolve the system prompt using prompt overrides (with fallback to default)
|
|
const systemPrompt = resolveWorkflowStepRefinePrompt(
|
|
"workflow-step-refine",
|
|
settings.promptOverrides
|
|
) || DEFAULT_WORKFLOW_STEP_REFINE_PROMPT;
|
|
|
|
if (!createFnAgent) {
|
|
throw new Error("createFnAgent is not available");
|
|
}
|
|
const { session } = await createFnAgent({
|
|
cwd: scopedStore.getRootDir(),
|
|
systemPrompt,
|
|
tools: "readonly",
|
|
// Resolve planning model using canonical lane hierarchy:
|
|
// 1. Project planning override (planningProvider + planningModelId)
|
|
// 2. Global planning lane (planningGlobalProvider + planningGlobalModelId)
|
|
// 3. Default pair (defaultProvider + defaultModelId)
|
|
defaultProvider: (settings.planningProvider && settings.planningModelId
|
|
? settings.planningProvider
|
|
: (settings.planningGlobalProvider && settings.planningGlobalModelId
|
|
? settings.planningGlobalProvider
|
|
: settings.defaultProvider)),
|
|
defaultModelId: (settings.planningProvider && settings.planningModelId
|
|
? settings.planningModelId
|
|
: (settings.planningGlobalProvider && settings.planningGlobalModelId
|
|
? settings.planningGlobalModelId
|
|
: settings.defaultModelId)),
|
|
defaultThinkingLevel: settings.defaultThinkingLevel,
|
|
});
|
|
|
|
const refineSession = session as unknown as RefineAgentSession;
|
|
let output = "";
|
|
refineSession.on("text", (delta: string) => {
|
|
output += delta;
|
|
});
|
|
|
|
await refineSession.prompt(
|
|
`Refine this workflow step description into a detailed agent prompt:\n\nName: ${step.name}\nDescription: ${step.description}`
|
|
);
|
|
refineSession.dispose();
|
|
|
|
refinedPrompt = output.trim();
|
|
} catch {
|
|
// Fallback: return the description as-is if AI is unavailable
|
|
refinedPrompt = step.description;
|
|
}
|
|
|
|
// Update the workflow step with the refined prompt
|
|
const updated = await scopedStore.updateWorkflowStep(step.id, { prompt: refinedPrompt });
|
|
res.json({ prompt: refinedPrompt, workflowStep: updated });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Workflow Step Templates ───────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/workflow-step-templates
|
|
* List all built-in workflow step templates.
|
|
* Returns: { templates: WorkflowStepTemplate[] }
|
|
*/
|
|
router.get("/workflow-step-templates", async (_req, res) => {
|
|
try {
|
|
const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core");
|
|
res.json({ templates: WORKFLOW_STEP_TEMPLATES });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/workflow-step-templates/:id/create
|
|
* Create a workflow step from a built-in template.
|
|
* Returns: WorkflowStep
|
|
*/
|
|
router.post("/workflow-step-templates/:id/create", async (req, res) => {
|
|
try {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core");
|
|
const template = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id);
|
|
|
|
if (!template) {
|
|
throw notFound(`Template '${req.params.id}' not found`);
|
|
}
|
|
|
|
// Check for name conflicts with existing workflow steps
|
|
const existing = await scopedStore.listWorkflowSteps();
|
|
if (existing.some((ws) => ws.name.toLowerCase() === template.name.toLowerCase())) {
|
|
throw conflict(`A workflow step named '${template.name}' already exists`);
|
|
}
|
|
|
|
const step = await scopedStore.createWorkflowStep({
|
|
templateId: template.id,
|
|
name: template.name,
|
|
description: template.description,
|
|
prompt: template.prompt,
|
|
toolMode: template.toolMode,
|
|
enabled: true,
|
|
});
|
|
|
|
res.status(201).json(step);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// ── Agent Routes ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Terminal task statuses — tasks in these states should not be displayed
|
|
* as "working on" in agent UI surfaces to avoid stale activity indicators.
|
|
*/
|
|
const TERMINAL_TASK_STATUSES = new Set(["done", "archived"]);
|
|
|
|
/**
|
|
* Check if a task status is terminal (done or archived).
|
|
*/
|
|
function isTerminalTaskStatus(status: string | undefined): boolean {
|
|
return status !== undefined && TERMINAL_TASK_STATUSES.has(status);
|
|
}
|
|
|
|
/**
|
|
* Sanitize agent responses to omit taskId when the linked task is in a terminal state.
|
|
* This prevents stale "working on" UI indicators for completed/archived tasks.
|
|
*
|
|
* @param agents - Array of agents to sanitize
|
|
* @param scopedStore - Task store for looking up linked task status
|
|
* @returns Agents with terminal-linked taskId omitted from response
|
|
*/
|
|
async function sanitizeAgentTaskLinks(
|
|
agents: Array<import("@fusion/core").Agent>,
|
|
scopedStore: TaskStore,
|
|
): Promise<Array<import("@fusion/core").Agent>> {
|
|
// Batch lookup all unique taskIds to minimize individual store calls
|
|
const taskIds = [...new Set(agents.map((a) => a.taskId).filter((id): id is string => id !== undefined))];
|
|
const taskStatusMap = new Map<string, string>();
|
|
|
|
// Parallel fetch all linked tasks
|
|
await Promise.all(
|
|
taskIds.map(async (taskId) => {
|
|
try {
|
|
const task = await scopedStore.getTask(taskId);
|
|
if (task) {
|
|
taskStatusMap.set(taskId, task.column);
|
|
}
|
|
} catch {
|
|
// Task lookup failed — treat as non-terminal (preserve taskId)
|
|
}
|
|
}),
|
|
);
|
|
|
|
return agents.map((agent) => {
|
|
if (!agent.taskId) return agent;
|
|
|
|
const taskStatus = taskStatusMap.get(agent.taskId);
|
|
if (isTerminalTaskStatus(taskStatus)) {
|
|
// Omit taskId for terminal tasks — use spread to create shallow copy without taskId
|
|
const { taskId: _omitted, ...sanitized } = agent;
|
|
return sanitized as import("@fusion/core").Agent;
|
|
}
|
|
return agent;
|
|
});
|
|
}
|
|
|
|
function validateAgentInstructionsPayload(
|
|
instructionsPath: unknown,
|
|
instructionsText: unknown,
|
|
): boolean {
|
|
if (instructionsPath !== undefined && instructionsPath !== null && instructionsPath !== "") {
|
|
if (typeof instructionsPath !== "string") {
|
|
throw badRequest("instructionsPath must be a string");
|
|
}
|
|
if (instructionsPath.length > 500) {
|
|
throw badRequest("instructionsPath must be at most 500 characters");
|
|
}
|
|
if (instructionsPath.includes("..")) {
|
|
throw badRequest("instructionsPath must not contain parent directory traversal (..)");
|
|
}
|
|
const isAbsoluteUnix = instructionsPath.startsWith("/");
|
|
const isAbsoluteWindows = /^[A-Za-z]:[\\/]/.test(instructionsPath);
|
|
if (isAbsoluteUnix || isAbsoluteWindows) {
|
|
throw badRequest("instructionsPath must be a project-relative path");
|
|
}
|
|
if (!instructionsPath.endsWith(".md")) {
|
|
throw badRequest("instructionsPath must end in .md");
|
|
}
|
|
}
|
|
|
|
if (instructionsText !== undefined && instructionsText !== null && instructionsText !== "") {
|
|
if (typeof instructionsText !== "string") {
|
|
throw badRequest("instructionsText must be a string");
|
|
}
|
|
if (instructionsText.length > 50000) {
|
|
throw badRequest("instructionsText must be at most 50,000 characters");
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function serializeAccessState(state: import("@fusion/core").AgentAccessState) {
|
|
return {
|
|
...state,
|
|
resolvedPermissions: Array.from(state.resolvedPermissions),
|
|
explicitPermissions: Array.from(state.explicitPermissions),
|
|
roleDefaultPermissions: Array.from(state.roleDefaultPermissions),
|
|
};
|
|
}
|
|
|
|
// Agent registrar order is contract-sensitive. Keep this sequence stable:
|
|
// 1) /agents + create, 2) import/export, 3) ordering-sensitive core lookups + /agents/:id,
|
|
// 4) runtime control-plane (/runs/stop before /runs/:runId),
|
|
// 5) reflections/ratings (latest before list), 6) generation routes.
|
|
registerAgentCoreListCreateRoutes(routeContext, {
|
|
sanitizeAgentTaskLinks,
|
|
validateAgentInstructionsPayload,
|
|
});
|
|
|
|
registerAgentImportExportRoutes(routeContext);
|
|
|
|
registerAgentCoreRoutes(routeContext, {
|
|
sanitizeAgentTaskLinks,
|
|
validateAgentInstructionsPayload,
|
|
});
|
|
|
|
registerAgentRuntimeRoutes(routeContext, {
|
|
validateAgentInstructionsPayload,
|
|
serializeAccessState,
|
|
hasHeartbeatExecutor,
|
|
heartbeatMonitor,
|
|
isHeartbeatMonitorForProject,
|
|
runExcerptToAgentLogs,
|
|
parseRunAuditFilters,
|
|
normalizeRunAuditEvent,
|
|
auditEventToTimelineEntry,
|
|
logEntryToTimelineEntry,
|
|
compareTimelineEntries,
|
|
listAgentMemoryFiles: (...args) => listAgentMemoryFiles(...args),
|
|
readAgentMemoryFile: (...args) => readAgentMemoryFile(...args),
|
|
writeAgentMemoryFile: (...args) => writeAgentMemoryFile(...args),
|
|
isMemoryBackendError: (error): error is { code: string; backend?: string; message: string } => error instanceof MemoryBackendError,
|
|
});
|
|
|
|
// ── Agent Reflection Routes ──────────────────────────────────────────────
|
|
|
|
registerAgentReflectionRatingRoutes(routeContext);
|
|
|
|
registerAgentGenerationRoutes(routeContext);
|
|
|
|
// ── Integrated domain routers ──────────────────────────────────────────────
|
|
// Keep this call at the current position to preserve precedence with
|
|
// surrounding route handlers. registerIntegratedRouters() mounts:
|
|
// - /missions
|
|
// - /roadmaps
|
|
// - /insights
|
|
registerIntegratedRouters({
|
|
router,
|
|
store,
|
|
options,
|
|
aiSessionStore,
|
|
});
|
|
|
|
// ── Plugin Routes ─────────────────────────────────────────────────────────
|
|
// Plugin management endpoints with projectId scoping support.
|
|
// Uses getScopedStore(req) pattern for multi-project support.
|
|
// Requires pluginStore in options.
|
|
|
|
/**
|
|
* GET /api/plugins
|
|
* List all installed plugins.
|
|
* Query: { projectId?: string, enabled?: boolean }
|
|
*/
|
|
router.get("/plugins", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
|
|
const filter: { enabled?: boolean } = {};
|
|
if (req.query.enabled !== undefined) {
|
|
filter.enabled = req.query.enabled === "true";
|
|
}
|
|
|
|
const plugins = await pluginStore.listPlugins(filter);
|
|
res.json(plugins);
|
|
});
|
|
|
|
/**
|
|
* GET /api/plugins/ui-slots
|
|
* Get all UI slot definitions from active plugins.
|
|
* Returns aggregated array of { pluginId, slot } objects.
|
|
*/
|
|
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
|
|
const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
|
|
res.json(slots);
|
|
});
|
|
|
|
/**
|
|
* GET /api/plugins/:id
|
|
* Get a single plugin by ID.
|
|
* Query: { projectId?: string }
|
|
*/
|
|
router.get("/plugins/:id", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
try {
|
|
const plugin = await pluginStore.getPlugin(id);
|
|
res.json(plugin);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(`Plugin "${id}" not found`);
|
|
}
|
|
throw internalError(err instanceof Error ? err.message : "Unknown error");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/plugins/:id/settings
|
|
* Get plugin settings by plugin ID.
|
|
* Query: { projectId?: string }
|
|
*/
|
|
router.get("/plugins/:id/settings", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
try {
|
|
const plugin = await pluginStore.getPlugin(id);
|
|
res.json(plugin.settings);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(`Plugin "${id}" not found`);
|
|
}
|
|
throw internalError(err instanceof Error ? err.message : "Unknown error");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/plugins
|
|
* Create or register a plugin.
|
|
* Requires `mode` discriminator in body:
|
|
* - mode: "register" → body must include { id, name, version, path }, optional { enabled, settings, projectId }
|
|
* - mode: "install" → body must include { path }, optional { projectId }
|
|
* Returns 201 on success, 400 for validation errors, 409 for conflicts.
|
|
*/
|
|
router.post("/plugins", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
|
|
if (!req.body || typeof req.body !== "object") {
|
|
throw badRequest("Request body is required");
|
|
}
|
|
|
|
const body = req.body as Record<string, unknown>;
|
|
|
|
// Validate mode discriminator is present
|
|
if (!("mode" in body) || typeof body.mode !== "string") {
|
|
throw badRequest("Request body must have a 'mode' field with value 'register' or 'install'");
|
|
}
|
|
|
|
const mode = body.mode as string;
|
|
|
|
if (mode === "register") {
|
|
// Register mode: requires id, name, version, path
|
|
if (typeof body.id !== "string" || !body.id.trim()) {
|
|
throw badRequest("'id' is required for register mode and must be a non-empty string");
|
|
}
|
|
if (typeof body.name !== "string" || !body.name.trim()) {
|
|
throw badRequest("'name' is required for register mode and must be a non-empty string");
|
|
}
|
|
if (typeof body.version !== "string" || !body.version.trim()) {
|
|
throw badRequest("'version' is required for register mode and must be a non-empty string");
|
|
}
|
|
if (typeof body.path !== "string" || !body.path.trim()) {
|
|
throw badRequest("'path' is required for register mode and must be a non-empty string");
|
|
}
|
|
|
|
const manifest: import("@fusion/core").PluginManifest = {
|
|
id: body.id as string,
|
|
name: body.name as string,
|
|
version: body.version as string,
|
|
description: typeof body.description === "string" ? body.description : undefined,
|
|
author: typeof body.author === "string" ? body.author : undefined,
|
|
homepage: typeof body.homepage === "string" ? body.homepage : undefined,
|
|
dependencies: Array.isArray(body.dependencies) ? (body.dependencies as string[]) : undefined,
|
|
settingsSchema: typeof body.settingsSchema === "object" && body.settingsSchema !== null
|
|
? (body.settingsSchema as Record<string, import("@fusion/core").PluginSettingSchema>)
|
|
: undefined,
|
|
};
|
|
|
|
const settings = typeof body.settings === "object" && body.settings !== null
|
|
? (body.settings as Record<string, unknown>)
|
|
: undefined;
|
|
|
|
// If enabled and loader is available, try to load the plugin
|
|
let plugin: import("@fusion/core").PluginInstallation;
|
|
try {
|
|
plugin = await pluginStore.registerPlugin({
|
|
manifest,
|
|
path: body.path as string,
|
|
settings,
|
|
});
|
|
|
|
if (plugin.enabled && options?.pluginLoader) {
|
|
try {
|
|
await options.pluginLoader.loadPlugin(plugin.id);
|
|
} catch (loadErr) {
|
|
// Log but don't fail - plugin is registered, just not loaded
|
|
runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, {
|
|
error: loadErr instanceof Error ? loadErr.message : String(loadErr),
|
|
});
|
|
}
|
|
}
|
|
|
|
res.status(201).json(plugin);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("already registered")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
}
|
|
throw internalError(err instanceof Error ? err.message : "Failed to register plugin");
|
|
}
|
|
} else if (mode === "install") {
|
|
// Install mode: requires path, loads manifest from path
|
|
// Supports package root and dist-folder selections via resolvePluginManifest
|
|
if (typeof body.path !== "string" || !body.path.trim()) {
|
|
throw badRequest("'path' is required for install mode and must be a non-empty string");
|
|
}
|
|
|
|
// Check if runtime install interface is available
|
|
if (!options?.pluginLoader) {
|
|
throw badRequest("Plugin install mode is not supported: plugin loader not available");
|
|
}
|
|
|
|
// Resolve manifest — supports package root and dist-folder selections
|
|
const { manifestDir, manifest } = await resolvePluginManifest(body.path as string);
|
|
|
|
try {
|
|
const plugin = await pluginStore.registerPlugin({
|
|
manifest,
|
|
path: manifestDir,
|
|
});
|
|
|
|
// If enabled, try to load the plugin
|
|
if (plugin.enabled) {
|
|
try {
|
|
await options.pluginLoader.loadPlugin(plugin.id);
|
|
} catch (loadErr) {
|
|
runtimeLogger.child("plugin-routes").error(`Failed to load plugin ${plugin.id}`, {
|
|
error: loadErr instanceof Error ? loadErr.message : String(loadErr),
|
|
});
|
|
}
|
|
}
|
|
|
|
res.status(201).json(plugin);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("already registered")) {
|
|
throw conflict(err instanceof Error ? err.message : String(err));
|
|
}
|
|
throw internalError(err instanceof Error ? err.message : "Failed to register plugin");
|
|
}
|
|
} else {
|
|
throw badRequest(`Invalid mode: '${mode}'. Must be 'register' or 'install'`);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/plugins/:id/enable
|
|
* Enable a plugin and start it.
|
|
* Body: { projectId?: string }
|
|
*/
|
|
router.post("/plugins/:id/enable", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
let plugin = await pluginStore.enablePlugin(id);
|
|
|
|
// Start the plugin if loader is available
|
|
if (options?.pluginLoader) {
|
|
try {
|
|
await options.pluginLoader.loadPlugin(id);
|
|
} catch (loadErr) {
|
|
// Update state to error
|
|
await pluginStore.updatePluginState(
|
|
id,
|
|
"error",
|
|
loadErr instanceof Error ? loadErr.message : String(loadErr),
|
|
);
|
|
plugin = await pluginStore.getPlugin(id);
|
|
}
|
|
}
|
|
|
|
res.json(plugin);
|
|
});
|
|
|
|
/**
|
|
* POST /api/plugins/:id/disable
|
|
* Disable a plugin and stop it.
|
|
* Body: { projectId?: string }
|
|
*/
|
|
router.post("/plugins/:id/disable", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
// Stop the plugin if loader is available
|
|
if (options?.pluginLoader) {
|
|
try {
|
|
await options.pluginLoader.stopPlugin(id);
|
|
} catch {
|
|
// Ignore errors from stopping - plugin might not be loaded
|
|
}
|
|
}
|
|
|
|
const plugin = await pluginStore.disablePlugin(id);
|
|
res.json(plugin);
|
|
});
|
|
|
|
/**
|
|
* POST /api/plugins/:id/reload
|
|
* Reload a running plugin with updated code.
|
|
* Body: { projectId?: string }
|
|
*/
|
|
router.post("/plugins/:id/reload", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
let plugin: import("@fusion/core").PluginInstallation;
|
|
try {
|
|
plugin = await pluginStore.getPlugin(id);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(`Plugin "${id}" not found`);
|
|
}
|
|
throw internalError(err instanceof Error ? err.message : "Unknown error");
|
|
}
|
|
|
|
if (plugin.state !== "started") {
|
|
throw badRequest("Plugin is not currently loaded. Use enable instead.");
|
|
}
|
|
|
|
if (!options?.pluginRunner?.reloadPlugin) {
|
|
throw internalError("Plugin runner not available");
|
|
}
|
|
|
|
try {
|
|
await options.pluginRunner.reloadPlugin(id);
|
|
} catch (reloadErr: unknown) {
|
|
throw internalError(`Reload failed: ${reloadErr instanceof Error ? reloadErr.message : String(reloadErr)}`);
|
|
}
|
|
|
|
const updatedPlugin = await pluginStore.getPlugin(id);
|
|
res.json(updatedPlugin);
|
|
});
|
|
|
|
/**
|
|
* PUT /api/plugins/:id/settings
|
|
* Update plugin settings.
|
|
* Body: { settings: Record<string, unknown>, projectId?: string }
|
|
*/
|
|
router.put("/plugins/:id/settings", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
if (!req.body || typeof req.body !== "object") {
|
|
throw badRequest("Request body must be an object with 'settings' field");
|
|
}
|
|
|
|
const body = req.body as Record<string, unknown>;
|
|
const settings = body.settings as Record<string, unknown> | undefined;
|
|
|
|
if (!settings || typeof settings !== "object") {
|
|
throw badRequest("Request body must have a 'settings' object");
|
|
}
|
|
|
|
try {
|
|
const plugin = await pluginStore.updatePluginSettings(id, settings);
|
|
res.json(plugin);
|
|
} catch (err: unknown) {
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("not found")) {
|
|
throw notFound(`Plugin "${id}" not found`);
|
|
}
|
|
if (err instanceof Error && (err instanceof Error ? err.message : String(err)).includes("validation failed")) {
|
|
throw badRequest(err instanceof Error ? err.message : String(err));
|
|
}
|
|
throw internalError(err instanceof Error ? err.message : "Failed to update settings");
|
|
}
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/plugins/:id
|
|
* Uninstall a plugin.
|
|
* Query: { projectId?: string }
|
|
*/
|
|
router.delete("/plugins/:id", async (req: Request, res: Response) => {
|
|
const { store: scopedStore } = await getProjectContext(req);
|
|
const pluginStore = scopedStore.getPluginStore();
|
|
const id = req.params.id as string;
|
|
|
|
// Stop the plugin if loader is available
|
|
if (options?.pluginLoader) {
|
|
try {
|
|
await options.pluginLoader.stopPlugin(id);
|
|
} catch {
|
|
// Ignore - plugin might not be loaded
|
|
}
|
|
}
|
|
|
|
await pluginStore.unregisterPlugin(id);
|
|
res.status(204).send();
|
|
});
|
|
|
|
// ── AI Session Routes (Background Tasks) ─────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/ai-sessions
|
|
* List active background AI sessions (generating or awaiting_input).
|
|
* Query: { projectId?: string }
|
|
*/
|
|
router.get("/ai-sessions", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
res.json({ sessions: [] });
|
|
return;
|
|
}
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
const sessions = aiSessionStore.listActive(projectId);
|
|
res.json({ sessions });
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/ai-sessions/cleanup
|
|
* Cleanup stale AI sessions with optional max-age override.
|
|
*/
|
|
router.delete("/ai-sessions/cleanup", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
sendErrorResponse(res, 503, "Session store not available");
|
|
return;
|
|
}
|
|
|
|
const minimumMaxAgeMs = 60 * 60 * 1000;
|
|
let maxAgeMs = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS;
|
|
|
|
if (typeof req.query.maxAgeMs === "string") {
|
|
const parsed = Number(req.query.maxAgeMs);
|
|
if (!Number.isFinite(parsed)) {
|
|
throw badRequest("maxAgeMs must be a valid number");
|
|
}
|
|
maxAgeMs = Math.max(minimumMaxAgeMs, Math.floor(parsed));
|
|
}
|
|
|
|
const result = aiSessionStore.cleanupStaleSessions(maxAgeMs);
|
|
res.json({
|
|
...result,
|
|
maxAgeMs,
|
|
});
|
|
});
|
|
|
|
/**
|
|
* GET /api/ai-sessions/:id
|
|
* Get full session state for modal reconnection.
|
|
*/
|
|
router.get("/ai-sessions/:id", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
const session = aiSessionStore.get(req.params.id);
|
|
if (!session) {
|
|
throw notFound("Session not found");
|
|
}
|
|
res.json(session);
|
|
});
|
|
|
|
router.post("/ai-sessions/:id/lock", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const session = aiSessionStore.get(id);
|
|
if (!session) {
|
|
throw notFound("Session not found");
|
|
}
|
|
|
|
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
|
|
if (!tabId) {
|
|
throw badRequest("tabId is required");
|
|
}
|
|
|
|
const result = aiSessionStore.acquireLock(id, tabId);
|
|
if (!result.acquired) {
|
|
res.json({ acquired: false, currentHolder: result.currentHolder });
|
|
return;
|
|
}
|
|
|
|
res.json({ acquired: true });
|
|
});
|
|
|
|
router.delete("/ai-sessions/:id/lock", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
|
|
if (!tabId) {
|
|
throw badRequest("tabId is required");
|
|
}
|
|
|
|
aiSessionStore.releaseLock(id, tabId);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.post("/ai-sessions/:id/lock/force", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const session = aiSessionStore.get(id);
|
|
if (!session) {
|
|
throw notFound("Session not found");
|
|
}
|
|
|
|
const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : "";
|
|
if (!tabId) {
|
|
throw badRequest("tabId is required");
|
|
}
|
|
|
|
aiSessionStore.forceAcquireLock(id, tabId);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
router.delete("/ai-sessions/:id/lock/beacon", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const tabId = typeof req.query.tabId === "string" ? req.query.tabId.trim() : "";
|
|
if (tabId) {
|
|
aiSessionStore.releaseLock(id, tabId);
|
|
}
|
|
|
|
res.status(200).end();
|
|
});
|
|
|
|
/**
|
|
* POST /api/ai-sessions/:id/ping
|
|
* Lightweight keep-alive touch for active AI sessions.
|
|
*/
|
|
router.post("/ai-sessions/:id/ping", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const updated = aiSessionStore.ping(id);
|
|
if (!updated) {
|
|
throw notFound("Session not found");
|
|
}
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
/**
|
|
* DELETE /api/ai-sessions/:id
|
|
* Dismiss/cancel a background AI session.
|
|
* Also cleans up the in-memory agent if still alive.
|
|
*/
|
|
router.delete("/ai-sessions/:id", (req, res) => {
|
|
if (!aiSessionStore) {
|
|
throw notFound("AI sessions not available");
|
|
}
|
|
const { id } = req.params;
|
|
const session = aiSessionStore.get(id);
|
|
if (!session) {
|
|
throw notFound("Session not found");
|
|
}
|
|
|
|
aiSessionStore.delete(id);
|
|
|
|
try {
|
|
if (getPlanningSession(id)) cleanupPlanningSession(id);
|
|
} catch {
|
|
// Session may not belong to planning or may already be cleaned up.
|
|
}
|
|
|
|
try {
|
|
if (getSubtaskSession(id)) cleanupSubtaskSession(id);
|
|
} catch {
|
|
// Session may not belong to subtask breakdown or may already be cleaned up.
|
|
}
|
|
|
|
try {
|
|
if (getMissionInterviewSession(id)) cleanupMissionInterviewSession(id);
|
|
} catch {
|
|
// Session may not belong to mission interview or may already be cleaned up.
|
|
}
|
|
|
|
try {
|
|
if (getTargetInterviewSession(id)) cleanupTargetInterviewSession(id);
|
|
} catch {
|
|
// Session may not belong to milestone/slice interview or may already be cleaned up.
|
|
}
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ── Directory Browsing ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* GET /api/browse-directory
|
|
* Browse filesystem directories for the directory picker.
|
|
* Query: { path?: string, showHidden?: "true", nodeId?: string }
|
|
* Returns: { currentPath: string, parentPath: string | null, entries: Array<{ name: string, path: string, hasChildren: boolean }> }
|
|
*/
|
|
router.get("/browse-directory", async (req, res) => {
|
|
try {
|
|
const nodeId = req.query.nodeId as string | undefined;
|
|
|
|
// Node-aware proxying: route to remote node if nodeId is provided and not local
|
|
if (nodeId) {
|
|
const { CentralCore } = await import("@fusion/core");
|
|
const central = new CentralCore(store.getFusionDir());
|
|
await central.init();
|
|
|
|
const localNodes = await central.listNodes();
|
|
const localNode = localNodes.find((n: { type?: string; id?: string }) => n.type === "local");
|
|
|
|
if (localNode && localNode.id === nodeId) {
|
|
// Local node — fall through to existing filesystem logic below
|
|
await central.close();
|
|
} else {
|
|
// Remote node — look up node config and proxy directly
|
|
const node = await central.getNode(nodeId);
|
|
await central.close();
|
|
|
|
if (!node) {
|
|
throw notFound("Node not found");
|
|
}
|
|
if (!node.url) {
|
|
throw badRequest("Node has no URL configured");
|
|
}
|
|
|
|
const queryString = req.url.split('?').slice(1).join('?');
|
|
const targetUrl = `${node.url.replace(/\/$/, '')}/api/browse-directory${queryString ? '?' + queryString : ''}`;
|
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
if (node.apiKey) {
|
|
headers["Authorization"] = `Bearer ${node.apiKey}`;
|
|
}
|
|
|
|
try {
|
|
const proxyRes = await fetch(targetUrl, {
|
|
method: "GET",
|
|
headers,
|
|
signal: AbortSignal.timeout(30000),
|
|
});
|
|
|
|
if (proxyRes.status === 401 && !node.apiKey) {
|
|
throw unauthorized("Remote node rejected directory browse request. Configure an apiKey for that node in Fusion settings.");
|
|
}
|
|
|
|
const body = Buffer.from(await proxyRes.arrayBuffer());
|
|
// Filter hop-by-hop headers
|
|
const skipHeaders = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade"]);
|
|
for (const [key, value] of proxyRes.headers.entries()) {
|
|
if (!skipHeaders.has(key.toLowerCase())) {
|
|
res.setHeader(key, value);
|
|
}
|
|
}
|
|
res.status(proxyRes.status);
|
|
res.send(body);
|
|
} catch (fetchErr) {
|
|
const e = fetchErr as { name?: string; code?: string; message?: string };
|
|
if (e.name === "AbortError" || e.code === "ETIMEDOUT") {
|
|
throw new ApiError(504, `Remote node timeout: ${e.message ?? String(fetchErr)}`);
|
|
}
|
|
throw new ApiError(502, `Remote node error: ${e.message ?? String(fetchErr)}`);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Local node logic
|
|
const { resolve, dirname, join } = await import("node:path");
|
|
const { readdir, stat } = await import("node:fs/promises");
|
|
|
|
const rawPath = (req.query.path as string) || process.env.HOME || process.env.USERPROFILE || "/";
|
|
const showHidden = req.query.showHidden === "true";
|
|
|
|
// Validate: must be absolute, no .. traversal
|
|
const resolvedPath = resolve(rawPath);
|
|
if (rawPath.includes("..")) {
|
|
throw badRequest("Path must not contain '..' traversal");
|
|
}
|
|
if (resolvedPath !== resolve(resolvedPath)) {
|
|
throw badRequest("Path must be absolute");
|
|
}
|
|
|
|
// Check path exists and is a directory
|
|
let pathStat;
|
|
try {
|
|
pathStat = await stat(resolvedPath);
|
|
} catch {
|
|
throw notFound("Directory not found");
|
|
}
|
|
if (!pathStat.isDirectory()) {
|
|
throw badRequest("Path is not a directory");
|
|
}
|
|
|
|
// Read directory entries
|
|
const dirEntries = await readdir(resolvedPath, { withFileTypes: true });
|
|
const entries: Array<{ name: string; path: string; hasChildren: boolean }> = [];
|
|
|
|
for (const entry of dirEntries) {
|
|
if (!entry.isDirectory()) continue;
|
|
if (!showHidden && entry.name.startsWith(".")) continue;
|
|
|
|
const entryPath = join(resolvedPath, entry.name);
|
|
let hasChildren = false;
|
|
try {
|
|
const subEntries = await readdir(entryPath, { withFileTypes: true });
|
|
hasChildren = subEntries.some((e) => e.isDirectory());
|
|
} catch {
|
|
// Can't read subdirectory — treat as no children
|
|
}
|
|
|
|
entries.push({ name: entry.name, path: entryPath, hasChildren });
|
|
}
|
|
|
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
const parentPath = resolvedPath === "/" ? null : dirname(resolvedPath);
|
|
|
|
res.json({ currentPath: resolvedPath, parentPath, entries });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// Registrar order is API-contract sensitive. Keep this domain sequence stable:
|
|
// 1) project routes (`/projects/across-nodes|detect` before `/projects/:id`)
|
|
// 2) node CRUD/operational routes
|
|
// 3) node settings/auth sync routes
|
|
// 4) mesh routes (`/mesh/state` before `/mesh/sync`)
|
|
// 5) discovery routes
|
|
// 6) inbound settings/auth receive/export routes
|
|
// ── Project Management Routes (Multi-Project Support) ───────────────────────
|
|
|
|
registerProjectRoutes(routeContext);
|
|
|
|
// ── Node Management Routes (Multi-Node Support) ───────────────────────────
|
|
|
|
registerNodeRoutes(routeContext);
|
|
|
|
// ── Remote Node Settings Sync Routes ──────────────────────────────────────
|
|
|
|
registerSettingsSyncRoutes(routeContext);
|
|
|
|
// ── Mesh Topology Routes ────────────────────────────────────────────────
|
|
|
|
registerMeshRoutes(routeContext);
|
|
|
|
// ── Node Discovery Routes (mDNS / DNS-SD) ────────────────────────────────
|
|
|
|
registerDiscoveryRoutes(routeContext);
|
|
|
|
// ── Inbound Settings/Auth Sync Routes ─────────────────────────────────────
|
|
|
|
registerSettingsSyncInboundRoutes(routeContext);
|
|
|
|
/**
|
|
* GET /api/activity-feed
|
|
* Get unified activity feed across all projects.
|
|
* Query: limit, projectId, types
|
|
* Returns: ActivityFeedEntry[]
|
|
*/
|
|
router.get("/activity-feed", async (req, res) => {
|
|
try {
|
|
const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50;
|
|
const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined;
|
|
const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined;
|
|
const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined;
|
|
|
|
const { CentralCore } = await import("@fusion/core");
|
|
const central = new CentralCore();
|
|
await central.init();
|
|
|
|
const entries = await central.getRecentActivity({ limit, projectId, types });
|
|
await central.close();
|
|
|
|
res.json(entries);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/global-concurrency
|
|
* Get global concurrency state across all projects.
|
|
* Returns: GlobalConcurrencyState
|
|
*/
|
|
router.get("/global-concurrency", async (_req, res) => {
|
|
try {
|
|
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
|
const shouldClose = !options?.centralCore;
|
|
if (shouldClose) await central.init();
|
|
|
|
const state = await central.getGlobalConcurrencyState();
|
|
if (shouldClose) await central.close();
|
|
|
|
res.json(state);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* PUT /api/global-concurrency
|
|
* Update the system-wide concurrency limit across all projects.
|
|
* Body: { globalMaxConcurrent: number }
|
|
* Returns: GlobalConcurrencyState
|
|
*/
|
|
router.put("/global-concurrency", async (req, res) => {
|
|
const { globalMaxConcurrent } = req.body ?? {};
|
|
if (!Number.isInteger(globalMaxConcurrent) || globalMaxConcurrent < 1 || globalMaxConcurrent > 10000) {
|
|
throw badRequest("globalMaxConcurrent must be an integer between 1 and 10000");
|
|
}
|
|
|
|
try {
|
|
const central = options?.centralCore ?? new (await import("@fusion/core")).CentralCore();
|
|
const shouldClose = !options?.centralCore;
|
|
if (shouldClose) await central.init();
|
|
|
|
const state = await central.updateGlobalConcurrency({ globalMaxConcurrent });
|
|
if (shouldClose) await central.close();
|
|
|
|
res.json(state);
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/first-run-status
|
|
* Check if user has projects or needs setup wizard.
|
|
* Returns: { hasProjects: boolean, singleProjectPath: string | null }
|
|
*/
|
|
router.get("/first-run-status", async (_req, res) => {
|
|
try {
|
|
const { CentralCore } = await import("@fusion/core");
|
|
const central = new CentralCore();
|
|
await central.init();
|
|
|
|
const projects = await central.listProjects();
|
|
await central.close();
|
|
|
|
const hasProjects = projects.length > 0;
|
|
const singleProjectPath = projects.length === 1 ? projects[0].path : null;
|
|
|
|
res.json({ hasProjects, singleProjectPath });
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* GET /api/setup-state
|
|
* Returns the first-run state and any detected projects for migration.
|
|
* This is used by the dashboard to determine what UI to show on startup.
|
|
*/
|
|
router.get("/setup-state", async (_req, res) => {
|
|
try {
|
|
const { FirstRunDetector } = await import("@fusion/core");
|
|
const { CentralCore } = await import("@fusion/core");
|
|
|
|
const detector = new FirstRunDetector();
|
|
const state = await detector.detectFirstRunState();
|
|
const detectedProjects = await detector.detectExistingProjects(process.cwd());
|
|
|
|
// Get central DB info
|
|
const central = new CentralCore();
|
|
await central.init();
|
|
const projects = await central.listProjects();
|
|
await central.close();
|
|
|
|
res.json({
|
|
state,
|
|
detectedProjects,
|
|
hasCentralDb: detector.hasCentralDb(),
|
|
registeredProjects: projects.map((p) => ({
|
|
id: p.id,
|
|
name: p.name,
|
|
path: p.path,
|
|
})),
|
|
});
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* POST /api/complete-setup
|
|
* Complete the first-run setup by registering projects.
|
|
* Body: { projects: Array<{ path: string, name: string, isolationMode?: "in-process" | "child-process" }> }
|
|
*/
|
|
router.post("/complete-setup", async (req, res) => {
|
|
try {
|
|
const { CentralCore } = await import("@fusion/core");
|
|
const { MigrationCoordinator } = await import("@fusion/core");
|
|
|
|
const { projects } = req.body as {
|
|
projects: Array<{ path: string; name: string; isolationMode?: "in-process" | "child-process" }>;
|
|
};
|
|
|
|
if (!Array.isArray(projects)) {
|
|
throw badRequest("projects must be an array");
|
|
}
|
|
|
|
const central = new CentralCore();
|
|
await central.init();
|
|
|
|
try {
|
|
const coordinator = new MigrationCoordinator(central);
|
|
const result = await coordinator.completeSetup(projects);
|
|
|
|
res.json({
|
|
success: result.success,
|
|
projectsRegistered: result.projectsRegistered,
|
|
errors: result.errors,
|
|
});
|
|
} finally {
|
|
await central.close();
|
|
}
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
rethrowAsApiError(err);
|
|
}
|
|
});
|
|
|
|
// Dev server mount intentionally stays in this late position to keep route
|
|
// precedence unchanged relative to existing wildcard handlers.
|
|
registerIntegratedDevServerRouter({ router, store });
|
|
|
|
// Scripts and messaging routes are registered by registerMessagingScriptRoutes().
|
|
|
|
router.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
|
|
if (res.headersSent) {
|
|
next(err);
|
|
return;
|
|
}
|
|
|
|
if (err instanceof ApiError) {
|
|
sendErrorResponse(res, err.statusCode, err.message, { details: err.details });
|
|
return;
|
|
}
|
|
|
|
if (err instanceof Error) {
|
|
sendErrorResponse(res, 500, err.message);
|
|
return;
|
|
}
|
|
|
|
sendErrorResponse(res, 500, "Internal server error");
|
|
});
|
|
|
|
|
|
// ── Skills Routes ──────────────────────────────────────────────────────────
|
|
|
|
registerAgentSkillsRoutes(routeContext);
|
|
|
|
// Remote node proxy routes are registered by registerProxyRoutes().
|
|
|
|
return router;
|
|
}
|
|
|
|
// ── Automation step helpers ─────────────────────────────────────────
|
|
|
|
/**
|
|
* Validate an array of automation steps.
|
|
* Returns an error string if invalid, or null if valid.
|
|
*/
|
|
function validateAutomationSteps(steps: unknown[]): string | null {
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i] as Record<string, unknown>;
|
|
if (!step.id || typeof step.id !== "string") {
|
|
return `Step ${i + 1}: id is required`;
|
|
}
|
|
if (!step.type || (step.type !== "command" && step.type !== "ai-prompt" && step.type !== "create-task")) {
|
|
return `Step ${i + 1}: type must be "command", "ai-prompt", or "create-task"`;
|
|
}
|
|
if (!step.name || typeof step.name !== "string" || !step.name.trim()) {
|
|
return `Step ${i + 1}: name is required`;
|
|
}
|
|
if (step.type === "command") {
|
|
if (!step.command || typeof step.command !== "string" || !step.command.trim()) {
|
|
return `Step ${i + 1}: command is required for command steps`;
|
|
}
|
|
}
|
|
if (step.type === "ai-prompt") {
|
|
if (!step.prompt || typeof step.prompt !== "string" || !step.prompt.trim()) {
|
|
return `Step ${i + 1}: prompt is required for ai-prompt steps`;
|
|
}
|
|
}
|
|
if (step.type === "create-task") {
|
|
if (!step.taskDescription || typeof step.taskDescription !== "string" || !step.taskDescription.trim()) {
|
|
return `Step ${i + 1}: taskDescription is required for create-task steps`;
|
|
}
|
|
}
|
|
// Validate model fields are both present or both absent
|
|
const hasProvider = step.modelProvider && typeof step.modelProvider === "string";
|
|
const hasModelId = step.modelId && typeof step.modelId === "string";
|
|
if ((hasProvider && !hasModelId) || (!hasProvider && hasModelId)) {
|
|
return `Step ${i + 1}: modelProvider and modelId must both be present or both absent`;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Execute a single shell command (used by manual run endpoint).
|
|
*/
|
|
async function executeSingleCommand(
|
|
command: string,
|
|
timeoutMs: number | undefined,
|
|
startedAt: string,
|
|
): Promise<import("@fusion/core").AutomationRunResult> {
|
|
const { exec } = await import("node:child_process");
|
|
const { promisify } = await import("node:util");
|
|
const execAsyncFn = promisify(exec);
|
|
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
const MAX_BUFFER = 1024 * 1024;
|
|
const MAX_OUTPUT = 10240;
|
|
|
|
try {
|
|
const { stdout, stderr } = await execAsyncFn(command, {
|
|
timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
maxBuffer: MAX_BUFFER,
|
|
shell: "/bin/sh",
|
|
});
|
|
|
|
let output = stdout;
|
|
if (stderr) {
|
|
output += stdout ? "\n--- stderr ---\n" : "";
|
|
output += stderr;
|
|
}
|
|
if (output.length > MAX_OUTPUT) {
|
|
output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]";
|
|
}
|
|
|
|
return { success: true, output, startedAt, completedAt: new Date().toISOString() };
|
|
} catch (err: unknown) {
|
|
if (err instanceof ApiError) {
|
|
throw err;
|
|
}
|
|
const execErr = err as NodeJS.ErrnoException & { stdout?: string; stderr?: string; killed?: boolean };
|
|
const stdout = execErr.stdout ?? "";
|
|
const stderr = execErr.stderr ?? "";
|
|
let output = stdout;
|
|
if (stderr) {
|
|
output += stdout ? "\n--- stderr ---\n" : "";
|
|
output += stderr;
|
|
}
|
|
if (output.length > MAX_OUTPUT) {
|
|
output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]";
|
|
}
|
|
|
|
return {
|
|
success: false,
|
|
output,
|
|
error: execErr.killed
|
|
? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s`
|
|
: (err instanceof Error ? err.message : String(err)),
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute all steps in a multi-step schedule (used by manual run endpoint).
|
|
*/
|
|
async function executeScheduleSteps(
|
|
schedule: import("@fusion/core").ScheduledTask,
|
|
startedAt: string,
|
|
): Promise<import("@fusion/core").AutomationRunResult> {
|
|
const steps = schedule.steps!;
|
|
const stepResults: import("@fusion/core").AutomationStepResult[] = [];
|
|
let overallSuccess = true;
|
|
let stoppedEarly = false;
|
|
|
|
for (let i = 0; i < steps.length; i++) {
|
|
const step = steps[i];
|
|
const stepStartedAt = new Date().toISOString();
|
|
const timeoutMs = step.timeoutMs ?? schedule.timeoutMs ?? 300000;
|
|
|
|
let stepResult: import("@fusion/core").AutomationStepResult;
|
|
|
|
if (step.type === "command") {
|
|
const cmdResult = await executeSingleCommand(step.command ?? "", timeoutMs, stepStartedAt);
|
|
stepResult = {
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
stepIndex: i,
|
|
success: cmdResult.success,
|
|
output: cmdResult.output,
|
|
error: cmdResult.error,
|
|
startedAt: stepStartedAt,
|
|
completedAt: cmdResult.completedAt,
|
|
};
|
|
} else if (step.type === "ai-prompt") {
|
|
// AI prompt steps return a placeholder in manual run mode
|
|
const model = step.modelProvider && step.modelId
|
|
? `${step.modelProvider}/${step.modelId}`
|
|
: "default";
|
|
stepResult = {
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
stepIndex: i,
|
|
success: !!step.prompt?.trim(),
|
|
output: step.prompt?.trim()
|
|
? `[AI prompt step — model: ${model}]\nPrompt: ${step.prompt}`
|
|
: "",
|
|
error: step.prompt?.trim() ? undefined : "AI prompt step has no prompt specified",
|
|
startedAt: stepStartedAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
} else {
|
|
stepResult = {
|
|
stepId: step.id,
|
|
stepName: step.name,
|
|
stepIndex: i,
|
|
success: false,
|
|
output: "",
|
|
error: `Unknown step type: "${step.type}"`,
|
|
startedAt: stepStartedAt,
|
|
completedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
|
|
stepResults.push(stepResult);
|
|
|
|
if (!stepResult.success) {
|
|
overallSuccess = false;
|
|
if (!step.continueOnFailure) {
|
|
stoppedEarly = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Aggregate output
|
|
const outputParts: string[] = [];
|
|
for (const sr of stepResults) {
|
|
outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`);
|
|
if (sr.output) outputParts.push(sr.output);
|
|
if (sr.error) outputParts.push(`Error: ${sr.error}`);
|
|
}
|
|
let output = outputParts.join("\n");
|
|
if (output.length > 10240) {
|
|
output = output.slice(0, 10240) + "\n[output truncated]";
|
|
}
|
|
|
|
const failedSteps = stepResults.filter((sr) => !sr.success);
|
|
const error = failedSteps.length > 0
|
|
? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}`
|
|
: undefined;
|
|
|
|
return {
|
|
success: overallSuccess,
|
|
output,
|
|
error,
|
|
startedAt,
|
|
completedAt: new Date().toISOString(),
|
|
stepResults,
|
|
};
|
|
}
|
|
|