mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 11:01:16 +01:00
Reconcile Claude protocol with merged runtime prerequisite
Co-Authored-By: Paperclip <noreply@paperclip.ing> * commit 'd67e3507b23041212fa26302ce6de078c5ebcc05': feat(runner): qualify Codex platform packages and cache hosted builds (#16028) fix(ui): default human task replies to the latest assigning agent (#15594) refactor(server): move provider setup, inventory and delivery into channel folders (#16058) fix(apps): show user avatars and center access rows (#15577) fix(issues): preserve human handoffs from the active agent run (#16082) # Conflicts: # doc/plans/2026-10-06-default-agent-runner.md # maps/agent-access-map/runs-adapters.md # maps/feature-map/runs-adapters.md # packages/paperclip-runner/README.md # server/src/__tests__/dev-runner-native-binary.test.ts
This commit is contained in:
commit
abcd8261d2
58 files changed
+4036
-2462
No files matched your search
@@ -67,6 +67,11 @@ Tasks have parentage. Every task exists in service of a parent task, all the way
|
||||
|
||||
The current issue model includes stable issue identifiers, parent/sub-issues, blockers, a single assignee, comments, issue documents, attachments and work products, and review/approval handoffs. That structure keeps work inspectable by both the board and agents while still allowing agents to decompose work into smaller tasks.
|
||||
|
||||
When a task is assigned to the current human, the message composer defaults to
|
||||
the latest agent that assigned it to them. If no assignment is available, it uses
|
||||
the latest other commenter, or leaves the recipient empty. A manual recipient
|
||||
choice stays selected while task data refreshes.
|
||||
|
||||
### Company Skills and Policy
|
||||
|
||||
Company skills are shared operating capabilities, not privileged objects by default. Every authenticated agent in a company can create, import, install, edit, update, test, reset, and remove that company's skills unless the company has configured an explicit restriction.
|
||||
|
||||
@@ -29,6 +29,17 @@ An issue has at most one assignee.
|
||||
|
||||
This is a hard invariant. Paperclip is single-assignee by design.
|
||||
|
||||
A legacy agent run can hand its own task to a human without cancelling itself.
|
||||
The REST request must use the run's signed token and match the current agent,
|
||||
company, task, and active run. The assignment and requested status persist while
|
||||
the current turn finishes normally. This avoids losing the handoff when workspace
|
||||
restoration takes longer than the stop timeout. The former agent no longer owns
|
||||
the task. Its later task mutations are rejected, including under the update
|
||||
transaction's task and run locks. Read and comment access follows normal policy.
|
||||
|
||||
Board-initiated reassignments, agent API keys, native executions, and handoffs to
|
||||
another agent still require the outgoing execution to stop before ownership changes.
|
||||
|
||||
## 3. Status Semantics
|
||||
|
||||
Paperclip issue statuses are not just UI labels. They imply different expectations about ownership and execution.
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Chat channel provider ownership refactor
|
||||
|
||||
Date: 2026-10-12. Batch five starts at `4b958ecbd8f33a7782fe6f29ec825f80f636b833`.
|
||||
|
||||
The user requested another stack of about ten PRs. Put provider-specific code in
|
||||
the provider's existing folder. Preserve behavior and build each PR on the previous
|
||||
verified extraction. This batch does not authorize merging the new stack.
|
||||
|
||||
The starting service is 21,460 lines and 862,284 bytes. The previous ten PRs are
|
||||
merged. Common admission, runtime ownership, and publication scheduling stay in
|
||||
the service. In particular, the receiver lease currently serves both Discord and
|
||||
Photon; its Discord names do not make it a Discord-only boundary.
|
||||
|
||||
| Order | Responsibility and destination under `server/src/services/chat-channels/` | Approximate source lines | Contract and state | Risks and verification |
|
||||
| --- | --- | ---: | --- | --- |
|
||||
| 1 | Credential verification: provider `credentials.ts` files | 250 | Credentials, fetch, timeout; return verified bot identity. Photon inspection retains its error translation. | Preserve scope/event checks and immutable identity. Existing configuration, credential, and Photon tests. |
|
||||
| 2 | Inventory discovery: `slack/inventory.ts`, `github/inventory.ts`, `discord/inventory.ts` | 120 | Credential input and prepared inventory result. Slack label hydration uses the same endpoint reader. | Preserve GitHub installation discovery and canonical repository IDs; same public resource tests. |
|
||||
| 3 | GitHub secret rotation: `github/setup-secret.ts` | 278 | Endpoint reader, credential lease, credential storage, runtime invalidation, and audit. | Keep lease checks, commit recovery, and audit ordering intact; real database rotation/race tests. |
|
||||
| 4 | Teams file delivery: `microsoft-teams/file-delivery.ts` | 360 | Transfer protocol and consent callback; shared runtime readers and retry cooldown. Maintenance state remains per service. | Preserve exact transfer authority, unknown outcomes, consent races, and live shutdown state; assembled file-delivery tests. |
|
||||
| 5 | Native slash callbacks: `slack/commands.ts`, `telegram/commands.ts` | 340 | Provider command callbacks use shared admission and durable effects. Dispatcher retains callback admission. | Preserve synthetic Telegram media/time, Slack acknowledgment, identity linking, and duplicate receipts. |
|
||||
| 6 | Telegram attachment failure: `telegram/attachment-failure.ts` | 106 | Accepted inbound delivery and attachment result; return whether it settled. | Keep comment/action/delivery writes atomic and avoid waking an agent with no usable content. |
|
||||
| 7 | GitHub publication preparation and settlement: `github/publication-policy.ts` | 235 | Current source proof, explicit operator classifier, receipt cleanup, and task URL getter. | Preserve prepared-text receipts and governed-tool fallback suppression; publication/review integration tests. |
|
||||
| 8 | Slack task tool authority: `slack/task-authority.ts` | 170 | Register resolver with current endpoint/credential and inbound authorization readers; return unregister callback. | Preserve lock order, responsible-user authority, company scope, and shutdown cleanup; existing Slack tool tests. |
|
||||
| 9 | GitHub App setup: `github/app-setup.ts` | 180 | App persistence, repository refresh, registration/wizard assembly, and cloud ingress registration. | Preserve live origins, credential leases, ingress fences, and unregister ownership; setup and wizard integration tests. |
|
||||
| 10 | GitHub installation lifecycle: `github/installation-lifecycle.ts` | 430 | Admitted lifecycle delivery, locked credential lease, identity/inventory readers; explicit runtime outcomes and failure notification. | Keep claim/retry ownership in the shared coordinator. Preserve failure notification before throwing; deletion/reinstall/race tests. |
|
||||
|
||||
These estimates include code that stays behind as dispatch and typed wiring. Each
|
||||
slice must be reassessed before implementation. Do not introduce an untyped context
|
||||
bag, copy mutable live values, or change algorithms to meet a line-count target.
|
||||
|
||||
Run existing public-path tests before production edits and after each extraction.
|
||||
Compare moved declarations and retained statements with normalized TypeScript
|
||||
syntax; review every deliberate dispatch substitution. Run server typecheck and
|
||||
module boundaries for each slice, and repository typecheck, full tests, and build
|
||||
once at the final handoff. Record failures and omitted checks without claiming they
|
||||
passed. Local fixtures prove assembled application behavior, not live third-party
|
||||
execution. Use the same managed checkout for dependency preflight and builds.
|
||||
|
||||
Every PR uses the repository template and points to its preceding stack branch.
|
||||
Check current-head Greptile feedback and CI before completing each slice. Update
|
||||
this plan with evidence and a fresh map after the batch.
|
||||
|
||||
## Implemented boundaries and evidence
|
||||
|
||||
All ten local slices are committed. The service is now 19,153 lines and 762,442
|
||||
bytes: 2,307 lines and 99,842 bytes removed from the entry point. The service body
|
||||
is 17,787 lines, including 138 nested functions (17,270 lines) and 71 state/wiring
|
||||
statements (231 lines). Imports occupy 399 lines. The aggregate AST audit checks
|
||||
38 moved bodies/declarations and the syntax and order of 263 retained statements.
|
||||
|
||||
The first baseline passed 96 selected assembled-service cases and 33 credential,
|
||||
inventory, and Photon cases. Additional command, setup, and publication baselines
|
||||
passed before their moves. Each slice passed server typecheck, module boundaries,
|
||||
and its public-path checks. Post-move integration case counts were 6, 8, 10, 50,
|
||||
7, 1, 21, 3, 34, and 12 respectively; these selections overlap and are not a count
|
||||
of unique tests. The first slice also reran all 33 credential/inventory/Photon
|
||||
cases. Repository-wide typecheck passes on the complete stack. Full tests and build
|
||||
are recorded below when complete.
|
||||
|
||||
Review found that the inventory dispatcher needed `return await` to retain its
|
||||
existing catch boundary. Three new real-database HTTP tests passed against the
|
||||
pre-extraction service, failed with 500 instead of 422 after the initial move, and
|
||||
passed after the correction. They also check the public error code/message and
|
||||
unchanged credential refs. The repair is included in slice two and replayed through
|
||||
all subsequent local branches. Moved provider bodies remain unchanged.
|
||||
|
||||
The Teams activity protocol callback is deferred until use because its new factory
|
||||
is initialized after file-authority construction. Shutdown is read through a live
|
||||
callback. GitHub lifecycle claims and credential leasing remain in the coordinator;
|
||||
the provider workflow returns runtime outcomes and notifies its cleanup owner
|
||||
immediately after persisting a failure, before throwing.
|
||||
|
||||
## Remaining map
|
||||
|
||||
The largest shared functions are `processMessage` (2,166 lines), `handleAction`
|
||||
(1,074), `configureWithCredentialLease` (868), `processSelectedPublication` (729),
|
||||
`handleModalSubmit` (708), `handleWebhook` (685), and `processLifecycleDelivery`
|
||||
(657). These include shared admission and transaction ownership; do not relocate
|
||||
them wholesale into a provider folder.
|
||||
|
||||
The next provider candidates to inspect are:
|
||||
|
||||
- Provider setup-state and runtime-config projections. Extend provider modules
|
||||
while retaining live origin reads and the shared dispatcher.
|
||||
- Photon outbound publication (a 227-line provider branch), including interaction
|
||||
prompts and exact retry evidence. Keep current-source checks and partial receipt
|
||||
ownership explicit.
|
||||
- GitHub authenticated webhook handling (a 209-line provider branch). Preserve
|
||||
durable ingress admission, signed ping verification, and receiver fencing.
|
||||
- Discord durable Markdown transport (96 lines) and its modal wire handling
|
||||
(85-line branch plus smaller branches). Preserve shared question resolution and
|
||||
publication ownership.
|
||||
|
||||
This remains a candidate map, not authorization for another batch or merging this
|
||||
stack. All provider calls in local integration evidence use fixtures.
|
||||
@@ -0,0 +1,19 @@
|
||||
# App access user avatars
|
||||
|
||||
Storybook: **Connections / Chat management / 2 · People and access**
|
||||
(`connections-chat-management--access`).
|
||||
|
||||
These screenshots show the production access component with synthetic fixture
|
||||
accounts: a linked account with an image, a linked account without an image,
|
||||
and an account waiting for confirmation. The image fixture is an existing
|
||||
Cliptoon asset used as a deterministic profile image; it is not a real person's
|
||||
photo.
|
||||
|
||||
The avatar is centered beside both the Paperclip name and the Slack name, at
|
||||
the same vertical center as Disconnect. Missing or failed images retain initials.
|
||||
Truncated Paperclip names retain their full text in the native hover tooltip.
|
||||
|
||||
- [Desktop, 1280 × 900](desktop.png)
|
||||
- [Mobile, 390 × 844](mobile.png)
|
||||
|
||||
The Access story checks image loading, initials, hover text, and vertical alignment.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -71,7 +71,12 @@ Agent access: `partial` — Native set_task_title/reassign_task/set_dependencies
|
||||
for permitted non-lifecycle fields; REST.
|
||||
|
||||
Policy: Company/task visibility, assignment/change policy, active-run checkout ownership
|
||||
and lifecycle rules.
|
||||
and lifecycle rules. A legacy run can assign its own task to a human without
|
||||
cancelling itself only with its signed run token and matching active run, agent,
|
||||
company and task. The outgoing run cannot mutate its human-owned task afterward,
|
||||
including through a late update already admitted before the handoff. Agent keys,
|
||||
native runs, board reassignment and transfers to
|
||||
another agent retain verified-stop requirements.
|
||||
|
||||
Evidence: `source-only` — [implementation](../../server/src/routes/issues.ts) at the
|
||||
reviewed commit above.
|
||||
@@ -79,7 +84,10 @@ reviewed commit above.
|
||||
Verification: `not-run` — exercise a listed permitted operation with actual agent
|
||||
credentials/run context; inspect its result and applicable persistence/attribution.
|
||||
Assert the relevant denied actor, owner, company or mode from Policy. Review the
|
||||
remaining operations before qualifying the whole capability.
|
||||
remaining operations before qualifying the whole capability. Focused route
|
||||
regressions in [ownership tests](../../server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts)
|
||||
cover a human handoff and stop enforcement for other actors, runtimes and targets;
|
||||
they use mocked services and do not claim live execution proof.
|
||||
|
||||
Gaps: Native fallback blocks status/assignment/dependency policy fields. Dedicated tools
|
||||
differ in scope and supported fields.
|
||||
|
||||
@@ -55,6 +55,13 @@ Manual: Filter by status and assignee, change grouping/sort, open a task, and re
|
||||
|
||||
Automated: [task detail](../../ui/src/pages/IssueDetail.test.tsx) covers component behavior, while [issue service](../../server/src/__tests__/issues-service.test.ts) covers server contracts.
|
||||
|
||||
Manual: For a disposable task owned by a legacy agent, have its current run
|
||||
assign the task to a human with status In review. Verify that assignment and
|
||||
status persist, that the current run finishes without `issue_reassigned`, and
|
||||
that slow workspace restoration does not turn the task Blocked. Try a later edit
|
||||
from the outgoing run and verify rejection. Repeat a board
|
||||
reassignment and confirm that the outgoing run still stops.
|
||||
|
||||
Manual: Add a comment and edit authorized properties. Reload and verify history, actor, and values. Complete a disposable task and inspect its deliverable; reopen through an available action and confirm the next execution follows current policy.
|
||||
|
||||
## Gotchas
|
||||
|
||||
@@ -341,6 +341,7 @@ export interface ChatIdentityLink {
|
||||
externalDetail?: string | null;
|
||||
paperclipUserId?: string | null;
|
||||
paperclipUserLabel?: string | null;
|
||||
paperclipUserImage?: string | null;
|
||||
status: ChatIdentityLinkStatus;
|
||||
expiresAt?: string | null;
|
||||
confirmedAt?: string | null;
|
||||
|
||||
@@ -6930,7 +6930,10 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
expect(saved.setup.webhookVerifiedAt).toBeNull();
|
||||
expect(saved.providerAccountId).toBe("TAUTO");
|
||||
expect(saved.setup.slackAccount).toEqual({ externalUserId: f.installerId, paperclipUserId: actor.userId, status: "linked", welcomeStatus: "sent", dmChannelId: "DWELCOME" });
|
||||
expect(await f.service.listPrincipals(f.endpoint.id)).toEqual([expect.objectContaining({ status: "linked", paperclipUserId: actor.userId })]);
|
||||
expect(await f.service.listPrincipals(f.endpoint.id)).toEqual([expect.objectContaining({ status: "linked", paperclipUserId: actor.userId, paperclipUserImage: null })]);
|
||||
await db.update(authUsers).set({ image: "https://example.test/installer.png" }).where(eq(authUsers.id, actor.userId));
|
||||
expect(await f.service.listPrincipals(f.endpoint.id)).toEqual([expect.objectContaining({ paperclipUserImage: "https://example.test/installer.png" })]);
|
||||
await db.update(authUsers).set({ image: null }).where(eq(authUsers.id, actor.userId));
|
||||
const [, welcome] = f.provider.mock.calls.find(([url]) => String(url).endsWith("chat.postMessage"))!;
|
||||
expect(new URLSearchParams(String(welcome!.body)).get("channel")).toBe(f.installerId);
|
||||
expect(new URLSearchParams(String(welcome!.body)).get("text")).toMatch(/^Hi, I’m .+\. Your Slack account is connected to Paperclip\.$/);
|
||||
@@ -10117,6 +10120,60 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["slack", "discord", "github"] as const)("preserves the public inventory error response for %s", async (provider) => {
|
||||
const fixture = await seedCompany();
|
||||
let failInventory = false;
|
||||
let service: ChatChannelService;
|
||||
let endpoint: Awaited<ReturnType<ChatChannelService["create"]>>;
|
||||
if (provider === "slack") {
|
||||
const successfulFetch = fakeSlackFetch();
|
||||
const context = await configuredSlackEndpoint(fixture, {
|
||||
fetch: (async (input, init) => {
|
||||
if (failInventory && String(input).includes("/conversations.list"))
|
||||
throw new Error("inventory fixture outage");
|
||||
return successfulFetch(input, init);
|
||||
}) as typeof fetch,
|
||||
});
|
||||
({ service, endpoint } = context);
|
||||
} else if (provider === "discord") {
|
||||
const applicationId = uniqueDiscordApplicationId();
|
||||
const successfulFetch = fakeDiscordFetch(applicationId);
|
||||
const context = createService(new FakeChatSdkRuntime(), (async (input) => {
|
||||
if (failInventory && new URL(String(input)).pathname.endsWith("/channels"))
|
||||
throw new Error("inventory fixture outage");
|
||||
return successfulFetch(input);
|
||||
}) as typeof fetch);
|
||||
service = context.service;
|
||||
endpoint = await service.create(fixture.companyId, {
|
||||
provider, assignedAgentId: fixture.assignedAgentId,
|
||||
}, "owner-user");
|
||||
await service.configure(endpoint.id, {
|
||||
action: "configure",
|
||||
credentials: { applicationId, botToken: "discord-secret", guildId: "1457808928258658549" },
|
||||
}, "owner-user");
|
||||
} else {
|
||||
const context = await configuredGitHubEndpoint(fixture);
|
||||
({ service, endpoint } = context);
|
||||
context.setSupplementalProviderFetch(async (input) => {
|
||||
if (failInventory && String(input).includes("/app/installations?"))
|
||||
throw new Error("inventory fixture outage");
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
const [before] = await readToolConnectionsById(endpoint.connectionId);
|
||||
failInventory = true;
|
||||
const response = await request(routesApp(db, fixture.companyId, service))
|
||||
.post(`/api/chat-endpoints/${endpoint.id}/setup`)
|
||||
.send({ action: "reconnect" });
|
||||
expect(response.status).toBe(422);
|
||||
expect(response.body).toMatchObject({
|
||||
error: expect.stringContaining("inventory fixture outage"),
|
||||
details: { code: "chat_provider_inventory_failed" },
|
||||
});
|
||||
const [after] = await readToolConnectionsById(endpoint.connectionId);
|
||||
expect(after.credentialSecretRefs).toEqual(before.credentialSecretRefs);
|
||||
});
|
||||
|
||||
it("rejects under-scoped Slack and GitHub apps before saving provider credentials", async () => {
|
||||
const fixture = await seedCompany();
|
||||
const slack = createService(new FakeChatSdkRuntime(), (async (
|
||||
|
||||
@@ -325,6 +325,7 @@ function createRunContextDb(
|
||||
runAgentOrRows: string | Record<string, unknown>[] = ownerAgentId,
|
||||
runId: string = ownerRunId,
|
||||
chatBindings: Array<{ id: string; companyId: string; issueId: string; state: string }> = [],
|
||||
emptyWakeQueue = false,
|
||||
) {
|
||||
const chatBindingQueries: ReturnType<PgDialect["sqlToQuery"]>[] = [];
|
||||
const runRows = Array.isArray(runAgentOrRows)
|
||||
@@ -382,6 +383,9 @@ function createRunContextDb(
|
||||
transaction: async (callback: (tx: typeof dbStub) => Promise<unknown>) => callback(dbStub),
|
||||
select: vi.fn((selection: Record<string, unknown> = {}) => ({
|
||||
from: vi.fn((table: Parameters<typeof getTableName>[0]) => {
|
||||
if (emptyWakeQueue && getTableName(table) === "agent_wakeup_requests") {
|
||||
return buildQuery({}, false, true);
|
||||
}
|
||||
if (getTableName(table) === "issue_thread_interactions") {
|
||||
return { where: vi.fn(() => ({ limit: vi.fn(async () => []) })) };
|
||||
}
|
||||
@@ -2688,6 +2692,149 @@ describe("agent issue mutation checkout ownership", () => {
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("human self-handoff", () => {
|
||||
const runningRun = {
|
||||
id: ownerRunId,
|
||||
companyId,
|
||||
agentId: ownerAgentId,
|
||||
runtimeMode: "legacy",
|
||||
status: "running",
|
||||
contextSnapshot: { issueId },
|
||||
};
|
||||
const handoff = {
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "board-user",
|
||||
status: "in_review",
|
||||
};
|
||||
const handoffApp = (actor: Record<string, unknown>) => createApp(actor,
|
||||
createRunContextDb({}, ownerAgentId, ownerRunId, [], true));
|
||||
|
||||
beforeEach(() => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({
|
||||
status: "in_review",
|
||||
executionRunId: ownerRunId,
|
||||
}));
|
||||
mockHeartbeatService.getRun.mockResolvedValue(runningRun);
|
||||
mockHeartbeatService.cancelRun.mockResolvedValue({ ...runningRun, status: "cancelled" });
|
||||
mockAgentService.resolveByReference.mockImplementation(async (_companyId: string, id: string) => ({
|
||||
ambiguous: false,
|
||||
agent: await mockAgentService.getById(id),
|
||||
}));
|
||||
});
|
||||
|
||||
it.each(["in_progress", "in_review"])("persists a human handoff from %s without waiting for its own slow shutdown", async status => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status, executionRunId: ownerRunId }));
|
||||
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified."));
|
||||
|
||||
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
|
||||
.patch(`/api/issues/${issueId}`).send(handoff);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject(handoff);
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
expect(mockIssueService.update.mock.calls[0]?.[1]).toMatchObject({
|
||||
...handoff,
|
||||
actorRunId: ownerRunId,
|
||||
actorRunStopId: null,
|
||||
});
|
||||
expect(mockLogActivity.mock.calls.find(call => call[1].action === "issue.updated")?.[1]).toMatchObject({
|
||||
action: "issue.updated",
|
||||
runId: ownerRunId,
|
||||
details: handoff,
|
||||
});
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recognizes the caller when the active run is resolved without an issue execution lock", async () => {
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue({ status: "in_review" }));
|
||||
mockHeartbeatService.getActiveRunForAgent.mockResolvedValue(runningRun);
|
||||
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
|
||||
.patch(`/api/issues/${issueId}`).send(handoff);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(res.body).toMatchObject(handoff);
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["edit", "complete", "reclaim"])("rejects the outgoing run's later %s after handing the task to a human", async action => {
|
||||
const app = handoffApp({ ...ownerActor(), source: "agent_jwt" });
|
||||
const handedOff = await request(app).patch(`/api/issues/${issueId}`).send(handoff);
|
||||
expect(handedOff.status, JSON.stringify(handedOff.body)).toBe(200);
|
||||
mockIssueService.getById.mockResolvedValue(makeIssue(handoff));
|
||||
mockIssueService.update.mockClear();
|
||||
|
||||
const res = await request(app).patch(`/api/issues/${issueId}`).send(
|
||||
action === "edit" ? { title: "Late outgoing edit" } :
|
||||
action === "complete" ? { status: "done" } :
|
||||
{ assigneeAgentId: ownerAgentId, assigneeUserId: null },
|
||||
);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(res.body.details).toMatchObject({ code: "issue_handed_off" });
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a queued human response with its current agent", async () => {
|
||||
const res = await request(createApp({ ...ownerActor(), source: "agent_jwt" }))
|
||||
.patch(`/api/issues/${issueId}`).send(handoff);
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
||||
expect(res.body.code).toBe("interaction_response_queued");
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retains assignment permission checks for a different human recipient", async () => {
|
||||
mockAccessService.decide.mockImplementation(async (input: { action: string }) => ({
|
||||
allowed: input.action !== "tasks:assign",
|
||||
action: input.action,
|
||||
reason: "deny_policy_restricted",
|
||||
explanation: "Assignment requires approval.",
|
||||
}));
|
||||
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
|
||||
.patch(`/api/issues/${issueId}`).send({ ...handoff, assigneeUserId: "another-user" });
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(403);
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
expect(mockHeartbeatService.cancelRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["board", "agent_key", "other_run", "native", "other_issue", "other_agent", "other_company"])(
|
||||
"still requires a verified stop for %s reassignment", async scenario => {
|
||||
let actor: Record<string, unknown> = { ...ownerActor(), source: "agent_jwt" };
|
||||
if (scenario === "board") actor = boardActor();
|
||||
if (scenario === "agent_key") actor = ownerActor();
|
||||
const run = {
|
||||
...runningRun,
|
||||
...(scenario === "other_run" ? { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}),
|
||||
...(scenario === "native" ? { runtimeMode: "native" } : {}),
|
||||
...(scenario === "other_issue" ? { contextSnapshot: { issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } } : {}),
|
||||
...(scenario === "other_agent" ? { agentId: peerAgentId } : {}),
|
||||
...(scenario === "other_company" ? { companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" } : {}),
|
||||
};
|
||||
mockHeartbeatService.getRun.mockResolvedValue(run);
|
||||
mockHeartbeatService.cancelRun.mockRejectedValue(new Error("Execution is still stopping; termination has not been verified."));
|
||||
|
||||
const res = await request(handoffApp(actor)).patch(`/api/issues/${issueId}`).send(handoff);
|
||||
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(500);
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledWith(run.id, "Cancelled before issue reassignment",
|
||||
expect.objectContaining({ errorCode: "issue_reassigned" }));
|
||||
expect(mockIssueService.update).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["agent", "unassigned"])("still stops the outgoing run before an %s handoff", async target => {
|
||||
const res = await request(handoffApp({ ...ownerActor(), source: "agent_jwt" }))
|
||||
.patch(`/api/issues/${issueId}`).send({
|
||||
assigneeAgentId: target === "agent" ? peerAgentId : null,
|
||||
assigneeUserId: null,
|
||||
});
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(200);
|
||||
expect(mockHeartbeatService.cancelRun).toHaveBeenCalledTimes(1);
|
||||
expect(mockHeartbeatService.cancelRun.mock.invocationCallOrder[0])
|
||||
.toBeLessThan(mockIssueService.update.mock.invocationCallOrder[0]!);
|
||||
});
|
||||
});
|
||||
|
||||
describe("task watchdog scope grants", () => {
|
||||
const watchdogRunId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaab";
|
||||
const watchdogReportIssueId = "cccccccc-cccc-4ccc-8ccc-cccccccccccd";
|
||||
|
||||
@@ -78,6 +78,7 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", ()
|
||||
async function seed() {
|
||||
const company = await db.insert(companies).values({
|
||||
name: "Attribution Company",
|
||||
issueCounter: 2,
|
||||
issuePrefix: `AT${randomUUID().replaceAll("-", "").slice(0, 6).toUpperCase()}`,
|
||||
}).returning().then((rows) => rows[0]!);
|
||||
const [actorAgent, targetAgent] = await db.insert(agents).values(["Actor", "Target"].map((name) => ({
|
||||
@@ -162,6 +163,27 @@ describeEmbeddedPostgres("issue comment attribution and patch audit routes", ()
|
||||
};
|
||||
}
|
||||
|
||||
it("records the human recipient for REST task and child creation", async () => {
|
||||
const fixture = await seed();
|
||||
const app = await createApp(db, agentActor(fixture));
|
||||
for (const [path, action] of [
|
||||
[`/api/companies/${fixture.company.id}/issues`, "issue.created"],
|
||||
[`/api/issues/${fixture.issue.id}/children`, "issue.child_created"],
|
||||
]) {
|
||||
const response = await request(app).post(path!).send({
|
||||
title: "Task for the human", status: "todo", assigneeUserId: fixture.responsibleUserId,
|
||||
});
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(201);
|
||||
const events = await db.select().from(activityLog).where(and(
|
||||
eq(activityLog.entityId, response.body.id), eq(activityLog.action, action!),
|
||||
));
|
||||
expect(events).toEqual([expect.objectContaining({
|
||||
actorType: "agent", actorId: fixture.actorAgent.id,
|
||||
details: expect.objectContaining({ assigneeAgentId: null, assigneeUserId: fixture.responsibleUserId }),
|
||||
})]);
|
||||
}
|
||||
});
|
||||
|
||||
it("attributes foreign-issue agent comments and emits complete agent and human PATCH receipts", async () => {
|
||||
const fixture = await seed();
|
||||
const agentApp = await createApp(db, agentActor(fixture));
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
issueAccessGrants,
|
||||
issuePlanDecompositions,
|
||||
issueReadStates,
|
||||
issueRecoveryActions,
|
||||
issueRelations,
|
||||
issueThreadInteractions,
|
||||
issueWorkProducts,
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
readIssueCommentRunLogText,
|
||||
} from "../services/issues.ts";
|
||||
import { getRunLogStore } from "../services/run-log-store.js";
|
||||
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
||||
import { settleUnrecoverableExecutions } from "../services/execution-recovery-resolution.js";
|
||||
import {
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_CODE,
|
||||
WORKSPACE_WORKTREE_REQUIRES_PROJECT_MESSAGE,
|
||||
@@ -7048,6 +7051,7 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(instanceSettings);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
@@ -7136,6 +7140,67 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti
|
||||
return { issueId, assigneeAgentId, actorAgentId, staleRunId, actorRunId };
|
||||
}
|
||||
|
||||
it.each(["succeeded", "failed"])("preserves a human handoff when the outgoing legacy run later %s", async status => {
|
||||
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
||||
const original = await svc.getById(seeded.issueId);
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId: original!.companyId,
|
||||
principalType: "user",
|
||||
principalId: "handoff-reviewer",
|
||||
status: "active",
|
||||
});
|
||||
await db.update(heartbeatRuns).set({
|
||||
contextSnapshot: { issueId: seeded.issueId },
|
||||
}).where(eq(heartbeatRuns.id, seeded.actorRunId));
|
||||
await svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId);
|
||||
|
||||
const handoff = await svc.update(seeded.issueId, {
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "handoff-reviewer",
|
||||
status: "in_review",
|
||||
actorAgentId: seeded.actorAgentId,
|
||||
actorRunId: seeded.actorRunId,
|
||||
});
|
||||
expect(handoff).toMatchObject({
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "handoff-reviewer",
|
||||
status: "in_review",
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
});
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.actorRunId));
|
||||
expect(run!.status).toBe("running");
|
||||
await expect(svc.assertCheckoutOwner(seeded.issueId, seeded.actorAgentId, seeded.actorRunId))
|
||||
.rejects.toMatchObject({ status: 409 });
|
||||
await expect(svc.update(seeded.issueId, {
|
||||
title: "Late outgoing edit",
|
||||
actorAgentId: seeded.actorAgentId,
|
||||
actorRunId: seeded.actorRunId,
|
||||
})).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } });
|
||||
|
||||
await terminalizeLegacyExecution({
|
||||
db,
|
||||
run: run!,
|
||||
status,
|
||||
fromStatuses: ["running"],
|
||||
reconcileIfNeeded: true,
|
||||
patch: { finishedAt: new Date(), ...(status === "failed" ? { errorCode: "process_lost" } : {}) },
|
||||
});
|
||||
await settleUnrecoverableExecutions(db);
|
||||
await expect(svc.update(seeded.issueId, {
|
||||
status: "done",
|
||||
actorAgentId: seeded.actorAgentId,
|
||||
actorRunId: seeded.actorRunId,
|
||||
})).rejects.toMatchObject({ status: 403, details: { code: "issue_handed_off" } });
|
||||
expect(await svc.getById(seeded.issueId)).toMatchObject({
|
||||
status: "in_review",
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: "handoff-reviewer",
|
||||
});
|
||||
expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, seeded.issueId)))
|
||||
.toHaveLength(0);
|
||||
});
|
||||
|
||||
it("lets the current assignee adopt a stale terminal checkout owner", async () => {
|
||||
const seeded = await seedOwnershipIssue({ checkoutStatus: "failed" });
|
||||
|
||||
|
||||
@@ -977,14 +977,19 @@ describeEmbeddedPostgres(
|
||||
const handoff = await request(directedAgent).patch(`/api/issues/${created.body.id}`)
|
||||
.send({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user", comment: "Ready for human review" });
|
||||
expect(handoff.status, JSON.stringify(handoff.body)).toBe(200);
|
||||
const [stopped] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id));
|
||||
expect(stopped).toMatchObject({ status: "cancelled", errorCode: "issue_reassigned" });
|
||||
expect(handoff.body).toMatchObject({ status: "in_review", assigneeAgentId: null, assigneeUserId: "board-user" });
|
||||
const [outgoing] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, directRun.id));
|
||||
expect(outgoing).toMatchObject({ status: "running", errorCode: null });
|
||||
const lateEdit = await request(directedAgent).patch(`/api/issues/${created.body.id}`)
|
||||
.send({ description: "Late outgoing edit" });
|
||||
expect([403, 404]).toContain(lateEdit.status);
|
||||
const returned = await request(board).patch(`/api/issues/${created.body.id}`)
|
||||
.send({ status: "backlog", assigneeAgentId: fixture.agents.lowTrust.id, assigneeUserId: null });
|
||||
expect(returned.status, JSON.stringify(returned.body)).toBe(200);
|
||||
// Reassignment back does not revive the cancelled run's human request.
|
||||
const staleRead = await request(directedAgent).get(`/api/issues/${created.body.id}`);
|
||||
expect([403, 404]).toContain(staleRead.status);
|
||||
const [humanRequest] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wake.id));
|
||||
expect(humanRequest.status).toBe("cancelled");
|
||||
const reassignedRead = await request(directedAgent).get(`/api/issues/${created.body.id}`);
|
||||
expect(reassignedRead.status, JSON.stringify(reassignedRead.body)).toBe(200);
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ export async function assertAgentRunWriteAllowed(tx: Db, companyId: string, acto
|
||||
stopId?: string | null;
|
||||
}) {
|
||||
if (!actor.agentId || !actor.runId) return;
|
||||
const [run] = await tx.select({ status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson })
|
||||
const [run] = await tx.select({ status: heartbeatRuns.status, resultJson: heartbeatRuns.resultJson,
|
||||
runtimeMode: heartbeatRuns.runtimeMode, companyId: heartbeatRuns.companyId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns).where(and(eq(heartbeatRuns.id, actor.runId),
|
||||
eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, actor.agentId)))
|
||||
.for("share");
|
||||
@@ -28,4 +30,5 @@ export async function assertAgentRunWriteAllowed(tx: Db, companyId: string, acto
|
||||
if (agentRunWritesRevoked(run) && !stoppedForThisMutation) {
|
||||
throw forbidden("This run was cancelled", { code: "agent_run_cancelled" });
|
||||
}
|
||||
return run;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { forbidden } from "./errors.js";
|
||||
|
||||
export function assertLegacyRunTaskOwnership(
|
||||
run: {
|
||||
companyId: string;
|
||||
runtimeMode: string;
|
||||
contextSnapshot?: Record<string, unknown> | null;
|
||||
} | null | undefined,
|
||||
issue: {
|
||||
id: string;
|
||||
companyId: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
},
|
||||
) {
|
||||
if (run?.runtimeMode === "legacy" && run.companyId === issue.companyId &&
|
||||
run.contextSnapshot?.issueId === issue.id &&
|
||||
issue.assigneeAgentId === null && issue.assigneeUserId) {
|
||||
throw forbidden("This task is assigned to a human; the outgoing run can no longer change it", {
|
||||
code: "issue_handed_off",
|
||||
issueId: issue.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parseObject } from "../adapters/utils.js";
|
||||
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
|
||||
import { enqueueFastResponse } from "../services/fast-responses.js";
|
||||
import { hasRequiredWorkspaceRecovery, LEGACY_WORKSPACE_RECOVERY_SCHEMA } from "../services/workspace-restore-recovery-state.js";
|
||||
import { hasUnverifiedNativeWorkspaceFinalizationOwner } from "../services/native-runtime/native-workspace-finalization-recovery-state.js";
|
||||
@@ -661,7 +662,7 @@ const attachmentArtifactMetadataInputSchema = z
|
||||
.passthrough();
|
||||
|
||||
function buildCreateIssueActivityStatusDetails(
|
||||
issue: { assigneeAgentId: string | null; status: string },
|
||||
issue: { assigneeAgentId: string | null; assigneeUserId: string | null; status: string },
|
||||
res: Response,
|
||||
) {
|
||||
const statusDefault = res.locals.createIssueStatusDefault as
|
||||
@@ -669,6 +670,8 @@ function buildCreateIssueActivityStatusDetails(
|
||||
const assignmentWakeSkipped =
|
||||
!issue.assigneeAgentId || issue.status === "backlog";
|
||||
return {
|
||||
assigneeAgentId: issue.assigneeAgentId,
|
||||
assigneeUserId: issue.assigneeUserId,
|
||||
status: issue.status,
|
||||
statusDefaulted: statusDefault?.defaulted ?? false,
|
||||
statusDefaultReason: statusDefault?.reason ?? "explicit",
|
||||
@@ -5632,6 +5635,11 @@ export function issueRoutes(
|
||||
res.status(403).json({ error: "Agent authentication required" });
|
||||
return false;
|
||||
}
|
||||
if (req.actor.source === "agent_jwt" && req.actor.runId &&
|
||||
issue.assigneeAgentId === null && issue.assigneeUserId) {
|
||||
const run = await heartbeat.getRun(req.actor.runId);
|
||||
if (run?.agentId === actorAgentId) assertLegacyRunTaskOwnership(run, issue);
|
||||
}
|
||||
// Task-watchdog runs receive a scoped *grant* to mutate issues inside the
|
||||
// watched subtree. This must be evaluated before the base assignee-ownership
|
||||
// boundary below: that boundary denies an agent mutating an issue owned by a
|
||||
@@ -14241,7 +14249,18 @@ export function issueRoutes(
|
||||
agentId: existing.assigneeAgentId,
|
||||
});
|
||||
const runToStopForReassignment = await resolveActiveIssueRun(existing);
|
||||
if (runToStopForReassignment) {
|
||||
const isHumanSelfHandoff =
|
||||
req.actor.type === "agent" &&
|
||||
req.actor.source === "agent_jwt" &&
|
||||
nextAssigneeAgentId === null &&
|
||||
typeof nextAssigneeUserId === "string" &&
|
||||
req.actor.agentId === existing.assigneeAgentId &&
|
||||
runToStopForReassignment?.runtimeMode === "legacy" &&
|
||||
runToStopForReassignment.id === req.actor.runId &&
|
||||
runToStopForReassignment.agentId === req.actor.agentId &&
|
||||
runToStopForReassignment.companyId === existing.companyId &&
|
||||
runToStopForReassignment.contextSnapshot?.issueId === existing.id;
|
||||
if (runToStopForReassignment && !isHumanSelfHandoff) {
|
||||
const cancelled = await heartbeat.cancelRun(
|
||||
runToStopForReassignment.id,
|
||||
"Cancelled before issue reassignment",
|
||||
|
||||
@@ -910,6 +910,7 @@ const chatPrincipalLinkResponseSchema = z
|
||||
externalDetail: z.string(),
|
||||
paperclipUserId: z.string().nullable(),
|
||||
paperclipUserLabel: z.string().nullable(),
|
||||
paperclipUserImage: z.string().nullable(),
|
||||
status: chatIdentityLinkStatusSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import {
|
||||
verifyDiscordBot,
|
||||
} from "../../chat-discord.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
export async function verifyDiscordCredentials(
|
||||
credentials: Record<string, string>,
|
||||
fetchImpl: typeof fetch,
|
||||
redactError: (error: unknown) => string,
|
||||
): Promise<VerifiedProviderIdentity> {
|
||||
const identity = await verifyDiscordBot({
|
||||
applicationId: credentials.applicationId,
|
||||
botToken: credentials.botToken,
|
||||
fetch: fetchImpl,
|
||||
guildId: credentials.guildId,
|
||||
}).catch((error) => {
|
||||
throw unprocessable(redactError(error), {
|
||||
code: "chat_provider_permissions_missing",
|
||||
provider: "discord",
|
||||
});
|
||||
});
|
||||
return identity;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
listDiscordBotChannels,
|
||||
} from "../../chat-discord.js";
|
||||
|
||||
import type { PreparedProviderInventory } from "../runtime-types.js";
|
||||
|
||||
export async function prepareDiscordInventory(credentials: Record<string,string>, fetchImpl: typeof fetch): Promise<PreparedProviderInventory> {
|
||||
return {
|
||||
credentials,
|
||||
inventory: await listDiscordBotChannels({
|
||||
botUserId: credentials.applicationId,
|
||||
botToken: credentials.botToken,
|
||||
fetch: fetchImpl,
|
||||
guildId: credentials.guildId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
import { createSetupProbes } from "../setup-probes.js";
|
||||
|
||||
import type { EndpointRow } from "../runtime-types.js";
|
||||
|
||||
import { createChatResourceReconciliation } from "../resource-reconciliation.js";
|
||||
|
||||
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import { chatGitHubRegistrations } from "@paperclipai/db";
|
||||
|
||||
import { githubChatWizardService } from "../../chat-github-wizard.js";
|
||||
import { registerGitHubBotCloudIngress } from "../../chat-github-cloud-ingress.js";
|
||||
import { githubChatRegistrationService } from "../../chat-github-registration.js";
|
||||
|
||||
import { githubAppJwt } from "../../chat-github-client.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpoint,
|
||||
ChatProvider,
|
||||
ConfigureChatEndpointInput,
|
||||
ToolCredentialSecretRef,
|
||||
UpdateChatEndpointInput,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
HttpError,
|
||||
conflict,
|
||||
forbidden,
|
||||
notFound,
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../../activity-log.js";
|
||||
|
||||
import {
|
||||
listGitHubAppWebhookDeliveries,
|
||||
requestGitHubAppWebhookRedelivery,
|
||||
} from "../../chat-github-webhook-config.js";
|
||||
|
||||
import type { EndpointRecord, PreparedProviderInventory } from "../runtime-types.js";
|
||||
|
||||
type GitHubAppSetupOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
normalizedCredentials: (endpoint: EndpointRow, supplied: Record<string, string> | undefined) => Promise<Record<string, string>>;
|
||||
verifyCredentials: (provider: ChatProvider, credentials: Record<string, string>) => Promise<VerifiedProviderIdentity>;
|
||||
nativeBotIdentityMatches: (provider: ChatProvider, current: VerifiedProviderIdentity, incoming: VerifiedProviderIdentity) => boolean;
|
||||
assertNativeBotIdentityAvailable: (endpoint: EndpointRow, identity: VerifiedProviderIdentity) => Promise<void>;
|
||||
isNativeBotIdentityUniqueViolation: (error: unknown) => boolean;
|
||||
nativeBotIdentityConflict: (provider: ChatProvider) => HttpError;
|
||||
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise<ToolCredentialSecretRef[]>;
|
||||
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
|
||||
fetchImpl: typeof fetch;
|
||||
get: (endpointId: string) => Promise<ChatEndpoint>;
|
||||
getPublicBaseUrl: () => string | null;
|
||||
getWebhookPublicBaseUrl: () => string | null;
|
||||
prepareProviderInventory: (endpoint: EndpointRow, credentials: Record<string, string>) => Promise<PreparedProviderInventory>;
|
||||
reconcileProviderResourceRows: ReturnType<typeof createChatResourceReconciliation>["reconcileProviderResourceRows"];
|
||||
listResources: ReturnType<typeof createChatResourceReconciliation>["listResources"];
|
||||
replaceResources: ReturnType<typeof createChatResourceReconciliation>["replaceResources"];
|
||||
update: (endpointId: string, input: UpdateChatEndpointInput, actorUserId?: string | null) => Promise<ChatEndpoint>;
|
||||
configure: (endpointId: string, input: ConfigureChatEndpointInput, actorUserId?: string | null) => Promise<ChatEndpoint>;
|
||||
test: ReturnType<typeof createSetupProbes>["test"];
|
||||
githubWizardOrigin?: string | null;
|
||||
handleWebhook: (publicId: string, provider: "github", request: Request) => Promise<Response>;
|
||||
};
|
||||
|
||||
export function createGitHubAppSetup(db: Db, options: GitHubAppSetupOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, normalizedCredentials, verifyCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, isNativeBotIdentityUniqueViolation, nativeBotIdentityConflict, persistCredentials, resolveCredentials, fetchImpl, get, getPublicBaseUrl, getWebhookPublicBaseUrl, prepareProviderInventory, reconcileProviderResourceRows, listResources, replaceResources, update, configure, test, handleWebhook } = options;
|
||||
|
||||
|
||||
async function storeGitHubApp(endpointId: string, userId: string, credentials: Record<string, string>, source: "manifest" | "existing_app" = "existing_app") {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
||||
await withCredentialMutationLease(initial.endpoint, async credentialLease => {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record || ["archived", "active", "paused"].includes(record.endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot");
|
||||
const normalized = await normalizedCredentials(record.endpoint, credentials);
|
||||
const identity = await verifyCredentials("github", normalized);
|
||||
if (record.endpoint.botExternalId && !nativeBotIdentityMatches("github", record.endpoint, identity)) throw conflict("This App belongs to a different bot. Create a new connection instead.");
|
||||
await assertNativeBotIdentityAvailable(record.endpoint, identity);
|
||||
// Claim the globally unique App identity before writing credentials.
|
||||
// A losing concurrent registration must never persist usable secrets.
|
||||
if (!record.endpoint.botExternalId) {
|
||||
try {
|
||||
await db.transaction(async tx => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const [current] = await tx.select({ setup: chatEndpoints.setup }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
|
||||
const [resource] = await tx.select({ id: chatEndpointResources.id }).from(chatEndpointResources).where(eq(chatEndpointResources.endpointId, endpointId)).limit(1);
|
||||
// A fresh manual App has no registration session. Record its first
|
||||
// import before vault writes so interrupted credential storage can resume.
|
||||
const initialImport = !current?.setup.github?.repositorySelectionSaved && !resource;
|
||||
const claimed = await tx.update(chatEndpoints).set({ status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel,
|
||||
setup: sql`jsonb_set(${chatEndpoints.setup}, '{github}', coalesce(${chatEndpoints.setup}->'github', '{}'::jsonb) || ${JSON.stringify({ initialSetupPending: true, ...(initialImport ? { initialRepositoryImportPending: true } : {}) })}::jsonb)`,
|
||||
healthMessage: "Complete GitHub App setup", updatedAt: new Date() }).where(and(eq(chatEndpoints.id, endpointId), isNull(chatEndpoints.botExternalId))).returning({ id: chatEndpoints.id });
|
||||
if (claimed.length !== 1) throw conflict("The bot changed during registration");
|
||||
});
|
||||
} catch (error) {
|
||||
if (isNativeBotIdentityUniqueViolation(error)) throw nativeBotIdentityConflict("github");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await persistCredentials(record.endpoint, normalized, credentialLease, userId);
|
||||
const slug = identity.botUsername?.replace(/\[bot\]$/, "");
|
||||
if (!slug || !/^[a-z0-9-]+$/.test(slug)) throw unprocessable("GitHub did not identify this App");
|
||||
await db.transaction(async tx => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const [current] = await tx.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)).for("update");
|
||||
if (!current || ["archived", "active", "paused"].includes(current.status)) throw conflict("Use the reconnect flow for an existing active bot");
|
||||
await tx.update(chatEndpoints).set({
|
||||
status: "attention", botExternalId: identity.botExternalId, botUsername: identity.botUsername,
|
||||
botDisplayName: identity.botLabel, providerAccountId: identity.providerAccountId, providerAccountLabel: identity.providerAccountLabel,
|
||||
setup: { ...current.setup, github: { ...current.setup.github, appOwnerType: identity.githubOwnerType, appOwnerLogin: identity.githubOwnerLogin, stage: "install", appSlug: slug, installationUrl: `https://github.com/apps/${slug}/installations/new`, registrationStatus: "completed" } },
|
||||
healthMessage: "Install the App on GitHub to continue", updatedAt: new Date(),
|
||||
}).where(eq(chatEndpoints.id, endpointId));
|
||||
if (source === "existing_app") {
|
||||
// Explicit recovery uses the existing-App identity path. Only drop the
|
||||
// manifest requirement after the same App's credentials are vaulted;
|
||||
// leave registration incomplete so advance repairs and verifies delivery.
|
||||
await tx.update(chatGitHubRegistrations).set({
|
||||
handoff: sql`${chatGitHubRegistrations.handoff} - 'manifestClaimId'`,
|
||||
}).where(and(
|
||||
eq(chatGitHubRegistrations.companyId, record.endpoint.companyId),
|
||||
eq(chatGitHubRegistrations.endpointId, endpointId),
|
||||
eq(chatGitHubRegistrations.userId, userId),
|
||||
inArray(chatGitHubRegistrations.status, ["pending", "exchanging", "failed"]),
|
||||
sql`${chatGitHubRegistrations.handoff} ? 'manifestClaimId'`,
|
||||
));
|
||||
}
|
||||
await logActivity(tx as unknown as Db, { companyId: record.endpoint.companyId, actorType: "user", actorId: userId, action: "chat_github.app_connected", entityType: "tool_connection", entityId: record.endpoint.connectionId, details: { endpointId, appId: identity.botExternalId } });
|
||||
});
|
||||
});
|
||||
// Manifest registration can send its ping before the credential exchange
|
||||
// returns. Request a provider-signed replay after vaulting the secret.
|
||||
const stored = await endpointRecord(endpointId);
|
||||
const credentialsNow = await resolveCredentials(stored!.endpoint);
|
||||
try {
|
||||
const appToken = githubAppJwt(credentialsNow.appId, credentialsNow.privateKey);
|
||||
const history = await listGitHubAppWebhookDeliveries({ fetch: fetchImpl, appToken });
|
||||
const ping = history.deliveries.find(delivery => delivery.event === "ping");
|
||||
if (ping) await requestGitHubAppWebhookRedelivery({ fetch: fetchImpl, appToken, deliveryId: ping.id });
|
||||
} catch {
|
||||
// Registration succeeded. The verify screen exposes signed-delivery
|
||||
// recovery rather than reporting credentials as failed or exchanging again.
|
||||
}
|
||||
return get(endpointId);
|
||||
}
|
||||
|
||||
const githubRegistration = githubChatRegistrationService(db, {
|
||||
publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl,
|
||||
storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest"); },
|
||||
});
|
||||
|
||||
async function refreshGitHubRepositories(endpointId: string, userId: string) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial || initial.endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
||||
await withCredentialMutationLease(initial.endpoint, async lease => {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("GitHub bot not found");
|
||||
const credentials = await resolveCredentials(record.endpoint);
|
||||
const prepared = await prepareProviderInventory(record.endpoint, credentials);
|
||||
if (prepared.credentials.installationId !== credentials.installationId) await persistCredentials(record.endpoint, prepared.credentials, lease, userId);
|
||||
if (prepared.inventory) await reconcileProviderResourceRows(record.endpoint, prepared.inventory, lease);
|
||||
await lease.assertOwned();
|
||||
await db.update(chatEndpoints).set({ setup: { ...record.endpoint.setup, github: { ...record.endpoint.setup.github, stage: "repositories", managementUrl: prepared.managementUrl } }, updatedAt: new Date() }).where(eq(chatEndpoints.id, endpointId));
|
||||
});
|
||||
return listResources(endpointId);
|
||||
}
|
||||
|
||||
const githubWizard = githubChatWizardService(db, {
|
||||
origin: () => getPublicBaseUrl() ?? options.githubWizardOrigin ?? null, fetch: fetchImpl, startDirect: githubRegistration.start, completeDirect: githubRegistration.complete, resumeDirect: githubRegistration.resumeRegistration,
|
||||
storeApp: (id, userId, credentials) => storeGitHubApp(id, userId, credentials, "manifest"),
|
||||
storeCredentials: async (id, userId, additional) => {
|
||||
const record = await endpointRecord(id);
|
||||
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
||||
await withCredentialMutationLease(record.endpoint, async lease => {
|
||||
const current = await endpointRecord(id);
|
||||
if (!current || ["archived", "revoked", "paused"].includes(current.endpoint.status)) throw conflict("GitHub App is unavailable");
|
||||
const credentials = await resolveCredentials(current.endpoint);
|
||||
await persistCredentials(current.endpoint, { ...credentials, ...additional }, lease, userId);
|
||||
});
|
||||
},
|
||||
refreshRepositories: refreshGitHubRepositories, resources: listResources, replaceResources,
|
||||
configure: async (id, userId) => {
|
||||
await update(id, { allowGroupChats: true }, userId);
|
||||
return configure(id, { action: "configure" }, userId);
|
||||
},
|
||||
finish: (id, userId) => test(id, { optionalSlackTestForUser: userId }),
|
||||
});
|
||||
const unregisterGitHubCloudIngress = registerGitHubBotCloudIngress(
|
||||
db,
|
||||
async (endpoint, request) => {
|
||||
const record = await endpointRecord(endpoint.id);
|
||||
if (
|
||||
!record ||
|
||||
record.endpoint.companyId !== endpoint.companyId ||
|
||||
record.endpoint.connectionId !== endpoint.connectionId ||
|
||||
record.endpoint.assignedAgentId !== endpoint.assignedAgentId ||
|
||||
record.endpoint.setup.github?.cloudRegistrationId !==
|
||||
endpoint.setup.github?.cloudRegistrationId
|
||||
)
|
||||
throw forbidden("GitHub Cloud event binding changed");
|
||||
// A manifest ping may arrive before its webhook verifier reaches the vault.
|
||||
// Keep the sealed delivery queued until the stack can authenticate it.
|
||||
if (!record.credentialSecretRefs.some((ref) => ref.configPath === "credentials.webhookSecret"))
|
||||
throw conflict("GitHub webhook credentials are not ready");
|
||||
// Preserve the exact provider payload and signature. Authentication, lifecycle
|
||||
// checks and durable admission all run in the stack's canonical ingress.
|
||||
const response = await handleWebhook(
|
||||
record.endpoint.publicId,
|
||||
"github",
|
||||
request,
|
||||
);
|
||||
if ([400, 401, 403, 404, 413, 422].includes(response.status)) return; // Discard hostile transport without retrying it indefinitely.
|
||||
if (!response.ok)
|
||||
throw conflict("GitHub Cloud ingress could not be durably accepted");
|
||||
},
|
||||
);
|
||||
return { storeGitHubApp, githubRegistration, refreshGitHubRepositories, githubWizard, unregisterGitHubCloudIngress };
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { githubAppJwt } from "../../chat-github-client.js";
|
||||
|
||||
import {
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
export const REQUIRED_GITHUB_EVENTS = [
|
||||
"issue_comment",
|
||||
"pull_request_review_comment",
|
||||
] as const;
|
||||
|
||||
export const REQUIRED_GITHUB_PERMISSIONS = {
|
||||
issues: "write",
|
||||
metadata: "read",
|
||||
pull_requests: "write",
|
||||
} as const;
|
||||
|
||||
export const UNAVOIDABLE_GITHUB_EVENTS = [
|
||||
"github_app_authorization",
|
||||
"installation",
|
||||
"installation_repositories",
|
||||
] as const;
|
||||
|
||||
export async function verifyGitHubCredentials(
|
||||
credentials: Record<string, string>,
|
||||
fetchImpl: typeof fetch,
|
||||
PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number,
|
||||
): Promise<VerifiedProviderIdentity> {
|
||||
const token = githubAppJwt(credentials.appId, credentials.privateKey);
|
||||
const response = await fetchImpl("https://api.github.com/app", {
|
||||
signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS),
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
authorization: `Bearer ${token}`,
|
||||
"x-github-api-version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
id?: number;
|
||||
login?: string;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
message?: string;
|
||||
owner?: { login?: string; type?: string };
|
||||
permissions?: Record<string, string>;
|
||||
events?: string[];
|
||||
};
|
||||
if (!response.ok)
|
||||
throw unprocessable(
|
||||
`GitHub rejected the app credentials: ${result.message ?? response.status}`,
|
||||
);
|
||||
const missingPermissions = Object.entries(REQUIRED_GITHUB_PERMISSIONS)
|
||||
.filter(
|
||||
([permission, access]) => result.permissions?.[permission] !== access,
|
||||
)
|
||||
.map(([permission]) => permission);
|
||||
const excessivePermissions = Object.keys(result.permissions ?? {}).filter(
|
||||
(permission) => !(permission in REQUIRED_GITHUB_PERMISSIONS) && !((permission === "contents" && result.permissions?.contents === "read") || (permission === "checks" && result.permissions?.checks === "write")),
|
||||
);
|
||||
const configuredEvents = new Set(result.events ?? []);
|
||||
const missingEvents = REQUIRED_GITHUB_EVENTS.filter(
|
||||
(event) => !configuredEvents.has(event),
|
||||
);
|
||||
const allowedEvents = new Set<string>([
|
||||
...REQUIRED_GITHUB_EVENTS,
|
||||
...UNAVOIDABLE_GITHUB_EVENTS,
|
||||
"pull_request",
|
||||
"pull_request_review",
|
||||
"issues",
|
||||
]);
|
||||
const excessiveEvents = [...configuredEvents].filter(
|
||||
(event) => !allowedEvents.has(event),
|
||||
);
|
||||
if (
|
||||
missingPermissions.length > 0 ||
|
||||
excessivePermissions.length > 0 ||
|
||||
missingEvents.length > 0 ||
|
||||
excessiveEvents.length > 0
|
||||
) {
|
||||
throw unprocessable(
|
||||
[
|
||||
missingPermissions.length > 0
|
||||
? `GitHub App needs the documented minimum access for: ${missingPermissions.join(", ")}`
|
||||
: null,
|
||||
excessivePermissions.length > 0
|
||||
? `GitHub App has broader permissions than Paperclip needs: ${excessivePermissions.join(", ")}`
|
||||
: null,
|
||||
missingEvents.length > 0
|
||||
? `GitHub App must subscribe to: ${missingEvents.join(", ")}`
|
||||
: null,
|
||||
excessiveEvents.length > 0
|
||||
? `GitHub App subscribes to broader events than Paperclip needs: ${excessiveEvents.join(", ")}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(". "),
|
||||
{
|
||||
code: "chat_provider_permissions_missing",
|
||||
provider: "github",
|
||||
missingPermissions,
|
||||
excessivePermissions,
|
||||
missingEvents,
|
||||
excessiveEvents,
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
githubOwnerType: result.owner?.type === "Organization" ? "organization" : result.owner?.type === "User" ? "personal" : undefined,
|
||||
githubOwnerLogin: result.owner?.login,
|
||||
providerAccountId: result.owner?.login,
|
||||
providerAccountLabel: result.owner?.login ?? result.name,
|
||||
// The App registration id is the stable, immutable endpoint identity.
|
||||
// The adapter discovers the separate bot-user id while initializing.
|
||||
botExternalId:
|
||||
typeof result.id === "number" && Number.isFinite(result.id)
|
||||
? String(result.id)
|
||||
: undefined,
|
||||
botUsername: result.slug ? `${result.slug}[bot]` : undefined,
|
||||
botLabel: result.name ?? result.slug,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
import type { EndpointRow, DbTransaction, LifecycleRuntimeFence, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
import { createChatResourceReconciliation } from "../resource-reconciliation.js";
|
||||
|
||||
import { notifyChatDeliveryWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpointResources,
|
||||
chatEndpoints,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpointSetupState,
|
||||
ChatProvider,
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
notFound,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import { redactSensitiveText } from "../../../redaction.js";
|
||||
|
||||
import {
|
||||
type ChatProviderLifecycleEffect,
|
||||
} from "../../chat-provider-lifecycle.js";
|
||||
|
||||
import type { EndpointRecord, PreparedProviderInventory } from "../runtime-types.js";
|
||||
|
||||
type GitHubInstallationLifecycleOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
||||
verifyCredentials: (provider: ChatProvider, credentials: Record<string, string>) => Promise<VerifiedProviderIdentity>;
|
||||
legacyGitHubLabelsMatchVerifiedCredentials: (current: VerifiedProviderIdentity, verified: VerifiedProviderIdentity) => boolean;
|
||||
nativeBotIdentityMatches: (provider: ChatProvider, current: VerifiedProviderIdentity, incoming: VerifiedProviderIdentity) => boolean;
|
||||
assertNativeBotIdentityAvailable: (endpoint: EndpointRow, identity: VerifiedProviderIdentity) => Promise<void>;
|
||||
prepareProviderInventory: (endpoint: EndpointRow, credentials: Record<string, string>) => Promise<PreparedProviderInventory>;
|
||||
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise<ToolCredentialSecretRef[]>;
|
||||
reconcileProviderResourceRows: ReturnType<typeof createChatResourceReconciliation>["reconcileProviderResourceRows"];
|
||||
redactError: (error: unknown) => string;
|
||||
normalizedLifecycleWithRuntimeFence: (delivery: typeof chatDeliveries.$inferSelect, fence: LifecycleRuntimeFence) => Record<string, unknown>;
|
||||
MAX_ERROR_TEXT: number;
|
||||
};
|
||||
|
||||
type GitHubInstallationLifecycleInput = {
|
||||
currentEndpoint: EndpointRow;
|
||||
effect: ChatProviderLifecycleEffect;
|
||||
credentialLease: CredentialMutationLeaseGuard;
|
||||
admittedRuntimeContext: LifecycleRuntimeFence;
|
||||
claimed: { id: string };
|
||||
candidate: typeof chatDeliveries.$inferSelect;
|
||||
onFailurePersisted: () => void;
|
||||
};
|
||||
|
||||
type GitHubInstallationLifecycleOutcome = {
|
||||
refreshRuntimeAfterLifecycle: boolean;
|
||||
stopRuntimeAfterLifecycle: boolean;
|
||||
};
|
||||
|
||||
export function createGitHubInstallationLifecycle(db: Db, options: GitHubInstallationLifecycleOptions) {
|
||||
const { endpointRecord, resolveCredentials, runtimeCallbackEndpoint, runtimeGeneration, credentialFingerprint, verifyCredentials, legacyGitHubLabelsMatchVerifiedCredentials, nativeBotIdentityMatches, assertNativeBotIdentityAvailable, prepareProviderInventory, persistCredentials, reconcileProviderResourceRows, redactError, normalizedLifecycleWithRuntimeFence, MAX_ERROR_TEXT } = options;
|
||||
|
||||
async function applyGitHubInstallationLifecycle(input: GitHubInstallationLifecycleInput): Promise<GitHubInstallationLifecycleOutcome> {
|
||||
const { currentEndpoint, effect, credentialLease, admittedRuntimeContext, claimed, candidate } = input;
|
||||
let refreshRuntimeAfterLifecycle = false;
|
||||
let stopRuntimeAfterLifecycle = false;
|
||||
try {
|
||||
const currentCredentials =
|
||||
await resolveCredentials(currentEndpoint);
|
||||
const lifecycleInstallationId =
|
||||
effect.kind === "endpoint" &&
|
||||
typeof effect.metadata?.installationId === "string"
|
||||
? effect.metadata.installationId
|
||||
: null;
|
||||
const removesCurrentInstallation =
|
||||
effect.kind === "endpoint" &&
|
||||
effect.availability === "revoked" &&
|
||||
lifecycleInstallationId !== null &&
|
||||
lifecycleInstallationId === currentCredentials.installationId;
|
||||
const currentInstallationRemovalReason =
|
||||
removesCurrentInstallation && effect.kind === "endpoint"
|
||||
? effect.reason
|
||||
: null;
|
||||
if (removesCurrentInstallation) {
|
||||
// A signed deletion for the installation currently bound to
|
||||
// this endpoint is authoritative provider truth. Inventory now
|
||||
// correctly contains zero active installations, so treating its
|
||||
// empty result as a transient discovery failure would strand the
|
||||
// endpoint in attention and ask GitHub to redeliver forever.
|
||||
// Keep the App key and webhook secret so a later installation
|
||||
// event can recover this same immutable bot identity.
|
||||
const revoked = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
currentEndpoint.id,
|
||||
admittedRuntimeContext,
|
||||
["verifying", "active", "attention", "revoked"],
|
||||
);
|
||||
const revokedAt = new Date();
|
||||
if (!current) {
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [filtered] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "filtered",
|
||||
processedAt: revokedAt,
|
||||
nextAttemptAt: null,
|
||||
redactedError:
|
||||
"GitHub installation deletion belonged to a superseded runtime",
|
||||
updatedAt: revokedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, claimed.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
eq(chatDeliveries.attempts, candidate.attempts + 1),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (!filtered) {
|
||||
throw new Error(
|
||||
"GitHub installation deletion ownership changed before stale settlement",
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
return false;
|
||||
}
|
||||
const reason = redactSensitiveText(
|
||||
currentInstallationRemovalReason ??
|
||||
"GitHub App installation was removed",
|
||||
).slice(0, MAX_ERROR_TEXT);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "revoked",
|
||||
healthMessage: reason,
|
||||
lastError: reason,
|
||||
lastEventAt: revokedAt,
|
||||
setup: {
|
||||
...current.setup,
|
||||
runtimeGeneration: runtimeGeneration(current.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
updatedAt: revokedAt,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, current.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus: "failed",
|
||||
healthMessage: reason,
|
||||
lastError: reason,
|
||||
healthCheckedAt: revokedAt,
|
||||
updatedAt: revokedAt,
|
||||
})
|
||||
.where(eq(toolConnections.id, current.connectionId));
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: revokedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, current.companyId),
|
||||
eq(chatEndpointResources.endpointId, current.id),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: revokedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(chatConversations.companyId, current.companyId),
|
||||
eq(chatConversations.endpointId, current.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
await notifyChatDeliveryWork(tx);
|
||||
const [processed] = await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: revokedAt,
|
||||
redactedError: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: revokedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, claimed.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
eq(chatDeliveries.attempts, candidate.attempts + 1),
|
||||
),
|
||||
)
|
||||
.returning({ id: chatDeliveries.id });
|
||||
if (!processed) {
|
||||
throw new Error(
|
||||
"GitHub installation deletion ownership changed before revocation settlement",
|
||||
);
|
||||
}
|
||||
await credentialLease.assertOwned(tx);
|
||||
return true;
|
||||
});
|
||||
stopRuntimeAfterLifecycle = revoked;
|
||||
return { refreshRuntimeAfterLifecycle, stopRuntimeAfterLifecycle };
|
||||
}
|
||||
const identity = await verifyCredentials(
|
||||
"github",
|
||||
currentCredentials,
|
||||
);
|
||||
const upgradingLegacyIdentity =
|
||||
!currentEndpoint.botExternalId &&
|
||||
legacyGitHubLabelsMatchVerifiedCredentials(
|
||||
currentEndpoint,
|
||||
identity,
|
||||
);
|
||||
if (
|
||||
!upgradingLegacyIdentity &&
|
||||
!nativeBotIdentityMatches("github", currentEndpoint, identity)
|
||||
) {
|
||||
throw conflict(
|
||||
"The GitHub App identity changed while reconciling this connection",
|
||||
{
|
||||
code: "chat_bot_identity_changed",
|
||||
},
|
||||
);
|
||||
}
|
||||
await assertNativeBotIdentityAvailable(currentEndpoint, identity);
|
||||
if (upgradingLegacyIdentity) {
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
botExternalId: identity.botExternalId,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
|
||||
// GitHub lifecycle payloads are wake-up signals, not provider
|
||||
// truth. Re-read the one active App installation and its complete
|
||||
// repository inventory while the credential lease excludes secret
|
||||
// rotation and every other lifecycle callback for this endpoint.
|
||||
const prepared = await prepareProviderInventory(
|
||||
currentEndpoint,
|
||||
currentCredentials,
|
||||
);
|
||||
if (
|
||||
prepared.credentials.installationId !==
|
||||
currentCredentials.installationId
|
||||
) {
|
||||
await persistCredentials(
|
||||
currentEndpoint,
|
||||
prepared.credentials,
|
||||
credentialLease,
|
||||
);
|
||||
refreshRuntimeAfterLifecycle = true;
|
||||
}
|
||||
if (prepared.inventory)
|
||||
await reconcileProviderResourceRows(
|
||||
currentEndpoint,
|
||||
prepared.inventory,
|
||||
credentialLease,
|
||||
);
|
||||
|
||||
const canonical = await endpointRecord(currentEndpoint.id);
|
||||
if (!canonical) throw notFound("Chat endpoint not found");
|
||||
const now = new Date();
|
||||
const intentionallyUnavailable =
|
||||
canonical.endpoint.status === "paused" ||
|
||||
canonical.endpoint.status === "archived" ||
|
||||
!canonical.endpoint.setup.webhookVerifiedAt;
|
||||
const advanceRuntimeGeneration =
|
||||
refreshRuntimeAfterLifecycle ||
|
||||
!canonical.endpoint.setup.webhookVerifiedAt;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
if (intentionallyUnavailable) {
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
...(advanceRuntimeGeneration
|
||||
? {
|
||||
setup: {
|
||||
...canonical.endpoint.setup,
|
||||
runtimeGeneration:
|
||||
runtimeGeneration(canonical.endpoint.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
}
|
||||
: {}),
|
||||
lastEventAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, canonical.endpoint.id));
|
||||
} else {
|
||||
const status =
|
||||
canonical.endpoint.setup.step === "complete"
|
||||
? "active"
|
||||
: "verifying";
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status,
|
||||
healthMessage:
|
||||
status === "active"
|
||||
? "Connected"
|
||||
: "Waiting for a test conversation",
|
||||
lastError: null,
|
||||
lastEventAt: now,
|
||||
...(advanceRuntimeGeneration
|
||||
? {
|
||||
setup: {
|
||||
...canonical.endpoint.setup,
|
||||
runtimeGeneration:
|
||||
runtimeGeneration(canonical.endpoint.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
}
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, canonical.endpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "active",
|
||||
enabled: true,
|
||||
healthStatus: "healthy",
|
||||
healthMessage: "Connected",
|
||||
lastError: null,
|
||||
healthCheckedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
eq(toolConnections.id, canonical.endpoint.connectionId),
|
||||
);
|
||||
}
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: "processed",
|
||||
processedAt: now,
|
||||
redactedError: null,
|
||||
nextAttemptAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, claimed.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
eq(chatDeliveries.attempts, candidate.attempts + 1),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
if (!canonical.endpoint.setup.webhookVerifiedAt) {
|
||||
stopRuntimeAfterLifecycle = true;
|
||||
}
|
||||
} catch (error) {
|
||||
const latest = await endpointRecord(currentEndpoint.id);
|
||||
const preserveIntentionalState =
|
||||
!latest ||
|
||||
latest.endpoint.status === "paused" ||
|
||||
latest.endpoint.status === "archived" ||
|
||||
!latest.endpoint.setup.webhookVerifiedAt;
|
||||
if (!preserveIntentionalState) {
|
||||
const failedAt = new Date();
|
||||
const failure = redactError(error);
|
||||
const nextGeneration =
|
||||
runtimeGeneration(latest.endpoint.setup) + 1;
|
||||
const retryRuntimeContext: LifecycleRuntimeFence = {
|
||||
credentialFingerprint: credentialFingerprint(
|
||||
latest.credentialSecretRefs,
|
||||
),
|
||||
generation: nextGeneration,
|
||||
};
|
||||
const terminal = candidate.attempts + 1 >= 5;
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "attention",
|
||||
healthMessage:
|
||||
"GitHub App credentials, permissions, events, or identity need attention",
|
||||
lastError: failure,
|
||||
lastEventAt: failedAt,
|
||||
setup: {
|
||||
...latest.endpoint.setup,
|
||||
runtimeGeneration: nextGeneration,
|
||||
} as InternalSetupState,
|
||||
updatedAt: failedAt,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, currentEndpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus: "degraded",
|
||||
healthMessage:
|
||||
"GitHub App credentials, permissions, events, or identity need attention",
|
||||
lastError: failure,
|
||||
healthCheckedAt: failedAt,
|
||||
updatedAt: failedAt,
|
||||
})
|
||||
.where(eq(toolConnections.id, currentEndpoint.connectionId));
|
||||
await tx
|
||||
.update(chatEndpointResources)
|
||||
.set({ availability: "unavailable", updatedAt: failedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
chatEndpointResources.companyId,
|
||||
currentEndpoint.companyId,
|
||||
),
|
||||
eq(chatEndpointResources.endpointId, currentEndpoint.id),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatConversations)
|
||||
.set({ state: "unavailable", updatedAt: failedAt })
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
chatConversations.companyId,
|
||||
currentEndpoint.companyId,
|
||||
),
|
||||
eq(chatConversations.endpointId, currentEndpoint.id),
|
||||
inArray(chatConversations.state, ["active", "waiting"]),
|
||||
),
|
||||
);
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
state: terminal ? "failed" : "retry",
|
||||
normalizedEvent: normalizedLifecycleWithRuntimeFence(
|
||||
candidate,
|
||||
retryRuntimeContext,
|
||||
),
|
||||
redactedError: failure,
|
||||
nextAttemptAt: terminal ? null : failedAt,
|
||||
updatedAt: failedAt,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, claimed.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
eq(chatDeliveries.attempts, candidate.attempts + 1),
|
||||
),
|
||||
);
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
input.onFailurePersisted();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { refreshRuntimeAfterLifecycle, stopRuntimeAfterLifecycle };
|
||||
}
|
||||
return { applyGitHubInstallationLifecycle };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { githubAppJwt } from "../../chat-github-client.js";
|
||||
|
||||
import { discoverDedicatedGitHubAppInstallation, listGitHubInstallationRepositories } from "../../chat-provider-inventory.js";
|
||||
|
||||
import type { PreparedProviderInventory } from "../runtime-types.js";
|
||||
|
||||
export async function prepareGitHubInventory(credentials: Record<string,string>, fetchImpl: typeof fetch): Promise<PreparedProviderInventory> {
|
||||
const appJwt = githubAppJwt(credentials.appId, credentials.privateKey);
|
||||
const installation = await discoverDedicatedGitHubAppInstallation({
|
||||
appJwt,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
const preparedCredentials = {
|
||||
...credentials,
|
||||
installationId: installation.installationId,
|
||||
};
|
||||
const inventory = await listGitHubInstallationRepositories({
|
||||
appJwt,
|
||||
installationId: installation.installationId,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
return {
|
||||
credentials: preparedCredentials,
|
||||
managementUrl: installation.accountType === "Organization" && installation.accountLabel
|
||||
? `https://github.com/organizations/${encodeURIComponent(installation.accountLabel)}/settings/installations/${installation.installationId}`
|
||||
: `https://github.com/settings/installations/${installation.installationId}`,
|
||||
inventory: {
|
||||
...inventory,
|
||||
resources: inventory.resources.map((resource) => {
|
||||
const fullName =
|
||||
typeof resource.metadata?.fullName === "string"
|
||||
? resource.metadata.fullName
|
||||
: resource.label.includes("/")
|
||||
? resource.label
|
||||
: null;
|
||||
return fullName
|
||||
? {
|
||||
...resource,
|
||||
providerResourceId: fullName.toLowerCase(),
|
||||
metadata: {
|
||||
...resource.metadata,
|
||||
providerRepositoryId: resource.providerResourceId,
|
||||
fullName,
|
||||
},
|
||||
}
|
||||
: resource;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { createReceiptReactions } from "../receipt-reactions.js";
|
||||
|
||||
import type { EndpointRow, DbOrTransaction, RuntimeContext } from "../runtime-types.js";
|
||||
import { createChatFailedRunRetry } from "../failed-run-retry.js";
|
||||
import { createChatPublicationReplacement } from "../publication-replacement.js";
|
||||
|
||||
import { notifyChatPublicationWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import { chatGitHubConfigurations } from "@paperclipai/db";
|
||||
|
||||
import { githubResponseCommentService } from "../../chat-github-response-comments.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatPublications,
|
||||
heartbeatRuns,
|
||||
nativeRunFinalizations,
|
||||
issueComments,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
SafeChatPublicationPayload,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
NativeChatReviewPresentationContentionError,
|
||||
} from "../../native-runtime/native-chat-review-presentation.js";
|
||||
import { isExternalChatWaitAuthorizationContention } from "../../native-runtime/chat-attachment-reuse.js";
|
||||
|
||||
import { safeChatTaskUrl } from "../../chat-task-url.js";
|
||||
|
||||
import {
|
||||
githubRunReplyState,
|
||||
} from "../../chat-run-publications.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type GitHubPublicationPolicyOptions = {
|
||||
isExplicitOperatorPublication: (publication: typeof chatPublications.$inferSelect) => boolean;
|
||||
getTaskBaseUrl: () => string | null | undefined;
|
||||
failedChatRetrySource: ReturnType<typeof createChatFailedRunRetry>["failedChatRetrySource"];
|
||||
isExternalActionAuthorizationChange: (error: unknown) => boolean;
|
||||
FailedChatRetryPublicationReadError: ReturnType<typeof createChatFailedRunRetry>["FailedChatRetryPublicationReadError"];
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
runIdFromMilestonePublication: ReturnType<typeof createChatPublicationReplacement>["runIdFromMilestonePublication"];
|
||||
fetchImpl: typeof fetch;
|
||||
stageTerminalReceiptReactionRemovals: (tx: Db, input: { endpoint: EndpointRow; publication: typeof chatPublications.$inferSelect; payload: SafeChatPublicationPayload; runtimeContext: RuntimeContext; closedProgressRunId?: string }) => Promise<string[]>;
|
||||
runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext;
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
processReceiptReaction: ReturnType<typeof createReceiptReactions>["processReceiptReaction"];
|
||||
};
|
||||
|
||||
export function createGitHubPublicationPolicy(db: Db, options: GitHubPublicationPolicyOptions) {
|
||||
const { isExplicitOperatorPublication, getTaskBaseUrl, failedChatRetrySource, isExternalActionAuthorizationChange, FailedChatRetryPublicationReadError, endpointRecord, runIdFromMilestonePublication, fetchImpl, stageTerminalReceiptReactionRemovals, runtimeContextForRecord, scheduleMessageProcessing, processReceiptReaction } = options;
|
||||
|
||||
|
||||
/** Add navigation only from the exact accepted native input batch. The
|
||||
* omission text/model response is not authority, nor is an older task file. */
|
||||
async function githubCurrentOmissionPreparedText(
|
||||
tx: DbOrTransaction,
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
endpoint: EndpointRow,
|
||||
): Promise<string | null | false> {
|
||||
if (endpoint.provider !== "github") return null;
|
||||
const preparationKey = `github-omission-navigation:${publication.id}`;
|
||||
const [prepared] = await tx
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.companyId, publication.companyId),
|
||||
eq(chatActions.endpointId, publication.endpointId),
|
||||
eq(chatActions.conversationId, publication.conversationId),
|
||||
eq(chatActions.providerActionId, preparationKey),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
// A prepared final cannot become a different kind of publication while
|
||||
// retaining the decorated body. Check its receipt before these exclusions.
|
||||
if (
|
||||
!publication.commentId ||
|
||||
publication.payload.progressState !== undefined ||
|
||||
publication.payload.interactionId ||
|
||||
publication.payload.card ||
|
||||
publication.payload.attachmentIds?.length ||
|
||||
publication.payload.transportPart ||
|
||||
publication.idempotencyKey.startsWith("control:") ||
|
||||
isExplicitOperatorPublication(publication)
|
||||
)
|
||||
return prepared ? false : null;
|
||||
const taskUrl = prepared
|
||||
? typeof prepared.payload.taskUrl === "string"
|
||||
? safeChatTaskUrl(prepared.payload.taskUrl, publication.issueId)
|
||||
: null
|
||||
: safeChatTaskUrl(getTaskBaseUrl(), publication.issueId);
|
||||
if (
|
||||
prepared &&
|
||||
(prepared.kind !== "github_omission_navigation" ||
|
||||
prepared.status !== "processed" ||
|
||||
prepared.payload.version !== 1 ||
|
||||
prepared.payload.publicationId !== publication.id ||
|
||||
taskUrl !== prepared.payload.taskUrl ||
|
||||
prepared.payload.preparedTextSha256 !==
|
||||
createHash("sha256").update(publication.payload.text).digest("hex"))
|
||||
)
|
||||
return false;
|
||||
if (!taskUrl) return prepared ? false : null;
|
||||
const [origin] = await tx
|
||||
.select({ run: heartbeatRuns, resultId: nativeRunFinalizations.resultId })
|
||||
.from(issueComments)
|
||||
.innerJoin(
|
||||
heartbeatRuns,
|
||||
and(
|
||||
eq(heartbeatRuns.id, issueComments.createdByRunId),
|
||||
eq(heartbeatRuns.companyId, publication.companyId),
|
||||
eq(heartbeatRuns.nativeIssueId, publication.issueId),
|
||||
eq(heartbeatRuns.agentId, endpoint.assignedAgentId),
|
||||
eq(heartbeatRuns.runtimeMode, "native"),
|
||||
eq(heartbeatRuns.status, "succeeded"),
|
||||
isNotNull(heartbeatRuns.finishedAt),
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
nativeRunFinalizations,
|
||||
and(
|
||||
eq(nativeRunFinalizations.companyId, publication.companyId),
|
||||
eq(nativeRunFinalizations.issueId, publication.issueId),
|
||||
eq(nativeRunFinalizations.runId, heartbeatRuns.id),
|
||||
eq(nativeRunFinalizations.phase, "committed"),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.id, publication.commentId),
|
||||
eq(issueComments.companyId, publication.companyId),
|
||||
eq(issueComments.issueId, publication.issueId),
|
||||
eq(issueComments.authorAgentId, endpoint.assignedAgentId),
|
||||
isNull(issueComments.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const presentation = origin?.run.resultJson?.presentationDecision as
|
||||
Record<string, unknown> | undefined;
|
||||
if (!origin?.resultId || presentation?.commentId !== publication.commentId)
|
||||
return prepared ? false : null;
|
||||
if (
|
||||
prepared &&
|
||||
(prepared.payload.runId !== origin.run.id ||
|
||||
prepared.payload.resultId !== origin.resultId)
|
||||
)
|
||||
return false;
|
||||
try {
|
||||
// This existing publication proof includes the accepted response_wake
|
||||
// result, immutable owner/coalesced receipts, every current source,
|
||||
// runtime/generation, source edits/deletes and current principal access.
|
||||
const source = await failedChatRetrySource(tx, {
|
||||
companyId: publication.companyId,
|
||||
issueId: publication.issueId,
|
||||
agentId: origin.run.agentId,
|
||||
failedRunId: origin.run.id,
|
||||
publication: true,
|
||||
committedResponse: { resultId: origin.resultId },
|
||||
});
|
||||
if (
|
||||
source.endpointId !== endpoint.id ||
|
||||
source.conversationId !== publication.conversationId
|
||||
)
|
||||
return prepared ? false : null;
|
||||
const omitted = source.sources.some((entry) => {
|
||||
const count = (
|
||||
entry.omissionReasons as Record<string, unknown> | undefined
|
||||
)?.download_unavailable;
|
||||
return (
|
||||
typeof count === "number" && Number.isSafeInteger(count) && count > 0
|
||||
);
|
||||
});
|
||||
if (!omitted) return prepared ? false : null;
|
||||
if (prepared) return publication.payload.text;
|
||||
const text = publication.payload.text.includes(taskUrl)
|
||||
? publication.payload.text
|
||||
: `${publication.payload.text}\n\n[Open this Paperclip task](${taskUrl})`;
|
||||
await tx.insert(chatActions).values({
|
||||
companyId: publication.companyId,
|
||||
endpointId: publication.endpointId,
|
||||
conversationId: publication.conversationId,
|
||||
kind: "github_omission_navigation",
|
||||
providerActionId: preparationKey,
|
||||
status: "processed",
|
||||
payload: {
|
||||
version: 1,
|
||||
publicationId: publication.id,
|
||||
runId: origin.run.id,
|
||||
resultId: origin.resultId,
|
||||
taskUrl,
|
||||
preparedTextSha256: createHash("sha256").update(text).digest("hex"),
|
||||
},
|
||||
});
|
||||
return text;
|
||||
} catch (error) {
|
||||
if (
|
||||
isExternalChatWaitAuthorizationContention(error) ||
|
||||
error instanceof NativeChatReviewPresentationContentionError
|
||||
)
|
||||
throw new NativeChatReviewPresentationContentionError();
|
||||
if (
|
||||
isExternalActionAuthorizationChange(error) ||
|
||||
(error &&
|
||||
typeof error === "object" &&
|
||||
"details" in error &&
|
||||
error.details &&
|
||||
typeof error.details === "object" &&
|
||||
"code" in error.details &&
|
||||
error.details.code === "chat_failed_run_retry_not_authorized")
|
||||
)
|
||||
return prepared ? false : null;
|
||||
throw new FailedChatRetryPublicationReadError();
|
||||
}
|
||||
}
|
||||
|
||||
async function settleGitHubAutomaticPublication(
|
||||
publication: typeof chatPublications.$inferSelect,
|
||||
guard?: CredentialMutationLeaseGuard,
|
||||
): Promise<boolean> {
|
||||
if (isExplicitOperatorPublication(publication) || publication.payload.interactionId) return false;
|
||||
const record = await endpointRecord(publication.endpointId);
|
||||
if (!record || record.endpoint.provider !== "github" || record.endpoint.companyId !== publication.companyId) return false;
|
||||
// Legacy connections cannot use the replacement task-scoped tools yet.
|
||||
const [configuration] = await db.select({ endpointId: chatGitHubConfigurations.endpointId })
|
||||
.from(chatGitHubConfigurations).where(and(
|
||||
eq(chatGitHubConfigurations.companyId, publication.companyId),
|
||||
eq(chatGitHubConfigurations.endpointId, publication.endpointId),
|
||||
)).limit(1);
|
||||
if (!configuration) return false;
|
||||
let runId = runIdFromMilestonePublication(publication);
|
||||
if (!publication.payload.progressState) {
|
||||
// Also settle automatic agent comments retained from an older instance
|
||||
// version, and per-destination finals from a mixed-provider origin.
|
||||
if (!publication.commentId || !publication.idempotencyKey.startsWith("comment:")) return false;
|
||||
const [comment] = await db.select({ runId: issueComments.createdByRunId }).from(issueComments)
|
||||
.where(and(eq(issueComments.id, publication.commentId), eq(issueComments.companyId, publication.companyId),
|
||||
eq(issueComments.issueId, publication.issueId), eq(issueComments.authorType, "agent")));
|
||||
if (!comment) return false;
|
||||
runId = comment.runId;
|
||||
}
|
||||
if (["failed", "completed"].includes(publication.payload.progressState ?? "") && runId) {
|
||||
const reply = await githubRunReplyState(db, {
|
||||
companyId: publication.companyId, endpointId: publication.endpointId, issueId: publication.issueId, runId,
|
||||
});
|
||||
// A pending or ambiguous write may already have arrived. Suppress the
|
||||
// fallback without changing the governed outbox that owns that effect.
|
||||
if (reply === "unsettled") {
|
||||
// Quarantine only the automatic fallback. The governed tool outbox
|
||||
// retains the uncertain write; ordinary board messages and question
|
||||
// cards must not wait behind this notice in the conversation FIFO.
|
||||
await db.transaction(async tx => {
|
||||
await guard?.assertOwned(tx);
|
||||
await notifyChatPublicationWork(tx);
|
||||
await tx.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null,
|
||||
redactedError: "Automatic fallback suppressed while the GitHub tool reply remains unresolved", updatedAt: new Date() })
|
||||
.where(and(eq(chatPublications.id, publication.id), guard
|
||||
? and(eq(chatPublications.state, "streaming"), eq(chatPublications.attempts, publication.attempts + 1))
|
||||
: inArray(chatPublications.state, ["pending", "retry"])));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (reply === "none" && (publication.payload.progressState === "failed" ||
|
||||
await githubResponseCommentService(db, fetchImpl).hasWorkingResponseForRun({
|
||||
companyId: publication.companyId, endpointId: publication.endpointId, issueId: publication.issueId, runId,
|
||||
}))) return false;
|
||||
} else if (publication.payload.progressState === "failed") return false;
|
||||
const actionIds = await db.transaction(async tx => {
|
||||
await guard?.assertOwned(tx);
|
||||
await notifyChatPublicationWork(tx);
|
||||
const [cancelled] = await tx.update(chatPublications).set({ state: "cancelled", nextAttemptAt: null,
|
||||
redactedError: "GitHub replies are sent through task-scoped tools", updatedAt: new Date() })
|
||||
.where(and(eq(chatPublications.id, publication.id), eq(chatPublications.companyId, publication.companyId),
|
||||
guard ? and(eq(chatPublications.state, "streaming"), eq(chatPublications.attempts, publication.attempts + 1))
|
||||
: inArray(chatPublications.state, ["pending", "retry"])))
|
||||
.returning({ id: chatPublications.id });
|
||||
if (!cancelled || !runId) return [];
|
||||
const [run] = await tx.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and(
|
||||
eq(heartbeatRuns.id, runId), eq(heartbeatRuns.companyId, publication.companyId),
|
||||
eq(heartbeatRuns.agentId, record.endpoint.assignedAgentId),
|
||||
sql`${heartbeatRuns.contextSnapshot}->>'issueId' = ${publication.issueId}`,
|
||||
inArray(heartbeatRuns.status, ["succeeded", "failed", "interrupted", "timed_out", "cancelled"])));
|
||||
if (!run) return [];
|
||||
return stageTerminalReceiptReactionRemovals(tx as unknown as Db, {
|
||||
endpoint: record.endpoint, publication, payload: publication.payload,
|
||||
runtimeContext: runtimeContextForRecord(record), closedProgressRunId: run.id,
|
||||
});
|
||||
});
|
||||
for (const id of actionIds) scheduleMessageProcessing(() => processReceiptReaction(id));
|
||||
return true;
|
||||
}
|
||||
return { githubCurrentOmissionPreparedText, settleGitHubAutomaticPublication };
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import type { EndpointRow, InternalSetupState } from "../runtime-types.js";
|
||||
|
||||
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
randomBytes,
|
||||
randomUUID,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatEndpoints,
|
||||
companySecretBindings,
|
||||
toolConnections,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatEndpointSetupState,
|
||||
ToolCredentialSecretRef,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
notFound,
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../../activity-log.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type GitHubSetupSecretOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
|
||||
persistCredentials: (endpoint: EndpointRow, credentials: Record<string, string>, lease: CredentialMutationLeaseGuard, actorUserId?: string | null) => Promise<ToolCredentialSecretRef[]>;
|
||||
runtimeGeneration: (setup: ChatEndpointSetupState) => number;
|
||||
credentialFingerprint: (refs: ToolCredentialSecretRef[]) => string;
|
||||
invalidateRuntime: (endpointId: string) => Promise<boolean>;
|
||||
redactError: (error: unknown) => string;
|
||||
setupSecretActivityLogger?: typeof logActivity;
|
||||
setupSecretCredentialPersistBarrier?: () => Promise<void>;
|
||||
setupSecretFinalOwnershipBarrier?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export function createGitHubSetupSecret(db: Db, options: GitHubSetupSecretOptions) {
|
||||
const { endpointRecord, withCredentialMutationLease, resolveCredentials, persistCredentials, runtimeGeneration, credentialFingerprint, invalidateRuntime, redactError } = options;
|
||||
|
||||
|
||||
async function generateSetupSecret(
|
||||
endpointId: string,
|
||||
actorUserId?: string | null,
|
||||
) {
|
||||
const initial = await endpointRecord(endpointId);
|
||||
if (!initial) throw notFound("Chat endpoint not found");
|
||||
if (initial.endpoint.provider !== "github") {
|
||||
throw unprocessable(
|
||||
"Setup secret generation is only available for GitHub",
|
||||
);
|
||||
}
|
||||
let committedWebhookSecretRef: ToolCredentialSecretRef | null = null;
|
||||
return withCredentialMutationLease(
|
||||
initial.endpoint,
|
||||
async (credentialLease) => {
|
||||
// A competing request may have completed while this request waited for
|
||||
// the durable lease. Reload both endpoint state and credential refs only
|
||||
// after ownership so each successful rotation starts from its immediate
|
||||
// predecessor instead of replacing the credential set from a stale read.
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record) throw notFound("Chat endpoint not found");
|
||||
const endpoint = record.endpoint;
|
||||
if (
|
||||
![
|
||||
"draft",
|
||||
"verifying",
|
||||
"active",
|
||||
"attention",
|
||||
"revoked",
|
||||
"paused",
|
||||
].includes(endpoint.status)
|
||||
) {
|
||||
throw conflict(
|
||||
"The GitHub webhook secret cannot be rotated in this connection state",
|
||||
{
|
||||
code: "chat_endpoint_setup_secret_unavailable",
|
||||
},
|
||||
);
|
||||
}
|
||||
const webhookSecret = randomBytes(32).toString("hex");
|
||||
// Rotation must fail closed if any existing credential cannot be resolved.
|
||||
// Falling back to an empty object here would replace the full credential
|
||||
// set with only the new webhook secret and strand an otherwise-live App.
|
||||
const existing = await resolveCredentials(endpoint);
|
||||
const replacedExistingSecret = record.credentialSecretRefs.some(
|
||||
(ref) => ref.configPath === "credentials.webhookSecret",
|
||||
);
|
||||
const rotatingConfiguredApp =
|
||||
Boolean(endpoint.providerAccountId || endpoint.botExternalId) ||
|
||||
record.credentialSecretRefs.some((ref) =>
|
||||
["credentials.appId", "credentials.privateKey"].includes(
|
||||
ref.configPath,
|
||||
),
|
||||
);
|
||||
const rotationId = randomUUID();
|
||||
const auditDetails = {
|
||||
endpointId: endpoint.id,
|
||||
provider: endpoint.provider,
|
||||
rotationId,
|
||||
rotated: rotatingConfiguredApp,
|
||||
replacedPrevious: replacedExistingSecret,
|
||||
};
|
||||
const writeSetupSecretActivity =
|
||||
options.setupSecretActivityLogger ?? logActivity;
|
||||
// This durable intent is the audit boundary. If it cannot be written,
|
||||
// fail before changing state or refs so retry is safe. Endpoint/tool
|
||||
// state moves fail-closed before ref replacement. Once refs change, only
|
||||
// best-effort work remains and the plaintext must still be returned.
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await writeSetupSecretActivity(tx as unknown as Db, {
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "chat_endpoint.setup_secret_rotation_started",
|
||||
entityType: "tool_connection",
|
||||
entityId: endpoint.connectionId,
|
||||
details: auditDetails,
|
||||
});
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
try {
|
||||
if (rotatingConfiguredApp) {
|
||||
const updatedAt = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await tx
|
||||
.select({ setup: chatEndpoints.setup })
|
||||
.from(chatEndpoints)
|
||||
.where(eq(chatEndpoints.id, endpoint.id))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current) throw notFound("Chat endpoint not found");
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "attention",
|
||||
healthMessage:
|
||||
"Update the GitHub webhook secret, then reconnect this App",
|
||||
lastError: null,
|
||||
setup: {
|
||||
...current.setup,
|
||||
step: "provider_setup",
|
||||
webhookVerifiedAt: null,
|
||||
runtimeGeneration: runtimeGeneration(current.setup) + 1,
|
||||
} as InternalSetupState,
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, endpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "disabled",
|
||||
enabled: false,
|
||||
healthStatus: "degraded",
|
||||
healthMessage:
|
||||
"GitHub webhook secret rotation needs reconnect",
|
||||
lastError: null,
|
||||
healthCheckedAt: updatedAt,
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(toolConnections.id, endpoint.connectionId));
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
await invalidateRuntime(endpoint.id).catch(() => undefined);
|
||||
} else {
|
||||
// Replacing a secret while the operator is still creating the GitHub
|
||||
// App is not a live credential rotation. Keep first-time setup in its
|
||||
// draft state, clear any ping for the superseded value, and also heal
|
||||
// endpoints affected by the earlier pre-connect rotation trap.
|
||||
const updatedAt = new Date();
|
||||
await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const current = await tx
|
||||
.select({ setup: chatEndpoints.setup })
|
||||
.from(chatEndpoints)
|
||||
.where(eq(chatEndpoints.id, endpoint.id))
|
||||
.for("update")
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!current) throw notFound("Chat endpoint not found");
|
||||
await tx
|
||||
.update(chatEndpoints)
|
||||
.set({
|
||||
status: "draft",
|
||||
healthMessage: null,
|
||||
lastError: null,
|
||||
setup: {
|
||||
...current.setup,
|
||||
step: "provider_setup",
|
||||
webhookVerifiedAt: null,
|
||||
},
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(chatEndpoints.id, endpoint.id));
|
||||
await tx
|
||||
.update(toolConnections)
|
||||
.set({
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
healthStatus: "unchecked",
|
||||
healthMessage: null,
|
||||
lastError: null,
|
||||
healthCheckedAt: null,
|
||||
updatedAt,
|
||||
})
|
||||
.where(eq(toolConnections.id, endpoint.connectionId));
|
||||
await credentialLease.assertOwned(tx);
|
||||
});
|
||||
}
|
||||
await options.setupSecretCredentialPersistBarrier?.();
|
||||
// Make credential refs the final required mutation. Once this returns,
|
||||
// only the best-effort completion audit remains, so the request cannot
|
||||
// fail after making the newly generated secret authoritative.
|
||||
const persistedRefs = await persistCredentials(
|
||||
endpoint,
|
||||
{ ...existing, webhookSecret },
|
||||
credentialLease,
|
||||
actorUserId,
|
||||
);
|
||||
committedWebhookSecretRef =
|
||||
persistedRefs.find(
|
||||
(ref) => ref.configPath === "credentials.webhookSecret",
|
||||
) ?? null;
|
||||
if (!committedWebhookSecretRef) {
|
||||
throw new Error(
|
||||
"GitHub webhook secret was not present after credential persistence",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await writeSetupSecretActivity(db, {
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "chat_endpoint.setup_secret_rotation_failed",
|
||||
entityType: "tool_connection",
|
||||
entityId: endpoint.connectionId,
|
||||
details: auditDetails,
|
||||
}).catch((auditError) => {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: endpoint.id,
|
||||
error: redactError(auditError),
|
||||
rotationId,
|
||||
},
|
||||
"could not audit failed GitHub setup-secret rotation",
|
||||
);
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
await db
|
||||
.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
await writeSetupSecretActivity(tx as unknown as Db, {
|
||||
companyId: endpoint.companyId,
|
||||
actorType: "user",
|
||||
actorId: actorUserId ?? "board",
|
||||
action: "chat_endpoint.setup_secret_generated",
|
||||
entityType: "tool_connection",
|
||||
entityId: endpoint.connectionId,
|
||||
details: auditDetails,
|
||||
});
|
||||
await credentialLease.assertOwned(tx);
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.warn(
|
||||
{
|
||||
endpointId: endpoint.id,
|
||||
error: redactError(error),
|
||||
rotationId,
|
||||
},
|
||||
"GitHub setup secret rotated but completion audit failed",
|
||||
);
|
||||
});
|
||||
return { webhookSecret };
|
||||
},
|
||||
{
|
||||
beforeFinalOwnershipCheck: options.setupSecretFinalOwnershipBarrier,
|
||||
recoverCommittedResultAfterLeaseLoss: async () => {
|
||||
const committedRef = committedWebhookSecretRef;
|
||||
if (!committedRef) return false;
|
||||
const authoritative = await db
|
||||
.select({ refs: toolConnections.credentialSecretRefs })
|
||||
.from(toolConnections)
|
||||
.innerJoin(
|
||||
companySecretBindings,
|
||||
and(
|
||||
eq(companySecretBindings.companyId, initial.endpoint.companyId),
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(
|
||||
companySecretBindings.targetId,
|
||||
initial.endpoint.connectionId,
|
||||
),
|
||||
eq(companySecretBindings.configPath, committedRef.configPath),
|
||||
eq(companySecretBindings.secretId, committedRef.secretId),
|
||||
eq(
|
||||
companySecretBindings.versionSelector,
|
||||
String(committedRef.versionSelector ?? "latest"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(toolConnections.companyId, initial.endpoint.companyId),
|
||||
eq(toolConnections.id, initial.endpoint.connectionId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return (
|
||||
authoritative?.refs.some(
|
||||
(ref) =>
|
||||
credentialFingerprint([ref]) ===
|
||||
credentialFingerprint([committedRef]),
|
||||
) === true
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
return { generateSetupSecret };
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
id: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.from(authUsers)
|
||||
.where(inArray(authUsers.id, userIds))
|
||||
@@ -80,6 +81,7 @@ export function createChatIdentityLinks(db: Db, options: ChatIdentityLinkOptions
|
||||
: principal.externalId,
|
||||
paperclipUserId: link?.paperclipUserId ?? null,
|
||||
paperclipUserLabel: user?.name ?? user?.email ?? null,
|
||||
paperclipUserImage: user?.image ?? null,
|
||||
status: link?.status ?? "pending",
|
||||
lastConnectAt: connectByPrincipal.get(principal.id) ?? null,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { PhotonCloudClient, PhotonError, photonSharedIdentity } from "../../photon/cloud.js";
|
||||
|
||||
import {
|
||||
HttpError,
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
export function createPhotonCredentialChecks(fetchImpl: typeof fetch) {
|
||||
|
||||
|
||||
async function inspectPhotonCredentials(projectId: string, projectSecret: string) {
|
||||
try {
|
||||
return await new PhotonCloudClient(fetchImpl).inspect(projectId, projectSecret);
|
||||
} catch (error) {
|
||||
if (!(error instanceof PhotonError)) throw error;
|
||||
// Only credentials/allocation failures mean the setup input needs repair.
|
||||
// Preserve safe provider messages while distinguishing outages and bad
|
||||
// upstream responses from validation errors. Never return response bodies.
|
||||
const status = error.code === "credentials" || error.code === "line_unavailable"
|
||||
? 422
|
||||
: error.code === "quota"
|
||||
? 429
|
||||
: error.code === "network"
|
||||
? 503
|
||||
: 502;
|
||||
throw new HttpError(status, error.message, {
|
||||
code: `photon_${error.code}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function verifyPhotonCredentials(credentials: Record<string, string>): Promise<VerifiedProviderIdentity> {
|
||||
const inspection = await inspectPhotonCredentials(credentials.projectId, credentials.projectSecret);
|
||||
if (inspection.allocation !== (credentials.allocation ?? "dedicated")) throw unprocessable("Photon allocation changed; inspect the project again");
|
||||
if (inspection.allocation === "shared") {
|
||||
if (!inspection.eligible) throw unprocessable("Photon shared project is unavailable");
|
||||
return { providerAccountId: inspection.projectId, providerAccountLabel: inspection.projectName, botExternalId: photonSharedIdentity(inspection.projectId), botUsername: null, botLabel: `${inspection.projectName} (DM only)` };
|
||||
}
|
||||
const line = inspection.lines.find((candidate) => candidate.lineId === credentials.lineId && candidate.eligible);
|
||||
if (!line || !inspection.eligible) throw unprocessable("Select an eligible dedicated Photon line");
|
||||
return { providerAccountId: inspection.projectId, providerAccountLabel: inspection.projectName, botExternalId: line.phoneNumber, botUsername: line.phoneNumber, botLabel: line.phoneNumber };
|
||||
}
|
||||
return { inspectPhotonCredentials, verifyPhotonCredentials };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import {
|
||||
normalizeMicrosoftTeamsCredentialIds,
|
||||
} from "../../chat-teams-credentials.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
export async function verifyTeamsCredentials(credentials: Record<string, string>, fetchImpl: typeof fetch, PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number): Promise<VerifiedProviderIdentity> {
|
||||
|
||||
const teamsCredentials = normalizeMicrosoftTeamsCredentialIds(credentials);
|
||||
const body = new URLSearchParams({
|
||||
client_id: teamsCredentials.clientId,
|
||||
client_secret: teamsCredentials.clientSecret,
|
||||
grant_type: "client_credentials",
|
||||
scope: "https://api.botframework.com/.default",
|
||||
});
|
||||
const response = await fetchImpl(
|
||||
`https://login.microsoftonline.com/${encodeURIComponent(teamsCredentials.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS),
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
},
|
||||
);
|
||||
const result = (await response.json()) as {
|
||||
access_token?: string;
|
||||
error_description?: string;
|
||||
};
|
||||
if (!response.ok || !result.access_token)
|
||||
throw unprocessable(
|
||||
`Microsoft rejected the app credentials: ${result.error_description ?? response.status}`,
|
||||
);
|
||||
return {
|
||||
providerAccountId: teamsCredentials.tenantId,
|
||||
providerAccountLabel: teamsCredentials.tenantId,
|
||||
botExternalId: teamsCredentials.clientId,
|
||||
};
|
||||
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { createOutboundAttachments } from "../outbound-attachments.js";
|
||||
|
||||
import { createTeamsFileAuthority } from "./file-transfer-authority.js";
|
||||
|
||||
import type { EndpointRow, RuntimeContext } from "../runtime-types.js";
|
||||
|
||||
import { type ChatRuntime } from "../../chat-runtime.js";
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import { chatCredentialMutationLease, type CredentialMutationLeaseGuard } from "../../chat-credential-mutation-lease.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
inArray,
|
||||
lte,
|
||||
or,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatPublications,
|
||||
chatTeamsFileTransfers,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
teamsFileTransferService,
|
||||
type TeamsFileTransferAuthority,
|
||||
type TeamsFileTransferOptions,
|
||||
} from "../../chat-teams-file-transfers.js";
|
||||
import { projectTeamsFilePublication } from "../../chat-teams-file-publication.js";
|
||||
import type { TeamsFileConsentEvent } from "../../chat-teams-file-consent.js";
|
||||
|
||||
import {
|
||||
HttpError,
|
||||
conflict,
|
||||
} from "../../../errors.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
|
||||
import {
|
||||
type ChatSdkCallbackEvent,
|
||||
type ChatSdkRuntime,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type TeamsFileDeliveryOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
runtimeContexts: Pick<WeakMap<object, RuntimeContext>, "get">;
|
||||
localRuntimeEpoch: (endpointId: string) => number;
|
||||
runtime: Pick<ChatSdkRuntime | ChatRuntime, "get">;
|
||||
deriveTeamsFileTransferAuthority: ReturnType<typeof createTeamsFileAuthority>["deriveTeamsFileTransferAuthority"];
|
||||
publicationFiles: ReturnType<typeof createOutboundAttachments>["publicationFiles"];
|
||||
teamsFileUploadRequest?: TeamsFileTransferOptions["uploadRequest"];
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
processPendingPublications: () => Promise<unknown>;
|
||||
withCredentialMutationLease: ReturnType<typeof chatCredentialMutationLease>;
|
||||
teamsFileRetryAfter: Map<string, number>;
|
||||
isShuttingDown: () => boolean;
|
||||
};
|
||||
|
||||
export function createTeamsFileDelivery(db: Db, options: TeamsFileDeliveryOptions) {
|
||||
const { endpointRecord, runtimeContextForRecord, runtimeFor, runtimeContexts, localRuntimeEpoch, runtime, deriveTeamsFileTransferAuthority, publicationFiles, scheduleMessageProcessing, processPendingPublications, withCredentialMutationLease, teamsFileRetryAfter, isShuttingDown } = options;
|
||||
|
||||
|
||||
function teamsTransferProtocol(
|
||||
input: {
|
||||
credentialLease?: CredentialMutationLeaseGuard;
|
||||
callbackContext?: RuntimeContext;
|
||||
} = {},
|
||||
) {
|
||||
const denied = () =>
|
||||
conflict("Teams file delivery authority changed", {
|
||||
code: "chat_file_transfer_authority_changed",
|
||||
});
|
||||
const currentRuntime = async (authority: TeamsFileTransferAuthority) => {
|
||||
if (!input.credentialLease) throw denied();
|
||||
await input.credentialLease.assertOwned();
|
||||
const record = await endpointRecord(authority.endpointId);
|
||||
if (
|
||||
!record ||
|
||||
record.endpoint.companyId !== authority.companyId ||
|
||||
record.endpoint.provider !== "microsoft-teams" ||
|
||||
record.endpoint.status !== "active"
|
||||
)
|
||||
throw denied();
|
||||
const context = runtimeContextForRecord(record);
|
||||
if (
|
||||
context.generation !== authority.runtimeGeneration ||
|
||||
context.credentialFingerprint !== authority.credentialFingerprint
|
||||
)
|
||||
throw denied();
|
||||
const instance = await runtimeFor(record.endpoint);
|
||||
const registered = runtimeContexts.get(instance as object);
|
||||
if (
|
||||
!registered ||
|
||||
registered.version !== context.version ||
|
||||
registered.localEpoch !== localRuntimeEpoch(authority.endpointId) ||
|
||||
runtime.get(authority.endpointId) !== instance
|
||||
)
|
||||
throw denied();
|
||||
await input.credentialLease.assertOwned();
|
||||
return instance;
|
||||
};
|
||||
const personalThread = (authority: TeamsFileTransferAuthority) =>
|
||||
`teams:${Buffer.from(authority.providerConversationId).toString("base64url")}:personal`;
|
||||
return teamsFileTransferService(db, {
|
||||
project: projectTeamsFilePublication,
|
||||
authorize: async (tx, expected, stage) => {
|
||||
await input.credentialLease?.assertOwned(tx);
|
||||
if (
|
||||
input.callbackContext &&
|
||||
(input.callbackContext.generation !== expected.runtimeGeneration ||
|
||||
input.callbackContext.credentialFingerprint !==
|
||||
expected.credentialFingerprint ||
|
||||
input.callbackContext.localEpoch !==
|
||||
localRuntimeEpoch(expected.endpointId) ||
|
||||
input.callbackContext.endpointRuntime !==
|
||||
runtime.get(expected.endpointId))
|
||||
)
|
||||
throw denied();
|
||||
const [publication] = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, expected.companyId),
|
||||
eq(chatPublications.endpointId, expected.endpointId),
|
||||
eq(chatPublications.id, expected.publicationId),
|
||||
),
|
||||
)
|
||||
.for("update");
|
||||
// This selector is derived only inside the staged protocol's locked
|
||||
// authority check, never from a retry request or a model instruction.
|
||||
const [continuing] =
|
||||
publication?.state === "delivery_unknown" &&
|
||||
(stage === "response" || stage === "file_info")
|
||||
? await tx
|
||||
.select({
|
||||
transferId: chatTeamsFileTransfers.id,
|
||||
publicationId: chatTeamsFileTransfers.publicationId,
|
||||
version: chatTeamsFileTransfers.version,
|
||||
phase: chatTeamsFileTransfers.phase,
|
||||
})
|
||||
.from(chatTeamsFileTransfers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatTeamsFileTransfers.companyId, expected.companyId),
|
||||
eq(chatTeamsFileTransfers.endpointId, expected.endpointId),
|
||||
eq(
|
||||
chatTeamsFileTransfers.conversationId,
|
||||
expected.conversationId,
|
||||
),
|
||||
eq(
|
||||
chatTeamsFileTransfers.publicationId,
|
||||
expected.publicationId,
|
||||
),
|
||||
eq(
|
||||
chatTeamsFileTransfers.phase,
|
||||
stage === "response"
|
||||
? "consent_unknown"
|
||||
: "file_info_unknown",
|
||||
),
|
||||
),
|
||||
)
|
||||
.for("update")
|
||||
: [];
|
||||
const current =
|
||||
publication &&
|
||||
(await deriveTeamsFileTransferAuthority(
|
||||
tx,
|
||||
publication,
|
||||
{
|
||||
generation: expected.runtimeGeneration,
|
||||
credentialFingerprint: expected.credentialFingerprint,
|
||||
},
|
||||
continuing && {
|
||||
...continuing,
|
||||
phase: continuing.phase as
|
||||
| "consent_unknown"
|
||||
| "file_info_unknown",
|
||||
},
|
||||
));
|
||||
if (!current) throw denied();
|
||||
await input.credentialLease?.assertOwned(tx);
|
||||
return current;
|
||||
},
|
||||
loadBytes: async (authority) => {
|
||||
const [publication] = await db
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, authority.companyId),
|
||||
eq(chatPublications.endpointId, authority.endpointId),
|
||||
eq(chatPublications.id, authority.publicationId),
|
||||
),
|
||||
);
|
||||
if (!publication) throw denied();
|
||||
const files = await publicationFiles(publication, publication.payload);
|
||||
if (files.length !== 1 || !Buffer.isBuffer(files[0]?.data))
|
||||
throw denied();
|
||||
return files[0].data;
|
||||
},
|
||||
postConsent: async ({ authority, card, signal }) => {
|
||||
const instance = await currentRuntime(authority);
|
||||
signal.throwIfAborted();
|
||||
return instance.sendTeamsFileConsentCard(
|
||||
personalThread(authority),
|
||||
card,
|
||||
);
|
||||
},
|
||||
postFileInfo: async ({ authority, card, signal }) => {
|
||||
const instance = await currentRuntime(authority);
|
||||
signal.throwIfAborted();
|
||||
return instance.sendTeamsUploadedFileCard(
|
||||
personalThread(authority),
|
||||
card,
|
||||
);
|
||||
},
|
||||
uploadRequest: options.teamsFileUploadRequest,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTeamsFileConsent(
|
||||
callback: ChatSdkCallbackEvent<TeamsFileConsentEvent>,
|
||||
context: RuntimeContext,
|
||||
): Promise<"recorded" | "ignored" | "denied"> {
|
||||
if (
|
||||
callback.provider !== "microsoft-teams" ||
|
||||
callback.endpointId !== callback.event.endpointId ||
|
||||
context.endpointRuntime !== runtime.get(callback.endpointId) ||
|
||||
context.localEpoch !== localRuntimeEpoch(callback.endpointId)
|
||||
)
|
||||
return "denied";
|
||||
// Do not wait for the sender's credential lease: an authentic acceptance
|
||||
// can arrive while its consent-card POST is still awaiting the receipt.
|
||||
let result: "recorded" | "ignored" | "denied";
|
||||
try {
|
||||
result = await teamsTransferProtocol({
|
||||
callbackContext: context,
|
||||
}).recordConsent(callback.event);
|
||||
} catch (error) {
|
||||
// A known current-policy denial is not a temporary provider outage.
|
||||
// Contention and unproven persistence still propagate as retryable 503.
|
||||
if (
|
||||
error instanceof HttpError &&
|
||||
(error.details as { code?: string } | undefined)?.code ===
|
||||
"chat_file_transfer_authority_changed"
|
||||
)
|
||||
return "denied";
|
||||
throw error;
|
||||
}
|
||||
if (result === "recorded")
|
||||
scheduleMessageProcessing(async () => {
|
||||
await processPendingPublications();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function tryProcessTeamsFilePublication(
|
||||
selected: typeof chatPublications.$inferSelect,
|
||||
): Promise<boolean> {
|
||||
const [existing] = await db
|
||||
.select({
|
||||
id: chatTeamsFileTransfers.id,
|
||||
endpointId: chatTeamsFileTransfers.endpointId,
|
||||
conversationId: chatTeamsFileTransfers.conversationId,
|
||||
})
|
||||
.from(chatTeamsFileTransfers)
|
||||
.where(
|
||||
and(
|
||||
eq(chatTeamsFileTransfers.companyId, selected.companyId),
|
||||
eq(chatTeamsFileTransfers.publicationId, selected.id),
|
||||
),
|
||||
);
|
||||
if (!existing && !selected.payload.attachmentIds?.length) return false;
|
||||
const record = await endpointRecord(selected.endpointId);
|
||||
if (!record || record.endpoint.provider !== "microsoft-teams")
|
||||
return Boolean(existing);
|
||||
if (
|
||||
existing &&
|
||||
(existing.endpointId !== selected.endpointId ||
|
||||
existing.conversationId !== selected.conversationId)
|
||||
)
|
||||
return true;
|
||||
if (record.endpoint.status !== "active") return Boolean(existing);
|
||||
try {
|
||||
return await withCredentialMutationLease(
|
||||
record.endpoint,
|
||||
async (credentialLease) => {
|
||||
await runtimeFor(record.endpoint);
|
||||
const protocol = teamsTransferProtocol({ credentialLease });
|
||||
let transferId = existing?.id;
|
||||
if (!transferId) {
|
||||
const authority = await db.transaction(async (tx) => {
|
||||
await credentialLease.assertOwned(tx);
|
||||
const [current] = await tx
|
||||
.select()
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, selected.companyId),
|
||||
eq(chatPublications.id, selected.id),
|
||||
),
|
||||
)
|
||||
.for("update");
|
||||
if (
|
||||
!current ||
|
||||
!["pending", "retry"].includes(current.state) ||
|
||||
(current.nextAttemptAt && current.nextAttemptAt > new Date())
|
||||
)
|
||||
return null;
|
||||
return deriveTeamsFileTransferAuthority(
|
||||
tx,
|
||||
current,
|
||||
runtimeContextForRecord(record),
|
||||
);
|
||||
});
|
||||
// Missing personal-recipient proof is not permission to guess a DM.
|
||||
// The ordinary path sends the truthful task-link fallback instead.
|
||||
if (!authority) return false;
|
||||
transferId = (
|
||||
await protocol.issue(
|
||||
authority,
|
||||
new Date(Date.now() + 60 * 60 * 1000),
|
||||
)
|
||||
).id;
|
||||
}
|
||||
// A successful consent/upload transition can immediately progress to
|
||||
// its next effect, but no user wait, unknown outcome or backoff spins.
|
||||
for (let stage = 0; stage < 3; stage++) {
|
||||
const [current] = await db
|
||||
.select({ nextAttemptAt: chatPublications.nextAttemptAt })
|
||||
.from(chatPublications)
|
||||
.where(
|
||||
and(
|
||||
eq(chatPublications.companyId, selected.companyId),
|
||||
eq(chatPublications.id, selected.id),
|
||||
),
|
||||
);
|
||||
if (
|
||||
!current ||
|
||||
(current.nextAttemptAt && current.nextAttemptAt > new Date())
|
||||
)
|
||||
break;
|
||||
const result = await protocol.process(
|
||||
selected.companyId,
|
||||
transferId,
|
||||
);
|
||||
if (
|
||||
![
|
||||
"consent_pending",
|
||||
"upload_pending",
|
||||
"file_info_pending",
|
||||
].includes(result.phase)
|
||||
)
|
||||
break;
|
||||
}
|
||||
teamsFileRetryAfter.delete(selected.id);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
// A failed authorization/lock/receipt write must never fall through to a
|
||||
// generic resend. Preserve exact durable evidence; this cooldown grants
|
||||
// no authority and safely resets when the server restarts.
|
||||
if (teamsFileRetryAfter.size >= 1000)
|
||||
teamsFileRetryAfter.delete(teamsFileRetryAfter.keys().next().value!);
|
||||
teamsFileRetryAfter.set(selected.id, Date.now() + 30_000);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
let teamsMaintenancePending = false;
|
||||
let teamsMaintenanceAfter = 0;
|
||||
function scheduleTeamsFileMaintenance() {
|
||||
if (
|
||||
teamsMaintenancePending ||
|
||||
Date.now() < teamsMaintenanceAfter ||
|
||||
isShuttingDown()
|
||||
)
|
||||
return;
|
||||
teamsMaintenancePending = true;
|
||||
teamsMaintenanceAfter = Date.now() + 30_000;
|
||||
scheduleMessageProcessing(async () => {
|
||||
try {
|
||||
const scopes = await db
|
||||
.selectDistinct({ companyId: chatTeamsFileTransfers.companyId })
|
||||
.from(chatTeamsFileTransfers)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
inArray(chatTeamsFileTransfers.phase, [
|
||||
"consent_pending",
|
||||
"awaiting_consent",
|
||||
"upload_pending",
|
||||
]),
|
||||
lte(chatTeamsFileTransfers.expiresAt, new Date()),
|
||||
),
|
||||
and(
|
||||
inArray(chatTeamsFileTransfers.phase, [
|
||||
"consent_sending",
|
||||
"uploading",
|
||||
"file_info_sending",
|
||||
"conflict",
|
||||
]),
|
||||
lte(chatTeamsFileTransfers.attemptExpiresAt, new Date()),
|
||||
),
|
||||
),
|
||||
);
|
||||
const protocol = teamsTransferProtocol();
|
||||
// Separate from the ordinary message queue. Each company sweep bounds
|
||||
// its row count and lock waits and performs no provider I/O.
|
||||
for (const scope of scopes) {
|
||||
if (isShuttingDown()) break;
|
||||
await protocol.expireAndRecover(scope.companyId, 100);
|
||||
}
|
||||
} catch {
|
||||
// Selection errors are closed here too; private transfer envelopes and
|
||||
// SQL parameters must not escape through the generic background logger.
|
||||
logger.warn(
|
||||
"Teams file maintenance will retry after a temporary failure",
|
||||
);
|
||||
} finally {
|
||||
teamsMaintenancePending = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
return { teamsTransferProtocol, handleTeamsFileConsent, tryProcessTeamsFilePublication, scheduleTeamsFileMaintenance };
|
||||
}
|
||||
@@ -50,3 +50,19 @@ export type InternalSetupState = ChatEndpointSetupState & {
|
||||
Record<SlackCallbackSurface, SlackCallbackObservation>
|
||||
>;
|
||||
};
|
||||
|
||||
export type VerifiedProviderIdentity = {
|
||||
githubOwnerType?: "personal" | "organization";
|
||||
githubOwnerLogin?: string;
|
||||
providerAccountId?: string | null;
|
||||
providerAccountLabel?: string | null;
|
||||
botExternalId?: string | null;
|
||||
botUsername?: string | null;
|
||||
botLabel?: string | null;
|
||||
};
|
||||
|
||||
export type PreparedProviderInventory = {
|
||||
credentials: Record<string, string>;
|
||||
inventory: import("../chat-provider-inventory.js").ChatProviderInventoryResult | null;
|
||||
managementUrl?: string;
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js";
|
||||
|
||||
import { createSlackTaskStart } from "./task-start.js";
|
||||
|
||||
import type { EndpointRow, DbTransaction, RuntimeContext, LifecycleRuntimeFence } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatActionWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import { type ChatRuntime } from "../../chat-runtime.js";
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
randomBytes,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatEndpointResources,
|
||||
chatExternalPrincipals,
|
||||
chatIdentityLinks,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
ChatProvider,
|
||||
} from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
conflict,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import {
|
||||
type ChatSdkCallbackEvent,
|
||||
type ChatSdkMessageCallbackEvent,
|
||||
type ChatSdkRuntime,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import {
|
||||
logActivity,
|
||||
} from "../../activity-log.js";
|
||||
|
||||
import type {
|
||||
Author,
|
||||
Message,
|
||||
SlashCommandEvent,
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
export function slackCommandForAgent(agentName: string, publicId: string): string {
|
||||
const slug = agentName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 20);
|
||||
const suffix = publicId
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, "")
|
||||
.slice(0, 6);
|
||||
return `/${slug || "paperclip"}-${suffix || "agent"}`;
|
||||
}
|
||||
|
||||
type SlackCommandsOptions = {
|
||||
runtime: Pick<ChatSdkRuntime | ChatRuntime, "get">;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
handleSdkMessage: (event: ChatSdkMessageCallbackEvent, context: RuntimeContext, input?: { receiptReactionSupported?: boolean; slackSlashControl?: boolean }) => Promise<void>;
|
||||
stageProviderEffect: ReturnType<typeof createProviderEffects>["stageProviderEffect"];
|
||||
scheduleProviderEffect: (actionId: string, liveTarget?: ProviderEffectTarget) => void;
|
||||
ensurePrincipal: (endpoint: EndpointRow, author: Author, raw?: unknown) => Promise<{ principal: typeof chatExternalPrincipals.$inferSelect; userId: string | null; linkedDenied: boolean }>;
|
||||
runtimeCallbackEndpoint: (tx: DbTransaction, endpointId: string, context: LifecycleRuntimeFence, statuses: EndpointRow["status"][]) => Promise<EndpointRow | null>;
|
||||
getPublicBaseUrl: () => string | null;
|
||||
canonicalProviderResourceId: (provider: ChatProvider, thread: Pick<Thread, "channelId" | "id">) => string;
|
||||
nonDirectDestinationAllowed: (endpoint: EndpointRow, resource: typeof chatEndpointResources.$inferSelect | null | undefined) => boolean;
|
||||
sponsorAllowsGuest: (endpoint: EndpointRow) => Promise<boolean>;
|
||||
latestSlackDmControlThreadId: (endpointId: string, channelId: string) => Promise<string | null>;
|
||||
scheduleMessageProcessing: (task: () => Promise<void>) => void;
|
||||
processSlackTaskStart: ReturnType<typeof createSlackTaskStart>["processSlackTaskStart"];
|
||||
deferWebhookProcessing?: boolean;
|
||||
};
|
||||
|
||||
export function createSlackCommands(db: Db, options: SlackCommandsOptions) {
|
||||
const { runtime, runtimeFor, handleSdkMessage, stageProviderEffect, scheduleProviderEffect, ensurePrincipal, runtimeCallbackEndpoint, getPublicBaseUrl, canonicalProviderResourceId, nonDirectDestinationAllowed, sponsorAllowsGuest, latestSlackDmControlThreadId, scheduleMessageProcessing, processSlackTaskStart } = options;
|
||||
async function handleSlackSlashCommand(event: ChatSdkCallbackEvent<SlashCommandEvent>, runtimeContext: RuntimeContext, record: EndpointRecord, command: string, text: string) {
|
||||
const providerCommandId =
|
||||
event.event.triggerId ??
|
||||
createHash("sha256")
|
||||
.update(JSON.stringify(event.event.raw))
|
||||
.digest("hex");
|
||||
const stageSlackNotice = (tx: DbTransaction, notice: string, principalId?: string | null) =>
|
||||
stageProviderEffect(tx, {
|
||||
endpoint: record.endpoint,
|
||||
principalId: principalId ?? null,
|
||||
providerActionId: `provider_effect:slash_notice:${createHash("sha256").update(JSON.stringify([
|
||||
event.event.command, event.event.user.userId, providerCommandId, event.event.text,
|
||||
])).digest("hex")}`,
|
||||
payload: {
|
||||
version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice",
|
||||
threadId: event.event.channel.id, userId: event.event.user.userId,
|
||||
text: notice, settleDelivery: false,
|
||||
},
|
||||
runtimeContext,
|
||||
});
|
||||
const queueSlackNotice = async (notice: string, principalId?: string | null) => {
|
||||
const effect = await db.transaction((tx) => stageSlackNotice(tx, notice, principalId));
|
||||
if (!effect) throw new Error("Slack notice was not persisted");
|
||||
scheduleProviderEffect(effect.id, event.event.channel);
|
||||
};
|
||||
if (!["verifying", "active"].includes(record.endpoint.status)) {
|
||||
await queueSlackNotice("This Paperclip connection is not active.");
|
||||
return;
|
||||
}
|
||||
const expectedCommand =
|
||||
typeof record.endpoint.setup.command === "string"
|
||||
? record.endpoint.setup.command
|
||||
: slackCommandForAgent(
|
||||
record.assignedAgentName,
|
||||
record.endpoint.publicId,
|
||||
);
|
||||
if (event.provider !== "slack" || command !== expectedCommand) {
|
||||
await queueSlackNotice(
|
||||
`This connection only accepts ${expectedCommand}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const principal = await ensurePrincipal(
|
||||
record.endpoint,
|
||||
event.event.user,
|
||||
event.event.raw,
|
||||
);
|
||||
if (text.toLowerCase() === "connect") {
|
||||
// Discovery must work before identity and channel access are granted.
|
||||
// It records no task and grants no permissions; linking still requires
|
||||
// explicit confirmation by a company member in the board.
|
||||
if (principal.principal.kind !== "user" || principal.principal.isBot) return;
|
||||
const effect = await db.transaction(async (tx) => {
|
||||
if (!(await runtimeCallbackEndpoint(tx, event.endpointId, runtimeContext, ["verifying", "active"]))) {
|
||||
throw conflict("This Slack connection changed; send the connect command again");
|
||||
}
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.principal.id}`}, 0))`);
|
||||
const inserted = await tx.insert(chatActions).values({
|
||||
companyId: record.endpoint.companyId, endpointId: event.endpointId,
|
||||
principalId: principal.principal.id, kind: "slack_connect",
|
||||
providerActionId: `slack_connect:${providerCommandId}`,
|
||||
payload: { version: 1, channelId: event.event.channel.id, userId: event.event.user.userId },
|
||||
status: "processed", result: { code: "slack_identity_discovered" },
|
||||
}).onConflictDoNothing().returning({ id: chatActions.id });
|
||||
if (!inserted.length) return null;
|
||||
const currentLink = await tx.select().from(chatIdentityLinks).where(and(
|
||||
eq(chatIdentityLinks.endpointId, event.endpointId), eq(chatIdentityLinks.principalId, principal.principal.id),
|
||||
)).then((rows) => rows[0]);
|
||||
let notice = "Your Slack account is already connected to Paperclip. You can return to Slack and message the agent.";
|
||||
if (currentLink?.status !== "linked") {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
const tokenHash = createHash("sha256").update(token).digest("hex");
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
await tx.insert(chatIdentityLinks).values({
|
||||
companyId: record.endpoint.companyId, endpointId: event.endpointId, principalId: principal.principal.id,
|
||||
status: "pending", confirmationTokenHash: tokenHash, expiresAt,
|
||||
}).onConflictDoUpdate({
|
||||
target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId],
|
||||
set: { paperclipUserId: null, status: "pending", confirmationTokenHash: tokenHash, expiresAt, confirmedAt: null, revokedAt: null, updatedAt: new Date() },
|
||||
});
|
||||
await tx.update(chatActions).set({ payload: {
|
||||
version: 1, channelId: event.event.channel.id, userId: event.event.user.userId, identityLinkHash: tokenHash,
|
||||
} }).where(eq(chatActions.id, inserted[0].id));
|
||||
const url = `${getPublicBaseUrl()}/chat-identity/confirm?token=${encodeURIComponent(token)}`;
|
||||
notice = getPublicBaseUrl()
|
||||
? `[Connect your Paperclip account](${url}) — sign in and confirm this Slack identity. This private link expires in 15 minutes and works once. You can also confirm in the setup wizard. No agent work has started.`
|
||||
: "Return to the Paperclip setup wizard to confirm your Slack account. No agent work has started.";
|
||||
}
|
||||
await logActivity(tx as unknown as Db, {
|
||||
companyId: record.endpoint.companyId, actorType: "system", actorId: `chat:${principal.principal.id}`,
|
||||
action: "chat.slack_identity_discovered", entityType: "chat_endpoint", entityId: event.endpointId,
|
||||
details: { principalId: principal.principal.id },
|
||||
});
|
||||
const staged = await stageSlackNotice(tx, notice, principal.principal.id);
|
||||
if (!staged) throw new Error("Slack identity link notice was not persisted");
|
||||
return staged;
|
||||
});
|
||||
if (effect) scheduleProviderEffect(effect.id, event.event.channel);
|
||||
return;
|
||||
}
|
||||
const resource = await db
|
||||
.select()
|
||||
.from(chatEndpointResources)
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.endpointId, event.endpointId),
|
||||
eq(
|
||||
chatEndpointResources.providerResourceId,
|
||||
canonicalProviderResourceId(event.provider, {
|
||||
channelId: event.event.channel.id,
|
||||
id: event.event.channel.id,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
// Slack's SDK channel wrapper has reported false for isDM on real slash
|
||||
// callbacks even though the signed payload contains a D-prefixed channel
|
||||
// id. The provider id is authoritative for this routing decision.
|
||||
const rawSlackChannelId = event.event.channel.id.replace(/^slack:/, "");
|
||||
const slashIsDirectMessage = /^D[A-Z0-9]+$/i.test(rawSlackChannelId)
|
||||
? true
|
||||
: event.event.channel.isDM;
|
||||
const destinationAllowed = slashIsDirectMessage
|
||||
? record.endpoint.allowDirectMessages
|
||||
: nonDirectDestinationAllowed(record.endpoint, resource);
|
||||
const authorized =
|
||||
destinationAllowed &&
|
||||
!principal.linkedDenied &&
|
||||
(principal.userId !== null ||
|
||||
(record.endpoint.allowUnlinkedPeople &&
|
||||
(await sponsorAllowsGuest(record.endpoint))));
|
||||
if (!authorized) {
|
||||
await queueSlackNotice(
|
||||
"This channel or account is not allowed to start Paperclip work.",
|
||||
principal.principal.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const slackControl = /^(status|new|close)$/i
|
||||
.exec(text)?.[1]
|
||||
?.toLowerCase() as "status" | "new" | "close" | undefined;
|
||||
if (slackControl) {
|
||||
if (!slashIsDirectMessage) {
|
||||
// Slack slash-command payloads are channel-scoped and do not carry a
|
||||
// thread timestamp, so they cannot safely identify one of several
|
||||
// Paperclip tasks in the channel. Native @mention threads remain the
|
||||
// task-management surface there; the control vocabulary is exact only
|
||||
// in a DM, whose provider channel identity is stable.
|
||||
await queueSlackNotice(
|
||||
"Use status, new, and close in a direct message with this agent. In a channel, open the Paperclip task from its Slack thread.",
|
||||
principal.principal.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const endpointRuntime =
|
||||
runtime.get(event.endpointId) ?? (await runtimeFor(record.endpoint));
|
||||
// A slash-command task starts a real Slack root and binds Paperclip to
|
||||
// that returned thread id. Later slash controls carry only the DM channel
|
||||
// id, so target the most recently active task instead of synthesizing an
|
||||
// unrelated base-DM thread that cannot find the binding.
|
||||
const activeThreadId = await latestSlackDmControlThreadId(
|
||||
event.endpointId,
|
||||
rawSlackChannelId,
|
||||
);
|
||||
const thread = endpointRuntime.thread(
|
||||
activeThreadId ?? `slack:${rawSlackChannelId}:`,
|
||||
);
|
||||
const synthetic = {
|
||||
id: createHash("sha256")
|
||||
.update(
|
||||
`${event.event.command}:${event.event.user.userId}:${providerCommandId}:${slackControl}`,
|
||||
)
|
||||
.digest("hex"),
|
||||
threadId: thread.id,
|
||||
text: `/${slackControl}`,
|
||||
formatted: { type: "root", children: [] },
|
||||
raw: event.event.raw,
|
||||
author: event.event.user,
|
||||
metadata: { dateSent: new Date(), edited: false },
|
||||
attachments: [],
|
||||
links: [],
|
||||
isMention: true,
|
||||
} as unknown as Message;
|
||||
await handleSdkMessage(
|
||||
{
|
||||
endpointId: event.endpointId,
|
||||
provider: event.provider,
|
||||
thread,
|
||||
message: synthetic,
|
||||
trigger: "direct_message",
|
||||
},
|
||||
runtimeContext,
|
||||
{ receiptReactionSupported: false, slackSlashControl: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
await queueSlackNotice(
|
||||
`Use ${expectedCommand} followed by a task, or ${expectedCommand} status, new, or close in a direct message.`,
|
||||
principal.principal.id,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const providerActionId = `slash_task:${createHash("sha256")
|
||||
.update(
|
||||
`${event.event.command}:${event.event.user.userId}:${providerCommandId}`,
|
||||
)
|
||||
.digest("hex")}`;
|
||||
const syntheticMessageId = createHash("sha256")
|
||||
.update(
|
||||
`${event.event.command}:${event.event.user.userId}:${providerCommandId}`,
|
||||
)
|
||||
.digest("hex");
|
||||
// A duplicate Slack retry must acknowledge from the already-durable row
|
||||
// without waiting for the first worker's endpoint/identity locks or Web
|
||||
// API request. Slack otherwise treats a healthy in-flight command as an
|
||||
// acknowledgement timeout and retries it again.
|
||||
const existingAction = await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, record.endpoint.id),
|
||||
eq(chatActions.providerActionId, providerActionId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const insertedAction = existingAction
|
||||
? null
|
||||
: await db.transaction(async (tx) => {
|
||||
if (
|
||||
!(await runtimeCallbackEndpoint(
|
||||
tx,
|
||||
event.endpointId,
|
||||
runtimeContext,
|
||||
["verifying", "active"],
|
||||
))
|
||||
)
|
||||
return null;
|
||||
await notifyChatActionWork(tx, "slash_task_start");
|
||||
return tx
|
||||
.insert(chatActions)
|
||||
.values({
|
||||
companyId: record.endpoint.companyId,
|
||||
endpointId: record.endpoint.id,
|
||||
principalId: principal.principal.id,
|
||||
kind: "slash_task_start",
|
||||
providerActionId,
|
||||
payload: {
|
||||
version: 1,
|
||||
channelId: event.event.channel.id,
|
||||
command,
|
||||
syntheticMessageId,
|
||||
taskText: text,
|
||||
},
|
||||
status: "queued",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
const action =
|
||||
existingAction ??
|
||||
insertedAction ??
|
||||
(await db
|
||||
.select()
|
||||
.from(chatActions)
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.endpointId, record.endpoint.id),
|
||||
eq(chatActions.providerActionId, providerActionId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null));
|
||||
if (!action) throw new Error("Slack command admission was not persisted");
|
||||
// Slack has a tight acknowledgement budget. The unique action row is the
|
||||
// durable receipt; provider publication and Paperclip admission run behind
|
||||
// the acknowledgement under an atomic `received -> resolving` claim.
|
||||
// Duplicate callbacks schedule the same id and converge without waiting
|
||||
// on Slack's Web API or creating a second root.
|
||||
scheduleMessageProcessing(async () => {
|
||||
await processSlackTaskStart(
|
||||
action.id,
|
||||
options.deferWebhookProcessing === true,
|
||||
);
|
||||
});
|
||||
|
||||
}
|
||||
return { handleSlackSlashCommand };
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { SLACK_CHAT_BOT_SCOPES } from "@paperclipai/shared";
|
||||
|
||||
import {
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
const REQUIRED_SLACK_BOT_SCOPES = SLACK_CHAT_BOT_SCOPES;
|
||||
|
||||
export async function verifySlackCredentials(
|
||||
credentials: Record<string, string>,
|
||||
fetchImpl: typeof fetch,
|
||||
PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number,
|
||||
): Promise<VerifiedProviderIdentity> {
|
||||
const response = await fetchImpl("https://slack.com/api/auth.test", {
|
||||
method: "POST",
|
||||
signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS),
|
||||
headers: {
|
||||
authorization: `Bearer ${credentials.botToken}`,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: "",
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
error?: string;
|
||||
team_id?: string;
|
||||
team?: string;
|
||||
user_id?: string;
|
||||
user?: string;
|
||||
};
|
||||
if (!response.ok || !result.ok)
|
||||
throw unprocessable(
|
||||
`Slack rejected the bot token: ${result.error ?? response.status}`,
|
||||
);
|
||||
const grantedScopes = new Set(
|
||||
(response.headers.get("x-oauth-scopes") ?? "")
|
||||
.split(",")
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const missingScopes = REQUIRED_SLACK_BOT_SCOPES.filter(
|
||||
(scope) => !grantedScopes.has(scope),
|
||||
);
|
||||
if (missingScopes.length > 0) {
|
||||
throw unprocessable(
|
||||
`Slack app is missing required bot scopes: ${missingScopes.join(", ")}`,
|
||||
{
|
||||
code: "chat_provider_permissions_missing",
|
||||
provider: "slack",
|
||||
missingScopes,
|
||||
},
|
||||
);
|
||||
}
|
||||
return {
|
||||
providerAccountId: result.team_id,
|
||||
providerAccountLabel: result.team,
|
||||
botExternalId: result.user_id,
|
||||
botUsername: result.user,
|
||||
botLabel: result.user,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { EndpointRow, EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
import { slackResourceLabelIsFallback } from "./resources.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatEndpointResources,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import { getSlackBotChannel, listSlackBotChannels } from "../../chat-provider-inventory.js";
|
||||
|
||||
import type { PreparedProviderInventory } from "../runtime-types.js";
|
||||
|
||||
export async function prepareSlackInventory(credentials: Record<string,string>, fetchImpl: typeof fetch): Promise<PreparedProviderInventory> {
|
||||
return {
|
||||
credentials,
|
||||
inventory: await listSlackBotChannels({
|
||||
botToken: credentials.botToken,
|
||||
fetch: fetchImpl,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
type SlackInventoryOptions = {
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
|
||||
fetchImpl: typeof fetch;
|
||||
};
|
||||
|
||||
export function createSlackInventory(db: Db, options: SlackInventoryOptions) {
|
||||
const { endpointRecord, resolveCredentials, fetchImpl } = options;
|
||||
|
||||
|
||||
async function hydrateSlackResourceLabel(
|
||||
endpointId: string,
|
||||
providerResourceId: string,
|
||||
): Promise<void> {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (
|
||||
!record ||
|
||||
record.endpoint.provider !== "slack" ||
|
||||
record.endpoint.status === "archived" ||
|
||||
record.endpoint.status === "revoked"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const credentials = await resolveCredentials(record.endpoint);
|
||||
const resource = await getSlackBotChannel({
|
||||
botToken: credentials.botToken,
|
||||
channelId: providerResourceId,
|
||||
fetch: fetchImpl,
|
||||
});
|
||||
if (
|
||||
!resource ||
|
||||
slackResourceLabelIsFallback(providerResourceId, resource.label)
|
||||
)
|
||||
return;
|
||||
await db
|
||||
.update(chatEndpointResources)
|
||||
.set({ label: resource.label, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(chatEndpointResources.companyId, record.endpoint.companyId),
|
||||
eq(chatEndpointResources.endpointId, endpointId),
|
||||
eq(chatEndpointResources.type, "channel"),
|
||||
eq(chatEndpointResources.providerResourceId, providerResourceId),
|
||||
eq(chatEndpointResources.availability, "available"),
|
||||
),
|
||||
);
|
||||
}
|
||||
return { hydrateSlackResourceLabel };
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { EndpointRow, DbOrTransaction } from "../runtime-types.js";
|
||||
|
||||
import { slackSearchActionToken } from "../../connectors/slack-search-context.js";
|
||||
import { slackAuthorizationRevision } from "../../connectors/slack-revision.js";
|
||||
|
||||
import { instanceSettingsService } from "../../instance-settings.js";
|
||||
import { registerSlackTaskAuthority, slackRunOrigin } from "../../connectors/slack-authority.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
isNull,
|
||||
ne,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatConversations,
|
||||
chatDeliveries,
|
||||
chatEndpoints,
|
||||
chatExternalPrincipals,
|
||||
chatIdentityLinks,
|
||||
chatMessageLinks,
|
||||
companyMemberships,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
forbidden,
|
||||
} from "../../../errors.js";
|
||||
|
||||
type SlackTaskAuthorityRegistrationOptions = {
|
||||
authorizeInboundWakeup: (tx: DbOrTransaction, action: typeof chatActions.$inferSelect) => Promise<{
|
||||
issue: typeof issues.$inferSelect;
|
||||
delivery: typeof chatDeliveries.$inferSelect;
|
||||
endpoint: EndpointRow;
|
||||
conversation: typeof chatConversations.$inferSelect;
|
||||
}>;
|
||||
resolveCredentials: (endpoint: EndpointRow) => Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export function createSlackTaskAuthorityRegistration(db: Db, options: SlackTaskAuthorityRegistrationOptions) {
|
||||
const { authorizeInboundWakeup, resolveCredentials } = options;
|
||||
|
||||
|
||||
const unregisterSlackTaskAuthority = registerSlackTaskAuthority(db, async (binding) => {
|
||||
if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors)
|
||||
throw forbidden("Chat connectors are disabled");
|
||||
const identity = await slackRunOrigin(db, binding);
|
||||
const run = identity.run;
|
||||
if ((run.contextSnapshot?.issueId ?? run.contextSnapshot?.taskId) !== binding.issueId || !run.responsibleUserId)
|
||||
throw forbidden("Slack tools require the bound task and an accepted linked user");
|
||||
const resolved = await db.transaction(async (tx) => {
|
||||
const [action] = await tx.select().from(chatActions).where(and(
|
||||
eq(chatActions.companyId, binding.companyId),
|
||||
eq(chatActions.kind, "inbound_wakeup"),
|
||||
sql`${chatActions.payload}->>'issueId' = ${binding.issueId}`,
|
||||
sql`${chatActions.payload}->>'agentId' = ${binding.agentId}`,
|
||||
identity.sourceMessageId
|
||||
? sql`${chatActions.payload}->>'commentId' = ${identity.sourceMessageId}`
|
||||
: eq(chatActions.id, run.wakeupRequestId ?? "00000000-0000-0000-0000-000000000000"),
|
||||
)).limit(1);
|
||||
if (!action) {
|
||||
// Ordinary tasks and routines use their accepted responsible user, never
|
||||
// an earlier Slack sender or the connection owner's credentials.
|
||||
const [message] = identity.sourceMessageId ? await tx.select().from(issueComments)
|
||||
.where(and(eq(issueComments.companyId, binding.companyId), eq(issueComments.issueId, binding.issueId),
|
||||
eq(issueComments.id, identity.sourceMessageId), isNull(issueComments.deletedAt))) : [];
|
||||
if (identity.sourceMessageId && (!message || message.authorType !== "user" || message.authorUserId !== run.responsibleUserId))
|
||||
throw forbidden("Slack tools require the current task requester's identity");
|
||||
// An external participant may not acquire unrelated bot connections by
|
||||
// falling back to ordinary-task access after source admission fails.
|
||||
if (identity.sourceMessageId) {
|
||||
const [external] = await tx.select({ id: chatMessageLinks.id }).from(chatMessageLinks)
|
||||
.where(and(eq(chatMessageLinks.companyId, binding.companyId), eq(chatMessageLinks.commentId, identity.sourceMessageId), eq(chatMessageLinks.direction, "inbound"))).limit(1);
|
||||
if (external) throw forbidden("External messages require their originating Slack connection");
|
||||
}
|
||||
const endpoints = await tx.select().from(chatEndpoints).where(and(
|
||||
eq(chatEndpoints.companyId, binding.companyId), eq(chatEndpoints.provider, "slack"),
|
||||
eq(chatEndpoints.assignedAgentId, binding.agentId), eq(chatEndpoints.status, "active"),
|
||||
...(binding.endpointId ? [eq(chatEndpoints.id, binding.endpointId)] : []),
|
||||
)).for("no key update");
|
||||
if (endpoints.length !== 1) throw forbidden("Select an active Slack connection assigned to this agent");
|
||||
const endpoint = endpoints[0]!;
|
||||
const [issue] = await tx.select().from(issues).where(and(eq(issues.companyId, binding.companyId), eq(issues.id, binding.issueId), eq(issues.assigneeAgentId, binding.agentId)));
|
||||
if (!issue) throw forbidden("Slack tools require a task assigned to this agent");
|
||||
const [member] = await tx.select().from(companyMemberships).where(and(
|
||||
eq(companyMemberships.companyId, binding.companyId), eq(companyMemberships.principalType, "user"),
|
||||
eq(companyMemberships.principalId, run.responsibleUserId!), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"),
|
||||
));
|
||||
if (!member) throw forbidden("The responsible user must be an active company operator");
|
||||
const links = await tx.select({ principal: chatExternalPrincipals }).from(chatIdentityLinks)
|
||||
.innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, binding.companyId)))
|
||||
.where(and(eq(chatIdentityLinks.companyId, binding.companyId), eq(chatIdentityLinks.endpointId, endpoint.id),
|
||||
eq(chatIdentityLinks.paperclipUserId, run.responsibleUserId!), eq(chatIdentityLinks.status, "linked"),
|
||||
eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId!), eq(chatExternalPrincipals.isBot, false)));
|
||||
if (links.length !== 1) throw forbidden("Link your Slack account to this connection before using it from tasks or routines");
|
||||
// A Board message changes who is directing the work, not the privacy
|
||||
// of context already present in its linked Slack conversation.
|
||||
const conversations = await tx.select().from(chatConversations).where(and(
|
||||
eq(chatConversations.companyId, binding.companyId), eq(chatConversations.endpointId, endpoint.id),
|
||||
eq(chatConversations.issueId, binding.issueId),
|
||||
)).limit(2);
|
||||
if (conversations.length > 1) throw forbidden("The task's Slack conversation boundary is ambiguous");
|
||||
return { endpoint, issue, conversation: conversations[0] ?? null, delivery: null, principalId: links[0]!.principal.id, slackUserId: links[0]!.principal.externalId };
|
||||
}
|
||||
if (action.payload.requestedByActorType !== "user" || action.payload.requestedByActorId !== run.responsibleUserId ||
|
||||
(binding.endpointId && action.endpointId !== binding.endpointId))
|
||||
throw forbidden("Slack tools require a verified linked Slack request");
|
||||
// This standalone resolver does not own the scheduler's issue lock. Use
|
||||
// the ingress lock order (endpoint, then issue) so a normal webhook or
|
||||
// endpoint update cannot turn tool setup into a NOWAIT failure. The
|
||||
// scheduler retains its nonblocking check when it owns the issue first.
|
||||
await tx.select({ id: chatEndpoints.id }).from(chatEndpoints).where(and(
|
||||
eq(chatEndpoints.companyId, binding.companyId),
|
||||
eq(chatEndpoints.id, action.endpointId),
|
||||
)).for("no key update");
|
||||
const source = await authorizeInboundWakeup(tx, action);
|
||||
if (source.endpoint.provider !== "slack") throw forbidden("This is not a Slack task");
|
||||
const [principal] = await tx.select().from(chatExternalPrincipals).where(and(
|
||||
eq(chatExternalPrincipals.companyId, binding.companyId),
|
||||
eq(chatExternalPrincipals.id, action.principalId!),
|
||||
eq(chatExternalPrincipals.provider, "slack"),
|
||||
eq(chatExternalPrincipals.providerAccountId, source.endpoint.providerAccountId!),
|
||||
eq(chatExternalPrincipals.isBot, false),
|
||||
));
|
||||
if (!principal) throw forbidden("Slack sender is no longer available");
|
||||
return { ...source, principalId: principal.id, slackUserId: principal.externalId };
|
||||
});
|
||||
const credentials = await resolveCredentials(resolved.endpoint);
|
||||
if (!credentials.botToken) throw forbidden("Slack bot credential is unavailable");
|
||||
return {
|
||||
endpoint: resolved.endpoint, issueId: binding.issueId, conversation: resolved.conversation,
|
||||
principalId: resolved.principalId, slackUserId: resolved.slackUserId,
|
||||
userId: run.responsibleUserId, deliveryId: resolved.delivery?.id ?? null,
|
||||
identityContextId: identity.context?.id ?? null,
|
||||
workMode: resolved.issue.workMode,
|
||||
revision: createHash("sha256").update(JSON.stringify([
|
||||
resolved.endpoint.status, resolved.endpoint.allowDirectMessages, resolved.conversation?.sessionGeneration ?? null,
|
||||
run.responsibleUserId, credentials.botToken,
|
||||
await slackAuthorizationRevision(db, binding.companyId, resolved.endpoint.id, resolved.endpoint.connectionId, binding.agentId, run.responsibleUserId),
|
||||
])).digest("hex"),
|
||||
botToken: credentials.botToken,
|
||||
searchActionToken: resolved.delivery ? slackSearchActionToken(db, resolved.endpoint.id, resolved.endpoint.providerAccountId!, resolved.slackUserId, String((resolved.delivery.normalizedEvent.message as Record<string, unknown> | undefined)?.providerMessageId ?? "")) : null,
|
||||
};
|
||||
});
|
||||
return { unregisterSlackTaskAuthority };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createSpekoProvider } from "../../voice/speko-provider.js";
|
||||
|
||||
import {
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
export async function verifySpekoCredentials(
|
||||
credentials: Record<string, string>,
|
||||
fetchImpl: typeof fetch,
|
||||
): Promise<VerifiedProviderIdentity> {
|
||||
if (!/^whsec_[A-Za-z0-9+/]{43}=$/.test(credentials.signingSecret ?? "")) throw unprocessable("Speko requires a Standard Webhooks signing secret");
|
||||
const identity = await createSpekoProvider(credentials.apiKey, fetchImpl).verifyAgent(credentials.agentId);
|
||||
return { providerAccountId: identity.organizationId, providerAccountLabel: "Speko workspace", botExternalId: identity.id, botLabel: identity.name };
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createProviderEffects, type ProviderEffectTarget } from "../provider-effects.js";
|
||||
|
||||
import type { EndpointRow, RuntimeContext, InboundRuntimeContext } from "../runtime-types.js";
|
||||
|
||||
import { notifyChatDeliveryWork } from "../../chat-work-notifications.js";
|
||||
|
||||
import {
|
||||
and,
|
||||
eq,
|
||||
} from "drizzle-orm";
|
||||
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
chatActions,
|
||||
chatDeliveries,
|
||||
issueComments,
|
||||
} from "@paperclipai/db";
|
||||
|
||||
import {
|
||||
formatAttachmentSize,
|
||||
MAX_ATTACHMENT_BYTES,
|
||||
} from "../../../attachment-types.js";
|
||||
|
||||
import type {
|
||||
Message,
|
||||
Thread,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
type DeliveryRow = typeof chatDeliveries.$inferSelect;
|
||||
type TelegramAttachmentFailureOptions = {
|
||||
attachmentOmissionDetail: (result: { omissionReasons: Record<string, number> }) => string | null;
|
||||
runtimeContextForRecord: (record: EndpointRecord) => RuntimeContext;
|
||||
endpointRecord: (endpointId: string) => Promise<EndpointRecord | null>;
|
||||
stageProviderEffect: ReturnType<typeof createProviderEffects>["stageProviderEffect"];
|
||||
scheduleProviderEffect: (actionId: string, liveTarget?: ProviderEffectTarget) => void;
|
||||
};
|
||||
|
||||
export function createTelegramAttachmentFailure(db: Db, options: TelegramAttachmentFailureOptions) {
|
||||
const { attachmentOmissionDetail, runtimeContextForRecord, endpointRecord, stageProviderEffect, scheduleProviderEffect } = options;
|
||||
|
||||
|
||||
async function settleTelegramAttachmentOnlyFailure(input: {
|
||||
activeDelivery: DeliveryRow;
|
||||
attachmentResult: {
|
||||
storedIds: string[];
|
||||
omissionReasons: Record<string, number>;
|
||||
};
|
||||
commentId: string;
|
||||
conversationId: string;
|
||||
endpoint: EndpointRow;
|
||||
message: Message;
|
||||
principalId: string | null;
|
||||
resourceId: string | null;
|
||||
runtimeContext?: InboundRuntimeContext;
|
||||
thread: Thread;
|
||||
}): Promise<boolean> {
|
||||
if (
|
||||
input.endpoint.provider !== "telegram" ||
|
||||
input.message.text.trim().length > 0 ||
|
||||
input.message.attachments.length === 0 ||
|
||||
input.attachmentResult.storedIds.length > 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const diagnostic =
|
||||
attachmentOmissionDetail(input.attachmentResult) ??
|
||||
"The Telegram attachment could not be imported";
|
||||
const visibleFailure = `Paperclip could not safely import the attached Telegram file. Please resend it as a supported file under ${formatAttachmentSize(MAX_ATTACHMENT_BYTES)} or include text describing the request.`;
|
||||
const effectContext =
|
||||
input.runtimeContext ??
|
||||
runtimeContextForRecord(
|
||||
(await endpointRecord(input.endpoint.id)) ??
|
||||
(() => {
|
||||
throw new Error("Chat endpoint is unavailable");
|
||||
})(),
|
||||
);
|
||||
const effect = await db.transaction(async (tx) => {
|
||||
// Replace the optimistic attachment placeholder with an explicit safe
|
||||
// Paperclip-visible failure. The message link stays authoritative for
|
||||
// exact provider redelivery, while no agent wakeup is enqueued for an
|
||||
// empty turn that contains no usable bytes.
|
||||
await tx
|
||||
.update(issueComments)
|
||||
.set({ body: visibleFailure, updatedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.id, input.commentId),
|
||||
eq(issueComments.companyId, input.endpoint.companyId),
|
||||
),
|
||||
);
|
||||
await tx
|
||||
.update(chatActions)
|
||||
.set({
|
||||
status: "failed",
|
||||
result: { code: "inbound_wakeup_attachment_unavailable" },
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatActions.deliveryId, input.activeDelivery.id),
|
||||
eq(chatActions.kind, "inbound_wakeup"),
|
||||
eq(chatActions.status, "preparing"),
|
||||
),
|
||||
);
|
||||
const staged = await stageProviderEffect(tx, {
|
||||
endpoint: input.endpoint,
|
||||
deliveryId: input.activeDelivery.id,
|
||||
conversationId: input.conversationId,
|
||||
principalId: input.principalId,
|
||||
providerActionId: `provider_effect:attachment-ingest-failure:${input.activeDelivery.id}`,
|
||||
payload: {
|
||||
version: 1,
|
||||
authorizationMode: "safe_notice",
|
||||
effect: "thread_message",
|
||||
threadId: input.thread.id,
|
||||
text: visibleFailure,
|
||||
settleDelivery: false,
|
||||
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
||||
},
|
||||
runtimeContext: effectContext,
|
||||
});
|
||||
await notifyChatDeliveryWork(tx);
|
||||
await tx
|
||||
.update(chatDeliveries)
|
||||
.set({
|
||||
conversationId: input.conversationId,
|
||||
state: "processed",
|
||||
processedAt: new Date(),
|
||||
nextAttemptAt: null,
|
||||
redactedError: diagnostic,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(chatDeliveries.id, input.activeDelivery.id),
|
||||
eq(chatDeliveries.state, "processing"),
|
||||
),
|
||||
);
|
||||
return staged;
|
||||
});
|
||||
if (!effect) throw new Error("Attachment failure notice was not persisted");
|
||||
// Posting the safe notice is its own durable outbox operation. A provider
|
||||
// outage must not turn the already-settled, deliberately unwoken inbound
|
||||
// delivery back into a replayable attachment message.
|
||||
scheduleProviderEffect(effect.id, input.thread);
|
||||
return true;
|
||||
}
|
||||
return { settleTelegramAttachmentOnlyFailure };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EndpointRow, RuntimeContext } from "../runtime-types.js";
|
||||
|
||||
import {
|
||||
telegramMessageId,
|
||||
telegramMessageSentAt,
|
||||
} from "./messages.js";
|
||||
|
||||
import { type ChatRuntime } from "../../chat-runtime.js";
|
||||
import type { ChatEndpointRuntime } from "../../voice/voice-runtime.js";
|
||||
|
||||
import {
|
||||
createHash,
|
||||
} from "node:crypto";
|
||||
|
||||
import {
|
||||
type ChatSdkCallbackEvent,
|
||||
type ChatSdkMessageCallbackEvent,
|
||||
type ChatSdkRuntime,
|
||||
} from "../../chat-sdk-runtime.js";
|
||||
|
||||
import type {
|
||||
Message,
|
||||
SlashCommandEvent,
|
||||
} from "chat";
|
||||
|
||||
import type { EndpointRecord } from "../runtime-types.js";
|
||||
|
||||
export function telegramGuidanceCommand(
|
||||
text: string,
|
||||
): "start" | "task" | "unknown" | null {
|
||||
const match = /^\/([a-z][\w-]*)(?:@[\w.-]+)?(?:\s|$)/i.exec(text.trim());
|
||||
const command = match?.[1]?.toLowerCase();
|
||||
if (
|
||||
!command ||
|
||||
command === "new" ||
|
||||
command === "close" ||
|
||||
command === "status"
|
||||
)
|
||||
return null;
|
||||
if (command === "start") return "start";
|
||||
if (command === "task") return "task";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
type TelegramCommandsOptions = {
|
||||
runtime: Pick<ChatSdkRuntime | ChatRuntime, "get">;
|
||||
runtimeFor: (endpoint: EndpointRow) => Promise<ChatEndpointRuntime>;
|
||||
handleSdkMessage: (event: ChatSdkMessageCallbackEvent, context: RuntimeContext) => Promise<void>;
|
||||
};
|
||||
|
||||
export function createTelegramCommands(options: TelegramCommandsOptions) {
|
||||
const { runtime, runtimeFor, handleSdkMessage } = options;
|
||||
async function handleTelegramSlashCommand(event: ChatSdkCallbackEvent<SlashCommandEvent>, runtimeContext: RuntimeContext, record: EndpointRecord, command: string, text: string) {
|
||||
const endpointRuntime =
|
||||
runtime.get(event.endpointId) ?? (await runtimeFor(record.endpoint));
|
||||
const thread = endpointRuntime.thread(event.event.channel.id);
|
||||
const parsedCommandMessage =
|
||||
endpointRuntime.parseTelegramCommandMessage?.(event.event.raw) ?? null;
|
||||
const synthetic = {
|
||||
id:
|
||||
telegramMessageId(event.event.raw) ??
|
||||
event.event.triggerId ??
|
||||
createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
command,
|
||||
raw: event.event.raw,
|
||||
userId: event.event.user.userId,
|
||||
}),
|
||||
)
|
||||
.digest("hex"),
|
||||
threadId: thread.id,
|
||||
// Telegram privacy mode does not deliver an ordinary @mention in a
|
||||
// group. /task@bot <request> is therefore the provider-native root
|
||||
// activation path; direct replies and later /task commands can
|
||||
// continue the same active group/topic task.
|
||||
text: command === "/task" && text ? text : command,
|
||||
formatted: { type: "root", children: [] },
|
||||
raw: event.event.raw,
|
||||
author: event.event.user,
|
||||
metadata: {
|
||||
// Telegram slash commands carry the original message as `raw`.
|
||||
// Preserve its whole-second provider clock so the durable drain can
|
||||
// use message_id to order a command and the next rapid DM correctly.
|
||||
dateSent: telegramMessageSentAt(event.event.raw) ?? new Date(),
|
||||
edited: false,
|
||||
},
|
||||
// Telegram routes a captioned file command through onSlashCommand
|
||||
// instead of the ordinary message callback. Preserve the provider's
|
||||
// parsed attachment descriptors so the durable delivery worker can
|
||||
// rehydrate and ingest them exactly like non-command media.
|
||||
attachments: parsedCommandMessage?.attachments ?? [],
|
||||
links: [],
|
||||
isMention: true,
|
||||
} as unknown as Message;
|
||||
await handleSdkMessage(
|
||||
{
|
||||
endpointId: event.endpointId,
|
||||
provider: event.provider,
|
||||
thread,
|
||||
message: synthetic,
|
||||
trigger: thread.isDM ? "direct_message" : "mention",
|
||||
},
|
||||
runtimeContext,
|
||||
);
|
||||
return;
|
||||
}
|
||||
return { handleTelegramSlashCommand };
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
unprocessable,
|
||||
} from "../../../errors.js";
|
||||
|
||||
import type { VerifiedProviderIdentity } from "../runtime-types.js";
|
||||
|
||||
export async function verifyTelegramCredentials(
|
||||
credentials: Record<string, string>,
|
||||
fetchImpl: typeof fetch,
|
||||
PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS: number,
|
||||
): Promise<VerifiedProviderIdentity> {
|
||||
const response = await fetchImpl(
|
||||
`https://api.telegram.org/bot${encodeURIComponent(credentials.botToken)}/getMe`,
|
||||
{ signal: AbortSignal.timeout(PROVIDER_CREDENTIAL_CHECK_TIMEOUT_MS) },
|
||||
);
|
||||
const result = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: { id?: number; username?: string; first_name?: string };
|
||||
};
|
||||
if (!response.ok || !result.ok)
|
||||
throw unprocessable(
|
||||
`Telegram rejected the bot token: ${result.description ?? response.status}`,
|
||||
);
|
||||
return {
|
||||
providerAccountId: String(result.result?.id ?? ""),
|
||||
botExternalId: String(result.result?.id ?? ""),
|
||||
botUsername: result.result?.username,
|
||||
botLabel: result.result?.first_name,
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { notifyChatPublicationWork } from "./chat-work-notifications.js";
|
||||
import { recordChatHandoff, recordChatCompletion, existingChatCompletionReply, acknowledgeChatCompletionReply } from "./chat-completion-delivery.js";
|
||||
import { mirrorSlackBoardComment, slackBoardReplyBindings } from "./slack-board-messages.js";
|
||||
import { assertAgentRunWriteAllowed } from "../agent-run-cancellation.js";
|
||||
import { assertLegacyRunTaskOwnership } from "../legacy-run-task-ownership.js";
|
||||
import { retryIdempotentDatabaseOperation } from "../database-retry.js";
|
||||
import { externalConversationStateSql, nonIdleSlackIssueCondition, resumeSlackConversation } from "./slack-conversation-state.js";
|
||||
import { documentService } from "./documents.js";
|
||||
@@ -11194,9 +11195,10 @@ export function issueService(db: Db) {
|
||||
if (actorAgentId && actorRunId) {
|
||||
// Recheck under a run lock: a request admitted before Stop must not
|
||||
// commit a late Done after cancellation revoked its credentials.
|
||||
await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, {
|
||||
const actorRun = await assertAgentRunWriteAllowed(tx, receiptExisting.companyId, {
|
||||
agentId: actorAgentId, runId: actorRunId, stopId: actorRunStopId,
|
||||
});
|
||||
assertLegacyRunTaskOwnership(actorRun, receiptExisting);
|
||||
}
|
||||
if (actorAgentId && patch.status === "done") {
|
||||
const [review] = await tx.select({ id: toolActionRequests.id }).from(toolActionRequests).where(and(eq(toolActionRequests.companyId, existing.companyId), eq(toolActionRequests.issueId, id), inArray(toolActionRequests.status, ["pending", "approved", "executing"]))).limit(1);
|
||||
|
||||
@@ -1286,7 +1286,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
|
||||
action: "issue.created", entityType: "issue", entityId: child.id,
|
||||
details: { identifier: child.identifier, title: child.title, parentId: child.parentId,
|
||||
assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" },
|
||||
assigneeAgentId: child.assigneeAgentId, assigneeUserId: child.assigneeUserId, status: childStatus, source: "paperclip_runner_protocol" },
|
||||
});
|
||||
publication = activity.publication;
|
||||
}
|
||||
@@ -1486,7 +1486,8 @@ export class PaperclipRunnerToolAuthority {
|
||||
agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId,
|
||||
action: "issue.reassigned", entityType: "issue", entityId: taskId,
|
||||
details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint,
|
||||
previousAssigneeAgentId: target.assigneeAgentId, assigneeAgentId, reason, changed, receipt },
|
||||
previousAssigneeAgentId: target.assigneeAgentId, previousAssigneeUserId: target.assigneeUserId,
|
||||
assigneeAgentId, assigneeUserId, reason, changed, receipt },
|
||||
});
|
||||
publication = activity.publication;
|
||||
return receipt;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { activityLog, agents, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
|
||||
import { activityLog, agents, authUsers, companyMemberships, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db";
|
||||
import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js";
|
||||
@@ -49,6 +49,24 @@ describe("runner task reassignment", () => {
|
||||
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.targetId))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("records human assignment fields for native creation and reassignment", async () => {
|
||||
const f = await fixture();
|
||||
const person = randomUUID();
|
||||
await db.insert(authUsers).values({ id: person, name: "Reviewer", email: `${person}@example.test`, createdAt: new Date(), updatedAt: new Date() });
|
||||
await db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: person, status: "active", membershipRole: "operator" });
|
||||
await f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, assigneeActorId: null, assigneeUserId: person } });
|
||||
const [reassigned] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, f.targetId), eq(activityLog.action, "issue.reassigned")));
|
||||
expect(reassigned).toMatchObject({ actorType: "agent", actorId: f.agentId, details: {
|
||||
previousAssigneeAgentId: f.agentId, previousAssigneeUserId: null,
|
||||
assigneeAgentId: null, assigneeUserId: person, changed: true,
|
||||
} });
|
||||
const created = await f.authority.execute({ tool: "create_task", callId: "human-child", arguments: {
|
||||
title: "Review result", assigneeUserId: person, idempotencyKey: "human-child",
|
||||
} }) as { task: { id: string } };
|
||||
const [creation] = await db.select().from(activityLog).where(and(eq(activityLog.entityId, created.task.id), eq(activityLog.action, "issue.created")));
|
||||
expect(creation).toMatchObject({ actorType: "agent", actorId: f.agentId, details: { assigneeAgentId: null, assigneeUserId: person } });
|
||||
});
|
||||
|
||||
it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => {
|
||||
const f = await fixture(status);
|
||||
await f.authority.execute(f.call);
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
// Real local Board UI, upload storage, comment HTTP routes, and disposable DB.
|
||||
// Only the interface flag and explicit transport/upload rejection faults are mocked.
|
||||
// Tasks belong to the Board user: no agent, runner, or provider is contacted.
|
||||
// Tasks have no recipient: no agent, runner, or provider is contacted.
|
||||
type Attachment = {
|
||||
id: string;
|
||||
issueCommentId: string | null;
|
||||
@@ -44,7 +44,9 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) {
|
||||
data: {
|
||||
title: "Inspect the newly uploaded Board files",
|
||||
status: "todo",
|
||||
assigneeUserId: "local-board",
|
||||
// Keep attachment sends on the comment route. A task assigned to the
|
||||
// current human now suggests an empty recipient and sends a reassignment.
|
||||
assigneeUserId: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -78,6 +80,14 @@ async function setup(page: Page, request: APIRequestContext, classic: boolean) {
|
||||
composer,
|
||||
editor,
|
||||
send,
|
||||
sendComment: async () => {
|
||||
await send.click();
|
||||
if (classic) {
|
||||
const confirm = page.getByTestId("issue-chat-no-assignee-send-anyway");
|
||||
await expect(confirm).toBeVisible();
|
||||
await confirm.click();
|
||||
}
|
||||
},
|
||||
attachments: () =>
|
||||
request
|
||||
.get(`/api/issues/${issue.id}/attachments`)
|
||||
@@ -183,7 +193,7 @@ for (const classic of [false, true]) {
|
||||
req.method() === "POST" &&
|
||||
new URL(req.url()).pathname.endsWith("/comments"),
|
||||
);
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
expect((await outbound).postDataJSON().attachmentIds.sort()).toEqual(
|
||||
receipts.map((row) => row.id).sort(),
|
||||
);
|
||||
@@ -244,7 +254,7 @@ for (const classic of [false, true]) {
|
||||
accepted = true;
|
||||
await route.abort("connectionreset");
|
||||
});
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect.poll(() => accepted).toBe(true);
|
||||
expect(await fixture.comments()).toHaveLength(1);
|
||||
expect(acceptedRequestId).toEqual(expect.any(String));
|
||||
@@ -276,7 +286,7 @@ for (const classic of [false, true]) {
|
||||
await fixture.editor.fill(
|
||||
"A deliberately new comment after the original receipt settled.",
|
||||
);
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
||||
});
|
||||
|
||||
@@ -298,14 +308,14 @@ for (const classic of [false, true]) {
|
||||
body: JSON.stringify({ error: "Fixture policy rejected this attempt" }),
|
||||
});
|
||||
});
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect(fixture.editor).toContainText("Known rejection,");
|
||||
await expect(fixture.send).toBeEnabled();
|
||||
expect(await fixture.comments()).toHaveLength(0);
|
||||
await page.reload();
|
||||
await expect(fixture.editor).toContainText("Known rejection,");
|
||||
await expect(fixture.composer.getByRole("alert")).toHaveCount(0);
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
||||
expect(
|
||||
(await fixture.attachments()).find((row) => row.id === receipt.id)
|
||||
@@ -339,7 +349,7 @@ for (const classic of [false, true]) {
|
||||
await route.fulfill({ response }).catch(() => {});
|
||||
});
|
||||
try {
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect.poll(() => accepted).toBe(true);
|
||||
expect(await fixture.comments()).toHaveLength(1);
|
||||
await fixture.editor.fill("A newer draft written while delivery was pending.");
|
||||
@@ -354,7 +364,7 @@ for (const classic of [false, true]) {
|
||||
expect(await fixture.comments()).toHaveLength(1);
|
||||
expect(acceptedRequestId).toEqual(expect.any(String));
|
||||
expect((await fixture.comments())[0]!.clientRequestId).toBe(acceptedRequestId);
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect.poll(async () => (await fixture.comments()).length).toBe(2);
|
||||
expect(attempts).toBe(2);
|
||||
expect((await fixture.comments()).map((comment) => comment.body).sort()).toEqual([
|
||||
@@ -412,7 +422,7 @@ for (const classic of [false, true])
|
||||
req.method() === "POST" &&
|
||||
new URL(req.url()).pathname.endsWith("/comments"),
|
||||
);
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
expect((await outbound).postDataJSON().attachmentIds).toBeUndefined();
|
||||
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
||||
expect((await fixture.comments())[0]!.body).not.toContain(
|
||||
@@ -450,7 +460,7 @@ test("legacy failed upload can be removed before sending the retained text", asy
|
||||
});
|
||||
await expect(remove).toBeVisible();
|
||||
await remove.click();
|
||||
await fixture.send.click();
|
||||
await fixture.sendComment();
|
||||
await expect.poll(async () => (await fixture.comments()).length).toBe(1);
|
||||
expect((await fixture.comments())[0]!.body).toBe(
|
||||
"Keep this text after removing the failed file.",
|
||||
|
||||
@@ -59,6 +59,7 @@ export interface ChatIdentityLink {
|
||||
externalDetail?: string | null;
|
||||
paperclipUserId?: string | null;
|
||||
paperclipUserLabel?: string | null;
|
||||
paperclipUserImage?: string | null;
|
||||
status: "linked" | "pending" | "revoked";
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<select aria-label="Test recipient" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="agent:first">First agent</option>
|
||||
<option value="agent:latest">Latest agent</option>
|
||||
<option value="user:me">Me</option>
|
||||
<option value="">No responsible</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./ApprovalCard", () => ({
|
||||
@@ -78,6 +85,34 @@ describe("CommentThread", () => {
|
||||
let writeTextMock: ReturnType<typeof vi.fn<(text: string) => Promise<void>>>;
|
||||
let execCommandMock: ReturnType<typeof vi.fn<typeof document.execCommand>>;
|
||||
|
||||
it("updates automatic suggestions but preserves manual choices until the task changes", () => {
|
||||
const root = createRoot(container);
|
||||
const render = (suggestion: string, draftKey = "task-one") => act(() => root.render(
|
||||
<MemoryRouter>
|
||||
<CommentThread comments={[]} onAdd={async () => {}} enableReassign
|
||||
reassignOptions={[{ id: "agent:first", label: "First agent" }]}
|
||||
currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement;
|
||||
render("agent:first");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe("agent:latest");
|
||||
for (const manual of ["user:me", ""]) {
|
||||
act(() => {
|
||||
selector().value = manual;
|
||||
selector().dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
render("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe(manual);
|
||||
}
|
||||
render("agent:first", "task-two");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
@@ -752,6 +752,12 @@ export function CommentThread({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [attaching, setAttaching] = useState(false);
|
||||
const effectiveSuggestedAssigneeValue = suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const manualAssigneeRef = useRef(false);
|
||||
const assigneeDraftKeyRef = useRef(draftKey);
|
||||
const selectAssignee = (value: string) => {
|
||||
manualAssigneeRef.current = true;
|
||||
setReassignTarget(value);
|
||||
};
|
||||
const [reassignTarget, setReassignTarget] = useState(effectiveSuggestedAssigneeValue);
|
||||
const [highlightCommentId, setHighlightCommentId] = useState<string | null>(null);
|
||||
const [votingTargetId, setVotingTargetId] = useState<string | null>(null);
|
||||
@@ -852,8 +858,12 @@ export function CommentThread({
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
if (assigneeDraftKeyRef.current !== draftKey) {
|
||||
assigneeDraftKeyRef.current = draftKey;
|
||||
manualAssigneeRef.current = false;
|
||||
}
|
||||
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [draftKey, effectiveSuggestedAssigneeValue]);
|
||||
|
||||
// Scroll to comment when URL hash matches #comment-{id}
|
||||
useEffect(() => {
|
||||
@@ -898,6 +908,7 @@ export function CommentThread({
|
||||
try {
|
||||
await onAdd(submittedBody, reopen, reassignment ?? undefined);
|
||||
if (draftKey) clearDraft(draftKey);
|
||||
manualAssigneeRef.current = false;
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
} catch {
|
||||
setBody((current) =>
|
||||
@@ -1051,7 +1062,7 @@ export function CommentThread({
|
||||
noneLabel="No responsible"
|
||||
searchPlaceholder="Search responsible..."
|
||||
emptyMessage="No responsible found."
|
||||
onChange={setReassignTarget}
|
||||
onChange={selectAssignee}
|
||||
className="text-xs h-8"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option) return <span className="text-muted-foreground">Responsible</span>;
|
||||
|
||||
@@ -167,7 +167,14 @@ vi.mock("./MarkdownEditor", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./InlineEntitySelector", () => ({
|
||||
InlineEntitySelector: () => null,
|
||||
InlineEntitySelector: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<select aria-label="Test recipient" value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<option value="agent:first">First agent</option>
|
||||
<option value="agent:latest">Latest agent</option>
|
||||
<option value="user:me">Me</option>
|
||||
<option value="">No responsible</option>
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./Identity", () => ({
|
||||
@@ -375,6 +382,33 @@ function createFileDragEvent(type: string, files: File[]) {
|
||||
describe("IssueChatThread", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
it("updates automatic suggestions but preserves manual choices until the task changes", () => {
|
||||
const root = createRoot(container);
|
||||
const render = (suggestion: string, draftKey = "task-one") => act(() => root.render(
|
||||
<MemoryRouter>
|
||||
<IssueChatThread comments={[]} onAdd={async () => {}} enableReassign
|
||||
reassignOptions={[{ id: "agent:first", label: "First agent" }]}
|
||||
currentAssigneeValue="user:me" suggestedAssigneeValue={suggestion} draftKey={draftKey} enableLiveTranscriptPolling={false} />
|
||||
</MemoryRouter>,
|
||||
));
|
||||
const selector = () => container.querySelector('select[aria-label="Test recipient"]') as HTMLSelectElement;
|
||||
render("agent:first");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe("agent:latest");
|
||||
for (const manual of ["user:me", ""]) {
|
||||
act(() => {
|
||||
selector().value = manual;
|
||||
selector().dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
render("agent:first");
|
||||
render("agent:latest");
|
||||
expect(selector().value).toBe(manual);
|
||||
}
|
||||
render("agent:first", "task-two");
|
||||
expect(selector().value).toBe("agent:first");
|
||||
act(() => root.unmount());
|
||||
});
|
||||
function renderIssueThread(root: ReturnType<typeof createRoot>, props: Partial<ComponentProps<typeof IssueChatThread>> = {}) {
|
||||
root.render(
|
||||
<MemoryRouter>
|
||||
|
||||
@@ -4763,6 +4763,12 @@ const IssueChatComposer = forwardRef<
|
||||
const dragDepthRef = useRef(0);
|
||||
const effectiveSuggestedAssigneeValue =
|
||||
suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const manualAssigneeRef = useRef(false);
|
||||
const assigneeDraftKeyRef = useRef(draftKey);
|
||||
const selectAssignee = (value: string) => {
|
||||
manualAssigneeRef.current = true;
|
||||
setReassignTarget(value);
|
||||
};
|
||||
const [reassignTarget, setReassignTarget] = useState(
|
||||
effectiveSuggestedAssigneeValue,
|
||||
);
|
||||
@@ -4919,8 +4925,12 @@ const IssueChatComposer = forwardRef<
|
||||
}, [draftKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [effectiveSuggestedAssigneeValue]);
|
||||
if (assigneeDraftKeyRef.current !== draftKey) {
|
||||
assigneeDraftKeyRef.current = draftKey;
|
||||
manualAssigneeRef.current = false;
|
||||
}
|
||||
if (!manualAssigneeRef.current) setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
}, [draftKey, effectiveSuggestedAssigneeValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setPendingWorkMode(resolvedIssueWorkMode);
|
||||
@@ -5065,6 +5075,7 @@ const IssueChatComposer = forwardRef<
|
||||
setComposerAttachments((current) =>
|
||||
current.filter((item) => !submittedAttachmentKeys.has(item.id)),
|
||||
);
|
||||
manualAssigneeRef.current = false;
|
||||
setReassignTarget(effectiveSuggestedAssigneeValue);
|
||||
setRunSettings(null);
|
||||
} catch (error) {
|
||||
@@ -5606,7 +5617,7 @@ const IssueChatComposer = forwardRef<
|
||||
overrides={assigneeAdapterOverrides}
|
||||
settings={runSettings}
|
||||
onSettingsChange={setRunSettings}
|
||||
onAssigneeChange={setReassignTarget}
|
||||
onAssigneeChange={selectAssignee}
|
||||
triggerRef={reassignTriggerRef}
|
||||
renderAssigneeIdentity={(value) => {
|
||||
const selected = value.startsWith("agent:") ? agentMap.get(value.slice(6)) : null;
|
||||
@@ -5622,7 +5633,7 @@ const IssueChatComposer = forwardRef<
|
||||
noneLabel="No responsible"
|
||||
searchPlaceholder="Search responsible..."
|
||||
emptyMessage="No responsible found."
|
||||
onChange={setReassignTarget}
|
||||
onChange={selectAssignee}
|
||||
className="h-8 text-xs"
|
||||
renderTriggerValue={(option) => {
|
||||
if (!option)
|
||||
|
||||
@@ -527,6 +527,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
conversationMode,
|
||||
reassignOptions,
|
||||
currentAssigneeValue,
|
||||
suggestedAssigneeValue,
|
||||
assigneeAdapterOverrides,
|
||||
issueStatus,
|
||||
issueAssigneeAgentId = null,
|
||||
@@ -604,12 +605,15 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
const [pendingComposerAssignee, setPendingComposerAssignee] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const effectiveGoalAgentId =
|
||||
pendingComposerAssignee === null
|
||||
? issueAssigneeAgentId
|
||||
: pendingComposerAssignee.startsWith("agent:")
|
||||
? pendingComposerAssignee.slice("agent:".length) || null
|
||||
: null;
|
||||
const composerSuggestedAssignee = currentUserId && currentAssigneeValue === `user:${currentUserId}`
|
||||
? suggestedAssigneeValue
|
||||
: undefined;
|
||||
const effectiveComposerAssignee = pendingComposerAssignee ?? composerSuggestedAssignee;
|
||||
const effectiveGoalAgentId = effectiveComposerAssignee === undefined
|
||||
? issueAssigneeAgentId
|
||||
: effectiveComposerAssignee.startsWith("agent:")
|
||||
? effectiveComposerAssignee.slice("agent:".length) || null
|
||||
: null;
|
||||
const runnerGoal = useRunnerGoalControl(issueId, effectiveGoalAgentId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -3240,6 +3244,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
|
||||
modelAgents={agentMap}
|
||||
userProfileMap={userProfileMap}
|
||||
currentAssigneeValue={currentAssigneeValue}
|
||||
suggestedAssigneeValue={composerSuggestedAssignee}
|
||||
companyId={companyId}
|
||||
assigneeAdapterOverrides={assigneeAdapterOverrides}
|
||||
onPendingAssigneeChange={setPendingComposerAssignee}
|
||||
|
||||
@@ -1701,6 +1701,25 @@ describe("TaskChatComposer", () => {
|
||||
).toBe("agent:a2");
|
||||
});
|
||||
|
||||
it("uses assignment suggestions and retains a manual human choice after refresh", async () => {
|
||||
const view = (suggestion: string) => <TaskChatComposer onAdd={vi.fn()} workMode="standard"
|
||||
enableReassign currentAssigneeValue="user:u1" suggestedAssigneeValue={suggestion}
|
||||
reassignOptions={[{ id: "agent:a1", label: "Alpha" }, { id: "agent:a2", label: "Beta" }, { id: "user:u1", label: "Me" }]}
|
||||
agentMap={new Map([["a1", { icon: "bot" }], ["a2", { icon: "bot" }]])} />;
|
||||
render(view("agent:a1"));
|
||||
const trigger = () => container.querySelector<HTMLButtonElement>('[data-testid="task-chat-composer-assignee"]')!;
|
||||
expect(trigger().textContent).toContain("Alpha");
|
||||
flushSync(() => root!.render(view("agent:a2")));
|
||||
expect(trigger().textContent).toContain("Beta");
|
||||
flushSync(() => trigger().click());
|
||||
await flushAsync();
|
||||
const me = document.querySelector('[data-assignee-identity="user:u1"]')!.closest("button")!;
|
||||
flushSync(() => me.click());
|
||||
await flushAsync();
|
||||
flushSync(() => root!.render(view("agent:a1")));
|
||||
expect(trigger().textContent).toContain("Me");
|
||||
});
|
||||
|
||||
it("shows human avatars in assignee options and after selection", async () => {
|
||||
render(
|
||||
<TaskChatComposer
|
||||
|
||||
@@ -142,6 +142,7 @@ interface TaskChatComposerProps {
|
||||
{ label: string; image: string | null }
|
||||
> | null;
|
||||
currentAssigneeValue?: string;
|
||||
suggestedAssigneeValue?: string;
|
||||
onPendingAssigneeChange?: (value: string | null) => void;
|
||||
issueStatus?: string;
|
||||
/** Mobile document-flow host: 16px editor text so iOS doesn't zoom on focus. */
|
||||
@@ -397,6 +398,7 @@ export function TaskChatComposer({
|
||||
assigneeAdapterOverrides,
|
||||
userProfileMap,
|
||||
currentAssigneeValue = "",
|
||||
suggestedAssigneeValue,
|
||||
onPendingAssigneeChange,
|
||||
issueStatus,
|
||||
mobile = false,
|
||||
@@ -638,7 +640,7 @@ export function TaskChatComposer({
|
||||
const showAssignee = Boolean(
|
||||
enableReassign && reassignOptions && reassignOptions.length > 0,
|
||||
);
|
||||
const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? currentAssigneeValue;
|
||||
const assigneeValue = creation ? currentAssigneeValue : pendingAssignee ?? suggestedAssigneeValue ?? currentAssigneeValue;
|
||||
const assigneeLabel =
|
||||
reassignOptions?.find((o) => o.id === assigneeValue)?.label ?? "Unassigned";
|
||||
const assigneeName =
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
assigneeValueFromSelection,
|
||||
@@ -72,14 +73,14 @@ describe("assignee selection helpers", () => {
|
||||
).toBe("agent:agent-123");
|
||||
});
|
||||
|
||||
it("falls back to the actual assignee when there is no better commenter hint", () => {
|
||||
it("leaves the recipient empty instead of defaulting to the current human", () => {
|
||||
expect(
|
||||
suggestedCommentAssigneeValue(
|
||||
{ assigneeUserId: "board-user" },
|
||||
[{ authorUserId: "board-user" }],
|
||||
"board-user",
|
||||
),
|
||||
).toBe("user:board-user");
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
it("skips the current agent when choosing a suggested commenter assignee", () => {
|
||||
@@ -97,3 +98,71 @@ describe("assignee selection helpers", () => {
|
||||
).toBe("agent:agent-123");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("assignment history suggestions", () => {
|
||||
const assignment = (actorId: string, date: string, details: Record<string, unknown> = {}): ActivityEvent => ({
|
||||
companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null,
|
||||
id: actorId, actorType: "agent", actorId, action: "issue.updated",
|
||||
createdAt: new Date(date), details: { assigneeUserId: "me", _previous: { assigneeUserId: null }, ...details },
|
||||
});
|
||||
const suggest = (activity: ActivityEvent[]) => suggestedCommentAssigneeValue(
|
||||
{ assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, activity,
|
||||
);
|
||||
|
||||
it("uses the latest assigning actor in either history order, ahead of commenters", () => {
|
||||
const older = assignment("first", "2026-01-01");
|
||||
const newer = assignment("last", "2026-02-01");
|
||||
expect(suggest([older, newer])).toBe("agent:last");
|
||||
expect(suggest([newer, older])).toBe("agent:last");
|
||||
});
|
||||
|
||||
it("ignores other recipients, human actors, non-assignment events and unchanged fields", () => {
|
||||
const valid = assignment("assigner", "2026-01-01");
|
||||
const invalid = assignment("other", "2026-02-01");
|
||||
expect(suggest([
|
||||
valid,
|
||||
{ ...invalid, actorType: "user" },
|
||||
{ ...invalid, action: "issue.comment_added" },
|
||||
assignment("other-user", "2026-02-01", { assigneeUserId: "someone-else" }),
|
||||
assignment("no-op", "2026-02-01", { _previous: { assigneeUserId: "me" } }),
|
||||
assignment("resent", "2026-02-01", { _previous: { status: "todo" } }),
|
||||
])).toBe("agent:assigner");
|
||||
});
|
||||
|
||||
it("includes the agent that created a task assigned to the human", () => {
|
||||
expect(suggest([{ ...assignment("creator", "2026-01-01"), action: "issue.created" }])).toBe("agent:creator");
|
||||
});
|
||||
|
||||
it("keeps the existing commenter fallback and other task defaults", () => {
|
||||
expect(suggest([])).toBe("agent:commenter");
|
||||
expect(suggestedCommentAssigneeValue({ assigneeAgentId: "owner" }, [], "me")).toBe("agent:owner");
|
||||
expect(suggestedCommentAssigneeValue({ assigneeUserId: "someone-else" }, [], "me")).toBe("user:someone-else");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("prefers a newer native reassignment over a REST assignment and ignores native no-ops", () => {
|
||||
const event = (actorId: string, action: string, createdAt: string, details: Record<string, unknown>): ActivityEvent => ({
|
||||
id: actorId, companyId: "company", entityType: "issue", entityId: "task", agentId: actorId, runId: null,
|
||||
actorType: "agent", actorId, action, createdAt: new Date(createdAt), details,
|
||||
});
|
||||
const history = [
|
||||
event("alpha", "issue.updated", "2026-01-01", { assigneeUserId: "me", _previous: { assigneeUserId: null } }),
|
||||
event("beta", "issue.reassigned", "2026-02-01", { assigneeUserId: "me", previousAssigneeUserId: null, changed: true }),
|
||||
event("no-op", "issue.reassigned", "2026-03-01", { assigneeUserId: "me", previousAssigneeUserId: "me", changed: false }),
|
||||
];
|
||||
expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [], "me", undefined, history)).toBe("agent:beta");
|
||||
});
|
||||
|
||||
|
||||
it.each(["issue.created", "issue.child_created"])("uses a REST %s receipt assigned to the current human", (action) => {
|
||||
const event: ActivityEvent = {
|
||||
id: "creation", companyId: "company", entityType: "issue", entityId: "task",
|
||||
agentId: "creator", runId: null, actorType: "agent", actorId: "creator", action,
|
||||
createdAt: new Date("2026-02-01"),
|
||||
details: { title: "Task", identifier: "PAP-1", status: "todo", assigneeAgentId: null, assigneeUserId: "me" },
|
||||
};
|
||||
expect(suggestedCommentAssigneeValue({ assigneeUserId: "me" }, [{ authorAgentId: "commenter" }], "me", undefined, [event])).toBe("agent:creator");
|
||||
expect(suggestedCommentAssigneeValue({ assigneeUserId: "other" }, [], "other", undefined, [event])).toBe("");
|
||||
});
|
||||
+28
-1
@@ -1,3 +1,5 @@
|
||||
import type { ActivityEvent } from "@paperclipai/shared";
|
||||
|
||||
export interface AssigneeSelection {
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
@@ -30,7 +32,32 @@ export function suggestedCommentAssigneeValue(
|
||||
comments: CommentAssigneeSuggestionComment[] | null | undefined,
|
||||
currentUserId: string | null | undefined,
|
||||
currentAgentId?: string | null | undefined,
|
||||
activity: readonly ActivityEvent[] = [],
|
||||
): string {
|
||||
const assignedToCurrentUser = Boolean(currentUserId && issue.assigneeUserId === currentUserId);
|
||||
if (assignedToCurrentUser) {
|
||||
const assignment = activity
|
||||
.filter((event) => {
|
||||
if (event.actorType !== "agent" || !event.actorId || event.actorId === currentAgentId) return false;
|
||||
const details = event.details;
|
||||
if (!details || details.assigneeUserId !== currentUserId) return false;
|
||||
if (event.action === "issue.created" || event.action === "issue.child_created") return true;
|
||||
if (event.action === "issue.reassigned") {
|
||||
return details.changed === true
|
||||
&& Object.hasOwn(details, "previousAssigneeUserId")
|
||||
&& details.previousAssigneeUserId !== currentUserId;
|
||||
}
|
||||
if (event.action !== "issue.updated") return false;
|
||||
const previous = details._previous;
|
||||
// Updates can resend unchanged fields. Only a real assignment counts.
|
||||
return previous !== null && typeof previous === "object"
|
||||
&& Object.hasOwn(previous, "assigneeUserId")
|
||||
&& (previous as Record<string, unknown>).assigneeUserId !== currentUserId;
|
||||
})
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
if (assignment) return `agent:${assignment.actorId}`;
|
||||
}
|
||||
|
||||
if (comments && comments.length > 0 && (currentUserId || currentAgentId)) {
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const comment = comments[i];
|
||||
@@ -43,7 +70,7 @@ export function suggestedCommentAssigneeValue(
|
||||
}
|
||||
}
|
||||
|
||||
return assigneeValueFromSelection(issue);
|
||||
return assignedToCurrentUser ? "" : assigneeValueFromSelection(issue);
|
||||
}
|
||||
|
||||
export function parseAssigneeValue(value: string): AssigneeSelection {
|
||||
|
||||
@@ -3850,14 +3850,22 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
|
||||
[issue],
|
||||
);
|
||||
|
||||
const { data: assignmentActivity } = useQuery({
|
||||
queryKey: queryKeys.issues.activity(issueId!),
|
||||
queryFn: () => activityApi.forIssue(issueId!),
|
||||
enabled: Boolean(issueId && currentUserId && issue?.assigneeUserId === currentUserId),
|
||||
});
|
||||
|
||||
const suggestedAssigneeValue = useMemo(
|
||||
() =>
|
||||
suggestedCommentAssigneeValue(
|
||||
issue ?? {},
|
||||
mergeIssueComments(comments ?? [], optimisticComments),
|
||||
currentUserId,
|
||||
undefined,
|
||||
assignmentActivity,
|
||||
),
|
||||
[issue, comments, optimisticComments, currentUserId],
|
||||
[issue, comments, optimisticComments, currentUserId, assignmentActivity],
|
||||
);
|
||||
|
||||
const threadComments = useMemo(
|
||||
|
||||
@@ -2177,8 +2177,10 @@ export function PipelineItemDetailView({ pipelineId, caseId }: { pipelineId: str
|
||||
starterAssigneeValue ? parseAssigneeValue(starterAssigneeValue) : activeConversationIssue ?? {},
|
||||
conversationComments,
|
||||
currentUserId,
|
||||
undefined,
|
||||
conversationActivity,
|
||||
),
|
||||
[activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue],
|
||||
[activeConversationIssue, conversationComments, currentUserId, starterAssigneeValue, conversationActivity],
|
||||
);
|
||||
const conversationRunningRun = useMemo(
|
||||
() => resolveRunningPipelineConversationRun(resolvedConversationActiveRun, resolvedConversationLiveRuns),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { deriveInitials } from "@/components/Identity";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { SlackTestMessageButton } from "./SlackTestMessageButton";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { Identity } from "@/components/Identity";
|
||||
import { SlackSetupAdvanced } from "./SlackAppDetails";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings";
|
||||
@@ -782,13 +783,19 @@ function Access({
|
||||
role="listitem"
|
||||
className="flex flex-wrap items-center gap-3 py-3"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Identity name={link.paperclipUserLabel ?? link.externalLabel} size="default" className="gap-2" />
|
||||
{(link.status !== "linked" || link.externalLabel !== link.paperclipUserLabel) && <p className="pl-10 text-xs text-muted-foreground">
|
||||
{link.status === "revoked" ? "Disconnected" : link.status === "linked"
|
||||
? link.externalLabel
|
||||
: "Waiting for account confirmation"}
|
||||
</p>}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Avatar aria-hidden="true">
|
||||
{link.paperclipUserImage && <AvatarImage src={link.paperclipUserImage} alt="" />}
|
||||
<AvatarFallback>{deriveInitials(link.paperclipUserLabel ?? link.externalLabel)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm" title={link.paperclipUserLabel ?? link.externalLabel}>{link.paperclipUserLabel ?? link.externalLabel}</p>
|
||||
{(link.status !== "linked" || link.externalLabel !== link.paperclipUserLabel) && <p className="text-xs text-muted-foreground">
|
||||
{link.status === "revoked" ? "Disconnected" : link.status === "linked"
|
||||
? link.externalLabel
|
||||
: "Waiting for account confirmation"}
|
||||
</p>}
|
||||
</div>
|
||||
</div>
|
||||
{link.status === "linked" ? (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { expect, userEvent, waitFor, within } from "storybook/test";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Route, Routes, useNavigate } from "@/lib/router";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -9,6 +9,7 @@ import { PluginLauncherProvider } from "@/plugins/launchers";
|
||||
import { ChatEndpointDetail } from "@/pages/apps/chat/ChatEndpointDetail";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import type { ChatEndpoint, ChatEndpointResource, ChatIdentityLink } from "@/api/chatEndpoints";
|
||||
import userAvatar from "../../../prototypes/slack-avatar/ceo-cliptoon.png";
|
||||
import { storybookAgents, storybookExecutionWorkspaces } from "../../../fixtures/paperclipData";
|
||||
|
||||
type Scenario = "populated" | "empty" | "error" | "channel-added" | "unlinked" | "invitation-expired";
|
||||
@@ -33,7 +34,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
{ id: "general", type: "channel", providerResourceId: "CGENERAL", label: "#general", availability: "available", enabled: false },
|
||||
];
|
||||
let principals: ChatIdentityLink[] = scenario === "empty" ? [] : [
|
||||
{ id: "ada", principalId: "ada", externalLabel: "@ada", paperclipUserLabel: "Ada Lovelace", paperclipUserId: "ada", status: "linked" },
|
||||
{ id: "ada", principalId: "ada", externalLabel: "@ada", paperclipUserLabel: "Ada Lovelace", paperclipUserId: "ada", paperclipUserImage: userAvatar, status: "linked" },
|
||||
{ id: "alan", principalId: "alan", externalLabel: "@alan", paperclipUserLabel: "Alan Turing", paperclipUserImage: null, status: "linked" },
|
||||
{ id: "grace", principalId: "grace", externalLabel: "Grace Hopper", status: "pending" },
|
||||
];
|
||||
let resourceReads = 0;
|
||||
@@ -119,7 +121,24 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Settings: Story = { name: "1 · Settings" };
|
||||
export const Access: Story = { name: "2 · People and access", args: { tab: "access" } };
|
||||
export const Access: Story = {
|
||||
name: "2 · People and access", args: { tab: "access" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const name = await canvas.findByText("Ada Lovelace");
|
||||
await expect(name).toHaveAttribute("title", "Ada Lovelace");
|
||||
const row = name.closest('[role="listitem"]')!;
|
||||
const avatar = row.querySelector('[data-slot="avatar"]')!;
|
||||
await waitFor(() => expect(avatar.querySelector("img")).toBeVisible());
|
||||
await expect(canvas.getByText("AT")).toBeVisible();
|
||||
const center = (element: Element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
return bounds.top + bounds.height / 2;
|
||||
};
|
||||
await expect(Math.abs(center(avatar) - center(name.parentElement!))).toBeLessThan(1);
|
||||
await expect(Math.abs(center(avatar) - center(within(row as HTMLElement).getByRole("button", { name: "Disconnect" })))).toBeLessThan(1);
|
||||
},
|
||||
};
|
||||
export const InvitePeople: Story = {
|
||||
name: "3 · Invite people", args: { tab: "access" },
|
||||
play: async ({ canvasElement }) => {
|
||||
|
||||
Reference in new issue
Block a user