Files
PaperClipAI/server/src/services/chat-channels/endpoints.ts
T
ec3b558277 fix(server): schedule accepted native response recovery (#17696)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Native runs store accepted results and their finalization decisions.
> - A completed run can still owe its public answer after an
interruption.
> - Periodic recovery scans for that answer even when no new work
exists.
> - This pull request wakes recovery from committed changes and saved
retry deadlines.
> - The existing owner still verifies the result, source, and current
publication authority.
> - Settled or dormant response work no longer needs its own periodic
scan.

## Linked Issues or Issue Description

Refs #17690 (merged). This is slice 48 of the sleep migration stack, now
based on master. Related: #17661 handles GitHub terminal publication;
this change handles native accepted-response presentation.
Governed-review presentation remains a separate duty.

**What happened?**

Native finalization recovery repeatedly checks already accepted results
for a missing answer. Removing that check without complete notifications
would lose responses when evidence arrives late, an agent resumes, or
admission settles after execution.

**Expected behavior**

Recover the answer from its saved result when a supported change makes
it eligible. Retain real retry deadlines. Stop querying when the answer
is selected or cannot currently be published. Reconstruct work after
restart.

**Steps to reproduce**

1. Commit a native accepted result with no selected response comment.
2. Run finalization recovery while its source is unavailable or locked.
3. Restore that source or release the lock. The answer must appear once
without repeating provider execution.

## What Changed

- Honor recorded non-create decisions and skip-comment policy in both
discovery and recovery. Apply durable conversation/result/continuation
guards before recovery locks. Preserve genuine unanswered and legacy
recovery.
- Scope routine response wakes to the affected issue/run. Coalesced
input names its active run; pending-effect settlement names its issue.
Sixteen existing global authority/configuration hooks use one explicit
company invalidation boundary. No new transaction wrappers or tables are
added.
- Add an accepted-response worker using the existing delivery
coordinator and scoped deadline helper. Preserve saved retry deadlines
and pending-effect fences under the owner's lock.
- Share the finalization notification boundary. Wake on finalization
settlement, exact accepted-result/final-message evidence, agent
availability, retry admission, and existing chat authority changes.
- Keep lifecycle policy in services. A narrow pre-write status hook lets
lifecycle retain ownership of its transaction and status transitions.
- Register the worker during enabled maintenance startup. Remove its
authority subscription on shutdown. Periodic replay skips this duty on
both committed and fresh-commit paths; ordinary finalization and
explicit recovery retain their behavior.
- Add a partial index and expression statistics for unpresented native
runs. Migration 0412 supports concurrent replay and adds no queue table.
- Add real PostgreSQL and lifecycle coverage. Preserve the
implementation and producer audit in the plan document.

## Verification

- Additional real Photon callback acceptance on the published production
code disproves the security warning: ten active assertions cause zero
response-recovery hints/SELECTs, while three concurrent assertions
during an actual attention-to-active recovery cause exactly one
wake/SELECT. The existing locked status guard already enforces this
boundary. No production change was needed; the resolved finding now has
a successful current-head security check. The regression will be
retained with the next slice.
- Eligibility repair source `892f36e5f66ba1af123034936a7160c64c3ab883`
passes **1489 distinct server cases**: 1139 in six complete
recovery/finalizer/monitor/summary/workspace suites (81.02 seconds),
plus 350 selected chat cases (297.68s). Another 1463 chat cases are
outside the pattern; the complete 1813-case file is not claimed. All
eleven new regressions are included in that count. Five dedicated fresh
PostgreSQL 17 databases migrate through unchanged 0412, with no
fixture-source adaptations. Database build, server types/build,
boundaries and full-SHA migration ordering pass. All seven test source
hashes match.
- The exact published baseline first fails eight behavioral assertions
while the three genuine-answer cases pass. The same corrected test
source passes all eleven after repair: recorded none/reuse remain
suppressed; five ineligible history families take zero recovery row
locks across repeated issue hints; missing/legacy/create-null answers
still publish once; fresh periodic finalization defers presentation to
its worker. Earlier fixture setup and variable/compiler failures remain
explained in the audit, rather than erased by later success.
- Repair source `2fc8b0614aaf9ac7337045de4ced7325f95edc45` passes **1821
server cases**: 1496 in 13 complete producer/lifecycle/authority suites
(110.63 seconds), plus 325 selected chat cases (278.99 seconds).
Coverage includes all native-session cases, queue admission, status
decisions, membership/board access, the complete GitHub workflow,
accepted-response lifecycle and real authority restoration. The other
1477 chat cases are outside this selection; the complete 1802-case file
is not claimed for this repair run.
- Actual PostgreSQL regressions first reproduce a coalesced-input hint
missing its run scope and two routine hint paths producing 12
response-history SELECTs each after three notifications. The same
assertions pass with run-scoped coalescing and zero response-history
SELECTs for unscoped routine hints. Actual membership restoration still
publishes the waiting response once. Assertion time limits are
unchanged; this measures real driver SQL and persisted producer work.
- Eleven dedicated fresh PostgreSQL 17 databases migrate through 0412.
One embedded-only wake-queue fixture import temporarily uses its own
external database and restores byte for byte. All 14 selected source
files match pre-run hashes. This qualifies external-database behavior
rather than reproducing embedded CI locally.
- Master advanced independently with #18126's Pi archive retry fix.
Normal merge `2a535bc61259ef99387369e8b32498f774e5ba2c` preserves both
complete binary patches. All accepted server/database/UI/test/CLI trees
stay identical; the three independent runner installer/manifest files
are retained. All 13 installer tests pass, including stalled header/body
cancellation. Combined server types/build, boundaries and full-SHA
migration order pass. A subsequent normal merge of #18128 at
`e8b78cce5bb557b086bee9ca930eeaab1d09b244` retains its three test-only
file changes with both complete patches preserved. The changed HTTPS
backfill case passes on a fresh PostgreSQL fixture (1 selected case; 178
outside the pattern), and combined types/build, boundaries and migration
order pass again. All 14 repair source hashes remain unchanged. The
independent browser fixture changes are preserved; no local browser run
is claimed.
- The earlier source `9feec170ef8174011fc3c5d3d6d76f0a0f39fea0` passed
the 4171-case full baseline before this scope repair. That scope is
preserved in the audit rather than represented as a current full rerun.
All 0412 database bytes remain unchanged: original concurrent
index/statistics/ANALYZE, prior executable history, journal entries and
retained snapshots. Five snapshots 0408 through 0412 remain. The
populated 0411-to0412 upgrade preserved 410 prior receipts and 38
companies, appended one receipt and none on replay; index/statistic
checks passed.
- The earlier P2 security failure, Greptile's former conditional
dependency on #17702, and the subsequent actual Ponytail eligibility
findings are recorded in
`doc/plans/2026-10-25-native-response-presentation-sleep-audit.md`. This
PR now includes the issue-scoped pending-effect notification fix itself,
so it is independently correct before #17702. Fresh published-head CI,
Greptile 5/5 and a clean security scan remain required. No
deployed-sleep, external-wake or fleet-savings claim is made.


## Risks

- The writer contract is process-local. Independent SQL writers and
other replicas are not covered by these notifications.
- Unpresented backlog reconstruction still has memory and time cost
proportional to candidate count. Pages contain at most 100 rows;
execution uses the shared fair batch. Selected history is excluded.
Health-only signals do not trigger reconstruction.
- Publication policy remains with its current owner. Digest, company/run
binding, source authorization, selected-answer suppression, and
ownership fences remain required.
- The rest of native recovery and governed-review presentation still run
separately. This PR does not relax Cloud sleep eligibility or add
external wake coverage.
- The stack parent has merged and its actual ancestry is reconciled.
This publication still needs fresh current-head CI and review.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository tools, shell
execution, and PostgreSQL tests. The runtime does not expose a more
specific model identifier or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Codex <noreply@openai.com>
2026-10-29 02:31:20 -05:00

492 lines
20 KiB
TypeScript

import { notifyChatCompanyAuthorityChange } from "../chat-run-milestone-notifications.js";
import { chatConnectionTransport } from "./provider-catalog.js";
import type { ConnectionManagementContext } from "../connection-management.js";
import { slackSetupState } from "./slack/setup.js";
import { assertChatExecutionDefaultsAccess } from "../chat-execution-defaults.js";
import type { EndpointRow } from "./runtime-types.js";
import { chatCredentialMutationLease } from "../chat-credential-mutation-lease.js";
import { slackChatRegistrationService, slackRegistrationProjection } from "../chat-slack-registration.js";
import { chatSlackRegistrations } from "@paperclipai/db";
import { defaultSlackAppConfiguration } from "@paperclipai/shared";
import {
randomBytes,
randomUUID,
} from "node:crypto";
import {
and,
desc,
eq,
ne,
} from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agents,
chatEndpoints,
toolApplications,
toolConnections,
} from "@paperclipai/db";
import type {
ChatAdapterCapabilities,
ChatEndpoint,
ChatProvider,
CreateChatEndpointInput,
UpdateChatEndpointInput,
} from "@paperclipai/shared";
import {
isAgentStatusInvokable,
isUuidLike,
} from "@paperclipai/shared";
import {
badRequest,
conflict,
notFound,
unprocessable,
} from "../../errors.js";
import {
logActivity,
} from "../activity-log.js";
import type { EndpointRecord } from "./runtime-types.js";
type ChatEndpointsOptions = {
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
CAPABILITIES: Record<ChatProvider, ChatAdapterCapabilities>;
PROVIDER_LABELS: Record<ChatProvider, string>;
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
getPublicBaseUrl: () => string | null;
getWebhookPublicBaseUrl: () => string | null;
slackRegistration: Pick<ReturnType<typeof slackChatRegistrationService>, "cleanup" | "managerGrants">;
};
export function createChatEndpoints(db: Db, options: ChatEndpointsOptions) {
const { endpointRecord, CAPABILITIES, PROVIDER_LABELS, withCredentialMutationLease, getPublicBaseUrl, getWebhookPublicBaseUrl } = options;
function iso(value: Date | null | undefined): string | null {
return value ? value.toISOString() : null;
}
function providerSetupState(
endpoint: Pick<
EndpointRow,
| "provider"
| "providerAccountId"
| "publicId"
| "status"
| "setup"
| "botUsername"
>,
publicBaseUrl: string | null,
assignedAgentName?: string | null,
) {
const path = endpoint.provider === "speko" ? `/api/voice-webhooks/${endpoint.publicId}/tools` : `/api/chat-webhooks/${endpoint.publicId}/${endpoint.provider}`;
const webhookUrl = publicBaseUrl ? `${publicBaseUrl}${path}` : null;
const step = endpoint.status === "active" ? "complete" : endpoint.setup.step;
const rotationId: unknown = Reflect.get(endpoint.setup, "credentialRotationId");
switch (endpoint.provider) {
case "speko":
return { step, providerUrl: "https://platform.speko.ai/", webhookUrl } as const;
case "imessage-photon": return { step, providerUrl: "https://photon.codes/", testStartedAt: endpoint.setup.testStartedAt } as const;
case "agentmail": return endpoint.setup;
case "slack": {
return slackSetupState({ endpoint, webhookUrl, step, assignedAgentName });
}
case "github":
return {
step,
github: endpoint.setup.github,
testStartedAt: endpoint.setup.testStartedAt,
testSkipped: endpoint.setup.testSkipped,
authorizationUrl: "https://github.com/settings/apps/new",
providerUrl: "https://github.com/settings/installations",
webhookUrl,
webhookVerifiedAt: endpoint.setup.webhookVerifiedAt ?? null,
credentialRotationId: typeof rotationId === "string" && isUuidLike(rotationId) ? rotationId : null,
} as const;
case "discord":
return {
step,
authorizationUrl: "https://discord.com/developers/applications",
providerUrl: endpoint.providerAccountId
? `https://discord.com/channels/${encodeURIComponent(endpoint.providerAccountId)}`
: "https://discord.com/channels/@me",
webhookUrl,
} as const;
case "microsoft-teams":
return {
step,
authorizationUrl: "https://dev.teams.microsoft.com/apps",
providerUrl: "https://teams.microsoft.com/",
messagingEndpoint: webhookUrl,
} as const;
case "telegram":
return {
step,
providerUrl: endpoint.botUsername
? `https://t.me/${encodeURIComponent(endpoint.botUsername)}`
: "https://t.me/BotFather",
webhookUrl,
} as const;
}
}
function serializeEndpoint(
row: Awaited<ReturnType<typeof endpointRecord>>,
): ChatEndpoint {
if (!row) throw notFound("Chat endpoint not found");
const endpoint = row.endpoint;
return {
id: endpoint.id,
companyId: endpoint.companyId,
connectionId: endpoint.connectionId,
publicationMode: endpoint.publicationMode,
externalExecutionPolicy: endpoint.externalExecutionPolicy,
provider: endpoint.provider,
publicId: endpoint.publicId,
status: endpoint.status,
deploymentMode: endpoint.deploymentMode,
assignedAgentId: endpoint.assignedAgentId,
assignedAgentName: row.assignedAgentName,
sponsorUserId: endpoint.sponsorUserId,
providerAccountId: endpoint.providerAccountId,
providerAccountLabel: endpoint.providerAccountLabel,
botExternalId: endpoint.botExternalId,
botUsername: endpoint.botUsername,
botLabel: endpoint.botDisplayName ?? row.assignedAgentName,
botAvatarUrl: endpoint.botAvatarUrl,
communicationInstructions: endpoint.communicationInstructions,
executionDefaults: endpoint.executionDefaults,
...(endpoint.provider === "imessage-photon" && endpoint.botExternalId ? { photonAllocation: endpoint.botExternalId.startsWith("photon-project:") ? "shared" as const : "dedicated" as const } : {}),
allowDirectMessages: endpoint.allowDirectMessages,
requireAtMention: endpoint.requireAtMention,
allowGroupChats: endpoint.allowGroupChats,
allowUnlinkedPeople: endpoint.allowUnlinkedPeople,
replyMode: "subscribed",
capabilities: endpoint.capabilities,
setup: {
...providerSetupState(
endpoint,
getWebhookPublicBaseUrl(),
row.assignedAgentName,
),
...(endpoint.provider === "github"
? {
webhookSecretConfigured: row.credentialSecretRefs.some(
(ref) => ref.configPath === "credentials.webhookSecret",
),
}
: endpoint.provider === "speko" ? {
webhookSecretConfigured: row.credentialSecretRefs.some(
(ref) => ref.configPath === "credentials.signingSecret",
),
} : {}),
},
healthMessage: endpoint.healthMessage,
lastError: endpoint.lastError,
lastActivityAt: iso(endpoint.lastEventAt),
lastPublicationAt: iso(endpoint.lastPublicationAt),
activatedAt: iso(endpoint.activatedAt),
createdAt: endpoint.createdAt.toISOString(),
updatedAt: endpoint.updatedAt.toISOString(),
};
}
async function list(companyId: string) {
const rows = await db
.select({
endpoint: chatEndpoints,
assignedAgentName: agents.name,
connectionName: toolConnections.name,
applicationId: toolConnections.applicationId,
credentialSecretRefs: toolConnections.credentialSecretRefs,
})
.from(chatEndpoints)
.innerJoin(
agents,
and(
eq(agents.id, chatEndpoints.assignedAgentId),
eq(agents.companyId, chatEndpoints.companyId),
),
)
.innerJoin(
toolConnections,
and(
eq(toolConnections.id, chatEndpoints.connectionId),
eq(toolConnections.companyId, chatEndpoints.companyId),
),
)
.where(
and(
eq(chatEndpoints.companyId, companyId),
ne(chatEndpoints.status, "archived"),
),
)
.orderBy(desc(chatEndpoints.updatedAt));
return rows.map((row) => serializeEndpoint(row));
}
async function get(endpointId: string) {
const result = serializeEndpoint(await endpointRecord(endpointId));
if (result.provider === "slack") {
const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId));
result.setup.slackRegistration = registration ? slackRegistrationProjection(registration) : undefined;
const publicOrigin = getPublicBaseUrl();
result.setup.slackOAuthCallbackUri = publicOrigin ? `${publicOrigin}/api/chat-slack/oauth/callback` : null;
}
return result;
}
async function create(
companyId: string,
input: CreateChatEndpointInput,
actorUserId?: string | null,
managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail");
if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections");
if (input.slackSetupMethod === "managed" && options.slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable");
const agent = await db
.select({ id: agents.id, name: agents.name, status: agents.status })
.from(agents)
.where(
and(
eq(agents.companyId, companyId),
eq(agents.id, input.assignedAgentId),
),
)
.then((rows) => rows[0] ?? null);
if (!agent)
throw unprocessable("The selected agent does not belong to this company");
if (!isAgentStatusInvokable(agent.status)) {
throw unprocessable(
"The selected agent must be active before it can be connected to chat",
{
code: "chat_agent_not_invokable",
},
);
}
const endpointId = randomUUID();
const publicId = randomBytes(32).toString("base64url");
const applicationId = input.applicationId ?? randomUUID();
const connectionId = randomUUID();
const suffix = endpointId.slice(0, 8);
const name = (
input.name?.trim() || `${PROVIDER_LABELS[input.provider]} — ${agent.name}`
).slice(0, 145);
await managementContext?.reauthorize();
await db.transaction(async (tx) => {
if (input.applicationId) {
const application = await tx
.select({
id: toolApplications.id,
applicationKey: toolApplications.applicationKey,
metadata: toolApplications.metadata,
status: toolApplications.status,
})
.from(toolApplications)
.where(
and(
eq(toolApplications.companyId, companyId),
eq(toolApplications.id, input.applicationId),
),
)
.then((rows) => rows[0] ?? null);
if (!application) throw notFound("App not found");
const sourceTemplateKey =
typeof application.metadata.sourceTemplateKey === "string"
? application.metadata.sourceTemplateKey
: null;
if (
application.status === "archived" ||
(application.applicationKey !== input.provider &&
sourceTemplateKey !== input.provider)
) {
throw unprocessable(
`The selected App is not the ${PROVIDER_LABELS[input.provider]} connector`,
);
}
} else {
await tx.insert(toolApplications).values({
id: applicationId,
companyId,
applicationKey: `chat:${input.provider}:${endpointId}`,
name: `${name} ${suffix}`,
description: `People talk to ${agent.name} through ${PROVIDER_LABELS[input.provider]}.`,
type: "chat",
status: "draft",
metadata: { sourceTemplateKey: input.provider, purpose: "channel" },
ownerUserId: actorUserId ?? null,
});
}
await tx.insert(toolConnections).values({
id: connectionId,
companyId,
applicationId,
name,
uid: `chat-${input.provider}-${endpointId}`,
connectionKind: "managed",
connectionPurpose: "channel",
ownership: "customer",
transport: chatConnectionTransport(input.provider),
authKind: "api_key",
credentialPolicy: "shared",
status: "draft",
enabled: false,
config: { provider: input.provider },
transportConfig: {},
createdByUserId: actorUserId ?? null,
});
await tx.insert(chatEndpoints).values({
id: endpointId,
companyId,
connectionId,
provider: input.provider,
publicId,
assignedAgentId: agent.id,
sponsorUserId: actorUserId ?? null,
// Group chat is a broad Teams reach grant rather than a discovered
// destination toggle. Keep it closed until the operator explicitly
// enables that surface, even when this process is running against a
// database created before the column default was hardened.
requireAtMention: input.provider === "slack",
allowGroupChats: !["microsoft-teams", "speko"].includes(input.provider),
allowUnlinkedPeople: !["slack", "imessage-photon", "speko"].includes(input.provider),
capabilities: CAPABILITIES[input.provider],
setup: {
step: "provider_setup",
...(input.provider === "slack"
? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name),
command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? options.slackRegistration.managerGrants.defaultMethod() }
: {}),
},
});
});
await logActivity(db, {
companyId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.created",
entityType: "tool_connection",
entityId: connectionId,
details: {
endpointId,
provider: input.provider,
assignedAgentId: agent.id,
},
});
return get(endpointId);
}
async function update(
endpointId: string,
input: UpdateChatEndpointInput,
actorUserId?: string | null,
managementContext?: ConnectionManagementContext,
) {
await managementContext?.reauthorize();
const initial = await endpointRecord(endpointId);
if (!initial) throw notFound("Chat endpoint not found");
if (initial.endpoint.provider === "agentmail" && Object.keys(input).some((key) => key !== "executionDefaults")) throw badRequest("Use the email inbox API for AgentMail");
await withCredentialMutationLease(
initial.endpoint,
async (credentialLease) => {
// Reach changes share the provider-transport lane with publications.
// Once a disable returns, no send that sampled the previous grant can
// still begin or finish behind it.
const existing = await endpointRecord(endpointId);
if (!existing) throw notFound("Chat endpoint not found");
const values: Partial<typeof chatEndpoints.$inferInsert> = {
updatedAt: new Date(),
};
if (input.communicationInstructions !== undefined) {
if (existing.endpoint.provider !== "slack") {
throw unprocessable("Communication instructions are currently supported for Slack connections");
}
values.communicationInstructions = input.communicationInstructions;
}
if (input.slackSetupMethod === "managed" && options.slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable");
if (input.slackSetupMethod && (existing.endpoint.provider !== "slack" || existing.endpoint.botExternalId || existing.endpoint.status !== "draft")) {
throw conflict("Setup method can only change before connecting the app");
}
if (input.slackApp || input.slackSetupMethod) {
const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId));
if (registration && (registration.managerGrantId || registration.status !== "failed")) {
if (registration.managerGrantId || input.slackSetupMethod === "managed" || input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts");
// Manual recovery fences pending authorization under the same credential lease.
await options.slackRegistration.cleanup(endpointId, credentialLease);
}
}
if (input.slackSetupMethod) {
values.setup = { ...existing.endpoint.setup, slackSetupMethod: input.slackSetupMethod };
}
if (input.slackApp) {
if (existing.endpoint.provider !== "slack") {
throw unprocessable("Slack app details only apply to Slack connections");
}
if (existing.endpoint.status !== "draft" || existing.endpoint.botExternalId) {
throw conflict("Slack app details can only be edited before connecting the app");
}
values.setup = {
...(values.setup ?? existing.endpoint.setup),
slackApp: input.slackApp,
command: input.slackApp.command,
};
}
if (input.allowDirectMessages !== undefined)
values.allowDirectMessages = input.allowDirectMessages;
if (input.requireAtMention !== undefined) {
if (existing.endpoint.provider !== "slack")
throw unprocessable("Require at-mention only applies to Slack connections");
values.requireAtMention = input.requireAtMention;
}
if (input.allowGroupChats && existing.endpoint.provider === "imessage-photon" && (!existing.endpoint.botExternalId || existing.endpoint.botExternalId.startsWith("photon-project:")))
throw unprocessable("Photon shared channels support direct messages only; groups require a dedicated channel");
if (input.allowGroupChats !== undefined)
values.allowGroupChats = input.allowGroupChats;
if (input.allowUnlinkedPeople !== undefined)
values.allowUnlinkedPeople = input.allowUnlinkedPeople;
await managementContext?.reauthorize();
await db.transaction(async (tx) => {
await credentialLease.assertOwned(tx);
if ((input.allowDirectMessages === true && !existing.endpoint.allowDirectMessages)
|| (input.allowGroupChats === true && !existing.endpoint.allowGroupChats)
|| (input.allowUnlinkedPeople === true && !existing.endpoint.allowUnlinkedPeople)) {
await notifyChatCompanyAuthorityChange(tx, { companyId: existing.endpoint.companyId });
}
if (input.executionDefaults !== undefined) {
await assertChatExecutionDefaultsAccess(db, {
companyId: existing.endpoint.companyId,
assigneeAgentId: values.assignedAgentId ?? existing.endpoint.assignedAgentId,
actor: managementContext?.actor ?? { type: "board", userId: actorUserId, source: actorUserId ? "session" : "local_implicit" },
defaults: input.executionDefaults,
}, tx);
values.executionDefaults = input.executionDefaults;
}
await tx
.update(chatEndpoints)
.set(values)
.where(eq(chatEndpoints.id, endpointId));
await logActivity(tx as unknown as Db, {
companyId: existing.endpoint.companyId,
...(managementContext?.audit ?? { actorType: "user" as const, actorId: actorUserId ?? "board" }),
action: "chat_endpoint.updated",
entityType: "tool_connection",
entityId: existing.endpoint.connectionId,
details: { endpointId, fields: Object.keys(input) },
});
await credentialLease.assertOwned(tx);
});
},
);
return get(endpointId);
}
return { list, get, create, update };
}