mirror of
https://github.com/paperclipai/paperclip
synced 2026-04-25 17:25:15 +02:00
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators and agents coordinate through company-scoped issues, comments, documents, and task relationships. > - Issue text can mention other tickets, but those references were previously plain markdown/text without durable relationship data. > - That made it harder to understand related work, surface backlinks, and keep cross-ticket context visible in the board. > - This pull request adds first-class issue reference extraction, storage, API responses, and UI surfaces. > - The benefit is that issue references become queryable, navigable, and visible without relying on ad hoc text scanning. ## What Changed - Added shared issue-reference parsing utilities and exported reference-related types/constants. - Added an `issue_reference_mentions` table, idempotent migration DDL, schema exports, and database documentation. - Added server-side issue reference services, route integration, activity summaries, and a backfill command for existing issue content. - Added UI reference pills, related-work panels, markdown/editor mention handling, and issue detail/property rendering updates. - Added focused shared, server, and UI tests for parsing, persistence, display, and related-work behavior. - Rebased `PAP-735-first-class-task-references` cleanly onto `public-gh/master`; no `pnpm-lock.yaml` changes are included. ## Verification - `pnpm -r typecheck` - `pnpm test:run packages/shared/src/issue-references.test.ts server/src/__tests__/issue-references-service.test.ts ui/src/components/IssueRelatedWorkPanel.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/MarkdownBody.test.tsx` ## Risks - Medium risk because this adds a new issue-reference persistence path that touches shared parsing, database schema, server routes, and UI rendering. - Migration risk is mitigated by `CREATE TABLE IF NOT EXISTS`, guarded foreign-key creation, and `CREATE INDEX IF NOT EXISTS` statements so users who have applied an older local version of the numbered migration can re-run safely. - UI risk is limited by focused component coverage, but reviewers should still manually inspect issue detail pages containing ticket references before merge. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-using shell workflow with repository inspection, git rebase/push, typecheck, and focused Vitest verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: dotta <dotta@example.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
3080 lines
110 KiB
TypeScript
3080 lines
110 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { Router, type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import { z } from "zod";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { issueExecutionDecisions } from "@paperclipai/db";
|
|
import {
|
|
addIssueCommentSchema,
|
|
createIssueAttachmentMetadataSchema,
|
|
createIssueWorkProductSchema,
|
|
createIssueLabelSchema,
|
|
checkoutIssueSchema,
|
|
createChildIssueSchema,
|
|
createIssueSchema,
|
|
feedbackTargetTypeSchema,
|
|
feedbackTraceStatusSchema,
|
|
feedbackVoteValueSchema,
|
|
upsertIssueFeedbackVoteSchema,
|
|
linkIssueApprovalSchema,
|
|
issueDocumentKeySchema,
|
|
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
|
restoreIssueDocumentRevisionSchema,
|
|
updateIssueWorkProductSchema,
|
|
upsertIssueDocumentSchema,
|
|
updateIssueSchema,
|
|
getClosedIsolatedExecutionWorkspaceMessage,
|
|
isClosedIsolatedExecutionWorkspace,
|
|
type ExecutionWorkspace,
|
|
} from "@paperclipai/shared";
|
|
import { trackAgentTaskCompleted } from "@paperclipai/shared/telemetry";
|
|
import { getTelemetryClient } from "../telemetry.js";
|
|
import type { StorageService } from "../storage/types.js";
|
|
import { validate } from "../middleware/validate.js";
|
|
import {
|
|
accessService,
|
|
agentService,
|
|
executionWorkspaceService,
|
|
feedbackService,
|
|
goalService,
|
|
heartbeatService,
|
|
instanceSettingsService,
|
|
issueApprovalService,
|
|
ISSUE_LIST_DEFAULT_LIMIT,
|
|
ISSUE_LIST_MAX_LIMIT,
|
|
issueReferenceService,
|
|
issueService,
|
|
clampIssueListLimit,
|
|
documentService,
|
|
logActivity,
|
|
projectService,
|
|
routineService,
|
|
workProductService,
|
|
} from "../services/index.js";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
|
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
import {
|
|
assertNoAgentHostWorkspaceCommandMutation,
|
|
collectIssueWorkspaceCommandPaths,
|
|
} from "./workspace-command-authz.js";
|
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
|
import {
|
|
isInlineAttachmentContentType,
|
|
MAX_ATTACHMENT_BYTES,
|
|
normalizeContentType,
|
|
SVG_CONTENT_TYPE,
|
|
} from "../attachment-types.js";
|
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
|
import {
|
|
applyIssueExecutionPolicyTransition,
|
|
normalizeIssueExecutionPolicy,
|
|
parseIssueExecutionState,
|
|
} from "../services/issue-execution-policy.js";
|
|
|
|
const MAX_ISSUE_COMMENT_LIMIT = 500;
|
|
const updateIssueRouteSchema = updateIssueSchema.extend({
|
|
interrupt: z.boolean().optional(),
|
|
});
|
|
|
|
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
|
|
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
|
|
type ActivityIssueRelationSummary = {
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
};
|
|
type ActivityExecutionParticipant = Pick<
|
|
NormalizedExecutionPolicy["stages"][number]["participants"][number],
|
|
"type" | "agentId" | "userId"
|
|
>;
|
|
type ExecutionStageWakeContext = {
|
|
wakeRole: "reviewer" | "approver" | "executor";
|
|
stageId: string | null;
|
|
stageType: ParsedExecutionState["currentStageType"];
|
|
currentParticipant: ParsedExecutionState["currentParticipant"];
|
|
returnAssignee: ParsedExecutionState["returnAssignee"];
|
|
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
|
|
allowedActions: string[];
|
|
};
|
|
|
|
function executionPrincipalsEqual(
|
|
left: ParsedExecutionState["currentParticipant"] | null,
|
|
right: ParsedExecutionState["currentParticipant"] | null,
|
|
) {
|
|
if (!left || !right || left.type !== right.type) return false;
|
|
return left.type === "agent" ? left.agentId === right.agentId : left.userId === right.userId;
|
|
}
|
|
|
|
function buildExecutionStageWakeContext(input: {
|
|
state: ParsedExecutionState;
|
|
wakeRole: ExecutionStageWakeContext["wakeRole"];
|
|
allowedActions: string[];
|
|
}): ExecutionStageWakeContext {
|
|
return {
|
|
wakeRole: input.wakeRole,
|
|
stageId: input.state.currentStageId,
|
|
stageType: input.state.currentStageType,
|
|
currentParticipant: input.state.currentParticipant,
|
|
returnAssignee: input.state.returnAssignee,
|
|
lastDecisionOutcome: input.state.lastDecisionOutcome,
|
|
allowedActions: input.allowedActions,
|
|
};
|
|
}
|
|
|
|
function summarizeIssueRelationForActivity(relation: {
|
|
id: string;
|
|
identifier: string | null;
|
|
title: string;
|
|
}): ActivityIssueRelationSummary {
|
|
return {
|
|
id: relation.id,
|
|
identifier: relation.identifier,
|
|
title: relation.title,
|
|
};
|
|
}
|
|
|
|
function summarizeIssueReferenceActivityDetails(input:
|
|
| {
|
|
addedReferencedIssues: ActivityIssueRelationSummary[];
|
|
removedReferencedIssues: ActivityIssueRelationSummary[];
|
|
currentReferencedIssues: ActivityIssueRelationSummary[];
|
|
}
|
|
| null
|
|
| undefined,
|
|
) {
|
|
if (!input) return {};
|
|
return {
|
|
...(input.addedReferencedIssues.length > 0 ? { addedReferencedIssues: input.addedReferencedIssues } : {}),
|
|
...(input.removedReferencedIssues.length > 0 ? { removedReferencedIssues: input.removedReferencedIssues } : {}),
|
|
...(input.currentReferencedIssues.length > 0 ? { currentReferencedIssues: input.currentReferencedIssues } : {}),
|
|
};
|
|
}
|
|
|
|
function activityExecutionParticipantKey(participant: ActivityExecutionParticipant): string {
|
|
return participant.type === "agent" ? `agent:${participant.agentId}` : `user:${participant.userId}`;
|
|
}
|
|
|
|
function summarizeExecutionParticipants(
|
|
policy: NormalizedExecutionPolicy | null,
|
|
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
|
|
): ActivityExecutionParticipant[] {
|
|
const stage = policy?.stages.find((candidate) => candidate.type === stageType);
|
|
return (
|
|
stage?.participants.map((participant) => ({
|
|
type: participant.type,
|
|
agentId: participant.agentId ?? null,
|
|
userId: participant.userId ?? null,
|
|
})) ?? []
|
|
);
|
|
}
|
|
|
|
function isClosedIssueStatus(status: string | null | undefined): status is "done" | "cancelled" {
|
|
return status === "done" || status === "cancelled";
|
|
}
|
|
|
|
function shouldImplicitlyReopenCommentForAgent(input: {
|
|
issueStatus: string | null | undefined;
|
|
assigneeAgentId: string | null | undefined;
|
|
actorType: "agent" | "user";
|
|
actorId: string;
|
|
}) {
|
|
if (!isClosedIssueStatus(input.issueStatus)) return false;
|
|
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
|
|
if (input.actorType === "agent" && input.actorId === input.assigneeAgentId) return false;
|
|
return true;
|
|
}
|
|
|
|
function diffExecutionParticipants(
|
|
previousPolicy: NormalizedExecutionPolicy | null,
|
|
nextPolicy: NormalizedExecutionPolicy | null,
|
|
stageType: NormalizedExecutionPolicy["stages"][number]["type"],
|
|
) {
|
|
const previousParticipants = summarizeExecutionParticipants(previousPolicy, stageType);
|
|
const nextParticipants = summarizeExecutionParticipants(nextPolicy, stageType);
|
|
const previousByKey = new Map(previousParticipants.map((participant) => [
|
|
activityExecutionParticipantKey(participant),
|
|
participant,
|
|
]));
|
|
const nextByKey = new Map(nextParticipants.map((participant) => [
|
|
activityExecutionParticipantKey(participant),
|
|
participant,
|
|
]));
|
|
|
|
return {
|
|
participants: nextParticipants,
|
|
addedParticipants: nextParticipants.filter((participant) => !previousByKey.has(activityExecutionParticipantKey(participant))),
|
|
removedParticipants: previousParticipants.filter((participant) => !nextByKey.has(activityExecutionParticipantKey(participant))),
|
|
};
|
|
}
|
|
|
|
function buildExecutionStageWakeup(input: {
|
|
issueId: string;
|
|
previousState: ParsedExecutionState | null;
|
|
nextState: ParsedExecutionState | null;
|
|
interruptedRunId: string | null;
|
|
requestedByActorType: "user" | "agent";
|
|
requestedByActorId: string;
|
|
}) {
|
|
const { issueId, previousState, nextState, interruptedRunId } = input;
|
|
if (!nextState) return null;
|
|
|
|
if (nextState.status === "pending") {
|
|
const agentId =
|
|
nextState.currentParticipant?.type === "agent" ? (nextState.currentParticipant.agentId ?? null) : null;
|
|
const stageChanged =
|
|
previousState?.status !== "pending" ||
|
|
previousState?.currentStageId !== nextState.currentStageId ||
|
|
!executionPrincipalsEqual(previousState?.currentParticipant ?? null, nextState.currentParticipant ?? null);
|
|
if (!agentId || !stageChanged) return null;
|
|
|
|
const reason =
|
|
nextState.currentStageType === "approval" ? "execution_approval_requested" : "execution_review_requested";
|
|
const executionStage = buildExecutionStageWakeContext({
|
|
state: nextState,
|
|
wakeRole: nextState.currentStageType === "approval" ? "approver" : "reviewer",
|
|
allowedActions: ["approve", "request_changes"],
|
|
});
|
|
|
|
return {
|
|
agentId,
|
|
wakeup: {
|
|
source: "assignment" as const,
|
|
triggerDetail: "system" as const,
|
|
reason,
|
|
payload: {
|
|
issueId,
|
|
mutation: "update",
|
|
executionStage,
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: input.requestedByActorType,
|
|
requestedByActorId: input.requestedByActorId,
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: reason,
|
|
source: "issue.execution_stage",
|
|
executionStage,
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
if (nextState.status === "changes_requested") {
|
|
const agentId = nextState.returnAssignee?.type === "agent" ? (nextState.returnAssignee.agentId ?? null) : null;
|
|
const becameChangesRequested =
|
|
previousState?.status !== "changes_requested" ||
|
|
previousState?.lastDecisionId !== nextState.lastDecisionId ||
|
|
!executionPrincipalsEqual(previousState?.returnAssignee ?? null, nextState.returnAssignee ?? null);
|
|
if (!agentId || !becameChangesRequested) return null;
|
|
|
|
const executionStage = buildExecutionStageWakeContext({
|
|
state: nextState,
|
|
wakeRole: "executor",
|
|
allowedActions: ["address_changes", "resubmit"],
|
|
});
|
|
|
|
return {
|
|
agentId,
|
|
wakeup: {
|
|
source: "assignment" as const,
|
|
triggerDetail: "system" as const,
|
|
reason: "execution_changes_requested",
|
|
payload: {
|
|
issueId,
|
|
mutation: "update",
|
|
executionStage,
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: input.requestedByActorType,
|
|
requestedByActorId: input.requestedByActorId,
|
|
contextSnapshot: {
|
|
issueId,
|
|
taskId: issueId,
|
|
wakeReason: "execution_changes_requested",
|
|
source: "issue.execution_stage",
|
|
executionStage,
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function issueRoutes(
|
|
db: Db,
|
|
storage: StorageService,
|
|
opts?: {
|
|
feedbackExportService?: {
|
|
flushPendingFeedbackTraces(input?: {
|
|
companyId?: string;
|
|
traceId?: string;
|
|
limit?: number;
|
|
now?: Date;
|
|
}): Promise<unknown>;
|
|
};
|
|
},
|
|
) {
|
|
const router = Router();
|
|
const svc = issueService(db);
|
|
const access = accessService(db);
|
|
const heartbeat = heartbeatService(db);
|
|
const feedback = feedbackService(db);
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const agentsSvc = agentService(db);
|
|
const projectsSvc = projectService(db);
|
|
const goalsSvc = goalService(db);
|
|
const issueApprovalsSvc = issueApprovalService(db);
|
|
const executionWorkspacesSvc = executionWorkspaceService(db);
|
|
const workProductsSvc = workProductService(db);
|
|
const documentsSvc = documentService(db);
|
|
const issueReferencesSvc = issueReferenceService(db);
|
|
const routinesSvc = routineService(db);
|
|
const feedbackExportService = opts?.feedbackExportService;
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: MAX_ATTACHMENT_BYTES, files: 1 },
|
|
});
|
|
|
|
function withContentPath<T extends { id: string }>(attachment: T) {
|
|
return {
|
|
...attachment,
|
|
contentPath: `/api/attachments/${attachment.id}/content`,
|
|
};
|
|
}
|
|
|
|
function parseBooleanQuery(value: unknown) {
|
|
return value === true || value === "true" || value === "1";
|
|
}
|
|
|
|
function parseDateQuery(value: unknown, field: string) {
|
|
if (typeof value !== "string" || value.trim().length === 0) return undefined;
|
|
const parsed = new Date(value);
|
|
if (Number.isNaN(parsed.getTime())) {
|
|
throw new HttpError(400, `Invalid ${field} query value`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
async function runSingleFileUpload(req: Request, res: Response) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
upload.single("file")(req, res, (err: unknown) => {
|
|
if (err) reject(err);
|
|
else resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
async function assertCanManageIssueApprovalLinks(req: Request, res: Response, companyId: string) {
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "board") return true;
|
|
if (!req.actor.agentId) {
|
|
res.status(403).json({ error: "Agent authentication required" });
|
|
return false;
|
|
}
|
|
const actorAgent = await agentsSvc.getById(req.actor.agentId);
|
|
if (!actorAgent || actorAgent.companyId !== companyId) {
|
|
res.status(403).json({ error: "Forbidden" });
|
|
return false;
|
|
}
|
|
if (actorAgent.role === "ceo" || Boolean(actorAgent.permissions?.canCreateAgents)) return true;
|
|
res.status(403).json({ error: "Missing permission to link approvals" });
|
|
return false;
|
|
}
|
|
|
|
function actorCanAccessCompany(req: Request, companyId: string) {
|
|
if (req.actor.type === "none") return false;
|
|
if (req.actor.type === "agent") return req.actor.companyId === companyId;
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return true;
|
|
return (req.actor.companyIds ?? []).includes(companyId);
|
|
}
|
|
|
|
function canCreateAgentsLegacy(agent: { permissions: Record<string, unknown> | null | undefined; role: string }) {
|
|
if (agent.role === "ceo") return true;
|
|
if (!agent.permissions || typeof agent.permissions !== "object") return false;
|
|
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
|
}
|
|
|
|
async function assertCanAssignTasks(req: Request, companyId: string) {
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.type === "board") {
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) return;
|
|
const allowed = await access.canUser(companyId, req.actor.userId, "tasks:assign");
|
|
if (!allowed) throw forbidden("Missing permission: tasks:assign");
|
|
return;
|
|
}
|
|
if (req.actor.type === "agent") {
|
|
if (!req.actor.agentId) throw forbidden("Agent authentication required");
|
|
const allowedByGrant = await access.hasPermission(companyId, "agent", req.actor.agentId, "tasks:assign");
|
|
if (allowedByGrant) return;
|
|
const actorAgent = await agentsSvc.getById(req.actor.agentId);
|
|
if (actorAgent && actorAgent.companyId === companyId && canCreateAgentsLegacy(actorAgent)) return;
|
|
throw forbidden("Missing permission: tasks:assign");
|
|
}
|
|
throw unauthorized();
|
|
}
|
|
|
|
function requireAgentRunId(req: Request, res: Response) {
|
|
if (req.actor.type !== "agent") return null;
|
|
const runId = req.actor.runId?.trim();
|
|
if (runId) return runId;
|
|
res.status(401).json({ error: "Agent run id required" });
|
|
return null;
|
|
}
|
|
|
|
async function hasActiveCheckoutManagementOverride(
|
|
actorAgentId: string,
|
|
companyId: string,
|
|
assigneeAgentId: string,
|
|
) {
|
|
const allowedByGrant = await access.hasPermission(
|
|
companyId,
|
|
"agent",
|
|
actorAgentId,
|
|
"tasks:manage_active_checkouts",
|
|
);
|
|
if (allowedByGrant) return true;
|
|
|
|
const companyAgents = await agentsSvc.list(companyId);
|
|
const agentsById = new Map(companyAgents.map((agent) => [agent.id, agent]));
|
|
const actorAgent = agentsById.get(actorAgentId);
|
|
if (!actorAgent) return false;
|
|
if (canCreateAgentsLegacy(actorAgent)) return true;
|
|
|
|
// Reporting-chain managers may intervene in an agent's active checkout
|
|
// without taking the task over. Peers must own the checkout/run first.
|
|
let cursor: string | null = assigneeAgentId;
|
|
for (let depth = 0; cursor && depth < 50; depth += 1) {
|
|
const assignee = agentsById.get(cursor);
|
|
if (!assignee) return false;
|
|
if (assignee.reportsTo === actorAgentId) return true;
|
|
cursor = assignee.reportsTo;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
async function assertAgentIssueMutationAllowed(
|
|
req: Request,
|
|
res: Response,
|
|
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
|
|
) {
|
|
if (req.actor.type !== "agent") return true;
|
|
const actorAgentId = req.actor.agentId;
|
|
if (!actorAgentId) {
|
|
res.status(403).json({ error: "Agent authentication required" });
|
|
return false;
|
|
}
|
|
if (issue.status !== "in_progress" || issue.assigneeAgentId === null) {
|
|
return true;
|
|
}
|
|
if (issue.assigneeAgentId !== actorAgentId) {
|
|
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
|
|
return true;
|
|
}
|
|
res.status(409).json({
|
|
error: "Issue is checked out by another agent",
|
|
details: {
|
|
issueId: issue.id,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
actorAgentId,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
const runId = requireAgentRunId(req, res);
|
|
if (!runId) return false;
|
|
const ownership = await svc.assertCheckoutOwner(issue.id, actorAgentId, runId);
|
|
if (ownership.adoptedFromRunId) {
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.checkout_lock_adopted",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
previousCheckoutRunId: ownership.adoptedFromRunId,
|
|
checkoutRunId: runId,
|
|
reason: "stale_checkout_run",
|
|
},
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function resolveActiveIssueRun(issue: {
|
|
id: string;
|
|
assigneeAgentId: string | null;
|
|
executionRunId?: string | null;
|
|
}) {
|
|
let runToInterrupt = issue.executionRunId ? await heartbeat.getRun(issue.executionRunId) : null;
|
|
|
|
if ((!runToInterrupt || runToInterrupt.status !== "running") && issue.assigneeAgentId) {
|
|
const activeRun = await heartbeat.getActiveRunForAgent(issue.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 === issue.id) {
|
|
runToInterrupt = activeRun;
|
|
}
|
|
}
|
|
|
|
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
|
}
|
|
|
|
async function normalizeIssueAssigneeAgentReference(
|
|
companyId: string,
|
|
rawAssigneeAgentId: string | null | undefined,
|
|
) {
|
|
if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) {
|
|
return rawAssigneeAgentId;
|
|
}
|
|
|
|
const raw = rawAssigneeAgentId.trim();
|
|
if (raw.length === 0) {
|
|
return rawAssigneeAgentId;
|
|
}
|
|
|
|
const resolved = await agentsSvc.resolveByReference(companyId, raw);
|
|
if (resolved.ambiguous) {
|
|
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
|
|
}
|
|
if (!resolved.agent) {
|
|
throw notFound("Agent not found");
|
|
}
|
|
return resolved.agent.id;
|
|
}
|
|
function toValidTimestamp(value: Date | string | null | undefined) {
|
|
if (!value) return null;
|
|
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
|
return Number.isFinite(timestamp) ? timestamp : null;
|
|
}
|
|
|
|
function isQueuedIssueCommentForActiveRun(params: {
|
|
comment: {
|
|
authorAgentId?: string | null;
|
|
createdAt?: Date | string | null;
|
|
};
|
|
activeRun: {
|
|
agentId?: string | null;
|
|
startedAt?: Date | string | null;
|
|
createdAt?: Date | string | null;
|
|
};
|
|
}) {
|
|
const activeRunStartedAtMs =
|
|
toValidTimestamp(params.activeRun.startedAt) ?? toValidTimestamp(params.activeRun.createdAt);
|
|
const commentCreatedAtMs = toValidTimestamp(params.comment.createdAt);
|
|
|
|
if (activeRunStartedAtMs === null || commentCreatedAtMs === null) return false;
|
|
if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false;
|
|
return commentCreatedAtMs >= activeRunStartedAtMs;
|
|
}
|
|
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
|
if (!issue.executionWorkspaceId) return null;
|
|
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
|
if (!workspace || !isClosedIsolatedExecutionWorkspace(workspace)) return null;
|
|
return workspace;
|
|
}
|
|
|
|
function respondClosedIssueExecutionWorkspace(
|
|
res: Response,
|
|
workspace: Pick<ExecutionWorkspace, "closedAt" | "id" | "mode" | "name" | "status">,
|
|
) {
|
|
res.status(409).json({
|
|
error: getClosedIsolatedExecutionWorkspaceMessage(workspace),
|
|
executionWorkspace: workspace,
|
|
});
|
|
}
|
|
|
|
async function normalizeIssueIdentifier(rawId: string): Promise<string> {
|
|
if (/^[A-Z]+-\d+$/i.test(rawId)) {
|
|
const issue = await svc.getByIdentifier(rawId);
|
|
if (issue) {
|
|
return issue.id;
|
|
}
|
|
}
|
|
return rawId;
|
|
}
|
|
|
|
async function resolveIssueProjectAndGoal(issue: {
|
|
companyId: string;
|
|
projectId: string | null;
|
|
goalId: string | null;
|
|
}) {
|
|
const projectPromise = issue.projectId ? projectsSvc.getById(issue.projectId) : Promise.resolve(null);
|
|
const directGoalPromise = issue.goalId ? goalsSvc.getById(issue.goalId) : Promise.resolve(null);
|
|
const [project, directGoal] = await Promise.all([projectPromise, directGoalPromise]);
|
|
|
|
if (directGoal) {
|
|
return { project, goal: directGoal };
|
|
}
|
|
|
|
const projectGoalId = project?.goalId ?? project?.goalIds[0] ?? null;
|
|
if (projectGoalId) {
|
|
const projectGoal = await goalsSvc.getById(projectGoalId);
|
|
return { project, goal: projectGoal };
|
|
}
|
|
|
|
if (!issue.projectId) {
|
|
const defaultGoal = await goalsSvc.getDefaultCompanyGoal(issue.companyId);
|
|
return { project, goal: defaultGoal };
|
|
}
|
|
|
|
return { project, goal: null };
|
|
}
|
|
|
|
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for all /issues/:id routes
|
|
router.param("id", async (req, res, next, rawId) => {
|
|
try {
|
|
req.params.id = await normalizeIssueIdentifier(rawId);
|
|
next();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// Resolve issue identifiers (e.g. "PAP-39") to UUIDs for company-scoped attachment routes.
|
|
router.param("issueId", async (req, res, next, rawId) => {
|
|
try {
|
|
req.params.issueId = await normalizeIssueIdentifier(rawId);
|
|
next();
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// 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);
|
|
const assigneeUserFilterRaw = req.query.assigneeUserId as string | undefined;
|
|
const touchedByUserFilterRaw = req.query.touchedByUserId as string | undefined;
|
|
const inboxArchivedByUserFilterRaw = req.query.inboxArchivedByUserId as string | undefined;
|
|
const unreadForUserFilterRaw = req.query.unreadForUserId as string | undefined;
|
|
const assigneeUserId =
|
|
assigneeUserFilterRaw === "me" && req.actor.type === "board"
|
|
? req.actor.userId
|
|
: assigneeUserFilterRaw;
|
|
const touchedByUserId =
|
|
touchedByUserFilterRaw === "me" && req.actor.type === "board"
|
|
? req.actor.userId
|
|
: touchedByUserFilterRaw;
|
|
const inboxArchivedByUserId =
|
|
inboxArchivedByUserFilterRaw === "me" && req.actor.type === "board"
|
|
? req.actor.userId
|
|
: inboxArchivedByUserFilterRaw;
|
|
const unreadForUserId =
|
|
unreadForUserFilterRaw === "me" && req.actor.type === "board"
|
|
? req.actor.userId
|
|
: unreadForUserFilterRaw;
|
|
const rawLimit = req.query.limit as string | undefined;
|
|
const parsedLimit = rawLimit !== undefined && /^\d+$/.test(rawLimit)
|
|
? Number.parseInt(rawLimit, 10)
|
|
: null;
|
|
const limit = parsedLimit === null ? ISSUE_LIST_DEFAULT_LIMIT : clampIssueListLimit(parsedLimit);
|
|
|
|
if (assigneeUserFilterRaw === "me" && (!assigneeUserId || req.actor.type !== "board")) {
|
|
res.status(403).json({ error: "assigneeUserId=me requires board authentication" });
|
|
return;
|
|
}
|
|
if (touchedByUserFilterRaw === "me" && (!touchedByUserId || req.actor.type !== "board")) {
|
|
res.status(403).json({ error: "touchedByUserId=me requires board authentication" });
|
|
return;
|
|
}
|
|
if (inboxArchivedByUserFilterRaw === "me" && (!inboxArchivedByUserId || req.actor.type !== "board")) {
|
|
res.status(403).json({ error: "inboxArchivedByUserId=me requires board authentication" });
|
|
return;
|
|
}
|
|
if (unreadForUserFilterRaw === "me" && (!unreadForUserId || req.actor.type !== "board")) {
|
|
res.status(403).json({ error: "unreadForUserId=me requires board authentication" });
|
|
return;
|
|
}
|
|
if (rawLimit !== undefined && (parsedLimit === null || !Number.isInteger(parsedLimit) || parsedLimit <= 0)) {
|
|
res.status(400).json({ error: `limit must be a positive integer up to ${ISSUE_LIST_MAX_LIMIT}` });
|
|
return;
|
|
}
|
|
|
|
const result = await svc.list(companyId, {
|
|
status: req.query.status as string | undefined,
|
|
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
|
participantAgentId: req.query.participantAgentId as string | undefined,
|
|
assigneeUserId,
|
|
touchedByUserId,
|
|
inboxArchivedByUserId,
|
|
unreadForUserId,
|
|
projectId: req.query.projectId as string | undefined,
|
|
executionWorkspaceId: req.query.executionWorkspaceId as string | undefined,
|
|
parentId: req.query.parentId as string | undefined,
|
|
labelId: req.query.labelId as string | undefined,
|
|
originKind: req.query.originKind as string | undefined,
|
|
originId: req.query.originId as string | undefined,
|
|
includeRoutineExecutions:
|
|
req.query.includeRoutineExecutions === "true" || req.query.includeRoutineExecutions === "1",
|
|
excludeRoutineExecutions:
|
|
req.query.excludeRoutineExecutions === "true" || req.query.excludeRoutineExecutions === "1",
|
|
q: req.query.q as string | undefined,
|
|
limit,
|
|
});
|
|
res.json(result);
|
|
});
|
|
|
|
router.get("/companies/:companyId/labels", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const result = await svc.listLabels(companyId);
|
|
res.json(result);
|
|
});
|
|
|
|
router.post("/companies/:companyId/labels", validate(createIssueLabelSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const label = await svc.createLabel(companyId, req.body);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "label.created",
|
|
entityType: "label",
|
|
entityId: label.id,
|
|
details: { name: label.name, color: label.color },
|
|
});
|
|
res.status(201).json(label);
|
|
});
|
|
|
|
router.delete("/labels/:labelId", async (req, res) => {
|
|
const labelId = req.params.labelId as string;
|
|
const existing = await svc.getLabelById(labelId);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Label not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const removed = await svc.deleteLabel(labelId);
|
|
if (!removed) {
|
|
res.status(404).json({ error: "Label not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: removed.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "label.deleted",
|
|
entityType: "label",
|
|
entityId: removed.id,
|
|
details: { name: removed.name, color: removed.color },
|
|
});
|
|
res.json(removed);
|
|
});
|
|
|
|
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 [{ project, goal }, ancestors, commentCursor, wakeComment, relations, attachments, continuationSummary] =
|
|
await Promise.all([
|
|
resolveIssueProjectAndGoal(issue),
|
|
svc.getAncestors(issue.id),
|
|
svc.getCommentCursor(issue.id),
|
|
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
|
svc.getRelationSummaries(issue.id),
|
|
svc.listAttachments(issue.id),
|
|
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
|
]);
|
|
|
|
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,
|
|
blockedBy: relations.blockedBy,
|
|
blocks: relations.blocks,
|
|
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,
|
|
attachments: attachments.map((a) => ({
|
|
id: a.id,
|
|
filename: a.originalFilename,
|
|
contentType: a.contentType,
|
|
byteSize: a.byteSize,
|
|
contentPath: withContentPath(a).contentPath,
|
|
createdAt: a.createdAt,
|
|
})),
|
|
continuationSummary: continuationSummary
|
|
? {
|
|
key: continuationSummary.key,
|
|
title: continuationSummary.title,
|
|
body: continuationSummary.body,
|
|
latestRevisionId: continuationSummary.latestRevisionId,
|
|
latestRevisionNumber: continuationSummary.latestRevisionNumber,
|
|
updatedAt: continuationSummary.updatedAt,
|
|
}
|
|
: null,
|
|
});
|
|
});
|
|
|
|
router.get("/issues/:id", 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 [{ project, goal }, ancestors, mentionedProjectIds, documentPayload, relations, referenceSummary] = await Promise.all([
|
|
resolveIssueProjectAndGoal(issue),
|
|
svc.getAncestors(issue.id),
|
|
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
|
documentsSvc.getIssueDocumentPayload(issue),
|
|
svc.getRelationSummaries(issue.id),
|
|
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
|
]);
|
|
const mentionedProjects = mentionedProjectIds.length > 0
|
|
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
|
|
: [];
|
|
const currentExecutionWorkspace = issue.executionWorkspaceId
|
|
? await executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
|
: null;
|
|
const workProducts = await workProductsSvc.listForIssue(issue.id);
|
|
res.json({
|
|
...issue,
|
|
goalId: goal?.id ?? issue.goalId,
|
|
ancestors,
|
|
blockedBy: relations.blockedBy,
|
|
blocks: relations.blocks,
|
|
relatedWork: referenceSummary,
|
|
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
|
...documentPayload,
|
|
project: project ?? null,
|
|
goal: goal ?? null,
|
|
mentionedProjects,
|
|
currentExecutionWorkspace,
|
|
workProducts,
|
|
});
|
|
});
|
|
|
|
router.get("/issues/:id/work-products", 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 workProducts = await workProductsSvc.listForIssue(issue.id);
|
|
res.json(workProducts);
|
|
});
|
|
|
|
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, {
|
|
includeSystem: req.query.includeSystem === "true",
|
|
});
|
|
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);
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) 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 actor = getActorInfo(req);
|
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
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,
|
|
createdByRunId: actor.runId ?? null,
|
|
});
|
|
const doc = result.document;
|
|
await issueReferencesSvc.syncDocument(doc.id);
|
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
|
|
|
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,
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
|
|
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.post(
|
|
"/issues/:id/documents/:key/revisions/:revisionId/restore",
|
|
validate(restoreIssueDocumentRevisionSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const revisionId = req.params.revisionId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) 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 actor = getActorInfo(req);
|
|
const referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const result = await documentsSvc.restoreIssueDocumentRevision({
|
|
issueId: issue.id,
|
|
key: keyParsed.data,
|
|
revisionId,
|
|
createdByAgentId: actor.agentId ?? null,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
await issueReferencesSvc.syncDocument(result.document.id);
|
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.document_restored",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
key: result.document.key,
|
|
documentId: result.document.id,
|
|
title: result.document.title,
|
|
format: result.document.format,
|
|
revisionNumber: result.document.latestRevisionNumber,
|
|
restoredFromRevisionId: result.restoredFromRevisionId,
|
|
restoredFromRevisionNumber: result.restoredFromRevisionNumber,
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
|
|
res.json(result.document);
|
|
},
|
|
);
|
|
|
|
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 referenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const removed = await documentsSvc.deleteIssueDocument(issue.id, keyParsed.data);
|
|
if (!removed) {
|
|
res.status(404).json({ error: "Document not found" });
|
|
return;
|
|
}
|
|
await issueReferencesSvc.deleteDocumentSource(removed.id);
|
|
const referenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(referenceSummaryBefore, referenceSummaryAfter);
|
|
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,
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post("/issues/:id/work-products", validate(createIssueWorkProductSchema), 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 (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, {
|
|
...req.body,
|
|
projectId: req.body.projectId ?? issue.projectId ?? null,
|
|
});
|
|
if (!product) {
|
|
res.status(422).json({ error: "Invalid work product payload" });
|
|
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.work_product_created",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { workProductId: product.id, type: product.type, provider: product.provider },
|
|
});
|
|
res.status(201).json(product);
|
|
});
|
|
|
|
router.patch("/work-products/:id", validate(updateIssueWorkProductSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await workProductsSvc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Work product not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const issue = await svc.getById(existing.issueId);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
const product = await workProductsSvc.update(id, req.body);
|
|
if (!product) {
|
|
res.status(404).json({ error: "Work product not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.work_product_updated",
|
|
entityType: "issue",
|
|
entityId: existing.issueId,
|
|
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
|
|
});
|
|
res.json(product);
|
|
});
|
|
|
|
router.delete("/work-products/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await workProductsSvc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Work product not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
const issue = await svc.getById(existing.issueId);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
const removed = await workProductsSvc.remove(id);
|
|
if (!removed) {
|
|
res.status(404).json({ error: "Work product not found" });
|
|
return;
|
|
}
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.work_product_deleted",
|
|
entityType: "issue",
|
|
entityId: existing.issueId,
|
|
details: { workProductId: removed.id, type: removed.type },
|
|
});
|
|
res.json(removed);
|
|
});
|
|
|
|
router.post("/issues/:id/read", 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;
|
|
}
|
|
if (!req.actor.userId) {
|
|
res.status(403).json({ error: "Board user context required" });
|
|
return;
|
|
}
|
|
const readState = await svc.markRead(issue.companyId, issue.id, req.actor.userId, new Date());
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.read_marked",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { userId: req.actor.userId, lastReadAt: readState.lastReadAt },
|
|
});
|
|
res.json(readState);
|
|
});
|
|
|
|
router.delete("/issues/:id/read", 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;
|
|
}
|
|
if (!req.actor.userId) {
|
|
res.status(403).json({ error: "Board user context required" });
|
|
return;
|
|
}
|
|
const removed = await svc.markUnread(issue.companyId, issue.id, req.actor.userId);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.read_unmarked",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { userId: req.actor.userId },
|
|
});
|
|
res.json({ id: issue.id, removed });
|
|
});
|
|
|
|
router.post("/issues/:id/inbox-archive", 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;
|
|
}
|
|
if (!req.actor.userId) {
|
|
res.status(403).json({ error: "Board user context required" });
|
|
return;
|
|
}
|
|
const archiveState = await svc.archiveInbox(issue.companyId, issue.id, req.actor.userId, new Date());
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.inbox_archived",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { userId: req.actor.userId, archivedAt: archiveState.archivedAt },
|
|
});
|
|
res.json(archiveState);
|
|
});
|
|
|
|
router.delete("/issues/:id/inbox-archive", 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;
|
|
}
|
|
if (!req.actor.userId) {
|
|
res.status(403).json({ error: "Board user context required" });
|
|
return;
|
|
}
|
|
const removed = await svc.unarchiveInbox(issue.companyId, issue.id, req.actor.userId);
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.inbox_unarchived",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { userId: req.actor.userId },
|
|
});
|
|
res.json(removed ?? { ok: true });
|
|
});
|
|
|
|
router.get("/issues/:id/approvals", 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 approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
|
|
res.json(approvals);
|
|
});
|
|
|
|
router.post("/issues/:id/approvals", validate(linkIssueApprovalSchema), 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 (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
|
|
|
|
const actor = getActorInfo(req);
|
|
await issueApprovalsSvc.link(id, req.body.approvalId, {
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.approval_linked",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { approvalId: req.body.approvalId },
|
|
});
|
|
|
|
const approvals = await issueApprovalsSvc.listApprovalsForIssue(id);
|
|
res.status(201).json(approvals);
|
|
});
|
|
|
|
router.delete("/issues/:id/approvals/:approvalId", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const approvalId = req.params.approvalId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
if (!(await assertCanManageIssueApprovalLinks(req, res, issue.companyId))) return;
|
|
|
|
await issueApprovalsSvc.unlink(id, approvalId);
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.approval_unlinked",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { approvalId },
|
|
});
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post("/companies/:companyId/issues", validate(createIssueSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
|
await assertCanAssignTasks(req, companyId);
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
|
const issue = await svc.create(companyId, {
|
|
...req.body,
|
|
executionPolicy,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
await issueReferencesSvc.syncIssue(issue.id);
|
|
const referenceSummary = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const referenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
|
issueReferencesSvc.emptySummary(),
|
|
referenceSummary,
|
|
);
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.created",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
title: issue.title,
|
|
identifier: issue.identifier,
|
|
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
|
|
void queueIssueAssignmentWakeup({
|
|
heartbeat,
|
|
issue,
|
|
reason: "issue_assigned",
|
|
mutation: "create",
|
|
contextSource: "issue.create",
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
});
|
|
|
|
res.status(201).json({
|
|
...issue,
|
|
relatedWork: referenceSummary,
|
|
referencedIssueIdentifiers: referenceSummary.outbound.map((item) => item.issue.identifier ?? item.issue.id),
|
|
});
|
|
});
|
|
|
|
router.post("/issues/:id/children", validate(createChildIssueSchema), async (req, res) => {
|
|
const parentId = req.params.id as string;
|
|
const parent = await svc.getById(parentId);
|
|
if (!parent) {
|
|
res.status(404).json({ error: "Parent issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, parent.companyId);
|
|
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
|
await assertCanAssignTasks(req, parent.companyId);
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
|
const { issue, parentBlockerAdded } = await svc.createChild(parent.id, {
|
|
...req.body,
|
|
executionPolicy,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
actorAgentId: actor.agentId,
|
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId: parent.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.child_created",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
parentId: parent.id,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
inheritedExecutionWorkspaceFromIssueId: parent.id,
|
|
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
|
...(parentBlockerAdded ? { parentBlockerAdded: true } : {}),
|
|
},
|
|
});
|
|
|
|
void queueIssueAssignmentWakeup({
|
|
heartbeat,
|
|
issue,
|
|
reason: "issue_assigned",
|
|
mutation: "create",
|
|
contextSource: "issue.child_create",
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
});
|
|
|
|
res.status(201).json(issue);
|
|
});
|
|
|
|
router.patch("/issues/:id", validate(updateIssueRouteSchema), async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
|
|
|
const actor = getActorInfo(req);
|
|
const isClosed = isClosedIssueStatus(existing.status);
|
|
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
|
|
existing.companyId,
|
|
req.body.assigneeAgentId as string | null | undefined,
|
|
);
|
|
const titleOrDescriptionChanged = req.body.title !== undefined || req.body.description !== undefined;
|
|
const existingRelations =
|
|
Array.isArray(req.body.blockedByIssueIds)
|
|
? await svc.getRelationSummaries(existing.id)
|
|
: null;
|
|
const {
|
|
comment: commentBody,
|
|
reopen: reopenRequested,
|
|
interrupt: interruptRequested,
|
|
hiddenAt: hiddenAtRaw,
|
|
...updateFields
|
|
} = req.body;
|
|
const requestedAssigneeAgentId =
|
|
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
|
const effectiveReopenRequested =
|
|
reopenRequested ||
|
|
(!!commentBody &&
|
|
shouldImplicitlyReopenCommentForAgent({
|
|
issueStatus: existing.status,
|
|
assigneeAgentId: requestedAssigneeAgentId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
}));
|
|
const updateReferenceSummaryBefore = titleOrDescriptionChanged
|
|
? await issueReferencesSvc.listIssueReferenceSummary(existing.id)
|
|
: null;
|
|
let interruptedRunId: string | null = null;
|
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
|
const isAgentWorkUpdate = req.actor.type === "agent" && Object.keys(updateFields).length > 0;
|
|
|
|
if (closedExecutionWorkspace && (commentBody || isAgentWorkUpdate)) {
|
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
|
return;
|
|
}
|
|
|
|
if (interruptRequested) {
|
|
if (!commentBody) {
|
|
res.status(400).json({ error: "Interrupt is only supported when posting a comment" });
|
|
return;
|
|
}
|
|
if (req.actor.type !== "board") {
|
|
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
|
|
return;
|
|
}
|
|
|
|
const runToInterrupt = await resolveActiveIssueRun(existing);
|
|
if (runToInterrupt) {
|
|
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: existing.id },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hiddenAtRaw !== undefined) {
|
|
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
|
}
|
|
if (commentBody && effectiveReopenRequested && isClosed && updateFields.status === undefined) {
|
|
updateFields.status = "todo";
|
|
}
|
|
if (req.body.executionPolicy !== undefined) {
|
|
updateFields.executionPolicy = normalizeIssueExecutionPolicy(req.body.executionPolicy);
|
|
}
|
|
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
|
|
const nextExecutionPolicy =
|
|
updateFields.executionPolicy !== undefined
|
|
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
|
: previousExecutionPolicy;
|
|
if (normalizedAssigneeAgentId !== undefined) {
|
|
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
|
|
}
|
|
|
|
const transition = applyIssueExecutionPolicyTransition({
|
|
issue: existing,
|
|
policy: nextExecutionPolicy,
|
|
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
|
requestedAssigneePatch: {
|
|
assigneeAgentId: normalizedAssigneeAgentId,
|
|
assigneeUserId:
|
|
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
|
|
},
|
|
actor: {
|
|
agentId: actor.agentId ?? null,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
commentBody,
|
|
});
|
|
const decisionId = transition.decision ? randomUUID() : null;
|
|
if (decisionId) {
|
|
const nextExecutionState = transition.patch.executionState;
|
|
if (!nextExecutionState || typeof nextExecutionState !== "object") {
|
|
throw new Error("Execution policy decision patch is missing executionState");
|
|
}
|
|
transition.patch.executionState = {
|
|
...nextExecutionState,
|
|
lastDecisionId: decisionId,
|
|
};
|
|
}
|
|
Object.assign(updateFields, transition.patch);
|
|
|
|
const nextAssigneeAgentId =
|
|
updateFields.assigneeAgentId === undefined ? existing.assigneeAgentId : (updateFields.assigneeAgentId as string | null);
|
|
const nextAssigneeUserId =
|
|
updateFields.assigneeUserId === undefined ? existing.assigneeUserId : (updateFields.assigneeUserId as string | null);
|
|
const assigneeWillChange =
|
|
nextAssigneeAgentId !== existing.assigneeAgentId || nextAssigneeUserId !== existing.assigneeUserId;
|
|
const isAgentReturningIssueToCreator =
|
|
req.actor.type === "agent" &&
|
|
!!req.actor.agentId &&
|
|
existing.assigneeAgentId === req.actor.agentId &&
|
|
nextAssigneeAgentId === null &&
|
|
typeof nextAssigneeUserId === "string" &&
|
|
!!existing.createdByUserId &&
|
|
nextAssigneeUserId === existing.createdByUserId;
|
|
|
|
if (assigneeWillChange && !transition.workflowControlledAssignment) {
|
|
if (!isAgentReturningIssueToCreator) {
|
|
await assertCanAssignTasks(req, existing.companyId);
|
|
}
|
|
}
|
|
|
|
let issue;
|
|
try {
|
|
if (transition.decision && decisionId) {
|
|
const decision = transition.decision;
|
|
issue = await db.transaction(async (tx) => {
|
|
const updated = await svc.update(
|
|
id,
|
|
{
|
|
...updateFields,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
tx,
|
|
);
|
|
if (!updated) return null;
|
|
|
|
await tx.insert(issueExecutionDecisions).values({
|
|
id: decisionId,
|
|
companyId: updated.companyId,
|
|
issueId: updated.id,
|
|
stageId: decision.stageId,
|
|
stageType: decision.stageType,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
outcome: decision.outcome,
|
|
body: decision.body,
|
|
createdByRunId: actor.runId ?? null,
|
|
});
|
|
|
|
return updated;
|
|
});
|
|
} else {
|
|
issue = await svc.update(id, {
|
|
...updateFields,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof HttpError && err.status === 422) {
|
|
logger.warn(
|
|
{
|
|
issueId: id,
|
|
companyId: existing.companyId,
|
|
assigneePatch: {
|
|
assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId,
|
|
assigneeUserId:
|
|
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
|
|
},
|
|
currentAssignee: {
|
|
assigneeAgentId: existing.assigneeAgentId,
|
|
assigneeUserId: existing.assigneeUserId,
|
|
},
|
|
error: err.message,
|
|
details: err.details,
|
|
},
|
|
"issue update rejected with 422",
|
|
);
|
|
}
|
|
throw err;
|
|
}
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
if (titleOrDescriptionChanged) {
|
|
await issueReferencesSvc.syncIssue(issue.id);
|
|
}
|
|
const updateReferenceSummaryAfter = titleOrDescriptionChanged
|
|
? await issueReferencesSvc.listIssueReferenceSummary(issue.id)
|
|
: null;
|
|
const updateReferenceDiff = updateReferenceSummaryBefore && updateReferenceSummaryAfter
|
|
? issueReferencesSvc.diffIssueReferenceSummary(updateReferenceSummaryBefore, updateReferenceSummaryAfter)
|
|
: null;
|
|
let issueResponse: typeof issue & {
|
|
blockedBy?: unknown;
|
|
blocks?: unknown;
|
|
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
|
|
referencedIssueIdentifiers?: string[];
|
|
} = issue;
|
|
let updatedRelations: Awaited<ReturnType<typeof svc.getRelationSummaries>> | null = null;
|
|
if (issue && Array.isArray(req.body.blockedByIssueIds)) {
|
|
updatedRelations = await svc.getRelationSummaries(issue.id);
|
|
issueResponse = {
|
|
...issue,
|
|
blockedBy: updatedRelations.blockedBy,
|
|
blocks: updatedRelations.blocks,
|
|
};
|
|
}
|
|
await routinesSvc.syncRunStatusForIssue(issue.id);
|
|
|
|
if (actor.runId) {
|
|
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
|
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue activity"));
|
|
}
|
|
|
|
// Build activity details with previous values for changed fields
|
|
const previous: Record<string, unknown> = {};
|
|
for (const key of Object.keys(updateFields)) {
|
|
if (key in existing && (existing as Record<string, unknown>)[key] !== (updateFields as Record<string, unknown>)[key]) {
|
|
previous[key] = (existing as Record<string, unknown>)[key];
|
|
}
|
|
}
|
|
if (Array.isArray(req.body.blockedByIssueIds)) {
|
|
previous.blockedByIssueIds = existingRelations?.blockedBy.map((relation) => relation.id) ?? [];
|
|
}
|
|
|
|
const hasFieldChanges = Object.keys(previous).length > 0;
|
|
const reopened =
|
|
commentBody &&
|
|
effectiveReopenRequested &&
|
|
isClosed &&
|
|
previous.status !== undefined &&
|
|
issue.status === "todo";
|
|
const reopenFromStatus = reopened ? existing.status : null;
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
...updateFields,
|
|
identifier: issue.identifier,
|
|
...(commentBody ? { source: "comment" } : {}),
|
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
_previous: hasFieldChanges ? previous : undefined,
|
|
...summarizeIssueReferenceActivityDetails(
|
|
updateReferenceDiff
|
|
? {
|
|
addedReferencedIssues: updateReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: updateReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: updateReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}
|
|
: null,
|
|
),
|
|
},
|
|
});
|
|
|
|
if (Array.isArray(req.body.blockedByIssueIds)) {
|
|
const previousBlockedByIds = new Set((existingRelations?.blockedBy ?? []).map((relation) => relation.id));
|
|
const nextBlockedByIds = new Set(req.body.blockedByIssueIds as string[]);
|
|
const addedBlockedByIssueIds = [...nextBlockedByIds].filter((candidate) => !previousBlockedByIds.has(candidate));
|
|
const removedBlockedByIssueIds = [...previousBlockedByIds].filter((candidate) => !nextBlockedByIds.has(candidate));
|
|
const nextBlockedByRelations = updatedRelations?.blockedBy ?? [];
|
|
const previousBlockedByRelations = existingRelations?.blockedBy ?? [];
|
|
if (addedBlockedByIssueIds.length > 0 || removedBlockedByIssueIds.length > 0) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.blockers_updated",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
blockedByIssueIds: req.body.blockedByIssueIds,
|
|
addedBlockedByIssueIds,
|
|
removedBlockedByIssueIds,
|
|
blockedByIssues: nextBlockedByRelations.map(summarizeIssueRelationForActivity),
|
|
addedBlockedByIssues: nextBlockedByRelations
|
|
.filter((relation) => addedBlockedByIssueIds.includes(relation.id))
|
|
.map(summarizeIssueRelationForActivity),
|
|
removedBlockedByIssues: previousBlockedByRelations
|
|
.filter((relation) => removedBlockedByIssueIds.includes(relation.id))
|
|
.map(summarizeIssueRelationForActivity),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const reviewerChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "review");
|
|
if (reviewerChanges.addedParticipants.length > 0 || reviewerChanges.removedParticipants.length > 0) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.reviewers_updated",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
participants: reviewerChanges.participants,
|
|
addedParticipants: reviewerChanges.addedParticipants,
|
|
removedParticipants: reviewerChanges.removedParticipants,
|
|
},
|
|
});
|
|
}
|
|
|
|
const approverChanges = diffExecutionParticipants(previousExecutionPolicy, nextExecutionPolicy, "approval");
|
|
if (approverChanges.addedParticipants.length > 0 || approverChanges.removedParticipants.length > 0) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.approvers_updated",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
participants: approverChanges.participants,
|
|
addedParticipants: approverChanges.addedParticipants,
|
|
removedParticipants: approverChanges.removedParticipants,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (issue.status === "done" && existing.status !== "done") {
|
|
const tc = getTelemetryClient();
|
|
if (tc && actor.agentId) {
|
|
const actorAgent = await agentsSvc.getById(actor.agentId);
|
|
if (actorAgent) {
|
|
const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined;
|
|
trackAgentTaskCompleted(tc, {
|
|
agentRole: actorAgent.role,
|
|
agentId: actorAgent.id,
|
|
adapterType: actorAgent.adapterType,
|
|
model,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
let comment = null;
|
|
if (commentBody) {
|
|
const commentReferenceSummaryBefore = updateReferenceSummaryAfter
|
|
?? await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
comment = await svc.addComment(id, commentBody, {
|
|
agentId: actor.agentId ?? undefined,
|
|
userId: actor.actorType === "user" ? actor.actorId : undefined,
|
|
runId: actor.runId,
|
|
});
|
|
await issueReferencesSvc.syncComment(comment.id);
|
|
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
|
commentReferenceSummaryBefore,
|
|
commentReferenceSummaryAfter,
|
|
);
|
|
issueResponse = {
|
|
...issueResponse,
|
|
relatedWork: commentReferenceSummaryAfter,
|
|
referencedIssueIdentifiers: commentReferenceSummaryAfter.outbound.map(
|
|
(item) => item.issue.identifier ?? item.issue.id,
|
|
),
|
|
};
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.comment_added",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
commentId: comment.id,
|
|
bodySnippet: comment.body.slice(0, 120),
|
|
identifier: issue.identifier,
|
|
issueTitle: issue.title,
|
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
...(hasFieldChanges ? { updated: true } : {}),
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
|
|
} else if (updateReferenceSummaryAfter) {
|
|
issueResponse = {
|
|
...issueResponse,
|
|
relatedWork: updateReferenceSummaryAfter,
|
|
referencedIssueIdentifiers: updateReferenceSummaryAfter.outbound.map(
|
|
(item) => item.issue.identifier ?? item.issue.id,
|
|
),
|
|
};
|
|
}
|
|
const assigneeChanged =
|
|
issue.assigneeAgentId !== existing.assigneeAgentId || issue.assigneeUserId !== existing.assigneeUserId;
|
|
const statusChangedFromBacklog =
|
|
existing.status === "backlog" &&
|
|
issue.status !== "backlog" &&
|
|
req.body.status !== undefined;
|
|
const statusChangedFromBlockedToTodo =
|
|
existing.status === "blocked" &&
|
|
issue.status === "todo" &&
|
|
req.body.status !== undefined;
|
|
const previousExecutionState = parseIssueExecutionState(existing.executionState);
|
|
const nextExecutionState = parseIssueExecutionState(issue.executionState);
|
|
const executionStageWakeup = buildExecutionStageWakeup({
|
|
issueId: issue.id,
|
|
previousState: previousExecutionState,
|
|
nextState: nextExecutionState,
|
|
interruptedRunId,
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
});
|
|
|
|
// Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs.
|
|
void (async () => {
|
|
type WakeupRequest = NonNullable<Parameters<typeof heartbeat.wakeup>[1]>;
|
|
const wakeups = new Map<string, { agentId: string; wakeup: WakeupRequest }>();
|
|
const addWakeup = (agentId: string, wakeup: WakeupRequest) => {
|
|
const wakeIssueId =
|
|
wakeup.payload && typeof wakeup.payload === "object" && typeof wakeup.payload.issueId === "string"
|
|
? wakeup.payload.issueId
|
|
: issue.id;
|
|
wakeups.set(`${agentId}:${wakeIssueId}`, { agentId, wakeup });
|
|
};
|
|
|
|
if (executionStageWakeup) {
|
|
addWakeup(executionStageWakeup.agentId, executionStageWakeup.wakeup);
|
|
} else if (assigneeChanged && issue.assigneeAgentId && issue.status !== "backlog") {
|
|
addWakeup(issue.assigneeAgentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_assigned",
|
|
payload: {
|
|
issueId: issue.id,
|
|
...(comment ? { commentId: comment.id } : {}),
|
|
mutation: "update",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: issue.id,
|
|
...(comment
|
|
? {
|
|
taskId: issue.id,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
}
|
|
: {}),
|
|
source: "issue.update",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) {
|
|
addWakeup(issue.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_status_changed",
|
|
payload: {
|
|
issueId: issue.id,
|
|
mutation: "update",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: issue.id,
|
|
source: "issue.status_change",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (commentBody && comment) {
|
|
const assigneeId = issue.assigneeAgentId;
|
|
const actorIsAgent = actor.actorType === "agent";
|
|
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
|
const skipAssigneeCommentWake = selfComment || isClosed;
|
|
|
|
if (assigneeId && !assigneeChanged && (reopened || !skipAssigneeCommentWake)) {
|
|
addWakeup(assigneeId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: reopened ? "issue_reopened_via_comment" : "issue_commented",
|
|
payload: {
|
|
issueId: id,
|
|
commentId: comment.id,
|
|
mutation: "comment",
|
|
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: id,
|
|
taskId: id,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
source: reopened ? "issue.comment.reopen" : "issue.comment",
|
|
wakeReason: reopened ? "issue_reopened_via_comment" : "issue_commented",
|
|
...(reopened ? { reopenedFrom: reopenFromStatus } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
let mentionedIds: string[] = [];
|
|
try {
|
|
mentionedIds = await svc.findMentionedAgents(issue.companyId, commentBody);
|
|
} catch (err) {
|
|
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
|
|
}
|
|
|
|
for (const mentionedId of mentionedIds) {
|
|
if (actor.actorType === "agent" && actor.actorId === mentionedId) continue;
|
|
addWakeup(mentionedId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_comment_mentioned",
|
|
payload: { issueId: id, commentId: comment.id },
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: id,
|
|
taskId: id,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
wakeReason: "issue_comment_mentioned",
|
|
source: "comment.mention",
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const becameDone = existing.status !== "done" && issue.status === "done";
|
|
if (becameDone) {
|
|
const dependents = await svc.listWakeableBlockedDependents(issue.id);
|
|
for (const dependent of dependents) {
|
|
addWakeup(dependent.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_blockers_resolved",
|
|
payload: {
|
|
issueId: dependent.id,
|
|
resolvedBlockerIssueId: issue.id,
|
|
blockerIssueIds: dependent.blockerIssueIds,
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: dependent.id,
|
|
taskId: dependent.id,
|
|
wakeReason: "issue_blockers_resolved",
|
|
source: "issue.blockers_resolved",
|
|
resolvedBlockerIssueId: issue.id,
|
|
blockerIssueIds: dependent.blockerIssueIds,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const becameTerminal =
|
|
!["done", "cancelled"].includes(existing.status) && ["done", "cancelled"].includes(issue.status);
|
|
if (becameTerminal && issue.parentId) {
|
|
const parent = await svc.getWakeableParentAfterChildCompletion(issue.parentId);
|
|
if (parent) {
|
|
addWakeup(parent.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_children_completed",
|
|
payload: {
|
|
issueId: parent.id,
|
|
completedChildIssueId: issue.id,
|
|
childIssueIds: parent.childIssueIds,
|
|
childIssueSummaries: parent.childIssueSummaries,
|
|
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: parent.id,
|
|
taskId: parent.id,
|
|
wakeReason: "issue_children_completed",
|
|
source: "issue.children_completed",
|
|
completedChildIssueId: issue.id,
|
|
childIssueIds: parent.childIssueIds,
|
|
childIssueSummaries: parent.childIssueSummaries,
|
|
childIssueSummaryTruncated: parent.childIssueSummaryTruncated,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const { agentId, wakeup } of wakeups.values()) {
|
|
heartbeat
|
|
.wakeup(agentId, wakeup)
|
|
.catch((err) => logger.warn({ err, issueId: issue.id, agentId }, "failed to wake agent on issue update"));
|
|
}
|
|
})();
|
|
|
|
res.json({ ...issueResponse, comment });
|
|
});
|
|
|
|
router.delete("/issues/:id", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
|
const attachments = await svc.listAttachments(id);
|
|
|
|
const issue = await svc.remove(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
|
|
for (const attachment of attachments) {
|
|
try {
|
|
await storage.deleteObject(attachment.companyId, attachment.objectKey);
|
|
} catch (err) {
|
|
logger.warn({ err, issueId: id, attachmentId: attachment.id }, "failed to delete attachment object during issue delete");
|
|
}
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.deleted",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
});
|
|
|
|
res.json(issue);
|
|
});
|
|
|
|
router.post("/issues/:id/checkout", validate(checkoutIssueSchema), 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 (issue.projectId) {
|
|
const project = await projectsSvc.getById(issue.projectId);
|
|
if (project?.pausedAt) {
|
|
res.status(409).json({
|
|
error:
|
|
project.pauseReason === "budget"
|
|
? "Project is paused because its budget hard-stop was reached"
|
|
: "Project is paused",
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (req.actor.type === "agent" && req.actor.agentId !== req.body.agentId) {
|
|
res.status(403).json({ error: "Agent can only checkout as itself" });
|
|
return;
|
|
}
|
|
|
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
|
if (closedExecutionWorkspace) {
|
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
|
return;
|
|
}
|
|
|
|
const checkoutRunId = requireAgentRunId(req, res);
|
|
if (req.actor.type === "agent" && !checkoutRunId) return;
|
|
const updated = await svc.checkout(id, req.body.agentId, req.body.expectedStatuses, checkoutRunId);
|
|
const actor = getActorInfo(req);
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.checked_out",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: { agentId: req.body.agentId },
|
|
});
|
|
|
|
if (
|
|
shouldWakeAssigneeOnCheckout({
|
|
actorType: req.actor.type,
|
|
actorAgentId: req.actor.type === "agent" ? req.actor.agentId ?? null : null,
|
|
checkoutAgentId: req.body.agentId,
|
|
checkoutRunId,
|
|
})
|
|
) {
|
|
void heartbeat
|
|
.wakeup(req.body.agentId, {
|
|
source: "assignment",
|
|
triggerDetail: "system",
|
|
reason: "issue_checked_out",
|
|
payload: { issueId: issue.id, mutation: "checkout" },
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: { issueId: issue.id, source: "issue.checkout" },
|
|
})
|
|
.catch((err) => logger.warn({ err, issueId: issue.id }, "failed to wake assignee on issue checkout"));
|
|
}
|
|
|
|
res.json(updated);
|
|
});
|
|
|
|
router.post("/issues/:id/release", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const existing = await svc.getById(id);
|
|
if (!existing) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, existing.companyId);
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
|
|
const actorRunId = requireAgentRunId(req, res);
|
|
if (req.actor.type === "agent" && !actorRunId) return;
|
|
|
|
const released = await svc.release(
|
|
id,
|
|
req.actor.type === "agent" ? req.actor.agentId : undefined,
|
|
actorRunId,
|
|
);
|
|
if (!released) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: released.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.released",
|
|
entityType: "issue",
|
|
entityId: released.id,
|
|
});
|
|
|
|
res.json(released);
|
|
});
|
|
|
|
router.get("/issues/:id/comments", 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 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);
|
|
});
|
|
|
|
router.get("/issues/:id/comments/:commentId", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const commentId = req.params.commentId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
const comment = await svc.getComment(commentId);
|
|
if (!comment || comment.issueId !== id) {
|
|
res.status(404).json({ error: "Comment not found" });
|
|
return;
|
|
}
|
|
res.json(comment);
|
|
});
|
|
|
|
router.delete("/issues/:id/comments/:commentId", async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const commentId = req.params.commentId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
|
|
const comment = await svc.getComment(commentId);
|
|
if (!comment || comment.issueId !== id) {
|
|
res.status(404).json({ error: "Comment not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const actorOwnsComment =
|
|
actor.actorType === "agent"
|
|
? comment.authorAgentId === actor.agentId
|
|
: comment.authorUserId === actor.actorId;
|
|
if (!actorOwnsComment) {
|
|
res.status(403).json({ error: "Only the comment author can cancel queued comments" });
|
|
return;
|
|
}
|
|
|
|
const activeRun = await resolveActiveIssueRun(issue);
|
|
if (!activeRun) {
|
|
res.status(409).json({ error: "Queued comment can no longer be canceled" });
|
|
return;
|
|
}
|
|
|
|
if (!isQueuedIssueCommentForActiveRun({ comment, activeRun })) {
|
|
res.status(409).json({ error: "Only queued comments can be canceled" });
|
|
return;
|
|
}
|
|
|
|
const removed = await svc.removeComment(commentId);
|
|
if (!removed) {
|
|
res.status(404).json({ error: "Comment not found" });
|
|
return;
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.comment_cancelled",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
commentId: removed.id,
|
|
bodySnippet: removed.body.slice(0, 120),
|
|
identifier: issue.identifier,
|
|
issueTitle: issue.title,
|
|
source: "queue_cancel",
|
|
queueTargetRunId: activeRun.id,
|
|
},
|
|
});
|
|
|
|
res.json(removed);
|
|
});
|
|
|
|
router.get("/issues/:id/feedback-votes", 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: "Only board users can view feedback votes" });
|
|
return;
|
|
}
|
|
|
|
const votes = await feedback.listIssueVotesForUser(id, req.actor.userId ?? "local-board");
|
|
res.json(votes);
|
|
});
|
|
|
|
router.get("/issues/:id/feedback-traces", 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: "Only board users can view feedback traces" });
|
|
return;
|
|
}
|
|
|
|
const targetTypeRaw = typeof req.query.targetType === "string" ? req.query.targetType : undefined;
|
|
const voteRaw = typeof req.query.vote === "string" ? req.query.vote : undefined;
|
|
const statusRaw = typeof req.query.status === "string" ? req.query.status : undefined;
|
|
const targetType = targetTypeRaw ? feedbackTargetTypeSchema.parse(targetTypeRaw) : undefined;
|
|
const vote = voteRaw ? feedbackVoteValueSchema.parse(voteRaw) : undefined;
|
|
const status = statusRaw ? feedbackTraceStatusSchema.parse(statusRaw) : undefined;
|
|
|
|
const traces = await feedback.listFeedbackTraces({
|
|
companyId: issue.companyId,
|
|
issueId: issue.id,
|
|
targetType,
|
|
vote,
|
|
status,
|
|
from: parseDateQuery(req.query.from, "from"),
|
|
to: parseDateQuery(req.query.to, "to"),
|
|
sharedOnly: parseBooleanQuery(req.query.sharedOnly),
|
|
includePayload: parseBooleanQuery(req.query.includePayload),
|
|
});
|
|
res.json(traces);
|
|
});
|
|
|
|
router.get("/feedback-traces/:traceId", async (req, res) => {
|
|
const traceId = req.params.traceId as string;
|
|
if (req.actor.type !== "board") {
|
|
res.status(403).json({ error: "Only board users can view feedback traces" });
|
|
return;
|
|
}
|
|
const includePayload = parseBooleanQuery(req.query.includePayload) || req.query.includePayload === undefined;
|
|
const trace = await feedback.getFeedbackTraceById(traceId, includePayload);
|
|
if (!trace || !actorCanAccessCompany(req, trace.companyId)) {
|
|
res.status(404).json({ error: "Feedback trace not found" });
|
|
return;
|
|
}
|
|
res.json(trace);
|
|
});
|
|
|
|
router.get("/feedback-traces/:traceId/bundle", async (req, res) => {
|
|
const traceId = req.params.traceId as string;
|
|
if (req.actor.type !== "board") {
|
|
res.status(403).json({ error: "Only board users can view feedback trace bundles" });
|
|
return;
|
|
}
|
|
const bundle = await feedback.getFeedbackTraceBundle(traceId);
|
|
if (!bundle || !actorCanAccessCompany(req, bundle.companyId)) {
|
|
res.status(404).json({ error: "Feedback trace not found" });
|
|
return;
|
|
}
|
|
res.json(bundle);
|
|
});
|
|
|
|
router.post("/issues/:id/comments", validate(addIssueCommentSchema), 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 (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
|
if (closedExecutionWorkspace) {
|
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const reopenRequested = req.body.reopen === true;
|
|
const interruptRequested = req.body.interrupt === true;
|
|
const isClosed = isClosedIssueStatus(issue.status);
|
|
const effectiveReopenRequested =
|
|
reopenRequested ||
|
|
shouldImplicitlyReopenCommentForAgent({
|
|
issueStatus: issue.status,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
});
|
|
let reopened = false;
|
|
let reopenFromStatus: string | null = null;
|
|
let interruptedRunId: string | null = null;
|
|
let currentIssue = issue;
|
|
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
|
|
if (effectiveReopenRequested && isClosed) {
|
|
const reopenedIssue = await svc.update(id, { status: "todo" });
|
|
if (!reopenedIssue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
reopened = true;
|
|
reopenFromStatus = issue.status;
|
|
currentIssue = reopenedIssue;
|
|
|
|
await logActivity(db, {
|
|
companyId: currentIssue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: currentIssue.id,
|
|
details: {
|
|
status: "todo",
|
|
reopened: true,
|
|
reopenedFrom: reopenFromStatus,
|
|
source: "comment",
|
|
identifier: currentIssue.identifier,
|
|
},
|
|
});
|
|
}
|
|
|
|
if (interruptRequested) {
|
|
if (req.actor.type !== "board") {
|
|
res.status(403).json({ error: "Only board users can interrupt active runs from issue comments" });
|
|
return;
|
|
}
|
|
|
|
const runToInterrupt = await resolveActiveIssueRun(currentIssue);
|
|
if (runToInterrupt) {
|
|
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,
|
|
runId: actor.runId,
|
|
});
|
|
await issueReferencesSvc.syncComment(comment.id);
|
|
const commentReferenceSummaryAfter = await issueReferencesSvc.listIssueReferenceSummary(currentIssue.id);
|
|
const commentReferenceDiff = issueReferencesSvc.diffIssueReferenceSummary(
|
|
commentReferenceSummaryBefore,
|
|
commentReferenceSummaryAfter,
|
|
);
|
|
|
|
if (actor.runId) {
|
|
await heartbeat.reportRunActivity(actor.runId).catch((err) =>
|
|
logger.warn({ err, runId: actor.runId }, "failed to clear detached run warning after issue comment"));
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: currentIssue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.comment_added",
|
|
entityType: "issue",
|
|
entityId: currentIssue.id,
|
|
details: {
|
|
commentId: comment.id,
|
|
bodySnippet: comment.body.slice(0, 120),
|
|
identifier: currentIssue.identifier,
|
|
issueTitle: currentIssue.title,
|
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus, source: "comment" } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: commentReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: commentReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: commentReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
|
|
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
|
|
void (async () => {
|
|
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();
|
|
const assigneeId = currentIssue.assigneeAgentId;
|
|
const actorIsAgent = actor.actorType === "agent";
|
|
const selfComment = actorIsAgent && actor.actorId === assigneeId;
|
|
const skipWake = selfComment || isClosed;
|
|
if (assigneeId && (reopened || !skipWake)) {
|
|
if (reopened) {
|
|
wakeups.set(assigneeId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_reopened_via_comment",
|
|
payload: {
|
|
issueId: currentIssue.id,
|
|
commentId: comment.id,
|
|
reopenedFrom: reopenFromStatus,
|
|
mutation: "comment",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: currentIssue.id,
|
|
taskId: currentIssue.id,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
source: "issue.comment.reopen",
|
|
wakeReason: "issue_reopened_via_comment",
|
|
reopenedFrom: reopenFromStatus,
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
} else {
|
|
wakeups.set(assigneeId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: currentIssue.id,
|
|
commentId: comment.id,
|
|
mutation: "comment",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: currentIssue.id,
|
|
taskId: currentIssue.id,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
source: "issue.comment",
|
|
wakeReason: "issue_commented",
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
let mentionedIds: string[] = [];
|
|
try {
|
|
mentionedIds = await svc.findMentionedAgents(issue.companyId, req.body.body);
|
|
} catch (err) {
|
|
logger.warn({ err, issueId: id }, "failed to resolve @-mentions");
|
|
}
|
|
|
|
for (const mentionedId of mentionedIds) {
|
|
if (wakeups.has(mentionedId)) continue;
|
|
if (actorIsAgent && actor.actorId === mentionedId) continue;
|
|
wakeups.set(mentionedId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_comment_mentioned",
|
|
payload: { issueId: id, commentId: comment.id },
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: id,
|
|
taskId: id,
|
|
commentId: comment.id,
|
|
wakeCommentId: comment.id,
|
|
wakeReason: "issue_comment_mentioned",
|
|
source: "comment.mention",
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const [agentId, wakeup] of wakeups.entries()) {
|
|
heartbeat
|
|
.wakeup(agentId, wakeup)
|
|
.catch((err) => logger.warn({ err, issueId: currentIssue.id, agentId }, "failed to wake agent on issue comment"));
|
|
}
|
|
})();
|
|
|
|
res.status(201).json(comment);
|
|
});
|
|
|
|
router.post("/issues/:id/feedback-votes", validate(upsertIssueFeedbackVoteSchema), 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: "Only board users can vote on AI feedback" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const result = await feedback.saveIssueVote({
|
|
issueId: id,
|
|
targetType: req.body.targetType,
|
|
targetId: req.body.targetId,
|
|
vote: req.body.vote,
|
|
reason: req.body.reason,
|
|
authorUserId: req.actor.userId ?? "local-board",
|
|
allowSharing: req.body.allowSharing === true,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.feedback_vote_saved",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
targetType: result.vote.targetType,
|
|
targetId: result.vote.targetId,
|
|
vote: result.vote.vote,
|
|
hasReason: Boolean(result.vote.reason),
|
|
sharingEnabled: result.sharingEnabled,
|
|
},
|
|
});
|
|
|
|
if (result.consentEnabledNow) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "company.feedback_data_sharing_updated",
|
|
entityType: "company",
|
|
entityId: issue.companyId,
|
|
details: {
|
|
feedbackDataSharingEnabled: true,
|
|
source: "issue_feedback_vote",
|
|
},
|
|
});
|
|
}
|
|
|
|
if (result.persistedSharingPreference) {
|
|
const settings = await instanceSettings.get();
|
|
const companyIds = await instanceSettings.listCompanyIds();
|
|
await Promise.all(
|
|
companyIds.map((companyId) =>
|
|
logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "instance.settings.general_updated",
|
|
entityType: "instance_settings",
|
|
entityId: settings.id,
|
|
details: {
|
|
general: settings.general,
|
|
changedKeys: ["feedbackDataSharingPreference"],
|
|
source: "issue_feedback_vote",
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (result.sharingEnabled && result.traceId && feedbackExportService) {
|
|
try {
|
|
await feedbackExportService.flushPendingFeedbackTraces({
|
|
companyId: issue.companyId,
|
|
traceId: result.traceId,
|
|
limit: 1,
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err, issueId: issue.id, traceId: result.traceId }, "failed to flush shared feedback trace immediately");
|
|
}
|
|
}
|
|
|
|
res.status(201).json(result.vote);
|
|
});
|
|
|
|
router.get("/issues/:id/attachments", async (req, res) => {
|
|
const issueId = req.params.id as string;
|
|
const issue = await svc.getById(issueId);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
const attachments = await svc.listAttachments(issueId);
|
|
res.json(attachments.map(withContentPath));
|
|
});
|
|
|
|
router.post("/companies/:companyId/issues/:issueId/attachments", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
const issueId = req.params.issueId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const issue = await svc.getById(issueId);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
if (issue.companyId !== companyId) {
|
|
res.status(422).json({ error: "Issue does not belong to company" });
|
|
return;
|
|
}
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
|
|
try {
|
|
await runSingleFileUpload(req, res);
|
|
} catch (err) {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
res.status(422).json({ error: `Attachment exceeds ${MAX_ATTACHMENT_BYTES} bytes` });
|
|
return;
|
|
}
|
|
res.status(400).json({ error: err.message });
|
|
return;
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
const file = (req as Request & { file?: { mimetype: string; buffer: Buffer; originalname: string } }).file;
|
|
if (!file) {
|
|
res.status(400).json({ error: "Missing file field 'file'" });
|
|
return;
|
|
}
|
|
const contentType = normalizeContentType(file.mimetype);
|
|
if (file.buffer.length <= 0) {
|
|
res.status(422).json({ error: "Attachment is empty" });
|
|
return;
|
|
}
|
|
|
|
const parsedMeta = createIssueAttachmentMetadataSchema.safeParse(req.body ?? {});
|
|
if (!parsedMeta.success) {
|
|
res.status(400).json({ error: "Invalid attachment metadata", details: parsedMeta.error.issues });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const stored = await storage.putFile({
|
|
companyId,
|
|
namespace: `issues/${issueId}`,
|
|
originalFilename: file.originalname || null,
|
|
contentType,
|
|
body: file.buffer,
|
|
});
|
|
|
|
const attachment = await svc.createAttachment({
|
|
issueId,
|
|
issueCommentId: parsedMeta.data.issueCommentId ?? null,
|
|
provider: stored.provider,
|
|
objectKey: stored.objectKey,
|
|
contentType: stored.contentType,
|
|
byteSize: stored.byteSize,
|
|
sha256: stored.sha256,
|
|
originalFilename: stored.originalFilename,
|
|
createdByAgentId: actor.agentId,
|
|
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.attachment_added",
|
|
entityType: "issue",
|
|
entityId: issueId,
|
|
details: {
|
|
attachmentId: attachment.id,
|
|
originalFilename: attachment.originalFilename,
|
|
contentType: attachment.contentType,
|
|
byteSize: attachment.byteSize,
|
|
},
|
|
});
|
|
|
|
res.status(201).json(withContentPath(attachment));
|
|
});
|
|
|
|
router.get("/attachments/:attachmentId/content", async (req, res, next) => {
|
|
const attachmentId = req.params.attachmentId as string;
|
|
const attachment = await svc.getAttachmentById(attachmentId);
|
|
if (!attachment) {
|
|
res.status(404).json({ error: "Attachment not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, attachment.companyId);
|
|
|
|
const object = await storage.getObject(attachment.companyId, attachment.objectKey);
|
|
const responseContentType = normalizeContentType(attachment.contentType || object.contentType);
|
|
res.setHeader("Content-Type", responseContentType);
|
|
res.setHeader("Content-Length", String(attachment.byteSize || object.contentLength || 0));
|
|
res.setHeader("Cache-Control", "private, max-age=60");
|
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
if (responseContentType === SVG_CONTENT_TYPE) {
|
|
res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'");
|
|
}
|
|
const filename = attachment.originalFilename ?? "attachment";
|
|
const disposition = isInlineAttachmentContentType(responseContentType) ? "inline" : "attachment";
|
|
res.setHeader("Content-Disposition", `${disposition}; filename=\"${filename.replaceAll("\"", "")}\"`);
|
|
|
|
object.stream.on("error", (err) => {
|
|
next(err);
|
|
});
|
|
object.stream.pipe(res);
|
|
});
|
|
|
|
router.delete("/attachments/:attachmentId", async (req, res) => {
|
|
const attachmentId = req.params.attachmentId as string;
|
|
const attachment = await svc.getAttachmentById(attachmentId);
|
|
if (!attachment) {
|
|
res.status(404).json({ error: "Attachment not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, attachment.companyId);
|
|
const issue = await svc.getById(attachment.issueId);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
|
|
try {
|
|
await storage.deleteObject(attachment.companyId, attachment.objectKey);
|
|
} catch (err) {
|
|
logger.warn({ err, attachmentId }, "storage delete failed while removing attachment");
|
|
}
|
|
|
|
const removed = await svc.removeAttachment(attachmentId);
|
|
if (!removed) {
|
|
res.status(404).json({ error: "Attachment not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: removed.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.attachment_removed",
|
|
entityType: "issue",
|
|
entityId: removed.issueId,
|
|
details: {
|
|
attachmentId: removed.id,
|
|
},
|
|
});
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
return router;
|
|
}
|