From 56acdfdffaa3d962dfc9a887e8942117e1681fcc Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 2 Nov 2026 14:12:41 -0600 Subject: [PATCH] Fix completed-history owner receipts and verified attachment cleanup Use truthful storage absence receipts for MCP deletion and preserve replay fences. Qualify retained chat controls and provider submissions through their actual owners, retain voice and registration obligations, and bound delivery receipt lookup. Co-Authored-By: Paperclip --- doc/idle-sleep-safety.md | 24 + .../migrations/0423_glossy_mariko_yashida.sql | 1 + ...{0418_snapshot.json => 0423_snapshot.json} | 328 ++++++- packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/public_mcp.ts | 3 + .../chat-channels.integration.test.ts | 36 +- .../__tests__/helpers/chat-history-proof.ts | 11 + .../__tests__/idle-completed-history.test.ts | 837 +++++++++++++++++- .../__tests__/storage-local-provider.test.ts | 26 + .../voice-session-store.integration.test.ts | 43 +- server/src/modules/external-agents/index.ts | 2 + server/src/routes/issues.ts | 6 +- .../discord/idle-history-proof.ts | 25 + .../github/idle-history-proof.ts | 70 +- .../chat-channels/idle-history-proof.ts | 67 +- .../chat-channels/imessage-photon/events.ts | 41 +- .../imessage-photon/idle-history-proof.ts | 58 ++ .../imessage-photon/response-notices.ts | 27 + .../microsoft-teams/idle-history-proof.ts | 35 + .../chat-channels/slack/idle-history-proof.ts | 63 +- .../telegram/idle-history-proof.ts | 10 +- .../services/chat-question-form-idle-proof.ts | 54 ++ server/src/services/idle-sleep-safety.ts | 2 +- server/src/services/issues.ts | 8 +- .../native-session-executor.test.ts | 5 + .../src/services/public-mcp/file-transfers.ts | 22 +- .../services/public-mcp/idle-history-proof.ts | 13 +- .../src/services/public-mcp/upload-cleanup.ts | 38 + .../src/services/voice/idle-history-proof.ts | 71 ++ .../src/services/voice/voice-call-reports.ts | 3 +- server/src/storage/local-disk-provider.ts | 11 +- 31 files changed, 1849 insertions(+), 98 deletions(-) create mode 100644 packages/db/src/migrations/0423_glossy_mariko_yashida.sql rename packages/db/src/migrations/meta/{0418_snapshot.json => 0423_snapshot.json} (99%) create mode 100644 server/src/__tests__/helpers/chat-history-proof.ts create mode 100644 server/src/services/chat-channels/discord/idle-history-proof.ts create mode 100644 server/src/services/chat-channels/imessage-photon/idle-history-proof.ts create mode 100644 server/src/services/chat-channels/imessage-photon/response-notices.ts create mode 100644 server/src/services/chat-channels/microsoft-teams/idle-history-proof.ts create mode 100644 server/src/services/chat-question-form-idle-proof.ts create mode 100644 server/src/services/public-mcp/upload-cleanup.ts create mode 100644 server/src/services/voice/idle-history-proof.ts diff --git a/doc/idle-sleep-safety.md b/doc/idle-sleep-safety.md index 075ace1d59f..81e5c6c262f 100644 --- a/doc/idle-sleep-safety.md +++ b/doc/idle-sleep-safety.md @@ -118,6 +118,30 @@ Failed company transfers remain resumable. Completed transfers require their recorded manifest, exact part receipts and matching counts. Completed/cancelled transfers still block while their registered local spool contains cleanup work. +Chat control tokens can remain issued or become expired after another actor +resolves their canonical interaction. Their exact interaction, publication and +conversation receipts establish completion; a token status alone does not. +Provider submission receipts do not exempt pending source wakes or publications. +Photon attachment provenance remains immutable history after ordinary attachment +removal, but any surviving metadata must agree with its recorded identities and +hashes. This proof establishes completed registration, not storage cleanup. + +Public-MCP upload cleanup requires both the existing cleanup stamp and a new +verified-cleanup stamp. Ordinary attachment and issue removal write this proof +only after successful object deletion and an absence readback. Legacy cleanup +stamps are not backfilled: the existing bounded opportunistic sweep can verify +expired or orphaned uploads. Reissuing an uncommitted upload clears both stamps; +a committed/deleted request replay preserves them. An expired or cleaned upload +cannot accept a still-valid old transport ticket. + +Voice report refresh remains work for the owner's full 24-hour window after a +provider session ends, even when the report is available. Live session recovery, +unconfirmed inbound cleanup and ambiguous reply submission still block. A +completed provider submission does not prove spoken playback. Speko setup also +requires the existing successful whole-configuration receipt after its individual +tool and webhook writes. Discord command registration retains its own future +check; a completed command invocation does not retire that deadline. + Waiting agent conversations can be passive containers under both ordinary and scheduled inspection. The same narrow proof requires the exact conversation agent/company/user, waiting state, no checkout, active run, accounting, monitor diff --git a/packages/db/src/migrations/0423_glossy_mariko_yashida.sql b/packages/db/src/migrations/0423_glossy_mariko_yashida.sql new file mode 100644 index 00000000000..b90f13b2b22 --- /dev/null +++ b/packages/db/src/migrations/0423_glossy_mariko_yashida.sql @@ -0,0 +1 @@ +ALTER TABLE "mcp_attachment_uploads" ADD COLUMN IF NOT EXISTS "cleanup_verified_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0418_snapshot.json b/packages/db/src/migrations/meta/0423_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0418_snapshot.json rename to packages/db/src/migrations/meta/0423_snapshot.json index 23ede87cb5d..4014dff68d6 100644 --- a/packages/db/src/migrations/meta/0418_snapshot.json +++ b/packages/db/src/migrations/meta/0423_snapshot.json @@ -1,6 +1,6 @@ { - "id": "c777fa84-85a6-418f-bea1-0d60b56cf071", - "prevId": "c2c69a4a-8281-419c-9eeb-a7bcbc2beaae", + "id": "aef8cf7c-1939-4841-bb4a-b20203f10b69", + "prevId": "fe0b877c-d63e-46d9-8798-c7729fd13eb7", "version": "7", "dialect": "postgresql", "tables": { @@ -2233,6 +2233,74 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_file_roots": { + "name": "agent_file_roots", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "generation": { + "name": "generation", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "entries": { + "name": "entries", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_file_roots_company_id_agent_id_agents_company_id_id_fk": { + "name": "agent_file_roots_company_id_agent_id_agents_company_id_id_fk", + "tableFrom": "agent_file_roots", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_file_roots_company_id_agent_id_pk": { + "name": "agent_file_roots_company_id_agent_id_pk", + "columns": [ + "company_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.agent_identity_keys": { "name": "agent_identity_keys", "schema": "", @@ -29705,6 +29773,241 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.execution_workspace_snapshots": { + "name": "execution_workspace_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "execution_workspace_id": { + "name": "execution_workspace_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_operation_id": { + "name": "source_operation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_run_id": { + "name": "source_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_snapshot_id": { + "name": "parent_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "generation": { + "name": "generation", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "epoch": { + "name": "epoch", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paperclip.remote-workspace-archive.v1'" + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "manifest_digest": { + "name": "manifest_digest", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_workspace_snapshots_operation_uniq": { + "name": "execution_workspace_snapshots_operation_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_operation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_snapshots_generation_uniq": { + "name": "execution_workspace_snapshots_generation_uniq", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "generation", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_snapshots_workspace_idx": { + "name": "execution_workspace_snapshots_workspace_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_workspace_snapshots_cleanup_idx": { + "name": "execution_workspace_snapshots_cleanup_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_after", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.execution_workspaces": { "name": "execution_workspaces", "schema": "", @@ -37105,6 +37408,15 @@ "client_request_id" ] }, + "issue_comments_agent_client_request_uq": { + "name": "issue_comments_agent_client_request_uq", + "nullsNotDistinct": false, + "columns": [ + "issue_id", + "author_agent_id", + "client_request_id" + ] + }, "issue_comments_company_id_uq": { "name": "issue_comments_company_id_uq", "nullsNotDistinct": false, @@ -52770,6 +53082,12 @@ "primaryKey": false, "notNull": false }, + "next_eval_reason": { + "name": "next_eval_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, "next_eval_at": { "name": "next_eval_at", "type": "timestamp with time zone", @@ -62124,6 +62442,12 @@ "primaryKey": false, "notNull": false }, + "cleanup_verified_at": { + "name": "cleanup_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index b3bc9221371..3d83909fe61 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2948,6 +2948,13 @@ "when": 1793503729899, "tag": "0422_eminent_darwin", "breakpoints": true + }, + { + "idx": 423, + "version": "7", + "when": 1793645545010, + "tag": "0423_glossy_mariko_yashida", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/public_mcp.ts b/packages/db/src/schema/public_mcp.ts index ec42c436739..7011a37aa0c 100644 --- a/packages/db/src/schema/public_mcp.ts +++ b/packages/db/src/schema/public_mcp.ts @@ -188,6 +188,9 @@ export const mcpAttachmentUploads = pgTable("mcp_attachment_uploads", { storageProvider: text("storage_provider").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), cleanedAt: timestamp("cleaned_at", { withTimezone: true }), + // Written only after storage deletion and an authoritative absence readback. + // Legacy cleanedAt can predate truthful local filesystem error handling. + cleanupVerifiedAt: timestamp("cleanup_verified_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => [ uniqueIndex("mcp_attachment_upload_request_uq").on(t.companyId, t.userId, t.requestId), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 08c61039a80..40783fae2cd 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,3 +1,5 @@ +import { chatHistoryActionBlocks } from "./helpers/chat-history-proof.js"; +import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js"; import { resolveCommittedNativeRecoveryNotices, pendingNativeRecoveryNotices } from "../services/native-runtime/native-recovery-notices.js"; import { issueRecoveryActionService } from "../services/issue-recovery-actions.js"; import { projectCommittedRun } from "../services/native-runtime/native-run-projection.js"; @@ -284,7 +286,8 @@ import { startEmbeddedPostgresTestDatabase, } from "./helpers/embedded-postgres.js"; -const externalTestDatabaseUrl = process.env.PAPERCLIP_TEST_DATABASE_URL; +const historyTestDatabaseUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_HISTORY_TEST_DATABASE_URL"); +const externalTestDatabaseUrl = historyTestDatabaseUrl ?? process.env.PAPERCLIP_TEST_DATABASE_URL; const embeddedPostgresSupport = externalTestDatabaseUrl ? { supported: true } : await getEmbeddedPostgresTestSupport(); @@ -1122,6 +1125,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { // Do not expire or time out its connections on those virtual clock jumps. db = createDb(externalTestDatabaseUrl ?? tempDb!.connectionString, { idleTimeoutSeconds: 0, connectTimeoutSeconds: 0, maxLifetimeSeconds: 0, + ...(historyTestDatabaseUrl ? { maxConnections: 4 } : {}), }); }, 90_000); @@ -3006,6 +3010,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const [review] = await readChatGitHubReviewsByEndpointId(f.endpoint.id); expect(review.assessment?.findings[0].category).toBe(categories[0]); expect((await f.action()).status).toBe("processed"); + expect(await chatHistoryActionBlocks(db, (await f.action()).id)).toBe(false); expect(f.responseComments.size).toBe(1); }); it("publishes one score heading when a newly configured bot submits a formatted summary", async () => { @@ -3069,6 +3074,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const [response] = await db.select().from(chatActions).where(and(eq(chatActions.deliveryId, delivery.id), eq(chatActions.kind, "github_response_comment"))); expect(response).toMatchObject({ status: "cancelled", result: { final: true, code: "passive_response_wait" } }); + expect(await chatHistoryActionBlocks(db, response.id)).toBe(false); const writes = f.responseWrites.length; const updatedAt = response.updatedAt; await responses.processPending(); @@ -3314,6 +3320,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const body = [...f.responseComments.values()][0].body; expect(body).not.toMatch(/Review queued|Working on this/); expect(body).toContain("paperclip-response-state:final"); + const responses = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "github_response_comment"))); + expect(responses.length).toBeGreaterThan(0); + for (const receipt of responses) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(false); }); it("retires a legacy coalesced GitHub placeholder before replacing its receipt", async () => { const f = await publicationRetryFixture(); @@ -3425,6 +3434,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect([...f.responseComments.values()].some(comment => comment.body.includes("Newer progress"))).toBe(true); const [settled] = await db.select().from(chatActions).where(eq(chatActions.id, first.id)); expect(settled.result?.final).toBe(false); + expect(await chatHistoryActionBlocks(db, settled.id)).toBe(true); }); it.each([false, true])("recovers an approved queued GitHub review without another approval (revoked: %s)", async revoked => { const f = await publicationRetryFixture(); @@ -3460,6 +3470,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { await db.update(chatActions).set({ result: { ...action.result, retryAt: new Date(0).toISOString() } }).where(eq(chatActions.id, action.id)); await f.service.processPending(); expect((await f.action()).status).toBe(revoked ? "cancelled" : "processed"); + expect(await chatHistoryActionBlocks(db, (await f.action()).id)).toBe(false); if (revoked) await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment })).rejects.toThrow(); else expect(await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: f.assessment })) .toMatchObject({ status: "replayed", result: { data: { status: "processed", score: 3 } } }); @@ -4830,6 +4841,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(f.responseWrites.filter(write => write.method === "POST")).toHaveLength(2); const [deleted] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "github_response_comment"), sql`${chatActions.result}->>'id' = ${String(firstId)}`)); expect(deleted).toMatchObject({ status: "cancelled", result: { final: true, code: "response_deleted" } }); + expect(await chatHistoryActionBlocks(db, deleted.id)).toBe(false); await githubResponseCommentService(db, f.providerFetch).processPending(); expect(f.responseComments.size).toBe(1); await expect(githubRunReplyState(db, { ...f.session, endpointId: f.endpoint.id })).resolves.toBe("confirmed"); @@ -9767,6 +9779,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked"); expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId }); + const [reconnectReceipt] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_managed_reconnect"))); + expect(reconnectReceipt).toMatchObject({ status: "processed", result: null }); + expect(await chatHistoryActionBlocks(db, reconnectReceipt.id)).toBe(false); await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); await f.provision(); expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); @@ -17019,12 +17034,13 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { context.endpoint.id, ); expect(context.posts).toEqual([]); - if (change === "edited comment") + if (change === "edited comment") { expect(await context.receipt()).toMatchObject({ status: "cancelled", result: { attempts: 0, code: "source_changed_or_unavailable" }, }); - else expect(await context.receipt()).toBeUndefined(); + expect(await chatHistoryActionBlocks(db, (await context.receipt())!.id)).toBe(false); + } else expect(await context.receipt()).toBeUndefined(); expect(context.wakeup).not.toHaveBeenCalled(); } finally { await retirePublicationFixture(context.service, context.endpoint.id); @@ -17705,6 +17721,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { result: { code: "github_webhook_ingress_runtime_superseded" }, }); expect((await context.ingress())!.payload).not.toHaveProperty("body"); + expect(await chatHistoryActionBlocks(db, (await context.ingress())!.id)).toBe(false); expect(sdkDispatch).not.toHaveBeenCalled(); expect( await db @@ -17756,6 +17773,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { result: { code: "github_webhook_recovery_source_changed" }, }); expect(cancelled!.payload).not.toHaveProperty("body"); + expect(await chatHistoryActionBlocks(db, cancelled!.id)).toBe(false); expect(cancelled!.result?.retryable).not.toBe(true); expect(context.requests).toEqual([]); expect( @@ -43530,6 +43548,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(original?.state).toBe("published"); expect(original?.payload.card?.actions).toEqual(expect.arrayContaining([expect.objectContaining({ type: "callback" })])); if (kind === "connection_intent") expect(original?.payload.card?.actions).toContainEqual({ type: "link", label: "Review connection", url: expect.any(String) }); + const controls = (await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), + eq(sql`${chatActions.payload}->>'interactionId'`, interaction.id)))) + .filter(row => ["confirmation_response", "question_form_open", "question_form_submit", "question_answer"].includes(row.kind)); + expect(controls.length).toBeGreaterThan(0); + for (const control of controls) expect(await chatHistoryActionBlocks(db, control.id)).toBe(true); if (kind === "connection_intent") await interactions.resolveConnectionIntent(issue, interaction.id, { version: 1, outcome: "declined" }, { userId: "owner-user" }); else if (kind === "request_item_verdicts") await interactions.submitItemVerdicts(issue, interaction.id, { verdicts: [{ id: "docs", verdict: "approve" }] }, { userId: "owner-user" }); else await interactions.rejectInteraction(issue, interaction.id, {}, { userId: "owner-user" }); @@ -43539,6 +43562,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(terminal?.state).toBe("published"); expect(terminal?.providerMessageId).toBe(original.providerMessageId); expect(terminal?.payload.card?.actions).toBeUndefined(); + for (const control of controls) expect(await chatHistoryActionBlocks(db, control.id)).toBe(false); + const control = controls[0]!; + await db.update(chatActions).set({ payload: { ...control.payload, interactionId: randomUUID() } }).where(eq(chatActions.id, control.id)); + expect(await chatHistoryActionBlocks(db, control.id)).toBe(true); + await db.update(chatActions).set({ payload: control.payload }).where(eq(chatActions.id, control.id)); } finally { await service.shutdown(); if (previousPublicUrl === undefined) delete process.env.PAPERCLIP_PUBLIC_URL; @@ -80927,6 +80955,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { expect(context.consentCards).toHaveLength(1); expect(context.fileCards).toEqual([]); expect(context.uploadRequest).not.toHaveBeenCalled(); + expect(await chatHistoryActionBlocks(db, intent!.id)).toBe(true); const before = await context.service.getPublicationBatchStatus( context.endpoint.id, context.conversation.id, @@ -80955,6 +80984,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { body: context.bytes, }); expect(context.fileCards).toHaveLength(1); + expect(await chatHistoryActionBlocks(db, intent!.id)).toBe(false); const status = await context.service.getPublicationBatchStatus( context.endpoint.id, context.conversation.id, diff --git a/server/src/__tests__/helpers/chat-history-proof.ts b/server/src/__tests__/helpers/chat-history-proof.ts new file mode 100644 index 00000000000..60dcef89966 --- /dev/null +++ b/server/src/__tests__/helpers/chat-history-proof.ts @@ -0,0 +1,11 @@ +import { sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { idleChatHistoryChecks } from "../../services/chat-channels/idle-history-proof.js"; + +/** Scope the actual shared predicate to a real producer's receipt. Other pending + * fixture work remains independently observable through its owning gates. */ +export async function chatHistoryActionBlocks(db: Db, actionId: string): Promise { + const check = idleChatHistoryChecks.find(check => check.reason === "chat_actions")!; + const [row] = await db.execute<{ blocked: boolean }>(sql`SELECT EXISTS (${sql.raw(check.query)} AND a.id = ${actionId}::uuid) AS blocked`); + return row.blocked; +} diff --git a/server/src/__tests__/idle-completed-history.test.ts b/server/src/__tests__/idle-completed-history.test.ts index c4fe2b56843..4e86383c51b 100644 --- a/server/src/__tests__/idle-completed-history.test.ts +++ b/server/src/__tests__/idle-completed-history.test.ts @@ -1,5 +1,5 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { createHash, generateKeyPairSync, randomBytes, randomUUID } from "node:crypto"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import net from "node:net"; @@ -33,6 +33,11 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos if (elapsed > 1000) console.info(`History predicate ${reason}: ${Math.round(elapsed)}ms`); return row.blocked; }; + const blockedActionKind = async (kind: string) => { + const query = completedHistoryChecks.find(check => check.reason === "chat_actions")!.query; + const [row] = await db.execute<{ blocked: boolean }>(sql`SELECT EXISTS (${sql.raw(query)} AND a.kind = ${kind}) AS blocked`); + return row.blocked; + }; beforeAll(async () => { if (!externalDatabaseUrl) database = await startEmbeddedPostgresTestDatabase("idle-history-"); db = tables.createDb(externalDatabaseUrl ?? database!.connectionString, { maxConnections: 4 }); @@ -107,10 +112,28 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos await db.insert(tables.mcpOauthTokens).values({ grantId: p.grantId, kind: "access", tokenHash: hashMcpSecret(token), expiresAt: new Date(Date.now() + 600_000) }); const directory = await mkdtemp(path.join(tmpdir(), "history-mcp-storage-")); - const router = Router(); - router.use(issueRoutes(db, createStorageService(createLocalDiskStorageProvider(directory)))); + const router = Router(), storage = createStorageService(createLocalDiskStorageProvider(directory)); + router.use(issueRoutes(db, storage)); const oauth = createPublicMcpOAuth(db, { origin, resource }), api = createMcpApiDispatch(router); - return { ...p, token, oauth, api, storage: createStorageService(createLocalDiskStorageProvider(directory)), cleanup: () => rm(directory, { recursive: true, force: true }) }; + return { ...p, token, oauth, api, storage, cleanup: () => rm(directory, { recursive: true, force: true }) }; + } + + async function slackFixture() { + const f = await seed(), c = await connection(f), p = await mcp(f); + await db.update(tables.toolConnections).set({ enabled: true, status: "active" }).where(eq(tables.toolConnections.id, c.connectionId)); + await db.insert(tables.companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: p.userId, + status: "active", membershipRole: "owner" }); + const [endpoint] = await db.insert(tables.chatEndpoints).values({ companyId: f.companyId, connectionId: c.connectionId, + assignedAgentId: f.agentId, publicId: randomUUID(), provider: "slack", status: "active", providerAccountId: "TWORKSPACE", + botExternalId: "UBOT", allowDirectMessages: true, publicationMode: "automatic" }).returning(); + const [conversation] = await db.insert(tables.chatConversations).values({ companyId: f.companyId, endpointId: endpoint.id, + issueId: f.issueId, externalConversationId: "DCHANNEL", externalThreadId: "slack:DCHANNEL:123.456", externalLabel: "Personal", + state: "active", isDirectMessage: true }).returning(); + const [principal] = await db.insert(tables.chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", + providerAccountId: "TWORKSPACE", externalId: "UREQUESTER", kind: "user", isBot: false }).returning(); + const authority = { endpoint, issueId: f.issueId, conversation, userId: p.userId, slackUserId: "UREQUESTER", principalId: principal.id, + deliveryId: null, identityContextId: null, revision: "fixture-revision", workMode: "standard", botToken: "fixture-bot-token" }; + return { ...f, ...p, ...c, endpoint, conversation, principal, authority }; } it("checks all 32 families on the real migrated schema and keeps an empty instance eligible", async () => { @@ -166,13 +189,117 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos const result = await request(app).put(url.pathname + url.search).set("Content-Type", "text/plain").send(bytes).timeout({ response: 5000, deadline: 10_000 }); expect(result.status).toBe(200); expect(await blocked("mcp_attachment_uploads")).toBe(false); - const abandoned = await transfers.getUploadUrl(principal, { ...args, requestId: randomUUID() }); + await db.update(tables.mcpAttachmentUploads).set({ cleanedAt: now, cleanupVerifiedAt: now }).where(eq(tables.mcpAttachmentUploads.id, String(upload.uploadId))); + expect(await blocked("mcp_attachment_uploads")).toBe(true); + await db.update(tables.mcpAttachmentUploads).set({ cleanedAt: null, cleanupVerifiedAt: null }).where(eq(tables.mcpAttachmentUploads.id, String(upload.uploadId))); + const abandonedArgs = { ...args, requestId: randomUUID() }; + const abandoned = await transfers.getUploadUrl(principal, abandonedArgs); + expect(await blocked("mcp_attachment_uploads")).toBe(true); + const [old] = await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, String(abandoned.uploadId))); + await p.storage.putFile({ companyId: f.companyId, namespace: "mcp-transfers", objectKey: old.objectKey, + originalFilename: args.filename, contentType: args.contentType, body: bytes }); + // Model the old owner's optimistic cleanup: its stamp exists while bytes + // survive. It must stay blocked until actual deletion and readback. + await db.update(tables.mcpAttachmentUploads).set({ expiresAt: new Date(0), cleanedAt: new Date() }).where(eq(tables.mcpAttachmentUploads.id, old.id)); + expect(await p.storage.headObject(f.companyId, old.objectKey)).toMatchObject({ exists: true }); expect(await blocked("mcp_attachment_uploads")).toBe(true); - await db.update(tables.mcpAttachmentUploads).set({ expiresAt: new Date(0) }).where(eq(tables.mcpAttachmentUploads.id, String(abandoned.uploadId))); await transfers.getDownloadUrl(principal, result.body.attachment.id); + expect(await p.storage.headObject(f.companyId, old.objectKey)).toEqual({ exists: false }); expect(await blocked("mcp_attachment_uploads")).toBe(false); - await db.update(tables.mcpAttachmentUploads).set({ attachmentId: null }).where(eq(tables.mcpAttachmentUploads.id, String(upload.uploadId))); + const verified = (await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, old.id)))[0]; + const staleUrl = new URL(String(abandoned.url)); + expect((await request(app).put(staleUrl.pathname + staleUrl.search).set("Content-Type", "text/plain").send(bytes) + .timeout({ response: 5000, deadline: 10_000 })).status).toBe(403); + expect(await p.storage.headObject(f.companyId, old.objectKey)).toEqual({ exists: false }); + expect((await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, old.id)))[0]).toEqual(verified); + const reissued = await transfers.getUploadUrl(principal, abandonedArgs); + expect((await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, old.id)))[0]) + .toMatchObject({ cleanedAt: null, cleanupVerifiedAt: null }); expect(await blocked("mcp_attachment_uploads")).toBe(true); + const secondUrl = new URL(String(reissued.url)); + const second = await request(app).put(secondUrl.pathname + secondUrl.search).set("Content-Type", "text/plain").send(bytes) + .timeout({ response: 5000, deadline: 10_000 }); + expect(second.status).toBe(200); + for (const attachment of [result.body.attachment, second.body.attachment]) { + expect(await p.api(principal, "DELETE", `/attachments/${attachment.id}`)).toEqual({ ok: true }); + } + const [removed] = await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, String(upload.uploadId))); + expect(removed).toMatchObject({ attachmentId: result.body.attachment.id, cleanedAt: expect.any(Date), cleanupVerifiedAt: expect.any(Date) }); + expect(await p.storage.headObject(f.companyId, removed.objectKey)).toEqual({ exists: false }); + expect(await blocked("mcp_attachment_uploads")).toBe(false); + await expect(transfers.getUploadUrl(principal, args)).rejects.toThrow(); + expect((await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, removed.id)))[0]).toEqual(removed); + expect(await blocked("mcp_attachment_uploads")).toBe(false); + await db.update(tables.mcpAttachmentUploads).set({ cleanupVerifiedAt: null }).where(eq(tables.mcpAttachmentUploads.id, removed.id)); + expect(await blocked("mcp_attachment_uploads")).toBe(true); + } finally { await p.cleanup(); } + }, 60_000); + + it.each(["delete_failed", "object_survived", "head_failed"] as const)("retains MCP cleanup uncertainty after actual attachment removal: %s", async failure => { + const { createPublicMcpTransfers } = await import("../services/public-mcp/file-transfers.js"); + const { default: express } = await import("express"); + const { default: request } = await import("supertest"); + const f = await seed(), p = await connectedMcp(f); + try { + const principal = await p.oauth.authenticate(p.token), bytes = Buffer.from("retained uncertain attachment"); + const transfers = createPublicMcpTransfers(db, p.oauth, p.api, p.storage); + const upload = await transfers.getUploadUrl(principal, { companyId: f.companyId, taskId: f.issueId, requestId: randomUUID(), + filename: "uncertain.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }); + const app = express(); app.use(transfers.router); + const url = new URL(String(upload.url)); + const result = await request(app).put(url.pathname + url.search).set("Content-Type", "text/plain").send(bytes) + .timeout({ response: 5000, deadline: 10_000 }); + expect(result.status).toBe(200); + const [before] = await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, String(upload.uploadId))); + const originalDelete = p.storage.deleteObject, originalHead = p.storage.headObject; + if (failure === "delete_failed") p.storage.deleteObject = async () => { throw new Error("controlled storage removal failure"); }; + if (failure === "object_survived") p.storage.deleteObject = async () => {}; + if (failure === "head_failed") p.storage.headObject = async () => { throw new Error("controlled storage readback failure"); }; + try { expect(await p.api(principal, "DELETE", `/attachments/${result.body.attachment.id}`)).toEqual({ ok: true }); } + finally { p.storage.deleteObject = originalDelete; p.storage.headObject = originalHead; } + expect(await db.select().from(tables.issueAttachments).where(eq(tables.issueAttachments.id, result.body.attachment.id))).toHaveLength(0); + expect((await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, before.id)))[0]) + .toMatchObject({ attachmentId: before.attachmentId, cleanedAt: null, cleanupVerifiedAt: null }); + expect((await p.storage.headObject(f.companyId, before.objectKey)).exists).toBe(failure !== "head_failed"); + expect(await blocked("mcp_attachment_uploads")).toBe(true); + // The existing bounded opportunistic owner can later retire this orphan + // only by a real successful delete and readback, preserving its identity. + await db.update(tables.mcpAttachmentUploads).set({ expiresAt: new Date(0) }).where(eq(tables.mcpAttachmentUploads.id, before.id)); + const pending = await transfers.getUploadUrl(principal, { companyId: f.companyId, taskId: f.issueId, requestId: randomUUID(), + filename: "new.txt", contentType: "text/plain", byteSize: 1, sha256: createHash("sha256").update("x").digest("hex") }); + expect((await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, before.id)))[0]) + .toMatchObject({ attachmentId: before.attachmentId, cleanupVerifiedAt: expect.any(Date) }); + expect(await p.storage.headObject(f.companyId, before.objectKey)).toEqual({ exists: false }); + expect(await blocked("mcp_attachment_uploads")).toBe(true); // newly allocated upload remains pending + expect(pending.uploadId).toBeTruthy(); + } finally { await p.cleanup(); } + }, 60_000); + + it.each([false, true])("preserves verified MCP cleanup after ordinary issue deletion, storageFailure=%s", async storageFailure => { + const { createPublicMcpTransfers } = await import("../services/public-mcp/file-transfers.js"); + const { default: express } = await import("express"); + const { default: request } = await import("supertest"); + const f = await seed(), p = await connectedMcp(f); + try { + const principal = await p.oauth.authenticate(p.token), bytes = Buffer.from("removed with parent"); + const transfers = createPublicMcpTransfers(db, p.oauth, p.api, p.storage); + const upload = await transfers.getUploadUrl(principal, { companyId: f.companyId, taskId: f.issueId, requestId: randomUUID(), + filename: "parent.txt", contentType: "text/plain", byteSize: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }); + const app = express(); app.use(transfers.router); + const url = new URL(String(upload.url)); + const result = await request(app).put(url.pathname + url.search).set("Content-Type", "text/plain").send(bytes) + .timeout({ response: 5000, deadline: 10_000 }); + expect(result.status).toBe(200); + const originalDelete = p.storage.deleteObject; + if (storageFailure) p.storage.deleteObject = async () => { throw new Error("controlled parent storage failure"); }; + try { await p.api(principal, "DELETE", `/issues/${f.issueId}`); } + finally { p.storage.deleteObject = originalDelete; } + expect(await db.select().from(tables.issues).where(eq(tables.issues.id, f.issueId))).toHaveLength(0); + const [removed] = await db.select().from(tables.mcpAttachmentUploads).where(eq(tables.mcpAttachmentUploads.id, String(upload.uploadId))); + expect(removed.attachmentId).toBe(result.body.attachment.id); + expect(removed.cleanupVerifiedAt !== null).toBe(!storageFailure); + expect((await p.storage.headObject(f.companyId, removed.objectKey)).exists).toBe(storageFailure); + expect(await blocked("mcp_attachment_uploads")).toBe(storageFailure); } finally { await p.cleanup(); } }, 60_000); @@ -205,7 +332,7 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos operation: "paperclip_create_task", requestId: randomUUID(), argumentsHash: "a", status: "completed", result: { id: f.issueId } }).returning(); const [upload] = await db.insert(tables.mcpAttachmentUploads).values({ ...p, companyId: f.companyId, taskId: f.issueId, requestId: randomUUID(), argumentsHash: "a", originalFilename: "history.txt", contentType: "text/plain", byteSize: 1, - sha256: "a".repeat(64), objectKey: "history/abandoned", storageProvider: "local_disk", expiresAt: now, cleanedAt: now }).returning(); + sha256: "a".repeat(64), objectKey: "history/abandoned", storageProvider: "local_disk", expiresAt: now, cleanedAt: now, cleanupVerifiedAt: now }).returning(); expect(await blocked("mcp_mutation_receipts")).toBe(false); expect(await blocked("mcp_attachment_uploads")).toBe(false); expect(await read()).toEqual({ version: 1, backgroundWork: "none" }); @@ -281,6 +408,698 @@ const support = externalDatabaseUrl ? { supported: true } : await getEmbeddedPos expect(await blocked("mcp_event_deliveries")).toBe(true); }); + it.each([false, true])("requires actual Speko configure completion after tool receipts, defaultsFailure=%s", async defaultsFailure => { + const { chatChannelService } = await import("../services/chat-channels.js"); + const { instanceSettingsService } = await import("../services/instance-settings.js"); + const f = await seed(), p = await mcp(f), oldKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "a".repeat(64); + await db.insert(tables.companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: p.userId, + membershipRole: "owner", status: "active" }); + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + await db.insert(tables.principalPermissionGrants).values({ companyId: f.companyId, principalType: "user", principalId: p.userId, + permissionKey: "agents:configure" }); + const tools: Record[] = [], hooks: Record[] = []; + let failDefaults = defaultsFailure; + const chat = chatChannelService(db, { heartbeat: { wakeup: async () => { throw new Error("No call expected"); } }, + publicBaseUrl: "https://history.example", webhookPublicBaseUrl: "https://history.example", fetch: async (input, init) => { + const pathname = new URL(String(input)).pathname, method = init?.method ?? "GET"; + if (pathname === "/v1/agents/agent_test") { + if (method === "PATCH" && failDefaults) return new Response("Defaults failed", { status: 503 }); + return Response.json({ id: "agent_test", organizationId: "org_test", name: "History voice" }); + } + if (pathname.startsWith("/v1/agents/agent_test/tools")) { + if (method === "GET") return Response.json(tools); + const tool = { ...JSON.parse(String(init?.body)), id: pathname.split("/").length > 5 ? pathname.split("/").at(-1)! : `tool_${tools.length}` }; + const index = tools.findIndex(old => old.id === tool.id); if (index < 0) tools.push(tool); else tools[index] = tool; + return Response.json({ id: tool.id }); + } + if (pathname.startsWith("/v1/webhooks")) { + if (method === "GET") return Response.json({ data: hooks }); + const hook = { ...JSON.parse(String(init?.body)), id: "hook_test", filterTags: {} }; hooks.splice(0, hooks.length, hook); + return Response.json({ id: hook.id }); + } + throw new Error(`Unexpected Speko provider boundary ${method} ${pathname}`); + } }); + try { + const endpoint = await chat.create(f.companyId, { provider: "speko", assignedAgentId: f.agentId }, p.userId); + let configureAttempts = 0; + const configure = () => chat.configure(endpoint.id, { action: configureAttempts++ === 0 ? "configure" : "reconnect", + credentials: { apiKey: "fixture-api-key", agentId: "agent_test", signingSecret: `whsec_${Buffer.alloc(32, 7).toString("base64")}` } }, p.userId); + if (defaultsFailure) { + await expect(configure()).rejects.toMatchObject({ status: 422 }); + const partial = (await db.select().from(tables.chatActions)).filter(a => a.endpointId === endpoint.id); + expect(partial).toHaveLength(4); + expect(partial.every(a => a.status === "completed")).toBe(true); + for (const receipt of partial) expect(await blockedActionKind(receipt.kind)).toBe(true); + failDefaults = false; + } + await configure(); + const receipts = (await db.select().from(tables.chatActions)).filter(a => a.endpointId === endpoint.id); + expect(receipts).toHaveLength(4); + expect(await blockedActionKind("speko_tool_setup")).toBe(false); + expect(await blockedActionKind("speko_lifecycle_setup")).toBe(false); + const [activity] = await db.select().from(tables.activityLog).where(eq(tables.activityLog.action, + defaultsFailure ? "chat_endpoint.reconnected" : "chat_endpoint.credentials_verified")); + expect(activity.details).toMatchObject({ endpointId: endpoint.id, provider: "speko" }); + } finally { + await chat.shutdown(); + if (oldKey === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; else process.env.PAPERCLIP_SECRETS_MASTER_KEY = oldKey; + } + }, 60_000); + + it("retires actual GitHub omission navigation only after its exact native response publication settles", async () => { + const { createGitHubPublicationPolicy } = await import("../services/chat-channels/github/publication-policy.js"); + const { ensureNativeCompletionContract } = await import("../services/native-runtime/completion-contracts.js"); + const { chatChannelService } = await import("../services/chat-channels.js"); + const f = await slackFixture(); + const [endpoint] = await db.update(tables.chatEndpoints).set({ provider: "github" }).where(eq(tables.chatEndpoints.id, f.endpoint.id)).returning(); + const contract = await ensureNativeCompletionContract({ db, companyId: f.companyId, issue: { id: f.issueId, title: "Omitted source", description: null }, actorId: "history" }); + const [comment] = await db.insert(tables.issueComments).values({ companyId: f.companyId, issueId: f.issueId, + authorAgentId: f.agentId, createdByRunId: f.runId, body: "Source unavailable; see the task" }).returning(); + await db.update(tables.heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: f.issueId, completionContractId: contract.row.id, + resultJson: { presentationDecision: { commentId: comment.id } } }).where(eq(tables.heartbeatRuns.id, f.runId)); + const [result] = await db.insert(tables.nativeRunResults).values({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, + completionContractId: contract.row.id, schemaStatus: "accepted", serverFingerprint: randomUUID(), canonicalSha256: "a".repeat(64), resultJson: {} }).returning(); + await db.insert(tables.nativeRunFinalizations).values({ companyId: f.companyId, issueId: f.issueId, runId: f.runId, + phase: "committed", resultId: result.id }); + const [publication] = await db.insert(tables.chatPublications).values({ companyId: f.companyId, endpointId: endpoint.id, + conversationId: f.conversation.id, issueId: f.issueId, commentId: comment.id, idempotencyKey: randomUUID(), payload: { text: comment.body } }).returning(); + const unused = async (): Promise => { throw new Error("Unexpected GitHub publication boundary"); }; + const policy = createGitHubPublicationPolicy(db, { isExplicitOperatorPublication: () => false, getTaskBaseUrl: () => "https://history.example", + // Exact current native-input authority is the named consumer boundary; + // the actual policy validates canonical owner rows and writes the marker. + failedChatRetrySource: async () => ({ endpointId: endpoint.id, conversationId: f.conversation.id, + sources: [{ omissionReasons: { download_unavailable: 1 } }] }) as never, + isExternalActionAuthorizationChange: () => false, FailedChatRetryPublicationReadError: class extends Error {}, endpointRecord: unused, + runIdFromMilestonePublication: () => null, fetchImpl: unused, stageTerminalReceiptReactionRemovals: unused, + runtimeContextForRecord: unused, scheduleMessageProcessing: unused, processReceiptReaction: unused }); + const text = await db.transaction(tx => policy.githubCurrentOmissionPreparedText(tx, publication, endpoint)); + expect(text).toContain("Open this Paperclip task"); + const [receipt] = await db.select().from(tables.chatActions).where(eq(tables.chatActions.kind, "github_omission_navigation")); + expect(receipt).toMatchObject({ status: "processed", result: null }); + expect(await blockedActionKind("github_omission_navigation")).toBe(true); + await db.update(tables.chatPublications).set({ payload: { ...publication.payload, text: String(text) }, state: "delivery_unknown" }) + .where(eq(tables.chatPublications.id, publication.id)); + const chat = chatChannelService(db, { heartbeat: { wakeup: unused }, fetch: unused }); + try { await chat.resolvePublication(endpoint.id, publication.id, "mark_delivered", f.userId); } + finally { await chat.shutdown(); } + expect(await blockedActionKind("github_omission_navigation")).toBe(false); + await db.update(tables.chatActions).set({ payload: { ...receipt.payload, resultId: randomUUID() } }).where(eq(tables.chatActions.id, receipt.id)); + expect(await blockedActionKind("github_omission_navigation")).toBe(true); + }); + + it("retires an actual Discord status-command receipt while preserving registration deadline work", async () => { + const { reconcileStoredDiscordCommandRegistration } = await import("../services/chat-discord-command-registration-store.js"); + const { createDiscordCommands } = await import("../services/chat-channels/discord/commands.js"); + const { chatCredentialMutationLease } = await import("../services/chat-credential-mutation-lease.js"); + const f = await slackFixture(), applicationId = String(1_400_000_000_000_000_000n + BigInt(Date.now()) + BigInt(randomBytes(2).readUInt16BE())), guildId = "1457808928258658549", channelId = "333333333333333333"; + const [endpoint] = await db.update(tables.chatEndpoints).set({ provider: "discord", botExternalId: applicationId, + providerAccountId: guildId, capabilities: { slashCommands: true } }).where(eq(tables.chatEndpoints.id, f.endpoint.id)).returning(); + const [principal] = await db.update(tables.chatExternalPrincipals).set({ provider: "discord", externalId: "444444444444444444", + providerAccountId: guildId }).where(eq(tables.chatExternalPrincipals.id, f.principal.id)).returning(); + const context = { generation: 1, credentialFingerprint: "c".repeat(64), localEpoch: 0, version: "fixture", discordGatewayOwned: true }; + const scope = { companyId: f.companyId, endpointId: endpoint.id, applicationId, guildId }; + const remote: Record[] = []; + const registered = await reconcileStoredDiscordCommandRegistration(db, { scope, runtimeFence: { generation: context.generation, credentialFingerprint: context.credentialFingerprint }, botToken: "fixture-token", + authorize: async tx => { + expect((await tx.select().from(tables.chatEndpoints).where(eq(tables.chatEndpoints.id, endpoint.id)).for("update"))[0].status).toBe("active"); + }, fetch: async (_url, init) => { + if ((init?.method ?? "GET") === "GET") return Response.json(remote); + const command = { ...JSON.parse(String(init?.body)), id: "888888888888888888", application_id: applicationId, version: "999999999999999999" }; + remote.splice(0, remote.length, command); return Response.json(command); + } }); + expect(registered.kind).toBe("registered"); + const leaseKey = "discord_gateway", token = randomUUID(); + await db.insert(tables.chatEndpointLeases).values({ companyId: f.companyId, endpointId: endpoint.id, leaseKey, token, + expiresAt: new Date(Date.now() + 60_000) }); + const record = { endpoint, assignedAgentName: "Idle agent", connectionName: "Discord", applicationId: randomUUID(), credentialSecretRefs: [] }; + const unused = async (): Promise => { throw new Error("No Discord transport/control boundary expected"); }; + const service = createDiscordCommands(db, { endpointRecord: async () => record, runtimeContextForRecord: () => context, + runtimeGeneration: () => 1, credentialFingerprint: () => context.credentialFingerprint, resolveCredentialRefs: unused, + fetchImpl: unused, withCredentialMutationLease: chatCredentialMutationLease(db), runtimeCallbackRecord: async () => record, + ensurePrincipal: async () => ({ principal, userId: f.userId }), runtimeCallbackEndpoint: async () => endpoint, + nonDirectDestinationAllowed: () => true, lockCurrentPrincipalAuthorization: async () => ({ allowed: true, userId: f.userId }), + getDiscordGatewayOwnership: () => ({ leaseKey, token }), discordGatewayRuntimeIsCurrent: () => true, + stageAuthorizedTaskControlPublication: unused, scheduleMessageProcessing: unused, processPendingPublications: unused }); + const thread = `discord:${guildId}:${channelId}`; + const result = await service.handleDiscordNativeCommand({ endpointId: endpoint.id, provider: "discord", transport: "discord_gateway", + event: { command: "/paperclip status", text: "", channelId: thread, channel: { id: thread }, + user: { userId: principal.externalId, isBot: false, isMe: false }, raw: { id: "777777777777777777", application_id: applicationId, + type: 2, version: 1, channel_id: channelId, channel: { id: channelId, type: 0 }, guild_id: guildId, context: 0, + authorizing_integration_owners: { "0": guildId }, data: { id: "888888888888888888", type: 1, name: "paperclip", + options: [{ type: 1, name: "status" }] }, user: { id: principal.externalId, username: "operator", bot: false } } } } as never, context); + expect(result).toMatchObject({ kind: "accepted" }); + expect(await blockedActionKind("discord_native_command")).toBe(false); + expect(await blockedActionKind("discord_command_registration")).toBe(true); + const [receipt] = await db.select().from(tables.chatActions).where(eq(tables.chatActions.kind, "discord_native_command")); + await db.update(tables.chatActions).set({ result: { ...receipt.result, kind: "unknown" } }).where(eq(tables.chatActions.id, receipt.id)); + expect(await blockedActionKind("discord_native_command")).toBe(true); + }); + + it.each(["live_photo_video", "heif_jpeg_preview"] as const)("retains actual Photon %s provenance after source-bound storage registration", async relationKind => { + const { createInboundAttachments } = await import("../services/chat-channels/inbound-attachments.js"); + const { createInboundWakeups } = await import("../services/chat-channels/inbound/wakeups.js"); + const { downloadPhotonAttachment } = await import("../services/photon/attachments.js"); + const { issueService } = await import("../services/issues.js"); + const { createStorageService } = await import("../storage/service.js"); + const { createLocalDiskStorageProvider } = await import("../storage/local-disk-provider.js"); + const f = await slackFixture(), directory = await mkdtemp(path.join(tmpdir(), "history-photon-provenance-")); + const [endpoint] = await db.update(tables.chatEndpoints).set({ provider: "imessage-photon" }).where(eq(tables.chatEndpoints.id, f.endpoint.id)).returning(); + const [comment] = await db.insert(tables.issueComments).values({ companyId: f.companyId, issueId: f.issueId, body: "Source-bound photo" }).returning(); + const providerEventId = randomUUID(); + const [delivery] = await db.insert(tables.chatDeliveries).values({ companyId: f.companyId, endpointId: endpoint.id, + conversationId: f.conversation.id, principalId: f.principal.id, providerEventId, deduplicationKey: randomUUID(), + eventKind: "message", state: "processing", attempts: 1, normalizedEvent: { providerEventId, kind: "message" } }).returning(); + const unused = async (): Promise => { throw new Error("Unexpected Photon admission boundary"); }; + const wakes = createInboundWakeups(db, { authorizeInboundWakeup: unused, authorizeInboundWorkStart: unused, + attachmentOmissionDetail: () => null, isExternalActionAuthorizationChange: () => false, redactError: String, + fetchImpl: unused, heartbeat: { wakeup: unused } }); + try { + await db.transaction(tx => wakes.stageInboundWakeup(tx, { endpoint, deliveryId: delivery.id, conversation: f.conversation, + commentId: comment.id, principalId: f.principal.id, actorUserId: null })); + const storage = createStorageService(createLocalDiskStorageProvider(directory)); + const bytes = relationKind === "heif_jpeg_preview" + ? await readFile(new URL("./photon/fixtures/synthetic.heic", import.meta.url)) + : Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/6S8AAAAASUVORK5CYII=", "base64"); + const type = relationKind === "heif_jpeg_preview" ? "image/heic" : "image/png"; + let body = bytes, closed = false; + if (relationKind === "live_photo_video") { + const companion = Buffer.from("Actual bounded companion bytes"), locator = { kind: "photon_attachment" as const, + lineId: "line", chatGuid: "chat", messageGuid: "message", attachmentGuid: "attachment" }; + const info = { guid: "attachment", totalBytes: bytes.length, mimeType: type, fileName: "photo.png", isSticker: false, isHidden: false }; + const stream = { async *[Symbol.asyncIterator]() { + yield { type: "header", info, companionInfo: { kind: "live-photo-video", mimeType: "video/mp4", totalBytes: companion.length, fileName: "motion.mp4" } }; + yield { type: "primaryChunk", data: bytes }; yield { type: "companionChunk", data: companion }; + }, close: async () => { closed = true; } }; + body = await downloadPhotonAttachment({ messages: { get: async () => ({ guid: "message", chatGuids: ["chat"], content: { attachments: [info] } }) }, + attachments: { downloadStream: () => stream } } as never, "line", locator); + expect(closed).toBe(true); + } + const service = createInboundAttachments(db, { storage, authorizeInboundWakeup: async () => ({ delivery, conversation: f.conversation }), + lifecycleRuntimeFence: () => ({ generation: 1, credentialFingerprint: "c".repeat(64) }), sanitizeFilename: value => value ?? null, + issuesSvc: issueService(db), isExternalActionAuthorizationChange: () => false, redactError: String }); + const result = await service.ingestAttachments({ endpoint, endpointRuntime: {} as never, deliveryId: delivery.id, + issueId: f.issueId, issueCommentId: comment.id, actorUserId: null, + attachments: [{ type: "image", name: relationKind === "heif_jpeg_preview" ? "photo.heic" : "photo.png", mimeType: type, + size: bytes.length, fetchData: async () => body }] }); + expect(result.omissionReasons).toEqual({}); + expect(result.storedIds).toHaveLength(2); + const receipt = (await db.select().from(tables.chatActions)).find(a => a.kind === (relationKind === "heif_jpeg_preview" ? "attachment_derivative" : "attachment_companion"))!; + expect(receipt).toMatchObject({ status: "processed", payload: { kind: relationKind } }); + expect(await blockedActionKind(receipt.kind)).toBe(false); + expect(await blockedActionKind("inbound_wakeup")).toBe(true); + const assets = await db.select().from(tables.assets); + for (const asset of assets) expect((await storage.headObject(f.companyId, asset.objectKey)).exists).toBe(true); + const { chatChannelService } = await import("../services/chat-channels.js"); + const chat = chatChannelService(db, { heartbeat: { wakeup: unused }, fetch: unused }); + try { + await expect(chat.publishBoardMessage(endpoint.id, f.conversation.id, "Reuse an already bound attachment", randomUUID(), f.userId, + [result.storedIds[0]])).rejects.toMatchObject({ status: 409 }); + expect(await blockedActionKind("board_send_rejected")).toBe(false); + } finally { await chat.shutdown(); } + await db.update(tables.chatActions).set({ payload: { ...receipt.payload, derivativeSha256: "0".repeat(64) } }).where(eq(tables.chatActions.id, receipt.id)); + expect(await blockedActionKind(receipt.kind)).toBe(true); + await db.update(tables.chatActions).set({ payload: receipt.payload }).where(eq(tables.chatActions.id, receipt.id)); + const [otherIssue] = await db.insert(tables.issues).values({ companyId: f.companyId, title: "Wrong lineage" }).returning(); + await db.update(tables.issueAttachments).set({ issueId: otherIssue.id }).where(eq(tables.issueAttachments.id, String(receipt.payload.derivativeAttachmentId))); + expect(await blockedActionKind(receipt.kind)).toBe(true); + await db.update(tables.issueAttachments).set({ issueId: f.issueId }).where(eq(tables.issueAttachments.id, String(receipt.payload.derivativeAttachmentId))); + const { issueRoutes } = await import("../routes/issues.js"); + const { createMcpApiDispatch } = await import("../services/public-mcp/capabilities.js"); + const { Router } = await import("express"); + const router = Router(); router.use(issueRoutes(db, storage)); + const api = createMcpApiDispatch(router), actor = { type: "board", userId: f.userId, companyIds: [f.companyId], source: "session", + memberships: [{ companyId: f.companyId, membershipRole: "owner", status: "active" }] }; + for (const id of result.storedIds) expect(await api({ actor } as never, "DELETE", `/attachments/${id}`)).toEqual({ ok: true }); + expect(await db.select().from(tables.issueAttachments)).toHaveLength(0); + for (const asset of assets) expect((await storage.headObject(f.companyId, asset.objectKey)).exists).toBe(false); + expect(await blockedActionKind(receipt.kind)).toBe(false); + expect(await blockedActionKind("inbound_wakeup")).toBe(true); + } finally { wakes.unregisterDeferredWakeOwner(); await rm(directory, { recursive: true, force: true }); } + }, 60_000); + + it("retires actual Photon interaction, retry and response-notice history with their canonical owners", async () => { + const { issueThreadInteractionService } = await import("../services/issue-thread-interactions.js"); + const { recordPhotonPublicationRetry } = await import("../services/chat-channels/imessage-photon/publication-retry.js"); + const { stagePhotonResponseNotice } = await import("../services/chat-channels/imessage-photon/response-notices.js"); + const { chatChannelService } = await import("../services/chat-channels.js"); + const f = await seed(), c = await connection(f); + await db.update(tables.issues).set({ status: "in_progress" }).where(eq(tables.issues.id, f.issueId)); + const [endpoint] = await db.insert(tables.chatEndpoints).values({ companyId: f.companyId, connectionId: c.connectionId, + assignedAgentId: f.agentId, provider: "imessage-photon", publicId: randomUUID(), status: "active", publicationMode: "automatic" }).returning(); + const [conversation] = await db.insert(tables.chatConversations).values({ companyId: f.companyId, endpointId: endpoint.id, + issueId: f.issueId, externalConversationId: "photon-personal", externalThreadId: "photon-personal", externalLabel: "Personal", state: "active" }).returning(); + const interactions = issueThreadInteractionService(db); + const interaction = await interactions.create({ id: f.issueId, companyId: f.companyId }, { kind: "ask_user_questions", + continuationPolicy: "wake_assignee", payload: { version: 1, title: "Choose", submitLabel: "Continue", questions: [{ + id: "choice", prompt: "Choose", selectionMode: "single", required: true, allowOther: false, + options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] }] } }, { agentId: f.agentId }); + const [token] = await db.select().from(tables.chatActions); + expect(token).toMatchObject({ kind: "photon_interaction", status: "issued", conversationId: conversation.id }); + expect(await blockedActionKind("photon_interaction")).toBe(true); + await interactions.answerQuestions({ id: f.issueId, companyId: f.companyId }, interaction.id, + { answers: [{ questionId: "choice", optionIds: ["yes"] }] }, { userId: "local-board" }); + expect(await blockedActionKind("photon_interaction")).toBe(false); + const [previousSource] = await db.select().from(tables.chatPublications); + const [source] = await db.update(tables.chatPublications).set({ state: "delivery_unknown" }) + .where(eq(tables.chatPublications.id, previousSource.id)).returning(); + await db.transaction(async tx => { + await recordPhotonPublicationRetry(tx, endpoint.id, source, "local-board"); + await stagePhotonResponseNotice(tx, { publication: source, reference: String(token.payload.reference), questionIndex: 0, + key: `photon-response:${token.payload.reference}:1`, text: "Answer saved" }); + }); + expect(await blockedActionKind("photon_publication_retry")).toBe(true); + expect(await blockedActionKind("photon_response_notice")).toBe(true); + const unused = async (): Promise => { throw new Error("No Photon network expected"); }; + const service = chatChannelService(db, { heartbeat: { wakeup: unused }, fetch: unused }); + try { + for (const publication of await db.select().from(tables.chatPublications)) { + await db.update(tables.chatPublications).set({ state: "delivery_unknown" }).where(eq(tables.chatPublications.id, publication.id)); + await service.resolvePublication(endpoint.id, publication.id, "mark_delivered", "local-board"); + } + expect(await blocked("chat_actions")).toBe(false); + await db.update(tables.chatActions).set({ payload: { ...token.payload, interactionId: randomUUID() } }).where(eq(tables.chatActions.id, token.id)); + expect(await blockedActionKind("photon_interaction")).toBe(true); + } finally { await service.shutdown(); } + }); + + it("retains actual Slack connect discovery while its separate notice remains pending", async () => { + const { createSlackCommands, slackCommandForAgent } = await import("../services/chat-channels/slack/commands.js"); + const { createProviderEffects } = await import("../services/chat-channels/provider-effects.js"); + const { chatCredentialMutationLease } = await import("../services/chat-credential-mutation-lease.js"); + const f = await slackFixture(), unused = async (): Promise => { throw new Error("Unexpected discovery boundary"); }; + const effects = createProviderEffects(db, { endpointRecord: unused, runtimeCallbackEndpoint: async () => f.endpoint, + withCredentialMutationLease: chatCredentialMutationLease(db), runtimeFor: unused, invalidateRuntime: unused, + lockCurrentPrincipalAuthorization: unused, redactError: String, MAX_ERROR_TEXT: 500, + nonDirectDestinationAllowed: () => false, lockTelegramCallbackNoticeAuthority: unused }); + const command = slackCommandForAgent("Idle agent", f.endpoint.publicId), scheduled: string[] = []; + const service = createSlackCommands(db, { runtime: { get: () => undefined }, runtimeFor: unused, handleSdkMessage: unused, + stageProviderEffect: effects.stageProviderEffect, scheduleProviderEffect: id => { scheduled.push(id); }, + ensurePrincipal: async () => ({ principal: f.principal, userId: null, linkedDenied: false }), + runtimeCallbackEndpoint: async () => f.endpoint, getPublicBaseUrl: () => "https://history.example", + canonicalProviderResourceId: unused, nonDirectDestinationAllowed: () => false, sponsorAllowsGuest: unused, + latestSlackDmControlThreadId: unused, scheduleMessageProcessing: unused, processSlackTaskStart: unused }); + await service.handleSlackSlashCommand({ endpointId: f.endpoint.id, provider: "slack", transport: "slack_socket", + event: { command, text: "connect", triggerId: randomUUID(), channel: { id: "DCHANNEL" }, + user: { userId: "UREQUESTER", isBot: false, isMe: false }, raw: {} } } as never, + { generation: 1, credentialFingerprint: "c".repeat(64), localEpoch: 0, version: "fixture" }, + { endpoint: f.endpoint, assignedAgentName: "Idle agent", connectionName: "Slack", applicationId: randomUUID(), credentialSecretRefs: [] }, command, "connect"); + expect(scheduled).toHaveLength(1); + expect((await db.select().from(tables.chatIdentityLinks))[0]).toMatchObject({ status: "pending", confirmationTokenHash: expect.any(String) }); + expect(await blockedActionKind("slack_connect")).toBe(false); + expect(await blockedActionKind("provider_effect")).toBe(true); + expect(await blocked("chat_actions")).toBe(true); + }); + + it("retains the actual Telegram stop-subscription ownership receipt", async () => { + const { createTelegramMaintenance } = await import("../services/chat-channels/telegram/maintenance.js"); + const { chatCredentialMutationLease } = await import("../services/chat-credential-mutation-lease.js"); + const f = await seed(), c = await connection(f); + await db.update(tables.toolConnections).set({ enabled: true, status: "active" }).where(eq(tables.toolConnections.id, c.connectionId)); + const [endpoint] = await db.insert(tables.chatEndpoints).values({ companyId: f.companyId, connectionId: c.connectionId, + assignedAgentId: f.agentId, provider: "telegram", publicId: randomUUID(), status: "active", botExternalId: "1234567" }).returning(); + const unused = async (): Promise => { throw new Error("Unexpected Telegram provider boundary"); }; + const context = { generation: 3, credentialFingerprint: "c".repeat(64) }, withCredentialMutationLease = chatCredentialMutationLease(db); + const service = createTelegramMaintenance(db, { endpointRecord: unused, runtimeContextForRecord: () => context, + withCredentialMutationLease, resolveCredentials: unused, clearCredentials: unused, + runtimeCallbackEndpoint: async () => endpoint, getWebhookPublicBaseUrl: () => "https://history.example", + fetchImpl: unused, runtimeGeneration: () => 3, credentialFingerprint: () => context.credentialFingerprint, + redactError: String, credentialCheckTimeoutMs: 1000 }); + await withCredentialMutationLease(endpoint, lease => service.recordTelegramStopSubscription(endpoint, context, lease)); + expect(await service.hasTelegramStopSubscription(endpoint, context)).toBe(true); + expect(await blocked("chat_actions")).toBe(false); + const [receipt] = await db.select().from(tables.chatActions); + await db.update(tables.chatActions).set({ payload: { ...receipt.payload, webhookUrlSha256: "unknown" } }).where(eq(tables.chatActions.id, receipt.id)); + expect(await blocked("chat_actions")).toBe(true); + }); + + it("retains the actual Slack private-source boundary and governed completed write", async () => { + const { recordSlackReadBoundary } = await import("../services/connectors/slack-access.js"); + const { registerSlackTaskAuthority } = await import("../services/connectors/slack-authority.js"); + const { executeSlackWrite } = await import("../services/connectors/slack-writes.js"); + const f = await slackFixture(); + await recordSlackReadBoundary(db, f.authority, { id: "CPRIVATE", is_private: true }); + expect(await blocked("chat_actions")).toBe(false); + const invocationId = randomUUID(); + await db.insert(tables.toolInvocations).values({ id: invocationId, companyId: f.companyId, agentId: f.agentId, + issueId: f.issueId, runId: f.runId, connectionId: f.connectionId, actorType: "agent", actorId: f.agentId, + toolName: "slack_post_message", status: "executing" }); + const unregister = registerSlackTaskAuthority(db, async () => f.authority); + const methods: string[] = []; + try { + await executeSlackWrite(db, { companyId: f.companyId, agentId: f.agentId, issueId: f.issueId, runId: f.runId }, + "slack_post_message", { channel: "DCHANNEL", text: "Confirmed message", idempotencyKey: randomUUID() }, invocationId, + async input => { + const method = new URL(String(input)).pathname.split("/").at(-1)!; methods.push(method); + if (method === "conversations.info") return Response.json({ ok: true, channel: { id: "DCHANNEL", is_im: true, user: "UREQUESTER" } }); + if (method === "users.info") return Response.json({ ok: true, user: { id: "UREQUESTER", team_id: "TWORKSPACE", is_bot: false } }); + if (method === "chat.postMessage") return Response.json({ ok: true, channel: "DCHANNEL", ts: "123.456" }); + throw new Error(`Unexpected Slack boundary ${method}`); + }); + expect(methods).toContain("chat.postMessage"); + expect((await db.select().from(tables.chatActions)).find(a => a.kind === "slack_tool_write")) + .toMatchObject({ status: "processed", result: { channel: "DCHANNEL", ts: "123.456" } }); + expect(await blocked("chat_actions")).toBe(false); + await db.update(tables.chatActions).set({ status: "uncertain" }).where(eq(tables.chatActions.kind, "slack_tool_write")); + expect(await blocked("chat_actions")).toBe(true); + } finally { unregister(); } + }); + + it("retires actual Slack invitation, test-message and mirror receipts without hiding their publication", async () => { + const { slackTestMessageService } = await import("../services/connectors/slack/test-message.js"); + const { mirrorSlackBoardComment } = await import("../services/slack-board-messages.js"); + const { chatChannelService } = await import("../services/chat-channels.js"); + const { chatCredentialMutationLease } = await import("../services/chat-credential-mutation-lease.js"); + const { instanceSettingsService } = await import("../services/instance-settings.js"); + const f = await slackFixture(); + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + const unused = async (): Promise => { throw new Error("Unexpected provider boundary"); }; + const credentialLease = chatCredentialMutationLease(db); + const send = slackTestMessageService(db, { withLock: (id, task) => credentialLease(f.endpoint, task), + runtimeBotToken: async () => "test-token", linkInstaller: async () => {}, ensureAccount: async () => f.principal.id, + createLinkIntent: async () => ({ confirmationUrl: "https://example.test/link", expiresAt: new Date(Date.now() + 600_000).toISOString() }), + fetch: async input => { + const method = new URL(String(input)).pathname.split("/").at(-1); + if (method === "users.info") return Response.json({ ok: true, user: { id: "UREQUESTER", team_id: "TWORKSPACE", is_bot: false } }); + if (method === "chat.postMessage") return Response.json({ ok: true, channel: "DCHANNEL", ts: "123.456" }); + throw new Error(`Unexpected setup boundary ${method}`); + } }); + expect(await send(f.endpoint.id, { userId: f.userId }, { requestId: randomUUID(), slackUserId: "UREQUESTER" })) + .toMatchObject({ status: "connection_pending" }); + expect(await blocked("chat_actions")).toBe(false); + await db.insert(tables.chatIdentityLinks).values({ companyId: f.companyId, endpointId: f.endpoint.id, + principalId: f.principal.id, paperclipUserId: f.userId, status: "linked", confirmedAt: now }); + expect(await send(f.endpoint.id, { userId: f.userId }, { requestId: randomUUID(), slackUserId: "UREQUESTER" })) + .toMatchObject({ status: "sent" }); + expect(await blocked("chat_actions")).toBe(false); + const [comment] = await db.insert(tables.issueComments).values({ companyId: f.companyId, issueId: f.issueId, + authorType: "user", authorUserId: f.userId, body: "Mirror this board comment" }).returning(); + await db.transaction(tx => mirrorSlackBoardComment(tx, comment)); + const [publication] = await db.select().from(tables.chatPublications); + expect(publication).toMatchObject({ state: "pending", commentId: comment.id }); + expect(await blocked("chat_actions")).toBe(true); + const service = chatChannelService(db, { heartbeat: { wakeup: unused }, fetch: unused }); + try { + await db.update(tables.chatPublications).set({ state: "delivery_unknown" }).where(eq(tables.chatPublications.id, publication.id)); + await service.resolvePublication(f.endpoint.id, publication.id, "mark_delivered", f.userId); + expect(await blocked("chat_actions")).toBe(false); + await db.update(tables.chatActions).set({ status: "uncertain" }).where(eq(tables.chatActions.kind, "slack_test_message")); + expect(await blocked("chat_actions")).toBe(true); + } finally { await service.shutdown(); } + }); + + it("retires the actual shared GitHub response target only after its request is final", async () => { + const { githubIngressFixture } = await import("./helpers/github-webhook-ingress.js"); + const { writeGitHubResponseComment } = await import("../services/chat-github-response-comments.js"); + const { stagePullResponse } = await import("../services/chat-channels/github/response-target.js"); + const { withGitHubPublicationLease } = await import("../services/chat-github-publication-lease.js"); + const f = await githubIngressFixture(db), providerEventId = randomUUID(); + const [issue] = await db.insert(tables.issues).values({ companyId: f.companyId, title: "GitHub reply", status: "done" }).returning(); + const [conversation] = await db.insert(tables.chatConversations).values({ companyId: f.companyId, endpointId: f.endpoint.id, + issueId: issue.id, externalConversationId: "repository:418", externalThreadId: "github:org/repo:7", externalLabel: "PR7" }).returning(); + const [delivery] = await db.insert(tables.chatDeliveries).values({ companyId: f.companyId, endpointId: f.endpoint.id, + conversationId: conversation.id, providerEventId, deduplicationKey: randomUUID(), eventKind: "action", normalizedEvent: { providerEventId, kind: "action" } }).returning(); + const source = { endpoint: f.endpoint, conversation, delivery, number: 7, replyId: null, repositoryId: "418" }; + await stagePullResponse(db, source); + expect(await blocked("chat_actions")).toBe(false); // initialized target owns no request yet + let posted: { id: number; html_url: string; body: string; user: { login: string | null } } | undefined; + const calls: string[] = []; + const api = { request: async (pathname: string, options?: { method?: string; body?: unknown }): Promise => { + calls.push(`${options?.method ?? "GET"} ${pathname}`); + if (!options?.method) return (pathname.includes("?per_page=") ? [] : posted) as T; + posted = { id: 123, html_url: "https://github.com/org/repo/pull/7#issuecomment-123", + body: (options.body as { body: string }).body, user: { login: f.endpoint.botUsername } }; + return posted as T; + } }; + const publish = (final: boolean) => withGitHubPublicationLease(db, { companyId: f.companyId, endpointId: f.endpoint.id, + repositoryId: "418", number: 7 }, async () => { throw new Error("No lease network expected"); }, lease => + writeGitHubResponseComment(db, source, api, lease, { body: final ? "Final result" : "Working", final }, async () => {})); + await publish(false); + expect(await blocked("chat_actions")).toBe(true); + await publish(true); + expect(calls).toContain("PATCH /issues/comments/123"); + expect(await blocked("chat_actions")).toBe(false); + const request = (await db.select().from(tables.chatActions)).find(a => a.kind === "github_response_comment")!; + expect(request).toMatchObject({ status: "processed", result: { final: true, id: "123" } }); + await db.update(tables.chatActions).set({ result: { ...request.result, final: false } }).where(eq(tables.chatActions.id, request.id)); + expect(await blocked("chat_actions")).toBe(true); + }); + + it("retires actual GitHub terminal review checks despite their retained retry hints", async () => { + const { githubIngressFixture } = await import("./helpers/github-webhook-ingress.js"); + const { githubReviewCheckService } = await import("../services/chat-github-checks.js"); + const { secretService } = await import("../services/secrets.js"); + const { defaultGitHubReviewPolicy } = await import("@paperclipai/shared"); + const oldKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "a".repeat(64); + try { + const f = await githubIngressFixture(db); + const privateKey = generateKeyPairSync("rsa", { modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" } }).privateKey; + const refs = []; + for (const [key, value] of Object.entries({ appId: "12", installationId: "34", privateKey })) { + const secret = await secretService(db).create(f.companyId, { name: `GitHub ${key}`, key, provider: "local_encrypted", value }); + refs.push({ configPath: `credentials.${key}`, secretId: secret.id, versionSelector: "latest" as const }); + await secretService(db).createBinding({ companyId: f.companyId, secretId: secret.id, targetType: "tool_connection", + targetId: f.endpoint.connectionId, configPath: `credentials.${key}`, versionSelector: "latest" }); + } + await db.update(tables.toolConnections).set({ enabled: true, status: "active", credentialSecretRefs: refs }) + .where(eq(tables.toolConnections.id, f.endpoint.connectionId)); + await db.insert(tables.chatGitHubConfigurations).values({ companyId: f.companyId, endpointId: f.endpoint.id, updatedByUserId: "local-board", + configuration: { version: 1, toolsEnabled: true, responsibleUserId: "local-board", memberAccess: "all_linked", people: [], + repositories: {}, defaults: { ...defaultGitHubReviewPolicy(), ratingThreshold: 3 } } }); + await db.insert(tables.chatEndpointResources).values({ companyId: f.companyId, endpointId: f.endpoint.id, + providerResourceId: "org/repo", type: "repository", label: "org/repo", enabled: true, + availability: "available", metadata: { providerRepositoryId: "418" } }); + const event = { event: "opened" as const, deliveryId: randomUUID(), repositoryId: "418", repository: "org/repo", pullNumber: 7, + title: "Saved review", body: "", baseSha: "a".repeat(40), headSha: "b".repeat(40), baseBranch: "master", + author: { id: "42", login: "octocat", isBot: false }, sender: { id: "42", login: "octocat" }, draft: false, labels: [] }; + const calls: string[] = []; + let stale = false; + const service = githubReviewCheckService(db, async input => { + const pathname = new URL(String(input)).pathname; calls.push(pathname); + if (pathname === "/app/installations/34/access_tokens") return Response.json({ token: "test-token" }); + if (pathname === "/repos/org/repo/pulls/7") return Response.json({ head: { sha: stale ? "c".repeat(40) : event.headSha } }); + if (pathname.endsWith("/check-runs")) return Response.json(pathname.includes("/commits/") + ? { check_runs: [] } : { id: 123, html_url: "https://github.com/org/repo/runs/123" }); + throw new Error(`Unexpected GitHub boundary ${pathname}`); + }); + await service.enqueue(f.endpoint, event, false, "manual review required"); + const [pending] = await db.select().from(tables.chatActions).where(eq(tables.chatActions.kind, "github_review_check")); + expect(await blocked("chat_actions")).toBe(true); + await service.processPending(); + const [completed] = await db.select().from(tables.chatActions).where(eq(tables.chatActions.id, pending.id)); + expect(completed, JSON.stringify({ result: completed.result, calls })).toMatchObject({ status: "processed", result: { state: "manual_required", retryable: true } }); + expect(calls).toContain("/repos/org/repo/check-runs"); + expect(await blocked("chat_actions")).toBe(false); + stale = true; + await service.enqueue(f.endpoint, { ...event, deliveryId: randomUUID() }, false, "stale head"); + const next = (await db.select().from(tables.chatActions)).find(a => a.status === "received")!; + await service.processPending(); + expect((await db.select().from(tables.chatActions).where(eq(tables.chatActions.id, next.id)))[0]) + .toMatchObject({ status: "cancelled", result: { code: "stale_head" } }); + expect(await blocked("chat_actions")).toBe(false); + await service.enqueue(f.endpoint, { ...event, deliveryId: randomUUID() }, false, "connection removed"); + await db.update(tables.chatEndpointResources).set({ enabled: false }).where(eq(tables.chatEndpointResources.endpointId, f.endpoint.id)); + const last = (await db.select().from(tables.chatActions)).find(a => a.status === "received")!; + await service.processPending(); + expect((await db.select().from(tables.chatActions).where(eq(tables.chatActions.id, last.id)))[0]) + .toMatchObject({ status: "cancelled", result: { code: "connection_changed" } }); + expect(await blocked("chat_actions")).toBe(false); + await db.update(tables.chatActions).set({ result: { state: "queued", retryable: true } }).where(eq(tables.chatActions.id, completed.id)); + expect(await blocked("chat_actions")).toBe(true); + } finally { if (oldKey === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; else process.env.PAPERCLIP_SECRETS_MASTER_KEY = oldKey; } + }, 60_000); + + it("retires real question-form tokens only after their exact canonical answer commits", async () => { + const { issueThreadInteractionService } = await import("../services/issue-thread-interactions.js"); + const { claimChatQuestionFormSubmission, completeChatQuestionFormSubmission } = await import("../services/chat-question-forms.js"); + const f = await seed(), c = await connection(f); + await db.update(tables.issues).set({ status: "in_progress" }).where(eq(tables.issues.id, f.issueId)); + const [endpoint] = await db.insert(tables.chatEndpoints).values({ companyId: f.companyId, connectionId: c.connectionId, + assignedAgentId: f.agentId, publicId: randomUUID(), provider: "slack", status: "active", publicationMode: "automatic", + capabilities: { actions: true, modals: true } }).returning(); + const [conversation] = await db.insert(tables.chatConversations).values({ companyId: f.companyId, endpointId: endpoint.id, + issueId: f.issueId, externalConversationId: "question-channel", externalThreadId: "question-thread", externalLabel: "Question", state: "active" }).returning(); + const [principal] = await db.insert(tables.chatExternalPrincipals).values({ companyId: f.companyId, provider: "slack", + providerAccountId: "workspace", externalId: "answering-user" }).returning(); + const service = issueThreadInteractionService(db); + const interaction = await service.create({ id: f.issueId, companyId: f.companyId }, { + kind: "ask_user_questions", continuationPolicy: "wake_assignee", title: "Two questions", + payload: { version: 1, title: "Two questions", submitLabel: "Continue", questions: ["first", "second"].map(id => ({ + id, prompt: id, selectionMode: "single" as const, required: true, allowOther: false, + options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] })) }, + }, { agentId: f.agentId }); + const actions = await db.select().from(tables.chatActions); + const open = actions.find(a => a.kind === "question_form_open"), submit = actions.find(a => a.kind === "question_form_submit"); + expect(open).toMatchObject({ conversationId: conversation.id, status: "issued", result: null }); + expect(submit).toMatchObject({ status: "issued", result: null }); + expect(await blocked("chat_actions")).toBe(true); + await service.answerQuestions({ id: f.issueId, companyId: f.companyId }, interaction.id, + { answers: ["first", "second"].map(questionId => ({ questionId, optionIds: ["yes"] })) }, { userId: "local-board" }, { + afterResolveInTransaction: async (tx, resolved) => { + expect(await claimChatQuestionFormSubmission(tx, { actionRowId: submit!.id, principalId: principal.id })).toBe(true); + expect(await completeChatQuestionFormSubmission(tx, { actionRowId: submit!.id, interactionId: resolved.id })).toBe(true); + }, + }); + expect((await db.select().from(tables.issueThreadInteractions).where(eq(tables.issueThreadInteractions.id, interaction.id)))[0]) + .toMatchObject({ status: "answered", resolvedAt: expect.any(Date) }); + expect(await blocked("chat_actions")).toBe(false); + await db.update(tables.chatActions).set({ result: { code: "question_form_answered", interactionId: randomUUID() } }) + .where(eq(tables.chatActions.id, submit!.id)); + expect(await blocked("chat_actions")).toBe(true); + }); + + it("retires a staged inbound wake only after its actual delivery owner rejects it", async () => { + const { githubIngressFixture } = await import("./helpers/github-webhook-ingress.js"); + const { createInboundWakeups } = await import("../services/chat-channels/inbound/wakeups.js"); + const { chatChannelService } = await import("../services/chat-channels.js"); + const f = await githubIngressFixture(db); + const [issue] = await db.insert(tables.issues).values({ companyId: f.companyId, title: "Rejected input" }).returning(); + const [conversation] = await db.insert(tables.chatConversations).values({ companyId: f.companyId, endpointId: f.endpoint.id, + issueId: issue.id, externalConversationId: "rejected", externalThreadId: "rejected", externalLabel: "Rejected" }).returning(); + const [principal] = await db.insert(tables.chatExternalPrincipals).values({ companyId: f.companyId, provider: "github", + providerAccountId: "test", externalId: "42" }).returning(); + const [comment] = await db.insert(tables.issueComments).values({ companyId: f.companyId, issueId: issue.id, body: "Input" }).returning(); + const providerEventId = randomUUID(); + const [delivery] = await db.insert(tables.chatDeliveries).values({ companyId: f.companyId, endpointId: f.endpoint.id, + conversationId: conversation.id, providerEventId, deduplicationKey: randomUUID(), eventKind: "message", state: "received", + normalizedEvent: { providerEventId, kind: "message" } }).returning(); + const unused = async (): Promise => { throw new Error("No provider/admission boundary expected"); }; + const wakes = createInboundWakeups(db, { authorizeInboundWakeup: unused, authorizeInboundWorkStart: unused, + attachmentOmissionDetail: () => null, isExternalActionAuthorizationChange: () => false, redactError: String, + fetchImpl: unused, heartbeat: { wakeup: unused } }); + const service = chatChannelService(db, { heartbeat: { wakeup: unused }, fetch: unused }); + try { + await db.transaction(tx => wakes.stageInboundWakeup(tx, { endpoint: f.endpoint, deliveryId: delivery.id, + conversation, commentId: comment.id, principalId: principal.id, actorUserId: null })); + expect(await blocked("chat_actions")).toBe(true); + await wakes.settleRejectedInboundWakeups(delivery.id); + expect((await db.select().from(tables.chatActions))[0].status).toBe("preparing"); + await service.configure(f.endpoint.id, { action: "pause" }, "local-board"); + await wakes.settleRejectedInboundWakeups(delivery.id); + expect((await db.select().from(tables.chatActions))[0]).toMatchObject({ status: "failed", + result: { code: "inbound_wakeup_delivery_rejected" } }); + expect(await db.select().from(tables.agentWakeupRequests)).toHaveLength(0); + expect(await blocked("chat_actions")).toBe(false); + expect(await blocked("chat_deliveries")).toBe(false); + await db.update(tables.chatDeliveries).set({ nextAttemptAt: new Date(Date.now() + 60_000) }).where(eq(tables.chatDeliveries.id, delivery.id)); + expect(await blocked("chat_actions")).toBe(true); + } finally { wakes.unregisterDeferredWakeOwner(); await service.shutdown(); } + }); + + it("retires actual Teams consent and provider-effect receipts only after the transfer closes", async () => { + const { teamsFileTransferService } = await import("../services/chat-teams-file-transfers.js"); + const { projectTeamsFilePublication } = await import("../services/chat-teams-file-publication.js"); + const { installTeamsFileConsentHook, restoreTeamsFileConsentBinding } = await import("../services/chat-teams-file-consent.js"); + const oldKey = process.env.PAPERCLIP_SECRETS_MASTER_KEY; + process.env.PAPERCLIP_SECRETS_MASTER_KEY = "a".repeat(64); + try { + const f = await seed(), c = await connection(f), bytes = Buffer.from("Actual Teams transfer"); + const authority = { companyId: f.companyId, endpointId: randomUUID(), conversationId: randomUUID(), issueId: f.issueId, + publicationId: randomUUID(), commentId: randomUUID(), attachmentId: randomUUID(), principalId: randomUUID(), + authorizedUserId: "local-board", runtimeGeneration: 3, credentialFingerprint: "c".repeat(64), conversationGeneration: 2, + sourceDigest: "d".repeat(64), tenantId: randomUUID(), botAppId: randomUUID(), aadObjectId: randomUUID(), + providerConversationId: "a:exact-personal", providerUserId: "29:exact-recipient", + sha256: createHash("sha256").update(bytes).digest("hex"), byteSize: bytes.length, filename: "file.txt" }; + await db.insert(tables.chatEndpoints).values({ id: authority.endpointId, companyId: f.companyId, connectionId: c.connectionId, + assignedAgentId: f.agentId, publicId: randomUUID(), provider: "microsoft-teams", status: "active", + providerAccountId: authority.tenantId, botExternalId: authority.botAppId }); + await db.insert(tables.chatConversations).values({ id: authority.conversationId, companyId: f.companyId, + endpointId: authority.endpointId, issueId: f.issueId, externalConversationId: authority.providerConversationId, + externalThreadId: `teams:${Buffer.from(authority.providerConversationId).toString("base64url")}`, externalLabel: "Personal", + isDirectMessage: true, sessionGeneration: 2 }); + await db.insert(tables.chatExternalPrincipals).values({ id: authority.principalId, companyId: f.companyId, provider: "microsoft-teams", + providerAccountId: authority.tenantId, externalId: authority.aadObjectId }); + await db.insert(tables.issueComments).values({ id: authority.commentId, companyId: f.companyId, issueId: f.issueId, + authorUserId: "local-board", body: "Share this file" }); + const [asset] = await db.insert(tables.assets).values({ companyId: f.companyId, provider: "local_disk", objectKey: randomUUID(), + contentType: "text/plain", byteSize: bytes.length, sha256: authority.sha256, originalFilename: authority.filename }).returning(); + await db.insert(tables.issueAttachments).values({ id: authority.attachmentId, companyId: f.companyId, issueId: f.issueId, + assetId: asset.id, issueCommentId: authority.commentId }); + await db.insert(tables.chatPublications).values({ id: authority.publicationId, companyId: f.companyId, + endpointId: authority.endpointId, conversationId: authority.conversationId, issueId: f.issueId, commentId: authority.commentId, + idempotencyKey: randomUUID(), payload: { text: "Share file", attachmentIds: [authority.attachmentId] } }); + const service = teamsFileTransferService(db, { authorize: async () => ({ ...authority }), loadBytes: async () => bytes, + postConsent: async () => ({ id: "card-1" }), postFileInfo: async () => ({ id: "file-info-1" }), + uploadRequest: async () => Response.json({ id: "item-1", name: "file.txt", size: bytes.length }, { status: 201 }), + project: projectTeamsFilePublication }); + const created = await service.issue(authority, new Date(Date.now() + 60_000)); + await service.process(f.companyId, created.id); + const [pending] = await db.select().from(tables.chatTeamsFileTransfers); + expect(pending.phase).toBe("awaiting_consent"); + expect((await db.select().from(tables.chatActions)).map(a => a.kind)).toContain("teams_file_effect_intent"); + expect(await blocked("chat_actions")).toBe(true); + const binding = await restoreTeamsFileConsentBinding({ companyId: f.companyId, endpointId: authority.endpointId, + transferId: created.id, authorityDigest: pending.authorityDigest }, pending.privateState.binding as Record); + const handlers = new Map Promise<{ status: number }>>(); + installTeamsFileConsentHook({ on: (name, callback) => { handlers.set(name, callback); } }, { + companyId: f.companyId, endpointId: authority.endpointId, tenantId: authority.tenantId, botAppId: authority.botAppId, + onConsent: event => service.recordConsent(event) }); + expect((await handlers.get("file.consent.decline")!({ activity: { type: "invoke", name: "fileConsent/invoke", channelId: "msteams", + id: "decline-1", from: { id: authority.providerUserId, aadObjectId: authority.aadObjectId }, + recipient: { id: `28:${authority.botAppId}` }, conversation: { id: authority.providerConversationId, conversationType: "personal", tenantId: authority.tenantId }, + replyToId: "card-1", value: { action: "decline", context: { schema: binding.schema, token: binding.token, action: "decline" } } } })).status).toBe(200); + expect((await db.select().from(tables.chatTeamsFileTransfers))[0]).toMatchObject({ phase: "declined", attemptId: null, attemptExpiresAt: null }); + expect((await db.select().from(tables.chatActions)).map(a => a.kind)).toEqual(expect.arrayContaining(["teams_file_effect_intent", "teams_file_consent"])); + expect(await blocked("chat_actions")).toBe(false); + expect(await blocked("chat_publications")).toBe(false); + const [receipt] = await db.select().from(tables.chatActions).where(eq(tables.chatActions.kind, "teams_file_consent")); + await db.update(tables.chatActions).set({ payload: { ...receipt.payload, receiptDigest: "wrong" } }).where(eq(tables.chatActions.id, receipt.id)); + expect(await blocked("chat_actions")).toBe(true); + } finally { if (oldKey === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY; else process.env.PAPERCLIP_SECRETS_MASTER_KEY = oldKey; } + }); + + it("measures both chat predicates against 10000 valid retained deliveries and 30000 completed actions", async () => { + const { githubIngressFixture } = await import("./helpers/github-webhook-ingress.js"); + const { chatChannelService } = await import("../services/chat-channels.js"); + const f = await githubIngressFixture(db, undefined, 256), providerEventId = randomUUID(); + const [delivery] = await db.insert(tables.chatDeliveries).values({ companyId: f.companyId, endpointId: f.endpoint.id, + providerEventId, deduplicationKey: randomUUID(), eventKind: "message", state: "received", + normalizedEvent: { providerEventId, kind: "message" } }).returning(); + const action = await f.insertAction({ deliveryId: delivery.id }); + await f.service.processGitHubWebhookIngress(action.id); + const unused = async (): Promise => { throw new Error("No external boundary expected"); }; + const service = chatChannelService(db, { heartbeat: { wakeup: unused }, fetch: unused }); + try { await service.configure(f.endpoint.id, { action: "pause" }, "local-board"); } + finally { await service.shutdown(); } + expect(await blocked("chat_deliveries")).toBe(false); + expect(await blocked("chat_actions")).toBe(false); + // Retention load clones a receipt produced above; it is measurement data, + // not a substitute for the producer regressions in the surrounding cases. + await db.execute(sql`INSERT INTO chat_deliveries (company_id, endpoint_id, provider_event_id, deduplication_key, event_kind, + state, normalized_event, processed_at) + SELECT ${f.companyId}::uuid, ${f.endpoint.id}::uuid, 'retained:' || n, 'retained:' || n, d.event_kind, d.state, + jsonb_set(d.normalized_event, '{providerEventId}', to_jsonb('retained:' || n)), d.processed_at + FROM chat_deliveries d CROSS JOIN generate_series(1, 9999) n WHERE d.id = ${delivery.id}::uuid`); + await db.execute(sql`INSERT INTO chat_actions (company_id, endpoint_id, delivery_id, kind, provider_action_id, payload, status, result) + SELECT a.company_id, a.endpoint_id, d.id, a.kind, 'retained:' || d.id || ':' || n, a.payload, a.status, a.result + FROM chat_actions a JOIN chat_deliveries d ON d.endpoint_id = a.endpoint_id CROSS JOIN generate_series(1, 3) n + WHERE a.id = ${action.id}::uuid AND d.id <> ${delivery.id}::uuid`); + await db.execute(sql`ANALYZE chat_deliveries`); + await db.execute(sql`ANALYZE chat_actions`); + const current = completedHistoryChecks.filter(c => ["chat_deliveries", "chat_actions"].includes(c.reason)); + const baselinePath = process.env.PAPERCLIP_HISTORY_QUERY_BASELINE_PATH; + const baseline: { reason: string; query: string }[] = baselinePath ? JSON.parse(await readFile(baselinePath, "utf8")) : []; + const measurements = []; + for (const [revision, checks] of [["published_aaf55692", baseline], ["repair", current]] as const) { + for (const check of checks) { + const started = performance.now(); + const plan = await db.transaction(async tx => { + await tx.execute(sql`SET LOCAL statement_timeout = '5s'`); + return tx.execute(sql`EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT EXISTS (${sql.raw(check.query)}) AS blocked`); + }); + measurements.push({ revision, reason: check.reason, wallMs: performance.now() - started, plan }); + } + } + const evidence = { deliveries: 10000, actions: 29998, measurements }; + if (process.env.PAPERCLIP_HISTORY_QUERY_PLANS_PATH) await writeFile(process.env.PAPERCLIP_HISTORY_QUERY_PLANS_PATH, + JSON.stringify(evidence, null, 2)); + console.info(`Retained chat history query plans ${JSON.stringify(evidence)}`); + expect(await blocked("chat_deliveries")).toBe(false); + expect(await blocked("chat_actions")).toBe(false); + }, 60_000); + it("uses actual chat ingress, lifecycle application and operator publication receipts", async () => { const { githubIngressFixture } = await import("./helpers/github-webhook-ingress.js"); const { chatCredentialMutationLease } = await import("../services/chat-credential-mutation-lease.js"); diff --git a/server/src/__tests__/storage-local-provider.test.ts b/server/src/__tests__/storage-local-provider.test.ts index e35500896fa..d25963d9fdf 100644 --- a/server/src/__tests__/storage-local-provider.test.ts +++ b/server/src/__tests__/storage-local-provider.test.ts @@ -119,6 +119,32 @@ describe("local disk storage provider", () => { await expect(service.getObject("company-1", stored.objectKey)).rejects.toMatchObject({ status: 404 }); }); + it.each(["EACCES", "EIO"])("retains storage uncertainty for %s instead of reporting absence", async code => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); + tempRoots.push(root); + const service = createStorageService(createLocalDiskStorageProvider(root)); + const stored = await service.putFile({ companyId: "company-1", namespace: "issues/issue-1", originalFilename: "retained.txt", + contentType: "text/plain", body: Buffer.from("must not disappear") }); + const failure = Object.assign(new Error("controlled filesystem failure"), { code }); + const unlink = vi.spyOn(fs, "unlink").mockRejectedValueOnce(failure); + try { await expect(service.deleteObject("company-1", stored.objectKey)).rejects.toBe(failure); } + finally { unlink.mockRestore(); } + expect(await service.headObject("company-1", stored.objectKey)).toMatchObject({ exists: true }); + const stat = vi.spyOn(fs, "stat").mockRejectedValueOnce(failure); + try { await expect(service.headObject("company-1", stored.objectKey)).rejects.toBe(failure); } + finally { stat.mockRestore(); } + expect(await readStreamToBuffer((await service.getObject("company-1", stored.objectKey)).stream)).toEqual(Buffer.from("must not disappear")); + }); + + it("treats a non-directory object path as absent", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-storage-")); + tempRoots.push(root); + await fs.writeFile(path.join(root, "company-1"), "not a directory"); + const service = createStorageService(createLocalDiskStorageProvider(root)); + expect(await service.headObject("company-1", "company-1/object")).toEqual({ exists: false }); + await expect(service.deleteObject("company-1", "company-1/object")).resolves.toBeUndefined(); + }); + it.each(["headObject", "deleteObject"] as const)("cancels %s even if a provider stops responding", async method => { const provider = createLocalDiskStorageProvider(os.tmpdir()); const request = vi.spyOn(provider, method).mockImplementation(async () => new Promise(() => {})); diff --git a/server/src/__tests__/voice-session-store.integration.test.ts b/server/src/__tests__/voice-session-store.integration.test.ts index ed0b95194cc..de061793644 100644 --- a/server/src/__tests__/voice-session-store.integration.test.ts +++ b/server/src/__tests__/voice-session-store.integration.test.ts @@ -1,3 +1,5 @@ +import { chatHistoryActionBlocks } from "./helpers/chat-history-proof.js"; +import { voiceCallReports } from "../services/voice/voice-call-reports.js"; import { subscribeDatabaseWork } from "@paperclipai/db"; import * as voiceReplyNotifications from "../services/voice/voice-reply-notifications.js"; import { voiceReplySchedule } from "../services/voice/voice-reply-schedule.js"; @@ -40,15 +42,16 @@ import { buildPaperclipRuntimeMcpServers } from "../services/heartbeat.js"; import { createToolGatewayService } from "../services/tool-gateway.js"; import { initializeRunIdentity } from "../services/run-identity.js"; import { syncSpekoVoiceTools, spekoToolsForSession, executeSpekoVoiceTool } from "../services/voice/speko-agent-tools.js"; -const external = resolveExternalTestDatabaseUrl("PAPERCLIP_VOICE_TEST_DATABASE_URL"); +const historyExternal = resolveExternalTestDatabaseUrl("PAPERCLIP_HISTORY_TEST_DATABASE_URL"); +const external = historyExternal ?? resolveExternalTestDatabaseUrl("PAPERCLIP_VOICE_TEST_DATABASE_URL"); const support = external ? { supported: true } : await getEmbeddedPostgresTestSupport(); (support.supported ? describe : describe.skip)("durable Speko voice sessions", () => { let db: ReturnType; let database: Awaited> | undefined; beforeAll(async () => { if (!external) database = await startEmbeddedPostgresTestDatabase("paperclip-speko-store-"); - if (external) await applyPendingMigrations(external); - db = createDb(external ?? database!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0 }); + if (external && !historyExternal) await applyPendingMigrations(external); + db = createDb(external ?? database!.connectionString, { idleTimeoutSeconds: 0, maxLifetimeSeconds: 0, ...(historyExternal ? { maxConnections: 4 } : {}) }); }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); afterAll(async () => { await db?.$client.end({ timeout: 1 }); await database?.cleanup(); }); @@ -75,7 +78,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu return { companyId, endpointId, sessionId, token: reserved.token, envelope, webhookId: `msg_${toolId}`, fingerprint: createHash("sha256").update(JSON.stringify(envelope)).digest("hex"), accept }; }; async function publication(state: "streaming" | "published" = "published") { - const [row] = await db.insert(chatPublications).values({ companyId, endpointId, conversationId, issueId, idempotencyKey: randomUUID(), state, payload: { text: "Approved result" } }).returning(); + const [row] = await db.insert(chatPublications).values({ companyId, endpointId, conversationId, issueId, idempotencyKey: randomUUID(), state, publishedAt: state === "published" ? new Date() : null, payload: { text: "Approved result" } }).returning(); return row; } return { companyId, endpointId, agentId, connectionId, issueId, conversationId, callerId, sessionId, store, request, reserved, accept, toolInput, publication }; @@ -164,6 +167,13 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu const report = f.sign({ type: "call.report", session_id: f.call.providerSessionId, organization_id: f.event.organization_id }); await f.service.inbound.lifecycle(f.endpoint.publicId, report.body, report.headers); await settle(); expect(f.transport.callReport).toHaveBeenCalledTimes(2); expect(onError).not.toHaveBeenCalled(); + const receipts = (await db.select().from(chatActions).where(eq(chatActions.endpointId, f.endpointId))) + .filter(row => row.kind === "speko_lifecycle_receipt"); + expect(receipts.length).toBeGreaterThan(0); + for (const receipt of receipts) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(true); + await db.update(chatVoiceSessions).set({ endedAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(chatVoiceSessions.companyId, f.companyId)); + expect(await voiceCallReports(db, f.transport.callReport).load({ kind: "company", id: f.companyId })).toEqual([]); + for (const receipt of receipts) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(false); } finally { await coordinator.stop(); vi.useRealTimers(); const ended = f.sign({ type: "call.status", call_id: f.call.providerSessionId, status: "ended" }); @@ -1381,7 +1391,7 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu it("sends long approved phone answers as durable ordered parts and resumes after a rate limit", async () => { const f = await pushFixture(), text = "😀 answer ".repeat(4000) + "FINAL"; await db.update(chatPublications).set({payload: {text}}).where(eq(chatPublications.id, f.publication.id)); - const [later] = await db.insert(chatPublications).values({companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, issueId: f.issueId, idempotencyKey: randomUUID(), state: "published", payload: {text: "Later answer B"}}).returning(); + const [later] = await db.insert(chatPublications).values({companyId: f.companyId, endpointId: f.endpointId, conversationId: f.conversationId, issueId: f.issueId, idempotencyKey: randomUUID(), state: "published", publishedAt: new Date(), payload: {text: "Later answer B"}}).returning(); await f.store.enqueuePublication(f.companyId, later.id); f.transport.sendCallMessage.mockResolvedValueOnce({messageId: "part-0"}).mockRejectedValueOnce(new SpekoProviderError("provider_unavailable", false, 429)); await f.push(); @@ -1405,6 +1415,13 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu for (const call of calls) {expect(call[1].length).toBeLessThanOrEqual(16_000); expect(/^[\uDC00-\uDFFF]|[\uD800-\uDBFF]$/.test(call[1])).toBe(false);} const [delivered] = await db.select().from(chatVoiceReplies).where(eq(chatVoiceReplies.id, reply.id)); expect(delivered.deliveredAt).toBeInstanceOf(Date); expect(delivered.spokenAt).toBeNull(); + const accepted = (await db.select().from(chatActions).where(eq(chatActions.endpointId, f.endpointId))) + .filter(row => row.kind === "speko_voice_reply_push"); + for (const receipt of accepted) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(true); + await f.service.end(f.companyId, f.session.id, f.input.caller); + await db.update(chatVoiceSessions).set({ endedAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(chatVoiceSessions.companyId, f.companyId)); + expect(await voiceCallReports(db, f.transport.callReport).load({ kind: "company", id: f.companyId })).toEqual([]); + for (const receipt of accepted) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(false); }); it("does not retry an uncertain long-answer part or send its suffix", async () => { const f = await pushFixture(); @@ -1416,6 +1433,13 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu expect(f.transport.sendCallMessage).toHaveBeenCalledTimes(2); const [reply] = await db.select().from(chatVoiceReplies).where(eq(chatVoiceReplies.publicationId, f.publication.id)); expect(reply.deliveredAt).toBeNull(); + await f.service.end(f.companyId, f.session.id, f.input.caller); + await db.update(chatVoiceSessions).set({ endedAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(chatVoiceSessions.companyId, f.companyId)); + expect(await voiceCallReports(db, f.transport.callReport).load({ kind: "company", id: f.companyId })).toEqual([]); + const uncertain = (await db.select().from(chatActions).where(eq(chatActions.endpointId, f.endpointId))) + .filter(row => row.kind === "speko_voice_reply_push" && row.status === "unknown"); + expect(uncertain).toHaveLength(1); + expect(await chatHistoryActionBlocks(db, uncertain[0]!.id)).toBe(true); }); it("pushes a published phone answer exactly once across concurrent dispatch and restart", async () => { const f = await pushFixture(); @@ -2027,6 +2051,15 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu expect(tools).toHaveLength(3); expect(f.transport.configureTool).toHaveBeenCalledTimes(7); expect(f.transport.configureTool.mock.calls.slice(-3).every((call) => Boolean(call[3]))).toBe(true); + const receipts = (await db.select().from(chatActions).where(eq(chatActions.endpointId, f.endpointId))) + .filter(row => ["speko_tool_setup", "speko_lifecycle_setup"].includes(row.kind)); + expect(receipts).toHaveLength(4); + for (const receipt of receipts) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(true); // real24h refresh obligation + await db.update(chatVoiceSessions).set({ endedAt: new Date(Date.now() - 2 * 86_400_000) }).where(eq(chatVoiceSessions.companyId, f.companyId)); + expect(await voiceCallReports(db, f.transport.callReport).load({ kind: "company", id: f.companyId })).toEqual([]); + // The resource helper alone has no post-configuration commit receipt; + // defaults/runtime completion belongs to the ordinary configure owner. + for (const receipt of receipts) expect(await chatHistoryActionBlocks(db, receipt.id)).toBe(true); }); it.each(["current", "legacy"])("moves only receipt-owned %s tools and hooks when callback origin and signing secret change", async proofVersion => { diff --git a/server/src/modules/external-agents/index.ts b/server/src/modules/external-agents/index.ts index 606d549b843..9d1b6e22c19 100644 --- a/server/src/modules/external-agents/index.ts +++ b/server/src/modules/external-agents/index.ts @@ -4,3 +4,5 @@ export { withExternalAdmissionGuard, withExternalExecutionAdmission, assertNoExt export { dotRunnerBroker, createDotRunnerMcpTools } from "./dot.js"; export { notifyExternalAgentSettlement, pendingMuseTerminalBindings, type ExternalAgentSettlementScope } from "./muse-terminal-settlement.js"; + +export { idleExternalAgentHistoryChecks } from "./idle-history-proof.js"; diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 3d6af2376f9..01bfe9f81d8 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -1,3 +1,4 @@ +import { confirmMcpAttachmentCleanup, recordMcpAttachmentCleanup, type McpAttachmentCleanup } from "../services/public-mcp/upload-cleanup.js"; import { notifyDeferredExecutionWork } from "../services/deferred-execution-notifications.js"; import { notifyNativeRecoveryNotices } from "../services/native-recovery-notice-notifications.js"; import { notifyNativeStatusReassessment } from "../services/native-status-reassessment-notifications.js"; @@ -16254,6 +16255,7 @@ export function issueRoutes( for (const attachment of attachments) { try { await storage.deleteObject(attachment.companyId, attachment.objectKey); + await recordMcpAttachmentCleanup(db, await confirmMcpAttachmentCleanup(db, storage, attachment)); } catch (err) { logger.warn( { err, issueId: id, attachmentId: attachment.id }, @@ -20138,8 +20140,10 @@ export function issueRoutes( if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; + let mcpCleanup: McpAttachmentCleanup | null = null; try { await storage.deleteObject(attachment.companyId, attachment.objectKey); + mcpCleanup = await confirmMcpAttachmentCleanup(db, storage, attachment); } catch (err) { logger.warn( { err, attachmentId }, @@ -20147,7 +20151,7 @@ export function issueRoutes( ); } - const removed = await svc.removeAttachment(attachmentId); + const removed = await svc.removeAttachment(attachmentId, { mcpCleanup }); if (!removed) { res.status(404).json({ error: "Attachment not found" }); return; diff --git a/server/src/services/chat-channels/discord/idle-history-proof.ts b/server/src/services/chat-channels/discord/idle-history-proof.ts new file mode 100644 index 00000000000..e90be06c0f6 --- /dev/null +++ b/server/src/services/chat-channels/discord/idle-history-proof.ts @@ -0,0 +1,25 @@ +/** Command acceptance is final history; a staged control publication remains an + * independent obligation and must settle before its command receipt is quiet. */ +export function completedDiscordAction(a: string): string { + return `${a}.kind = 'discord_native_command' AND ${a}.status = 'processed' + AND ${a}.payload->>'version' = '1' AND ${a}.payload->'invocation'->>'schema' = 'paperclip.discord.native-command.v1' + AND ${a}.payload->'invocation'->>'command' IN ('status', 'new', 'close') + AND ${a}.payload->'invocation'->>'digest' ~ '^[a-f0-9]{64}$' + AND ${a}.provider_action_id = 'discord-native-command:' || (${a}.payload->'invocation'->>'interactionId') + AND ${a}.result->>'kind' = 'discord_native_command_recorded' AND length(${a}.result->>'content') > 0 + AND EXISTS (SELECT 1 FROM chat_external_principals principal WHERE principal.id = ${a}.principal_id + AND principal.company_id = ${a}.company_id AND principal.provider = 'discord' + AND principal.external_id = ${a}.payload->'invocation'->>'actorExternalId') + AND ((${a}.conversation_id IS NULL AND ${a}.payload->'target' = 'null'::jsonb) + OR EXISTS (SELECT 1 FROM chat_conversations conversation WHERE conversation.id = ${a}.conversation_id + AND conversation.company_id = ${a}.company_id AND conversation.endpoint_id = ${a}.endpoint_id + AND conversation.id::text = ${a}.payload->'target'->>'conversationId' + AND conversation.issue_id::text = ${a}.payload->'target'->>'issueId' + AND conversation.session_generation::text = ${a}.payload->'target'->>'sessionGeneration')) + AND (${a}.result->'publicationId' = 'null'::jsonb OR EXISTS ( + SELECT 1 FROM chat_publications publication WHERE publication.id::text = ${a}.result->>'publicationId' + AND publication.company_id = ${a}.company_id AND publication.endpoint_id = ${a}.endpoint_id + AND publication.conversation_id = ${a}.conversation_id + AND ((publication.state = 'published' AND publication.published_at IS NOT NULL) + OR (publication.state IN ('failed', 'cancelled') AND publication.next_attempt_at IS NULL))))`; +} diff --git a/server/src/services/chat-channels/github/idle-history-proof.ts b/server/src/services/chat-channels/github/idle-history-proof.ts index 3dade037416..696460494f3 100644 --- a/server/src/services/chat-channels/github/idle-history-proof.ts +++ b/server/src/services/chat-channels/github/idle-history-proof.ts @@ -1,9 +1,65 @@ +function completedIngress(a: string): string { + return `(${a}.status = 'processed' AND ${a}.result->>'httpStatus' ~ '^2[0-9][0-9]$' AND ${a}.result->>'retryable' = 'false') + OR (${a}.status = 'failed' AND ${a}.result->>'retryable' = 'false' AND ( + ${a}.result->>'code' = 'github_webhook_ingress_payload_invalid' + OR ${a}.result->>'httpStatus' ~ '^4[0-9][0-9]$' + OR (jsonb_typeof(${a}.result->'attempts') = 'number' AND (${a}.result->>'attempts')::numeric >= 5))) + OR (${a}.status = 'cancelled' AND ${a}.result->>'retryable' = 'false' + AND ${a}.result->>'code' IN ('github_webhook_ingress_runtime_superseded', 'github_webhook_recovery_source_changed'))`; +} + export function completedGithubAction(a: string): string { - return `(${a}.kind = 'github_response_comment' AND ${a}.status = 'processed' AND ${a}.result->>'final' = 'true') - OR (${a}.kind = 'github_webhook_ingress' AND ( - (${a}.status = 'processed' AND ${a}.result->>'httpStatus' = '202' AND ${a}.result->>'retryable' = 'false') - OR (${a}.status = 'failed' AND ${a}.result->>'code' = 'github_webhook_ingress_payload_invalid' - AND ${a}.result->>'retryable' = 'false'))) - OR (${a}.kind = 'github_review_check' AND ${a}.status = 'processed' - AND ${a}.result->>'retryable' = 'false')`; + return `(${a}.kind = 'github_omission_navigation' AND ${a}.status = 'processed' AND ${a}.result IS NULL + AND ${a}.payload->>'version' = '1' AND ${a}.payload->>'preparedTextSha256' ~ '^[a-f0-9]{64}$' + AND ${a}.provider_action_id = 'github-omission-navigation:' || (${a}.payload->>'publicationId') + AND EXISTS (SELECT 1 FROM chat_publications publication JOIN heartbeat_runs run + ON run.id::text = ${a}.payload->>'runId' AND run.company_id = publication.company_id + AND run.native_issue_id = publication.issue_id AND run.runtime_mode = 'native' + JOIN native_run_finalizations finalization ON finalization.run_id = run.id AND finalization.company_id = run.company_id + AND finalization.issue_id = run.native_issue_id AND finalization.result_id::text = ${a}.payload->>'resultId' AND finalization.phase = 'committed' + WHERE publication.id::text = ${a}.payload->>'publicationId' AND publication.company_id = ${a}.company_id + AND publication.endpoint_id = ${a}.endpoint_id AND publication.conversation_id = ${a}.conversation_id + AND run.status = 'succeeded' AND run.finished_at IS NOT NULL AND run.cost_accounting_pending = false + AND ((publication.state = 'published' AND publication.published_at IS NOT NULL) + OR (publication.state IN ('failed', 'cancelled') AND publication.next_attempt_at IS NULL)))) + OR (${a}.kind = 'github_response_comment' AND ${a}.result->>'final' = 'true' + AND (${a}.status = 'processed' OR (${a}.status = 'cancelled' AND ${a}.result->>'code' + IN ('run_already_answered', 'passive_response_wait', 'response_deleted')))) + OR (${a}.kind = 'github_pr_response_comment' AND ${a}.status = 'processed' + AND (${a}.result IS NULL OR (${a}.result->>'final' = 'true' AND length(${a}.result->>'id') > 0 + AND length(${a}.result->>'url') > 0)) + AND EXISTS (SELECT 1 FROM chat_conversations conversation WHERE conversation.id = ${a}.conversation_id + AND conversation.company_id = ${a}.company_id AND conversation.endpoint_id = ${a}.endpoint_id) + AND NOT EXISTS (SELECT 1 FROM chat_actions request WHERE request.kind = 'github_response_comment' + AND request.company_id = ${a}.company_id AND request.endpoint_id = ${a}.endpoint_id + AND request.conversation_id = ${a}.conversation_id AND (request.result->>'final' = 'true') IS NOT TRUE)) + OR (${a}.kind = 'github_webhook_ingress' AND (${completedIngress(a)})) + OR (${a}.kind = 'github_review_check' AND ( + (${a}.status = 'processed' AND (${a}.result->>'state' IN ('manual_required', 'incomplete', 'error') + OR (${a}.result->>'code' = 'assessment_owns_check' AND EXISTS ( + SELECT 1 FROM chat_github_reviews review WHERE review.id::text = ${a}.result->>'reviewId' + AND review.company_id = ${a}.company_id AND review.endpoint_id = ${a}.endpoint_id AND review.assessment IS NOT NULL)))) + OR (${a}.status = 'cancelled' AND ${a}.result->>'code' IN ('connection_changed', 'stale_head') + AND (${a}.result->>'code' <> 'stale_head' OR NOT EXISTS ( + SELECT 1 FROM chat_github_reviews review JOIN chat_deliveries delivery + ON delivery.id::text = review.delivery_id AND delivery.endpoint_id = review.endpoint_id + WHERE review.company_id = ${a}.company_id AND review.endpoint_id = ${a}.endpoint_id + AND review.state IN ('queued', 'running') AND review.assessment IS NULL + AND delivery.normalized_event->'githubAutomatic'->'context'->>'deliveryId' = ${a}.payload->'event'->>'deliveryId'))))) + OR (${a}.kind = 'github_review_publication' AND ( + (${a}.status = 'processed' AND ( + (${a}.payload->>'operation' IN ('comment', 'update_comment', 'formal_review') + AND length(${a}.result->>'id') > 0 AND length(${a}.result->>'url') > 0) + OR (${a}.payload->>'operation' = 'assessment' AND EXISTS (SELECT 1 FROM chat_github_reviews review + WHERE review.id::text = ${a}.payload->>'reviewId' AND review.id::text = ${a}.result->>'reviewId' + AND review.company_id = ${a}.company_id AND review.endpoint_id = ${a}.endpoint_id + AND review.assessment IS NOT NULL AND review.check_url = ${a}.result->>'checkUrl')))) + OR (${a}.status = 'cancelled' AND ${a}.result->>'retryable' = 'false' + AND ${a}.result->>'code' IN ('publication_binding_unavailable', 'stale_head', 'authorization_changed')))) + OR (${a}.kind = 'github_webhook_recovery' AND ( + (${a}.status = 'cancelled' AND ${a}.result->>'code' IN ('not_eligible_for_recovery', 'source_changed_or_unavailable')) + OR (${a}.status = 'processed' AND EXISTS (SELECT 1 FROM chat_actions ingress + WHERE ingress.kind = 'github_webhook_ingress' AND ingress.company_id = ${a}.company_id + AND ingress.endpoint_id = ${a}.endpoint_id AND ingress.payload->>'deliveryId' = ${a}.payload->'original'->>'guid' + AND (${completedIngress('ingress')})))))`; } diff --git a/server/src/services/chat-channels/idle-history-proof.ts b/server/src/services/chat-channels/idle-history-proof.ts index 434087d5e38..992c71d8645 100644 --- a/server/src/services/chat-channels/idle-history-proof.ts +++ b/server/src/services/chat-channels/idle-history-proof.ts @@ -1,6 +1,11 @@ import { finishedIdleRun, finishedIdleWake } from "../idle-work-proof.js"; import { completedGithubAction } from "./github/idle-history-proof.js"; import { completedSlackAction } from "./slack/idle-history-proof.js"; +import { completedExpiredChatControl, completedQuestionFormAction } from "../chat-question-form-idle-proof.js"; +import { completedPhotonAction } from "./imessage-photon/idle-history-proof.js"; +import { completedDiscordAction } from "./discord/idle-history-proof.js"; +import { completedVoiceAction } from "../voice/idle-history-proof.js"; +import { completedTeamsAction } from "./microsoft-teams/idle-history-proof.js"; import { completedTelegramAction } from "./telegram/idle-history-proof.js"; /** A processed inbound receipt can still own an unconsumed wake. */ @@ -8,12 +13,18 @@ function completedAction(a: string): string { const wake = `EXISTS (SELECT 1 FROM agent_wakeup_requests w WHERE w.company_id = ${a}.company_id AND w.id = ${a}.id AND w.agent_id::text = ${a}.payload->>'agentId' AND w.payload->>'issueId' = ${a}.payload->>'issueId' AND ${finishedIdleWake('w')})`; - return `jsonb_typeof(${a}.payload) = 'object' AND jsonb_typeof(${a}.result) = 'object' + return `jsonb_typeof(${a}.payload) = 'object' AND EXISTS (SELECT 1 FROM chat_endpoints e WHERE e.id = ${a}.endpoint_id AND e.company_id = ${a}.company_id) AND (${a}.delivery_id IS NULL OR EXISTS (SELECT 1 FROM chat_deliveries d WHERE d.id = ${a}.delivery_id AND d.company_id = ${a}.company_id AND d.endpoint_id = ${a}.endpoint_id)) AND CASE - WHEN ${a}.kind = 'inbound_wakeup' THEN ${a}.status IN ('processed', 'failed') AND ${wake} + WHEN ${a}.kind = 'inbound_wakeup' THEN (${a}.status IN ('processed', 'failed') AND ${wake}) + OR (${a}.status = 'failed' AND ${a}.result->>'code' = 'inbound_wakeup_delivery_rejected' + AND EXISTS (SELECT 1 FROM chat_deliveries rejected WHERE rejected.id = ${a}.delivery_id + AND rejected.company_id = ${a}.company_id AND rejected.endpoint_id = ${a}.endpoint_id + AND rejected.state IN ('filtered', 'failed') AND rejected.next_attempt_at IS NULL)) + WHEN ${a}.kind IN ('question_form_open', 'question_form_submit', 'question_form_open_attempt', 'question_answer') + THEN (${completedQuestionFormAction(a)}) WHEN ${a}.kind = 'interaction_wakeup' THEN ${a}.status = 'processed' AND (${a}.result->>'code' = 'interaction_wakeup_no_longer_applicable' OR EXISTS ( SELECT 1 FROM agent_wakeup_requests w WHERE w.company_id = ${a}.company_id @@ -28,40 +39,64 @@ function completedAction(a: string): string { WHEN ${a}.kind = 'receipt_reaction' THEN (${a}.status = 'processed' AND jsonb_typeof(${a}.result->'attempts') = 'number' AND (${a}.result->>'attempts')::numeric > 0) OR (${a}.status = 'cancelled' AND ${a}.result->>'code' IN ('receipt_reaction_superseded_by_terminal_publication', 'receipt_reaction_runtime_superseded')) - WHEN ${a}.kind = 'confirmation_response' THEN ${a}.status = 'processed' + WHEN ${a}.kind = 'confirmation_response' THEN (${completedExpiredChatControl(a)}) OR (${a}.status = 'processed' AND ${a}.result->>'code' IN ('confirmation_resolution_committed_by_provider', 'confirmation_resolution_reconciled') AND EXISTS (SELECT 1 FROM issue_thread_interactions interaction WHERE interaction.id::text = ${a}.result->>'interactionId' AND interaction.company_id = ${a}.company_id AND interaction.status = ${a}.result->>'interactionStatus' - AND interaction.resolved_at IS NOT NULL) + AND interaction.resolved_at IS NOT NULL)) + WHEN ${a}.kind = 'board_send_rejected' THEN ${a}.status = 'processed' + AND ${a}.result->>'code' = 'chat_board_send_attachments_already_bound' + AND ${a}.payload->>'schemaVersion' = '1' AND length(${a}.payload->>'requestedByUserId') > 0 + AND jsonb_typeof(${a}.payload->'attachmentIds') = 'array' AND jsonb_array_length(CASE WHEN jsonb_typeof(${a}.payload->'attachmentIds') = 'array' THEN ${a}.payload->'attachmentIds' ELSE '[]'::jsonb END) BETWEEN 1 AND 20 + AND NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(CASE WHEN jsonb_typeof(${a}.payload->'attachmentIds') = 'array' THEN ${a}.payload->'attachmentIds' ELSE '[]'::jsonb END) rejected_id(value) + WHERE rejected_id.value !~ '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$') + AND EXISTS (SELECT 1 FROM chat_conversations conversation WHERE conversation.id = ${a}.conversation_id + AND conversation.company_id = ${a}.company_id AND conversation.endpoint_id = ${a}.endpoint_id + AND conversation.issue_id::text = ${a}.payload->>'issueId' + AND ${a}.provider_action_id = 'board-send-rejected:' || conversation.id::text || ':' || (${a}.payload->>'idempotencyKey')) WHEN ${a}.kind = 'task_control_authorization' THEN ${a}.status IN ('processed', 'cancelled') AND EXISTS (SELECT 1 FROM chat_publications p WHERE p.company_id = ${a}.company_id AND p.endpoint_id = ${a}.endpoint_id AND p.conversation_id = ${a}.conversation_id AND ${a}.provider_action_id = 'task-control-authorization:' || p.id::text AND ((p.state = 'published' AND p.published_at IS NOT NULL) OR p.state = 'cancelled')) - ELSE (${[completedGithubAction, completedSlackAction, completedTelegramAction] + ELSE (${[completedGithubAction, completedSlackAction, completedTelegramAction, completedTeamsAction, completedPhotonAction, completedDiscordAction, completedVoiceAction] .map(proof => `(${proof(a)})`).join(' OR ')}) END`; } export const idleChatHistoryChecks = [ - { reason: "chat_deliveries", query: `WITH action_outcomes AS MATERIALIZED ( - SELECT a.delivery_id, a.kind, (${completedAction('a')}) IS TRUE AS completed - FROM chat_actions a WHERE a.delivery_id IS NOT NULL - ), action_receipts AS ( - SELECT delivery_id, bool_and(completed) AS completed, - bool_or(kind = 'inbound_wakeup' AND completed) AS consumed - FROM action_outcomes GROUP BY delivery_id - ) SELECT 1 FROM chat_deliveries d LEFT JOIN action_receipts receipt ON receipt.delivery_id = d.id WHERE ( + { reason: "chat_deliveries", query: `WITH candidate_deliveries AS MATERIALIZED ( + SELECT d.id, d.company_id, d.endpoint_id, d.state, d.event_kind FROM chat_deliveries d WHERE ( EXISTS (SELECT 1 FROM chat_endpoints e WHERE e.id = d.endpoint_id AND e.company_id = d.company_id) AND jsonb_typeof(d.normalized_event) = 'object' AND d.normalized_event->>'providerEventId' = d.provider_event_id AND d.normalized_event->>'kind' = d.event_kind AND d.event_kind IN ('mention', 'message', 'direct_message', 'message_updated', 'message_deleted', 'message_restored', 'reaction_added', 'reaction_removed', 'action', 'modal_submitted', 'modal_closed', 'slash_command', 'file_shared', 'installation', 'uninstallation', 'unknown') - AND (d.state <> 'processed' OR d.event_kind NOT IN ('mention', 'message', 'direct_message') - OR receipt.consumed IS TRUE) AND d.next_attempt_at IS NULL AND (d.state = 'failed' OR (d.state IN ('processed', 'filtered') AND d.processed_at IS NOT NULL)) AND (d.conversation_id IS NULL OR EXISTS (SELECT 1 FROM chat_conversations c WHERE c.id = d.conversation_id AND c.company_id = d.company_id AND c.endpoint_id = d.endpoint_id)) - AND COALESCE(receipt.completed, true)) IS NOT TRUE` }, +) IS TRUE + ), action_outcomes AS MATERIALIZED ( + SELECT a.delivery_id, a.kind, (${completedAction('a')}) IS TRUE AS completed + FROM chat_actions a JOIN candidate_deliveries candidate ON candidate.id = a.delivery_id + ), action_receipts AS MATERIALIZED ( + SELECT delivery_id, bool_and(completed) AS completed, + bool_or(kind = 'inbound_wakeup' AND completed) AS consumed + FROM action_outcomes GROUP BY delivery_id + ) SELECT 1 FROM chat_deliveries d WHERE ( + EXISTS (SELECT 1 FROM chat_endpoints e WHERE e.id = d.endpoint_id AND e.company_id = d.company_id) + AND jsonb_typeof(d.normalized_event) = 'object' + AND d.normalized_event->>'providerEventId' = d.provider_event_id + AND d.normalized_event->>'kind' = d.event_kind + AND d.event_kind IN ('mention', 'message', 'direct_message', 'message_updated', 'message_deleted', 'message_restored', + 'reaction_added', 'reaction_removed', 'action', 'modal_submitted', 'modal_closed', 'slash_command', 'file_shared', 'installation', 'uninstallation', 'unknown') + AND d.next_attempt_at IS NULL AND (d.state = 'failed' OR (d.state IN ('processed', 'filtered') AND d.processed_at IS NOT NULL)) + AND (d.conversation_id IS NULL OR EXISTS (SELECT 1 FROM chat_conversations c WHERE c.id = d.conversation_id + AND c.company_id = d.company_id AND c.endpoint_id = d.endpoint_id)) +) IS NOT TRUE + UNION ALL SELECT 1 FROM candidate_deliveries d + WHERE d.id IN (SELECT receipt.delivery_id FROM action_receipts receipt WHERE NOT receipt.completed) + OR (d.state = 'processed' AND d.event_kind IN ('mention', 'message', 'direct_message') + AND d.id NOT IN (SELECT receipt.delivery_id FROM action_receipts receipt WHERE receipt.consumed))` }, { reason: "chat_publications", query: `SELECT 1 FROM chat_publications p WHERE ( (p.state = 'published' AND p.published_at IS NOT NULL AND p.redacted_error IS NULL OR (p.state IN ('failed', 'cancelled') AND p.next_attempt_at IS NULL)) diff --git a/server/src/services/chat-channels/imessage-photon/events.ts b/server/src/services/chat-channels/imessage-photon/events.ts index 75f6d2cc92b..882a57e7a7a 100644 --- a/server/src/services/chat-channels/imessage-photon/events.ts +++ b/server/src/services/chat-channels/imessage-photon/events.ts @@ -1,11 +1,10 @@ +import { stagePhotonResponseNotice } from "./response-notices.js"; import { notifyChatReceiver } from "../runtime/receiver-work-notifications.js"; import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../../chat-channels/runtime-types.js"; -import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; import { nativeSha256 } from "../../native-runtime/canonical.js"; -import { projectSafeChatPublicationText } from "../../chat-publication-projection.js"; import { PhotonAnswerValidationError, nativePhotonInteraction, photonResponseCommand, parsePhotonQuestionAnswer, type PhotonPromptReceipt, type PhotonInteractionBinding, type PhotonDraft } from "../../photon/interactions.js"; import { validateNativeQuestionResponseInput } from "../../native-runtime/native-question-bridge.js"; import type { AskUserQuestionsAnswer, AskUserQuestionsInteraction, IssueThreadInteraction } from "@paperclipai/shared"; @@ -368,41 +367,9 @@ export function createPhotonEvents(db: Db, options: PhotonEventsOptions) { } const enqueueResponse = async (key: string, text: string) => { await assertCurrent(); - await db.transaction(async (tx) => { - await tx - .insert(chatActions) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - kind: "photon_response_notice", - providerActionId: `photon-notice:${key}`, - payload: { - reference, - questionIndex, - publicationId: binding.publicationId, - }, - status: "processed", - }) - .onConflictDoNothing(); - await notifyChatPublicationWork(tx); - await tx - .insert(chatPublications) - .values({ - companyId: endpoint.companyId, - endpointId: endpoint.id, - conversationId: conversation.id, - issueId: conversation.issueId, - idempotencyKey: key, - payload: { - ...publication.payload, - card: undefined, - text: projectSafeChatPublicationText(text), - }, - state: "pending", - }) - .onConflictDoNothing(); - }); + await db.transaction(tx => stagePhotonResponseNotice(tx, { + publication, reference, questionIndex, key, text, + })); scheduleMessageProcessing(() => processPendingPublications().then(() => undefined), ); diff --git a/server/src/services/chat-channels/imessage-photon/idle-history-proof.ts b/server/src/services/chat-channels/imessage-photon/idle-history-proof.ts new file mode 100644 index 00000000000..2e10387d722 --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/idle-history-proof.ts @@ -0,0 +1,58 @@ +/** Photon tokens and immutable provenance receipts do not own a turn once their + * exact canonical owner has settled. Receiver ownership remains a separate gate. */ +export function completedPhotonAction(a: string): string { + const publication = (id: string) => `EXISTS (SELECT 1 FROM chat_publications publication + WHERE publication.id::text = ${id} AND publication.company_id = ${a}.company_id + AND publication.endpoint_id = ${a}.endpoint_id AND publication.conversation_id = ${a}.conversation_id + AND ((publication.state = 'published' AND publication.published_at IS NOT NULL) + OR (publication.state IN ('failed', 'cancelled') AND publication.next_attempt_at IS NULL)))`; + return `(${a}.kind = 'photon_interaction' AND (${a}.status IN ('issued', 'processed') OR (${a}.status = 'expired' AND ${a}.result->>'code' = 'interaction_resolved_elsewhere')) + AND ${a}.payload->>'version' = '1' AND length(${a}.payload->>'reference') > 0 + AND ${a}.provider_action_id = 'photon:' || (${a}.payload->>'reference') + AND EXISTS (SELECT 1 FROM issue_thread_interactions interaction JOIN chat_publications publication + ON publication.id::text = ${a}.payload->>'publicationId' AND publication.company_id = interaction.company_id + AND publication.issue_id = interaction.issue_id AND publication.payload->>'interactionId' = interaction.id::text + JOIN chat_conversations conversation ON conversation.id = publication.conversation_id + AND conversation.company_id = interaction.company_id AND conversation.issue_id = interaction.issue_id + WHERE interaction.id::text = ${a}.payload->>'interactionId' AND interaction.company_id = ${a}.company_id + AND interaction.kind IN ('ask_user_questions', 'request_confirmation') + AND interaction.status IN ('answered', 'accepted', 'rejected', 'cancelled', 'expired') AND interaction.resolved_at IS NOT NULL + AND publication.endpoint_id = ${a}.endpoint_id AND conversation.id = ${a}.conversation_id + AND conversation.session_generation::text = ${a}.payload->>'sessionGeneration')) + OR (${a}.kind = 'photon_publication_retry' AND ${a}.status = 'processed' + AND length(${a}.result->>'authorizedByUserId') > 0 AND ${a}.payload->>'attempt' ~ '^[1-9][0-9]*$' + AND ${a}.provider_action_id = 'photon-retry:' || (${a}.payload->>'publicationId') || ':' || (${a}.payload->>'attempt') + AND ${publication(`${a}.payload->>'publicationId'`)}) + OR (${a}.kind = 'photon_response_notice' AND ${a}.status = 'processed' AND ${a}.result IS NULL + AND length(${a}.payload->>'reference') > 0 AND ${a}.payload->>'questionIndex' ~ '^[0-9]+$' + AND EXISTS (SELECT 1 FROM chat_publications notice WHERE notice.company_id = ${a}.company_id + AND notice.endpoint_id = ${a}.endpoint_id AND notice.conversation_id = ${a}.conversation_id + AND ${a}.provider_action_id = 'photon-notice:' || notice.idempotency_key + AND ((notice.state = 'published' AND notice.published_at IS NOT NULL) + OR (notice.state IN ('failed', 'cancelled') AND notice.next_attempt_at IS NULL))) + AND EXISTS (SELECT 1 FROM chat_publications source WHERE source.id::text = ${a}.payload->>'publicationId' + AND source.company_id = ${a}.company_id AND source.endpoint_id = ${a}.endpoint_id + AND source.conversation_id = ${a}.conversation_id)) + OR (${a}.kind IN ('attachment_derivative', 'attachment_companion') AND ${a}.status = 'processed' AND ${a}.result IS NULL + AND EXISTS (SELECT 1 FROM chat_endpoints endpoint WHERE endpoint.id = ${a}.endpoint_id + AND endpoint.company_id = ${a}.company_id AND endpoint.provider = 'imessage-photon') + AND ${a}.delivery_id IS NOT NULL + AND ((${a}.kind = 'attachment_derivative' AND ${a}.payload->>'kind' = 'heif_jpeg_preview') + OR (${a}.kind = 'attachment_companion' AND ${a}.payload->>'kind' = 'live_photo_video')) + AND ${a}.payload->>'originalAttachmentId' ~ '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$' + AND ${a}.payload->>'derivativeAttachmentId' ~ '^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$' + AND ${a}.payload->>'originalAttachmentId' <> ${a}.payload->>'derivativeAttachmentId' + AND ${a}.payload->>'originalSha256' ~ '^[a-f0-9]{64}$' AND ${a}.payload->>'derivativeSha256' ~ '^[a-f0-9]{64}$' + AND ${a}.provider_action_id = 'photon-' || (${a}.payload->>'kind') || ':' || ${a}.delivery_id::text || ':' || (${a}.payload->>'originalAttachmentId') + AND NOT EXISTS (SELECT 1 FROM issue_attachments attachment WHERE attachment.id::text IN + (${a}.payload->>'originalAttachmentId', ${a}.payload->>'derivativeAttachmentId') + AND (attachment.company_id <> ${a}.company_id OR NOT EXISTS (SELECT 1 FROM assets asset + WHERE asset.id = attachment.asset_id AND asset.company_id = ${a}.company_id AND asset.sha256 = CASE + WHEN attachment.id::text = ${a}.payload->>'originalAttachmentId' THEN ${a}.payload->>'originalSha256' + ELSE ${a}.payload->>'derivativeSha256' END))) + AND NOT EXISTS (SELECT 1 FROM issue_attachments original JOIN issue_attachments derivative + ON derivative.id::text = ${a}.payload->>'derivativeAttachmentId' + WHERE original.id::text = ${a}.payload->>'originalAttachmentId' + AND (original.company_id <> derivative.company_id OR original.issue_id <> derivative.issue_id + OR original.issue_comment_id IS DISTINCT FROM derivative.issue_comment_id)))`; +} diff --git a/server/src/services/chat-channels/imessage-photon/response-notices.ts b/server/src/services/chat-channels/imessage-photon/response-notices.ts new file mode 100644 index 00000000000..18a7c65b3aa --- /dev/null +++ b/server/src/services/chat-channels/imessage-photon/response-notices.ts @@ -0,0 +1,27 @@ +import { chatActions, chatPublications, type Db } from "@paperclipai/db"; +import { notifyChatPublicationWork } from "../../chat-work-notifications.js"; +import { projectSafeChatPublicationText } from "../../chat-publication-projection.js"; + +type Tx = Parameters[0]>[0]; + +/** Keep the notice marker and its outbox row in the same owner transaction. */ +export async function stagePhotonResponseNotice(tx: Tx, input: { + publication: typeof chatPublications.$inferSelect; + reference: string; + questionIndex: number; + key: string; + text: string; +}) { + const { publication, reference, questionIndex, key, text } = input; + await tx.insert(chatActions).values({ + companyId: publication.companyId, endpointId: publication.endpointId, conversationId: publication.conversationId, + kind: "photon_response_notice", providerActionId: `photon-notice:${key}`, + payload: { reference, questionIndex, publicationId: publication.id }, status: "processed", + }).onConflictDoNothing(); + await notifyChatPublicationWork(tx); + await tx.insert(chatPublications).values({ + companyId: publication.companyId, endpointId: publication.endpointId, conversationId: publication.conversationId, + issueId: publication.issueId, idempotencyKey: key, + payload: { ...publication.payload, card: undefined, text: projectSafeChatPublicationText(text) }, state: "pending", + }).onConflictDoNothing(); +} diff --git a/server/src/services/chat-channels/microsoft-teams/idle-history-proof.ts b/server/src/services/chat-channels/microsoft-teams/idle-history-proof.ts new file mode 100644 index 00000000000..9d90d550623 --- /dev/null +++ b/server/src/services/chat-channels/microsoft-teams/idle-history-proof.ts @@ -0,0 +1,35 @@ +/** Teams keeps immutable provider-effect and recipient-intent receipts after + * delivery. Only the exact closed transfer/publication retires that authority. */ +export function completedTeamsAction(a: string): string { + return `${a}.status = 'processed' AND ${a}.kind IN ('teams_file_effect_intent', 'teams_file_consent', 'teams_board_file_intent') + AND EXISTS (SELECT 1 FROM chat_teams_file_transfers transfer JOIN chat_publications publication + ON publication.id = transfer.publication_id AND publication.company_id = transfer.company_id + WHERE transfer.company_id = ${a}.company_id AND transfer.endpoint_id = ${a}.endpoint_id + AND transfer.conversation_id = ${a}.conversation_id + AND publication.id::text = ${a}.payload->>'publicationId' + AND publication.endpoint_id = ${a}.endpoint_id AND publication.conversation_id = ${a}.conversation_id + AND transfer.phase IN ('delivered', 'declined', 'expired', 'cancelled') + AND transfer.attempt_id IS NULL AND transfer.attempt_expires_at IS NULL + AND ((publication.state = 'published' AND publication.published_at IS NOT NULL) + OR (publication.state IN ('failed', 'cancelled') AND publication.next_attempt_at IS NULL)) + AND CASE WHEN ${a}.kind = 'teams_file_effect_intent' THEN + ${a}.payload->>'schema' = 'paperclip.teams.file-effect-intent.v1' + AND ${a}.payload->>'transferId' = transfer.id::text + AND ${a}.provider_action_id = 'teams-file-effect:' || (${a}.payload->>'attemptId') + AND ${a}.payload->>'stage' IN ('consent', 'upload', 'file_info') + WHEN ${a}.kind = 'teams_file_consent' THEN + ${a}.payload->>'schema' = 'paperclip.teams.consent-receipt.v1' + AND ${a}.payload->>'transferId' = transfer.id::text + AND ${a}.payload->>'receiptDigest' = transfer.response_digest + AND ${a}.payload->>'action' IN ('accept', 'decline') + AND ${a}.principal_id IS NOT DISTINCT FROM transfer.principal_id + ELSE ${a}.payload->>'schema' = 'paperclip.teams.board-file-intent.v1' + AND ${a}.provider_action_id = 'teams_board_file_intent:' || publication.id::text + AND ${a}.payload->>'issueId' = transfer.issue_id::text + AND ${a}.payload->>'attachmentId' = transfer.attachment_id::text + AND ${a}.payload->>'commentId' = publication.comment_id::text + AND ${a}.payload->>'sourceScopeSha256' ~ '^[a-f0-9]{64}$' + AND ${a}.payload->>'recipientBindingSha256' ~ '^[a-f0-9]{64}$' + AND ${a}.payload->>'byteIdentitySha256' ~ '^[a-f0-9]{64}$' + AND ${a}.payload->>'commentBodySha256' ~ '^[a-f0-9]{64}$' END)`; +} diff --git a/server/src/services/chat-channels/slack/idle-history-proof.ts b/server/src/services/chat-channels/slack/idle-history-proof.ts index f57e9950f12..1db0169861a 100644 --- a/server/src/services/chat-channels/slack/idle-history-proof.ts +++ b/server/src/services/chat-channels/slack/idle-history-proof.ts @@ -1,3 +1,63 @@ +/** Connector rows below are immutable authority/confirmed-call receipts. Their + * own downstream publication, interaction and run rows keep independent gates. */ +function completedSlackConnectorAction(a: string): string { + const principal = `EXISTS (SELECT 1 FROM chat_external_principals principal WHERE principal.id = ${a}.principal_id + AND principal.company_id = ${a}.company_id AND principal.provider = 'slack')`; + const issue = `EXISTS (SELECT 1 FROM issues issue WHERE issue.id::text = ${a}.payload->>'issueId' + AND issue.company_id = ${a}.company_id AND (${a}.conversation_id IS NULL OR EXISTS ( + SELECT 1 FROM chat_conversations conversation WHERE conversation.id = ${a}.conversation_id + AND conversation.company_id = issue.company_id AND conversation.issue_id = issue.id + AND conversation.endpoint_id = ${a}.endpoint_id)))`; + return `(${a}.kind = 'slack_private_source' AND ${a}.status = 'processed' AND ${a}.result IS NULL + AND ${issue} AND ${principal} AND length(${a}.payload->>'channelId') > 0 + AND length(${a}.payload->>'requesterId') > 0 + AND ${a}.provider_action_id = 'slack-private:' || (${a}.payload->>'issueId') || ':' + || (${a}.payload->>'channelId') || ':' || (${a}.payload->>'requesterId')) + OR (${a}.kind = 'slack_connect' AND ${a}.status = 'processed' + AND ${a}.payload->>'version' = '1' AND ${a}.result->>'code' = 'slack_identity_discovered' + AND length(${a}.payload->>'channelId') > 0 AND ${principal} + AND EXISTS (SELECT 1 FROM chat_external_principals principal WHERE principal.id = ${a}.principal_id + AND principal.external_id = ${a}.payload->>'userId')) + OR (${a}.kind IN ('slack_test_message', 'slack_test_message_invitation') AND ${a}.status = 'processed' + AND length(${a}.payload->>'userId') > 0 AND ${a}.payload->>'slackUserId' ~ '^[UW][A-Z0-9]+$' + AND CASE WHEN ${a}.kind = 'slack_test_message' THEN ${a}.result->>'slackUrl' ~ '^https://app[.]slack[.]com/client/[^/]+/D[A-Z0-9]+$' + ELSE ${principal} AND ${a}.result->>'expiresAt' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T' END) + OR (${a}.kind = 'slack_managed_reconnect' AND ${a}.status = 'processed' AND ${a}.result IS NULL + AND ${a}.provider_action_id LIKE 'slack_managed_reconnect:%' + AND length(${a}.payload->>'registrationRequestId') > 0 + AND EXISTS (SELECT 1 FROM chat_slack_manager_grants grant_record WHERE grant_record.id::text = ${a}.payload->>'grantId' + AND grant_record.company_id = ${a}.company_id)) + OR (${a}.kind = 'slack_tool_write' AND ${a}.status = 'processed' AND jsonb_typeof(${a}.result) = 'object' + AND ${a}.payload->>'name' IN ('slack_open_dm', 'slack_post_message', 'slack_update_message', 'slack_delete_message', + 'slack_add_reaction', 'slack_remove_reaction', 'slack_add_pin', 'slack_remove_pin', 'slack_add_bookmark', + 'slack_remove_bookmark', 'slack_create_canvas', 'slack_edit_canvas', 'slack_replace_canvas_section', + 'slack_share_list', 'slack_edit_list', 'slack_create_list', 'slack_create_list_item', 'slack_update_list_item', + 'slack_invite', 'slack_create_channel', 'slack_upload_file') + AND ${a}.payload->>'hash' ~ '^[a-f0-9]{64}$' AND jsonb_typeof(${a}.payload->'args') = 'object' + AND length(${a}.payload->>'userId') > 0 AND length(${a}.payload->>'revision') > 0 + AND ${a}.provider_action_id = 'slack-tool:' || (${a}.payload->'binding'->>'issueId') || ':' || (${a}.payload->'args'->>'idempotencyKey') + AND EXISTS (SELECT 1 FROM tool_invocations invocation JOIN heartbeat_runs run ON run.id = invocation.run_id + AND run.company_id = invocation.company_id AND run.agent_id = invocation.agent_id + WHERE invocation.id::text = ${a}.payload->>'invocationId' AND invocation.company_id = ${a}.company_id + AND invocation.run_id::text = ${a}.payload->'binding'->>'runId' + AND invocation.agent_id::text = ${a}.payload->'binding'->>'agentId' + AND invocation.issue_id::text = ${a}.payload->'binding'->>'issueId' + AND ${a}.payload->'binding'->>'companyId' = ${a}.company_id::text)) + OR (${a}.kind = 'slack_board_message' AND ${issue} AND EXISTS ( + SELECT 1 FROM issue_comments comment JOIN chat_publications publication + ON publication.comment_id = comment.id AND publication.company_id = comment.company_id + WHERE comment.id::text = ${a}.payload->>'commentId' AND comment.company_id = ${a}.company_id + AND comment.issue_id::text = ${a}.payload->>'issueId' AND comment.author_user_id = ${a}.payload->>'userId' + AND publication.endpoint_id = ${a}.endpoint_id AND publication.conversation_id = ${a}.conversation_id + AND ((publication.state = 'published' AND publication.published_at IS NOT NULL) + OR (publication.state IN ('failed', 'cancelled') AND publication.next_attempt_at IS NULL))) + AND ((${a}.status = 'cancelled' AND ${a}.result->>'code' = 'slack_board_work_not_authorized') + OR (${a}.status = 'processed' AND (${a}.result IS NULL OR (${a}.result->>'resumeApplied' = 'true' + AND EXISTS (SELECT 1 FROM agent_wakeup_requests wake WHERE wake.company_id = ${a}.company_id + AND wake.idempotency_key = 'slack-board-comment:' || ${a}.id::text AND wake.finished_at IS NOT NULL + AND wake.status IN ('completed', 'failed', 'cancelled', 'skipped', 'timed_out')))))))`; +} + export function completedSlackAction(a: string): string { return `(${a}.kind = 'slack_session_sync' AND ( (${a}.status = 'processed' AND ${a}.result->>'outcome' IN ('updated', 'unsupported') @@ -12,5 +72,6 @@ export function completedSlackAction(a: string): string { AND ${a}.result->>'code' IN ('slack_session_stop_cancelled', 'slack_session_stop_target_superseded', 'slack_session_stop_no_current_work') AND NOT EXISTS (SELECT 1 FROM heartbeat_runs r WHERE r.id::text = ${a}.result->>'runId' AND (r.company_id <> ${a}.company_id OR r.status NOT IN ('succeeded', 'failed', 'cancelled', 'timed_out', 'interrupted') - OR r.finished_at IS NULL OR r.cost_accounting_pending)))`; + OR r.finished_at IS NULL OR r.cost_accounting_pending))) + OR (${completedSlackConnectorAction(a)})`; } diff --git a/server/src/services/chat-channels/telegram/idle-history-proof.ts b/server/src/services/chat-channels/telegram/idle-history-proof.ts index bd37617d627..87dea9045e1 100644 --- a/server/src/services/chat-channels/telegram/idle-history-proof.ts +++ b/server/src/services/chat-channels/telegram/idle-history-proof.ts @@ -1,5 +1,13 @@ export function completedTelegramAction(a: string): string { - return `(${a}.kind = 'telegram_maintenance' AND ( + return `(${a}.kind = 'telegram_stop_subscription' AND ${a}.status = 'processed' + AND ${a}.result->>'code' = 'telegram_stop_subscription_confirmed' AND ${a}.payload->>'version' = '1' + AND ${a}.payload->>'botUserId' ~ '^[1-9][0-9]{0,15}$' + AND ${a}.payload->>'credentialFingerprint' ~ '^[a-f0-9]{64}$' + AND ${a}.payload->>'webhookUrlSha256' ~ '^[a-f0-9]{64}$' + AND ${a}.payload->>'runtimeGeneration' ~ '^[0-9]+$' + AND ${a}.provider_action_id = 'telegram-stop-subscription:' || (${a}.payload->>'runtimeGeneration') || ':' + || (${a}.payload->>'credentialFingerprint') || ':' || (${a}.payload->>'webhookUrlSha256')) + OR (${a}.kind = 'telegram_maintenance' AND ( (${a}.status = 'processed' AND (${a}.result->>'providerConfirmed' = 'true' OR ${a}.result->>'code' = 'telegram_stop_subscription_confirmed')) OR (${a}.status = 'cancelled' AND ${a}.result->>'code' = 'telegram_maintenance_credentials_superseded') diff --git a/server/src/services/chat-question-form-idle-proof.ts b/server/src/services/chat-question-form-idle-proof.ts new file mode 100644 index 00000000000..b82cbb36c82 --- /dev/null +++ b/server/src/services/chat-question-form-idle-proof.ts @@ -0,0 +1,54 @@ +/** Form-open tokens stay issued: resolving the canonical question revokes their + * authority. A processed submission additionally needs its exact answer receipt. */ +export function completedQuestionFormAction(a: string): string { + const resolved = `EXISTS (SELECT 1 FROM issue_thread_interactions interaction + JOIN chat_conversations conversation ON conversation.issue_id = interaction.issue_id AND conversation.company_id = interaction.company_id + JOIN chat_publications publication ON publication.id::text = ${a}.payload->>'publicationId' + AND publication.company_id = interaction.company_id AND publication.issue_id = interaction.issue_id + AND publication.conversation_id = conversation.id AND publication.endpoint_id = conversation.endpoint_id + WHERE interaction.id::text = ${a}.payload->>'interactionId' AND interaction.company_id = ${a}.company_id + AND (interaction.kind = 'ask_user_questions' OR (interaction.kind = 'request_item_verdicts' AND EXISTS ( + SELECT 1 FROM chat_endpoints endpoint WHERE endpoint.id = ${a}.endpoint_id AND endpoint.company_id = ${a}.company_id AND endpoint.provider = 'slack'))) AND interaction.status IN ('answered', 'cancelled', 'expired') + AND interaction.resolved_at IS NOT NULL AND conversation.id = ${a}.conversation_id + AND conversation.endpoint_id = ${a}.endpoint_id AND publication.payload->>'interactionId' = interaction.id::text)`; + return `(${a}.kind IN ('question_form_open', 'question_form_submit') AND ${a}.payload->>'version' = '1' + AND ${resolved} AND ( + (${a}.kind = 'question_form_open' AND (${a}.status = 'issued' OR (${a}.status = 'expired' + AND ${a}.result->>'code' IN ('question_form_submitted', 'interaction_resolved_elsewhere'))) AND EXISTS ( + SELECT 1 FROM chat_actions submit WHERE submit.company_id = ${a}.company_id AND submit.endpoint_id = ${a}.endpoint_id + AND submit.conversation_id = ${a}.conversation_id AND submit.kind = 'question_form_submit' + AND submit.provider_action_id = ${a}.payload->>'formActionId' + AND submit.payload->>'formActionId' = submit.provider_action_id + AND submit.payload->>'interactionId' = ${a}.payload->>'interactionId' + AND submit.payload->>'publicationId' = ${a}.payload->>'publicationId' + AND submit.payload->>'expiresAt' = ${a}.payload->>'expiresAt' + AND (submit.status = 'issued' OR (submit.status = 'expired' AND submit.result->>'code' = 'interaction_resolved_elsewhere') OR (submit.status = 'processed' + AND submit.result->>'code' = 'question_form_answered' + AND submit.result->>'interactionId' = submit.payload->>'interactionId')))) + OR (${a}.kind = 'question_form_submit' AND ${a}.payload->>'formActionId' = ${a}.provider_action_id + AND (${a}.status = 'issued' OR (${a}.status = 'expired' AND ${a}.result->>'code' = 'interaction_resolved_elsewhere') OR (${a}.status = 'processed' AND ${a}.result->>'code' = 'question_form_answered' + AND ${a}.result->>'interactionId' = ${a}.payload->>'interactionId'))))) + OR (${a}.kind = 'question_answer' AND ${resolved} + AND (${a}.status = 'issued' OR (${a}.status = 'processed' + AND ${a}.result->>'interactionId' = ${a}.payload->>'interactionId') + OR (${a}.status = 'expired' AND ${a}.result->>'code' IN ('interaction_resolved_by_sibling_action', 'interaction_resolved_elsewhere')))) + OR (${a}.kind = 'question_form_open_attempt' AND ${a}.status = 'processed' + AND ${a}.result->>'code' = 'question_form_opened')`; +} + +/** The terminal interaction writer expires its unused callback tokens after a + * board/sibling resolution. Expiry alone never proves canonical completion. */ +export function completedExpiredChatControl(a: string): string { + return `${a}.kind = 'confirmation_response' AND ${a}.status = 'expired' + AND ${a}.result->>'code' = 'interaction_resolved_elsewhere' AND ${a}.payload->>'version' = '1' + AND ${a}.payload->>'decision' IN ('accept', 'reject') AND EXISTS ( + SELECT 1 FROM issue_thread_interactions interaction JOIN chat_publications publication + ON publication.id::text = ${a}.payload->>'publicationId' AND publication.company_id = interaction.company_id + AND publication.issue_id = interaction.issue_id AND publication.payload->>'interactionId' = interaction.id::text + JOIN chat_conversations conversation ON conversation.id = publication.conversation_id + AND conversation.company_id = interaction.company_id AND conversation.issue_id = interaction.issue_id + WHERE interaction.id::text = ${a}.payload->>'interactionId' AND interaction.company_id = ${a}.company_id + AND interaction.kind IN ('request_confirmation', 'request_checkbox_confirmation', 'suggest_tasks', 'connection_intent') + AND interaction.status IN ('accepted', 'rejected', 'cancelled', 'expired') AND interaction.resolved_at IS NOT NULL + AND publication.endpoint_id = ${a}.endpoint_id AND conversation.id = ${a}.conversation_id)`; +} diff --git a/server/src/services/idle-sleep-safety.ts b/server/src/services/idle-sleep-safety.ts index 73b360dad20..88f193cae55 100644 --- a/server/src/services/idle-sleep-safety.ts +++ b/server/src/services/idle-sleep-safety.ts @@ -17,7 +17,7 @@ import { idleBrowserHistoryChecks } from "./browser-use-idle-proof.js"; import { idleDecisionHistoryChecks } from "./decision-idle-proof.js"; import { idleWorkspaceHistoryChecks } from "./workspace-history-idle-proof.js"; import { idleMcpHistoryChecks } from "./public-mcp/idle-history-proof.js"; -import { idleExternalAgentHistoryChecks } from "../modules/external-agents/idle-history-proof.js"; +import { idleExternalAgentHistoryChecks } from "../modules/external-agents/index.js"; export const completedHistoryChecks = [ ...idleChatHistoryChecks, ...idleToolActionHistoryChecks, ...idleBrowserHistoryChecks, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index ecdb45c43a4..e3f66a720b5 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -1,3 +1,4 @@ +import { recordMcpAttachmentCleanup, type McpAttachmentCleanup } from "./public-mcp/upload-cleanup.js"; import { notifyStrandedAgentStatus, notifyActiveRunSilence } from "./live-run-recovery-notifications.js"; import { finishedIdleWake } from "./idle-work-proof.js"; import { notifyDeferredExecutionEligibility } from "./deferred-execution-notifications.js"; @@ -13907,7 +13908,7 @@ export function issueService(db: Db) { .where(eq(issueAttachments.id, id)) .then((rows) => rows[0] ?? null), - removeAttachment: async (id: string) => + removeAttachment: async (id: string, options?: { mcpCleanup?: McpAttachmentCleanup | null }) => db.transaction(async (tx) => { const existing = await tx .select({ @@ -13937,6 +13938,11 @@ export function issueService(db: Db) { await notifyIssueRecovery(tx, { companyId: existing.companyId }); await tx.delete(issueAttachments).where(eq(issueAttachments.id, id)); await tx.delete(assets).where(eq(assets.id, existing.assetId)); + if (options?.mcpCleanup?.id === existing.id && options.mcpCleanup.companyId === existing.companyId + && options.mcpCleanup.issueId === existing.issueId && options.mcpCleanup.provider === existing.provider + && options.mcpCleanup.objectKey === existing.objectKey) { + await recordMcpAttachmentCleanup(tx, options.mcpCleanup); + } return existing; }), diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index 67194b6a9f3..366242b6883 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -9897,6 +9897,11 @@ describe("native warm session supervision", () => { it("blocks idle sleep for a released local warm session until its close checkpoint finishes", async () => { await closeIdleWarmNativeSessionsForRestart(); + const { reconcilePersistedRuntimeServicesOnStartup, readWorkspaceRuntimeIdleWork } = await import("../workspace-runtime.js"); + // The real server reconstructs the workspace owner before startup can + // become quiet. This fixture has no runtime rows or desired services. + await reconcilePersistedRuntimeServicesOnStartup(leaseDb()); + expect(readWorkspaceRuntimeIdleWork()).toBe("none"); markIdleIngressTracked(); markIdleStartupComplete({ scheduledBackups: false }); let finishClose!: () => void; const checkpoint = new Promise(resolve => { finishClose = resolve; }); diff --git a/server/src/services/public-mcp/file-transfers.ts b/server/src/services/public-mcp/file-transfers.ts index eb268e76bc8..83837600769 100644 --- a/server/src/services/public-mcp/file-transfers.ts +++ b/server/src/services/public-mcp/file-transfers.ts @@ -80,13 +80,22 @@ export function createPublicMcpTransfers(db: Db, oauth: PublicMcpOAuth, api: Api } async function sweep() { await db.delete(mcpFileTickets).where(lt(mcpFileTickets.expiresAt, new Date())); - // Serialize cleanup against completion/reissue. Never remove a committed file. + // Serialize cleanup against completion/reissue. Requalify old optimistic + // cleanup and orphaned removed attachments, never a still-linked file. await db.transaction(async tx => { - const abandoned = await tx.select().from(mcpAttachmentUploads).where(and(isNull(mcpAttachmentUploads.attachmentId), isNull(mcpAttachmentUploads.cleanedAt), lt(mcpAttachmentUploads.expiresAt, new Date()))).limit(20).for("update", { skipLocked: true }); + const abandoned = await tx.select().from(mcpAttachmentUploads).where(and( + isNull(mcpAttachmentUploads.cleanupVerifiedAt), lt(mcpAttachmentUploads.expiresAt, new Date()), + sql`(${mcpAttachmentUploads.attachmentId} IS NULL OR NOT EXISTS ( + SELECT 1 FROM issue_attachments attachment WHERE attachment.id = ${mcpAttachmentUploads.attachmentId}))`, + )).limit(20).for("update", { skipLocked: true }); for (const upload of abandoned) { if (upload.storageProvider !== storage.provider) continue; await storage.deleteObject(upload.companyId, upload.objectKey); - await tx.update(mcpAttachmentUploads).set({ cleanedAt: new Date() }).where(eq(mcpAttachmentUploads.id, upload.id)); + if ((await storage.headObject(upload.companyId, upload.objectKey)).exists !== false) { + throw new Error("MCP upload storage cleanup could not be confirmed"); + } + const verifiedAt = new Date(); + await tx.update(mcpAttachmentUploads).set({ cleanedAt: verifiedAt, cleanupVerifiedAt: verifiedAt }).where(eq(mcpAttachmentUploads.id, upload.id)); } }); } @@ -133,6 +142,7 @@ export function createPublicMcpTransfers(db: Db, oauth: PublicMcpOAuth, api: Api const published = await db.transaction(async tx => { const [upload] = await tx.select().from(mcpAttachmentUploads).where(eq(mcpAttachmentUploads.id, row.uploadId!)).for("update"); if (!upload || upload.companyId !== principal.grant.companyId || upload.userId !== principal.grant.userId || upload.workSessionId !== row.workSessionId || upload.grantId && upload.grantId !== row.grantId || upload.storageProvider !== storage.provider) throw unavailable(); + if (upload.expiresAt <= new Date() || upload.cleanedAt || upload.cleanupVerifiedAt) throw unavailable(); if (upload.sha256 !== digest || upload.byteSize !== bytes.length || req.get("Content-Type")?.split(";")[0]?.trim().toLowerCase() !== upload.contentType) throw new McpCapabilityError("Upload bytes, size or Content-Type do not match the requested file."); await taskAccess(principal, upload.taskId, principal.grant.purpose !== "agent", tx as unknown as Db, row.workSessionId ?? undefined); const stored = upload.attachmentId ? null : await storage.putFile({ companyId: upload.companyId, namespace: "mcp-transfers", objectKey: upload.objectKey, originalFilename: upload.originalFilename, contentType: upload.contentType, body: bytes }); @@ -200,7 +210,11 @@ export function createPublicMcpTransfers(db: Db, oauth: PublicMcpOAuth, api: Api objectKey: `${p.grant.companyId}/mcp-transfers/${id}`, storageProvider: storage.provider, expiresAt: new Date(Date.now() + lifetime) }).returning(); } if (!found || found.argumentsHash !== hash) throw new McpCapabilityError("requestId was already used for a different upload."); - await tx.update(mcpAttachmentUploads).set({ expiresAt: new Date(Date.now() + lifetime), cleanedAt: null }).where(eq(mcpAttachmentUploads.id, found.id)); + // A committed identity is immutable, including after user deletion. + // A rejected replay must not erase verified cleanup or permit new bytes. + if (!found.attachmentId) await tx.update(mcpAttachmentUploads) + .set({ expiresAt: new Date(Date.now() + lifetime), cleanedAt: null, cleanupVerifiedAt: null }) + .where(eq(mcpAttachmentUploads.id, found.id)); return found; }); if (upload.attachmentId) { diff --git a/server/src/services/public-mcp/idle-history-proof.ts b/server/src/services/public-mcp/idle-history-proof.ts index b3c7f78abd1..b861eee3775 100644 --- a/server/src/services/public-mcp/idle-history-proof.ts +++ b/server/src/services/public-mcp/idle-history-proof.ts @@ -22,14 +22,17 @@ export const idleMcpHistoryChecks = [ OR (d.mailbox_item_id IS NOT NULL AND d.activity_id IS NULL AND EXISTS ( SELECT 1 FROM dot_mailbox_items m WHERE m.id = d.mailbox_item_id AND m.company_id = s.company_id AND m.binding_id = s.binding_id))))) IS NOT TRUE` }, - // The upload owner allocates object identity before storage writes and only - // stamps cleaned_at after deletion. A committed attachment is never deleted. + // Upload identity survives user deletion. Only the new owner-verified + // absence stamp can retire cleanup; legacy cleaned_at alone was optimistic. { reason: "mcp_attachment_uploads", query: `SELECT 1 FROM mcp_attachment_uploads u WHERE ( - EXISTS (SELECT 1 FROM issues i WHERE i.id = u.task_id AND i.company_id = u.company_id) + EXISTS (SELECT 1 FROM companies c WHERE c.id = u.company_id) + AND NOT EXISTS (SELECT 1 FROM issues contradiction WHERE contradiction.id = u.task_id AND contradiction.company_id <> u.company_id) AND (u.grant_id IS NULL OR EXISTS (SELECT 1 FROM mcp_oauth_grants g WHERE g.id = u.grant_id AND g.company_id = u.company_id AND g.user_id = u.user_id)) - AND ((u.attachment_id IS NULL AND u.cleaned_at IS NOT NULL) - OR (u.cleaned_at IS NULL AND u.attachment_id IS NOT NULL AND EXISTS ( + AND ((u.cleaned_at IS NOT NULL AND u.cleanup_verified_at IS NOT NULL AND u.cleanup_verified_at >= u.cleaned_at + AND (u.attachment_id IS NULL OR NOT EXISTS (SELECT 1 FROM issue_attachments live WHERE live.id = u.attachment_id))) + OR (u.cleaned_at IS NULL AND u.cleanup_verified_at IS NULL AND u.attachment_id IS NOT NULL + AND EXISTS (SELECT 1 FROM issues i WHERE i.id = u.task_id AND i.company_id = u.company_id) AND EXISTS ( SELECT 1 FROM issue_attachments a JOIN assets stored ON stored.id = a.asset_id WHERE a.id = u.attachment_id AND a.company_id = u.company_id AND a.issue_id = u.task_id AND stored.company_id = u.company_id AND stored.object_key = u.object_key diff --git a/server/src/services/public-mcp/upload-cleanup.ts b/server/src/services/public-mcp/upload-cleanup.ts new file mode 100644 index 00000000000..205b5107c8d --- /dev/null +++ b/server/src/services/public-mcp/upload-cleanup.ts @@ -0,0 +1,38 @@ +import { and, eq } from "drizzle-orm"; +import { mcpAttachmentUploads, type Db } from "@paperclipai/db"; +import type { StorageService } from "../../storage/types.js"; + +type AttachmentStorage = { + id: string; + companyId: string; + issueId: string; + provider: string; + objectKey: string; +}; + +export type McpAttachmentCleanup = AttachmentStorage & { verifiedAt: Date }; + +function uploadIdentity(attachment: AttachmentStorage) { + return and(eq(mcpAttachmentUploads.companyId, attachment.companyId), + eq(mcpAttachmentUploads.taskId, attachment.issueId), eq(mcpAttachmentUploads.attachmentId, attachment.id), + eq(mcpAttachmentUploads.storageProvider, attachment.provider), eq(mcpAttachmentUploads.objectKey, attachment.objectKey)); +} + +/** Called after deletion by the attachment owner. A resolved delete alone can + * be optimistic; only the exact upload's storage absence authorizes cleanup. */ +export async function confirmMcpAttachmentCleanup(db: Db, storage: StorageService, attachment: AttachmentStorage): Promise { + const [upload] = await db.select({ id: mcpAttachmentUploads.id }).from(mcpAttachmentUploads).where(uploadIdentity(attachment)).limit(1); + if (!upload) return null; + if (storage.provider !== attachment.provider || (await storage.headObject(attachment.companyId, attachment.objectKey)).exists !== false) { + throw new Error("MCP attachment storage cleanup could not be confirmed"); + } + return { ...attachment, verifiedAt: new Date() }; +} + +/** Keep attachmentId as immutable replay identity. For attachment removal this + * runs in the metadata deletion transaction; issue removal stamps each object + * after its existing post-transaction storage cleanup completes. */ +export async function recordMcpAttachmentCleanup(db: Pick, cleanup: McpAttachmentCleanup | null | undefined) { + if (!cleanup) return; + await db.update(mcpAttachmentUploads).set({ cleanedAt: cleanup.verifiedAt, cleanupVerifiedAt: cleanup.verifiedAt }).where(uploadIdentity(cleanup)); +} diff --git a/server/src/services/voice/idle-history-proof.ts b/server/src/services/voice/idle-history-proof.ts new file mode 100644 index 00000000000..2c76095f8e2 --- /dev/null +++ b/server/src/services/voice/idle-history-proof.ts @@ -0,0 +1,71 @@ +import { VOICE_CALL_REPORT_WINDOW_MS } from "./voice-call-reports.js"; + +/** Available reports still own refresh work for the owner's whole 24h window. + * The accepted push receipt proves submission, never unobservable playback. */ +export function completedVoiceAction(a: string): string { + const closedSession = (s: string) => `${s}.state IN ('ended', 'failed', 'expired') AND ${s}.ended_at IS NOT NULL + AND COALESCE(${s}.error_code, '') <> 'cleanup_pending' + AND (${s}.provider_session_id IS NULL OR ${s}.ended_at < now() - interval '${VOICE_CALL_REPORT_WINDOW_MS} milliseconds')`; + const submitted = (alias: string) => `(${alias}.status = 'completed' AND length(${alias}.result->>'messageId') > 0 + AND ${alias}.result->>'playback' = 'unknown' AND ${alias}.result->>'acceptedAt' ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T') + OR (${alias}.status = 'call_ended' AND ${alias}.result->>'errorCode' = 'provider_unavailable') + OR (${alias}.status = 'failed' AND ${alias}.result->>'errorCode' IN + ('credentials_rejected', 'insufficient_credits', 'provider_unavailable', 'voice_authority_unavailable'))`; + // Nonnull company/endpoint keys let PostgreSQL hash each debt set once, + // rather than rescanning every old session for every retained voice marker. + const settled = `(${a}.company_id, ${a}.endpoint_id) NOT IN ( + SELECT session.company_id, session.endpoint_id FROM chat_voice_sessions session + WHERE (${closedSession('session')}) IS NOT TRUE) + AND (${a}.company_id, ${a}.endpoint_id) NOT IN ( + SELECT admission.company_id, admission.endpoint_id FROM chat_voice_inbound_calls admission + WHERE admission.state NOT IN ('denied', 'expired', 'ended') + OR admission.cleanup_confirmed_at IS NULL OR admission.cleanup_next_attempt_at IS NOT NULL) + AND (${a}.company_id, ${a}.endpoint_id) NOT IN ( + SELECT push.company_id, push.endpoint_id FROM chat_actions push + WHERE push.kind = 'speko_voice_reply_push' AND (${submitted('push')}) IS NOT TRUE)`; + // Configuration records this existing audit event only after tools, webhook, + // voice defaults and runtime setup all succeeded. A partial setup cannot use + // individual resource receipts to certify the rest of the owning operation. + const setupCommitted = `EXISTS (SELECT 1 FROM activity_log receipt JOIN chat_endpoints endpoint + ON endpoint.id = ${a}.endpoint_id AND endpoint.company_id = ${a}.company_id + WHERE receipt.company_id = ${a}.company_id AND receipt.entity_type = 'tool_connection' + AND receipt.entity_id = endpoint.connection_id::text AND receipt.details->>'endpointId' = endpoint.id::text + AND receipt.details->>'provider' = 'speko' AND receipt.action IN ('chat_endpoint.credentials_verified', 'chat_endpoint.reconnected') + AND receipt.created_at >= ${a}.updated_at)`; + return `EXISTS (SELECT 1 FROM chat_endpoints endpoint WHERE endpoint.id = ${a}.endpoint_id + AND endpoint.company_id = ${a}.company_id AND endpoint.provider = 'speko') AND ${settled} AND ( + (${a}.kind = 'speko_tool_setup' AND ${a}.status = 'completed' AND ${setupCommitted} + AND ${a}.payload->>'name' IN ('submit_request', 'get_updates', 'answer_question') + AND ${a}.payload->>'fingerprint' ~ '^[a-f0-9]{64}$' AND ${a}.payload->>'ownershipDefinitionHash' ~ '^[a-f0-9]{64}$' + AND length(${a}.payload->>'ownershipAgentId') > 0 AND length(${a}.result->>'toolId') > 0 + AND ${a}.provider_action_id = 'speko_tool:' || (${a}.payload->>'name') || ':' || (${a}.payload->>'fingerprint')) + OR (${a}.kind = 'speko_lifecycle_setup' AND ${a}.status = 'completed' AND ${setupCommitted} + AND ${a}.payload->>'fingerprint' ~ '^[a-f0-9]{64}$' AND length(${a}.payload->>'ownershipAgentId') > 0 + AND ${a}.payload->>'ownershipUrl' LIKE 'https://%/events' AND length(${a}.result->>'webhookId') > 0 + AND ${a}.provider_action_id = 'speko_lifecycle:' || (${a}.payload->>'fingerprint')) + OR (${a}.kind = 'speko_lifecycle_receipt' AND ${a}.status = 'completed' AND ${a}.result->>'accepted' = 'true' + AND ${a}.payload->>'type' IN ('call.status', 'call.report') AND ${a}.payload->>'fingerprint' ~ '^[a-f0-9]{64}$' + AND ${a}.provider_action_id LIKE 'speko_event:%' AND ( + EXISTS (SELECT 1 FROM chat_voice_sessions session WHERE session.id::text = ${a}.payload->>'sessionId' + AND session.company_id = ${a}.company_id AND session.endpoint_id = ${a}.endpoint_id AND ${closedSession('session')}) + OR (${a}.payload->'sessionId' = 'null'::jsonb AND EXISTS ( + SELECT 1 FROM chat_voice_inbound_calls admission WHERE admission.company_id = ${a}.company_id + AND admission.endpoint_id = ${a}.endpoint_id AND admission.state IN ('denied', 'expired', 'ended') + AND admission.cleanup_confirmed_at IS NOT NULL AND admission.cleanup_next_attempt_at IS NULL)))) + OR (${a}.kind = 'speko_voice_reply_push' AND (${submitted(a)}) + AND ${a}.payload->>'textDigest' ~ '^[a-f0-9]{64}$' + AND ${a}.payload->>'partIndex' ~ '^[0-9]+$' AND ${a}.payload->>'partCount' ~ '^[1-9][0-9]*$' + AND CASE WHEN ${a}.payload->>'partIndex' ~ '^[0-9]+$' AND ${a}.payload->>'partCount' ~ '^[1-9][0-9]*$' + THEN (${a}.payload->>'partIndex')::numeric < (${a}.payload->>'partCount')::numeric ELSE false END + AND EXISTS (SELECT 1 FROM chat_voice_sessions session JOIN chat_voice_replies reply + ON reply.session_id = session.id AND reply.company_id = session.company_id + JOIN chat_publications publication ON publication.id = reply.publication_id AND publication.company_id = reply.company_id + WHERE session.id::text = ${a}.payload->>'sessionId' AND session.company_id = ${a}.company_id + AND session.endpoint_id = ${a}.endpoint_id AND session.conversation_id = ${a}.conversation_id + AND session.provider_session_id = ${a}.payload->>'providerSessionId' AND session.generation::text = ${a}.payload->>'generation' + AND reply.id::text = ${a}.payload->>'replyId' AND publication.id::text = ${a}.payload->>'publicationId' + AND ${a}.provider_action_id = 'voice_reply_push:' || reply.id::text || CASE WHEN ${a}.payload->>'partIndex' = '0' + THEN '' ELSE ':part:' || (${a}.payload->>'partIndex') END + AND publication.endpoint_id = session.endpoint_id AND publication.conversation_id = session.conversation_id + AND publication.issue_id = session.issue_id AND publication.state = 'published' AND publication.published_at IS NOT NULL AND ${closedSession('session')})))`; +} diff --git a/server/src/services/voice/voice-call-reports.ts b/server/src/services/voice/voice-call-reports.ts index 8f597f9f16b..0290554133d 100644 --- a/server/src/services/voice/voice-call-reports.ts +++ b/server/src/services/voice/voice-call-reports.ts @@ -13,7 +13,8 @@ import { SpekoProviderError } from "./speko-provider.js"; type Entry = { id: string; companyId: string; endpointId: string; connectionId: string; dueAt: number }; type Report = SpekoCallReport; -const WINDOW_MS = 24 * 60 * 60 * 1000; +export const VOICE_CALL_REPORT_WINDOW_MS = 24 * 60 * 60 * 1000; +const WINDOW_MS = VOICE_CALL_REPORT_WINDOW_MS; const eligible = (now: number) => and(inArray(chatVoiceSessions.state, ["ended", "failed", "expired"]), isNotNull(chatVoiceSessions.providerSessionId), gte(chatVoiceSessions.endedAt, new Date(now - WINDOW_MS))); diff --git a/server/src/storage/local-disk-provider.ts b/server/src/storage/local-disk-provider.ts index 9ee950117f3..b92f1eea05f 100644 --- a/server/src/storage/local-disk-provider.ts +++ b/server/src/storage/local-disk-provider.ts @@ -30,8 +30,10 @@ function resolveWithin(baseDir: string, objectKey: string): string { async function statOrNull(filePath: string) { try { return await fs.stat(filePath); - } catch { - return null; + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT" || code === "ENOTDIR") return null; + throw error; } } @@ -89,8 +91,9 @@ export function createLocalDiskStorageProvider(baseDir: string): StorageProvider const filePath = resolveWithin(root, input.objectKey); try { await fs.unlink(filePath); - } catch { - // idempotent delete + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") throw error; } }, };