Merge public-gh/master into review/pr-162
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@ export function activityRoutes(db: Db) {
|
||||
const svc = activityService(db);
|
||||
const issueSvc = issueService(db);
|
||||
|
||||
async function resolveIssueByRef(rawId: string) {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
return issueSvc.getByIdentifier(rawId);
|
||||
}
|
||||
return issueSvc.getById(rawId);
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/activity", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -47,42 +54,27 @@ export function activityRoutes(db: Db) {
|
||||
res.status(201).json(event);
|
||||
});
|
||||
|
||||
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs
|
||||
router.param("id", async (req, res, next, rawId) => {
|
||||
try {
|
||||
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
||||
const issue = await issueSvc.getByIdentifier(rawId);
|
||||
if (issue) {
|
||||
req.params.id = issue.id;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/issues/:id/activity", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await issueSvc.getById(id);
|
||||
const rawId = req.params.id as string;
|
||||
const issue = await resolveIssueByRef(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const result = await svc.forIssue(id);
|
||||
const result = await svc.forIssue(issue.id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/runs", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await issueSvc.getById(id);
|
||||
const rawId = req.params.id as string;
|
||||
const issue = await resolveIssueByRef(rawId);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const result = await svc.runsForIssue(issue.companyId, id);
|
||||
const result = await svc.runsForIssue(issue.companyId, issue.id);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Router, type Request } from "express";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db";
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
createAgentKeySchema,
|
||||
createAgentHireSchema,
|
||||
createAgentSchema,
|
||||
deriveAgentUrlKey,
|
||||
isUuidLike,
|
||||
resetAgentSessionSchema,
|
||||
testAdapterEnvironmentSchema,
|
||||
type InstanceSchedulerHeartbeatAgent,
|
||||
updateAgentPermissionsSchema,
|
||||
updateAgentInstructionsPathSchema,
|
||||
wakeAgentSchema,
|
||||
@@ -31,18 +33,21 @@ import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { findServerAdapter, listAdapterModels } from "../adapters/index.js";
|
||||
import { redactEventPayload } from "../redaction.js";
|
||||
import { redactCurrentUserValue } from "../log-redaction.js";
|
||||
import { runClaudeLogin } from "@paperclipai/adapter-claude-local/server";
|
||||
import {
|
||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
||||
DEFAULT_CODEX_LOCAL_MODEL,
|
||||
} from "@paperclipai/adapter-codex-local";
|
||||
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
|
||||
import { ensureOpenCodeModelConfiguredAndAvailable } from "@paperclipai/adapter-opencode-local/server";
|
||||
|
||||
export function agentRoutes(db: Db) {
|
||||
const DEFAULT_INSTRUCTIONS_PATH_KEYS: Record<string, string> = {
|
||||
claude_local: "instructionsFilePath",
|
||||
codex_local: "instructionsFilePath",
|
||||
gemini_local: "instructionsFilePath",
|
||||
opencode_local: "instructionsFilePath",
|
||||
cursor: "instructionsFilePath",
|
||||
};
|
||||
@@ -181,6 +186,55 @@ export function agentRoutes(db: Db) {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseBooleanLike(value: unknown): boolean | null {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on") {
|
||||
return true;
|
||||
}
|
||||
if (normalized === "false" || normalized === "0" || normalized === "no" || normalized === "off") {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseNumberLike(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value !== "string") return null;
|
||||
const parsed = Number(value.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
|
||||
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
|
||||
return {
|
||||
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
|
||||
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function generateEd25519PrivateKeyPem(): string {
|
||||
const { privateKey } = generateKeyPairSync("ed25519");
|
||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||
}
|
||||
|
||||
function ensureGatewayDeviceKey(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (adapterType !== "openclaw_gateway") return adapterConfig;
|
||||
const disableDeviceAuth = parseBooleanLike(adapterConfig.disableDeviceAuth) === true;
|
||||
if (disableDeviceAuth) return adapterConfig;
|
||||
if (asNonEmptyString(adapterConfig.devicePrivateKeyPem)) return adapterConfig;
|
||||
return { ...adapterConfig, devicePrivateKeyPem: generateEd25519PrivateKeyPem() };
|
||||
}
|
||||
|
||||
function applyCreateDefaultsByAdapterType(
|
||||
adapterType: string | null | undefined,
|
||||
adapterConfig: Record<string, unknown>,
|
||||
@@ -196,13 +250,17 @@ export function agentRoutes(db: Db) {
|
||||
if (!hasBypassFlag) {
|
||||
next.dangerouslyBypassApprovalsAndSandbox = DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
||||
}
|
||||
return next;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
if (adapterType === "gemini_local" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_GEMINI_LOCAL_MODEL;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
// OpenCode requires explicit model selection — no default
|
||||
if (adapterType === "cursor" && !asNonEmptyString(next.model)) {
|
||||
next.model = DEFAULT_CURSOR_LOCAL_MODEL;
|
||||
}
|
||||
return next;
|
||||
return ensureGatewayDeviceKey(adapterType, next);
|
||||
}
|
||||
|
||||
async function assertAdapterConfigConstraints(
|
||||
@@ -211,7 +269,7 @@ export function agentRoutes(db: Db) {
|
||||
adapterConfig: Record<string, unknown>,
|
||||
) {
|
||||
if (adapterType !== "opencode_local") return;
|
||||
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig);
|
||||
const runtimeEnv = asRecord(runtimeConfig.env) ?? {};
|
||||
try {
|
||||
await ensureOpenCodeModelConfiguredAndAvailable({
|
||||
@@ -386,7 +444,7 @@ export function agentRoutes(db: Db) {
|
||||
inputAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime(
|
||||
companyId,
|
||||
normalizedAdapterConfig,
|
||||
);
|
||||
@@ -413,6 +471,81 @@ export function agentRoutes(db: Db) {
|
||||
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
|
||||
});
|
||||
|
||||
router.get("/instance/scheduler-heartbeats", async (req, res) => {
|
||||
assertBoard(req);
|
||||
|
||||
const accessConditions = [];
|
||||
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
|
||||
const allowedCompanyIds = req.actor.companyIds ?? [];
|
||||
if (allowedCompanyIds.length === 0) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: agentsTable.id,
|
||||
companyId: agentsTable.companyId,
|
||||
agentName: agentsTable.name,
|
||||
role: agentsTable.role,
|
||||
title: agentsTable.title,
|
||||
status: agentsTable.status,
|
||||
adapterType: agentsTable.adapterType,
|
||||
runtimeConfig: agentsTable.runtimeConfig,
|
||||
lastHeartbeatAt: agentsTable.lastHeartbeatAt,
|
||||
companyName: companies.name,
|
||||
companyIssuePrefix: companies.issuePrefix,
|
||||
})
|
||||
.from(agentsTable)
|
||||
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
|
||||
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
|
||||
.orderBy(companies.name, agentsTable.name);
|
||||
|
||||
const items: InstanceSchedulerHeartbeatAgent[] = rows
|
||||
.map((row) => {
|
||||
const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig);
|
||||
const statusEligible =
|
||||
row.status !== "paused" &&
|
||||
row.status !== "terminated" &&
|
||||
row.status !== "pending_approval";
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
companyName: row.companyName,
|
||||
companyIssuePrefix: row.companyIssuePrefix,
|
||||
agentName: row.agentName,
|
||||
agentUrlKey: deriveAgentUrlKey(row.agentName, row.id),
|
||||
role: row.role as InstanceSchedulerHeartbeatAgent["role"],
|
||||
title: row.title,
|
||||
status: row.status as InstanceSchedulerHeartbeatAgent["status"],
|
||||
adapterType: row.adapterType,
|
||||
intervalSec: policy.intervalSec,
|
||||
heartbeatEnabled: policy.enabled,
|
||||
schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0,
|
||||
lastHeartbeatAt: row.lastHeartbeatAt,
|
||||
};
|
||||
})
|
||||
.filter((item) =>
|
||||
item.intervalSec > 0 &&
|
||||
item.status !== "paused" &&
|
||||
item.status !== "terminated" &&
|
||||
item.status !== "pending_approval",
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (left.schedulerActive !== right.schedulerActive) {
|
||||
return left.schedulerActive ? -1 : 1;
|
||||
}
|
||||
const companyOrder = left.companyName.localeCompare(right.companyName);
|
||||
if (companyOrder !== 0) return companyOrder;
|
||||
return left.agentName.localeCompare(right.agentName);
|
||||
});
|
||||
|
||||
res.json(items);
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/org", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -442,6 +575,34 @@ export function agentRoutes(db: Db) {
|
||||
res.json({ ...agent, chainOfCommand });
|
||||
});
|
||||
|
||||
router.get("/agents/me/inbox-lite", async (req, res) => {
|
||||
if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) {
|
||||
res.status(401).json({ error: "Agent authentication required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const issuesSvc = issueService(db);
|
||||
const rows = await issuesSvc.list(req.actor.companyId, {
|
||||
assigneeAgentId: req.actor.agentId,
|
||||
status: "todo,in_progress,blocked",
|
||||
});
|
||||
|
||||
res.json(
|
||||
rows.map((issue) => ({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: issue.goalId,
|
||||
parentId: issue.parentId,
|
||||
updatedAt: issue.updatedAt,
|
||||
activeRun: issue.activeRun,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const agent = await svc.getById(id);
|
||||
@@ -930,11 +1091,7 @@ export function agentRoutes(db: Db) {
|
||||
if (changingInstructionsPath) {
|
||||
await assertCanManageInstructionsPath(req, existing);
|
||||
}
|
||||
patchData.adapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
adapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
patchData.adapterConfig = adapterConfig;
|
||||
}
|
||||
|
||||
const requestedAdapterType =
|
||||
@@ -942,15 +1099,23 @@ export function agentRoutes(db: Db) {
|
||||
const touchesAdapterConfiguration =
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterType") ||
|
||||
Object.prototype.hasOwnProperty.call(patchData, "adapterConfig");
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
if (touchesAdapterConfiguration) {
|
||||
const rawEffectiveAdapterConfig = Object.prototype.hasOwnProperty.call(patchData, "adapterConfig")
|
||||
? (asRecord(patchData.adapterConfig) ?? {})
|
||||
: (asRecord(existing.adapterConfig) ?? {});
|
||||
const effectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
const effectiveAdapterConfig = applyCreateDefaultsByAdapterType(
|
||||
requestedAdapterType,
|
||||
rawEffectiveAdapterConfig,
|
||||
);
|
||||
const normalizedEffectiveAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
|
||||
existing.companyId,
|
||||
effectiveAdapterConfig,
|
||||
{ strictMode: strictSecretsMode },
|
||||
);
|
||||
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
|
||||
}
|
||||
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
|
||||
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};
|
||||
await assertAdapterConfigConstraints(
|
||||
existing.companyId,
|
||||
requestedAdapterType,
|
||||
@@ -1138,6 +1303,7 @@ export function agentRoutes(db: Db) {
|
||||
contextSnapshot: {
|
||||
triggeredBy: req.actor.type,
|
||||
actorId: req.actor.type === "agent" ? req.actor.agentId : req.actor.userId,
|
||||
forceFreshSession: req.body.forceFreshSession === true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1226,7 +1392,7 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
|
||||
const config = asRecord(agent.adapterConfig) ?? {};
|
||||
const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
|
||||
const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config);
|
||||
const result = await runClaudeLogin({
|
||||
runId: `claude-login-${randomUUID()}`,
|
||||
agent: {
|
||||
@@ -1308,6 +1474,17 @@ export function agentRoutes(db: Db) {
|
||||
res.json(liveRuns);
|
||||
});
|
||||
|
||||
router.get("/heartbeat-runs/:runId", async (req, res) => {
|
||||
const runId = req.params.runId as string;
|
||||
const run = await heartbeat.getRun(runId);
|
||||
if (!run) {
|
||||
res.status(404).json({ error: "Heartbeat run not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, run.companyId);
|
||||
res.json(redactCurrentUserValue(run));
|
||||
});
|
||||
|
||||
router.post("/heartbeat-runs/:runId/cancel", async (req, res) => {
|
||||
assertBoard(req);
|
||||
const runId = req.params.runId as string;
|
||||
@@ -1340,10 +1517,12 @@ export function agentRoutes(db: Db) {
|
||||
const afterSeq = Number(req.query.afterSeq ?? 0);
|
||||
const limit = Number(req.query.limit ?? 200);
|
||||
const events = await heartbeat.listEvents(runId, Number.isFinite(afterSeq) ? afterSeq : 0, Number.isFinite(limit) ? limit : 200);
|
||||
const redactedEvents = events.map((event) => ({
|
||||
...event,
|
||||
payload: redactEventPayload(event.payload),
|
||||
}));
|
||||
const redactedEvents = events.map((event) =>
|
||||
redactCurrentUserValue({
|
||||
...event,
|
||||
payload: redactEventPayload(event.payload),
|
||||
}),
|
||||
);
|
||||
res.json(redactedEvents);
|
||||
});
|
||||
|
||||
@@ -1440,7 +1619,7 @@ export function agentRoutes(db: Db) {
|
||||
}
|
||||
|
||||
res.json({
|
||||
...run,
|
||||
...redactCurrentUserValue(run),
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
adapterType: agent.adapterType,
|
||||
|
||||
@@ -121,85 +121,92 @@ export function approvalRoutes(db: Db) {
|
||||
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const approval = await svc.approve(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
|
||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
||||
const primaryIssueId = linkedIssueIds[0] ?? null;
|
||||
const { approval, applied } = await svc.approve(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
type: approval.type,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
if (applied) {
|
||||
const linkedIssues = await issueApprovalsSvc.listIssuesForApproval(approval.id);
|
||||
const linkedIssueIds = linkedIssues.map((issue) => issue.id);
|
||||
const primaryIssueId = linkedIssueIds[0] ?? null;
|
||||
|
||||
if (approval.requestedByAgentId) {
|
||||
try {
|
||||
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "approval_approved",
|
||||
payload: {
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: req.actor.userId ?? "board",
|
||||
contextSnapshot: {
|
||||
source: "approval.approved",
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
taskId: primaryIssueId,
|
||||
wakeReason: "approval_approved",
|
||||
},
|
||||
});
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.approved",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
type: approval.type,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_queued",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
wakeRunId: wakeRun?.id ?? null,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
approvalId: approval.id,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
},
|
||||
"failed to queue requester wakeup after approval",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_failed",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
if (approval.requestedByAgentId) {
|
||||
try {
|
||||
const wakeRun = await heartbeat.wakeup(approval.requestedByAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "approval_approved",
|
||||
payload: {
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
},
|
||||
requestedByActorType: "user",
|
||||
requestedByActorId: req.actor.userId ?? "board",
|
||||
contextSnapshot: {
|
||||
source: "approval.approved",
|
||||
approvalId: approval.id,
|
||||
approvalStatus: approval.status,
|
||||
issueId: primaryIssueId,
|
||||
issueIds: linkedIssueIds,
|
||||
taskId: primaryIssueId,
|
||||
wakeReason: "approval_approved",
|
||||
},
|
||||
});
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_queued",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
wakeRunId: wakeRun?.id ?? null,
|
||||
linkedIssueIds,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
{
|
||||
err,
|
||||
approvalId: approval.id,
|
||||
requestedByAgentId: approval.requestedByAgentId,
|
||||
},
|
||||
"failed to queue requester wakeup after approval",
|
||||
);
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.requester_wakeup_failed",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: {
|
||||
requesterAgentId: approval.requestedByAgentId,
|
||||
linkedIssueIds,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,17 +216,23 @@ export function approvalRoutes(db: Db) {
|
||||
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
|
||||
assertBoard(req);
|
||||
const id = req.params.id as string;
|
||||
const approval = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
|
||||
const { approval, applied } = await svc.reject(
|
||||
id,
|
||||
req.body.decidedByUserId ?? "board",
|
||||
req.body.decisionNote,
|
||||
);
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.rejected",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: { type: approval.type },
|
||||
});
|
||||
if (applied) {
|
||||
await logActivity(db, {
|
||||
companyId: approval.companyId,
|
||||
actorType: "user",
|
||||
actorId: req.actor.userId ?? "board",
|
||||
action: "approval.rejected",
|
||||
entityType: "approval",
|
||||
entityId: approval.id,
|
||||
details: { type: approval.type },
|
||||
});
|
||||
}
|
||||
|
||||
res.json(redactApprovalPayload(approval));
|
||||
});
|
||||
|
||||
@@ -6,19 +6,10 @@ import type { Db } from "@paperclipai/db";
|
||||
import { createAssetImageMetadataSchema } from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
import { assetService, logActivity } from "../services/index.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
const MAX_ASSET_IMAGE_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const MAX_COMPANY_LOGO_BYTES = 100 * 1024;
|
||||
const SVG_CONTENT_TYPE = "image/svg+xml";
|
||||
const ALLOWED_IMAGE_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
SVG_CONTENT_TYPE,
|
||||
]);
|
||||
|
||||
function sanitizeSvgBuffer(input: Buffer): Buffer | null {
|
||||
const raw = input.toString("utf8").trim();
|
||||
@@ -89,7 +80,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
const svc = assetService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ASSET_IMAGE_BYTES, files: 1 },
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
});
|
||||
|
||||
async function runSingleFileUpload(req: Request, res: Response) {
|
||||
@@ -110,7 +101,7 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
} catch (err) {
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === "LIMIT_FILE_SIZE") {
|
||||
res.status(422).json({ error: `Image exceeds ${MAX_ASSET_IMAGE_BYTES} bytes` });
|
||||
res.status(422).json({ error: `File exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: err.message });
|
||||
@@ -125,9 +116,9 @@ export function assetRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
|
||||
let contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_IMAGE_CONTENT_TYPES.has(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported image type: ${contentType || "unknown"}` });
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (contentType !== SVG_CONTENT_TYPE && !isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported file type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
let fileBody = file.buffer;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { count, sql } from "drizzle-orm";
|
||||
import { instanceUserRoles } from "@paperclipai/db";
|
||||
import { and, count, eq, gt, isNull, sql } from "drizzle-orm";
|
||||
import { instanceUserRoles, invites } from "@paperclipai/db";
|
||||
import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared";
|
||||
|
||||
export function healthRoutes(
|
||||
@@ -27,6 +27,7 @@ export function healthRoutes(
|
||||
}
|
||||
|
||||
let bootstrapStatus: "ready" | "bootstrap_pending" = "ready";
|
||||
let bootstrapInviteActive = false;
|
||||
if (opts.deploymentMode === "authenticated") {
|
||||
const roleCount = await db
|
||||
.select({ count: count() })
|
||||
@@ -34,6 +35,23 @@ export function healthRoutes(
|
||||
.where(sql`${instanceUserRoles.role} = 'instance_admin'`)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending";
|
||||
|
||||
if (bootstrapStatus === "bootstrap_pending") {
|
||||
const now = new Date();
|
||||
const inviteCount = await db
|
||||
.select({ count: count() })
|
||||
.from(invites)
|
||||
.where(
|
||||
and(
|
||||
eq(invites.inviteType, "bootstrap_ceo"),
|
||||
isNull(invites.revokedAt),
|
||||
isNull(invites.acceptedAt),
|
||||
gt(invites.expiresAt, now),
|
||||
),
|
||||
)
|
||||
.then((rows) => Number(rows[0]?.count ?? 0));
|
||||
bootstrapInviteActive = inviteCount > 0;
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -42,6 +60,7 @@ export function healthRoutes(
|
||||
deploymentExposure: opts.deploymentExposure,
|
||||
authReady: opts.authReady,
|
||||
bootstrapStatus,
|
||||
bootstrapInviteActive,
|
||||
features: {
|
||||
companyDeletionEnabled: opts.companyDeletionEnabled,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
checkoutIssueSchema,
|
||||
createIssueSchema,
|
||||
linkIssueApprovalSchema,
|
||||
issueDocumentKeySchema,
|
||||
upsertIssueDocumentSchema,
|
||||
updateIssueSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import type { StorageService } from "../storage/types.js";
|
||||
@@ -19,6 +21,7 @@ import {
|
||||
heartbeatService,
|
||||
issueApprovalService,
|
||||
issueService,
|
||||
documentService,
|
||||
logActivity,
|
||||
projectService,
|
||||
} from "../services/index.js";
|
||||
@@ -26,15 +29,9 @@ import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import { isAllowedContentType, MAX_ATTACHMENT_BYTES } from "../attachment-types.js";
|
||||
|
||||
const MAX_ATTACHMENT_BYTES = Number(process.env.PAPERCLIP_ATTACHMENT_MAX_BYTES) || 10 * 1024 * 1024;
|
||||
const ALLOWED_ATTACHMENT_CONTENT_TYPES = new Set([
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
||||
|
||||
export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const router = Router();
|
||||
@@ -45,6 +42,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
const projectsSvc = projectService(db);
|
||||
const goalsSvc = goalService(db);
|
||||
const issueApprovalsSvc = issueApprovalService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
||||
@@ -184,6 +182,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
});
|
||||
|
||||
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
|
||||
router.get("/issues", (_req, res) => {
|
||||
res.status(400).json({
|
||||
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -223,6 +228,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
touchedByUserId,
|
||||
unreadForUserId,
|
||||
projectId: req.query.projectId as string | undefined,
|
||||
parentId: req.query.parentId as string | undefined,
|
||||
labelId: req.query.labelId as string | undefined,
|
||||
q: req.query.q as string | undefined,
|
||||
});
|
||||
@@ -291,16 +297,242 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const [ancestors, project, goal, mentionedProjectIds] = await Promise.all([
|
||||
const [ancestors, project, goal, mentionedProjectIds, documentPayload] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId ? goalsSvc.getById(issue.goalId) : null,
|
||||
issue.goalId
|
||||
? goalsSvc.getById(issue.goalId)
|
||||
: !issue.projectId
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.findMentionedProjectIds(issue.id),
|
||||
documentsSvc.getIssueDocumentPayload(issue),
|
||||
]);
|
||||
const mentionedProjects = mentionedProjectIds.length > 0
|
||||
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
||||
: [];
|
||||
res.json({ ...issue, ancestors, project: project ?? null, goal: goal ?? null, mentionedProjects });
|
||||
res.json({
|
||||
...issue,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
ancestors,
|
||||
...documentPayload,
|
||||
project: project ?? null,
|
||||
goal: goal ?? null,
|
||||
mentionedProjects,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/heartbeat-context", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
const wakeCommentId =
|
||||
typeof req.query.wakeCommentId === "string" && req.query.wakeCommentId.trim().length > 0
|
||||
? req.query.wakeCommentId.trim()
|
||||
: null;
|
||||
|
||||
const [ancestors, project, goal, commentCursor, wakeComment] = await Promise.all([
|
||||
svc.getAncestors(issue.id),
|
||||
issue.projectId ? projectsSvc.getById(issue.projectId) : null,
|
||||
issue.goalId
|
||||
? goalsSvc.getById(issue.goalId)
|
||||
: !issue.projectId
|
||||
? goalsSvc.getDefaultCompanyGoal(issue.companyId)
|
||||
: null,
|
||||
svc.getCommentCursor(issue.id),
|
||||
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
||||
]);
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
description: issue.description,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
parentId: issue.parentId,
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
updatedAt: issue.updatedAt,
|
||||
},
|
||||
ancestors: ancestors.map((ancestor) => ({
|
||||
id: ancestor.id,
|
||||
identifier: ancestor.identifier,
|
||||
title: ancestor.title,
|
||||
status: ancestor.status,
|
||||
priority: ancestor.priority,
|
||||
})),
|
||||
project: project
|
||||
? {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
status: project.status,
|
||||
targetDate: project.targetDate,
|
||||
}
|
||||
: null,
|
||||
goal: goal
|
||||
? {
|
||||
id: goal.id,
|
||||
title: goal.title,
|
||||
status: goal.status,
|
||||
level: goal.level,
|
||||
parentId: goal.parentId,
|
||||
}
|
||||
: null,
|
||||
commentCursor,
|
||||
wakeComment:
|
||||
wakeComment && wakeComment.issueId === issue.id
|
||||
? wakeComment
|
||||
: null,
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const docs = await documentsSvc.listIssueDocuments(issue.id);
|
||||
res.json(docs);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents/:key", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const doc = await documentsSvc.getIssueDocumentByKey(issue.id, keyParsed.data);
|
||||
if (!doc) {
|
||||
res.status(404).json({ error: "Document not found" });
|
||||
return;
|
||||
}
|
||||
res.json(doc);
|
||||
});
|
||||
|
||||
router.put("/issues/:id/documents/:key", validate(upsertIssueDocumentSchema), async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const result = await documentsSvc.upsertIssueDocument({
|
||||
issueId: issue.id,
|
||||
key: keyParsed.data,
|
||||
title: req.body.title ?? null,
|
||||
format: req.body.format,
|
||||
body: req.body.body,
|
||||
changeSummary: req.body.changeSummary ?? null,
|
||||
baseRevisionId: req.body.baseRevisionId ?? null,
|
||||
createdByAgentId: actor.agentId ?? null,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
});
|
||||
const doc = result.document;
|
||||
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: result.created ? "issue.document_created" : "issue.document_updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: doc.key,
|
||||
documentId: doc.id,
|
||||
title: doc.title,
|
||||
format: doc.format,
|
||||
revisionNumber: doc.latestRevisionNumber,
|
||||
},
|
||||
});
|
||||
|
||||
res.status(result.created ? 201 : 200).json(doc);
|
||||
});
|
||||
|
||||
router.get("/issues/:id/documents/:key/revisions", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const revisions = await documentsSvc.listIssueDocumentRevisions(issue.id, keyParsed.data);
|
||||
res.json(revisions);
|
||||
});
|
||||
|
||||
router.delete("/issues/:id/documents/:key", async (req, res) => {
|
||||
const id = req.params.id as string;
|
||||
const issue = await svc.getById(id);
|
||||
if (!issue) {
|
||||
res.status(404).json({ error: "Issue not found" });
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
res.status(403).json({ error: "Board authentication required" });
|
||||
return;
|
||||
}
|
||||
const keyParsed = issueDocumentKeySchema.safeParse(String(req.params.key ?? "").trim().toLowerCase());
|
||||
if (!keyParsed.success) {
|
||||
res.status(400).json({ error: "Invalid document key", details: keyParsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: "Document not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "issue.document_deleted",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: {
|
||||
key: removed.key,
|
||||
documentId: removed.id,
|
||||
title: removed.title,
|
||||
},
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post("/issues/:id/read", async (req, res) => {
|
||||
@@ -522,6 +754,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||
await logActivity(db, {
|
||||
companyId: issue.companyId,
|
||||
actorType: actor.actorType,
|
||||
@@ -531,7 +764,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: issue.id,
|
||||
details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
|
||||
details: {
|
||||
...updateFields,
|
||||
identifier: issue.identifier,
|
||||
...(commentBody ? { source: "comment" } : {}),
|
||||
_previous: hasFieldChanges ? previous : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
let comment = null;
|
||||
@@ -555,12 +793,17 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
bodySnippet: comment.body.slice(0, 120),
|
||||
identifier: issue.identifier,
|
||||
issueTitle: issue.title,
|
||||
...(hasFieldChanges ? { updated: true } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
const assigneeChanged = assigneeWillChange;
|
||||
const statusChangedFromBacklog =
|
||||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
|
||||
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
||||
void (async () => {
|
||||
@@ -578,6 +821,18 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
|
||||
wakeups.set(issue.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_status_changed",
|
||||
payload: { issueId: issue.id, mutation: "update" },
|
||||
requestedByActorType: actor.actorType,
|
||||
requestedByActorId: actor.actorId,
|
||||
contextSnapshot: { issueId: issue.id, source: "issue.status_change" },
|
||||
});
|
||||
}
|
||||
|
||||
if (commentBody && comment) {
|
||||
let mentionedIds: string[] = [];
|
||||
try {
|
||||
@@ -757,7 +1012,29 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const comments = await svc.listComments(id);
|
||||
const afterCommentId =
|
||||
typeof req.query.after === "string" && req.query.after.trim().length > 0
|
||||
? req.query.after.trim()
|
||||
: typeof req.query.afterCommentId === "string" && req.query.afterCommentId.trim().length > 0
|
||||
? req.query.afterCommentId.trim()
|
||||
: null;
|
||||
const order =
|
||||
typeof req.query.order === "string" && req.query.order.trim().toLowerCase() === "asc"
|
||||
? "asc"
|
||||
: "desc";
|
||||
const limitRaw =
|
||||
typeof req.query.limit === "string" && req.query.limit.trim().length > 0
|
||||
? Number(req.query.limit)
|
||||
: null;
|
||||
const limit =
|
||||
limitRaw && Number.isFinite(limitRaw) && limitRaw > 0
|
||||
? Math.min(Math.floor(limitRaw), MAX_ISSUE_COMMENT_LIMIT)
|
||||
: null;
|
||||
const comments = await svc.listComments(id, {
|
||||
afterCommentId,
|
||||
order,
|
||||
limit,
|
||||
});
|
||||
res.json(comments);
|
||||
});
|
||||
|
||||
@@ -1037,7 +1314,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
||||
return;
|
||||
}
|
||||
const contentType = (file.mimetype || "").toLowerCase();
|
||||
if (!ALLOWED_ATTACHMENT_CONTENT_TYPES.has(contentType)) {
|
||||
if (!isAllowedContentType(contentType)) {
|
||||
res.status(422).json({ error: `Unsupported attachment type: ${contentType || "unknown"}` });
|
||||
return;
|
||||
}
|
||||
|
||||
496
server/src/routes/plugin-ui-static.ts
Normal file
496
server/src/routes/plugin-ui-static.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
/**
|
||||
* @fileoverview Plugin UI static file serving route
|
||||
*
|
||||
* Serves plugin UI bundles from the plugin's dist/ui/ directory under the
|
||||
* `/_plugins/:pluginId/ui/*` namespace. This is specified in PLUGIN_SPEC.md
|
||||
* §19.0.3 (Bundle Serving).
|
||||
*
|
||||
* Plugin UI bundles are pre-built ESM that the host serves as static assets.
|
||||
* The host dynamically imports the plugin's UI entry module from this path,
|
||||
* resolves the named export declared in `ui.slots[].exportName`, and mounts
|
||||
* it into the extension slot.
|
||||
*
|
||||
* Security:
|
||||
* - Path traversal is prevented by resolving the requested path and verifying
|
||||
* it stays within the plugin's UI directory.
|
||||
* - Only plugins in 'ready' status have their UI served.
|
||||
* - Only plugins that declare `entrypoints.ui` serve UI bundles.
|
||||
*
|
||||
* Cache Headers:
|
||||
* - Files with content-hash patterns in their name (e.g., `index-a1b2c3d4.js`)
|
||||
* receive `Cache-Control: public, max-age=31536000, immutable`.
|
||||
* - Other files receive `Cache-Control: public, max-age=0, must-revalidate`
|
||||
* with ETag-based conditional request support.
|
||||
*
|
||||
* @module server/routes/plugin-ui-static
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §19.0.3 — Bundle Serving
|
||||
* @see doc/plugins/PLUGIN_SPEC.md §25.4.5 — Frontend Cache Invalidation
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import crypto from "node:crypto";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { pluginRegistryService } from "../services/plugin-registry.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Regex to detect content-hashed filenames.
|
||||
*
|
||||
* Matches patterns like:
|
||||
* - `index-a1b2c3d4.js`
|
||||
* - `styles.abc123def.css`
|
||||
* - `chunk-ABCDEF01.mjs`
|
||||
*
|
||||
* The hash portion must be at least 8 hex characters to avoid false positives.
|
||||
*/
|
||||
const CONTENT_HASH_PATTERN = /[.-][a-fA-F0-9]{8,}\.\w+$/;
|
||||
|
||||
/**
|
||||
* Cache-Control header for content-hashed files.
|
||||
* These files are immutable by definition (the hash changes when content changes).
|
||||
*/
|
||||
/** 1 year in seconds — standard for content-hashed immutable resources. */
|
||||
const ONE_YEAR_SECONDS = 365 * 24 * 60 * 60; // 31_536_000
|
||||
const CACHE_CONTROL_IMMUTABLE = `public, max-age=${ONE_YEAR_SECONDS}, immutable`;
|
||||
|
||||
/**
|
||||
* Cache-Control header for non-hashed files.
|
||||
* These files must be revalidated on each request (ETag-based).
|
||||
*/
|
||||
const CACHE_CONTROL_REVALIDATE = "public, max-age=0, must-revalidate";
|
||||
|
||||
/**
|
||||
* MIME types for common plugin UI bundle file extensions.
|
||||
*/
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".ico": "image/x-icon",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a plugin's UI directory from its package location.
|
||||
*
|
||||
* The plugin's `packageName` is stored in the DB. We resolve the package path
|
||||
* from the local plugin directory (DEFAULT_LOCAL_PLUGIN_DIR) by looking in
|
||||
* `node_modules`. If the plugin was installed from a local path, the manifest
|
||||
* `entrypoints.ui` path is resolved relative to the package directory.
|
||||
*
|
||||
* @param localPluginDir - The plugin installation directory
|
||||
* @param packageName - The npm package name
|
||||
* @param entrypointsUi - The UI entrypoint path from the manifest (e.g., "./dist/ui/")
|
||||
* @returns Absolute path to the UI directory, or null if not found
|
||||
*/
|
||||
export function resolvePluginUiDir(
|
||||
localPluginDir: string,
|
||||
packageName: string,
|
||||
entrypointsUi: string,
|
||||
packagePath?: string | null,
|
||||
): string | null {
|
||||
// For local-path installs, prefer the persisted package path.
|
||||
if (packagePath) {
|
||||
const resolvedPackagePath = path.resolve(packagePath);
|
||||
if (fs.existsSync(resolvedPackagePath)) {
|
||||
const uiDirFromPackagePath = path.resolve(resolvedPackagePath, entrypointsUi);
|
||||
if (
|
||||
uiDirFromPackagePath.startsWith(resolvedPackagePath)
|
||||
&& fs.existsSync(uiDirFromPackagePath)
|
||||
) {
|
||||
return uiDirFromPackagePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the package root within the local plugin directory's node_modules.
|
||||
// npm installs go to <localPluginDir>/node_modules/<packageName>/
|
||||
let packageRoot: string;
|
||||
if (packageName.startsWith("@")) {
|
||||
// Scoped package: @scope/name -> node_modules/@scope/name
|
||||
packageRoot = path.join(localPluginDir, "node_modules", ...packageName.split("/"));
|
||||
} else {
|
||||
packageRoot = path.join(localPluginDir, "node_modules", packageName);
|
||||
}
|
||||
|
||||
// If the standard location doesn't exist, the plugin may have been installed
|
||||
// from a local path. Try to check if the package.json is accessible at the
|
||||
// computed path or if the package is found elsewhere.
|
||||
if (!fs.existsSync(packageRoot)) {
|
||||
// For local-path installs, the packageName may be a directory that doesn't
|
||||
// live inside node_modules. Check if the package exists directly at the
|
||||
// localPluginDir level.
|
||||
const directPath = path.join(localPluginDir, packageName);
|
||||
if (fs.existsSync(directPath)) {
|
||||
packageRoot = directPath;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the UI directory relative to the package root
|
||||
const uiDir = path.resolve(packageRoot, entrypointsUi);
|
||||
|
||||
// Verify the resolved UI directory exists and is actually inside the package
|
||||
if (!fs.existsSync(uiDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return uiDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute an ETag from file stat (size + mtime).
|
||||
* This is a lightweight approach that avoids reading the file content.
|
||||
*/
|
||||
function computeETag(size: number, mtimeMs: number): string {
|
||||
const ETAG_VERSION = "v2";
|
||||
const hash = crypto
|
||||
.createHash("md5")
|
||||
.update(`${ETAG_VERSION}:${size}-${mtimeMs}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
return `"${hash}"`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for the plugin UI static route.
|
||||
*/
|
||||
export interface PluginUiStaticRouteOptions {
|
||||
/**
|
||||
* The local plugin installation directory.
|
||||
* This is where plugins are installed via `npm install --prefix`.
|
||||
* Defaults to the standard `~/.paperclip/plugins/` location.
|
||||
*/
|
||||
localPluginDir: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Express router that serves plugin UI static files.
|
||||
*
|
||||
* This route handles `GET /_plugins/:pluginId/ui/*` requests by:
|
||||
* 1. Looking up the plugin in the registry by ID or key
|
||||
* 2. Verifying the plugin is in 'ready' status with UI declared
|
||||
* 3. Resolving the file path within the plugin's dist/ui/ directory
|
||||
* 4. Serving the file with appropriate cache headers
|
||||
*
|
||||
* @param db - Database connection for plugin registry lookups
|
||||
* @param options - Configuration options
|
||||
* @returns Express router
|
||||
*/
|
||||
export function pluginUiStaticRoutes(db: Db, options: PluginUiStaticRouteOptions) {
|
||||
const router = Router();
|
||||
const registry = pluginRegistryService(db);
|
||||
const log = logger.child({ service: "plugin-ui-static" });
|
||||
|
||||
/**
|
||||
* GET /_plugins/:pluginId/ui/*
|
||||
*
|
||||
* Serve a static file from a plugin's UI bundle directory.
|
||||
*
|
||||
* The :pluginId parameter accepts either:
|
||||
* - Database UUID
|
||||
* - Plugin key (e.g., "acme.linear")
|
||||
*
|
||||
* The wildcard captures the relative file path within the UI directory.
|
||||
*
|
||||
* Cache strategy:
|
||||
* - Content-hashed filenames → immutable, 1-year max-age
|
||||
* - Other files → must-revalidate with ETag
|
||||
*/
|
||||
router.get("/_plugins/:pluginId/ui/*filePath", async (req, res) => {
|
||||
const { pluginId } = req.params;
|
||||
|
||||
// Extract the relative file path from the named wildcard.
|
||||
// In Express 5 with path-to-regexp v8, named wildcards may return
|
||||
// an array of path segments or a single string.
|
||||
const rawParam = req.params.filePath;
|
||||
const rawFilePath = Array.isArray(rawParam)
|
||||
? rawParam.join("/")
|
||||
: rawParam as string | undefined;
|
||||
|
||||
if (!rawFilePath || rawFilePath.length === 0) {
|
||||
res.status(400).json({ error: "File path is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Look up the plugin
|
||||
let plugin = null;
|
||||
try {
|
||||
plugin = await registry.getById(pluginId);
|
||||
} catch (error) {
|
||||
const maybeCode =
|
||||
typeof error === "object" && error !== null && "code" in error
|
||||
? (error as { code?: unknown }).code
|
||||
: undefined;
|
||||
if (maybeCode !== "22P02") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!plugin) {
|
||||
plugin = await registry.getByKey(pluginId);
|
||||
}
|
||||
|
||||
if (!plugin) {
|
||||
res.status(404).json({ error: "Plugin not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Verify the plugin is ready and has UI declared
|
||||
if (plugin.status !== "ready") {
|
||||
res.status(403).json({
|
||||
error: `Plugin UI is not available (status: ${plugin.status})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = plugin.manifestJson;
|
||||
if (!manifest?.entrypoints?.ui) {
|
||||
res.status(404).json({ error: "Plugin does not declare a UI bundle" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2b: Check for devUiUrl in plugin config — proxy to local dev server
|
||||
// when a plugin author has configured a dev server URL for hot-reload.
|
||||
// See PLUGIN_SPEC.md §27.2 — Local Development Workflow
|
||||
try {
|
||||
const configRow = await registry.getConfig(plugin.id);
|
||||
const devUiUrl =
|
||||
configRow &&
|
||||
typeof configRow === "object" &&
|
||||
"configJson" in configRow &&
|
||||
(configRow as { configJson: Record<string, unknown> }).configJson?.devUiUrl;
|
||||
|
||||
if (typeof devUiUrl === "string" && devUiUrl.length > 0) {
|
||||
// Dev proxy is only available in development mode
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id },
|
||||
"plugin-ui-static: devUiUrl ignored in production",
|
||||
);
|
||||
// Fall through to static file serving below
|
||||
} else {
|
||||
// Guard against rawFilePath overriding the base URL via protocol
|
||||
// scheme (e.g. "https://evil.com/x") or protocol-relative paths
|
||||
// (e.g. "//evil.com/x") which cause `new URL(path, base)` to
|
||||
// ignore the base entirely.
|
||||
// Normalize percent-encoding so encoded slashes (%2F) can't bypass
|
||||
// the protocol/path checks below.
|
||||
let decodedPath: string;
|
||||
try {
|
||||
decodedPath = decodeURIComponent(rawFilePath);
|
||||
} catch {
|
||||
res.status(400).json({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
decodedPath.includes("://") ||
|
||||
decodedPath.startsWith("//") ||
|
||||
decodedPath.startsWith("\\\\")
|
||||
) {
|
||||
res.status(400).json({ error: "Invalid file path" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Proxy the request to the dev server
|
||||
const targetUrl = new URL(rawFilePath, devUiUrl.endsWith("/") ? devUiUrl : devUiUrl + "/");
|
||||
|
||||
// SSRF protection: only allow http/https and localhost targets for dev proxy
|
||||
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
||||
res.status(400).json({ error: "devUiUrl must use http or https protocol" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Dev proxy is restricted to loopback addresses only.
|
||||
// Validate the *constructed* targetUrl hostname (not the base) to
|
||||
// catch any path-based override that slipped past the checks above.
|
||||
const devHost = targetUrl.hostname;
|
||||
const isLoopback =
|
||||
devHost === "localhost" ||
|
||||
devHost === "127.0.0.1" ||
|
||||
devHost === "::1" ||
|
||||
devHost === "[::1]";
|
||||
if (!isLoopback) {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id, devUiUrl, host: devHost },
|
||||
"plugin-ui-static: devUiUrl must target localhost, rejecting proxy",
|
||||
);
|
||||
res.status(400).json({ error: "devUiUrl must target localhost" });
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
{ pluginId: plugin.id, devUiUrl, targetUrl: targetUrl.href },
|
||||
"plugin-ui-static: proxying to devUiUrl",
|
||||
);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const upstream = await fetch(targetUrl.href, { signal: controller.signal });
|
||||
if (!upstream.ok) {
|
||||
res.status(upstream.status).json({
|
||||
error: `Dev server returned ${upstream.status}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get("content-type");
|
||||
if (contentType) res.set("Content-Type", contentType);
|
||||
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
|
||||
const body = await upstream.arrayBuffer();
|
||||
res.send(Buffer.from(body));
|
||||
return;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
} catch (proxyErr) {
|
||||
log.warn(
|
||||
{
|
||||
pluginId: plugin.id,
|
||||
devUiUrl,
|
||||
err: proxyErr instanceof Error ? proxyErr.message : String(proxyErr),
|
||||
},
|
||||
"plugin-ui-static: failed to proxy to devUiUrl, falling back to static",
|
||||
);
|
||||
// Fall through to static serving below
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Config lookup failure is non-fatal — fall through to static serving
|
||||
}
|
||||
|
||||
// Step 3: Resolve the plugin's UI directory
|
||||
const uiDir = resolvePluginUiDir(
|
||||
options.localPluginDir,
|
||||
plugin.packageName,
|
||||
manifest.entrypoints.ui,
|
||||
plugin.packagePath,
|
||||
);
|
||||
|
||||
if (!uiDir) {
|
||||
log.warn(
|
||||
{ pluginId: plugin.id, pluginKey: plugin.pluginKey, packageName: plugin.packageName },
|
||||
"plugin-ui-static: UI directory not found on disk",
|
||||
);
|
||||
res.status(404).json({ error: "Plugin UI directory not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Resolve the requested file path and prevent traversal (including symlinks)
|
||||
const resolvedFilePath = path.resolve(uiDir, rawFilePath);
|
||||
|
||||
// Step 5: Check that the file exists and is a regular file
|
||||
let fileStat: fs.Stats;
|
||||
try {
|
||||
fileStat = fs.statSync(resolvedFilePath);
|
||||
} catch {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Security: resolve symlinks via realpathSync and verify containment.
|
||||
// This prevents symlink-based traversal that string-based startsWith misses.
|
||||
let realFilePath: string;
|
||||
let realUiDir: string;
|
||||
try {
|
||||
realFilePath = fs.realpathSync(resolvedFilePath);
|
||||
realUiDir = fs.realpathSync(uiDir);
|
||||
} catch {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const relative = path.relative(realUiDir, realFilePath);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
res.status(403).json({ error: "Access denied" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileStat.isFile()) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 6: Determine cache strategy based on filename
|
||||
const basename = path.basename(resolvedFilePath);
|
||||
const isContentHashed = CONTENT_HASH_PATTERN.test(basename);
|
||||
|
||||
// Step 7: Set cache headers
|
||||
if (isContentHashed) {
|
||||
res.set("Cache-Control", CACHE_CONTROL_IMMUTABLE);
|
||||
} else {
|
||||
res.set("Cache-Control", CACHE_CONTROL_REVALIDATE);
|
||||
|
||||
// Compute and set ETag for conditional request support
|
||||
const etag = computeETag(fileStat.size, fileStat.mtimeMs);
|
||||
res.set("ETag", etag);
|
||||
|
||||
// Check If-None-Match for 304 Not Modified
|
||||
const ifNoneMatch = req.headers["if-none-match"];
|
||||
if (ifNoneMatch === etag) {
|
||||
res.status(304).end();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 8: Set Content-Type
|
||||
const ext = path.extname(resolvedFilePath).toLowerCase();
|
||||
const contentType = MIME_TYPES[ext];
|
||||
if (contentType) {
|
||||
res.set("Content-Type", contentType);
|
||||
}
|
||||
|
||||
// Step 9: Set CORS headers (plugin UI may be loaded from different origin in dev)
|
||||
res.set("Access-Control-Allow-Origin", "*");
|
||||
|
||||
// Step 10: Send the file
|
||||
// The plugin source can live in Git worktrees (e.g. ".worktrees/...").
|
||||
// `send` defaults to dotfiles:"ignore", which treats dot-directories as
|
||||
// not found. We already enforce traversal safety above, so allow dot paths.
|
||||
res.sendFile(resolvedFilePath, { dotfiles: "allow" }, (err) => {
|
||||
if (err) {
|
||||
log.error(
|
||||
{ err, pluginId: plugin.id, filePath: resolvedFilePath },
|
||||
"plugin-ui-static: error sending file",
|
||||
);
|
||||
// Only send error if headers haven't been sent yet
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to serve file" });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
2219
server/src/routes/plugins.ts
Normal file
2219
server/src/routes/plugins.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ import type { Db } from "@paperclipai/db";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { joinRequests } from "@paperclipai/db";
|
||||
import { sidebarBadgeService } from "../services/sidebar-badges.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
import { accessService } from "../services/access.js";
|
||||
import { dashboardService } from "../services/dashboard.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
@@ -11,7 +10,6 @@ import { assertCompanyAccess } from "./authz.js";
|
||||
export function sidebarBadgeRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = sidebarBadgeService(db);
|
||||
const issueSvc = issueService(db);
|
||||
const access = accessService(db);
|
||||
const dashboard = dashboardService(db);
|
||||
|
||||
@@ -40,12 +38,11 @@ export function sidebarBadgeRoutes(db: Db) {
|
||||
joinRequests: joinRequestCount,
|
||||
});
|
||||
const summary = await dashboard.summary(companyId);
|
||||
const staleIssueCount = await issueSvc.staleCount(companyId, 24 * 60);
|
||||
const hasFailedRuns = badges.failedRuns > 0;
|
||||
const alertsCount =
|
||||
(summary.agents.error > 0 && !hasFailedRuns ? 1 : 0) +
|
||||
(summary.costs.monthBudgetCents > 0 && summary.costs.monthUtilizationPercent >= 80 ? 1 : 0);
|
||||
badges.inbox = badges.failedRuns + alertsCount + staleIssueCount + joinRequestCount + badges.approvals;
|
||||
badges.inbox = badges.failedRuns + alertsCount + joinRequestCount + badges.approvals;
|
||||
|
||||
res.json(badges);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user