feat(core): merge backup core changes with post-split functionality
This commit is contained in:
@@ -38,6 +38,7 @@ export async function createApp(
|
||||
allowedHostnames: string[];
|
||||
bindHost: string;
|
||||
authReady: boolean;
|
||||
companyDeletionEnabled: boolean;
|
||||
betterAuthHandler?: express.RequestHandler;
|
||||
resolveSession?: (req: ExpressRequest) => Promise<BetterAuthSessionResult | null>;
|
||||
},
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,6 +357,7 @@ const app = await createApp(db as any, {
|
||||
allowedHostnames: config.allowedHostnames,
|
||||
bindHost: config.host,
|
||||
authReady,
|
||||
companyDeletionEnabled: config.companyDeletionEnabled,
|
||||
betterAuthHandler,
|
||||
resolveSession,
|
||||
});
|
||||
|
||||
@@ -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<string, unknown> {
|
||||
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<string, string> | undefined {
|
||||
if (!isPlainObject(input)) return undefined;
|
||||
const out: Record<string, string> = {};
|
||||
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 <host> 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<string, unknown>)
|
||||
: 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<string, unknown> | null, diagnostics };
|
||||
}
|
||||
|
||||
const defaults = input.defaultsPayload as Record<string, unknown>;
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
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 <host>`."
|
||||
: "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<string, unknown> | 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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, unknown>).issueId === "string"
|
||||
? ((activeRun.contextSnapshot as Record<string, unknown>).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 } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<typeof agents.$infe
|
||||
}
|
||||
|
||||
export function agentService(db: Db) {
|
||||
function normalizeAgentRow(row: typeof agents.$inferSelect) {
|
||||
function withUrlKey<T extends { id: string; name: string }>(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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IssueWith
|
||||
});
|
||||
}
|
||||
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
|
||||
async function activeRunMapForIssues(
|
||||
dbOrTx: any,
|
||||
issueRows: IssueWithLabels[],
|
||||
): Promise<Map<string, IssueActiveRunRow>> {
|
||||
const map = new Map<string, IssueActiveRunRow>();
|
||||
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<string, IssueActiveRunRow>,
|
||||
): 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) => {
|
||||
|
||||
@@ -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<CreateWorkspaceInput>;
|
||||
|
||||
interface ProjectWithGoals extends ProjectRow {
|
||||
urlKey: string;
|
||||
goalIds: string[];
|
||||
goals: ProjectGoalRef[];
|
||||
workspaces: ProjectWorkspace[];
|
||||
@@ -52,7 +60,12 @@ async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals
|
||||
|
||||
return rows.map((r) => {
|
||||
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<ProjectWorkspace[]> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user