mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The runner package is useful only when the application can start, observe, and recover a native Codex run safely. > - Existing direct adapters must keep their current execution and finalization paths. > - The application boundary therefore needs additive persistence, authorization, coordination, and recovery behind an explicit experimental adapter. > - This pull request adds that Codex-only boundary without activating generalized providers, remote environments, or the later task/SDK surfaces. ## Linked Issues or Issue Description **Subsystem affected** Shared contracts, database persistence, adapter utilities, server native-runtime services, and the experimental Paperclip Runner adapter. **Problem or motivation** The already-landed runner package has a qualified Codex path, but the application needs durable native-run state, guarded runtime selection, authenticated coordination, tool security, finalization, and recovery before the experimental adapter can be exercised safely. **Proposed solution** Add a Codex-only `paperclip_runner` application path behind the existing default-off native-runner setting. Bind native state and coordination to company/run identity, preserve persisted-run recovery, and leave every direct adapter on its existing legacy execution path. **Alternatives considered** The earlier stack boundary introduced a generalized executor and remote-environment lifecycle here. That made this PR depend on implementations in higher PRs and changed reusable sandbox behavior globally. Those pieces are now deferred together to #12592. **Roadmap alignment** ROADMAP.md does not list a conflicting native-runner integration project. This change adds the application boundary for the existing Runner architecture. ## What Changed - Added native run/result/finalization/provider-trace persistence, shared validators, and idempotent migration/replay coverage. - Added guarded Codex-only runtime selection, authenticated PRP coordination, recovery, finalization, and interaction services. - Added run/company-bound tool-gateway authorization, credential redaction, SSRF protections, and replay-safe behavior. - Added the explicit `paperclip_runner` adapter behind the default-off rollout setting. - Preserved legacy answered-question wake projection and direct-adapter execution/finalization paths. - Hardened cancellation so only owned in-memory child processes are signaled; persisted recycled PIDs/process groups are never trusted. - Retained the narrow Claude ACPX isolated-context security follow-up discovered after #12590. - Deferred the generalized executor, provider ingress, remote lifecycle, SDK/lab/eval work, release-process changes, and lockfile. ## Verification - Changed-file delta against `master`: 133 files. - GitHub Actions is the authoritative verification environment for this PR. - Full CI, security, and Greptile review will run on this lowest unmerged stack PR. - Local tests/build/typecheck were not run because this checkout is resource constrained. - Static diff/reference checks pass, and `pnpm-lock.yaml` is unchanged. ## Risks - This touches central heartbeat and agent-route code, so legacy compatibility is the primary risk. - Runtime selection remains Codex-only and explicit; direct Codex, Claude, OpenCode, process, HTTP, and plugin adapters remain on their existing paths. - Fresh native starts fail closed while the rollout flag is off; persisted native records remain readable and recoverable. - Cancellation, company/run binding, tool calls, status decisions, and completion writes are guarded or replay-safe. > 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. ## Model Used OpenAI Codex, GPT-5.6, with repository tools, code execution, and parallel agent review. ## 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 linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass — GitHub Actions is authoritative for this resource-constrained checkout - [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 risks above - [ ] All Paperclip CI and security gates are green - [ ] Greptile is 5/5 with no open actionable findings - [x] I will address all Greptile and reviewer comments before merge ## Stack - Position: 3 of 5 overall; lowest of 3 currently unmerged - Base: `master` - Previous: [#12590](https://github.com/paperclipai/paperclip/pull/12590), qualified Claude ACPX runtime — merged - Next: [#12592](https://github.com/paperclipai/paperclip/pull/12592), generalized Codex executor, task experience, and developer SDKs --------- Co-authored-by: Dev Agent <dev@paperclip.ing>
596 lines
17 KiB
TypeScript
596 lines
17 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { eq } from "drizzle-orm";
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
agents,
|
|
companies,
|
|
createDb,
|
|
documentRevisions,
|
|
documents,
|
|
goals,
|
|
heartbeatRuns,
|
|
issueComments,
|
|
issueDocuments,
|
|
issueThreadInteractions,
|
|
issues,
|
|
} from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
|
|
|
|
const telemetryMocks = vi.hoisted(() => ({
|
|
track: vi.fn(),
|
|
getTelemetryClient: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../telemetry.js", () => ({
|
|
getTelemetryClient: telemetryMocks.getTelemetryClient,
|
|
}));
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
describeEmbeddedPostgres("issueThreadInteractionService telemetry", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-interaction-telemetry-");
|
|
db = createDb(tempDb.connectionString);
|
|
interactionsSvc = issueThreadInteractionService(db);
|
|
}, 20_000);
|
|
|
|
beforeEach(() => {
|
|
telemetryMocks.track.mockClear();
|
|
telemetryMocks.getTelemetryClient.mockReturnValue({
|
|
track: telemetryMocks.track,
|
|
hashPrivateRef: vi.fn((value: string) => `hashed:${value}`),
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await db.delete(issueThreadInteractions);
|
|
await db.delete(issueComments);
|
|
await db.delete(issueDocuments);
|
|
await db.delete(documentRevisions);
|
|
await db.delete(documents);
|
|
await db.delete(issues);
|
|
await db.delete(goals);
|
|
await db.delete(heartbeatRuns);
|
|
await db.delete(agents);
|
|
await db.delete(companies);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
async function seedIssue(title = "Interaction telemetry") {
|
|
const companyId = randomUUID();
|
|
const goalId = randomUUID();
|
|
const issueId = randomUUID();
|
|
|
|
await db.insert(companies).values({
|
|
id: companyId,
|
|
name: "Paperclip",
|
|
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
|
requireBoardApprovalForNewAgents: false,
|
|
});
|
|
await db.insert(goals).values({
|
|
id: goalId,
|
|
companyId,
|
|
title,
|
|
level: "task",
|
|
status: "active",
|
|
});
|
|
await db.insert(issues).values({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
title,
|
|
status: "in_progress",
|
|
priority: "medium",
|
|
});
|
|
|
|
return { companyId, goalId, issueId };
|
|
}
|
|
|
|
async function seedAgent(companyId: string, role: string) {
|
|
const agentId = randomUUID();
|
|
await db.insert(agents).values({
|
|
id: agentId,
|
|
companyId,
|
|
name: role,
|
|
role,
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {},
|
|
runtimeConfig: {},
|
|
permissions: {},
|
|
});
|
|
return agentId;
|
|
}
|
|
|
|
function expectNoRawInteractionIds(dimensions: Record<string, unknown>) {
|
|
expect(dimensions).not.toHaveProperty("interaction_id");
|
|
expect(dimensions).not.toHaveProperty("created_by_agent_id");
|
|
expect(dimensions).not.toHaveProperty("source_run_id");
|
|
}
|
|
|
|
function lastInteractionResolvedDimensions() {
|
|
expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.resolved", expect.any(Object));
|
|
const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.resolved");
|
|
return calls.at(-1)?.[1] as Record<string, unknown>;
|
|
}
|
|
|
|
function lastInteractionCreatedDimensions() {
|
|
expect(telemetryMocks.track).toHaveBeenCalledWith("interaction.created", expect.any(Object));
|
|
const calls = telemetryMocks.track.mock.calls.filter((call) => call[0] === "interaction.created");
|
|
return calls.at(-1)?.[1] as Record<string, unknown>;
|
|
}
|
|
|
|
it("emits deprecated resolver alias use at creation without interaction content", async () => {
|
|
const { companyId, issueId } = await seedIssue("Deprecated alias telemetry issue title");
|
|
|
|
await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
resolverPolicy: "board_only",
|
|
title: "Private confirmation title",
|
|
summary: "Private confirmation summary",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Private confirmation prompt",
|
|
detailsMarkdown: "Private confirmation details",
|
|
acceptLabel: "Private accept label",
|
|
rejectLabel: "Private reject label",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(lastInteractionCreatedDimensions()).toEqual({
|
|
interaction_kind: "request_confirmation",
|
|
used_deprecated_resolver_policy_alias: true,
|
|
});
|
|
});
|
|
|
|
it("emits skipped resolution telemetry for composer Skip", async () => {
|
|
const { companyId, issueId } = await seedIssue("Skipped interaction telemetry");
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
continuationPolicy: "none",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Continue with the proposed change?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await interactionsSvc.skipInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "cancelled",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "skipped",
|
|
created_by_kind: "user",
|
|
continuation_policy: "none",
|
|
target_type: "none",
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits accepted suggested-task telemetry with created and skipped task counts", async () => {
|
|
const { companyId, goalId, issueId } = await seedIssue("Accept suggested tasks telemetry");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "suggest_tasks",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
tasks: [
|
|
{
|
|
clientKey: "root",
|
|
title: "Create the root follow-up",
|
|
},
|
|
{
|
|
clientKey: "child",
|
|
parentClientKey: "root",
|
|
title: "Create the nested follow-up",
|
|
},
|
|
{
|
|
clientKey: "sibling",
|
|
title: "Create the sibling follow-up",
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await interactionsSvc.acceptInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
projectId: null,
|
|
}, created.id, {
|
|
selectedClientKeys: ["root"],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "suggest_tasks",
|
|
status: "accepted",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "accepted",
|
|
created_by_kind: "user",
|
|
continuation_policy: "wake_assignee",
|
|
target_type: "none",
|
|
created_task_count: 1,
|
|
skipped_task_count: 2,
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits accepted checkbox telemetry with raw role, target, and counts", async () => {
|
|
const { companyId, goalId, issueId } = await seedIssue("Accept checkbox telemetry");
|
|
const creatorAgentId = await seedAgent(companyId, "Backend Engineer");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_checkbox_confirmation",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Which files should be deleted?",
|
|
options: [
|
|
{ id: "file-a", label: "a.txt" },
|
|
{ id: "file-b", label: "b.txt" },
|
|
],
|
|
target: {
|
|
type: "custom",
|
|
key: "cleanup-plan",
|
|
},
|
|
},
|
|
}, {
|
|
agentId: creatorAgentId,
|
|
});
|
|
|
|
await interactionsSvc.acceptInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
goalId,
|
|
projectId: null,
|
|
}, created.id, {
|
|
selectedOptionIds: ["file-b"],
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_checkbox_confirmation",
|
|
status: "accepted",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "accepted",
|
|
created_by_kind: "agent",
|
|
creator_agent_role: "Backend Engineer",
|
|
continuation_policy: "wake_assignee",
|
|
target_type: "custom",
|
|
option_count: 2,
|
|
selected_option_count: 1,
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits rejected confirmation telemetry and omits creator_agent_role for user-created interactions", async () => {
|
|
const { companyId, issueId } = await seedIssue("Reject confirmation telemetry");
|
|
const resolverAgentId = await seedAgent(companyId, "SecurityEngineer");
|
|
const resolverRunId = randomUUID();
|
|
await db.insert(heartbeatRuns).values({
|
|
id: resolverRunId,
|
|
companyId,
|
|
agentId: resolverAgentId,
|
|
status: "running",
|
|
});
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
resolverPolicy: "board_or_agents",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
await interactionsSvc.rejectInteraction({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
reason: "Needs edits before approval.",
|
|
}, {
|
|
agentId: resolverAgentId,
|
|
runId: resolverRunId,
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "rejected",
|
|
resolved_by_kind: "agent",
|
|
resolution_reason: "rejected",
|
|
created_by_kind: "user",
|
|
continuation_policy: "none",
|
|
target_type: "none",
|
|
});
|
|
expect(dimensions).not.toHaveProperty("creator_agent_role");
|
|
expect(dimensions).not.toHaveProperty("reason");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits answered question telemetry with system resolver and legacy restriction signal", async () => {
|
|
const { companyId, issueId } = await seedIssue("Answer question telemetry");
|
|
const creatorAgentId = await seedAgent(companyId, "Wizard");
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [
|
|
{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
},
|
|
{
|
|
id: "extras",
|
|
prompt: "Optional extras",
|
|
selectionMode: "multi",
|
|
options: [{ id: "docs", label: "Docs" }],
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
agentId: creatorAgentId,
|
|
});
|
|
await db
|
|
.update(issueThreadInteractions)
|
|
.set({
|
|
continuationPolicy: "",
|
|
resolverPolicyProvenance: "legacy_inherited_restriction",
|
|
})
|
|
.where(eq(issueThreadInteractions.id, created.id));
|
|
|
|
await interactionsSvc.answerQuestions({
|
|
id: issueId,
|
|
companyId,
|
|
}, created.id, {
|
|
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
|
|
summaryMarkdown: "Do not emit this free text.",
|
|
}, {
|
|
systemId: "system:pr-merged",
|
|
});
|
|
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "ask_user_questions",
|
|
status: "answered",
|
|
resolved_by_kind: "system",
|
|
created_by_kind: "agent",
|
|
creator_agent_role: "Wizard",
|
|
target_type: "none",
|
|
question_count: 2,
|
|
answered_question_count: 1,
|
|
legacy_inherited_restriction: true,
|
|
});
|
|
expect(dimensions).not.toHaveProperty("continuation_policy");
|
|
expect(dimensions).not.toHaveProperty("summaryMarkdown");
|
|
expect(dimensions).not.toHaveProperty("answers");
|
|
expect(JSON.stringify(dimensions)).not.toContain("Choose the scope");
|
|
expect(JSON.stringify(dimensions)).not.toContain("Phase 1");
|
|
expect(JSON.stringify(dimensions)).not.toContain("Do not emit this free text.");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits expired question telemetry with zero answered question count", async () => {
|
|
const { companyId, issueId } = await seedIssue("Expired question telemetry");
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "ask_user_questions",
|
|
continuationPolicy: "wake_assignee",
|
|
payload: {
|
|
version: 1,
|
|
questions: [
|
|
{
|
|
id: "scope",
|
|
prompt: "Choose the scope",
|
|
selectionMode: "single",
|
|
required: true,
|
|
options: [{ id: "phase-1", label: "Phase 1" }],
|
|
},
|
|
],
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "ask_user_questions",
|
|
status: "expired",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "superseded_by_comment",
|
|
created_by_kind: "user",
|
|
continuation_policy: "wake_assignee",
|
|
target_type: "none",
|
|
question_count: 1,
|
|
answered_question_count: 0,
|
|
});
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits expired stale-target telemetry without stale target identifiers", async () => {
|
|
const { companyId, issueId } = await seedIssue("Stale target telemetry");
|
|
const documentId = randomUUID();
|
|
const revisionId = randomUUID();
|
|
|
|
await db.insert(documents).values({
|
|
id: documentId,
|
|
companyId,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
latestBody: "v1",
|
|
latestRevisionId: revisionId,
|
|
latestRevisionNumber: 1,
|
|
});
|
|
await db.insert(issueDocuments).values({
|
|
companyId,
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
});
|
|
await db.insert(documentRevisions).values({
|
|
id: revisionId,
|
|
companyId,
|
|
documentId,
|
|
revisionNumber: 1,
|
|
title: "Plan",
|
|
format: "markdown",
|
|
body: "v1",
|
|
});
|
|
|
|
await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve the plan?",
|
|
target: {
|
|
type: "issue_document",
|
|
issueId,
|
|
documentId,
|
|
key: "plan",
|
|
revisionId,
|
|
revisionNumber: 1,
|
|
},
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireStaleRequestConfirmationsForIssueDocument({
|
|
id: issueId,
|
|
companyId,
|
|
}, null, {});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "expired",
|
|
resolved_by_kind: "system",
|
|
resolution_reason: "stale_target",
|
|
created_by_kind: "user",
|
|
continuation_policy: "none",
|
|
target_type: "issue_document",
|
|
});
|
|
expect(dimensions).not.toHaveProperty("staleTarget");
|
|
expect(dimensions).not.toHaveProperty("revisionId");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
|
|
it("emits superseded expiration telemetry without comment identifiers", async () => {
|
|
const { companyId, issueId } = await seedIssue("Superseded telemetry");
|
|
const commentId = randomUUID();
|
|
|
|
const created = await interactionsSvc.create({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
kind: "request_confirmation",
|
|
payload: {
|
|
version: 1,
|
|
prompt: "Approve this plan?",
|
|
},
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
|
|
id: issueId,
|
|
companyId,
|
|
}, {
|
|
id: commentId,
|
|
createdAt: new Date(new Date(created.createdAt).getTime() + 1_000),
|
|
authorUserId: "local-board",
|
|
}, {
|
|
userId: "local-board",
|
|
});
|
|
|
|
expect(expired).toHaveLength(1);
|
|
const dimensions = lastInteractionResolvedDimensions();
|
|
expect(dimensions).toMatchObject({
|
|
interaction_kind: "request_confirmation",
|
|
status: "expired",
|
|
resolved_by_kind: "user",
|
|
resolution_reason: "superseded_by_comment",
|
|
created_by_kind: "user",
|
|
target_type: "none",
|
|
});
|
|
expect(dimensions).not.toHaveProperty("commentId");
|
|
expectNoRawInteractionIds(dimensions);
|
|
});
|
|
});
|