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; } }, };