mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 12:37:55 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The Decisions desk shows work that needs a human decision > - The queue foundation can group and rank decision work > - Operators also need a focused daily view and a safe way to handle old work > - This pull request adds the desk controls, the aging shelf, and reversible retention > - It also binds bulk archive decisions to the exact reviewed item set > - The benefit is a smaller daily queue without lost or orphaned work ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: server, UI, database, and shared contracts. **Problem or motivation** Decision work can grow into one large company list. Operators need quick queue and date controls. Old items also need a safe retention path that does not delete work. **Proposed solution** Add queue and date controls to the Decisions desk. Compute the aging shelf on the server. Archive idle items after 90 days unless an operator keeps them. Keep archived items searchable and revivable. Notify origin agents in one batch per sweep. Bind bulk archive proposals to a signed, exact item manifest. **Alternatives considered** Client-only aging can drift across browsers and source kinds. Deleting old rows removes audit and recovery paths. An unsigned dynamic bulk query can archive items that the reviewer did not inspect. **Roadmap alignment** This work supports the Work Queues and decision-memory directions in `ROADMAP.md`. It extends the Decisions and attention-feed foundation from #10651. Related earlier work includes #9380, #10010, and #10474. ## What Changed - Added the queue rail, date chips, decide split, triage strip, queue page, and aging shelf UI. - Added server-owned shelf state with per-queue retention overrides. - Added reversible retention state, archive history, and an idempotent notification outbox. - Added the 90-day archive sweeper, Keep exemption, archived feed query, and revive actions. - Added one origin-agent notification per agent and sweep. - Added signed bulk archive proposals with exact-set and version checks. - Persisted queue-exclusion reasons atomically and kept cross-domain source resolution per-item until it has an exact-set transaction contract. - Added API contracts, OpenAPI entries, migration coverage, focused tests, and Storybook screens. ## Verification - `pnpm -r typecheck` - `pnpm test:run` (server: 329 files and 3,453 tests passed; UI: 408 files and 3,362 tests passed) - `env -u AWS_ACCESS_KEY_ID -u AWS_SECRET_ACCESS_KEY pnpm exec vitest run cli/src/__tests__/secrets.test.ts` - `pnpm build` - `pnpm check:token-gates` - Focused retention, attention, decisions, migration replay, startup, and UI API tests. - Complete queue snapshot regression with 51 items across the normal 50-item page boundary. The unmodified CLI test reports one warning assertion in this runtime because the harness injects static AWS credential variables. The isolated test passes when those two variables are removed. ## Risks - The migration adds retention and notification outbox tables. It uses idempotent table, index, and foreign-key creation. - Retention runs on the heartbeat scheduler interval. Compare-and-set version checks prevent stale archive writes. - Bulk archive acceptance fails closed when authority, activity, version, or the reviewed set changes. - Archive is reversible and does not delete source records. > 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 with `gpt-5.6-sol`. The model used tool calls, code execution, database migration generation, and test execution. The context-window size is not exposed in this runtime. ## 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: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Paperclip <noreply@paperclip.ing>
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { createRoot } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type {
|
|
AttentionItem,
|
|
DecisionTrainingExample,
|
|
DecisionTrainingPreview,
|
|
} from "@paperclipai/shared";
|
|
|
|
const mockApi = vi.hoisted(() => ({
|
|
preview: vi.fn(),
|
|
create: vi.fn(),
|
|
get: vi.fn(),
|
|
updateNotes: vi.fn(),
|
|
delete: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../api/decisionTraining", async (importOriginal) => {
|
|
const original = await importOriginal<typeof import("../api/decisionTraining")>();
|
|
return { ...original, decisionTrainingApi: mockApi };
|
|
});
|
|
|
|
vi.mock("@/lib/router", () => ({
|
|
Link: ({ children, to, ...props }: React.ComponentProps<"a"> & { to: string }) => (
|
|
<a href={to} {...props}>{children}</a>
|
|
),
|
|
}));
|
|
|
|
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
import { DecisionTrainingDrawer } from "./DecisionTrainingDrawer";
|
|
import { ToastProvider } from "../context/ToastContext";
|
|
|
|
function act(callback: () => void | Promise<void>) {
|
|
let result: void | Promise<void> | undefined;
|
|
flushSync(() => {
|
|
result = callback();
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function waitFor(predicate: () => boolean, attempts = 40): Promise<void> {
|
|
for (let i = 0; i < attempts; i += 1) {
|
|
if (predicate()) return;
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
});
|
|
}
|
|
throw new Error("waitFor predicate did not become true");
|
|
}
|
|
|
|
function setTextareaValue(el: HTMLTextAreaElement, value: string) {
|
|
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!;
|
|
setter.call(el, value);
|
|
el.dispatchEvent(new Event("input", { bubbles: true }));
|
|
}
|
|
|
|
/** Text query across the whole document (Radix portals content into body). */
|
|
function bodyText(): string {
|
|
return document.body.textContent ?? "";
|
|
}
|
|
|
|
function findButton(label: string): HTMLButtonElement | null {
|
|
return [...document.body.querySelectorAll("button")].find(
|
|
(b) => (b.textContent ?? "").trim().includes(label),
|
|
) as HTMLButtonElement | null;
|
|
}
|
|
|
|
/** Buttons or anchors (asChild renders the trigger as its child element). */
|
|
function findClickable(label: string): HTMLElement | null {
|
|
return [...document.body.querySelectorAll("button, a")].find(
|
|
(el) => (el.textContent ?? "").trim().includes(label),
|
|
) as HTMLElement | null;
|
|
}
|
|
|
|
function buildItem(overrides: Partial<AttentionItem> = {}): AttentionItem {
|
|
return {
|
|
id: "row-1",
|
|
companyId: "c1",
|
|
sourceKind: "issue_thread_interaction",
|
|
subject: {
|
|
kind: "interaction",
|
|
id: "interaction-1",
|
|
companyId: "c1",
|
|
title: "Approve the migration plan?",
|
|
identifier: null,
|
|
status: "pending",
|
|
href: "/tasks/task-1",
|
|
metadata: { issueId: "issue-1", kind: "request_confirmation" },
|
|
},
|
|
whyNow: "",
|
|
decisionVerbs: [],
|
|
inlineResolvable: true,
|
|
entryRule: "",
|
|
exitRule: "",
|
|
dedupKey: "interaction:interaction-1",
|
|
dismissalKey: "attention:interaction:interaction-1",
|
|
severity: "medium",
|
|
rank: 0,
|
|
activityAt: "2026-07-09T12:00:00Z",
|
|
createdAt: "2026-07-09T12:00:00Z",
|
|
updatedAt: "2026-07-09T12:00:00Z",
|
|
relatedIssue: null,
|
|
project: null,
|
|
workspace: null,
|
|
detail: null,
|
|
dismissal: null,
|
|
...overrides,
|
|
expiresAt: overrides.expiresAt ?? null,
|
|
ruleKey: overrides.ruleKey ?? null,
|
|
originAgentName: overrides.originAgentName ?? null,
|
|
queues: overrides.queues ?? [],
|
|
shelf: overrides.shelf ?? false,
|
|
retentionDays: overrides.retentionDays ?? 30,
|
|
keep: overrides.keep ?? false,
|
|
archivedAt: overrides.archivedAt ?? null,
|
|
retentionVersion: overrides.retentionVersion ?? 1,
|
|
decideBy: overrides.decideBy ?? null,
|
|
decideByAttribution: overrides.decideByAttribution ?? null,
|
|
snoozedUntil: overrides.snoozedUntil ?? null,
|
|
trainingExampleId: overrides.trainingExampleId ?? null,
|
|
};
|
|
}
|
|
|
|
function buildSnapshot(): DecisionTrainingExample["snapshot"] {
|
|
return {
|
|
version: 1,
|
|
capturedAt: "2026-07-10T00:00:00Z",
|
|
cutoff: { at: "2026-07-10T00:00:00Z", lastCommentId: "comment-abcdef12", commentCount: 3 },
|
|
issue: {},
|
|
comments: [{}, {}, {}],
|
|
runs: [{}, {}],
|
|
decision: { kind: "interaction", payload: {}, actor: null, outcome: "accepted" },
|
|
code: { repoUrl: "r", ref: "main", commitSha: "0123456789abcdef", resolution: "exact" },
|
|
};
|
|
}
|
|
|
|
function buildPreview(): DecisionTrainingPreview {
|
|
return { cutoffAt: "2026-07-10T00:00:00Z", decisionOutcome: "accepted", snapshot: buildSnapshot() };
|
|
}
|
|
|
|
function buildExample(overrides: Partial<DecisionTrainingExample> = {}): DecisionTrainingExample {
|
|
return {
|
|
id: "example-1",
|
|
companyId: "c1",
|
|
sourceKind: "interaction",
|
|
sourceId: "interaction-1",
|
|
issueId: "issue-1",
|
|
cutoffAt: "2026-07-10T00:00:00Z",
|
|
notes: "I accepted because the plan covered rollback.",
|
|
notesHistory: [],
|
|
decisionOutcome: "accepted",
|
|
snapshot: buildSnapshot(),
|
|
createdByUserId: "user-1",
|
|
createdAt: "2026-07-10T00:00:00Z",
|
|
updatedAt: "2026-07-10T00:00:00Z",
|
|
...overrides,
|
|
retentionPolicy: overrides.retentionPolicy ?? "scrub_deleted_comments_v1",
|
|
};
|
|
}
|
|
|
|
let container: HTMLDivElement;
|
|
let root: ReturnType<typeof createRoot>;
|
|
|
|
function render(node: React.ReactNode) {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
|
|
});
|
|
root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
<ToastProvider>
|
|
<QueryClientProvider client={queryClient}>{node}</QueryClientProvider>
|
|
</ToastProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
Object.values(mockApi).forEach((fn) => fn.mockReset());
|
|
});
|
|
|
|
afterEach(() => {
|
|
act(() => root?.unmount());
|
|
container.remove();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
describe("DecisionTrainingDrawer — create state", () => {
|
|
it("previews the frozen snapshot and saves an example", async () => {
|
|
mockApi.preview.mockResolvedValue(buildPreview());
|
|
mockApi.create.mockResolvedValue(buildExample());
|
|
|
|
render(
|
|
<DecisionTrainingDrawer open onOpenChange={() => {}} companyId="c1" item={buildItem()} />,
|
|
);
|
|
|
|
await waitFor(() => bodyText().includes("before cutoff"));
|
|
|
|
// Snapshot preview surfaces cutoff, comment/run counts and the commit.
|
|
expect(bodyText()).toContain("3 · last comment-");
|
|
expect(bodyText()).toContain("2 before cutoff");
|
|
expect(bodyText()).toContain("0123456789");
|
|
expect(bodyText()).toContain("Resolved · accepted");
|
|
|
|
const textarea = document.body.querySelector("textarea") as HTMLTextAreaElement;
|
|
expect(textarea).toBeTruthy();
|
|
act(() => setTextareaValue(textarea, "Trusting the rollback plan."));
|
|
|
|
await act(() => findButton("Save example")?.click());
|
|
await waitFor(() => mockApi.create.mock.calls.length > 0);
|
|
|
|
expect(mockApi.create).toHaveBeenCalledWith("c1", {
|
|
sourceKind: "interaction",
|
|
sourceId: "interaction-1",
|
|
issueId: "issue-1",
|
|
notes: "Trusting the rollback plan.",
|
|
});
|
|
});
|
|
|
|
it("refuses to train a decision with no issue anchor", () => {
|
|
const item = buildItem({ subject: { ...buildItem().subject, metadata: {} }, relatedIssue: null });
|
|
render(<DecisionTrainingDrawer open onOpenChange={() => {}} companyId="c1" item={item} />);
|
|
expect(bodyText()).toContain("isn't anchored to an issue");
|
|
expect(mockApi.preview).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("DecisionTrainingDrawer — saved state", () => {
|
|
it("shows provenance and a read-only snapshot, and round-trips notes edits", async () => {
|
|
mockApi.get.mockResolvedValue(buildExample());
|
|
mockApi.updateNotes.mockResolvedValue(buildExample({ notes: "Revised reasoning.", updatedAt: "2026-07-11T00:00:00Z" }));
|
|
|
|
render(
|
|
<DecisionTrainingDrawer
|
|
open
|
|
onOpenChange={() => {}}
|
|
companyId="c1"
|
|
item={buildItem({ trainingExampleId: "example-1" })}
|
|
currentUserId="user-1"
|
|
/>,
|
|
);
|
|
|
|
expect(bodyText()).toContain("Training example");
|
|
expect(bodyText()).not.toContain("Train this decision");
|
|
expect(mockApi.preview).not.toHaveBeenCalled();
|
|
await waitFor(() => bodyText().includes("Frozen state"));
|
|
expect(bodyText()).toContain("You"); // provenance author
|
|
expect(bodyText()).toContain("Read-only"); // snapshot is visibly read-only
|
|
expect(bodyText()).toContain("I accepted because the plan covered rollback.");
|
|
expect(findClickable("Open full record")).toBeTruthy();
|
|
|
|
await act(() => findButton("Edit")?.click());
|
|
const textarea = document.body.querySelector("textarea") as HTMLTextAreaElement;
|
|
expect(textarea).toBeTruthy();
|
|
act(() => setTextareaValue(textarea, "Revised reasoning."));
|
|
await act(() => findButton("Save notes")?.click());
|
|
await waitFor(() => mockApi.updateNotes.mock.calls.length > 0);
|
|
|
|
expect(mockApi.updateNotes).toHaveBeenCalledWith("example-1", "Revised reasoning.");
|
|
});
|
|
|
|
it("deletes after confirmation", async () => {
|
|
mockApi.get.mockResolvedValue(buildExample());
|
|
mockApi.delete.mockResolvedValue(undefined);
|
|
const onOpenChange = vi.fn();
|
|
|
|
render(
|
|
<DecisionTrainingDrawer
|
|
open
|
|
onOpenChange={onOpenChange}
|
|
companyId="c1"
|
|
item={buildItem({ trainingExampleId: "example-1" })}
|
|
currentUserId="user-1"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => bodyText().includes("Frozen state"));
|
|
await act(() => {
|
|
findButton("Delete")?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
// Confirm dialog action.
|
|
await waitFor(() => bodyText().includes("Delete this training example?"));
|
|
const confirm = document.body.querySelector<HTMLButtonElement>(
|
|
'[data-slot="alert-dialog-action"]',
|
|
);
|
|
expect(confirm).toBeTruthy();
|
|
await act(() => {
|
|
confirm?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
});
|
|
await waitFor(() => mockApi.delete.mock.calls.length > 0);
|
|
|
|
expect(mockApi.delete).toHaveBeenCalledWith("example-1");
|
|
});
|
|
});
|