diff --git a/cli/src/commands/client/company.ts b/cli/src/commands/client/company.ts index f6720c51..5931fb3a 100644 --- a/cli/src/commands/client/company.ts +++ b/cli/src/commands/client/company.ts @@ -1,5 +1,15 @@ import { Command } from "commander"; -import type { Company } from "@paperclip/shared"; +import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import path from "node:path"; +import type { + Company, + CompanyPortabilityExportResult, + CompanyPortabilityInclude, + CompanyPortabilityManifest, + CompanyPortabilityPreviewResult, + CompanyPortabilityImportResult, +} from "@paperclip/shared"; +import { ApiRequestError } from "../../client/http.js"; import { addCommonClientOptions, formatInlineRecord, @@ -10,6 +20,185 @@ import { } from "./common.js"; interface CompanyCommandOptions extends BaseClientOptions {} +type CompanyDeleteSelectorMode = "auto" | "id" | "prefix"; +type CompanyImportTargetMode = "new" | "existing"; +type CompanyCollisionMode = "rename" | "skip" | "replace"; + +interface CompanyDeleteOptions extends BaseClientOptions { + by?: CompanyDeleteSelectorMode; + yes?: boolean; + confirm?: string; +} + +interface CompanyExportOptions extends BaseClientOptions { + out?: string; + include?: string; +} + +interface CompanyImportOptions extends BaseClientOptions { + from?: string; + include?: string; + target?: CompanyImportTargetMode; + companyId?: string; + newCompanyName?: string; + agents?: string; + collision?: CompanyCollisionMode; + dryRun?: boolean; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); +} + +function normalizeSelector(input: string): string { + return input.trim(); +} + +function parseInclude(input: string | undefined): CompanyPortabilityInclude { + if (!input || !input.trim()) return { company: true, agents: true }; + const values = input.split(",").map((part) => part.trim().toLowerCase()).filter(Boolean); + const include = { + company: values.includes("company"), + agents: values.includes("agents"), + }; + if (!include.company && !include.agents) { + throw new Error("Invalid --include value. Use one or both of: company,agents"); + } + return include; +} + +function parseAgents(input: string | undefined): "all" | string[] { + if (!input || !input.trim()) return "all"; + const normalized = input.trim().toLowerCase(); + if (normalized === "all") return "all"; + const values = input.split(",").map((part) => part.trim()).filter(Boolean); + if (values.length === 0) return "all"; + return Array.from(new Set(values)); +} + +function isHttpUrl(input: string): boolean { + return /^https?:\/\//i.test(input.trim()); +} + +function isGithubUrl(input: string): boolean { + return /^https?:\/\/github\.com\//i.test(input.trim()); +} + +async function resolveInlineSourceFromPath(inputPath: string): Promise<{ + manifest: CompanyPortabilityManifest; + files: Record; +}> { + const resolved = path.resolve(inputPath); + const resolvedStat = await stat(resolved); + const manifestPath = resolvedStat.isDirectory() + ? path.join(resolved, "paperclip.manifest.json") + : resolved; + const manifestBaseDir = path.dirname(manifestPath); + const manifestRaw = await readFile(manifestPath, "utf8"); + const manifest = JSON.parse(manifestRaw) as CompanyPortabilityManifest; + const files: Record = {}; + + if (manifest.company?.path) { + const companyPath = manifest.company.path.replace(/\\/g, "/"); + files[companyPath] = await readFile(path.join(manifestBaseDir, companyPath), "utf8"); + } + for (const agent of manifest.agents ?? []) { + const agentPath = agent.path.replace(/\\/g, "/"); + files[agentPath] = await readFile(path.join(manifestBaseDir, agentPath), "utf8"); + } + + return { manifest, files }; +} + +async function writeExportToFolder(outDir: string, exported: CompanyPortabilityExportResult): Promise { + const root = path.resolve(outDir); + await mkdir(root, { recursive: true }); + const manifestPath = path.join(root, "paperclip.manifest.json"); + await writeFile(manifestPath, JSON.stringify(exported.manifest, null, 2), "utf8"); + for (const [relativePath, content] of Object.entries(exported.files)) { + const normalized = relativePath.replace(/\\/g, "/"); + const filePath = path.join(root, normalized); + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, content, "utf8"); + } +} + +function matchesPrefix(company: Company, selector: string): boolean { + return company.issuePrefix.toUpperCase() === selector.toUpperCase(); +} + +export function resolveCompanyForDeletion( + companies: Company[], + selectorRaw: string, + by: CompanyDeleteSelectorMode = "auto", +): Company { + const selector = normalizeSelector(selectorRaw); + if (!selector) { + throw new Error("Company selector is required."); + } + + const idMatch = companies.find((company) => company.id === selector); + const prefixMatch = companies.find((company) => matchesPrefix(company, selector)); + + if (by === "id") { + if (!idMatch) { + throw new Error(`No company found by ID '${selector}'.`); + } + return idMatch; + } + + if (by === "prefix") { + if (!prefixMatch) { + throw new Error(`No company found by shortname/prefix '${selector}'.`); + } + return prefixMatch; + } + + if (idMatch && prefixMatch && idMatch.id !== prefixMatch.id) { + throw new Error( + `Selector '${selector}' is ambiguous (matches both an ID and a shortname). Re-run with --by id or --by prefix.`, + ); + } + + if (idMatch) return idMatch; + if (prefixMatch) return prefixMatch; + + throw new Error( + `No company found for selector '${selector}'. Use company ID or issue prefix (for example PAP).`, + ); +} + +export function assertDeleteConfirmation(company: Company, opts: CompanyDeleteOptions): void { + if (!opts.yes) { + throw new Error("Deletion requires --yes."); + } + + const confirm = opts.confirm?.trim(); + if (!confirm) { + throw new Error( + "Deletion requires --confirm where value matches the company ID or issue prefix.", + ); + } + + const confirmsById = confirm === company.id; + const confirmsByPrefix = confirm.toUpperCase() === company.issuePrefix.toUpperCase(); + if (!confirmsById && !confirmsByPrefix) { + throw new Error( + `Confirmation '${confirm}' does not match target company. Expected ID '${company.id}' or prefix '${company.issuePrefix}'.`, + ); + } +} + +function assertDeleteFlags(opts: CompanyDeleteOptions): void { + if (!opts.yes) { + throw new Error("Deletion requires --yes."); + } + if (!opts.confirm?.trim()) { + throw new Error( + "Deletion requires --confirm where value matches the company ID or issue prefix.", + ); + } +} export function registerCompanyCommands(program: Command): void { const company = program.command("company").description("Company operations"); @@ -64,4 +253,220 @@ export function registerCompanyCommands(program: Command): void { } }), ); + + addCommonClientOptions( + company + .command("export") + .description("Export a company into portable manifest + markdown files") + .argument("", "Company ID") + .requiredOption("--out ", "Output directory") + .option("--include ", "Comma-separated include set: company,agents", "company,agents") + .action(async (companyId: string, opts: CompanyExportOptions) => { + try { + const ctx = resolveCommandContext(opts); + const include = parseInclude(opts.include); + const exported = await ctx.api.post( + `/api/companies/${companyId}/export`, + { include }, + ); + if (!exported) { + throw new Error("Export request returned no data"); + } + await writeExportToFolder(opts.out!, exported); + printOutput( + { + ok: true, + out: path.resolve(opts.out!), + filesWritten: Object.keys(exported.files).length + 1, + warningCount: exported.warnings.length, + }, + { json: ctx.json }, + ); + if (!ctx.json && exported.warnings.length > 0) { + for (const warning of exported.warnings) { + console.log(`warning=${warning}`); + } + } + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("import") + .description("Import a portable company package from local path, URL, or GitHub") + .requiredOption("--from ", "Source path or URL") + .option("--include ", "Comma-separated include set: company,agents", "company,agents") + .option("--target ", "Target mode: new | existing") + .option("-C, --company-id ", "Existing target company ID") + .option("--new-company-name ", "Name override for --target new") + .option("--agents ", "Comma-separated agent slugs to import, or all", "all") + .option("--collision ", "Collision strategy: rename | skip | replace", "rename") + .option("--dry-run", "Run preview only without applying", false) + .action(async (opts: CompanyImportOptions) => { + try { + const ctx = resolveCommandContext(opts); + const from = (opts.from ?? "").trim(); + if (!from) { + throw new Error("--from is required"); + } + + const include = parseInclude(opts.include); + const agents = parseAgents(opts.agents); + const collision = (opts.collision ?? "rename").toLowerCase() as CompanyCollisionMode; + if (!["rename", "skip", "replace"].includes(collision)) { + throw new Error("Invalid --collision value. Use: rename, skip, replace"); + } + + const inferredTarget = opts.target ?? (opts.companyId || ctx.companyId ? "existing" : "new"); + const target = inferredTarget.toLowerCase() as CompanyImportTargetMode; + if (!["new", "existing"].includes(target)) { + throw new Error("Invalid --target value. Use: new | existing"); + } + + const existingTargetCompanyId = opts.companyId?.trim() || ctx.companyId; + const targetPayload = + target === "existing" + ? { + mode: "existing_company" as const, + companyId: existingTargetCompanyId, + } + : { + mode: "new_company" as const, + newCompanyName: opts.newCompanyName?.trim() || null, + }; + + if (targetPayload.mode === "existing_company" && !targetPayload.companyId) { + throw new Error("Target existing company requires --company-id (or context default companyId)."); + } + + let sourcePayload: + | { type: "inline"; manifest: CompanyPortabilityManifest; files: Record } + | { type: "url"; url: string } + | { type: "github"; url: string }; + + if (isHttpUrl(from)) { + sourcePayload = isGithubUrl(from) + ? { type: "github", url: from } + : { type: "url", url: from }; + } else { + const inline = await resolveInlineSourceFromPath(from); + sourcePayload = { + type: "inline", + manifest: inline.manifest, + files: inline.files, + }; + } + + const payload = { + source: sourcePayload, + include, + target: targetPayload, + agents, + collisionStrategy: collision, + }; + + if (opts.dryRun) { + const preview = await ctx.api.post( + "/api/companies/import/preview", + payload, + ); + printOutput(preview, { json: ctx.json }); + return; + } + + const imported = await ctx.api.post("/api/companies/import", payload); + printOutput(imported, { json: ctx.json }); + } catch (err) { + handleCommandError(err); + } + }), + ); + + addCommonClientOptions( + company + .command("delete") + .description("Delete a company by ID or shortname/prefix (destructive)") + .argument("", "Company ID or issue prefix (for example PAP)") + .option( + "--by ", + "Selector mode: auto | id | prefix", + "auto", + ) + .option("--yes", "Required safety flag to confirm destructive action", false) + .option( + "--confirm ", + "Required safety value: target company ID or shortname/prefix", + ) + .action(async (selector: string, opts: CompanyDeleteOptions) => { + try { + const by = (opts.by ?? "auto").trim().toLowerCase() as CompanyDeleteSelectorMode; + if (!["auto", "id", "prefix"].includes(by)) { + throw new Error(`Invalid --by mode '${opts.by}'. Expected one of: auto, id, prefix.`); + } + + const ctx = resolveCommandContext(opts); + const normalizedSelector = normalizeSelector(selector); + assertDeleteFlags(opts); + + let target: Company | null = null; + const shouldTryIdLookup = by === "id" || (by === "auto" && isUuidLike(normalizedSelector)); + if (shouldTryIdLookup) { + const byId = await ctx.api.get(`/api/companies/${normalizedSelector}`, { ignoreNotFound: true }); + if (byId) { + target = byId; + } else if (by === "id") { + throw new Error(`No company found by ID '${normalizedSelector}'.`); + } + } + + if (!target && ctx.companyId) { + const scoped = await ctx.api.get(`/api/companies/${ctx.companyId}`, { ignoreNotFound: true }); + if (scoped) { + try { + target = resolveCompanyForDeletion([scoped], normalizedSelector, by); + } catch { + // Fallback to board-wide lookup below. + } + } + } + + if (!target) { + try { + const companies = (await ctx.api.get("/api/companies")) ?? []; + target = resolveCompanyForDeletion(companies, normalizedSelector, by); + } catch (error) { + if (error instanceof ApiRequestError && error.status === 403 && error.message.includes("Board access required")) { + throw new Error( + "Board access is required to resolve companies across the instance. Use a company ID/prefix for your current company, or run with board authentication.", + ); + } + throw error; + } + } + + if (!target) { + throw new Error(`No company found for selector '${normalizedSelector}'.`); + } + + assertDeleteConfirmation(target, opts); + + await ctx.api.delete<{ ok: true }>(`/api/companies/${target.id}`); + + printOutput( + { + ok: true, + deletedCompanyId: target.id, + deletedCompanyName: target.name, + deletedCompanyPrefix: target.issuePrefix, + }, + { json: ctx.json }, + ); + } catch (err) { + handleCommandError(err); + } + }), + ); } diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index a1862f7f..d7e9d553 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -66,6 +66,7 @@ export interface AdapterInvocationMeta { command: string; cwd?: string; commandArgs?: string[]; + commandNotes?: string[]; env?: Record; prompt?: string; context?: Record; @@ -110,6 +111,12 @@ export interface AdapterEnvironmentTestContext { companyId: string; adapterType: string; config: Record; + deployment?: { + mode?: "local_trusted" | "authenticated"; + exposure?: "private" | "public"; + bindHost?: string | null; + allowedHostnames?: string[]; + }; } export interface ServerAdapterModule { diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 464a0d7c..6e96361f 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -18,7 +18,6 @@ Core fields: - effort (string, optional): reasoning effort passed via --effort (low|medium|high) - chrome (boolean, optional): pass --chrome when running Claude - promptTemplate (string, optional): run prompt template -- bootstrapPromptTemplate (string, optional): first-run prompt template - maxTurnsPerRun (number, optional): max turns for one run - dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude - command (string, optional): defaults to "claude" diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 4ae8e2e7..d7d1de5f 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -261,7 +261,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { + if (!instructionsFilePath) return [] as string[]; + if (instructionsPrefix.length > 0) { + return [ + `Loaded agent instructions from ${instructionsFilePath}`, + `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, + ]; + } + return [ + `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, + ]; + })(); + const renderedPrompt = renderTemplate(promptTemplate, { agentId: agent.id, companyId: agent.companyId, runId, @@ -283,6 +293,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise { if (idx === args.length - 1 && value !== "-") return ``; return value; diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index d5cd6f04..8e2d8f8d 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -55,7 +55,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); + } + const firstColon = trimmed.indexOf(":"); + if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); + return trimmed.toLowerCase(); +} + +function pushDeploymentDiagnostics( + checks: AdapterEnvironmentCheck[], + ctx: AdapterEnvironmentTestContext, + endpointUrl: URL | null, +) { + const mode = ctx.deployment?.mode; + const exposure = ctx.deployment?.exposure; + const bindHost = normalizeHostname(ctx.deployment?.bindHost ?? null); + const allowSet = new Set( + (ctx.deployment?.allowedHostnames ?? []) + .map((entry) => normalizeHostname(entry)) + .filter((entry): entry is string => Boolean(entry)), + ); + const endpointHost = endpointUrl ? normalizeHostname(endpointUrl.hostname) : null; + + if (!mode) return; + + checks.push({ + code: "openclaw_deployment_context", + level: "info", + message: `Deployment context: mode=${mode}${exposure ? ` exposure=${exposure}` : ""}`, + }); + + if (mode === "authenticated" && exposure === "private") { + if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { + checks.push({ + code: "openclaw_private_bind_hostname_not_allowed", + level: "warn", + message: `Paperclip bind host "${bindHost}" is not in allowed hostnames.`, + hint: `Run pnpm paperclip allowed-hostname ${bindHost} so remote OpenClaw callbacks can pass host checks.`, + }); + } + + if (!bindHost || isLoopbackHost(bindHost)) { + checks.push({ + code: "openclaw_private_bind_loopback", + level: "warn", + message: "Paperclip is bound to loopback in authenticated/private mode.", + hint: "Bind to a reachable private hostname/IP so remote OpenClaw agents can call back.", + }); + } + + if (endpointHost && !isLoopbackHost(endpointHost) && allowSet.size === 0) { + checks.push({ + code: "openclaw_private_no_allowed_hostnames", + level: "warn", + message: "No explicit allowed hostnames are configured for authenticated/private mode.", + hint: "Set one with pnpm paperclip allowed-hostname when OpenClaw runs on another machine.", + }); + } + } + + if (mode === "authenticated" && exposure === "public" && endpointUrl && endpointUrl.protocol !== "https:") { + checks.push({ + code: "openclaw_public_http_endpoint", + level: "warn", + message: "OpenClaw endpoint uses HTTP in authenticated/public mode.", + hint: "Prefer HTTPS for public deployments.", + }); + } +} + export async function testEnvironment( ctx: AdapterEnvironmentTestContext, ): Promise { @@ -75,6 +150,8 @@ export async function testEnvironment( } } + pushDeploymentDiagnostics(checks, ctx, url); + const method = asString(config.method, "POST").trim().toUpperCase() || "POST"; checks.push({ code: "openclaw_method_configured", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee361a65..3f8001d4 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -229,7 +229,8 @@ export { } from "./validators/index.js"; export { API_PREFIX, API } from "./api.js"; -export { normalizeAgentUrlKey, deriveAgentUrlKey } from "./agent-url-key.js"; +export { normalizeAgentUrlKey, deriveAgentUrlKey, isUuidLike } from "./agent-url-key.js"; +export { deriveProjectUrlKey, normalizeProjectUrlKey } from "./project-url-key.js"; export { PROJECT_MENTION_SCHEME, buildProjectMentionHref, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 004c1dbb..04a64a6d 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -12,6 +12,7 @@ export interface Agent { id: string; companyId: string; name: string; + urlKey: string; role: AgentRole; title: string | null; icon: string | null; diff --git a/packages/shared/src/types/project.ts b/packages/shared/src/types/project.ts index ad843c65..b209c77f 100644 --- a/packages/shared/src/types/project.ts +++ b/packages/shared/src/types/project.ts @@ -22,6 +22,7 @@ export interface ProjectWorkspace { export interface Project { id: string; companyId: string; + urlKey: string; /** @deprecated Use goalIds / goals instead */ goalId: string | null; goalIds: string[]; diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 352c8995..12ad7ffb 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -4,6 +4,23 @@ export { type CreateCompany, type UpdateCompany, } from "./company.js"; +export { + portabilityIncludeSchema, + portabilitySecretRequirementSchema, + portabilityCompanyManifestEntrySchema, + portabilityAgentManifestEntrySchema, + portabilityManifestSchema, + portabilitySourceSchema, + portabilityTargetSchema, + portabilityAgentSelectionSchema, + portabilityCollisionStrategySchema, + companyPortabilityExportSchema, + companyPortabilityPreviewSchema, + companyPortabilityImportSchema, + type CompanyPortabilityExport, + type CompanyPortabilityPreview, + type CompanyPortabilityImport, +} from "./company-portability.js"; export { createAgentSchema, @@ -114,21 +131,3 @@ export { type UpdateMemberPermissions, type UpdateUserCompanyAccess, } from "./access.js"; - -export { - portabilityIncludeSchema, - portabilitySecretRequirementSchema, - portabilityCompanyManifestEntrySchema, - portabilityAgentManifestEntrySchema, - portabilityManifestSchema, - portabilitySourceSchema, - portabilityTargetSchema, - portabilityAgentSelectionSchema, - portabilityCollisionStrategySchema, - companyPortabilityExportSchema, - companyPortabilityPreviewSchema, - companyPortabilityImportSchema, - type CompanyPortabilityExport, - type CompanyPortabilityPreview, - type CompanyPortabilityImport, -} from "./company-portability.js"; diff --git a/packages/shared/src/validators/issue.ts b/packages/shared/src/validators/issue.ts index b6033ba9..04bfca48 100644 --- a/packages/shared/src/validators/issue.ts +++ b/packages/shared/src/validators/issue.ts @@ -50,6 +50,7 @@ export type CheckoutIssue = z.infer; export const addIssueCommentSchema = z.object({ body: z.string().min(1), reopen: z.boolean().optional(), + interrupt: z.boolean().optional(), }); export type AddIssueComment = z.infer; diff --git a/server/src/app.ts b/server/src/app.ts index ca67a519..520450c7 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -38,6 +38,7 @@ export async function createApp( allowedHostnames: string[]; bindHost: string; authReady: boolean; + companyDeletionEnabled: boolean; betterAuthHandler?: express.RequestHandler; resolveSession?: (req: ExpressRequest) => Promise; }, @@ -79,6 +80,7 @@ export async function createApp( deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, + companyDeletionEnabled: opts.companyDeletionEnabled, }), ); api.use("/companies", companyRoutes(db)); @@ -93,7 +95,14 @@ export async function createApp( api.use(activityRoutes(db)); api.use(dashboardRoutes(db)); api.use(sidebarBadgeRoutes(db)); - api.use(accessRoutes(db)); + api.use( + accessRoutes(db, { + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames, + }), + ); app.use("/api", api); const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/server/src/config.ts b/server/src/config.ts index 3ff7edd3..56c14907 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -54,6 +54,7 @@ export interface Config { storageS3ForcePathStyle: boolean; heartbeatSchedulerEnabled: boolean; heartbeatSchedulerIntervalMs: number; + companyDeletionEnabled: boolean; } export function loadConfig(): Config { @@ -142,6 +143,11 @@ export function loadConfig(): Config { const allowedHostnames = Array.from( new Set((allowedHostnamesFromEnv ?? fileConfig?.server.allowedHostnames ?? []).map((value) => value.trim().toLowerCase()).filter(Boolean)), ); + const companyDeletionEnvRaw = process.env.PAPERCLIP_ENABLE_COMPANY_DELETION; + const companyDeletionEnabled = + companyDeletionEnvRaw !== undefined + ? companyDeletionEnvRaw === "true" + : deploymentMode === "local_trusted"; return { deploymentMode, @@ -179,5 +185,6 @@ export function loadConfig(): Config { storageS3ForcePathStyle, heartbeatSchedulerEnabled: process.env.HEARTBEAT_SCHEDULER_ENABLED !== "false", heartbeatSchedulerIntervalMs: Math.max(10000, Number(process.env.HEARTBEAT_SCHEDULER_INTERVAL_MS) || 30000), + companyDeletionEnabled, }; } diff --git a/server/src/index.ts b/server/src/index.ts index 16a54e95..d4eaad2a 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -357,6 +357,7 @@ const app = await createApp(db as any, { allowedHostnames: config.allowedHostnames, bindHost: config.host, authReady, + companyDeletionEnabled: config.companyDeletionEnabled, betterAuthHandler, resolveSession, }); diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index f0aab2e8..fcba4ea0 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -21,6 +21,7 @@ import { updateUserCompanyAccessSchema, PERMISSION_KEYS, } from "@paperclip/shared"; +import type { DeploymentExposure, DeploymentMode } from "@paperclip/shared"; import { forbidden, conflict, notFound, unauthorized, badRequest } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { accessService, agentService, logActivity } from "../services/index.js"; @@ -76,6 +77,218 @@ function toJoinRequestResponse(row: typeof joinRequests.$inferSelect) { return safe; } +type JoinDiagnostic = { + code: string; + level: "info" | "warn"; + message: string; + hint?: string; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isLoopbackHost(hostname: string): boolean { + const value = hostname.trim().toLowerCase(); + return value === "localhost" || value === "127.0.0.1" || value === "::1"; +} + +function normalizeHostname(value: string | null | undefined): string | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed) return null; + if (trimmed.startsWith("[")) { + const end = trimmed.indexOf("]"); + return end > 1 ? trimmed.slice(1, end).toLowerCase() : trimmed.toLowerCase(); + } + const firstColon = trimmed.indexOf(":"); + if (firstColon > -1) return trimmed.slice(0, firstColon).toLowerCase(); + return trimmed.toLowerCase(); +} + +function normalizeHeaderMap(input: unknown): Record | undefined { + if (!isPlainObject(input)) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(input)) { + if (typeof value !== "string") continue; + const trimmedKey = key.trim(); + const trimmedValue = value.trim(); + if (!trimmedKey || !trimmedValue) continue; + out[trimmedKey] = trimmedValue; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +function buildJoinConnectivityDiagnostics(input: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + bindHost: string; + allowedHostnames: string[]; + callbackUrl: URL | null; +}): JoinDiagnostic[] { + const diagnostics: JoinDiagnostic[] = []; + const bindHost = normalizeHostname(input.bindHost); + const callbackHost = input.callbackUrl ? normalizeHostname(input.callbackUrl.hostname) : null; + const allowSet = new Set( + input.allowedHostnames + .map((entry) => normalizeHostname(entry)) + .filter((entry): entry is string => Boolean(entry)), + ); + + diagnostics.push({ + code: "openclaw_deployment_context", + level: "info", + message: `Deployment context: mode=${input.deploymentMode}, exposure=${input.deploymentExposure}.`, + }); + + if (input.deploymentMode === "authenticated" && input.deploymentExposure === "private") { + if (!bindHost || isLoopbackHost(bindHost)) { + diagnostics.push({ + code: "openclaw_private_bind_loopback", + level: "warn", + message: "Paperclip is bound to loopback in authenticated/private mode.", + hint: "Bind to a reachable private hostname/IP for remote OpenClaw callbacks.", + }); + } + if (bindHost && !isLoopbackHost(bindHost) && !allowSet.has(bindHost)) { + diagnostics.push({ + code: "openclaw_private_bind_not_allowed", + level: "warn", + message: `Paperclip bind host \"${bindHost}\" is not in allowed hostnames.`, + hint: `Run pnpm paperclip allowed-hostname ${bindHost}`, + }); + } + if (callbackHost && !isLoopbackHost(callbackHost) && allowSet.size === 0) { + diagnostics.push({ + code: "openclaw_private_allowed_hostnames_empty", + level: "warn", + message: "No explicit allowed hostnames are configured for authenticated/private mode.", + hint: "Set one with pnpm paperclip allowed-hostname when OpenClaw runs off-host.", + }); + } + } + + if ( + input.deploymentMode === "authenticated" && + input.deploymentExposure === "public" && + input.callbackUrl && + input.callbackUrl.protocol !== "https:" + ) { + diagnostics.push({ + code: "openclaw_public_http_callback", + level: "warn", + message: "OpenClaw callback URL uses HTTP in authenticated/public mode.", + hint: "Prefer HTTPS for public deployments.", + }); + } + + return diagnostics; +} + +function normalizeAgentDefaultsForJoin(input: { + adapterType: string | null; + defaultsPayload: unknown; + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + bindHost: string; + allowedHostnames: string[]; +}) { + const diagnostics: JoinDiagnostic[] = []; + if (input.adapterType !== "openclaw") { + const normalized = isPlainObject(input.defaultsPayload) + ? (input.defaultsPayload as Record) + : null; + return { normalized, diagnostics }; + } + + if (!isPlainObject(input.defaultsPayload)) { + diagnostics.push({ + code: "openclaw_callback_config_missing", + level: "warn", + message: "No OpenClaw callback config was provided in agentDefaultsPayload.", + hint: "Include agentDefaultsPayload.url so Paperclip can invoke the OpenClaw webhook immediately after approval.", + }); + return { normalized: null as Record | null, diagnostics }; + } + + const defaults = input.defaultsPayload as Record; + const normalized: Record = {}; + + let callbackUrl: URL | null = null; + const rawUrl = typeof defaults.url === "string" ? defaults.url.trim() : ""; + if (!rawUrl) { + diagnostics.push({ + code: "openclaw_callback_url_missing", + level: "warn", + message: "OpenClaw callback URL is missing.", + hint: "Set agentDefaultsPayload.url to your OpenClaw webhook endpoint.", + }); + } else { + try { + callbackUrl = new URL(rawUrl); + if (callbackUrl.protocol !== "http:" && callbackUrl.protocol !== "https:") { + diagnostics.push({ + code: "openclaw_callback_url_protocol", + level: "warn", + message: `Unsupported callback protocol: ${callbackUrl.protocol}`, + hint: "Use http:// or https://.", + }); + } else { + normalized.url = callbackUrl.toString(); + diagnostics.push({ + code: "openclaw_callback_url_configured", + level: "info", + message: `Callback endpoint set to ${callbackUrl.toString()}`, + }); + } + if (isLoopbackHost(callbackUrl.hostname)) { + diagnostics.push({ + code: "openclaw_callback_loopback", + level: "warn", + message: "OpenClaw callback endpoint uses loopback hostname.", + hint: "Use a reachable hostname/IP when OpenClaw runs on another machine.", + }); + } + } catch { + diagnostics.push({ + code: "openclaw_callback_url_invalid", + level: "warn", + message: `Invalid callback URL: ${rawUrl}`, + }); + } + } + + const rawMethod = typeof defaults.method === "string" ? defaults.method.trim().toUpperCase() : ""; + normalized.method = rawMethod || "POST"; + + if (typeof defaults.timeoutSec === "number" && Number.isFinite(defaults.timeoutSec)) { + normalized.timeoutSec = Math.max(1, Math.min(120, Math.floor(defaults.timeoutSec))); + } + + const headers = normalizeHeaderMap(defaults.headers); + if (headers) normalized.headers = headers; + + if (typeof defaults.webhookAuthHeader === "string" && defaults.webhookAuthHeader.trim()) { + normalized.webhookAuthHeader = defaults.webhookAuthHeader.trim(); + } + + if (isPlainObject(defaults.payloadTemplate)) { + normalized.payloadTemplate = defaults.payloadTemplate; + } + + diagnostics.push( + ...buildJoinConnectivityDiagnostics({ + deploymentMode: input.deploymentMode, + deploymentExposure: input.deploymentExposure, + bindHost: input.bindHost, + allowedHostnames: input.allowedHostnames, + callbackUrl, + }), + ); + + return { normalized, diagnostics }; +} + function toInviteSummaryResponse(req: Request, token: string, invite: typeof invites.$inferSelect) { const baseUrl = requestBaseUrl(req); const onboardingPath = `/api/invites/${token}/onboarding`; @@ -92,7 +305,17 @@ function toInviteSummaryResponse(req: Request, token: string, invite: typeof inv }; } -function buildInviteOnboardingManifest(req: Request, token: string, invite: typeof invites.$inferSelect) { +function buildInviteOnboardingManifest( + req: Request, + token: string, + invite: typeof invites.$inferSelect, + opts: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + bindHost: string; + allowedHostnames: string[]; + }, +) { const baseUrl = requestBaseUrl(req); const skillPath = "/api/skills/paperclip"; const skillUrl = baseUrl ? `${baseUrl}${skillPath}` : skillPath; @@ -125,6 +348,16 @@ function buildInviteOnboardingManifest(req: Request, token: string, invite: type claimSecret: "one-time claim secret returned when the join request is created", }, }, + connectivity: { + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames, + guidance: + opts.deploymentMode === "authenticated" && opts.deploymentExposure === "private" + ? "If OpenClaw runs on another machine, ensure the Paperclip hostname is reachable and allowed via `pnpm paperclip allowed-hostname `." + : "Ensure OpenClaw can reach this Paperclip API base URL for callbacks and claims.", + }, skill: { name: "paperclip", path: skillPath, @@ -194,7 +427,15 @@ function grantsFromDefaults( return result; } -export function accessRoutes(db: Db) { +export function accessRoutes( + db: Db, + opts: { + deploymentMode: DeploymentMode; + deploymentExposure: DeploymentExposure; + bindHost: string; + allowedHostnames: string[]; + }, +) { const router = Router(); const access = accessService(db); const agents = agentService(db); @@ -341,7 +582,7 @@ export function accessRoutes(db: Db) { throw notFound("Invite not found"); } - res.json(buildInviteOnboardingManifest(req, token, invite)); + res.json(buildInviteOnboardingManifest(req, token, invite, opts)); }); router.post("/invites/:token/accept", validate(acceptInviteSchema), async (req, res) => { @@ -401,6 +642,17 @@ export function accessRoutes(db: Db) { throw badRequest("agentName is required for agent join requests"); } + const joinDefaults = requestType === "agent" + ? normalizeAgentDefaultsForJoin({ + adapterType: req.body.adapterType ?? null, + defaultsPayload: req.body.agentDefaultsPayload ?? null, + deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, + bindHost: opts.bindHost, + allowedHostnames: opts.allowedHostnames, + }) + : { normalized: null as Record | null, diagnostics: [] as JoinDiagnostic[] }; + const claimSecret = requestType === "agent" ? createClaimSecret() : null; const claimSecretHash = claimSecret ? hashToken(claimSecret) : null; const claimSecretExpiresAt = claimSecret @@ -427,7 +679,7 @@ export function accessRoutes(db: Db) { agentName: requestType === "agent" ? req.body.agentName : null, adapterType: requestType === "agent" ? req.body.adapterType ?? null : null, capabilities: requestType === "agent" ? req.body.capabilities ?? null : null, - agentDefaultsPayload: requestType === "agent" ? req.body.agentDefaultsPayload ?? null : null, + agentDefaultsPayload: requestType === "agent" ? joinDefaults.normalized : null, claimSecretHash, claimSecretExpiresAt, }) @@ -451,16 +703,20 @@ export function accessRoutes(db: Db) { const response = toJoinRequestResponse(created); if (claimSecret) { - const onboardingManifest = buildInviteOnboardingManifest(req, token, invite); + const onboardingManifest = buildInviteOnboardingManifest(req, token, invite, opts); res.status(202).json({ ...response, claimSecret, claimApiKeyPath: `/api/join-requests/${created.id}/claim-api-key`, onboarding: onboardingManifest.onboarding, + diagnostics: joinDefaults.diagnostics, }); return; } - res.status(202).json(response); + res.status(202).json({ + ...response, + ...(joinDefaults.diagnostics.length > 0 ? { diagnostics: joinDefaults.diagnostics } : {}), + }); }); router.post("/invites/:inviteId/revoke", async (req, res) => { diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index ca0dfb30..2bb49026 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -10,10 +10,12 @@ export function healthRoutes( deploymentMode: DeploymentMode; deploymentExposure: DeploymentExposure; authReady: boolean; + companyDeletionEnabled: boolean; } = { deploymentMode: "local_trusted", deploymentExposure: "private", authReady: true, + companyDeletionEnabled: true, }, ) { const router = Router(); @@ -40,6 +42,9 @@ export function healthRoutes( deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, bootstrapStatus, + features: { + companyDeletionEnabled: opts.companyDeletionEnabled, + }, }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 44fc96c0..3fe6d6f1 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -710,9 +710,11 @@ export function issueRoutes(db: Db, storage: StorageService) { const actor = getActorInfo(req); const reopenRequested = req.body.reopen === true; + const interruptRequested = req.body.interrupt === true; const isClosed = issue.status === "done" || issue.status === "cancelled"; let reopened = false; let reopenFromStatus: string | null = null; + let interruptedRunId: string | null = null; let currentIssue = issue; if (reopenRequested && isClosed) { @@ -744,6 +746,52 @@ export function issueRoutes(db: Db, storage: StorageService) { }); } + if (interruptRequested) { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" }); + return; + } + + let runToInterrupt = currentIssue.executionRunId + ? await heartbeat.getRun(currentIssue.executionRunId) + : null; + + if ( + (!runToInterrupt || runToInterrupt.status !== "running") && + currentIssue.assigneeAgentId + ) { + const activeRun = await heartbeat.getActiveRunForAgent(currentIssue.assigneeAgentId); + const activeIssueId = + activeRun && + activeRun.contextSnapshot && + typeof activeRun.contextSnapshot === "object" && + typeof (activeRun.contextSnapshot as Record).issueId === "string" + ? ((activeRun.contextSnapshot as Record).issueId as string) + : null; + if (activeRun && activeRun.status === "running" && activeIssueId === currentIssue.id) { + runToInterrupt = activeRun; + } + } + + if (runToInterrupt && runToInterrupt.status === "running") { + const cancelled = await heartbeat.cancelRun(runToInterrupt.id); + if (cancelled) { + interruptedRunId = cancelled.id; + await logActivity(db, { + companyId: cancelled.companyId, + actorType: actor.actorType, + actorId: actor.actorId, + agentId: actor.agentId, + runId: actor.runId, + action: "heartbeat.cancelled", + entityType: "heartbeat_run", + entityId: cancelled.id, + details: { agentId: cancelled.agentId, source: "issue_comment_interrupt", issueId: currentIssue.id }, + }); + } + } + } + const comment = await svc.addComment(id, req.body.body, { agentId: actor.agentId ?? undefined, userId: actor.actorType === "user" ? actor.actorId : undefined, @@ -763,6 +811,8 @@ export function issueRoutes(db: Db, storage: StorageService) { bodySnippet: comment.body.slice(0, 120), identifier: currentIssue.identifier, issueTitle: currentIssue.title, + ...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}), + ...(interruptedRunId ? { interruptedRunId } : {}), }, }); @@ -781,6 +831,7 @@ export function issueRoutes(db: Db, storage: StorageService) { commentId: comment.id, reopenedFrom: reopenFromStatus, mutation: "comment", + ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, @@ -791,6 +842,7 @@ export function issueRoutes(db: Db, storage: StorageService) { source: "issue.comment.reopen", wakeReason: "issue_reopened_via_comment", reopenedFrom: reopenFromStatus, + ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } else { @@ -802,6 +854,7 @@ export function issueRoutes(db: Db, storage: StorageService) { issueId: currentIssue.id, commentId: comment.id, mutation: "comment", + ...(interruptedRunId ? { interruptedRunId } : {}), }, requestedByActorType: actor.actorType, requestedByActorId: actor.actorId, @@ -811,6 +864,7 @@ export function issueRoutes(db: Db, storage: StorageService) { commentId: comment.id, source: "issue.comment", wakeReason: "issue_commented", + ...(interruptedRunId ? { interruptedRunId } : {}), }, }); } diff --git a/server/src/routes/projects.ts b/server/src/routes/projects.ts index 41bf9ace..3e42b990 100644 --- a/server/src/routes/projects.ts +++ b/server/src/routes/projects.ts @@ -1,19 +1,57 @@ -import { Router } from "express"; +import { Router, type Request } from "express"; import type { Db } from "@paperclip/db"; import { createProjectSchema, createProjectWorkspaceSchema, + isUuidLike, updateProjectSchema, updateProjectWorkspaceSchema, } from "@paperclip/shared"; import { validate } from "../middleware/validate.js"; import { projectService, logActivity } from "../services/index.js"; +import { conflict } from "../errors.js"; import { assertCompanyAccess, getActorInfo } from "./authz.js"; export function projectRoutes(db: Db) { const router = Router(); const svc = projectService(db); + async function resolveCompanyIdForProjectReference(req: Request) { + const companyIdQuery = req.query.companyId; + const requestedCompanyId = + typeof companyIdQuery === "string" && companyIdQuery.trim().length > 0 + ? companyIdQuery.trim() + : null; + if (requestedCompanyId) { + assertCompanyAccess(req, requestedCompanyId); + return requestedCompanyId; + } + if (req.actor.type === "agent" && req.actor.companyId) { + return req.actor.companyId; + } + return null; + } + + async function normalizeProjectReference(req: Request, rawId: string) { + if (isUuidLike(rawId)) return rawId; + const companyId = await resolveCompanyIdForProjectReference(req); + if (!companyId) return rawId; + const resolved = await svc.resolveByReference(companyId, rawId); + if (resolved.ambiguous) { + throw conflict("Project shortname is ambiguous in this company. Use the project ID."); + } + return resolved.project?.id ?? rawId; + } + + router.param("id", async (req, _res, next, rawId) => { + try { + req.params.id = await normalizeProjectReference(req, rawId); + next(); + } catch (err) { + next(err); + } + }); + router.get("/companies/:companyId/projects", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 2cb62d4f..94c93708 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -11,6 +11,7 @@ import { heartbeatRunEvents, heartbeatRuns, } from "@paperclip/db"; +import { isUuidLike, normalizeAgentUrlKey } from "@paperclip/shared"; import { conflict, notFound, unprocessable } from "../errors.js"; import { normalizeAgentPermissions } from "./agent-permissions.js"; import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js"; @@ -140,13 +141,20 @@ function configPatchFromSnapshot(snapshot: unknown): Partial(row: T) { return { ...row, - permissions: normalizeAgentPermissions(row.permissions, row.role), + urlKey: normalizeAgentUrlKey(row.name) ?? row.id, }; } + function normalizeAgentRow(row: typeof agents.$inferSelect) { + return withUrlKey({ + ...row, + permissions: normalizeAgentPermissions(row.permissions, row.role), + }); + } + async function getById(id: string) { const row = await db .select() @@ -502,5 +510,37 @@ export function agentService(db: Db) { .select() .from(heartbeatRuns) .where(and(eq(heartbeatRuns.agentId, agentId), inArray(heartbeatRuns.status, ["queued", "running"]))), + + resolveByReference: async (companyId: string, reference: string) => { + const raw = reference.trim(); + if (raw.length === 0) { + return { agent: null, ambiguous: false } as const; + } + + if (isUuidLike(raw)) { + const byId = await getById(raw); + if (!byId || byId.companyId !== companyId) { + return { agent: null, ambiguous: false } as const; + } + return { agent: byId, ambiguous: false } as const; + } + + const urlKey = normalizeAgentUrlKey(raw); + if (!urlKey) { + return { agent: null, ambiguous: false } as const; + } + + const rows = await db.select().from(agents).where(eq(agents.companyId, companyId)); + const matches = rows + .map(normalizeAgentRow) + .filter((agent) => agent.urlKey === urlKey && agent.status !== "terminated"); + if (matches.length === 1) { + return { agent: matches[0] ?? null, ambiguous: false } as const; + } + if (matches.length > 1) { + return { agent: null, ambiguous: true } as const; + } + return { agent: null, ambiguous: false } as const; + }, }; } diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 24eaccbb..3fd3fa30 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1606,8 +1606,14 @@ export function heartbeatService(db: Db) { const executionAgentNameKey = normalizeAgentNameKey(issue.executionAgentNameKey) ?? normalizeAgentNameKey(executionAgent?.name); + const isSameExecutionAgent = + Boolean(executionAgentNameKey) && executionAgentNameKey === agentNameKey; + const shouldQueueFollowupForCommentWake = + Boolean(wakeCommentId) && + activeExecutionRun.status === "running" && + isSameExecutionAgent; - if (executionAgentNameKey && executionAgentNameKey === agentNameKey) { + if (isSameExecutionAgent && !shouldQueueFollowupForCommentWake) { const mergedContextSnapshot = mergeCoalescedContextSnapshot( activeExecutionRun.contextSnapshot, enrichedContextSnapshot, @@ -1647,6 +1653,47 @@ export function heartbeatService(db: Db) { [DEFERRED_WAKE_CONTEXT_KEY]: enrichedContextSnapshot, }; + const existingDeferred = await tx + .select() + .from(agentWakeupRequests) + .where( + and( + eq(agentWakeupRequests.companyId, agent.companyId), + eq(agentWakeupRequests.agentId, agentId), + eq(agentWakeupRequests.status, "deferred_issue_execution"), + sql`${agentWakeupRequests.payload} ->> 'issueId' = ${issue.id}`, + ), + ) + .orderBy(asc(agentWakeupRequests.requestedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + + if (existingDeferred) { + const existingDeferredPayload = parseObject(existingDeferred.payload); + const existingDeferredContext = parseObject(existingDeferredPayload[DEFERRED_WAKE_CONTEXT_KEY]); + const mergedDeferredContext = mergeCoalescedContextSnapshot( + existingDeferredContext, + enrichedContextSnapshot, + ); + const mergedDeferredPayload = { + ...existingDeferredPayload, + ...(payload ?? {}), + issueId, + [DEFERRED_WAKE_CONTEXT_KEY]: mergedDeferredContext, + }; + + await tx + .update(agentWakeupRequests) + .set({ + payload: mergedDeferredPayload, + coalescedCount: (existingDeferred.coalescedCount ?? 0) + 1, + updatedAt: new Date(), + }) + .where(eq(agentWakeupRequests.id, existingDeferred.id)); + + return { kind: "deferred" as const }; + } + await tx.insert(agentWakeupRequests).values({ companyId: agent.companyId, agentId, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 3952cc82..ae9f10b2 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -56,7 +56,18 @@ export interface IssueFilters { type IssueRow = typeof issues.$inferSelect; type IssueLabelRow = typeof labels.$inferSelect; +type IssueActiveRunRow = { + id: string; + status: string; + agentId: string; + invocationSource: string; + triggerDetail: string | null; + startedAt: Date | null; + finishedAt: Date | null; + createdAt: Date; +}; type IssueWithLabels = IssueRow & { labels: IssueLabelRow[]; labelIds: string[] }; +type IssueWithLabelsAndRun = IssueWithLabels & { activeRun: IssueActiveRunRow | null }; function sameRunLock(checkoutRunId: string | null, actorRunId: string | null) { if (actorRunId) return checkoutRunId === actorRunId; @@ -103,6 +114,53 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise> { + const map = new Map(); + const runIds = issueRows + .map((row) => row.executionRunId) + .filter((id): id is string => id != null); + if (runIds.length === 0) return map; + + const rows = await dbOrTx + .select({ + id: heartbeatRuns.id, + status: heartbeatRuns.status, + agentId: heartbeatRuns.agentId, + invocationSource: heartbeatRuns.invocationSource, + triggerDetail: heartbeatRuns.triggerDetail, + startedAt: heartbeatRuns.startedAt, + finishedAt: heartbeatRuns.finishedAt, + createdAt: heartbeatRuns.createdAt, + }) + .from(heartbeatRuns) + .where( + and( + inArray(heartbeatRuns.id, runIds), + inArray(heartbeatRuns.status, ACTIVE_RUN_STATUSES), + ), + ); + + for (const row of rows) { + map.set(row.id, row); + } + return map; +} + +function withActiveRuns( + issueRows: IssueWithLabels[], + runMap: Map, +): IssueWithLabelsAndRun[] { + return issueRows.map((row) => ({ + ...row, + activeRun: row.executionRunId ? (runMap.get(row.executionRunId) ?? null) : null, + })); +} + export function issueService(db: Db) { async function assertAssignableAgent(companyId: string, agentId: string) { const assignee = await db @@ -293,7 +351,9 @@ export function issueService(db: Db) { .from(issues) .where(and(...conditions)) .orderBy(hasSearch ? asc(searchOrder) : asc(priorityOrder), asc(priorityOrder), desc(issues.updatedAt)); - return withIssueLabels(db, rows); + const withLabels = await withIssueLabels(db, rows); + const runMap = await activeRunMapForIssues(db, withLabels); + return withActiveRuns(withLabels, runMap); }, getById: async (id: string) => { diff --git a/server/src/services/projects.ts b/server/src/services/projects.ts index 108b39fb..18596cb9 100644 --- a/server/src/services/projects.ts +++ b/server/src/services/projects.ts @@ -1,7 +1,14 @@ import { and, asc, desc, eq, inArray } from "drizzle-orm"; import type { Db } from "@paperclip/db"; import { projects, projectGoals, goals, projectWorkspaces } from "@paperclip/db"; -import { PROJECT_COLORS, type ProjectGoalRef, type ProjectWorkspace } from "@paperclip/shared"; +import { + PROJECT_COLORS, + deriveProjectUrlKey, + isUuidLike, + normalizeProjectUrlKey, + type ProjectGoalRef, + type ProjectWorkspace, +} from "@paperclip/shared"; type ProjectRow = typeof projects.$inferSelect; type ProjectWorkspaceRow = typeof projectWorkspaces.$inferSelect; @@ -17,6 +24,7 @@ type CreateWorkspaceInput = { type UpdateWorkspaceInput = Partial; interface ProjectWithGoals extends ProjectRow { + urlKey: string; goalIds: string[]; goals: ProjectGoalRef[]; workspaces: ProjectWorkspace[]; @@ -52,7 +60,12 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise { const g = map.get(r.id) ?? []; - return { ...r, goalIds: g.map((x) => x.id), goals: g } as ProjectWithGoals; + return { + ...r, + urlKey: deriveProjectUrlKey(r.name, r.id), + goalIds: g.map((x) => x.id), + goals: g, + } as ProjectWithGoals; }); } @@ -314,7 +327,11 @@ export function projectService(db: Db) { .delete(projects) .where(eq(projects.id, id)) .returning() - .then((rows) => rows[0] ?? null), + .then((rows) => { + const row = rows[0] ?? null; + if (!row) return null; + return { ...row, urlKey: deriveProjectUrlKey(row.name, row.id) }; + }), listWorkspaces: async (projectId: string): Promise => { const rows = await db @@ -555,5 +572,47 @@ export function projectService(db: Db) { return removed ? toWorkspace(removed) : null; }, + + resolveByReference: async (companyId: string, reference: string) => { + const raw = reference.trim(); + if (raw.length === 0) { + return { project: null, ambiguous: false } as const; + } + + if (isUuidLike(raw)) { + const row = await db + .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) + .from(projects) + .where(and(eq(projects.id, raw), eq(projects.companyId, companyId))) + .then((rows) => rows[0] ?? null); + if (!row) return { project: null, ambiguous: false } as const; + return { + project: { id: row.id, companyId: row.companyId, urlKey: deriveProjectUrlKey(row.name, row.id) }, + ambiguous: false, + } as const; + } + + const urlKey = normalizeProjectUrlKey(raw); + if (!urlKey) { + return { project: null, ambiguous: false } as const; + } + + const rows = await db + .select({ id: projects.id, companyId: projects.companyId, name: projects.name }) + .from(projects) + .where(eq(projects.companyId, companyId)); + const matches = rows.filter((row) => deriveProjectUrlKey(row.name, row.id) === urlKey); + if (matches.length === 1) { + const match = matches[0]!; + return { + project: { id: match.id, companyId: match.companyId, urlKey: deriveProjectUrlKey(match.name, match.id) }, + ambiguous: false, + } as const; + } + if (matches.length > 1) { + return { project: null, ambiguous: true } as const; + } + return { project: null, ambiguous: false } as const; + }, }; }