fix(decisions): retire completed-target decisions and link targets from the card (#10892)
<!-- Write all pull request text in Simplified Technical English (ASD-STE100): short sentences, one instruction per sentence, simple approved vocabulary, and the active voice. --> ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The decisions desk shows pending decisions that need an operator response > - A strict decision cannot apply its effects after its target task changes > - A decision still remained pending when every target task finished after proposal > - The card also linked only the origin task, even when the decision acted on another task > - This pull request expires those moot decisions and links their target tasks > - The benefit is an accurate queue and a clear path to the work that each decision affects ## Linked Issues or Issue Description Related PR: #10801 removes the issue-page decision strip, which makes clear queue provenance more important. **What happened?** A strict decision stayed pending until its time-to-live limit after every target task reached `done`. The decision card linked only the origin task. The origin task is where the agent proposed the decision, and it can differ from the task that the decision affects. An operator could therefore open a finished task with no visible decision and no explanation of the real target. **Expected behavior** Paperclip must expire a strict decision when all of its targets finish after the decision is proposed. The card must show and link every target task that differs from the origin task. **Steps to reproduce** 1. Create a strict decision that targets an active task from a different origin task. 2. Move the target task to `done` without resolving the decision. 3. Run the decision expiry sweep. 4. Observe that the old code keeps the decision open until its time-to-live limit. 5. Observe that the old card links only the origin task. **Paperclip version or commit** The bug reproduces on upstream `master` before this pull request. **Deployment mode** Local dev and self-hosted server modes are affected because the behavior is in the shared decision service and board UI. ## What Changed - Expire an open strict decision with reason `target_completed` when every strict target reached `done` after proposal. - Keep decisions that intentionally target an already-finished task. - Keep lenient-only decisions open. - Keep continuation delivery consistent with other expiry reasons. - Add target-task links to the decision card provenance line. - Use one shared target-ID helper across signing, execution, expiry, card provenance, and resolver preloading. - Add service and UI regression tests for primary, secondary, and target-completed cases. ## Verification - `pnpm exec vitest run ui/src/components/DecisionCard.test.tsx server/src/__tests__/decisions-service.test.ts` — 51 tests passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — all gates clean. - `git diff --check origin/master...HEAD` — passed. ## Risks - Low migration risk. This change does not alter the database schema. - The expiry sweep performs the existing strict-target query and adds a snapshot comparison before expiry. - A decision remains open if any strict target is active or if a target was already `done` at proposal time. > The roadmap lists work queues as planned. This pull request fixes the existing decisions desk. It does not add a new queue subsystem. ## Model Used - Implementation: Anthropic Claude through Claude Code. The runtime did not expose the exact model snapshot or context-window size. The model used reasoning, repository tools, code execution, and test execution. - PR preparation: OpenAI Codex with GPT-5. The runtime did not expose a dated model snapshot or context-window size. The model used reasoning, repository tools, code execution, and test execution. ## 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 searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [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
c54936e2e9
commit
ef33c1d9ed
|
|
@ -20,6 +20,8 @@ export {
|
|||
type DecisionSpecInput,
|
||||
} from "./validators/decision.js";
|
||||
|
||||
export { decisionEffectTargetIssueIds } from "./types/decision.js";
|
||||
|
||||
export type {
|
||||
DecisionEffectStaleness,
|
||||
DecisionOptionStyle,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,18 @@ export type DecisionEffect =
|
|||
| CancelIssueTreeDecisionEffect
|
||||
| ResolveBlockerDecisionEffect;
|
||||
|
||||
export function decisionEffectTargetIssueIds(effect: DecisionEffect): string[] {
|
||||
const ids = new Set([effect.targetIssueId]);
|
||||
if (effect.type === "create_issue") {
|
||||
if (effect.draft.parentId) ids.add(effect.draft.parentId);
|
||||
for (const id of effect.draft.blockedByIssueIds ?? []) ids.add(id);
|
||||
}
|
||||
if (effect.type === "resolve_blocker") {
|
||||
for (const id of effect.removeBlockedByIssueIds) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
export interface DecisionOption {
|
||||
id: string;
|
||||
label: string;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { decisionEffectTargetIssueIds } from "./decision.js";
|
||||
export type {
|
||||
Company,
|
||||
InteractionResolverGovernance,
|
||||
|
|
|
|||
|
|
@ -542,6 +542,61 @@ describePg("decisionService", () => {
|
|||
expect(wakes).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("expires strict decisions whose targets completed after they were proposed", async () => {
|
||||
const completed = await createCommentDecision("strict", { idempotencyKey: "target-completed" });
|
||||
const lenient = await createCommentDecision("lenient", { idempotencyKey: "lenient-survives" });
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, targetIssueId));
|
||||
|
||||
expect((await service().sweepExpired()).expired).toBe(1);
|
||||
|
||||
expect((await service().get(completed.id))?.metadata).toMatchObject({ expiredReason: "target_completed" });
|
||||
expect((await service().get(lenient.id))?.status).toBe("open");
|
||||
expect(wakes).toEqual([{ companyId, agentId, issueId: originIssueId, decisionId: completed.id, outcome: "expired" }]);
|
||||
});
|
||||
|
||||
it("keeps strict decisions that intentionally target an already-done issue", async () => {
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, targetIssueId));
|
||||
const reopen = await createCommentDecision("strict", { idempotencyKey: "already-done" });
|
||||
|
||||
expect((await service().sweepExpired()).expired).toBe(0);
|
||||
expect((await service().get(reopen.id))?.status).toBe("open");
|
||||
});
|
||||
|
||||
it("expires when a strict secondary target completes after proposal", async () => {
|
||||
const blockerId = randomUUID();
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, targetIssueId));
|
||||
await db.insert(issues).values({
|
||||
id: blockerId,
|
||||
companyId,
|
||||
title: "Secondary blocker",
|
||||
status: "todo",
|
||||
priority: "medium",
|
||||
responsibleUserId: decidedByUserId,
|
||||
});
|
||||
const created = await service().create({
|
||||
companyId,
|
||||
actor: agentActor(),
|
||||
agentId,
|
||||
runId,
|
||||
title: "Create follow-up?",
|
||||
body: "Body",
|
||||
options: [{
|
||||
id: "yes",
|
||||
label: "Yes",
|
||||
effects: [{
|
||||
type: "create_issue",
|
||||
targetIssueId,
|
||||
staleness: "strict",
|
||||
draft: { title: "Follow-up", blockedByIssueIds: [blockerId] },
|
||||
}],
|
||||
}],
|
||||
});
|
||||
await db.update(issues).set({ status: "done" }).where(eq(issues.id, blockerId));
|
||||
|
||||
expect((await service().sweepExpired()).expired).toBe(1);
|
||||
expect((await service().get(created.id))?.metadata).toMatchObject({ expiredReason: "target_completed" });
|
||||
});
|
||||
|
||||
it("groups rule-key stats and separates explicit dismissals from expiry", async () => {
|
||||
const accepted = await service().create({
|
||||
companyId, actor: agentActor(), agentId, runId, ruleKey: "routing.assign", title: "Assign?", body: "Body",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||
import { and, asc, count, desc, eq, gt, gte, inArray, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companyMemberships, decisionBundles, decisionEffectExecutions, decisionRetention, decisions, decisionTargetIssues, heartbeatRuns, issueRelations, issues } from "@paperclipai/db";
|
||||
import { ATTENTION_SOURCE_KINDS } from "@paperclipai/shared";
|
||||
import { ATTENTION_SOURCE_KINDS, decisionEffectTargetIssueIds } from "@paperclipai/shared";
|
||||
import type { AttentionArchiveManifestEntry, DecisionEffect, DecisionInput, DecisionOption, DecisionStatsCounts, DecisionStatsResponse } from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../errors.js";
|
||||
import { authorizationService, type AuthorizationActor } from "./authorization.js";
|
||||
|
|
@ -17,19 +17,9 @@ type Wake = (input: { companyId: string; agentId: string; issueId: string; decis
|
|||
export type DecisionServiceOptions = { wakeOriginAgent: Wake };
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function effectTargetIds(effect: DecisionEffect) {
|
||||
const result = new Set([effect.targetIssueId]);
|
||||
if (effect.type === "create_issue") {
|
||||
if (effect.draft.parentId) result.add(effect.draft.parentId);
|
||||
for (const id of effect.draft.blockedByIssueIds ?? []) result.add(id);
|
||||
}
|
||||
if (effect.type === "resolve_blocker") for (const id of effect.removeBlockedByIssueIds) result.add(id);
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function targetIds(options: DecisionOption[]) {
|
||||
const result = new Set<string>();
|
||||
for (const option of options) for (const effect of option.effects) for (const id of effectTargetIds(effect)) result.add(id);
|
||||
for (const option of options) for (const effect of option.effects) for (const id of decisionEffectTargetIssueIds(effect)) result.add(id);
|
||||
return [...result];
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +27,7 @@ function targetActions(options: DecisionOption[]) {
|
|||
const result = new Map<string, Set<"issue:comment" | "issue:mutate">>();
|
||||
for (const option of options) for (const effect of option.effects) {
|
||||
const action = effect.type === "comment_on_issue" ? "issue:comment" as const : "issue:mutate" as const;
|
||||
for (const id of effectTargetIds(effect)) {
|
||||
for (const id of decisionEffectTargetIssueIds(effect)) {
|
||||
const actions = result.get(id) ?? new Set();
|
||||
actions.add(action);
|
||||
result.set(id, actions);
|
||||
|
|
@ -431,7 +421,7 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
const [row] = await tx.update(decisionEffectExecutions).set({ status, error: reason, result: details, activityLogId: activity?.id ?? null, executedAt: new Date() }).where(eq(decisionEffectExecutions.id, execution!.id)).returning();
|
||||
return row;
|
||||
};
|
||||
const directReferencedIds = new Set(effectTargetIds(effect));
|
||||
const directReferencedIds = new Set(decisionEffectTargetIssueIds(effect));
|
||||
const snapshots = decision.targetSnapshots as Record<string, Snapshot>;
|
||||
const cancellationDescendantIds = effect.type === "cancel_issue_tree"
|
||||
? snapshots[effect.targetIssueId]?.descendantIds
|
||||
|
|
@ -757,13 +747,21 @@ export function decisionService(db: Db, options: DecisionServiceOptions) {
|
|||
: [];
|
||||
targetSweepCursor = targetRows.length === remaining && targetRows.length > 0 ? targetRows[targetRows.length - 1]!.id : null;
|
||||
const rows = [...new Map([...ttlRows, ...targetRows].map((row) => [row.id, row])).values()]; let expired = 0;
|
||||
for (const decision of rows) { const strictTargetIds = new Set(decision.options.flatMap((option) => option.effects.filter((effect) => effect.staleness === "strict").map((effect) => effect.targetIssueId)));
|
||||
for (const decision of rows) { const strictTargetIds = new Set(decision.options.flatMap((option) => option.effects.filter((effect) => effect.staleness === "strict").flatMap(decisionEffectTargetIssueIds)));
|
||||
const targets = strictTargetIds.size > 0
|
||||
? await db.select({ id: issues.id, status: issues.status }).from(issues).where(and(eq(issues.companyId, decision.companyId), inArray(issues.id, [...strictTargetIds])))
|
||||
: [];
|
||||
const targetGone = targets.length !== strictTargetIds.size || targets.some((target) => target.status === "cancelled");
|
||||
if (!targetGone && decision.expiresAt >= now) continue;
|
||||
const reason = targetGone ? "target_gone" : "ttl";
|
||||
// A decision whose strict targets all completed after it was proposed is moot:
|
||||
// the strict guard would skip its effects anyway, so retire it instead of
|
||||
// leaving it pending until TTL. Targets that were already done at proposal
|
||||
// time don't count — those decisions intentionally act on a done issue.
|
||||
const snapshots = decision.targetSnapshots as Record<string, Snapshot>;
|
||||
const targetsCompleted = !targetGone && strictTargetIds.size > 0 &&
|
||||
targets.every((target) => target.status === "done") &&
|
||||
targets.some((target) => snapshots[target.id]?.status !== "done");
|
||||
if (!targetGone && !targetsCompleted && decision.expiresAt >= now) continue;
|
||||
const reason = targetGone ? "target_gone" : targetsCompleted ? "target_completed" : "ttl";
|
||||
const [updated] = await db.update(decisions).set({ status: "expired", updatedAt: now, metadata: { ...decision.metadata, expiredReason: reason,
|
||||
...(decision.continuationPolicy === "wake_origin_agent" ? { continuationPending: true } : {}) } }).where(and(eq(decisions.id, decision.id), eq(decisions.status, "open"))).returning();
|
||||
if (!updated) continue; expired += 1;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ vi.mock("@/lib/router", () => ({
|
|||
const ISSUES: Record<string, DecisionIssueRef> = {
|
||||
"issue-origin": { id: "issue-origin", identifier: "PAP-123", title: "Gardener sweep", href: "/PAP/issues/PAP-123", status: "in_progress" },
|
||||
"issue-target": { id: "issue-target", identifier: "PAP-456", title: "Stale epic", href: "/PAP/issues/PAP-456", status: "backlog" },
|
||||
"issue-parent": { id: "issue-parent", identifier: "PAP-789", title: "Parent task", href: "/PAP/issues/PAP-789", status: "todo" },
|
||||
"issue-blocker": { id: "issue-blocker", identifier: "PAP-790", title: "Blocking task", href: "/PAP/issues/PAP-790", status: "todo" },
|
||||
"issue-new": { id: "issue-new", identifier: "PAP-999", title: "Follow-up", href: "/PAP/issues/PAP-999", status: "todo" },
|
||||
};
|
||||
const resolveIssue = (id: string): DecisionIssueRef | null => ISSUES[id] ?? null;
|
||||
|
|
@ -135,6 +137,54 @@ describe("DecisionCard", () => {
|
|||
expect([...el.querySelectorAll("button")].some((b) => b.textContent?.includes("Dismiss"))).toBe(true);
|
||||
});
|
||||
|
||||
it("links the target issue the decision applies to, not just the origin", () => {
|
||||
const el = render({});
|
||||
expect(el.textContent).toContain("applies to");
|
||||
const provenance = el.querySelector("p");
|
||||
expect(provenance?.textContent).toContain("PAP-456");
|
||||
expect(
|
||||
[...(provenance?.querySelectorAll("a") ?? [])].some((a) => a.getAttribute("href") === "/PAP/issues/PAP-456"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the applies-to link when the decision only targets its origin issue", () => {
|
||||
const el = render({
|
||||
decision: mkDecision({
|
||||
options: [
|
||||
{ id: "comment", label: "Comment", effects: [{ type: "comment_on_issue", targetIssueId: "issue-origin", staleness: "lenient", bodyMarkdown: "nudge" }] },
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(el.textContent).not.toContain("applies to");
|
||||
});
|
||||
|
||||
it("links secondary target issues referenced by an effect", () => {
|
||||
const el = render({
|
||||
decision: mkDecision({
|
||||
options: [{
|
||||
id: "create",
|
||||
label: "Create follow-up",
|
||||
effects: [{
|
||||
type: "create_issue",
|
||||
targetIssueId: "issue-target",
|
||||
staleness: "strict",
|
||||
draft: {
|
||||
title: "Follow-up",
|
||||
parentId: "issue-parent",
|
||||
blockedByIssueIds: ["issue-blocker"],
|
||||
},
|
||||
}],
|
||||
}],
|
||||
}),
|
||||
});
|
||||
const targetHrefs = [...(el.querySelector("p")?.querySelectorAll("a") ?? [])].map((a) => a.getAttribute("href"));
|
||||
expect(targetHrefs).toEqual(expect.arrayContaining([
|
||||
"/PAP/issues/PAP-456",
|
||||
"/PAP/issues/PAP-789",
|
||||
"/PAP/issues/PAP-790",
|
||||
]));
|
||||
});
|
||||
|
||||
it("fires onDecide with the chosen option id", () => {
|
||||
const onDecide = vi.fn();
|
||||
const el = render({ onDecide });
|
||||
|
|
@ -262,4 +312,12 @@ describe("DecisionCard", () => {
|
|||
expect(dismissed.textContent).toContain("Dismissed");
|
||||
expect(dismissed.textContent).toContain("no effects were run");
|
||||
});
|
||||
|
||||
it("explains when a decision expires because its targets completed", () => {
|
||||
const expired = render({
|
||||
decision: mkDecision({ status: "expired", metadata: { expiredReason: "target_completed" } }),
|
||||
});
|
||||
expect(expired.textContent).toContain("target issues were completed");
|
||||
expect(expired.textContent).not.toContain("expiry deadline");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
ShieldAlert,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { DecisionEffect, DecisionOption } from "@paperclipai/shared";
|
||||
import { decisionEffectTargetIssueIds, type DecisionEffect, type DecisionOption } from "@paperclipai/shared";
|
||||
import type {
|
||||
Decision,
|
||||
DecisionEffectExecution,
|
||||
|
|
@ -68,18 +68,6 @@ function humanStatus(status: string | null | undefined): string {
|
|||
return status.replaceAll("_", " ");
|
||||
}
|
||||
|
||||
function referencedTargetIds(effect: DecisionEffect): string[] {
|
||||
const ids = new Set([effect.targetIssueId]);
|
||||
if (effect.type === "create_issue") {
|
||||
if (effect.draft.parentId) ids.add(effect.draft.parentId);
|
||||
for (const id of effect.draft.blockedByIssueIds ?? []) ids.add(id);
|
||||
}
|
||||
if (effect.type === "resolve_blocker") {
|
||||
for (const id of effect.removeBlockedByIssueIds) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function issueLabel(ref: DecisionIssueRef | null, fallbackId: string): string {
|
||||
if (ref?.identifier) return ref.identifier;
|
||||
if (ref?.title) return ref.title;
|
||||
|
|
@ -312,6 +300,22 @@ export function DecisionCard({
|
|||
};
|
||||
|
||||
const dimmed = decision.status === "expired" || decision.status === "cancelled";
|
||||
const expiredReason = (decision.metadata as { expiredReason?: string } | null)?.expiredReason;
|
||||
|
||||
// The issues this decision acts on. The origin issue is only where the agent
|
||||
// was running when it proposed the decision — the queue links there, but the
|
||||
// decision may target a different issue entirely, so name the targets
|
||||
// explicitly or the card is undiscoverable from the issue it applies to.
|
||||
const targetRefs = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const option of decision.options) {
|
||||
for (const effect of option.effects) {
|
||||
for (const id of decisionEffectTargetIssueIds(effect)) ids.add(id);
|
||||
}
|
||||
}
|
||||
if (originIssue?.id) ids.delete(originIssue.id);
|
||||
return [...ids].map((id) => ({ id, ref: resolveIssue(id) }));
|
||||
}, [decision.options, originIssue?.id, resolveIssue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -344,11 +348,28 @@ export function DecisionCard({
|
|||
{originIssue && (
|
||||
<>
|
||||
{" "}while running{" "}
|
||||
<a href={originIssue.href} className="font-medium text-sky-700 hover:underline dark:text-sky-300">
|
||||
<a href={originIssue.href} className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
{issueLabel(originIssue, originIssue.id)}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{targetRefs.length > 0 && (
|
||||
<>
|
||||
{" · applies to "}
|
||||
{targetRefs.map(({ id, ref }, index) => (
|
||||
<span key={id}>
|
||||
{index > 0 && ", "}
|
||||
{ref ? (
|
||||
<a href={ref.href} className="font-medium text-primary underline-offset-2 hover:underline">
|
||||
{issueLabel(ref, id)}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium text-foreground">{issueLabel(null, id)}</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{runHref && (
|
||||
<>
|
||||
{" · "}
|
||||
|
|
@ -418,7 +439,7 @@ export function DecisionCard({
|
|||
{decision.options.map((option) => {
|
||||
const destructive = isDestructiveOption(option);
|
||||
const blockedStale = option.effects.some(
|
||||
(effect) => effect.staleness === "strict" && referencedTargetIds(effect).some((id) => staleTargetIdSet.has(id)),
|
||||
(effect) => effect.staleness === "strict" && decisionEffectTargetIssueIds(effect).some((id) => staleTargetIdSet.has(id)),
|
||||
);
|
||||
const disabled = busy || requiredUnmet || blockedStale;
|
||||
const cancelTree = cancelTreeEffect(option);
|
||||
|
|
@ -559,9 +580,11 @@ export function DecisionCard({
|
|||
<Clock className="h-4 w-4" aria-hidden /> The decision window closed
|
||||
</div>
|
||||
<p className="mt-1">
|
||||
{((decision.metadata as { expiredReason?: string } | null)?.expiredReason === "target_gone")
|
||||
{expiredReason === "target_gone"
|
||||
? "A target issue was cancelled before this was decided."
|
||||
: "No response before the expiry deadline."}
|
||||
: expiredReason === "target_completed"
|
||||
? "All target issues were completed before this was decided."
|
||||
: "No response before the expiry deadline."}
|
||||
{decision.continuationPolicy === "wake_origin_agent" && " The proposer was re-woken."}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { flushSync } from "react-dom";
|
|||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ issueStatus: "todo" }));
|
||||
const state = vi.hoisted(() => ({ issueStatus: "todo", queriedIssueIds: [] as string[] }));
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn(), setQueryData: vi.fn() }),
|
||||
|
|
@ -18,9 +18,20 @@ vi.mock("@tanstack/react-query", () => ({
|
|||
originIssueId: "origin-1",
|
||||
status: "open",
|
||||
targetSnapshots: { "target-1": { updatedAt: "2026-07-31T00:00:00.000Z" } },
|
||||
options: [{ id: "yes", label: "Yes", effects: [{
|
||||
type: "comment_on_issue", targetIssueId: "target-1", staleness: "lenient", bodyMarkdown: "hello",
|
||||
}] }],
|
||||
options: [{ id: "yes", label: "Yes", effects: [
|
||||
{
|
||||
type: "create_issue",
|
||||
targetIssueId: "target-1",
|
||||
staleness: "strict",
|
||||
draft: { title: "Follow-up", parentId: "parent-1", blockedByIssueIds: ["blocker-1"] },
|
||||
},
|
||||
{
|
||||
type: "resolve_blocker",
|
||||
targetIssueId: "target-1",
|
||||
staleness: "strict",
|
||||
removeBlockedByIssueIds: ["removed-blocker-1"],
|
||||
},
|
||||
] }],
|
||||
executions: [],
|
||||
},
|
||||
isLoading: false,
|
||||
|
|
@ -34,8 +45,9 @@ vi.mock("@tanstack/react-query", () => ({
|
|||
queries: Array<{ queryKey: readonly string[] }>;
|
||||
combine?: (results: Array<{ data: { id: string; identifier: string; title: string; status: string } }>) => unknown;
|
||||
}) => {
|
||||
const results = queries.map(() => ({
|
||||
data: { id: "target-1", identifier: "PAP-1", title: "Target", status: state.issueStatus },
|
||||
state.queriedIssueIds = queries.map((query) => String(query.queryKey[2]));
|
||||
const results = queries.map((query) => ({
|
||||
data: { id: String(query.queryKey[2]), identifier: "PAP-1", title: "Target", status: state.issueStatus },
|
||||
}));
|
||||
return combine ? combine(results) : results;
|
||||
},
|
||||
|
|
@ -60,6 +72,7 @@ describe("DecisionResolver", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
state.issueStatus = "todo";
|
||||
state.queriedIssueIds = [];
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
|
@ -85,4 +98,18 @@ describe("DecisionResolver", () => {
|
|||
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("loads every primary and secondary effect target", () => {
|
||||
const root = createRoot(container);
|
||||
flushSync(() => root.render(<DecisionResolver companyId="company-1" decisionId="decision-1" />));
|
||||
|
||||
expect(state.queriedIssueIds).toEqual(expect.arrayContaining([
|
||||
"target-1",
|
||||
"parent-1",
|
||||
"blocker-1",
|
||||
"removed-blocker-1",
|
||||
]));
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { Agent, AttentionSubject } from "@paperclipai/shared";
|
||||
import { decisionEffectTargetIssueIds, type Agent, type AttentionSubject } from "@paperclipai/shared";
|
||||
import { decisionsApi, type DecisionOutcome } from "../api/decisions";
|
||||
import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
|
|
@ -78,8 +78,7 @@ export function DecisionResolver({ companyId, decisionId, originIssue, agentMap,
|
|||
}
|
||||
for (const option of decision.options) {
|
||||
for (const effect of option.effects) {
|
||||
ids.add(effect.targetIssueId);
|
||||
if (effect.type === "create_issue" && effect.draft.parentId) ids.add(effect.draft.parentId);
|
||||
for (const id of decisionEffectTargetIssueIds(effect)) ids.add(id);
|
||||
}
|
||||
}
|
||||
for (const execution of decision.executions ?? []) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue