bfe6369ef5
## Thinking Path > - Paperclip is the control plane that coordinates AI-agent work through issues, heartbeats, comments, approvals, and auditable recovery paths. > - The affected subsystem is heartbeat/recovery orchestration, especially the optional cheap model profile used for operational recovery overhead. > - Cheap recovery should repair status and liveness, but it must not become the worker lane that writes deliverables, continues source work, or propagates cheap execution hints into downstream retries. > - The gap was that cheap-profile hints could follow recovery wake contexts and assignment overrides farther than intended, making real work eligible to run on the cheap model. > - This pull request separates status-only cheap recovery from normal source-work continuations, adds route guards for deliverable mutations during cheap status-only runs, and documents the invariant. > - The benefit is safer retry/recovery behavior: cheap runs can clean up control-plane state, while any remaining source work resumes through a normal/original model path. ## What Changed - Added recovery model-profile work classes so status-only recovery carries explicit guard context and normal-model continuations scrub cheap hints. - Updated heartbeat, productivity review, liveness continuation, and recovery service wakeups to request cheap only for bounded status-only recovery work. - Blocked cheap status-only recovery runs from writing issue documents, plans, attachments, work products, or assigning downstream work back to `modelProfile: "cheap"`. - Added/updated server tests for cheap profile propagation, artifact/document guards, route authorization, retry scheduling, and successful-run handoff behavior. - Documented the recovery model-profile lane in `doc/SPEC-implementation.md` and `doc/execution-semantics.md`. - After rebasing onto current `public-gh/master`, stabilized the new `InstanceSidebar` plugin-filter tests so the PR check lane stays green. ## Verification - Local: `pnpm exec vitest run --config vitest.config.ts src/services/recovery/model-profile-hint.test.ts src/__tests__/issue-agent-mutation-ownership-routes.test.ts src/__tests__/issue-document-restore-routes.test.ts` from `server/` - 3 files, 37 tests passed after final edits. - Local: `pnpm exec vitest run --config vitest.config.ts src/__tests__/heartbeat-process-recovery.test.ts` from `server/` - 44 tests passed after rerunning the cleanup-sensitive file alone. - Local: `pnpm --filter @paperclipai/ui exec vitest run src/components/InstanceSidebar.test.tsx` - 4 tests passed. - Local: `pnpm --filter @paperclipai/server typecheck` - passed. - Local: `pnpm --filter @paperclipai/ui typecheck` - passed. - PR checks on latest head `6f8c3b1380f5bd872c6f49f6f7188ecf3bb6d263` - all green, including `verify`, build, typecheck, server/general/serialized tests, e2e, Snyk, and policy. - Greptile: pass 3 returned Confidence Score 5/5 with zero unresolved Greptile review threads. ## Risks - Medium risk: recovery behavior is intentionally stricter, so any path that incorrectly relies on cheap recovery to keep doing source work will now need to hand back to a normal-model run. - Low migration risk: no schema changes. - No product UI changes; the UI file touched is a test-only stabilization after rebasing onto current `master`. > 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 coding agent, GPT-5 model family (`gpt-5`), tool use and local code execution enabled; context window not exposed in this environment. ## 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 (N/A: no product UI changes) - [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
5508 lines
198 KiB
TypeScript
5508 lines
198 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { Router, type Request, type Response } from "express";
|
|
import multer from "multer";
|
|
import { z } from "zod";
|
|
import { and, desc, eq, inArray, notInArray } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import {
|
|
activityLog,
|
|
executionWorkspaces,
|
|
heartbeatRuns,
|
|
issueExecutionDecisions,
|
|
issueRelations,
|
|
issues as issueRows,
|
|
projectWorkspaces,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
addIssueCommentSchema,
|
|
acceptIssueThreadInteractionSchema,
|
|
cancelIssueThreadInteractionSchema,
|
|
companySearchQuerySchema,
|
|
createIssueAttachmentMetadataSchema,
|
|
createIssueThreadInteractionSchema,
|
|
createIssueWorkProductSchema,
|
|
createIssueLabelSchema,
|
|
checkoutIssueSchema,
|
|
createChildIssueSchema,
|
|
createIssueSchema,
|
|
resolveCreateIssueStatusDefault,
|
|
resolveIssueRecoveryActionSchema,
|
|
feedbackTargetTypeSchema,
|
|
feedbackTraceStatusSchema,
|
|
feedbackVoteValueSchema,
|
|
upsertIssueFeedbackVoteSchema,
|
|
linkIssueApprovalSchema,
|
|
issueDocumentKeySchema,
|
|
ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY,
|
|
rejectIssueThreadInteractionSchema,
|
|
restoreIssueDocumentRevisionSchema,
|
|
respondIssueThreadInteractionSchema,
|
|
updateIssueWorkProductSchema,
|
|
upsertIssueDocumentSchema,
|
|
updateIssueSchema,
|
|
getClosedIsolatedExecutionWorkspaceMessage,
|
|
isClosedIsolatedExecutionWorkspace,
|
|
normalizeIssueIdentifier as normalizeIssueReferenceIdentifier,
|
|
type CompanySearchQuery,
|
|
type CompanySearchResponse,
|
|
type ExecutionWorkspace,
|
|
type IssueRelationIssueSummary,
|
|
type SuccessfulRunHandoffState,
|
|
} 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 * as serviceIndex from "../services/index.js";
|
|
import {
|
|
accessService,
|
|
agentService,
|
|
companyService,
|
|
companySearchService,
|
|
executionWorkspaceService,
|
|
goalService,
|
|
heartbeatService,
|
|
issueApprovalService,
|
|
issueRecoveryActionService,
|
|
issueThreadInteractionService,
|
|
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, unprocessable } from "../errors.js";
|
|
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
|
import {
|
|
assertNoAgentHostWorkspaceCommandMutation,
|
|
collectIssueWorkspaceCommandPaths,
|
|
} from "./workspace-command-authz.js";
|
|
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
|
import {
|
|
isInlineAttachmentContentType,
|
|
normalizeIssueAttachmentMaxBytes,
|
|
normalizeContentType,
|
|
SVG_CONTENT_TYPE,
|
|
} from "../attachment-types.js";
|
|
import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js";
|
|
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
|
|
import { executionWorkspaceService as executionWorkspaceServiceDirect } from "../services/execution-workspaces.js";
|
|
import { feedbackService } from "../services/feedback.js";
|
|
import { instanceSettingsService } from "../services/instance-settings.js";
|
|
import { environmentService } from "../services/environments.js";
|
|
import { redactSensitiveText } from "../redaction.js";
|
|
import {
|
|
createCompanySearchRateLimiter,
|
|
type CompanySearchRateLimiter,
|
|
} from "../services/company-search-rate-limit.js";
|
|
import {
|
|
applyIssueExecutionPolicyTransition,
|
|
normalizeIssueExecutionPolicy,
|
|
parseIssueExecutionState,
|
|
redactIssueMonitorExternalRef,
|
|
setIssueExecutionPolicyMonitorScheduledBy,
|
|
} from "../services/issue-execution-policy.js";
|
|
import { parseIssueExecutionWorkspaceSettings } from "../services/execution-workspace-policy.js";
|
|
import type { PluginWorkerManager } from "../services/plugin-worker-manager.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 IssueRouteSnapshot = typeof issueRows.$inferSelect;
|
|
type RecoveryRevalidationTrigger =
|
|
| "issue_update"
|
|
| "comment"
|
|
| "document"
|
|
| "work_product"
|
|
| "read_projection";
|
|
type CompanySearchService = {
|
|
search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>;
|
|
};
|
|
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"];
|
|
reviewRequest: ParsedExecutionState["reviewRequest"];
|
|
lastDecisionOutcome: ParsedExecutionState["lastDecisionOutcome"];
|
|
allowedActions: string[];
|
|
};
|
|
type SuccessfulRunHandoffActivityRow = {
|
|
entityId: string;
|
|
action: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
details: Record<string, unknown> | null;
|
|
createdAt: Date;
|
|
};
|
|
|
|
function applyCreateIssueStatusDefault(req: Request, res: Response, next: () => void) {
|
|
if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const resolution = resolveCreateIssueStatusDefault(req.body as Record<string, unknown>);
|
|
res.locals.createIssueStatusDefault = resolution;
|
|
if (resolution.defaulted) {
|
|
req.body = {
|
|
...req.body,
|
|
status: resolution.status,
|
|
};
|
|
}
|
|
next();
|
|
}
|
|
|
|
function buildCreateIssueActivityStatusDetails(
|
|
issue: { assigneeAgentId: string | null; status: string },
|
|
res: Response,
|
|
) {
|
|
const statusDefault = res.locals.createIssueStatusDefault as
|
|
| ReturnType<typeof resolveCreateIssueStatusDefault>
|
|
| undefined;
|
|
const assignmentWakeSkipped = !issue.assigneeAgentId || issue.status === "backlog";
|
|
return {
|
|
status: issue.status,
|
|
statusDefaulted: statusDefault?.defaulted ?? false,
|
|
statusDefaultReason: statusDefault?.reason ?? "explicit",
|
|
assignmentWakeSkipped,
|
|
assignmentWakeSkipReason: assignmentWakeSkipped
|
|
? issue.assigneeAgentId
|
|
? "assigned_backlog"
|
|
: "no_agent_assignee"
|
|
: null,
|
|
};
|
|
}
|
|
|
|
const SUCCESSFUL_RUN_HANDOFF_ACTIONS = [
|
|
"issue.successful_run_handoff_required",
|
|
"issue.successful_run_handoff_resolved",
|
|
"issue.successful_run_handoff_escalated",
|
|
] as const;
|
|
|
|
const ISSUE_WORKSPACE_AUDIT_FIELDS = new Set([
|
|
"projectWorkspaceId",
|
|
"executionWorkspaceId",
|
|
"executionWorkspacePreference",
|
|
"executionWorkspaceSettings",
|
|
]);
|
|
|
|
function readNonEmptyString(value: unknown): string | null {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function hasIssueWorkspaceAuditChange(previous: Record<string, unknown>) {
|
|
return Object.keys(previous).some((key) => ISSUE_WORKSPACE_AUDIT_FIELDS.has(key));
|
|
}
|
|
|
|
function labelIssueWorkspaceMode(mode: string | null) {
|
|
switch (mode) {
|
|
case "shared_workspace":
|
|
return "Project default";
|
|
case "isolated_workspace":
|
|
return "New isolated workspace";
|
|
case "operator_branch":
|
|
return "Operator branch";
|
|
case "reuse_existing":
|
|
return "Reuse existing workspace";
|
|
case "agent_default":
|
|
return "Agent default";
|
|
case "inherit":
|
|
return "Inherited workspace";
|
|
default:
|
|
return "No workspace";
|
|
}
|
|
}
|
|
|
|
type IssueWorkspaceAuditInput = {
|
|
projectWorkspaceId?: string | null;
|
|
executionWorkspaceId?: string | null;
|
|
executionWorkspacePreference?: string | null;
|
|
executionWorkspaceSettings?: unknown;
|
|
};
|
|
|
|
type WorkspaceNameMaps = {
|
|
projectWorkspaceNames: Map<string, string>;
|
|
executionWorkspaceNames: Map<string, string>;
|
|
};
|
|
|
|
function emptyWorkspaceNameMaps(): WorkspaceNameMaps {
|
|
return {
|
|
projectWorkspaceNames: new Map(),
|
|
executionWorkspaceNames: new Map(),
|
|
};
|
|
}
|
|
|
|
function summarizeIssueWorkspaceForActivity(
|
|
issue: IssueWorkspaceAuditInput,
|
|
names: WorkspaceNameMaps,
|
|
) {
|
|
const settings = parseIssueExecutionWorkspaceSettings(issue.executionWorkspaceSettings);
|
|
const mode = settings?.mode ?? issue.executionWorkspacePreference ?? null;
|
|
const executionWorkspaceId = issue.executionWorkspaceId ?? null;
|
|
const projectWorkspaceId = issue.projectWorkspaceId ?? null;
|
|
|
|
const label = (() => {
|
|
if (executionWorkspaceId) {
|
|
return names.executionWorkspaceNames.get(executionWorkspaceId) ?? `Workspace ${executionWorkspaceId.slice(0, 8)}`;
|
|
}
|
|
if (projectWorkspaceId) {
|
|
return names.projectWorkspaceNames.get(projectWorkspaceId) ?? `Workspace ${projectWorkspaceId.slice(0, 8)}`;
|
|
}
|
|
return labelIssueWorkspaceMode(mode);
|
|
})();
|
|
|
|
return {
|
|
label,
|
|
projectWorkspaceId,
|
|
executionWorkspaceId,
|
|
mode,
|
|
};
|
|
}
|
|
|
|
async function buildIssueWorkspaceChangeActivityDetails(
|
|
db: Db,
|
|
companyId: string,
|
|
previousIssue: IssueWorkspaceAuditInput,
|
|
nextIssue: IssueWorkspaceAuditInput,
|
|
) {
|
|
const projectWorkspaceIds = [
|
|
previousIssue.projectWorkspaceId,
|
|
nextIssue.projectWorkspaceId,
|
|
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
const executionWorkspaceIds = [
|
|
previousIssue.executionWorkspaceId,
|
|
nextIssue.executionWorkspaceId,
|
|
].filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
|
|
const [projectRows, executionRows] = await Promise.all([
|
|
projectWorkspaceIds.length > 0
|
|
? db
|
|
.select({ id: projectWorkspaces.id, name: projectWorkspaces.name })
|
|
.from(projectWorkspaces)
|
|
.where(and(eq(projectWorkspaces.companyId, companyId), inArray(projectWorkspaces.id, projectWorkspaceIds)))
|
|
: Promise.resolve([]),
|
|
executionWorkspaceIds.length > 0
|
|
? db
|
|
.select({ id: executionWorkspaces.id, name: executionWorkspaces.name })
|
|
.from(executionWorkspaces)
|
|
.where(and(eq(executionWorkspaces.companyId, companyId), inArray(executionWorkspaces.id, executionWorkspaceIds)))
|
|
: Promise.resolve([]),
|
|
]);
|
|
|
|
const names: WorkspaceNameMaps = {
|
|
projectWorkspaceNames: new Map(projectRows.map((row) => [row.id, row.name])),
|
|
executionWorkspaceNames: new Map(executionRows.map((row) => [row.id, row.name])),
|
|
};
|
|
|
|
return {
|
|
from: summarizeIssueWorkspaceForActivity(previousIssue, names),
|
|
to: summarizeIssueWorkspaceForActivity(nextIssue, names),
|
|
};
|
|
}
|
|
|
|
function hasExecutionParticipant(value: unknown) {
|
|
const state = parseIssueExecutionState(value);
|
|
if (!state || state.status !== "pending") return false;
|
|
const participant = state.currentParticipant;
|
|
if (!participant) return false;
|
|
if (participant.type === "agent") return Boolean(participant.agentId);
|
|
if (participant.type === "user") return Boolean(participant.userId);
|
|
return false;
|
|
}
|
|
|
|
function hasScheduledMonitor(input: {
|
|
existingMonitorNextCheckAt?: Date | null;
|
|
patchMonitorNextCheckAt?: unknown;
|
|
executionPolicy?: unknown;
|
|
}) {
|
|
if (input.patchMonitorNextCheckAt instanceof Date && !Number.isNaN(input.patchMonitorNextCheckAt.getTime())) return true;
|
|
if (input.patchMonitorNextCheckAt === undefined && input.existingMonitorNextCheckAt) return true;
|
|
const policy = normalizeIssueExecutionPolicy(input.executionPolicy ?? null);
|
|
return Boolean(policy?.monitor?.nextCheckAt);
|
|
}
|
|
|
|
function successfulRunHandoffStateFromActivity(row: {
|
|
action: string;
|
|
agentId: string | null;
|
|
runId: string | null;
|
|
details: Record<string, unknown> | null;
|
|
createdAt: Date;
|
|
}): SuccessfulRunHandoffState | null {
|
|
const details = row.details ?? {};
|
|
const state =
|
|
row.action === "issue.successful_run_handoff_required"
|
|
? "required"
|
|
: row.action === "issue.successful_run_handoff_resolved"
|
|
? "resolved"
|
|
: row.action === "issue.successful_run_handoff_escalated"
|
|
? "escalated"
|
|
: null;
|
|
if (!state) return null;
|
|
|
|
const detectedProgressSummary =
|
|
readNonEmptyString(details.detectedProgressSummary)
|
|
?? readNonEmptyString(details.detected_progress_summary)
|
|
?? null;
|
|
|
|
return {
|
|
state,
|
|
required: state === "required",
|
|
sourceRunId:
|
|
readNonEmptyString(details.sourceRunId)
|
|
?? readNonEmptyString(details.source_run_id)
|
|
?? readNonEmptyString(details.resumeFromRunId)
|
|
?? row.runId
|
|
?? null,
|
|
correctiveRunId:
|
|
readNonEmptyString(details.correctiveRunId)
|
|
?? readNonEmptyString(details.corrective_run_id)
|
|
?? (state !== "required" ? row.runId : null),
|
|
assigneeAgentId:
|
|
readNonEmptyString(details.assigneeAgentId)
|
|
?? readNonEmptyString(details.agentId)
|
|
?? row.agentId
|
|
?? null,
|
|
detectedProgressSummary: detectedProgressSummary
|
|
? redactSensitiveText(detectedProgressSummary)
|
|
: null,
|
|
createdAt: row.createdAt,
|
|
};
|
|
}
|
|
|
|
async function listSuccessfulRunHandoffStates(
|
|
db: Db,
|
|
companyId: string,
|
|
issueIds: string[],
|
|
): Promise<Map<string, SuccessfulRunHandoffState>> {
|
|
if (issueIds.length === 0) return new Map();
|
|
const rows = await db
|
|
.select({
|
|
entityId: activityLog.entityId,
|
|
action: activityLog.action,
|
|
agentId: activityLog.agentId,
|
|
runId: activityLog.runId,
|
|
details: activityLog.details,
|
|
createdAt: activityLog.createdAt,
|
|
})
|
|
.from(activityLog)
|
|
.where(and(
|
|
eq(activityLog.companyId, companyId),
|
|
eq(activityLog.entityType, "issue"),
|
|
inArray(activityLog.entityId, issueIds),
|
|
inArray(activityLog.action, [...SUCCESSFUL_RUN_HANDOFF_ACTIONS]),
|
|
))
|
|
.orderBy(activityLog.entityId, desc(activityLog.createdAt), desc(activityLog.id)) as SuccessfulRunHandoffActivityRow[];
|
|
|
|
const states = new Map<string, SuccessfulRunHandoffState>();
|
|
for (const row of rows) {
|
|
if (states.has(row.entityId)) continue;
|
|
const state = successfulRunHandoffStateFromActivity(row);
|
|
if (state) states.set(row.entityId, state);
|
|
}
|
|
return states;
|
|
}
|
|
|
|
type RecoveryActionsLister = {
|
|
listActiveForIssues: (
|
|
companyId: string,
|
|
sourceIssueIds: string[],
|
|
) => Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>>;
|
|
};
|
|
|
|
async function relationRecoveryActionMap(
|
|
recoveryActionsSvc: RecoveryActionsLister,
|
|
companyId: string,
|
|
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
|
): Promise<Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>> {
|
|
const candidates: IssueRelationIssueSummary[] = [];
|
|
const visit = (summary: IssueRelationIssueSummary) => {
|
|
candidates.push(summary);
|
|
for (const terminal of summary.terminalBlockers ?? []) {
|
|
visit(terminal);
|
|
}
|
|
};
|
|
for (const blocker of relations.blockedBy) visit(blocker);
|
|
for (const blocking of relations.blocks) visit(blocking);
|
|
if (candidates.length === 0) return new Map();
|
|
const ids = [...new Set(candidates.map((summary) => summary.id))];
|
|
return recoveryActionsSvc.listActiveForIssues(companyId, ids);
|
|
}
|
|
|
|
function withRecoveryActionsOnRelationSummaries(
|
|
relations: { blockedBy: IssueRelationIssueSummary[]; blocks: IssueRelationIssueSummary[] },
|
|
recoveryActionByIssueId: Map<string, NonNullable<IssueRelationIssueSummary["activeRecoveryAction"]>>,
|
|
) {
|
|
const augment = (summary: IssueRelationIssueSummary): IssueRelationIssueSummary => ({
|
|
...summary,
|
|
activeRecoveryAction: recoveryActionByIssueId.get(summary.id) ?? summary.activeRecoveryAction ?? null,
|
|
terminalBlockers: summary.terminalBlockers?.map(augment),
|
|
});
|
|
return {
|
|
blockedBy: relations.blockedBy.map(augment),
|
|
blocks: relations.blocks.map(augment),
|
|
};
|
|
}
|
|
|
|
const ACTIVE_REVIEW_APPROVAL_STATUSES = new Set(["pending", "revision_requested"]);
|
|
|
|
const INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE =
|
|
"invalid_issue_disposition: Agent-authored updates that move an issue to in_review must include a real review path. " +
|
|
"This request would leave the issue in_review without anyone or anything owning the next action. " +
|
|
"Keep working instead of moving to review, create a request_confirmation or ask_user_questions interaction, " +
|
|
"link or request a pending approval, assign a human reviewer with assigneeUserId, set a typed executionState.currentParticipant through an execution policy, " +
|
|
"or schedule an issue monitor for an external review/check. After creating one of those review paths, retry the status update.";
|
|
|
|
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,
|
|
reviewRequest: input.state.reviewRequest ?? null,
|
|
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,
|
|
};
|
|
}
|
|
|
|
const defaultCompanySearchRateLimiter = createCompanySearchRateLimiter();
|
|
|
|
function companySearchRateLimitActor(req: Request, companyId: string) {
|
|
if (req.actor.type === "agent") {
|
|
return {
|
|
companyId,
|
|
actorType: "agent" as const,
|
|
actorId: req.actor.agentId ?? req.actor.keyId ?? "unknown-agent",
|
|
};
|
|
}
|
|
return {
|
|
companyId,
|
|
actorType: "board" as const,
|
|
actorId: req.actor.userId ?? req.actor.source ?? "board",
|
|
};
|
|
}
|
|
|
|
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 monitorPoliciesEqual(left: NormalizedExecutionPolicy | null, right: NormalizedExecutionPolicy | null) {
|
|
return JSON.stringify(left?.monitor ?? null) === JSON.stringify(right?.monitor ?? null);
|
|
}
|
|
|
|
function applyActorMonitorScheduledBy(
|
|
policy: NormalizedExecutionPolicy | null,
|
|
actorType: "agent" | "user",
|
|
) {
|
|
return setIssueExecutionPolicyMonitorScheduledBy(policy, actorType === "user" ? "board" : "assignee");
|
|
}
|
|
|
|
function assertCanManageIssueMonitor(req: Request, assigneeAgentId: string | null, monitorChanged: boolean) {
|
|
if (!monitorChanged) return;
|
|
if (req.actor.type === "board") return;
|
|
if (req.actor.type === "agent" && req.actor.agentId && req.actor.agentId === assigneeAgentId) return;
|
|
throw forbidden("Only the assignee agent or a board user can manage issue monitors");
|
|
}
|
|
|
|
function summarizeIssueMonitor(
|
|
issue: {
|
|
monitorNextCheckAt?: Date | null;
|
|
monitorLastTriggeredAt?: Date | null;
|
|
monitorAttemptCount?: number | null;
|
|
monitorNotes?: string | null;
|
|
monitorScheduledBy?: string | null;
|
|
executionState?: unknown;
|
|
},
|
|
policy: NormalizedExecutionPolicy | null,
|
|
) {
|
|
const state = parseIssueExecutionState(issue.executionState);
|
|
return {
|
|
nextCheckAt: issue.monitorNextCheckAt?.toISOString() ?? policy?.monitor?.nextCheckAt ?? null,
|
|
lastTriggeredAt: issue.monitorLastTriggeredAt?.toISOString() ?? state?.monitor?.lastTriggeredAt ?? null,
|
|
attemptCount: issue.monitorAttemptCount ?? state?.monitor?.attemptCount ?? 0,
|
|
notes: policy?.monitor?.notes ?? issue.monitorNotes ?? state?.monitor?.notes ?? null,
|
|
scheduledBy: issue.monitorScheduledBy ?? policy?.monitor?.scheduledBy ?? state?.monitor?.scheduledBy ?? null,
|
|
kind: policy?.monitor?.kind ?? state?.monitor?.kind ?? null,
|
|
serviceName: policy?.monitor?.serviceName ?? state?.monitor?.serviceName ?? null,
|
|
externalRef: redactIssueMonitorExternalRef(policy?.monitor?.externalRef ?? state?.monitor?.externalRef ?? null),
|
|
timeoutAt: policy?.monitor?.timeoutAt ?? state?.monitor?.timeoutAt ?? null,
|
|
maxAttempts: policy?.monitor?.maxAttempts ?? state?.monitor?.maxAttempts ?? null,
|
|
recoveryPolicy: policy?.monitor?.recoveryPolicy ?? state?.monitor?.recoveryPolicy ?? null,
|
|
status: state?.monitor?.status ?? (policy?.monitor ? "scheduled" : null),
|
|
clearReason: state?.monitor?.clearReason ?? null,
|
|
};
|
|
}
|
|
|
|
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 shouldImplicitlyMoveCommentedIssueToTodo(input: {
|
|
issueStatus: string | null | undefined;
|
|
assigneeAgentId: string | null | undefined;
|
|
actorType: "agent" | "user";
|
|
actorId: string;
|
|
}) {
|
|
// Only human comments should implicitly reopen finished work.
|
|
// Agent-authored comments remain communicative unless reopen was explicit.
|
|
if (input.actorType !== "user") return false;
|
|
if (!isClosedIssueStatus(input.issueStatus) && input.issueStatus !== "blocked") return false;
|
|
if (typeof input.assigneeAgentId !== "string" || input.assigneeAgentId.length === 0) return false;
|
|
return true;
|
|
}
|
|
|
|
function isExplicitResumeCapableStatus(status: string | null | undefined) {
|
|
return status === "done" || status === "blocked" || status === "todo" || status === "in_progress";
|
|
}
|
|
|
|
function queueResolvedInteractionContinuationWakeup(input: {
|
|
heartbeat: ReturnType<typeof heartbeatService>;
|
|
issue: { id: string; assigneeAgentId: string | null; status: string };
|
|
interaction: {
|
|
id: string;
|
|
kind: string;
|
|
status: string;
|
|
continuationPolicy: string;
|
|
sourceCommentId?: string | null;
|
|
sourceRunId?: string | null;
|
|
};
|
|
actor: { actorType: "user" | "agent"; actorId: string };
|
|
source: string;
|
|
forceFreshSession?: boolean;
|
|
workspaceRefreshReason?: string | null;
|
|
}) {
|
|
if (
|
|
input.interaction.continuationPolicy !== "wake_assignee"
|
|
&& input.interaction.continuationPolicy !== "wake_assignee_on_accept"
|
|
) return;
|
|
if (
|
|
input.interaction.continuationPolicy === "wake_assignee_on_accept"
|
|
&& input.interaction.status !== "accepted"
|
|
) return;
|
|
if (input.interaction.status === "expired") return;
|
|
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
|
|
|
|
const forceFreshSession = input.forceFreshSession === true;
|
|
const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason);
|
|
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: input.issue.id,
|
|
interactionId: input.interaction.id,
|
|
interactionKind: input.interaction.kind,
|
|
interactionStatus: input.interaction.status,
|
|
sourceCommentId: input.interaction.sourceCommentId ?? null,
|
|
sourceRunId: input.interaction.sourceRunId ?? null,
|
|
mutation: "interaction",
|
|
},
|
|
requestedByActorType: input.actor.actorType,
|
|
requestedByActorId: input.actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: input.issue.id,
|
|
taskId: input.issue.id,
|
|
interactionId: input.interaction.id,
|
|
interactionKind: input.interaction.kind,
|
|
interactionStatus: input.interaction.status,
|
|
sourceCommentId: input.interaction.sourceCommentId ?? null,
|
|
sourceRunId: input.interaction.sourceRunId ?? null,
|
|
wakeReason: "issue_commented",
|
|
source: input.source,
|
|
...(forceFreshSession ? { forceFreshSession: true } : {}),
|
|
...(workspaceRefreshReason ? { workspaceRefreshReason } : {}),
|
|
},
|
|
}).catch((err) => logger.warn({
|
|
err,
|
|
issueId: input.issue.id,
|
|
interactionId: input.interaction.id,
|
|
agentId: input.issue.assigneeAgentId,
|
|
}, "failed to wake assignee on issue interaction resolution"));
|
|
}
|
|
|
|
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>;
|
|
};
|
|
searchService?: CompanySearchService;
|
|
searchRateLimiter?: CompanySearchRateLimiter;
|
|
pluginWorkerManager?: PluginWorkerManager;
|
|
} = {},
|
|
) {
|
|
const router = Router();
|
|
const svc = issueService(db);
|
|
const access = accessService(db);
|
|
const heartbeat = heartbeatService(db, {
|
|
pluginWorkerManager: opts.pluginWorkerManager,
|
|
});
|
|
const feedback = feedbackService(db);
|
|
const companiesSvc = companyService(db);
|
|
let searchSvc = opts.searchService ?? null;
|
|
const getSearchService = () => {
|
|
searchSvc ??= companySearchService(db);
|
|
return searchSvc;
|
|
};
|
|
const searchRateLimiter = opts.searchRateLimiter ?? defaultCompanySearchRateLimiter;
|
|
const instanceSettings = instanceSettingsService(db);
|
|
const agentsSvc = agentService(db);
|
|
const projectsSvc = projectService(db);
|
|
const goalsSvc = goalService(db);
|
|
const issueApprovalsSvc = issueApprovalService(db);
|
|
const recoveryActionsSvc = issueRecoveryActionService(db);
|
|
const executionWorkspacesSvc = executionWorkspaceServiceDirect(db);
|
|
const workProductsSvc = workProductService(db);
|
|
const documentsSvc = documentService(db);
|
|
const issueReferencesSvc = issueReferenceService(db);
|
|
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
|
|
const routinesSvc = routineService(db, {
|
|
pluginWorkerManager: opts.pluginWorkerManager,
|
|
});
|
|
const issueTreeControlFactory = Object.prototype.hasOwnProperty.call(
|
|
serviceIndex,
|
|
"issueTreeControlService",
|
|
)
|
|
? serviceIndex.issueTreeControlService
|
|
: undefined;
|
|
const treeControlSvc = issueTreeControlFactory?.(db) ?? {
|
|
getActivePauseHoldGate: async () => null,
|
|
};
|
|
const feedbackExportService = opts?.feedbackExportService;
|
|
const environmentsSvc = environmentService(db);
|
|
|
|
async function classifySourceRecoveryRevalidation(input: {
|
|
issue: IssueRouteSnapshot;
|
|
trigger: RecoveryRevalidationTrigger;
|
|
statusChanged?: boolean;
|
|
assigneeChanged?: boolean;
|
|
blockersChanged?: boolean;
|
|
executionPolicyChanged?: boolean;
|
|
monitorChanged?: boolean;
|
|
documentChanged?: boolean;
|
|
workProductChanged?: boolean;
|
|
resumeRequested?: boolean;
|
|
reopened?: boolean;
|
|
blockedToTodoRecovery?: boolean;
|
|
}): Promise<string | null> {
|
|
const { issue } = input;
|
|
if (issue.status === "done" || issue.status === "cancelled") {
|
|
return `Recovery action became stale because the source issue reached ${issue.status}.`;
|
|
}
|
|
if (input.blockedToTodoRecovery === true) {
|
|
return "Recovery action became stale because the source issue was manually moved from blocked to todo.";
|
|
}
|
|
|
|
if (input.trigger === "read_projection") return null;
|
|
if (
|
|
input.trigger === "comment" &&
|
|
input.resumeRequested !== true &&
|
|
input.reopened !== true &&
|
|
input.statusChanged !== true
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const durableSourceChange =
|
|
input.statusChanged === true ||
|
|
input.assigneeChanged === true ||
|
|
input.blockersChanged === true ||
|
|
input.executionPolicyChanged === true ||
|
|
input.monitorChanged === true ||
|
|
input.documentChanged === true ||
|
|
input.workProductChanged === true ||
|
|
input.resumeRequested === true ||
|
|
input.reopened === true;
|
|
if (!durableSourceChange) return null;
|
|
|
|
if (issue.status === "blocked") {
|
|
const readiness = await svc.getDependencyReadiness(issue.id);
|
|
if (readiness.unresolvedBlockerCount > 0) {
|
|
return "Recovery action became stale because the source issue now has unresolved first-class blockers.";
|
|
}
|
|
return null;
|
|
}
|
|
|
|
if (issue.assigneeUserId && issue.status !== "done" && issue.status !== "cancelled") {
|
|
return "Recovery action became stale because the source issue now has a human owner.";
|
|
}
|
|
|
|
if ((issue.status === "todo" || issue.status === "in_progress") && issue.assigneeAgentId) {
|
|
return `Recovery action became stale because the source issue is ${issue.status} with an agent owner.`;
|
|
}
|
|
|
|
if (issue.status === "in_review") {
|
|
const executionState = parseIssueExecutionState(issue.executionState);
|
|
const participant = executionState?.status === "pending" ? executionState.currentParticipant : null;
|
|
if (
|
|
(participant?.type === "agent" && readNonEmptyString(participant.agentId)) ||
|
|
(participant?.type === "user" && readNonEmptyString(participant.userId))
|
|
) {
|
|
return "Recovery action became stale because the source issue now has a typed review participant.";
|
|
}
|
|
|
|
const interactions = await issueThreadInteractionsSvc.listForIssue(issue.id);
|
|
if (interactions.some((interaction) => interaction.status === "pending")) {
|
|
return "Recovery action became stale because the source issue now has a pending issue interaction.";
|
|
}
|
|
|
|
const approvals = await issueApprovalsSvc.listApprovalsForIssue(issue.id);
|
|
if (approvals.some((approval) => approval.status === "pending" || approval.status === "revision_requested")) {
|
|
return "Recovery action became stale because the source issue now has a pending approval.";
|
|
}
|
|
}
|
|
|
|
const monitor = summarizeIssueMonitor(issue, normalizeIssueExecutionPolicy(issue.executionPolicy ?? null));
|
|
if (monitor.nextCheckAt && Date.parse(monitor.nextCheckAt) > Date.now()) {
|
|
return "Recovery action became stale because the source issue now has a scheduled monitor.";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function revalidateActiveSourceRecovery(input: {
|
|
issue: IssueRouteSnapshot;
|
|
trigger: RecoveryRevalidationTrigger;
|
|
actor?: ReturnType<typeof getActorInfo> | null;
|
|
activeRecoveryAction?: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>> | null;
|
|
statusChanged?: boolean;
|
|
assigneeChanged?: boolean;
|
|
blockersChanged?: boolean;
|
|
executionPolicyChanged?: boolean;
|
|
monitorChanged?: boolean;
|
|
documentChanged?: boolean;
|
|
workProductChanged?: boolean;
|
|
resumeRequested?: boolean;
|
|
reopened?: boolean;
|
|
blockedToTodoRecovery?: boolean;
|
|
}) {
|
|
const activeRecoveryAction =
|
|
input.activeRecoveryAction === undefined
|
|
? await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id)
|
|
: input.activeRecoveryAction;
|
|
if (!activeRecoveryAction) return null;
|
|
|
|
const resolutionNote = await classifySourceRecoveryRevalidation(input);
|
|
if (!resolutionNote) return activeRecoveryAction;
|
|
|
|
const resolved = await recoveryActionsSvc.resolveActiveForIssue({
|
|
companyId: input.issue.companyId,
|
|
sourceIssueId: input.issue.id,
|
|
actionId: activeRecoveryAction.id,
|
|
status: "cancelled",
|
|
outcome: "cancelled",
|
|
resolutionNote,
|
|
});
|
|
if (!resolved) return activeRecoveryAction;
|
|
|
|
const actor = input.actor;
|
|
await logActivity(db, {
|
|
companyId: input.issue.companyId,
|
|
actorType: actor?.actorType ?? "system",
|
|
actorId: actor?.actorId ?? "system",
|
|
agentId: actor?.agentId ?? null,
|
|
runId: actor?.runId ?? null,
|
|
action: "issue.recovery_action_resolved",
|
|
entityType: "issue",
|
|
entityId: input.issue.id,
|
|
details: {
|
|
identifier: input.issue.identifier,
|
|
recoveryActionId: resolved.id,
|
|
recoveryActionStatus: resolved.status,
|
|
outcome: resolved.outcome,
|
|
sourceIssueStatus: input.issue.status,
|
|
resolutionNote: resolved.resolutionNote,
|
|
source: "source_revalidation",
|
|
trigger: input.trigger,
|
|
},
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
async function revalidateActiveSourceRecoveryForRead(input: Parameters<typeof revalidateActiveSourceRecovery>[0]) {
|
|
try {
|
|
return await revalidateActiveSourceRecovery(input);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, issueId: input.issue.id, trigger: input.trigger },
|
|
"failed to revalidate recovery action during read projection",
|
|
);
|
|
return input.activeRecoveryAction ?? null;
|
|
}
|
|
}
|
|
|
|
async function revalidateActiveSourceRecoveryAfterCommittedWrite(
|
|
input: Parameters<typeof revalidateActiveSourceRecovery>[0],
|
|
) {
|
|
try {
|
|
return await revalidateActiveSourceRecovery(input);
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, issueId: input.issue.id, trigger: input.trigger },
|
|
"failed to revalidate recovery action after committed issue write",
|
|
);
|
|
return input.activeRecoveryAction ?? null;
|
|
}
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
async function assertIssueEnvironmentSelection(
|
|
companyId: string,
|
|
environmentId: string | null | undefined,
|
|
) {
|
|
if (environmentId === undefined || environmentId === null) return;
|
|
await assertEnvironmentSelectionForCompany(
|
|
environmentsSvc,
|
|
companyId,
|
|
environmentId,
|
|
{ allowedDrivers: ["local", "ssh", "sandbox"] },
|
|
);
|
|
}
|
|
|
|
async function assertAgentInReviewReviewPath(input: {
|
|
existing: {
|
|
id: string;
|
|
companyId: string;
|
|
status: string;
|
|
assigneeUserId?: string | null;
|
|
executionState?: unknown;
|
|
monitorNextCheckAt?: Date | null;
|
|
};
|
|
updateFields: Record<string, unknown>;
|
|
actorType: string;
|
|
}) {
|
|
const nextStatus = typeof input.updateFields.status === "string"
|
|
? input.updateFields.status
|
|
: input.existing.status;
|
|
if (input.actorType !== "agent" || input.existing.status === "in_review" || nextStatus !== "in_review") return;
|
|
|
|
const nextAssigneeUserId = input.updateFields.assigneeUserId === undefined
|
|
? input.existing.assigneeUserId
|
|
: input.updateFields.assigneeUserId;
|
|
if (typeof nextAssigneeUserId === "string" && nextAssigneeUserId.trim().length > 0) return;
|
|
|
|
const nextExecutionState = input.updateFields.executionState === undefined
|
|
? input.existing.executionState
|
|
: input.updateFields.executionState;
|
|
if (hasExecutionParticipant(nextExecutionState)) return;
|
|
|
|
const nextExecutionPolicy = input.updateFields.executionPolicy;
|
|
if (hasScheduledMonitor({
|
|
existingMonitorNextCheckAt: input.existing.monitorNextCheckAt ?? null,
|
|
patchMonitorNextCheckAt: input.updateFields.monitorNextCheckAt,
|
|
executionPolicy: nextExecutionPolicy,
|
|
})) return;
|
|
|
|
const interactions = await issueThreadInteractionService(db).listForIssue(input.existing.id);
|
|
if (interactions.some((interaction) => interaction.status === "pending")) return;
|
|
|
|
const approvals = await issueApprovalsSvc.listApprovalsForIssue(input.existing.id);
|
|
if (approvals.some((approval) => ACTIVE_REVIEW_APPROVAL_STATUSES.has(String(approval.status)))) return;
|
|
|
|
throw unprocessable(INVALID_AGENT_IN_REVIEW_DISPOSITION_MESSAGE, {
|
|
code: "invalid_issue_disposition",
|
|
missing: "review_path",
|
|
validReviewPaths: [
|
|
"pending_issue_thread_interaction",
|
|
"linked_pending_approval",
|
|
"human_assignee_user_id",
|
|
"typed_execution_state_current_participant",
|
|
"scheduled_issue_monitor",
|
|
],
|
|
});
|
|
}
|
|
|
|
async function logExpiredRequestConfirmations(input: {
|
|
issue: { id: string; companyId: string; identifier?: string | null };
|
|
interactions: Array<{ id: string; kind: string; status: string; result?: unknown }>;
|
|
actor: ReturnType<typeof getActorInfo>;
|
|
source: string;
|
|
}) {
|
|
for (const interaction of input.interactions) {
|
|
await logActivity(db, {
|
|
companyId: input.issue.companyId,
|
|
actorType: input.actor.actorType,
|
|
actorId: input.actor.actorId,
|
|
agentId: input.actor.agentId,
|
|
runId: input.actor.runId,
|
|
action: "issue.thread_interaction_expired",
|
|
entityType: "issue",
|
|
entityId: input.issue.id,
|
|
details: {
|
|
identifier: input.issue.identifier ?? null,
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
source: input.source,
|
|
result: interaction.result ?? null,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
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, fileSizeLimit: number) {
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: fileSizeLimit, files: 1 },
|
|
});
|
|
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.assigneeAgentId === null) {
|
|
return true;
|
|
}
|
|
if (issue.assigneeAgentId !== actorAgentId) {
|
|
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
|
|
return true;
|
|
}
|
|
if (issue.status === "in_progress") {
|
|
res.status(409).json({
|
|
error: "Issue is checked out by another agent",
|
|
details: {
|
|
issueId: issue.id,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
actorAgentId,
|
|
},
|
|
});
|
|
} else {
|
|
res.status(403).json({
|
|
error: "Agent cannot mutate another agent's issue",
|
|
details: {
|
|
issueId: issue.id,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
actorAgentId,
|
|
status: issue.status,
|
|
securityPrinciples: ["Least Privilege", "Complete Mediation", "Fail Securely"],
|
|
},
|
|
});
|
|
}
|
|
return false;
|
|
}
|
|
if (issue.status !== "in_progress") {
|
|
return true;
|
|
}
|
|
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;
|
|
}
|
|
|
|
function isStatusOnlyCheapRecoveryContext(contextSnapshot: unknown) {
|
|
if (!contextSnapshot || typeof contextSnapshot !== "object" || Array.isArray(contextSnapshot)) return false;
|
|
const context = contextSnapshot as Record<string, unknown>;
|
|
return context.modelProfile === "cheap" &&
|
|
context.recoveryIntent === "status_only" &&
|
|
context.allowDeliverableWork === false &&
|
|
context.allowDocumentUpdates === false &&
|
|
context.resumeRequiresNormalModel === true;
|
|
}
|
|
|
|
function requestsCheapIssueAssigneeModelProfile(input: { assigneeAdapterOverrides?: unknown }) {
|
|
const overrides = input.assigneeAdapterOverrides;
|
|
return !!overrides &&
|
|
typeof overrides === "object" &&
|
|
!Array.isArray(overrides) &&
|
|
(overrides as Record<string, unknown>).modelProfile === "cheap";
|
|
}
|
|
|
|
async function loadActorRunContext(req: Request, companyId: string) {
|
|
if (req.actor.type !== "agent") return null;
|
|
const runId = req.actor.runId?.trim();
|
|
if (!runId) return null;
|
|
const run = await db
|
|
.select({
|
|
id: heartbeatRuns.id,
|
|
companyId: heartbeatRuns.companyId,
|
|
agentId: heartbeatRuns.agentId,
|
|
contextSnapshot: heartbeatRuns.contextSnapshot,
|
|
})
|
|
.from(heartbeatRuns)
|
|
.where(eq(heartbeatRuns.id, runId))
|
|
.then((rows) => rows[0] ?? null);
|
|
if (!run || run.companyId !== companyId || run.agentId !== req.actor.agentId) return null;
|
|
return run;
|
|
}
|
|
|
|
async function assertCheapRecoveryIssueAssigneeProfileAllowed(
|
|
req: Request,
|
|
res: Response,
|
|
issue: { id?: string; companyId: string },
|
|
input: { assigneeAdapterOverrides?: unknown },
|
|
) {
|
|
if (!requestsCheapIssueAssigneeModelProfile(input)) return true;
|
|
const run = await loadActorRunContext(req, issue.companyId);
|
|
if (!run || !isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true;
|
|
|
|
res.status(403).json({
|
|
error: "Cheap status-only recovery runs cannot assign downstream issue work to the cheap model profile",
|
|
details: {
|
|
issueId: issue.id ?? null,
|
|
runId: run.id,
|
|
modelProfile: "cheap",
|
|
recoveryIntent: "status_only",
|
|
resumeRequiresNormalModel: true,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
async function assertDeliverableMutationAllowedByRunContext(
|
|
req: Request,
|
|
res: Response,
|
|
issue: { id: string; companyId: string },
|
|
) {
|
|
const run = await loadActorRunContext(req, issue.companyId);
|
|
if (!run) return true;
|
|
if (!isStatusOnlyCheapRecoveryContext(run.contextSnapshot)) return true;
|
|
|
|
res.status(403).json({
|
|
error: "Cheap status-only recovery runs cannot update issue documents, plans, or deliverable artifacts",
|
|
details: {
|
|
issueId: issue.id,
|
|
runId: run.id,
|
|
modelProfile: "cheap",
|
|
recoveryIntent: "status_only",
|
|
resumeRequiresNormalModel: true,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
function assertStructuredCommentFieldsAllowed(
|
|
req: Request,
|
|
res: Response,
|
|
input: { presentation?: unknown; metadata?: unknown },
|
|
) {
|
|
const hasStructuredFields = input.presentation !== undefined || input.metadata !== undefined;
|
|
if (!hasStructuredFields) return true;
|
|
if (req.actor.type === "board") return true;
|
|
res.status(403).json({
|
|
error: "Only board users may set structured comment presentation or metadata",
|
|
details: {
|
|
securityPrinciples: ["Least Privilege", "Secure Defaults", "Complete Mediation"],
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
async function assertExplicitResumeIntentAllowed(
|
|
req: Request,
|
|
res: Response,
|
|
issue: { id: string; companyId: string; status: string; assigneeAgentId: string | null },
|
|
) {
|
|
if (issue.status === "cancelled") {
|
|
res.status(409).json({
|
|
error: "Cancelled issues must be restored through the dedicated restore flow",
|
|
details: {
|
|
issueId: issue.id,
|
|
status: issue.status,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (!isExplicitResumeCapableStatus(issue.status)) {
|
|
res.status(409).json({
|
|
error: "Issue is not resumable through comment follow-up intent",
|
|
details: { issueId: issue.id, status: issue.status },
|
|
});
|
|
return false;
|
|
}
|
|
|
|
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
|
|
if (activePauseHold) {
|
|
res.status(409).json({
|
|
error: "Issue follow-up blocked by active subtree pause hold",
|
|
details: {
|
|
issueId: issue.id,
|
|
holdId: activePauseHold.holdId,
|
|
rootIssueId: activePauseHold.rootIssueId,
|
|
mode: activePauseHold.mode,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
if (issue.status === "blocked") {
|
|
const readiness = await svc.getDependencyReadiness(issue.id);
|
|
if (readiness.unresolvedBlockerCount > 0) {
|
|
res.status(409).json({
|
|
error: "Issue follow-up blocked by unresolved blockers",
|
|
details: {
|
|
issueId: issue.id,
|
|
unresolvedBlockerIssueIds: readiness.unresolvedBlockerIssueIds,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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.assigneeAgentId) {
|
|
res.status(409).json({
|
|
error: "Issue follow-up requires an assigned agent",
|
|
details: { issueId: issue.id, actorAgentId },
|
|
});
|
|
return false;
|
|
}
|
|
if (issue.assigneeAgentId === actorAgentId) return true;
|
|
if (await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)) {
|
|
return true;
|
|
}
|
|
|
|
res.status(403).json({
|
|
error: "Agent cannot request follow-up for another agent's issue",
|
|
details: {
|
|
issueId: issue.id,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
actorAgentId,
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
async function assertRecoveryActionAuthority(
|
|
req: Request,
|
|
res: Response,
|
|
issue: { id: string; companyId: string; assigneeAgentId: string | null },
|
|
activeRecoveryAction: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>>,
|
|
input: { source: "issue_update" | "recovery_action_resolution" },
|
|
) {
|
|
if (req.actor.type !== "agent") return true;
|
|
if (!activeRecoveryAction) return true;
|
|
|
|
const actorAgentId = req.actor.agentId;
|
|
if (!actorAgentId) {
|
|
res.status(403).json({ error: "Agent authentication required" });
|
|
return false;
|
|
}
|
|
if (issue.assigneeAgentId === actorAgentId) return true;
|
|
if (
|
|
issue.assigneeAgentId &&
|
|
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)
|
|
) {
|
|
return true;
|
|
}
|
|
if (activeRecoveryAction.ownerAgentId === actorAgentId) return true;
|
|
if (
|
|
activeRecoveryAction.ownerAgentId &&
|
|
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, activeRecoveryAction.ownerAgentId)
|
|
) {
|
|
return true;
|
|
}
|
|
|
|
res.status(403).json({
|
|
error: "Agent cannot resolve another owner's recovery action",
|
|
details: {
|
|
issueId: issue.id,
|
|
recoveryActionId: activeRecoveryAction.id,
|
|
actorAgentId,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId,
|
|
source: input.source,
|
|
securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"],
|
|
},
|
|
});
|
|
return false;
|
|
}
|
|
|
|
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 resolveIssueRouteId(rawId: string): Promise<string> {
|
|
const identifier = normalizeIssueReferenceIdentifier(rawId);
|
|
if (identifier) {
|
|
const issue = await svc.getByIdentifier(identifier);
|
|
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 resolveIssueRouteId(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 resolveIssueRouteId(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/search", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const query = companySearchQuerySchema.parse(req.query);
|
|
const rateLimit = searchRateLimiter.consume(companySearchRateLimitActor(req, companyId));
|
|
res.setHeader("X-RateLimit-Limit", String(rateLimit.limit));
|
|
res.setHeader("X-RateLimit-Remaining", String(rateLimit.remaining));
|
|
if (!rateLimit.allowed) {
|
|
res.setHeader("Retry-After", String(rateLimit.retryAfterSeconds));
|
|
res.status(429).json({
|
|
error: "Search rate limit exceeded",
|
|
retryAfterSeconds: rateLimit.retryAfterSeconds,
|
|
});
|
|
return;
|
|
}
|
|
const result = await getSearchService().search(companyId, query);
|
|
res.json(result);
|
|
});
|
|
|
|
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);
|
|
const rawOffset = req.query.offset as string | undefined;
|
|
const parsedOffset = rawOffset !== undefined && /^\d+$/.test(rawOffset)
|
|
? Number.parseInt(rawOffset, 10)
|
|
: null;
|
|
const attention = req.query.attention as string | undefined;
|
|
|
|
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 (attention !== undefined && attention !== "blocked") {
|
|
res.status(400).json({ error: "attention must be 'blocked' when provided" });
|
|
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;
|
|
}
|
|
if (rawOffset !== undefined && (parsedOffset === null || !Number.isInteger(parsedOffset) || parsedOffset < 0)) {
|
|
res.status(400).json({ error: "offset must be a non-negative integer" });
|
|
return;
|
|
}
|
|
const offset = parsedOffset ?? 0;
|
|
|
|
const result = await svc.list(companyId, {
|
|
attention: attention === "blocked" ? "blocked" : undefined,
|
|
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,
|
|
workspaceId: req.query.workspaceId as string | undefined,
|
|
executionWorkspaceId: req.query.executionWorkspaceId as string | undefined,
|
|
parentId: req.query.parentId as string | undefined,
|
|
descendantOf: req.query.descendantOf as string | undefined,
|
|
labelId: req.query.labelId as string | undefined,
|
|
originKind: req.query.originKind as string | undefined,
|
|
originKindPrefix: req.query.originKindPrefix 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",
|
|
includePluginOperations:
|
|
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
|
includeBlockedBy: req.query.includeBlockedBy === "true" || req.query.includeBlockedBy === "1",
|
|
includeBlockedInboxAttention:
|
|
req.query.includeBlockedInboxAttention === "true" || req.query.includeBlockedInboxAttention === "1",
|
|
q: req.query.q as string | undefined,
|
|
limit,
|
|
offset,
|
|
});
|
|
const issueIds = result.map((issue) => issue.id);
|
|
const [handoffStates, recoveryActionByIssue] = await Promise.all([
|
|
listSuccessfulRunHandoffStates(db, companyId, issueIds),
|
|
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
|
|
]);
|
|
const actor = getActorInfo(req);
|
|
await Promise.all(result.map(async (issue) => {
|
|
const activeRecoveryAction = recoveryActionByIssue.get(issue.id) ?? null;
|
|
if (!activeRecoveryAction) return;
|
|
const revalidated = await revalidateActiveSourceRecoveryForRead({
|
|
issue,
|
|
trigger: "read_projection",
|
|
actor,
|
|
activeRecoveryAction,
|
|
});
|
|
if (revalidated) recoveryActionByIssue.set(issue.id, revalidated);
|
|
else recoveryActionByIssue.delete(issue.id);
|
|
}));
|
|
res.json(result.map((issue) => ({
|
|
...issue,
|
|
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
|
|
activeRecoveryAction: recoveryActionByIssue.get(issue.id) ?? null,
|
|
})));
|
|
});
|
|
|
|
router.get("/companies/:companyId/issues/count", async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
const attention = req.query.attention as string | undefined;
|
|
if (attention !== "blocked") {
|
|
res.status(400).json({ error: "issues/count currently requires attention=blocked" });
|
|
return;
|
|
}
|
|
if (req.query.limit !== undefined || req.query.offset !== undefined) {
|
|
res.status(400).json({ error: "issues/count does not accept limit or offset" });
|
|
return;
|
|
}
|
|
|
|
const count = await svc.count(companyId, {
|
|
attention: "blocked",
|
|
status: req.query.status as string | undefined,
|
|
assigneeAgentId: req.query.assigneeAgentId as string | undefined,
|
|
participantAgentId: req.query.participantAgentId as string | undefined,
|
|
assigneeUserId: req.query.assigneeUserId as string | undefined,
|
|
projectId: req.query.projectId as string | undefined,
|
|
workspaceId: req.query.workspaceId as string | undefined,
|
|
executionWorkspaceId: req.query.executionWorkspaceId as string | undefined,
|
|
parentId: req.query.parentId as string | undefined,
|
|
descendantOf: req.query.descendantOf as string | undefined,
|
|
labelId: req.query.labelId as string | undefined,
|
|
originKind: req.query.originKind as string | undefined,
|
|
originKindPrefix: req.query.originKindPrefix 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",
|
|
includePluginOperations:
|
|
req.query.includePluginOperations === "true" || req.query.includePluginOperations === "1",
|
|
includeBlockedBy: true,
|
|
includeBlockedInboxAttention: true,
|
|
q: req.query.q as string | undefined,
|
|
});
|
|
res.json({ count });
|
|
});
|
|
|
|
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 currentExecutionWorkspacePromise = issue.executionWorkspaceId
|
|
? executionWorkspacesSvc.getById(issue.executionWorkspaceId)
|
|
: Promise.resolve(null);
|
|
const [
|
|
{ project, goal },
|
|
ancestors,
|
|
commentCursor,
|
|
wakeComment,
|
|
relations,
|
|
blockerAttention,
|
|
productivityReview,
|
|
scheduledRetry,
|
|
attachments,
|
|
continuationSummary,
|
|
currentExecutionWorkspace,
|
|
activeRecoveryAction,
|
|
] =
|
|
await Promise.all([
|
|
resolveIssueProjectAndGoal(issue),
|
|
svc.getAncestors(issue.id),
|
|
svc.getCommentCursor(issue.id),
|
|
wakeCommentId ? svc.getComment(wakeCommentId) : null,
|
|
svc.getRelationSummaries(issue.id),
|
|
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
|
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
|
|
svc.getCurrentScheduledRetry(issue.id),
|
|
svc.listAttachments(issue.id),
|
|
documentsSvc.getIssueDocumentByKey(issue.id, ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY),
|
|
currentExecutionWorkspacePromise,
|
|
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
|
]);
|
|
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
|
|
recoveryActionsSvc,
|
|
issue.companyId,
|
|
relations,
|
|
);
|
|
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
|
|
relations,
|
|
recoveryActionsByRelationIssue,
|
|
);
|
|
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
|
issue,
|
|
trigger: "read_projection",
|
|
actor: getActorInfo(req),
|
|
activeRecoveryAction,
|
|
});
|
|
|
|
res.json({
|
|
issue: {
|
|
id: issue.id,
|
|
identifier: issue.identifier,
|
|
title: issue.title,
|
|
description: issue.description,
|
|
status: issue.status,
|
|
workMode: issue.workMode,
|
|
...(blockerAttention ? { blockerAttention } : {}),
|
|
productivityReview,
|
|
scheduledRetry,
|
|
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
|
priority: issue.priority,
|
|
projectId: issue.projectId,
|
|
goalId: goal?.id ?? issue.goalId,
|
|
parentId: issue.parentId,
|
|
blockedBy: relationsWithRecoveryActions.blockedBy,
|
|
blocks: relationsWithRecoveryActions.blocks,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
assigneeUserId: issue.assigneeUserId,
|
|
originKind: issue.originKind,
|
|
originId: issue.originId,
|
|
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,
|
|
currentExecutionWorkspace,
|
|
});
|
|
});
|
|
|
|
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,
|
|
blockerAttention,
|
|
productivityReview,
|
|
referenceSummary,
|
|
successfulRunHandoffStates,
|
|
scheduledRetry,
|
|
activeRecoveryAction,
|
|
] = await Promise.all([
|
|
resolveIssueProjectAndGoal(issue),
|
|
svc.getAncestors(issue.id),
|
|
svc.findMentionedProjectIds(issue.id, { includeCommentBodies: false }),
|
|
documentsSvc.getIssueDocumentPayload(issue),
|
|
svc.getRelationSummaries(issue.id),
|
|
svc.listBlockerAttention(issue.companyId, [issue]).then((map) => map.get(issue.id) ?? null),
|
|
svc.listProductivityReviews(issue.companyId, [issue.id]).then((map) => map.get(issue.id) ?? null),
|
|
issueReferencesSvc.listIssueReferenceSummary(issue.id),
|
|
listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id]),
|
|
svc.getCurrentScheduledRetry(issue.id),
|
|
recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id),
|
|
]);
|
|
const recoveryActionsByRelationIssue = await relationRecoveryActionMap(
|
|
recoveryActionsSvc,
|
|
issue.companyId,
|
|
relations,
|
|
);
|
|
const relationsWithRecoveryActions = withRecoveryActionsOnRelationSummaries(
|
|
relations,
|
|
recoveryActionsByRelationIssue,
|
|
);
|
|
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
|
issue,
|
|
trigger: "read_projection",
|
|
actor: getActorInfo(req),
|
|
activeRecoveryAction,
|
|
});
|
|
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,
|
|
...(blockerAttention ? { blockerAttention } : {}),
|
|
productivityReview,
|
|
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
|
scheduledRetry,
|
|
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
|
blockedBy: relationsWithRecoveryActions.blockedBy,
|
|
blocks: relationsWithRecoveryActions.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/recovery-actions", 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 active = await revalidateActiveSourceRecoveryForRead({
|
|
issue,
|
|
trigger: "read_projection",
|
|
actor: getActorInfo(req),
|
|
});
|
|
res.json({
|
|
active,
|
|
actions: active ? [active] : [],
|
|
});
|
|
});
|
|
|
|
router.post("/issues/:id/recovery-actions/resolve", validate(resolveIssueRecoveryActionSchema), 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 activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id);
|
|
if (
|
|
!(await assertRecoveryActionAuthority(
|
|
req,
|
|
res,
|
|
existing,
|
|
activeRecoveryAction,
|
|
{ source: "recovery_action_resolution" },
|
|
))
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
|
|
if (outcome === "false_positive" || outcome === "cancelled") {
|
|
assertBoard(req);
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const updateFields = sourceIssueStatus ? { status: sourceIssueStatus } : {};
|
|
await assertAgentInReviewReviewPath({
|
|
existing,
|
|
updateFields,
|
|
actorType: req.actor.type,
|
|
});
|
|
|
|
const actionStatus = outcome === "cancelled" ? "cancelled" : "resolved";
|
|
const result = await db.transaction(async (tx) => {
|
|
let issue = existing;
|
|
if (outcome === "blocked") {
|
|
const unresolvedBlockers = await tx
|
|
.select({ id: issueRows.id })
|
|
.from(issueRelations)
|
|
.innerJoin(issueRows, eq(issueRelations.issueId, issueRows.id))
|
|
.where(
|
|
and(
|
|
eq(issueRelations.companyId, existing.companyId),
|
|
eq(issueRelations.relatedIssueId, existing.id),
|
|
eq(issueRelations.type, "blocks"),
|
|
notInArray(issueRows.status, ["done", "cancelled"]),
|
|
),
|
|
)
|
|
.limit(1);
|
|
if (unresolvedBlockers.length === 0) {
|
|
throw unprocessable("Blocked recovery resolution requires an unresolved first-class blocker on the source issue");
|
|
}
|
|
}
|
|
|
|
if (sourceIssueStatus) {
|
|
const updatedIssue = await svc.update(
|
|
id,
|
|
{
|
|
status: sourceIssueStatus,
|
|
actorAgentId: actor.agentId ?? null,
|
|
actorUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
tx,
|
|
);
|
|
if (!updatedIssue) throw notFound("Issue not found");
|
|
issue = updatedIssue;
|
|
}
|
|
|
|
const recoveryAction = await recoveryActionsSvc.resolveActiveForIssue(
|
|
{
|
|
companyId: existing.companyId,
|
|
sourceIssueId: existing.id,
|
|
actionId: actionId ?? null,
|
|
status: actionStatus,
|
|
outcome,
|
|
resolutionNote: resolutionNote ?? null,
|
|
},
|
|
tx,
|
|
);
|
|
if (!recoveryAction) throw notFound("Active recovery action not found");
|
|
|
|
return { issue, recoveryAction };
|
|
});
|
|
|
|
await routinesSvc.syncRunStatusForIssue(result.issue.id);
|
|
|
|
if (sourceIssueStatus && existing.status !== result.issue.status) {
|
|
await logActivity(db, {
|
|
companyId: result.issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.updated",
|
|
entityType: "issue",
|
|
entityId: result.issue.id,
|
|
details: {
|
|
identifier: result.issue.identifier,
|
|
status: result.issue.status,
|
|
source: "recovery_action_resolution",
|
|
recoveryActionId: result.recoveryAction.id,
|
|
_previous: {
|
|
status: existing.status,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
await logActivity(db, {
|
|
companyId: result.issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.recovery_action_resolved",
|
|
entityType: "issue",
|
|
entityId: result.issue.id,
|
|
details: {
|
|
identifier: result.issue.identifier,
|
|
recoveryActionId: result.recoveryAction.id,
|
|
recoveryActionStatus: result.recoveryAction.status,
|
|
outcome: result.recoveryAction.outcome,
|
|
sourceIssueStatus: sourceIssueStatus ?? null,
|
|
resolutionNote: result.recoveryAction.resolutionNote,
|
|
},
|
|
});
|
|
|
|
if (
|
|
sourceIssueStatus === "todo" &&
|
|
existing.status !== result.issue.status &&
|
|
result.issue.assigneeAgentId
|
|
) {
|
|
void heartbeat.wakeup(result.issue.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_recovery_action_restored",
|
|
payload: {
|
|
issueId: result.issue.id,
|
|
recoveryActionId: result.recoveryAction.id,
|
|
mutation: "recovery_action_resolution",
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: result.issue.id,
|
|
taskId: result.issue.id,
|
|
wakeReason: "issue_recovery_action_restored",
|
|
source: "issue.recovery_action_resolution",
|
|
recoveryActionId: result.recoveryAction.id,
|
|
},
|
|
}).catch((err) =>
|
|
logger.warn(
|
|
{ err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId },
|
|
"failed to wake agent after recovery action restored issue",
|
|
));
|
|
}
|
|
|
|
res.json({
|
|
issue: {
|
|
...result.issue,
|
|
activeRecoveryAction: null,
|
|
},
|
|
recoveryAction: result.recoveryAction,
|
|
});
|
|
});
|
|
|
|
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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(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,
|
|
lockedDocumentStrategy: req.actor.type === "agent" ? "create_new_document" : "conflict",
|
|
});
|
|
const doc = result.document;
|
|
const redirectedFromLockedDocument =
|
|
"redirectedFromLockedDocument" in result ? result.redirectedFromLockedDocument : null;
|
|
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,
|
|
redirectedFromLockedDocument,
|
|
...summarizeIssueReferenceActivityDetails({
|
|
addedReferencedIssues: referenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: referenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: referenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}),
|
|
},
|
|
});
|
|
|
|
if (!result.created) {
|
|
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
|
issue,
|
|
{
|
|
id: doc.id,
|
|
key: doc.key,
|
|
latestRevisionId: doc.latestRevisionId,
|
|
latestRevisionNumber: doc.latestRevisionNumber,
|
|
},
|
|
{
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
);
|
|
await logExpiredRequestConfirmations({
|
|
issue,
|
|
interactions: expiredInteractions,
|
|
actor,
|
|
source: "issue.document_updated",
|
|
});
|
|
}
|
|
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "document",
|
|
actor,
|
|
documentChanged: true,
|
|
});
|
|
|
|
res.status(result.created ? 201 : 200).json(doc);
|
|
});
|
|
|
|
router.post("/issues/:id/documents/:key/lock", 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 actor = getActorInfo(req);
|
|
const result = await documentsSvc.lockIssueDocument({
|
|
issueId: issue.id,
|
|
key: keyParsed.data,
|
|
lockedByAgentId: actor.agentId ?? null,
|
|
lockedByUserId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
|
|
if (result.changed) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.document_locked",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
key: result.document.key,
|
|
documentId: result.document.id,
|
|
title: result.document.title,
|
|
lockedAt: result.document.lockedAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
res.json(result.document);
|
|
});
|
|
|
|
router.post("/issues/:id/documents/:key/unlock", 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 actor = getActorInfo(req);
|
|
const result = await documentsSvc.unlockIssueDocument(issue.id, keyParsed.data);
|
|
|
|
if (result.changed) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.document_unlocked",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
key: result.document.key,
|
|
documentId: result.document.id,
|
|
title: result.document.title,
|
|
},
|
|
});
|
|
}
|
|
|
|
res.json(result.document);
|
|
});
|
|
|
|
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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(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),
|
|
}),
|
|
},
|
|
});
|
|
|
|
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
|
issue,
|
|
{
|
|
id: result.document.id,
|
|
key: result.document.key,
|
|
latestRevisionId: result.document.latestRevisionId,
|
|
latestRevisionNumber: result.document.latestRevisionNumber,
|
|
},
|
|
{
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
);
|
|
await logExpiredRequestConfirmations({
|
|
issue,
|
|
interactions: expiredInteractions,
|
|
actor,
|
|
source: "issue.document_restored",
|
|
});
|
|
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "document",
|
|
actor,
|
|
documentChanged: true,
|
|
});
|
|
|
|
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),
|
|
}),
|
|
},
|
|
});
|
|
const expiredInteractions = await issueThreadInteractionService(db).expireStaleRequestConfirmationsForIssueDocument(
|
|
issue,
|
|
{
|
|
id: removed.id,
|
|
key: removed.key,
|
|
latestRevisionId: null,
|
|
latestRevisionNumber: null,
|
|
},
|
|
{
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
);
|
|
await logExpiredRequestConfirmations({
|
|
issue,
|
|
interactions: expiredInteractions,
|
|
actor,
|
|
source: "issue.document_deleted",
|
|
});
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "document",
|
|
actor,
|
|
documentChanged: true,
|
|
});
|
|
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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(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 },
|
|
});
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "work_product",
|
|
actor,
|
|
workProductChanged: true,
|
|
});
|
|
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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(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() },
|
|
});
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "work_product",
|
|
actor,
|
|
workProductChanged: true,
|
|
});
|
|
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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(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 },
|
|
});
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "work_product",
|
|
actor,
|
|
workProductChanged: true,
|
|
});
|
|
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", applyCreateIssueStatusDefault, validate(createIssueSchema), async (req, res) => {
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
assertNoAgentHostWorkspaceCommandMutation(req, collectIssueWorkspaceCommandPaths(req.body));
|
|
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, { companyId }, req.body))) return;
|
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
|
await assertCanAssignTasks(req, companyId);
|
|
}
|
|
await assertIssueEnvironmentSelection(companyId, req.body.executionWorkspaceSettings?.environmentId);
|
|
|
|
const actor = getActorInfo(req);
|
|
const executionPolicy = applyActorMonitorScheduledBy(
|
|
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
|
actor.actorType,
|
|
);
|
|
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
|
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,
|
|
...buildCreateIssueActivityStatusDetails(issue, res),
|
|
...(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),
|
|
}),
|
|
},
|
|
});
|
|
|
|
if (executionPolicy?.monitor) {
|
|
await logActivity(db, {
|
|
companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.monitor_scheduled",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
nextCheckAt: executionPolicy.monitor.nextCheckAt,
|
|
notes: executionPolicy.monitor.notes,
|
|
scheduledBy: executionPolicy.monitor.scheduledBy,
|
|
serviceName: executionPolicy.monitor.serviceName ?? null,
|
|
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
|
|
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
|
|
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
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", applyCreateIssueStatusDefault, 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 (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, parent, req.body))) return;
|
|
if (req.body.assigneeAgentId || req.body.assigneeUserId) {
|
|
await assertCanAssignTasks(req, parent.companyId);
|
|
}
|
|
await assertIssueEnvironmentSelection(parent.companyId, req.body.executionWorkspaceSettings?.environmentId);
|
|
|
|
const actor = getActorInfo(req);
|
|
const executionPolicy = applyActorMonitorScheduledBy(
|
|
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
|
actor.actorType,
|
|
);
|
|
assertCanManageIssueMonitor(req, req.body.assigneeAgentId ?? null, Boolean(executionPolicy?.monitor));
|
|
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,
|
|
...buildCreateIssueActivityStatusDetails(issue, res),
|
|
inheritedExecutionWorkspaceFromIssueId: parent.id,
|
|
...(Array.isArray(req.body.blockedByIssueIds) ? { blockedByIssueIds: req.body.blockedByIssueIds } : {}),
|
|
...(parentBlockerAdded ? { parentBlockerAdded: true } : {}),
|
|
},
|
|
});
|
|
|
|
if (executionPolicy?.monitor) {
|
|
await logActivity(db, {
|
|
companyId: parent.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.monitor_scheduled",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
parentId: parent.id,
|
|
nextCheckAt: executionPolicy.monitor.nextCheckAt,
|
|
notes: executionPolicy.monitor.notes,
|
|
scheduledBy: executionPolicy.monitor.scheduledBy,
|
|
serviceName: executionPolicy.monitor.serviceName ?? null,
|
|
timeoutAt: executionPolicy.monitor.timeoutAt ?? null,
|
|
maxAttempts: executionPolicy.monitor.maxAttempts ?? null,
|
|
recoveryPolicy: executionPolicy.monitor.recoveryPolicy ?? null,
|
|
},
|
|
});
|
|
}
|
|
|
|
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.post("/issues/:id/monitor/check-now", 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);
|
|
assertCanManageIssueMonitor(req, issue.assigneeAgentId, true);
|
|
|
|
const actor = getActorInfo(req);
|
|
await heartbeat.triggerIssueMonitor(issue.id, {
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId ?? null,
|
|
runId: actor.runId ?? null,
|
|
});
|
|
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
router.post("/issues/:id/scheduled-retry/retry-now", async (req, res) => {
|
|
assertBoard(req);
|
|
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 actor = getActorInfo(req);
|
|
const result = await heartbeat.retryScheduledRetryNow({
|
|
issueId: issue.id,
|
|
actor: {
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
},
|
|
});
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
action: "issue.scheduled_retry_retry_now",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
agentId: result.scheduledRetry?.agentId ?? issue.assigneeAgentId ?? null,
|
|
runId: result.scheduledRetry?.runId ?? null,
|
|
details: {
|
|
outcome: result.outcome,
|
|
message: result.message,
|
|
scheduledRetry: result.scheduledRetry,
|
|
},
|
|
});
|
|
|
|
res.json(result);
|
|
});
|
|
|
|
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;
|
|
if (!(await assertCheapRecoveryIssueAssigneeProfileAllowed(req, res, existing, req.body))) return;
|
|
|
|
const actor = getActorInfo(req);
|
|
const isClosed = isClosedIssueStatus(existing.status);
|
|
const isBlocked = existing.status === "blocked";
|
|
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,
|
|
reviewRequest,
|
|
reopen: reopenRequested,
|
|
resume: resumeRequested,
|
|
interrupt: interruptRequested,
|
|
hiddenAt: hiddenAtRaw,
|
|
...updateFields
|
|
} = req.body;
|
|
const shouldCancelActiveRunForCancelledStatus =
|
|
existing.status !== "cancelled" && updateFields.status === "cancelled";
|
|
if (resumeRequested === true && !commentBody) {
|
|
res.status(400).json({ error: "Follow-up intent requires a comment" });
|
|
return;
|
|
}
|
|
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
|
|
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
|
|
if (!(await assertExplicitResumeIntentAllowed(req, res, existing))) return;
|
|
}
|
|
await assertIssueEnvironmentSelection(existing.companyId, updateFields.executionWorkspaceSettings?.environmentId);
|
|
const requestedAssigneeAgentId =
|
|
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
|
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
|
|
const recoveryRelevantSourceMutationRequested =
|
|
req.body.status !== undefined ||
|
|
normalizedAssigneeAgentId !== undefined ||
|
|
req.body.assigneeUserId !== undefined ||
|
|
Array.isArray(req.body.blockedByIssueIds) ||
|
|
req.body.executionPolicy !== undefined ||
|
|
explicitMoveToTodoRequested;
|
|
const activeRecoveryActionBeforeUpdate = recoveryRelevantSourceMutationRequested
|
|
? await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id)
|
|
: null;
|
|
if (
|
|
recoveryRelevantSourceMutationRequested &&
|
|
!(await assertRecoveryActionAuthority(
|
|
req,
|
|
res,
|
|
existing,
|
|
activeRecoveryActionBeforeUpdate,
|
|
{ source: "issue_update" },
|
|
))
|
|
) {
|
|
return;
|
|
}
|
|
const effectiveMoveToTodoRequested =
|
|
explicitMoveToTodoRequested ||
|
|
(!!commentBody &&
|
|
shouldImplicitlyMoveCommentedIssueToTodo({
|
|
issueStatus: existing.status,
|
|
assigneeAgentId: requestedAssigneeAgentId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
}));
|
|
const updateReferenceSummaryBefore = titleOrDescriptionChanged
|
|
? await issueReferencesSvc.listIssueReferenceSummary(existing.id)
|
|
: null;
|
|
const hasUnresolvedFirstClassBlockers =
|
|
isBlocked && effectiveMoveToTodoRequested
|
|
? (await svc.getDependencyReadiness(existing.id)).unresolvedBlockerCount > 0
|
|
: false;
|
|
if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) {
|
|
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
|
|
return;
|
|
}
|
|
let interruptedRunId: string | null = null;
|
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(existing);
|
|
const isAgentWorkUpdate =
|
|
req.actor.type === "agent" && (Object.keys(updateFields).length > 0 || reviewRequest !== undefined);
|
|
|
|
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 },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const runToCancelForCancelledStatus = shouldCancelActiveRunForCancelledStatus
|
|
? await resolveActiveIssueRun(existing)
|
|
: null;
|
|
|
|
if (hiddenAtRaw !== undefined) {
|
|
updateFields.hiddenAt = hiddenAtRaw ? new Date(hiddenAtRaw) : null;
|
|
}
|
|
if (
|
|
commentBody &&
|
|
effectiveMoveToTodoRequested &&
|
|
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) &&
|
|
updateFields.status === undefined
|
|
) {
|
|
updateFields.status = "todo";
|
|
}
|
|
if (req.body.executionPolicy !== undefined) {
|
|
updateFields.executionPolicy = applyActorMonitorScheduledBy(
|
|
normalizeIssueExecutionPolicy(req.body.executionPolicy),
|
|
actor.actorType,
|
|
);
|
|
}
|
|
const previousExecutionPolicy = normalizeIssueExecutionPolicy(existing.executionPolicy ?? null);
|
|
const nextExecutionPolicy =
|
|
updateFields.executionPolicy !== undefined
|
|
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
|
: previousExecutionPolicy;
|
|
if (normalizedAssigneeAgentId !== undefined) {
|
|
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
|
|
}
|
|
const monitorChanged = monitorPoliciesEqual(previousExecutionPolicy, nextExecutionPolicy) === false;
|
|
assertCanManageIssueMonitor(req, existing.assigneeAgentId, req.body.executionPolicy !== undefined && monitorChanged);
|
|
|
|
const transition = applyIssueExecutionPolicyTransition({
|
|
issue: existing,
|
|
policy: nextExecutionPolicy,
|
|
previousPolicy: previousExecutionPolicy,
|
|
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,
|
|
reviewRequest: reviewRequest === undefined ? undefined : reviewRequest,
|
|
monitorExplicitlyUpdated: req.body.executionPolicy !== undefined && monitorChanged,
|
|
});
|
|
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);
|
|
if (reviewRequest !== undefined && transition.patch.executionState === undefined) {
|
|
const existingExecutionState = parseIssueExecutionState(existing.executionState);
|
|
if (!existingExecutionState || existingExecutionState.status !== "pending") {
|
|
if (reviewRequest !== null) {
|
|
res.status(422).json({ error: "reviewRequest requires an active review or approval stage" });
|
|
return;
|
|
}
|
|
} else {
|
|
updateFields.executionState = {
|
|
...existingExecutionState,
|
|
reviewRequest,
|
|
};
|
|
}
|
|
}
|
|
|
|
await assertAgentInReviewReviewPath({
|
|
existing,
|
|
updateFields,
|
|
actorType: req.actor.type,
|
|
});
|
|
|
|
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;
|
|
}
|
|
|
|
let cancelledStatusRunId: string | null = null;
|
|
if (runToCancelForCancelledStatus) {
|
|
try {
|
|
const cancelled = await heartbeat.cancelRun(runToCancelForCancelledStatus.id);
|
|
if (cancelled) {
|
|
cancelledStatusRunId = 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_status_cancelled", issueId: existing.id },
|
|
});
|
|
}
|
|
} catch (err) {
|
|
logger.warn({ err, issueId: existing.id, runId: runToCancelForCancelledStatus.id }, "failed to cancel run for cancelled issue");
|
|
await logActivity(db, {
|
|
companyId: existing.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "heartbeat.cancel_failed",
|
|
entityType: "heartbeat_run",
|
|
entityId: runToCancelForCancelledStatus.id,
|
|
details: { source: "issue_status_cancelled", issueId: existing.id },
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
activeRecoveryAction?: 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;
|
|
let workspaceChange = null;
|
|
if (hasIssueWorkspaceAuditChange(previous)) {
|
|
try {
|
|
workspaceChange = await buildIssueWorkspaceChangeActivityDetails(db, issue.companyId, existing, issue);
|
|
} catch (err) {
|
|
logger.warn({ err, issueId: issue.id }, "failed to enrich issue workspace change activity details");
|
|
const fallbackNames = emptyWorkspaceNameMaps();
|
|
workspaceChange = {
|
|
from: summarizeIssueWorkspaceForActivity(existing, fallbackNames),
|
|
to: summarizeIssueWorkspaceForActivity(issue, fallbackNames),
|
|
};
|
|
}
|
|
}
|
|
const reopened =
|
|
commentBody &&
|
|
effectiveMoveToTodoRequested &&
|
|
(isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers)) &&
|
|
previous.status !== undefined &&
|
|
issue.status === "todo";
|
|
const reopenFromStatus = reopened ? existing.status : null;
|
|
const statusChangedFromBlockedToTodo =
|
|
existing.status === "blocked" &&
|
|
issue.status === "todo" &&
|
|
(req.body.status !== undefined || reopened);
|
|
const revalidatedRecoveryAction = await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue,
|
|
trigger: "issue_update",
|
|
actor,
|
|
activeRecoveryAction: activeRecoveryActionBeforeUpdate ?? undefined,
|
|
statusChanged: existing.status !== issue.status,
|
|
assigneeChanged:
|
|
existing.assigneeAgentId !== issue.assigneeAgentId ||
|
|
existing.assigneeUserId !== issue.assigneeUserId,
|
|
blockersChanged: Array.isArray(req.body.blockedByIssueIds),
|
|
executionPolicyChanged: req.body.executionPolicy !== undefined,
|
|
monitorChanged,
|
|
resumeRequested: resumeRequested === true,
|
|
reopened,
|
|
blockedToTodoRecovery: statusChangedFromBlockedToTodo,
|
|
});
|
|
if (activeRecoveryActionBeforeUpdate && !revalidatedRecoveryAction) {
|
|
issueResponse = {
|
|
...issueResponse,
|
|
activeRecoveryAction: 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" } : {}),
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(reopened ? { reopened: true, reopenedFrom: reopenFromStatus } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
...(cancelledStatusRunId ? { cancelledStatusRunId } : {}),
|
|
...(workspaceChange ? { workspaceChange } : {}),
|
|
_previous: hasFieldChanges ? previous : undefined,
|
|
...summarizeIssueReferenceActivityDetails(
|
|
updateReferenceDiff
|
|
? {
|
|
addedReferencedIssues: updateReferenceDiff.addedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
removedReferencedIssues: updateReferenceDiff.removedReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
currentReferencedIssues: updateReferenceDiff.currentReferencedIssues.map(summarizeIssueRelationForActivity),
|
|
}
|
|
: null,
|
|
),
|
|
},
|
|
});
|
|
|
|
if (existing.status === "in_progress" && issue.status !== existing.status && issue.status !== "in_progress") {
|
|
await listSuccessfulRunHandoffStates(db, issue.companyId, [issue.id])
|
|
.then(async (handoffStates) => {
|
|
const handoff = handoffStates.get(issue.id);
|
|
if (handoff?.state !== "required") return;
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.successful_run_handoff_resolved",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
sourceRunId: handoff.sourceRunId,
|
|
correctiveRunId: handoff.correctiveRunId,
|
|
resolvedByStatus: issue.status,
|
|
},
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
logger.warn({ err, issueId: issue.id }, "failed to log successful run handoff resolution");
|
|
});
|
|
}
|
|
|
|
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,
|
|
},
|
|
});
|
|
}
|
|
|
|
const nextStoredExecutionPolicy = normalizeIssueExecutionPolicy(issue.executionPolicy ?? null);
|
|
const previousMonitor = summarizeIssueMonitor(existing, previousExecutionPolicy);
|
|
const nextMonitor = summarizeIssueMonitor(issue, nextStoredExecutionPolicy);
|
|
const monitorScheduledChanged = previousMonitor.nextCheckAt !== nextMonitor.nextCheckAt;
|
|
if (nextMonitor.nextCheckAt && (monitorScheduledChanged || previousMonitor.notes !== nextMonitor.notes)) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.monitor_scheduled",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
nextCheckAt: nextMonitor.nextCheckAt,
|
|
previousNextCheckAt: previousMonitor.nextCheckAt,
|
|
notes: nextMonitor.notes,
|
|
scheduledBy: nextMonitor.scheduledBy,
|
|
serviceName: nextMonitor.serviceName,
|
|
timeoutAt: nextMonitor.timeoutAt,
|
|
maxAttempts: nextMonitor.maxAttempts,
|
|
recoveryPolicy: nextMonitor.recoveryPolicy,
|
|
},
|
|
});
|
|
} else if (!nextMonitor.nextCheckAt && previousMonitor.nextCheckAt) {
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.monitor_cleared",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
identifier: issue.identifier,
|
|
previousNextCheckAt: previousMonitor.nextCheckAt,
|
|
reason: nextMonitor.clearReason ?? "manual",
|
|
notes: previousMonitor.notes,
|
|
},
|
|
});
|
|
}
|
|
|
|
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,
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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),
|
|
}),
|
|
},
|
|
});
|
|
|
|
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
|
|
issue,
|
|
comment,
|
|
{
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
);
|
|
await logExpiredRequestConfirmations({
|
|
issue,
|
|
interactions: expiredInteractions,
|
|
actor,
|
|
source: "issue.comment",
|
|
});
|
|
|
|
} 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 statusChangedFromClosedToTodo =
|
|
isClosedIssueStatus(existing.status) &&
|
|
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",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
if (
|
|
!assigneeChanged &&
|
|
(statusChangedFromBacklog || statusChangedFromBlockedToTodo || statusChangedFromClosedToTodo) &&
|
|
issue.assigneeAgentId
|
|
) {
|
|
addWakeup(issue.assigneeAgentId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_status_changed",
|
|
payload: {
|
|
issueId: issue.id,
|
|
mutation: "update",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
contextSnapshot: {
|
|
issueId: issue.id,
|
|
source: "issue.status_change",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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 } : {}),
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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 } : {}),
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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.post("/issues/:id/admin/force-release", async (req, res) => {
|
|
if (req.actor.type !== "board") {
|
|
res.status(403).json({ error: "Board access required" });
|
|
return;
|
|
}
|
|
if (!req.actor.userId) {
|
|
throw forbidden("Board user context required");
|
|
}
|
|
|
|
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);
|
|
|
|
const clearAssignee = req.query.clearAssignee === "true";
|
|
const result = await svc.adminForceRelease(id, { clearAssignee });
|
|
if (!result) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
await logActivity(db, {
|
|
companyId: result.issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: "issue.admin_force_release",
|
|
entityType: "issue",
|
|
entityId: result.issue.id,
|
|
details: {
|
|
issueId: result.issue.id,
|
|
actorUserId: req.actor.userId,
|
|
prevCheckoutRunId: result.previous.checkoutRunId,
|
|
prevExecutionRunId: result.previous.executionRunId,
|
|
clearAssignee,
|
|
},
|
|
});
|
|
|
|
res.json(result);
|
|
});
|
|
|
|
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/interactions", 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 interactions = await issueThreadInteractionService(db).listForIssue(id);
|
|
res.json(interactions);
|
|
});
|
|
|
|
router.post("/issues/:id/interactions", validate(createIssueThreadInteractionSchema), 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 === "agent") {
|
|
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
|
|
} else {
|
|
assertBoard(req);
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const agentSourceRunId = req.actor.type === "agent" ? requireAgentRunId(req, res) : null;
|
|
if (req.actor.type === "agent" && !agentSourceRunId) return;
|
|
|
|
const interaction = await issueThreadInteractionService(db).create(issue, {
|
|
...req.body,
|
|
sourceRunId: req.actor.type === "agent" ? agentSourceRunId : req.body.sourceRunId ?? null,
|
|
}, {
|
|
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.thread_interaction_created",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
continuationPolicy: interaction.continuationPolicy,
|
|
},
|
|
});
|
|
|
|
res.status(201).json(interaction);
|
|
});
|
|
|
|
router.post(
|
|
"/issues/:id/interactions/:interactionId/accept",
|
|
validate(acceptIssueThreadInteractionSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const interactionId = req.params.interactionId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
assertBoard(req);
|
|
|
|
const actor = getActorInfo(req);
|
|
const { interaction, createdIssues, continuationIssue } = await issueThreadInteractionService(db).acceptInteraction(issue, interactionId, req.body, {
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
});
|
|
const continuationWakeIssue = continuationIssue ?? issue;
|
|
|
|
await logActivity(db, {
|
|
companyId: issue.companyId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
agentId: actor.agentId,
|
|
runId: actor.runId,
|
|
action: interaction.status === "expired"
|
|
? "issue.thread_interaction_expired"
|
|
: "issue.thread_interaction_accepted",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
createdTaskCount:
|
|
interaction.kind === "suggest_tasks"
|
|
? (interaction.result?.createdTasks?.length ?? 0)
|
|
: 0,
|
|
skippedTaskCount:
|
|
interaction.kind === "suggest_tasks"
|
|
? (interaction.result?.skippedClientKeys?.length ?? 0)
|
|
: 0,
|
|
},
|
|
});
|
|
|
|
if (continuationIssue) {
|
|
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: {
|
|
identifier: issue.identifier,
|
|
status: continuationIssue.status,
|
|
assigneeAgentId: continuationIssue.assigneeAgentId ?? null,
|
|
assigneeUserId: continuationIssue.assigneeUserId ?? null,
|
|
source: "request_confirmation_accept",
|
|
interactionId: interaction.id,
|
|
_previous: {
|
|
status: issue.status,
|
|
assigneeAgentId: issue.assigneeAgentId ?? null,
|
|
assigneeUserId: issue.assigneeUserId ?? null,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
for (const createdIssue of createdIssues) {
|
|
void queueIssueAssignmentWakeup({
|
|
heartbeat,
|
|
issue: createdIssue,
|
|
reason: "issue_assigned",
|
|
mutation: "interaction_accept",
|
|
contextSource: "issue.interaction.accept",
|
|
requestedByActorType: actor.actorType,
|
|
requestedByActorId: actor.actorId,
|
|
});
|
|
}
|
|
|
|
const acceptedPlanConfirmation =
|
|
interaction.kind === "request_confirmation" &&
|
|
interaction.status === "accepted" &&
|
|
issue.workMode === "planning";
|
|
queueResolvedInteractionContinuationWakeup({
|
|
heartbeat,
|
|
issue: continuationWakeIssue,
|
|
interaction,
|
|
actor,
|
|
source: "issue.interaction.accept",
|
|
forceFreshSession: acceptedPlanConfirmation,
|
|
workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null,
|
|
});
|
|
|
|
res.json(interaction);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/issues/:id/interactions/:interactionId/reject",
|
|
validate(rejectIssueThreadInteractionSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const interactionId = req.params.interactionId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
assertBoard(req);
|
|
|
|
const actor = getActorInfo(req);
|
|
const interaction = await issueThreadInteractionService(db).rejectInteraction(issue, interactionId, req.body, {
|
|
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: interaction.status === "expired"
|
|
? "issue.thread_interaction_expired"
|
|
: "issue.thread_interaction_rejected",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
rejectionReason:
|
|
interaction.kind === "suggest_tasks"
|
|
? (interaction.result?.rejectionReason ?? null)
|
|
: interaction.kind === "request_confirmation"
|
|
? (interaction.result?.reason ?? null)
|
|
: null,
|
|
},
|
|
});
|
|
|
|
queueResolvedInteractionContinuationWakeup({
|
|
heartbeat,
|
|
issue,
|
|
interaction,
|
|
actor,
|
|
source: "issue.interaction.reject",
|
|
});
|
|
|
|
res.json(interaction);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/issues/:id/interactions/:interactionId/respond",
|
|
validate(respondIssueThreadInteractionSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const interactionId = req.params.interactionId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
assertBoard(req);
|
|
|
|
const actor = getActorInfo(req);
|
|
const interaction = await issueThreadInteractionService(db).answerQuestions(issue, interactionId, req.body, {
|
|
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.thread_interaction_answered",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
answeredQuestionCount:
|
|
interaction.kind === "ask_user_questions"
|
|
? (interaction.result?.answers?.length ?? 0)
|
|
: 0,
|
|
},
|
|
});
|
|
|
|
queueResolvedInteractionContinuationWakeup({
|
|
heartbeat,
|
|
issue,
|
|
interaction,
|
|
actor,
|
|
source: "issue.interaction.respond",
|
|
});
|
|
|
|
res.json(interaction);
|
|
},
|
|
);
|
|
|
|
router.post(
|
|
"/issues/:id/interactions/:interactionId/cancel",
|
|
validate(cancelIssueThreadInteractionSchema),
|
|
async (req, res) => {
|
|
const id = req.params.id as string;
|
|
const interactionId = req.params.interactionId as string;
|
|
const issue = await svc.getById(id);
|
|
if (!issue) {
|
|
res.status(404).json({ error: "Issue not found" });
|
|
return;
|
|
}
|
|
assertCompanyAccess(req, issue.companyId);
|
|
assertBoard(req);
|
|
|
|
const actor = getActorInfo(req);
|
|
const interaction = await issueThreadInteractionService(db).cancelQuestions(issue, interactionId, req.body, {
|
|
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.thread_interaction_cancelled",
|
|
entityType: "issue",
|
|
entityId: issue.id,
|
|
details: {
|
|
interactionId: interaction.id,
|
|
interactionKind: interaction.kind,
|
|
interactionStatus: interaction.status,
|
|
cancellationReason:
|
|
interaction.kind === "ask_user_questions"
|
|
? (interaction.result?.cancellationReason ?? null)
|
|
: null,
|
|
},
|
|
});
|
|
|
|
queueResolvedInteractionContinuationWakeup({
|
|
heartbeat,
|
|
issue,
|
|
interaction,
|
|
actor,
|
|
source: "issue.interaction.cancel",
|
|
});
|
|
|
|
res.json(interaction);
|
|
},
|
|
);
|
|
|
|
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;
|
|
if (!assertStructuredCommentFieldsAllowed(req, res, {
|
|
presentation: req.body.presentation,
|
|
metadata: req.body.metadata,
|
|
})) return;
|
|
const closedExecutionWorkspace = await getClosedIssueExecutionWorkspace(issue);
|
|
if (closedExecutionWorkspace) {
|
|
respondClosedIssueExecutionWorkspace(res, closedExecutionWorkspace);
|
|
return;
|
|
}
|
|
|
|
const actor = getActorInfo(req);
|
|
const reopenRequested = req.body.reopen === true;
|
|
const resumeRequested = req.body.resume === true;
|
|
const interruptRequested = req.body.interrupt === true;
|
|
if (resumeRequested === true && !(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
|
|
if (resumeRequested !== true && reopenRequested === true && req.actor.type === "agent") {
|
|
if (!(await assertExplicitResumeIntentAllowed(req, res, issue))) return;
|
|
}
|
|
const isClosed = isClosedIssueStatus(issue.status);
|
|
const isBlocked = issue.status === "blocked";
|
|
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
|
|
const effectiveMoveToTodoRequested =
|
|
explicitMoveToTodoRequested ||
|
|
shouldImplicitlyMoveCommentedIssueToTodo({
|
|
issueStatus: issue.status,
|
|
assigneeAgentId: issue.assigneeAgentId,
|
|
actorType: actor.actorType,
|
|
actorId: actor.actorId,
|
|
});
|
|
const hasUnresolvedFirstClassBlockers =
|
|
isBlocked && effectiveMoveToTodoRequested
|
|
? (await svc.getDependencyReadiness(issue.id)).unresolvedBlockerCount > 0
|
|
: false;
|
|
if (resumeRequested === true && isBlocked && hasUnresolvedFirstClassBlockers) {
|
|
res.status(409).json({ error: "Issue follow-up blocked by unresolved blockers" });
|
|
return;
|
|
}
|
|
let reopened = false;
|
|
let reopenFromStatus: string | null = null;
|
|
let interruptedRunId: string | null = null;
|
|
let currentIssue = issue;
|
|
const commentReferenceSummaryBefore = await issueReferencesSvc.listIssueReferenceSummary(issue.id);
|
|
|
|
if (effectiveMoveToTodoRequested && (isClosed || (isBlocked && !hasUnresolvedFirstClassBlockers))) {
|
|
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",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
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,
|
|
}, {
|
|
authorType: req.body.authorType ?? (actor.actorType === "agent" ? "agent" : "user"),
|
|
presentation: req.body.presentation ?? null,
|
|
metadata: req.body.metadata ?? null,
|
|
});
|
|
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,
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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),
|
|
}),
|
|
},
|
|
});
|
|
|
|
const expiredInteractions = await issueThreadInteractionService(db).expireRequestConfirmationsSupersededByComment(
|
|
currentIssue,
|
|
comment,
|
|
{
|
|
agentId: actor.agentId,
|
|
userId: actor.actorType === "user" ? actor.actorId : null,
|
|
},
|
|
);
|
|
await logExpiredRequestConfirmations({
|
|
issue: currentIssue,
|
|
interactions: expiredInteractions,
|
|
actor,
|
|
source: "issue.comment",
|
|
});
|
|
|
|
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
|
issue: currentIssue,
|
|
trigger: "comment",
|
|
actor,
|
|
statusChanged: reopened,
|
|
resumeRequested: resumeRequested === true,
|
|
reopened,
|
|
blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo",
|
|
});
|
|
|
|
// 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",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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,
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(interruptedRunId ? { interruptedRunId } : {}),
|
|
},
|
|
});
|
|
} else {
|
|
wakeups.set(assigneeId, {
|
|
source: "automation",
|
|
triggerDetail: "system",
|
|
reason: "issue_commented",
|
|
payload: {
|
|
issueId: currentIssue.id,
|
|
commentId: comment.id,
|
|
mutation: "comment",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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",
|
|
...(resumeRequested === true ? { resumeIntent: true, followUpRequested: true } : {}),
|
|
...(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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
|
|
|
|
const company = await companiesSvc.getById(companyId);
|
|
const attachmentMaxBytes = normalizeIssueAttachmentMaxBytes(company?.attachmentMaxBytes);
|
|
|
|
try {
|
|
await runSingleFileUpload(req, res, attachmentMaxBytes);
|
|
} catch (err) {
|
|
if (err instanceof multer.MulterError) {
|
|
if (err.code === "LIMIT_FILE_SIZE") {
|
|
res.status(422).json({ error: `Attachment exceeds ${attachmentMaxBytes} 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;
|
|
if (!(await assertDeliverableMutationAllowedByRunContext(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;
|
|
}
|