mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-17 11:20:37 +09:00
[codex] Roll up May 17 branch changes (#6210)
## Thinking Path > - Paperclip is the control plane for autonomous AI companies, so agent work needs visible ownership, recovery, and operator controls. > - This local branch had accumulated several related control-plane reliability and operator-experience fixes across recovery actions, watchdog folding, model-profile defaults, mentions, markdown editing, plugin launchers, and small UI polish. > - The branch needed to be converted into a PR against the current `origin/master` without losing dirty work or including lockfile/workflow churn. > - The safest standalone shape is a single rollup PR because the recovery/server/UI files overlap heavily across the local commits and splitting would create avoidable conflicts. > - This pull request replays the local branch onto latest `origin/master`, preserves the uncommitted work as logical commits, and adds a Zod 4 validator compatibility fix found during verification. > - The benefit is that the May 17 local branch can be reviewed and merged as one coherent, conflict-free branch under the 100-file Greptile limit. ## What Changed - Rebased the local May 17 branch work onto current `origin/master` in a dedicated worktree. - Preserved and committed previously dirty changes for recovery retry handling, plugin/sidebar launcher polish, and `.herenow` ignores. - Added recovery-action behavior for returning source issues to `todo` when retrying source-scoped recovery. - Included the existing local recovery/liveness/watchdog fold, Codex cheap-profile, markdown/mention, duplicate-agent, and UI polish commits from the branch. - Normalized shared validator `z.record(...)` schemas to explicit string-key records for Zod 4 compatibility. - Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*` changes and stays below the 100-file Greptile limit. ## Verification - `pnpm install --frozen-lockfile --ignore-scripts` - `npm run install` in `node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the local native sqlite3 binding after installing with scripts disabled - `pnpm exec vitest run packages/shared/src/validators/issue.test.ts packages/shared/src/project-mentions.test.ts packages/adapter-utils/src/server-utils.test.ts server/src/__tests__/heartbeat-model-profile.test.ts server/src/__tests__/issue-recovery-actions.test.ts server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts server/src/__tests__/plugin-local-folders.test.ts ui/src/components/IssueRecoveryActionCard.test.tsx ui/src/components/Sidebar.test.tsx ui/src/components/SidebarAccountMenu.test.tsx ui/src/components/IssueProperties.test.tsx ui/src/components/MarkdownEditor.test.tsx ui/src/components/MarkdownBody.test.tsx ui/src/lib/duplicate-agent-payload.test.ts ui/src/pages/Routines.test.tsx` - First pass: 13 files passed with 201 passing tests; 3 server files failed before sqlite3 native binding was built. - After rebuilding sqlite3: `server/src/__tests__/heartbeat-model-profile.test.ts`, `server/src/__tests__/issue-recovery-actions.test.ts`, and `server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts` passed/loaded; embedded Postgres tests were skipped by the local host guard. - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` ## Risks - Medium risk: this is a broad rollup PR across recovery semantics, server tests, shared validators, and UI surfaces. - Some embedded Postgres tests skipped locally due the host guard, so CI should provide the stronger database-backed signal. - UI changes were covered by component tests, but no browser screenshot was captured in this PR creation pass. - This branch may overlap with existing recovery/liveness PR work; merge this PR independently or restack/close overlapping branches rather than merging duplicate implementations together. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository and GitHub workflow, medium reasoning effort. ## 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 - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
705c1b8d81
commit
d734bd43d1
83 changed files with 3675 additions and 180 deletions
|
|
@ -117,6 +117,13 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
|
|||
|
||||
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>;
|
||||
};
|
||||
|
|
@ -636,6 +643,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
|||
};
|
||||
actor: { actorType: "user" | "agent"; actorId: string };
|
||||
source: string;
|
||||
forceFreshSession?: boolean;
|
||||
workspaceRefreshReason?: string | null;
|
||||
}) {
|
||||
if (
|
||||
input.interaction.continuationPolicy !== "wake_assignee"
|
||||
|
|
@ -648,6 +657,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
|||
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",
|
||||
|
|
@ -673,6 +684,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
|
|||
sourceRunId: input.interaction.sourceRunId ?? null,
|
||||
wakeReason: "issue_commented",
|
||||
source: input.source,
|
||||
...(forceFreshSession ? { forceFreshSession: true } : {}),
|
||||
...(workspaceRefreshReason ? { workspaceRefreshReason } : {}),
|
||||
},
|
||||
}).catch((err) => logger.warn({
|
||||
err,
|
||||
|
|
@ -843,6 +856,7 @@ export function issueRoutes(
|
|||
const workProductsSvc = workProductService(db);
|
||||
const documentsSvc = documentService(db);
|
||||
const issueReferencesSvc = issueReferenceService(db);
|
||||
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
|
||||
const routinesSvc = routineService(db, {
|
||||
pluginWorkerManager: opts.pluginWorkerManager,
|
||||
});
|
||||
|
|
@ -857,6 +871,182 @@ export function issueRoutes(
|
|||
};
|
||||
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,
|
||||
|
|
@ -1240,6 +1430,51 @@ export function issueRoutes(
|
|||
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;
|
||||
|
|
@ -1512,6 +1747,19 @@ export function issueRoutes(
|
|||
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,
|
||||
|
|
@ -1668,6 +1916,12 @@ export function issueRoutes(
|
|||
relations,
|
||||
recoveryActionsByRelationIssue,
|
||||
);
|
||||
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
actor: getActorInfo(req),
|
||||
activeRecoveryAction,
|
||||
});
|
||||
|
||||
res.json({
|
||||
issue: {
|
||||
|
|
@ -1680,7 +1934,7 @@ export function issueRoutes(
|
|||
...(blockerAttention ? { blockerAttention } : {}),
|
||||
productivityReview,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
priority: issue.priority,
|
||||
projectId: issue.projectId,
|
||||
goalId: goal?.id ?? issue.goalId,
|
||||
|
|
@ -1786,6 +2040,12 @@ export function issueRoutes(
|
|||
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)
|
||||
: [];
|
||||
|
|
@ -1801,7 +2061,7 @@ export function issueRoutes(
|
|||
productivityReview,
|
||||
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
|
||||
scheduledRetry,
|
||||
activeRecoveryAction,
|
||||
activeRecoveryAction: revalidatedActiveRecoveryAction,
|
||||
blockedBy: relationsWithRecoveryActions.blockedBy,
|
||||
blocks: relationsWithRecoveryActions.blocks,
|
||||
relatedWork: referenceSummary,
|
||||
|
|
@ -1823,7 +2083,11 @@ export function issueRoutes(
|
|||
return;
|
||||
}
|
||||
assertCompanyAccess(req, issue.companyId);
|
||||
const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id);
|
||||
const active = await revalidateActiveSourceRecoveryForRead({
|
||||
issue,
|
||||
trigger: "read_projection",
|
||||
actor: getActorInfo(req),
|
||||
});
|
||||
res.json({
|
||||
active,
|
||||
actions: active ? [active] : [],
|
||||
|
|
@ -1839,6 +2103,18 @@ export function issueRoutes(
|
|||
}
|
||||
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") {
|
||||
|
|
@ -1948,6 +2224,36 @@ export function issueRoutes(
|
|||
},
|
||||
});
|
||||
|
||||
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,
|
||||
|
|
@ -2087,6 +2393,13 @@ export function issueRoutes(
|
|||
});
|
||||
}
|
||||
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "document",
|
||||
actor,
|
||||
documentChanged: true,
|
||||
});
|
||||
|
||||
res.status(result.created ? 201 : 200).json(doc);
|
||||
});
|
||||
|
||||
|
|
@ -2274,6 +2587,13 @@ export function issueRoutes(
|
|||
source: "issue.document_restored",
|
||||
});
|
||||
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "document",
|
||||
actor,
|
||||
documentChanged: true,
|
||||
});
|
||||
|
||||
res.json(result.document);
|
||||
},
|
||||
);
|
||||
|
|
@ -2344,6 +2664,12 @@ export function issueRoutes(
|
|||
actor,
|
||||
source: "issue.document_deleted",
|
||||
});
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "document",
|
||||
actor,
|
||||
documentChanged: true,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
|
|
@ -2376,6 +2702,12 @@ export function issueRoutes(
|
|||
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);
|
||||
});
|
||||
|
||||
|
|
@ -2410,6 +2742,12 @@ export function issueRoutes(
|
|||
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);
|
||||
});
|
||||
|
||||
|
|
@ -2444,6 +2782,12 @@ export function issueRoutes(
|
|||
entityId: existing.issueId,
|
||||
details: { workProductId: removed.id, type: removed.type },
|
||||
});
|
||||
await revalidateActiveSourceRecoveryAfterCommittedWrite({
|
||||
issue,
|
||||
trigger: "work_product",
|
||||
actor,
|
||||
workProductChanged: true,
|
||||
});
|
||||
res.json(removed);
|
||||
});
|
||||
|
||||
|
|
@ -2931,6 +3275,28 @@ export function issueRoutes(
|
|||
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 &&
|
||||
|
|
@ -3207,6 +3573,7 @@ export function issueRoutes(
|
|||
let issueResponse: typeof issue & {
|
||||
blockedBy?: unknown;
|
||||
blocks?: unknown;
|
||||
activeRecoveryAction?: unknown;
|
||||
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
|
||||
referencedIssueIdentifiers?: string[];
|
||||
} = issue;
|
||||
|
|
@ -3258,6 +3625,32 @@ export function issueRoutes(
|
|||
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,
|
||||
|
|
@ -3531,10 +3924,6 @@ export function issueRoutes(
|
|||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
const statusChangedFromBlockedToTodo =
|
||||
existing.status === "blocked" &&
|
||||
issue.status === "todo" &&
|
||||
(req.body.status !== undefined || reopened);
|
||||
const statusChangedFromClosedToTodo =
|
||||
isClosedIssueStatus(existing.status) &&
|
||||
issue.status === "todo" &&
|
||||
|
|
@ -4126,12 +4515,18 @@ export function issueRoutes(
|
|||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
@ -4630,6 +5025,16 @@ export function issueRoutes(
|
|||
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]>();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue