From 3600b50af09148d4ac2e64a3c12ed11c1b211564 Mon Sep 17 00:00:00 2001 From: Bai Xiaohang Date: Fri, 17 Jul 2026 10:55:39 +0000 Subject: [PATCH 1/4] feat: align SCM entity attention semantics --- .../cli/src/__tests__/gitlab-commands.test.ts | 12 +- apps/cli/src/commands/gitlab/_shared.ts | 7 + apps/cli/src/commands/gitlab/follow.ts | 12 +- docs/cli-reference.md | 20 +- .../src/__tests__/agent-briefing.test.ts | 1 + .../src/runtime/templates/agent-briefing.ejs | 2 +- .../gitlab-webhook-basic-card.md | 31 +- .../scm-entity-attention-parity.md | 73 + .../runtime/agent-briefing-gitlab-cli.md | 3 +- .../server/drizzle/0081_dizzy_tyrannus.sql | 10 + packages/server/drizzle/LATEST | 2 +- .../server/drizzle/meta/0081_snapshot.json | 5582 +++++++++++++++++ packages/server/drizzle/meta/_journal.json | 7 + .../server/src/__tests__/chat-archive.test.ts | 166 +- .../src/__tests__/github-audience.test.ts | 2 + .../github-binding-invariants.test.ts | 6 +- .../src/__tests__/github-normalize.test.ts | 53 +- .../gitlab-attention-backfill.test.ts | 297 + .../gitlab-entities-agent-route.test.ts | 27 +- .../__tests__/gitlab-identity-fencing.test.ts | 2 + .../gitlab-webhook-normalize.test.ts | 108 + .../__tests__/gitlab-webhook-stage2a.test.ts | 293 +- .../__tests__/gitlab-webhook-stage3.test.ts | 240 +- .../src/__tests__/scm-attention-line.test.ts | 147 + .../__tests__/scm-entity-chat-topic.test.ts | 44 + .../scm-provider-attention-contract.test.ts | 223 + .../src/__tests__/scm-related-refs.test.ts | 21 + .../__tests__/scm-target-chat-policy.test.ts | 77 + .../__tests__/scm-webhook-processing.test.ts | 74 + packages/server/src/api/agent/chats.ts | 24 +- packages/server/src/api/chats.ts | 95 +- .../server/src/api/webhooks/github-app.ts | 179 +- .../server/src/api/webhooks/github-entity.ts | 22 +- packages/server/src/api/webhooks/gitlab.ts | 16 + packages/server/src/app.ts | 5 + .../db/schema/gitlab-entity-chat-mappings.ts | 43 +- .../server/src/services/background-tasks.ts | 2 +- packages/server/src/services/chat-archive.ts | 58 +- .../server/src/services/github-audience.ts | 6 + .../server/src/services/github-delivery.ts | 71 +- .../server/src/services/github-entity-chat.ts | 82 +- .../src/services/github-entity-follow.ts | 223 +- .../server/src/services/github-normalize.ts | 143 +- .../src/services/gitlab-attention-backfill.ts | 212 + .../server/src/services/gitlab-connections.ts | 21 + .../src/services/gitlab-entity-follow.ts | 325 +- .../server/src/services/gitlab-webhook.ts | 385 +- .../server/src/services/scm-attention-line.ts | 201 + .../src/services/scm-chat-delivery-plan.ts | 85 +- .../src/services/scm-entity-chat-topic.ts | 70 + .../server/src/services/scm-related-refs.ts | 23 + .../src/services/scm-target-chat-policy.ts | 40 + .../src/services/scm-webhook-processing.ts | 21 +- .../chat-gitlab-entities-schema.test.ts | 2 +- .../__tests__/normalized-event-schema.test.ts | 46 +- .../__tests__/scm-attention-schema.test.ts | 34 + packages/shared/src/index.ts | 20 + .../src/schemas/chat-gitlab-entities.ts | 18 +- packages/shared/src/schemas/gitlab.ts | 1 + .../shared/src/schemas/normalized-event.ts | 30 + packages/shared/src/schemas/scm-attention.ts | 42 + .../src/api/__tests__/api-wrappers.test.ts | 8 + packages/web/src/api/chats.ts | 7 + .../workspace-sidebars-extra-dom.test.tsx | 18 +- .../center/__tests__/chat-view-dom.test.tsx | 63 + .../src/pages/workspace/center/chat-view.tsx | 16 +- .../right-sidebar/gitlab-section.tsx | 7 +- 67 files changed, 9482 insertions(+), 724 deletions(-) create mode 100644 packages/qa/cases/cross-surface/scm-entity-attention-parity.md create mode 100644 packages/server/drizzle/0081_dizzy_tyrannus.sql create mode 100644 packages/server/drizzle/meta/0081_snapshot.json create mode 100644 packages/server/src/__tests__/gitlab-attention-backfill.test.ts create mode 100644 packages/server/src/__tests__/scm-attention-line.test.ts create mode 100644 packages/server/src/__tests__/scm-entity-chat-topic.test.ts create mode 100644 packages/server/src/__tests__/scm-provider-attention-contract.test.ts create mode 100644 packages/server/src/__tests__/scm-related-refs.test.ts create mode 100644 packages/server/src/__tests__/scm-target-chat-policy.test.ts create mode 100644 packages/server/src/services/gitlab-attention-backfill.ts create mode 100644 packages/server/src/services/scm-attention-line.ts create mode 100644 packages/server/src/services/scm-entity-chat-topic.ts create mode 100644 packages/server/src/services/scm-related-refs.ts create mode 100644 packages/server/src/services/scm-target-chat-policy.ts create mode 100644 packages/shared/src/__tests__/scm-attention-schema.test.ts create mode 100644 packages/shared/src/schemas/scm-attention.ts diff --git a/apps/cli/src/__tests__/gitlab-commands.test.ts b/apps/cli/src/__tests__/gitlab-commands.test.ts index fd1274254..a1a4464b6 100644 --- a/apps/cli/src/__tests__/gitlab-commands.test.ts +++ b/apps/cli/src/__tests__/gitlab-commands.test.ts @@ -38,14 +38,14 @@ afterEach(() => { }); describe("gitlab entity attention commands", () => { - it("registers only follow, following, and unfollow without GitHub-only flags", async () => { + it("registers follow, following, and unfollow with pair-aware rebind", async () => { const { registerGitlabCommands } = await import("../commands/gitlab/index.js"); const root = new Command(); registerGitlabCommands(root); const gitlab = command(root, "gitlab"); expect(gitlab.commands.map((entry) => entry.name()).sort()).toEqual(["follow", "following", "unfollow"]); - expect(command(gitlab, "follow").options.map((option) => option.long)).toEqual(["--chat", "--agent"]); + expect(command(gitlab, "follow").options.map((option) => option.long)).toEqual(["--chat", "--agent", "--rebind"]); expect(gitlab.commands.some((entry) => entry.name() === "context-review")).toBe(false); }); @@ -75,7 +75,7 @@ describe("gitlab entity attention commands", () => { const followRoot = new Command(); registerGitlabFollowCommand(followRoot); await command(followRoot, "follow").parseAsync([entity.entityUrl, "--agent", "builder"], { from: "user" }); - expect(sdk.followGitlabEntity).toHaveBeenCalledWith("chat-env", { entityUrl: entity.entityUrl }); + expect(sdk.followGitlabEntity).toHaveBeenCalledWith("chat-env", { entityUrl: entity.entityUrl, rebind: false }); expect(localAgentMocks.createSdk).toHaveBeenCalledWith("builder"); expect(String(outputMocks.success.mock.calls.at(-1)?.[0].hint)).toContain("remains pending"); expect(String(outputMocks.success.mock.calls.at(-1)?.[0].hint)).toContain("has not called GitLab"); @@ -83,7 +83,10 @@ describe("gitlab entity attention commands", () => { await command(followRoot, "follow").parseAsync([entity.entityUrl, "--chat", "chat-explicit"], { from: "user", }); - expect(sdk.followGitlabEntity).toHaveBeenLastCalledWith("chat-explicit", { entityUrl: entity.entityUrl }); + expect(sdk.followGitlabEntity).toHaveBeenLastCalledWith("chat-explicit", { + entityUrl: entity.entityUrl, + rebind: false, + }); expect(String(outputMocks.success.mock.calls.at(-1)?.[0].hint)).toContain("Already pending"); const { registerGitlabFollowingCommand } = await import("../commands/gitlab/following.js"); @@ -112,6 +115,7 @@ describe("gitlab entity attention commands", () => { expect(() => handleGitlabSdkError(new SdkError(404, "GitLab connection is not configured"))).toThrow( "NO_GITLAB_CONNECTION", ); + expect(() => handleGitlabSdkError(new SdkError(409, "line lives elsewhere"))).toThrow("GITLAB_FOLLOW_CONFLICT"); const generic = new Error("server unavailable"); expect(() => handleGitlabSdkError(generic)).toThrow("server unavailable"); expect(localAgentMocks.handleSdkError).toHaveBeenLastCalledWith(generic); diff --git a/apps/cli/src/commands/gitlab/_shared.ts b/apps/cli/src/commands/gitlab/_shared.ts index fa49bad11..d91105da8 100644 --- a/apps/cli/src/commands/gitlab/_shared.ts +++ b/apps/cli/src/commands/gitlab/_shared.ts @@ -18,6 +18,13 @@ export function handleGitlabSdkError(error: unknown): never { 1, ); } + if (error.statusCode === 409) { + fail( + "GITLAB_FOLLOW_CONFLICT", + `${error.message} Default to the existing chat, or retry with --rebind to move this attention line.`, + 1, + ); + } } handleSdkError(error); } diff --git a/apps/cli/src/commands/gitlab/follow.ts b/apps/cli/src/commands/gitlab/follow.ts index 700342887..f726997ba 100644 --- a/apps/cli/src/commands/gitlab/follow.ts +++ b/apps/cli/src/commands/gitlab/follow.ts @@ -8,6 +8,7 @@ import { handleGitlabSdkError } from "./_shared.js"; type FollowOptions = { chat?: string; agent?: string; + rebind?: boolean; }; export function registerGitlabFollowCommand(gitlab: Command): void { @@ -19,18 +20,21 @@ export function registerGitlabFollowCommand(gitlab: Command): void { ) .option("--chat ", "Target chat (default: the session's FIRST_TREE_CHAT_ID)") .option("--agent ", "Agent name on the First Tree server (default: first configured on this client)") + .option("--rebind", "Move this agent's existing attention line from another chat into the target chat") .action(async (entityUrl: string, options: FollowOptions) => { try { const chatId = resolveTargetChatId(options.chat); const sdk = createSdk(options.agent); - const result = await sdk.followGitlabEntity(chatId, { entityUrl }); + const result = await sdk.followGitlabEntity(chatId, { entityUrl, rebind: options.rebind ?? false }); const hint = result.status === "created" ? "Declaration recorded locally. It remains pending until the next matching valid GitLab webhook; " + "First Tree has not called GitLab or verified that the entity exists." - : result.entity.status === "pending" - ? "Already pending in this chat — idempotent success. Wait for a matching valid webhook; do not retry." - : "Already active in this chat — idempotent success, do not retry."; + : result.status === "rebound" + ? "Attention line moved into this chat. A pending line activates on the next matching valid webhook." + : result.entity.status === "pending" + ? "Already pending in this chat — idempotent success. Wait for a matching valid webhook; do not retry." + : "Already active in this chat — idempotent success, do not retry."; success({ ...result, hint: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d56f10fe0..32f0ed2d8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -615,7 +615,7 @@ GitLab API, validate an entity live, or use the current `glab` account. ``` first-tree gitlab -├── follow [--chat ] [--agent ] +├── follow [--chat ] [--agent ] [--rebind] ├── following [--chat ] [--agent ] └── unfollow [--chat ] [--agent ] ``` @@ -623,7 +623,7 @@ first-tree gitlab ```bash # Inside an agent session the chat is inferred from FIRST_TREE_CHAT_ID first-tree gitlab follow https://gitlab.example/acme/api/-/issues/42 -first-tree gitlab follow https://gitlab.example/acme/api/-/merge_requests/42 +first-tree gitlab follow https://gitlab.example/acme/api/-/merge_requests/42 --rebind first-tree gitlab following first-tree gitlab unfollow https://gitlab.example/acme/api/-/issues/42 ``` @@ -631,10 +631,13 @@ first-tree gitlab unfollow https://gitlab.example/acme/api/-/issues/42 `follow` accepts only a full Issue or Merge Request URL from the Team's one configured GitLab instance. It records a pending declaration without provider egress; the next matching valid webhook supplies numeric project identity and -activates the declaration. Repeating a follow in the same chat is idempotent, -and the same entity may be followed independently by multiple chats. A pending +activates the declaration. Repeating a follow by the same human/delegate pair +in the same chat is idempotent. The same pair cannot follow the entity from a +second chat: the command reports the existing room, and `--rebind` atomically +moves that line when the task context intentionally changes. Different pairs +remain independent. A pending declaration reports `state: null` because First Tree has not verified provider -state. There is no GitHub-style `--rebind` or `context-review` command. +state. There is no GitLab `context-review` command. `following` returns every active binding in the chat as a stable public projection, including automatic reviewer / assignee / mention routing and @@ -1272,9 +1275,10 @@ Old per-route rate-limit env vars are no longer read. | `FIRST_TREE_ARCHIVE_SWEEP_INTERVAL_SECONDS` | `300` (set `0` to disable) | | `FIRST_TREE_ARCHIVE_MAPPED_IDLE_SECONDS` | `3600` | -`FIRST_TREE_ARCHIVE_MAPPED_IDLE_SECONDS` is the GitHub-source archive idle -threshold. Mapped chats also require all bound entities to be closed/merged; -source=github chats with no mapping use the same idle threshold. +`FIRST_TREE_ARCHIVE_MAPPED_IDLE_SECONDS` is the SCM-source archive idle +threshold. Mapped GitHub/GitLab chats also require all bound entities to be +closed/merged; provider-owned chats with no mapping use the same idle +threshold. **Observability:** diff --git a/packages/client/src/__tests__/agent-briefing.test.ts b/packages/client/src/__tests__/agent-briefing.test.ts index 077928190..037c83431 100644 --- a/packages/client/src/__tests__/agent-briefing.test.ts +++ b/packages/client/src/__tests__/agent-briefing.test.ts @@ -625,6 +625,7 @@ describe("buildAgentBriefing — asking humans, GitHub, and CLI overview", () => expect(gitlab).toContain("first-tree gitlab follow "); expect(gitlab).toContain("pending and inbound-only"); expect(gitlab).toContain("without calling GitLab"); + expect(gitlab).toContain("gitlab follow --rebind"); expect(gitlab).toContain("does not invalidate an entity that was already created"); expect(gitlab).toContain("only when the human explicitly asks for personal-account notifications"); expect(gitlab).toContain("only when the human explicitly asks this chat"); diff --git a/packages/client/src/runtime/templates/agent-briefing.ejs b/packages/client/src/runtime/templates/agent-briefing.ejs index 70a826072..f01a4d50e 100644 --- a/packages/client/src/runtime/templates/agent-briefing.ejs +++ b/packages/client/src/runtime/templates/agent-briefing.ejs @@ -225,7 +225,7 @@ For upstream-dependency follows, `409` / `--rebind`, and full flags, see ## GitLab Entity Attention - **Default: follow what you create.** After creating a GitLab Issue or Merge Request for this task, wire its inbound webhook activity into the current chat with `<%- bin %> gitlab follow `. Skip only when the entity is clearly unrelated to this chat or the human explicitly does not want it tracked. A follow failure does not invalidate an entity that was already created; report only the First Tree chat attention gap. -- GitLab follow is pending and inbound-only: First Tree records the declaration without calling GitLab, then activates it when the next matching valid webhook arrives. It is independent of the current account's native GitLab notifications. Use native `glab` subscribe/unsubscribe only when the human explicitly asks for personal-account notifications. +- GitLab follow is pending and inbound-only: First Tree records the declaration without calling GitLab, then activates it when the next matching valid webhook arrives. One human/delegate attention line lives in one chat; if it is already followed elsewhere, work there by default or use `<%- bin %> gitlab follow --rebind` only when the task context should move. It is independent of the current account's native GitLab notifications. Use native `glab` subscribe/unsubscribe only when the human explicitly asks for personal-account notifications. - Run `<%- bin %> gitlab unfollow ` only when the human explicitly asks this chat to stop tracking the entity, never automatically when an Issue closes, an MR merges, or the task finishes. Unfollow removes every automatic or manual binding for that entity in the current chat; a later explicit reviewer, assignee, or mention event may create a new route. Use the current URL shown by `<%- bin %> gitlab following` after a project rename. Full flags and pending/active output live under `<%- bin %> gitlab follow --help`, `<%- bin %> gitlab following --help`, and `<%- bin %> gitlab unfollow --help`. ## Asking Humans diff --git a/packages/qa/cases/cross-surface/gitlab-webhook-basic-card.md b/packages/qa/cases/cross-surface/gitlab-webhook-basic-card.md index 79aec75d4..dc4b558a8 100644 --- a/packages/qa/cases/cross-surface/gitlab-webhook-basic-card.md +++ b/packages/qa/cases/cross-surface/gitlab-webhook-basic-card.md @@ -41,7 +41,12 @@ completed until a customer-side source mapping exists in a later phase. - Before that matching webhook, run `first-tree gitlab following` in both chats and confirm the stable public projection reports `pending` without connection, organization, mapping, actor, normalized-path, or timestamp fields. After the webhook, confirm both independent chat declarations report `active`, the latest URL/title/state projection is visible, - and First Tree made no outbound request to GitLab during follow or list. + and First Tree made no outbound request to GitLab during follow or list. Deliver an ordinary Note after activation and + confirm each explicit human/delegate line produces a notifying Inbox entry and wakes its delegate rather than only + appending a silent card. +- Attempt to follow one human/delegate line from a second chat. Confirm the non-rebind request reports the existing chat + without duplicating the line, then use `--rebind` and confirm the line moves atomically while other pairs remain in + their original chats. - Rename the project, declare the new path before its next event, and verify the pending declaration collapses into the existing numeric mapping rather than duplicating or failing it. Confirm two projects with the same entity type and IID can coexist in one chat because GitLab IIDs are project-scoped. @@ -50,8 +55,9 @@ completed until a customer-side source mapping exists in a later phase. the delegate. The card may say the review request was routed/source unavailable; it must not claim source review running/completed. - Deliver ordinary assignee and explicit Note `@mention` targets and confirm they route and wake without being labelled as - code review. Verify exact case-insensitive usernames only; display-name, email, similar-name, inactive link, inactive - member, missing delegate, and ineligible delegate cases must skip independently and emit only bounded operational logs. + code review. Also exercise a new or changed MR/Issue description containing an exact `@mention`. Verify exact + case-insensitive usernames only; display-name, email, similar-name, inactive link, inactive member, missing delegate, + and ineligible delegate cases must skip independently and emit only bounded operational logs. - Verify reviewer mode starts unknown. For `User-Agent: GitLab/15.3.0` or newer, a missing `reviewers` field means no reviewer and never falls back to assignee; a valid top-level `reviewers: []` proves modern capability without a target. For an older declared version, use assignee as reviewer only when `reviewers` is absent. If the version is unavailable or @@ -71,6 +77,17 @@ completed until a customer-side source mapping exists in a later phase. at-least-once delivery and may still reach the old delegate once, matching GitHub Inbox behavior. - Redeliver with the same stable provider delivery id and confirm whole-request deduplication. Deliver without a stable id and confirm no claim is stored; repeated cards are an accepted weak-reliability outcome. +- Exercise MR code updates, draft/ready transitions, terminal MR/Issue observations, and metadata-only changes. Confirm + code updates normalize to actionable synchronized cards, ready transitions can route current reviewers, terminal and + metadata-only observations refresh the public title/state projection without inventing an extra card, and label-only + Issue updates remain silent. +- Confirm provider-created chat topics use `MR !`, `MR Review !`, or + `Issue #` and refresh after a valid title/project-path observation only while the topic still matches the + automatic grammar. A manually renamed topic must remain untouched. In the Web header, confirm the external-link button + opens the typed metadata URL in a new tab and identifies GitLab; a manual/follow-only chat must not guess an anchor URL. +- With all mapped entities terminal, no unread state, no open request, no working/blocked runtime session, and the idle + threshold elapsed, confirm the SCM sweeper archives the provider-owned chat. Any one guard must prevent archive, and a + later delivered event must revive an archived chat. - Regenerate the bearer and confirm the old URL stops authenticating immediately while the replacement URL is returned only once. Confirm regeneration also clears the learned GitLab version/reviewer mode so the new bearer learns afresh. Replace the Team's single GitLab connection and confirm the old connection, bearer, identity links, and entity/chat @@ -91,9 +108,11 @@ completed until a customer-side source mapping exists in a later phase. `PASS`: endpoint identity alone selects the Team and authority, secrets remain redacted, Cloud performs no GitLab egress, the Team has at most one GitLab connection, supported entity events reach only existing followed chats as basic cards, stable ids are connection-scoped, exact active identities route to the current eligible delegate with one card per chat and -a generic at-least-once wake, agent follow/list/unfollow expose only the stable URL contract, chat-scoped unfollow removes -automatic and manual bindings while later directed events may route afresh, anomalies fail closed, and no source-review -state is claimed. +a generic at-least-once wake, explicit lines wake on ordinary subscribed events, pair-aware rebind moves rather than +duplicates a line, agent follow/list/unfollow expose only the stable URL contract, chat-scoped unfollow removes automatic +and manual bindings while later directed events may route afresh, lifecycle-only observations safely refresh public +state/topic, terminal chats archive only after all safety guards pass, anomalies fail closed, and no source-review state +is claimed. `FAIL`: cross-Team resolution, secret exposure, any outbound request to GitLab, incorrect pending binding, duplicate cards for one chat within one pass, a fuzzy personnel match, new personnel routing after its authority was removed, reviewer diff --git a/packages/qa/cases/cross-surface/scm-entity-attention-parity.md b/packages/qa/cases/cross-surface/scm-entity-attention-parity.md new file mode 100644 index 000000000..0484f34eb --- /dev/null +++ b/packages/qa/cases/cross-surface/scm-entity-attention-parity.md @@ -0,0 +1,73 @@ +--- +id: scm-entity-attention-parity +description: Verify that equivalent GitHub and GitLab MR/Issue activity produces the same First Tree attention, routing, lifecycle, and product projection. +areas: [cross-surface] +surfaces: [web, server, client, cli, github, gitlab] +--- + +# SCM Entity Attention Parity + +## Goal + +Verify provider-neutral user behavior for GitHub Pull Requests/Issues and GitLab Merge Requests/Issues while preserving +their protocol differences. Equivalent semantic activity must produce equivalent attention lines, chat selection, wake, +card significance, lifecycle projection, topic protection, and archive behavior. + +## Preconditions + +- Reach `QA READY` in an isolated run cell with disposable GitHub and GitLab projects, valid inbound webhook paths, a + disposable Team, two human identities, eligible delegates, and at least two chats. +- Record provider delivery identifiers and redacted entity URLs. Do not retain credentials, installation tokens, GitLab + webhook bearers, private payloads, or non-disposable identity data. +- Treat GitHub live validation and GitLab pending activation as intentional protocol differences. Begin paired assertions + only after both attention lines are active. + +## Operate and Observe + +- For each provider, create an agent explicit follow and a human explicit follow. Confirm the stored public behavior is a + complete human/delegate attention line, repeated same-pair follow is idempotent, a second-chat follow conflicts, and + `--rebind` atomically moves only that pair. +- Deliver an ordinary subscribed comment/Note. Confirm one card per target chat, notifying Inbox entries for every + surviving delegate, predictive sessions, and wake delivery. Provider actor attribution must not suppress the delegate. +- Unfollow in one chat and redeliver an ordinary event. Confirm the old chat stays silent. Then deliver an explicit + reviewer, assignee, or exact body/comment mention and confirm a fresh route may be established without reviving the + removed line. +- Exercise reviewer, assignee, and mention targets with equivalent identities. Only reviewer routing may reuse exactly one + eligible membership chat without writing a new line; assignee and mention routing establish the target's own line/chat. + Ambiguous membership reuse must fail closed. +- Deliver equivalent code updates, draft/ready transitions, description mentions, terminal state changes, and + metadata-only updates. Confirm code updates are actionable, ready reviewers are actionable, description mentions route + only on open or actual description change, and observation-only/metadata-only activity refreshes title/state without an + extra card. +- Confirm provider-created topics use the provider's stable grammar (`PR`/`PR Review` or `MR`/`MR Review`, plus project + basename and number) and update only while provider metadata proves the anchor and the current topic remains automatic. + Manual topics remain unchanged. Confirm the Web header link uses the typed provider metadata URL and no link is guessed + for a manual/follow-only chat. +- Let all mapped entities become terminal and the idle threshold elapse. Confirm unread state, open requests, and + working/blocked runtime sessions independently prevent archive; otherwise the chat archives. Deliver a later event and + confirm the archived chat revives. +- Compare `following`, the right sidebar, cards, Inbox rows, session/wake evidence, topics, header links, and archive + outcomes side by side. Record only explicit protocol exceptions: authentication, GitLab pending activation, provider + entity keys, webhook version compatibility, and delivery-id reliability. + +## Expected Result + +`PASS`: equivalent GitHub and GitLab semantic events produce the same observable attention-line ownership, rebind, +unfollow, target-chat, wake, card-significance, lifecycle, topic-protection, header-link, archive, and revive behavior; +only the declared protocol exceptions differ. + +`FAIL`: either provider permits a silent complete attention line, duplicates a pair across chats, chooses a different +target-chat policy, delivers a semantically noisy card, leaks internal identifiers, overwrites a manual topic, guesses an +entity URL, or archives despite a safety guard. + +`BLOCKED`: the complete isolated harness cannot receive both providers' disposable webhooks or cannot observe the required +Web, CLI, Inbox/session, and archive surfaces. + +`INCONCLUSIVE`: only source, unit-test, database, or unattributable log evidence exists, or the providers cannot be compared +at equivalent active-line preconditions. + +## Evidence + +Keep redacted paired webhook outcomes, public `following`/sidebar projections, cards, Inbox/session/wake traces, topic and +header-link screenshots, archive/revive state, provider versions, delivery reliability notes, and command exit statuses. +Include one disposition for this case without editing it during the QA run. diff --git a/packages/qa/cases/runtime/agent-briefing-gitlab-cli.md b/packages/qa/cases/runtime/agent-briefing-gitlab-cli.md index cc35cff66..56ae80e1a 100644 --- a/packages/qa/cases/runtime/agent-briefing-gitlab-cli.md +++ b/packages/qa/cases/runtime/agent-briefing-gitlab-cli.md @@ -64,7 +64,8 @@ Capture a redacted generated briefing and command trace showing: terminal success, and explains that a later directed personnel event may create a new route; - the `gitlab` namespace exposes only `follow`, `following`, and `unfollow`, - with no `--rebind`, `--connection`, `--mapping-id`, or `context-review`; + with `--rebind` available only on `follow` and no `--connection`, + `--mapping-id`, or `context-review`; - no GitHub App installation request is emitted for GitLab chat attention. ## Expected Result diff --git a/packages/server/drizzle/0081_dizzy_tyrannus.sql b/packages/server/drizzle/0081_dizzy_tyrannus.sql new file mode 100644 index 000000000..901efc5b8 --- /dev/null +++ b/packages/server/drizzle/0081_dizzy_tyrannus.sql @@ -0,0 +1,10 @@ +DROP INDEX "uq_gitlab_entity_pending_chat";--> statement-breakpoint +DROP INDEX "uq_gitlab_entity_observed_chat";--> statement-breakpoint +ALTER TABLE "gitlab_entity_chat_mappings" ADD COLUMN "attention_mode" text DEFAULT 'legacy_route_only' NOT NULL;--> statement-breakpoint +ALTER TABLE "gitlab_entity_chat_mappings" ADD COLUMN "attention_backfill_version" integer DEFAULT 0 NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_gitlab_entity_pending_pair" ON "gitlab_entity_chat_mappings" USING btree ("connection_id","human_agent_id","delegate_agent_id","project_path_normalized","entity_type","entity_iid") WHERE "gitlab_entity_chat_mappings"."project_id" IS NULL AND "gitlab_entity_chat_mappings"."active" AND "gitlab_entity_chat_mappings"."bound_via" <> 'identity_target' AND "gitlab_entity_chat_mappings"."human_agent_id" IS NOT NULL AND "gitlab_entity_chat_mappings"."delegate_agent_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_gitlab_entity_observed_pair" ON "gitlab_entity_chat_mappings" USING btree ("connection_id","human_agent_id","delegate_agent_id","project_id","entity_type","entity_iid") WHERE "gitlab_entity_chat_mappings"."project_id" IS NOT NULL AND "gitlab_entity_chat_mappings"."active" AND "gitlab_entity_chat_mappings"."bound_via" <> 'identity_target' AND "gitlab_entity_chat_mappings"."human_agent_id" IS NOT NULL AND "gitlab_entity_chat_mappings"."delegate_agent_id" IS NOT NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_gitlab_entity_pending_legacy_chat" ON "gitlab_entity_chat_mappings" USING btree ("connection_id","chat_id","project_path_normalized","entity_type","entity_iid") WHERE "gitlab_entity_chat_mappings"."project_id" IS NULL AND "gitlab_entity_chat_mappings"."active" AND "gitlab_entity_chat_mappings"."bound_via" <> 'identity_target' AND "gitlab_entity_chat_mappings"."human_agent_id" IS NULL AND "gitlab_entity_chat_mappings"."delegate_agent_id" IS NULL;--> statement-breakpoint +CREATE UNIQUE INDEX "uq_gitlab_entity_observed_legacy_chat" ON "gitlab_entity_chat_mappings" USING btree ("connection_id","chat_id","project_id","entity_type","entity_iid") WHERE "gitlab_entity_chat_mappings"."project_id" IS NOT NULL AND "gitlab_entity_chat_mappings"."active" AND "gitlab_entity_chat_mappings"."bound_via" <> 'identity_target' AND "gitlab_entity_chat_mappings"."human_agent_id" IS NULL AND "gitlab_entity_chat_mappings"."delegate_agent_id" IS NULL;--> statement-breakpoint +ALTER TABLE "gitlab_entity_chat_mappings" ADD CONSTRAINT "ck_gitlab_entity_attention_pair" CHECK (("gitlab_entity_chat_mappings"."human_agent_id" IS NULL AND "gitlab_entity_chat_mappings"."delegate_agent_id" IS NULL) OR ("gitlab_entity_chat_mappings"."human_agent_id" IS NOT NULL AND "gitlab_entity_chat_mappings"."delegate_agent_id" IS NOT NULL));--> statement-breakpoint +ALTER TABLE "gitlab_entity_chat_mappings" ADD CONSTRAINT "ck_gitlab_entity_attention_mode" CHECK ("gitlab_entity_chat_mappings"."attention_mode" IN ('paired', 'legacy_route_only')); \ No newline at end of file diff --git a/packages/server/drizzle/LATEST b/packages/server/drizzle/LATEST index c0384c466..0fcd22aa5 100644 --- a/packages/server/drizzle/LATEST +++ b/packages/server/drizzle/LATEST @@ -1 +1 @@ -0080_happy_komodo +0081_dizzy_tyrannus diff --git a/packages/server/drizzle/meta/0081_snapshot.json b/packages/server/drizzle/meta/0081_snapshot.json new file mode 100644 index 000000000..47acc16dd --- /dev/null +++ b/packages/server/drizzle/meta/0081_snapshot.json @@ -0,0 +1,5582 @@ +{ + "id": "ed77360a-1de2-4ce5-9f63-dd2d28edbdaf", + "prevId": "6c3daba7-309a-4f2b-86b1-7eb084db2730", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent_chat_sessions": { + "name": "agent_chat_sessions", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "runtime_state": { + "name": "runtime_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'idle'" + }, + "runtime_state_at": { + "name": "runtime_state_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_chat_sessions_chat_agent": { + "name": "idx_agent_chat_sessions_chat_agent", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_chat_sessions_agent_id_agents_uuid_fk": { + "name": "agent_chat_sessions_agent_id_agents_uuid_fk", + "tableFrom": "agent_chat_sessions", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_chat_sessions_chat_id_chats_id_fk": { + "name": "agent_chat_sessions_chat_id_chats_id_fk", + "tableFrom": "agent_chat_sessions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "agent_chat_sessions_agent_id_chat_id_pk": { + "name": "agent_chat_sessions_agent_id_chat_id_pk", + "columns": [ + "agent_id", + "chat_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_configs": { + "name": "agent_configs", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_presence": { + "name": "agent_presence", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_type": { + "name": "runtime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_version": { + "name": "runtime_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_state": { + "name": "runtime_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_sessions": { + "name": "active_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_sessions": { + "name": "total_sessions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "runtime_updated_at": { + "name": "runtime_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_presence_agent_id_agents_uuid_fk": { + "name": "agent_presence_agent_id_agents_uuid_fk", + "tableFrom": "agent_presence", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_presence_client_id_clients_id_fk": { + "name": "agent_presence_client_id_clients_id_fk", + "tableFrom": "agent_presence", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_resource_bindings": { + "name": "agent_resource_bindings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replaces_resource_id": { + "name": "replaces_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inline_prompt_body": { + "name": "inline_prompt_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_ref": { + "name": "repo_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "repo_local_path": { + "name": "repo_local_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_resource_bindings_agent": { + "name": "idx_agent_resource_bindings_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_resource_bindings_resource": { + "name": "idx_agent_resource_bindings_resource", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_resource_bindings_replaces": { + "name": "idx_agent_resource_bindings_replaces", + "columns": [ + { + "expression": "replaces_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_resource_bindings_organization_id_organizations_id_fk": { + "name": "agent_resource_bindings_organization_id_organizations_id_fk", + "tableFrom": "agent_resource_bindings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_resource_bindings_agent_id_agents_uuid_fk": { + "name": "agent_resource_bindings_agent_id_agents_uuid_fk", + "tableFrom": "agent_resource_bindings", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_resource_bindings_resource_id_resources_id_fk": { + "name": "agent_resource_bindings_resource_id_resources_id_fk", + "tableFrom": "agent_resource_bindings", + "tableTo": "resources", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_resource_bindings_replaces_resource_id_resources_id_fk": { + "name": "agent_resource_bindings_replaces_resource_id_resources_id_fk", + "tableFrom": "agent_resource_bindings", + "tableTo": "resources", + "columnsFrom": [ + "replaces_resource_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agents": { + "name": "agents", + "schema": "", + "columns": { + "uuid": { + "name": "uuid", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delegate_mention": { + "name": "delegate_mention", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "manager_id": { + "name": "manager_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "runtime_provider": { + "name": "runtime_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-code'" + }, + "avatar_color_token": { + "name": "avatar_color_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_image_data": { + "name": "avatar_image_data", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "avatar_image_mime": { + "name": "avatar_image_mime", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_image_updated_at": { + "name": "avatar_image_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agents_org": { + "name": "idx_agents_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agents_manager": { + "name": "idx_agents_manager", + "columns": [ + { + "expression": "manager_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agents_visibility_org": { + "name": "idx_agents_visibility_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agents_client": { + "name": "idx_agents_client", + "columns": [ + { + "expression": "client_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agents_organization_id_organizations_id_fk": { + "name": "agents_organization_id_organizations_id_fk", + "tableFrom": "agents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agents_client_id_clients_id_fk": { + "name": "agents_client_id_clients_id_fk", + "tableFrom": "agents", + "tableTo": "clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agents_inbox_id_unique": { + "name": "agents_inbox_id_unique", + "nullsNotDistinct": false, + "columns": [ + "inbox_id" + ] + }, + "uq_agents_org_name": { + "name": "uq_agents_org_name", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attachments": { + "name": "attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "attachments_uploaded_by_idx": { + "name": "attachments_uploaded_by_idx", + "columns": [ + { + "expression": "uploaded_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "attachments_created_at_idx": { + "name": "attachments_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.attentions": { + "name": "attentions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "origin_agent_id": { + "name": "origin_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_chat_id": { + "name": "origin_chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_human_id": { + "name": "target_human_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "requires_response": { + "name": "requires_response", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "response": { + "name": "response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responded_by": { + "name": "responded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled": { + "name": "cancelled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cancelled_reason": { + "name": "cancelled_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_attentions_target_open": { + "name": "idx_attentions_target_open", + "columns": [ + { + "expression": "target_human_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attentions_chat_open": { + "name": "idx_attentions_chat_open", + "columns": [ + { + "expression": "origin_chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_attentions_origin": { + "name": "idx_attentions_origin", + "columns": [ + { + "expression": "origin_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_identities": { + "name": "auth_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "credential_type": { + "name": "credential_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_payload": { + "name": "credential_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_auth_identities_user": { + "name": "idx_auth_identities_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_auth_identities_email": { + "name": "idx_auth_identities_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_identities_user_id_users_id_fk": { + "name": "auth_identities_user_id_users_id_fk", + "tableFrom": "auth_identities", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_auth_identities_provider_identifier": { + "name": "uq_auth_identities_provider_identifier", + "nullsNotDistinct": false, + "columns": [ + "provider", + "identifier" + ] + }, + "uq_auth_identities_user_provider": { + "name": "uq_auth_identities_user_provider", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_membership": { + "name": "chat_membership", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "access_mode": { + "name": "access_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_membership_agent": { + "name": "idx_membership_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_membership_chat_role": { + "name": "idx_membership_chat_role", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "access_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_membership_chat_id_agent_id_pk": { + "name": "chat_membership_chat_id_agent_id_pk", + "columns": [ + "chat_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_state": { + "name": "chat_user_state", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "unread_mention_count": { + "name": "unread_mention_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "open_request_count": { + "name": "open_request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "engagement_status": { + "name": "engagement_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_user_state_agent": { + "name": "idx_user_state_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_state_unread": { + "name": "idx_user_state_unread", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "unread_mention_count > 0", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_state_open_req": { + "name": "idx_user_state_open_req", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "open_request_count > 0", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_state_pinned": { + "name": "idx_user_state_pinned", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "pinned_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "chat_user_state_chat_id_agent_id_pk": { + "name": "chat_user_state_chat_id_agent_id_pk", + "columns": [ + "chat_id", + "agent_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'direct'" + }, + "topic": { + "name": "topic", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_updated_at": { + "name": "description_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lifecycle_policy": { + "name": "lifecycle_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'persistent'" + }, + "parent_chat_id": { + "name": "parent_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_kickoff_key": { + "name": "onboarding_kickoff_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_message_preview": { + "name": "last_message_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "activity_at": { + "name": "activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_chats_org_last_message": { + "name": "idx_chats_org_last_message", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"last_message_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chats_org_activity": { + "name": "idx_chats_org_activity", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"activity_at\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_chats_onboarding_kickoff_key": { + "name": "uq_chats_onboarding_kickoff_key", + "columns": [ + { + "expression": "onboarding_kickoff_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chats_organization_id_organizations_id_fk": { + "name": "chats_organization_id_organizations_id_fk", + "tableFrom": "chats", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.clients": { + "name": "clients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'disconnected'" + }, + "sdk_version": { + "name": "sdk_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os": { + "name": "os", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "connected_at": { + "name": "connected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_clients_user": { + "name": "idx_clients_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_clients_org": { + "name": "idx_clients_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "clients_user_id_users_id_fk": { + "name": "clients_user_id_users_id_fk", + "tableFrom": "clients", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "clients_organization_id_organizations_id_fk": { + "name": "clients_organization_id_organizations_id_fk", + "tableFrom": "clients", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connect_codes": { + "name": "connect_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_connect_codes_user": { + "name": "idx_connect_codes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_connect_codes_expires_at": { + "name": "idx_connect_codes_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "connect_codes_user_id_users_id_fk": { + "name": "connect_codes_user_id_users_id_fk", + "tableFrom": "connect_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "connect_codes_code_hash_unique": { + "name": "connect_codes_code_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "code_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.context_tree_io_events": { + "name": "context_tree_io_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_session_event_id": { + "name": "source_session_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_index": { + "name": "source_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "runtime_provider": { + "name": "runtime_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_repo_url": { + "name": "tree_repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tree_branch": { + "name": "tree_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_kind": { + "name": "target_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_path": { + "name": "target_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_context_tree_io_source": { + "name": "uq_context_tree_io_source", + "columns": [ + { + "expression": "source_session_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_context_tree_io_org_recent": { + "name": "idx_context_tree_io_org_recent", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_context_tree_io_org_action_recent": { + "name": "idx_context_tree_io_org_action_recent", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_context_tree_io_org_agent_recent": { + "name": "idx_context_tree_io_org_agent_recent", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_context_tree_io_org_target_recent": { + "name": "idx_context_tree_io_org_target_recent", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "context_tree_io_events_organization_id_organizations_id_fk": { + "name": "context_tree_io_events_organization_id_organizations_id_fk", + "tableFrom": "context_tree_io_events", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "context_tree_io_events_agent_id_agents_uuid_fk": { + "name": "context_tree_io_events_agent_id_agents_uuid_fk", + "tableFrom": "context_tree_io_events", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "context_tree_io_events_chat_id_chats_id_fk": { + "name": "context_tree_io_events_chat_id_chats_id_fk", + "tableFrom": "context_tree_io_events", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ck_context_tree_io_action": { + "name": "ck_context_tree_io_action", + "value": "\"context_tree_io_events\".\"action\" IN ('read', 'write')" + }, + "ck_context_tree_io_target_kind": { + "name": "ck_context_tree_io_target_kind", + "value": "\"context_tree_io_events\".\"target_kind\" IN ('file', 'directory', 'repo')" + }, + "ck_context_tree_io_target_path_nonempty": { + "name": "ck_context_tree_io_target_path_nonempty", + "value": "\"context_tree_io_events\".\"target_path\" <> ''" + } + }, + "isRLSEnabled": false + }, + "public.doc_comments": { + "name": "doc_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anchor": { + "name": "anchor", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_comments_document_status_idx": { + "name": "doc_comments_document_status_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_comments_parent_idx": { + "name": "doc_comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "doc_comments_document_id_doc_documents_id_fk": { + "name": "doc_comments_document_id_doc_documents_id_fk", + "tableFrom": "doc_comments", + "tableTo": "doc_documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "doc_comments_parent_id_doc_comments_id_fk": { + "name": "doc_comments_parent_id_doc_comments_id_fk", + "tableFrom": "doc_comments", + "tableTo": "doc_comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.doc_documents": { + "name": "doc_documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project": { + "name": "project", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "latest_version": { + "name": "latest_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_kind": { + "name": "created_by_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_name": { + "name": "created_by_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_documents_org_slug_unique": { + "name": "doc_documents_org_slug_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_documents_org_updated_idx": { + "name": "doc_documents_org_updated_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "doc_documents_organization_id_organizations_id_fk": { + "name": "doc_documents_organization_id_organizations_id_fk", + "tableFrom": "doc_documents", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.doc_versions": { + "name": "doc_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_kind": { + "name": "author_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_name": { + "name": "author_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_versions_document_number_unique": { + "name": "doc_versions_document_number_unique", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "doc_versions_document_id_doc_documents_id_fk": { + "name": "doc_versions_document_id_doc_documents_id_fk", + "tableFrom": "doc_versions", + "tableTo": "doc_documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_app_installations": { + "name": "github_app_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_github_id": { + "name": "account_github_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "installer_github_id": { + "name": "installer_github_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "requester_github_id": { + "name": "requester_github_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "hub_organization_id": { + "name": "hub_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_github_app_installations_installation_id": { + "name": "uq_github_app_installations_installation_id", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_github_app_installations_hub_org": { + "name": "uq_github_app_installations_hub_org", + "columns": [ + { + "expression": "hub_organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_app_installations_account": { + "name": "idx_github_app_installations_account", + "columns": [ + { + "expression": "account_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_app_installations_installer": { + "name": "idx_github_app_installations_installer", + "columns": [ + { + "expression": "installer_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_app_installations_requester": { + "name": "idx_github_app_installations_requester", + "columns": [ + { + "expression": "requester_github_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_app_installations_hub_organization_id_organizations_id_fk": { + "name": "github_app_installations_hub_organization_id_organizations_id_fk", + "tableFrom": "github_app_installations", + "tableTo": "organizations", + "columnsFrom": [ + "hub_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ck_github_app_installations_account_type": { + "name": "ck_github_app_installations_account_type", + "value": "\"github_app_installations\".\"account_type\" IN ('User', 'Organization')" + } + }, + "isRLSEnabled": false + }, + "public.github_entity_chat_mappings": { + "name": "github_entity_chat_mappings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "human_agent_id": { + "name": "human_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delegate_agent_id": { + "name": "delegate_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_key": { + "name": "entity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bound_at": { + "name": "bound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bound_via": { + "name": "bound_via", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_state": { + "name": "entity_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "entity_state_updated_at": { + "name": "entity_state_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_github_entity_chat_mappings_chat": { + "name": "idx_github_entity_chat_mappings_chat", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_github_entity_chat_mappings_chat_state": { + "name": "idx_github_entity_chat_mappings_chat_state", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_entity_chat_mappings_organization_id_organizations_id_fk": { + "name": "github_entity_chat_mappings_organization_id_organizations_id_fk", + "tableFrom": "github_entity_chat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "github_entity_chat_mappings_human_agent_id_agents_uuid_fk": { + "name": "github_entity_chat_mappings_human_agent_id_agents_uuid_fk", + "tableFrom": "github_entity_chat_mappings", + "tableTo": "agents", + "columnsFrom": [ + "human_agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_entity_chat_mappings_delegate_agent_id_agents_uuid_fk": { + "name": "github_entity_chat_mappings_delegate_agent_id_agents_uuid_fk", + "tableFrom": "github_entity_chat_mappings", + "tableTo": "agents", + "columnsFrom": [ + "delegate_agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_entity_chat_mappings_chat_id_chats_id_fk": { + "name": "github_entity_chat_mappings_chat_id_chats_id_fk", + "tableFrom": "github_entity_chat_mappings", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "github_entity_chat_mappings_organization_id_human_agent_id_delegate_agent_id_entity_type_entity_key_pk": { + "name": "github_entity_chat_mappings_organization_id_human_agent_id_delegate_agent_id_entity_type_entity_key_pk", + "columns": [ + "organization_id", + "human_agent_id", + "delegate_agent_id", + "entity_type", + "entity_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gitlab_connections": { + "name": "gitlab_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "instance_origin": { + "name": "instance_origin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint_first_seen_at": { + "name": "endpoint_first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_valid_inbound_at": { + "name": "last_valid_inbound_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_processing_failure_at": { + "name": "last_processing_failure_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_processing_failure_code": { + "name": "last_processing_failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stable_delivery_observed_at": { + "name": "stable_delivery_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_observed_version": { + "name": "last_observed_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewer_mode": { + "name": "reviewer_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "last_reviewer_schema_anomaly_at": { + "name": "last_reviewer_schema_anomaly_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reviewer_schema_anomaly_code": { + "name": "last_reviewer_schema_anomaly_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_member_id": { + "name": "created_by_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by_member_id": { + "name": "updated_by_member_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_gitlab_connections_org": { + "name": "uq_gitlab_connections_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_gitlab_connections_token_hash": { + "name": "uq_gitlab_connections_token_hash", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gitlab_connections_organization_id_organizations_id_fk": { + "name": "gitlab_connections_organization_id_organizations_id_fk", + "tableFrom": "gitlab_connections", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_connections_created_by_member_id_members_id_fk": { + "name": "gitlab_connections_created_by_member_id_members_id_fk", + "tableFrom": "gitlab_connections", + "tableTo": "members", + "columnsFrom": [ + "created_by_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "gitlab_connections_updated_by_member_id_members_id_fk": { + "name": "gitlab_connections_updated_by_member_id_members_id_fk", + "tableFrom": "gitlab_connections", + "tableTo": "members", + "columnsFrom": [ + "updated_by_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ck_gitlab_connections_reviewer_mode": { + "name": "ck_gitlab_connections_reviewer_mode", + "value": "\"gitlab_connections\".\"reviewer_mode\" IN ('unknown', 'assignee', 'reviewers')" + } + }, + "isRLSEnabled": false + }, + "public.gitlab_entity_chat_mappings": { + "name": "gitlab_entity_chat_mappings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "declared_by_agent_id": { + "name": "declared_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bound_via": { + "name": "bound_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'agent_declared'" + }, + "identity_link_id": { + "name": "identity_link_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "human_agent_id": { + "name": "human_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "delegate_agent_id": { + "name": "delegate_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attention_mode": { + "name": "attention_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'legacy_route_only'" + }, + "attention_backfill_version": { + "name": "attention_backfill_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_iid": { + "name": "entity_iid", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "project_id": { + "name": "project_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "project_path": { + "name": "project_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_path_normalized": { + "name": "project_path_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_url": { + "name": "entity_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "entity_state": { + "name": "entity_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_gitlab_entity_pending_pair": { + "name": "uq_gitlab_entity_pending_pair", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "human_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delegate_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_path_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"gitlab_entity_chat_mappings\".\"project_id\" IS NULL AND \"gitlab_entity_chat_mappings\".\"active\" AND \"gitlab_entity_chat_mappings\".\"bound_via\" <> 'identity_target' AND \"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_gitlab_entity_observed_pair": { + "name": "uq_gitlab_entity_observed_pair", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "human_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delegate_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"gitlab_entity_chat_mappings\".\"project_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"active\" AND \"gitlab_entity_chat_mappings\".\"bound_via\" <> 'identity_target' AND \"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_gitlab_entity_pending_legacy_chat": { + "name": "uq_gitlab_entity_pending_legacy_chat", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_path_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"gitlab_entity_chat_mappings\".\"project_id\" IS NULL AND \"gitlab_entity_chat_mappings\".\"active\" AND \"gitlab_entity_chat_mappings\".\"bound_via\" <> 'identity_target' AND \"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_gitlab_entity_observed_legacy_chat": { + "name": "uq_gitlab_entity_observed_legacy_chat", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"gitlab_entity_chat_mappings\".\"project_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"active\" AND \"gitlab_entity_chat_mappings\".\"bound_via\" <> 'identity_target' AND \"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_gitlab_entity_identity_target": { + "name": "uq_gitlab_entity_identity_target", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"gitlab_entity_chat_mappings\".\"project_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"active\" AND \"gitlab_entity_chat_mappings\".\"bound_via\" = 'identity_target'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gitlab_entity_observed_lookup": { + "name": "idx_gitlab_entity_observed_lookup", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gitlab_entity_pending_lookup": { + "name": "idx_gitlab_entity_pending_lookup", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "project_path_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_iid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"gitlab_entity_chat_mappings\".\"project_id\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gitlab_entity_chat": { + "name": "idx_gitlab_entity_chat", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gitlab_entity_chat_mappings_organization_id_organizations_id_fk": { + "name": "gitlab_entity_chat_mappings_organization_id_organizations_id_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_entity_chat_mappings_connection_id_gitlab_connections_id_fk": { + "name": "gitlab_entity_chat_mappings_connection_id_gitlab_connections_id_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "gitlab_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_entity_chat_mappings_chat_id_chats_id_fk": { + "name": "gitlab_entity_chat_mappings_chat_id_chats_id_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_entity_chat_mappings_declared_by_agent_id_agents_uuid_fk": { + "name": "gitlab_entity_chat_mappings_declared_by_agent_id_agents_uuid_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "agents", + "columnsFrom": [ + "declared_by_agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_entity_chat_mappings_identity_link_id_gitlab_identity_links_id_fk": { + "name": "gitlab_entity_chat_mappings_identity_link_id_gitlab_identity_links_id_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "gitlab_identity_links", + "columnsFrom": [ + "identity_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_entity_chat_mappings_human_agent_id_agents_uuid_fk": { + "name": "gitlab_entity_chat_mappings_human_agent_id_agents_uuid_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "agents", + "columnsFrom": [ + "human_agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_entity_chat_mappings_delegate_agent_id_agents_uuid_fk": { + "name": "gitlab_entity_chat_mappings_delegate_agent_id_agents_uuid_fk", + "tableFrom": "gitlab_entity_chat_mappings", + "tableTo": "agents", + "columnsFrom": [ + "delegate_agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ck_gitlab_entity_type": { + "name": "ck_gitlab_entity_type", + "value": "\"gitlab_entity_chat_mappings\".\"entity_type\" IN ('issue', 'pull_request')" + }, + "ck_gitlab_entity_bound_via": { + "name": "ck_gitlab_entity_bound_via", + "value": "\"gitlab_entity_chat_mappings\".\"bound_via\" IN ('agent_declared', 'human_declared', 'identity_target')" + }, + "ck_gitlab_entity_identity_owner": { + "name": "ck_gitlab_entity_identity_owner", + "value": "\"gitlab_entity_chat_mappings\".\"bound_via\" <> 'identity_target' OR (\"gitlab_entity_chat_mappings\".\"identity_link_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"project_id\" IS NOT NULL)" + }, + "ck_gitlab_entity_attention_pair": { + "name": "ck_gitlab_entity_attention_pair", + "value": "(\"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NULL) OR (\"gitlab_entity_chat_mappings\".\"human_agent_id\" IS NOT NULL AND \"gitlab_entity_chat_mappings\".\"delegate_agent_id\" IS NOT NULL)" + }, + "ck_gitlab_entity_attention_mode": { + "name": "ck_gitlab_entity_attention_mode", + "value": "\"gitlab_entity_chat_mappings\".\"attention_mode\" IN ('paired', 'legacy_route_only')" + } + }, + "isRLSEnabled": false + }, + "public.gitlab_identity_links": { + "name": "gitlab_identity_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_username": { + "name": "display_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_username": { + "name": "normalized_username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_gitlab_identity_connection_membership": { + "name": "uq_gitlab_identity_connection_membership", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_gitlab_identity_connection_username": { + "name": "uq_gitlab_identity_connection_username", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gitlab_identity_org_state": { + "name": "idx_gitlab_identity_org_state", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_gitlab_identity_membership_state": { + "name": "idx_gitlab_identity_membership_state", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gitlab_identity_links_organization_id_organizations_id_fk": { + "name": "gitlab_identity_links_organization_id_organizations_id_fk", + "tableFrom": "gitlab_identity_links", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gitlab_identity_links_membership_id_members_id_fk": { + "name": "gitlab_identity_links_membership_id_members_id_fk", + "tableFrom": "gitlab_identity_links", + "tableTo": "members", + "columnsFrom": [ + "membership_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "gitlab_identity_links_connection_id_gitlab_connections_id_fk": { + "name": "gitlab_identity_links_connection_id_gitlab_connections_id_fk", + "tableFrom": "gitlab_identity_links", + "tableTo": "gitlab_connections", + "columnsFrom": [ + "connection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "ck_gitlab_identity_state": { + "name": "ck_gitlab_identity_state", + "value": "\"gitlab_identity_links\".\"state\" IN ('active', 'suspended')" + } + }, + "isRLSEnabled": false + }, + "public.inbox_entries": { + "name": "inbox_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "inbox_id": { + "name": "inbox_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "notify": { + "name": "notify", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "acked_at": { + "name": "acked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_inbox_pending": { + "name": "idx_inbox_pending", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_inbox_pending_notify": { + "name": "idx_inbox_pending_notify", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'pending' AND notify = true", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_inbox_chat_silent": { + "name": "idx_inbox_chat_silent", + "columns": [ + { + "expression": "inbox_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "notify", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_inbox_entries_message_status": { + "name": "idx_inbox_entries_message_status", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inbox_entries_message_id_messages_id_fk": { + "name": "inbox_entries_message_id_messages_id_fk", + "tableFrom": "inbox_entries", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_inbox_delivery": { + "name": "uq_inbox_delivery", + "nullsNotDistinct": false, + "columns": [ + "inbox_id", + "message_id", + "chat_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_inbox_entries_status": { + "name": "ck_inbox_entries_status", + "value": "\"inbox_entries\".\"status\" IN ('pending', 'delivered', 'acked')" + } + }, + "isRLSEnabled": false + }, + "public.invitation_redemptions": { + "name": "invitation_redemptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redeemed_at": { + "name": "redeemed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_invitation_redemptions_invitation": { + "name": "idx_invitation_redemptions_invitation", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitation_redemptions_user": { + "name": "idx_invitation_redemptions_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_redemptions_invitation_id_invitations_id_fk": { + "name": "invitation_redemptions_invitation_id_invitations_id_fk", + "tableFrom": "invitation_redemptions", + "tableTo": "invitations", + "columnsFrom": [ + "invitation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_redemptions_user_id_users_id_fk": { + "name": "invitation_redemptions_user_id_users_id_fk", + "tableFrom": "invitation_redemptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_invitations_token": { + "name": "idx_invitations_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_invitations_org": { + "name": "idx_invitations_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_created_by_users_id_fk": { + "name": "invitations_created_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "onboarding_suppressed_at": { + "name": "onboarding_suppressed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarding_suppressed_reason": { + "name": "onboarding_suppressed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "onboarding_completed_at": { + "name": "onboarding_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_members_user": { + "name": "idx_members_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_members_org": { + "name": "idx_members_org", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_agent_id_agents_uuid_fk": { + "name": "members_agent_id_agents_uuid_fk", + "tableFrom": "members", + "tableTo": "agents", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_agent_id_unique": { + "name": "members_agent_id_unique", + "nullsNotDistinct": false, + "columns": [ + "agent_id" + ] + }, + "uq_members_user_org": { + "name": "uq_members_user_org", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ck_members_completed_implies_suppressed": { + "name": "ck_members_completed_implies_suppressed", + "value": "\"members\".\"onboarding_completed_at\" IS NULL OR \"members\".\"onboarding_suppressed_at\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "reply_to_inbox": { + "name": "reply_to_inbox", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reply_to_chat": { + "name": "reply_to_chat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_messages_chat_time": { + "name": "idx_messages_chat_time", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_in_reply_to": { + "name": "idx_messages_in_reply_to", + "columns": [ + { + "expression": "in_reply_to", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_chat_source_time": { + "name": "idx_messages_chat_source_time", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_mentions": { + "name": "idx_messages_mentions", + "columns": [ + { + "expression": "((\"metadata\" -> 'mentions')) jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dedup_key": { + "name": "dedup_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_notifications_org_created": { + "name": "idx_notifications_org_created", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_agent": { + "name": "idx_notifications_agent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_org_read": { + "name": "idx_notifications_org_read", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_notifications_org_dedup_unread": { + "name": "uq_notifications_org_dedup_unread", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dedup_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "read = false AND dedup_key IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_settings": { + "name": "organization_settings", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_org_settings_namespace": { + "name": "idx_org_settings_namespace", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_settings_organization_id_organizations_id_fk": { + "name": "organization_settings_organization_id_organizations_id_fk", + "tableFrom": "organization_settings", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_settings_updated_by_users_id_fk": { + "name": "organization_settings_updated_by_users_id_fk", + "tableFrom": "organization_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_settings_organization_id_namespace_pk": { + "name": "organization_settings_organization_id_namespace_pk", + "columns": [ + "organization_id", + "namespace" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "max_agents": { + "name": "max_agents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_messages_per_minute": { + "name": "max_messages_per_minute", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "features": { + "name": "features", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_name_unique": { + "name": "organizations_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_questions": { + "name": "pending_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "superseded_at": { + "name": "superseded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "superseded_reason": { + "name": "superseded_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_pending_questions_agent_status": { + "name": "idx_pending_questions_agent_status", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_pending_questions_chat_status": { + "name": "idx_pending_questions_chat_status", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_agent_id": { + "name": "owner_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_canonical_key": { + "name": "repo_canonical_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_enabled": { + "name": "default_enabled", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_resources_org_type_scope": { + "name": "idx_resources_org_type_scope", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scope", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resources_owner_agent": { + "name": "idx_resources_owner_agent", + "columns": [ + { + "expression": "owner_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resources_repo_key": { + "name": "idx_resources_repo_key", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_canonical_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_resources_team_repo_canonical_active": { + "name": "uq_resources_team_repo_canonical_active", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_canonical_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"resources\".\"type\" = 'repo' AND \"resources\".\"scope\" = 'team' AND \"resources\".\"status\" IN ('active', 'stale') AND \"resources\".\"repo_canonical_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "uq_resources_agent_repo_canonical_active": { + "name": "uq_resources_agent_repo_canonical_active", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "repo_canonical_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"resources\".\"type\" = 'repo' AND \"resources\".\"scope\" = 'agent' AND \"resources\".\"status\" IN ('active', 'stale') AND \"resources\".\"repo_canonical_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resources_organization_id_organizations_id_fk": { + "name": "resources_organization_id_organizations_id_fk", + "tableFrom": "resources", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resources_owner_agent_id_agents_uuid_fk": { + "name": "resources_owner_agent_id_agents_uuid_fk", + "tableFrom": "resources", + "tableTo": "agents", + "columnsFrom": [ + "owner_agent_id" + ], + "columnsTo": [ + "uuid" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.server_instances": { + "name": "server_instances", + "schema": "", + "columns": { + "instance_id": { + "name": "instance_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_heartbeat": { + "name": "last_heartbeat", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session_events": { + "name": "session_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_session_events_chat_seq": { + "name": "uq_session_events_chat_seq", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_events_chat_created": { + "name": "idx_session_events_chat_created", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_events_context_tree_usage_recent": { + "name": "idx_session_events_context_tree_usage_recent", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"session_events\".\"kind\" = 'context_tree_usage'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_events_context_tree_io_agent_recent": { + "name": "idx_session_events_context_tree_io_agent_recent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"session_events\".\"kind\" IN ('context_tree_usage', 'tool_call')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_session_events_token_usage_agent_recent": { + "name": "idx_session_events_token_usage_agent_recent", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"session_events\".\"kind\" = 'token_usage'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/server/drizzle/meta/_journal.json b/packages/server/drizzle/meta/_journal.json index 7792d50e1..717ac4edb 100644 --- a/packages/server/drizzle/meta/_journal.json +++ b/packages/server/drizzle/meta/_journal.json @@ -568,6 +568,13 @@ "when": 1784191584500, "tag": "0080_happy_komodo", "breakpoints": true + }, + { + "idx": 81, + "version": "7", + "when": 1784284070652, + "tag": "0081_dizzy_tyrannus", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/server/src/__tests__/chat-archive.test.ts b/packages/server/src/__tests__/chat-archive.test.ts index de8798c77..220944eaf 100644 --- a/packages/server/src/__tests__/chat-archive.test.ts +++ b/packages/server/src/__tests__/chat-archive.test.ts @@ -1,11 +1,11 @@ /** * Unit tests for the chat auto-archive sweeper (`sweepChatArchive`). Covers - * the two GitHub-source branches the sweeper owns: + * the two SCM-source branches the sweeper owns: * - * Mapped branch — source=github chats with GitHub mappings: archive when - * every mapped entity is terminal AND `last_message_at` is - * older than the GitHub idle threshold. - * No-mapping branch — source=github chats with no GitHub mapping: archive + * Mapped branch — provider-owned chats with matching SCM mappings: archive + * when every mapped entity is terminal AND + * `last_message_at` is older than the SCM idle threshold. + * No-mapping branch — provider-owned chats without an SCM mapping: archive * acknowledged (chat, user) pairs after the same idle * threshold. * @@ -16,12 +16,16 @@ import { randomUUID } from "node:crypto"; import { and, eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; +import { agentChatSessions } from "../db/schema/agent-chat-sessions.js"; import { agents } from "../db/schema/agents.js"; import { chatMembership } from "../db/schema/chat-membership.js"; import { chatUserState } from "../db/schema/chat-user-state.js"; import { chats } from "../db/schema/chats.js"; import { githubEntityChatMappings } from "../db/schema/github-entity-chat-mappings.js"; +import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; import { sweepChatArchive } from "../services/chat-archive.js"; +import { createGitlabConnection } from "../services/gitlab-connections.js"; +import { createGitlabIdentityLink } from "../services/gitlab-identities.js"; import { createMeChat } from "../services/me-chat.js"; import { createTestAdmin, useTestApp } from "./helpers.js"; @@ -100,7 +104,7 @@ const HOURS = 60 * 60; const longAgo = () => new Date(Date.now() - 48 * HOURS * 1000); const recent = () => new Date(Date.now() - 5 * 60 * 1000); -describe("sweepChatArchive — mapped source=github branch", () => { +describe("sweepChatArchive — mapped SCM branch", () => { const getApp = useTestApp(); it("archives a chat when every mapped entity is terminal and idle > threshold", async () => { @@ -126,6 +130,107 @@ describe("sweepChatArchive — mapped source=github branch", () => { expect(await getEngagement(app, chatId, human)).toBe("archived"); }); + it("archives a GitLab-owned chat under the same terminal and idle gates", async () => { + const app = getApp(); + const admin = await createTestAdmin(app); + const delegate = await seedDelegateAgent(app, admin.organizationId, admin.memberId); + await app.db.update(agents).set({ delegateMention: delegate }).where(eq(agents.uuid, admin.humanAgentUuid)); + const connection = await createGitlabConnection(app.db, { + organizationId: admin.organizationId, + memberId: admin.memberId, + displayName: "Archive GitLab", + instanceOrigin: "https://gitlab.internal", + }); + const link = await createGitlabIdentityLink(app.db, { + organizationId: admin.organizationId, + connectionId: connection.connectionId, + membershipId: admin.memberId, + username: "archive.user", + }); + const chatId = await seedChat(app, admin.organizationId, longAgo(), { + source: "gitlab", + entityType: "pull_request", + entityKey: "501:pull_request:17", + entityUrl: "https://gitlab.internal/acme/api/-/merge_requests/17", + }); + await app.db.insert(gitlabEntityChatMappings).values({ + id: randomUUID(), + organizationId: admin.organizationId, + connectionId: connection.connectionId, + chatId, + declaredByAgentId: admin.humanAgentUuid, + boundVia: "identity_target", + identityLinkId: link.id, + humanAgentId: admin.humanAgentUuid, + delegateAgentId: delegate, + active: true, + entityType: "pull_request", + entityIid: 17, + projectId: 501, + projectPath: "acme/api", + projectPathNormalized: "acme/api", + entityUrl: "https://gitlab.internal/acme/api/-/merge_requests/17", + title: "Merged", + entityState: "merged", + }); + + expect(await sweepChatArchive(app.db)).toMatchObject({ mappedRowsArchived: 1 }); + expect(await getEngagement(app, chatId, admin.humanAgentUuid)).toBe("archived"); + }); + + it("keeps a GitLab-owned chat active while any mapped entity remains open", async () => { + const app = getApp(); + const admin = await createTestAdmin(app); + const delegate = await seedDelegateAgent(app, admin.organizationId, admin.memberId); + await app.db.update(agents).set({ delegateMention: delegate }).where(eq(agents.uuid, admin.humanAgentUuid)); + const connection = await createGitlabConnection(app.db, { + organizationId: admin.organizationId, + memberId: admin.memberId, + displayName: "Mixed GitLab", + instanceOrigin: "https://gitlab.internal", + }); + const link = await createGitlabIdentityLink(app.db, { + organizationId: admin.organizationId, + connectionId: connection.connectionId, + membershipId: admin.memberId, + username: "mixed.user", + }); + const chatId = await seedChat(app, admin.organizationId, longAgo(), { + source: "gitlab", + entityType: "pull_request", + entityKey: "502:pull_request:18", + entityUrl: "https://gitlab.internal/acme/api/-/merge_requests/18", + }); + await app.db.insert(gitlabEntityChatMappings).values( + [ + { iid: 18, state: "merged", title: "Merged" }, + { iid: 19, state: "open", title: "Still open" }, + ].map(({ iid, state, title }) => ({ + id: randomUUID(), + organizationId: admin.organizationId, + connectionId: connection.connectionId, + chatId, + declaredByAgentId: admin.humanAgentUuid, + boundVia: "identity_target" as const, + identityLinkId: link.id, + humanAgentId: admin.humanAgentUuid, + delegateAgentId: delegate, + active: true, + entityType: "pull_request" as const, + entityIid: iid, + projectId: 502, + projectPath: "acme/api", + projectPathNormalized: "acme/api", + entityUrl: `https://gitlab.internal/acme/api/-/merge_requests/${iid}`, + title, + entityState: state, + })), + ); + + expect(await sweepChatArchive(app.db)).toMatchObject({ mappedRowsArchived: 0 }); + expect(await getEngagement(app, chatId, admin.humanAgentUuid)).toBeNull(); + }); + it("does not archive mapped chats unless their source is github", async () => { const app = getApp(); const admin = await createTestAdmin(app); @@ -198,6 +303,34 @@ describe("sweepChatArchive — mapped source=github branch", () => { expect(await getEngagement(app, chatId, human)).toBe("archived"); }); + it("does not archive while a session turn is working", async () => { + const app = getApp(); + const admin = await createTestAdmin(app); + const human = await seedHumanAgent(app, admin.organizationId, admin.memberId); + const delegate = await seedDelegateAgent(app, admin.organizationId, admin.memberId); + const chatId = await seedChat(app, admin.organizationId, longAgo()); + await app.db.insert(githubEntityChatMappings).values({ + organizationId: admin.organizationId, + humanAgentId: human, + delegateAgentId: delegate, + entityType: "pull_request", + entityKey: "owner/repo#working", + chatId, + boundVia: "direct", + entityState: "merged", + }); + await app.db.insert(agentChatSessions).values({ + agentId: delegate, + chatId, + state: "active", + runtimeState: "working", + runtimeStateAt: new Date(), + }); + + expect(await sweepChatArchive(app.db)).toMatchObject({ mappedRowsArchived: 0 }); + expect(await getEngagement(app, chatId, human)).toBeNull(); + }); + it("does not archive when any mapped entity is still open", async () => { const app = getApp(); const admin = await createTestAdmin(app); @@ -527,7 +660,7 @@ describe("sweepChatArchive — mapped source=github branch", () => { }); }); -describe("sweepChatArchive — no-mapping source=github branch", () => { +describe("sweepChatArchive — no-mapping SCM branch", () => { const getApp = useTestApp(); async function seedReadAcknowledgement(app: App, chatId: string, agentId: string): Promise { @@ -554,7 +687,24 @@ describe("sweepChatArchive — no-mapping source=github branch", () => { expect(await getEngagement(app, chatId, human)).toBe("archived"); }); - it("does not archive no-mapping chats unless their source is github", async () => { + it("archives an acknowledged source=gitlab chat after its mappings are removed", async () => { + const app = getApp(); + const admin = await createTestAdmin(app); + const human = await seedHumanAgent(app, admin.organizationId, admin.memberId); + const chatId = await seedChat(app, admin.organizationId, longAgo(), { + source: "gitlab", + entityType: "issue", + entityKey: "501:issue:7", + entityUrl: "https://gitlab.internal/acme/api/-/issues/7", + }); + await addHumanMember(app, chatId, human); + await seedReadAcknowledgement(app, chatId, human); + + expect(await sweepChatArchive(app.db)).toMatchObject({ unmappedRowsArchived: 1 }); + expect(await getEngagement(app, chatId, human)).toBe("archived"); + }); + + it("does not archive no-mapping chats unless their source is an SCM provider", async () => { const app = getApp(); const admin = await createTestAdmin(app); const human = await seedHumanAgent(app, admin.organizationId, admin.memberId); diff --git a/packages/server/src/__tests__/github-audience.test.ts b/packages/server/src/__tests__/github-audience.test.ts index b02775944..d0982c6d9 100644 --- a/packages/server/src/__tests__/github-audience.test.ts +++ b/packages/server/src/__tests__/github-audience.test.ts @@ -239,6 +239,7 @@ describe("resolveAudience", () => { chatId, involveReason: null, involveLogin: null, + provenance: "identity_target", }, ]); }); @@ -288,6 +289,7 @@ describe("resolveAudience", () => { chatId, involveReason: null, involveLogin: null, + provenance: "identity_target", }, ]); }); diff --git a/packages/server/src/__tests__/github-binding-invariants.test.ts b/packages/server/src/__tests__/github-binding-invariants.test.ts index 797ff4c43..167b63c0e 100644 --- a/packages/server/src/__tests__/github-binding-invariants.test.ts +++ b/packages/server/src/__tests__/github-binding-invariants.test.ts @@ -156,18 +156,16 @@ describe("github binding invariants", () => { expect(pair?.delegateAgentId).toBe(delegate); }); - it("falls back to the id-sorted-first active human when no delegateMention links the caller", async () => { + it("rejects an ambiguous owner when no delegateMention links the caller", async () => { const app = getApp(); const admin = await createTestAdmin(app); const delegate = await seedAgent(app, { orgId: admin.organizationId, memberId: admin.memberId, type: "agent" }); const humanA = await seedAgent(app, { orgId: admin.organizationId, memberId: admin.memberId, type: "human" }); const humanB = await seedAgent(app, { orgId: admin.organizationId, memberId: admin.memberId, type: "human" }); - const smallerHuman = humanA < humanB ? humanA : humanB; - const chatId = await seedChat(app, admin.organizationId, [humanA, humanB, delegate]); const pair = await resolveBindingPair(app.db, chatId, delegate); - expect(pair?.humanAgentId).toBe(smallerHuman); + expect(pair).toBeNull(); }); it("returns null when the caller is not a chat member, or is itself human", async () => { diff --git a/packages/server/src/__tests__/github-normalize.test.ts b/packages/server/src/__tests__/github-normalize.test.ts index e1592c424..89c8eef81 100644 --- a/packages/server/src/__tests__/github-normalize.test.ts +++ b/packages/server/src/__tests__/github-normalize.test.ts @@ -1,6 +1,6 @@ import type { ScmIngressContext } from "@first-tree/shared"; import { describe, expect, it } from "vitest"; -import { extractMentions, normalizeGithubEvent } from "../services/github-normalize.js"; +import { extractMentions, normalizeGithubEvent, normalizeGithubWebhook } from "../services/github-normalize.js"; const senderUser = { login: "alice", type: "User" }; const repository = { full_name: "owner/repo" }; @@ -15,6 +15,16 @@ function normalize(eventType: string, payload: unknown, deliveryId: string | nul return normalizeGithubEvent(eventType, payload, ingress); } +function normalizeEnvelope(eventType: string, payload: unknown) { + const ingress: ScmIngressContext = { + provider: "github", + source: { organizationId: "org-uuid", externalId: "installation:99" }, + stableDeliveryId: "d-envelope", + ingressAuthority: "verified_signature", + }; + return normalizeGithubWebhook(eventType, payload, ingress); +} + describe("extractMentions", () => { it("captures unique lowercased @mentions", () => { expect(extractMentions("hi @Alice and @bob, also @ALICE").sort()).toEqual(["alice", "bob"]); @@ -34,6 +44,47 @@ describe("extractMentions", () => { }); }); +describe("normalizeGithubWebhook", () => { + it("emits an observation-only envelope for terminal pull requests", () => { + const normalized = normalizeEnvelope("pull_request", { + action: "closed", + sender: senderUser, + repository, + pull_request: { + number: 15, + title: "Terminal PR", + html_url: "https://github.com/owner/repo/pull/15", + state: "closed", + merged: true, + }, + }); + expect(normalized.event).toBeNull(); + expect(normalized.observation).toMatchObject({ + state: "merged", + entity: { type: "pull_request", key: "owner/repo#15", title: "Terminal PR" }, + }); + expect(normalized.entityStateSeed?.state).toBe("merged"); + }); + + it("keeps non-transition observations from overwriting lifecycle state", () => { + const normalized = normalizeEnvelope("pull_request", { + action: "opened", + sender: senderUser, + repository, + pull_request: { + number: 16, + title: "Opened PR", + html_url: "https://github.com/owner/repo/pull/16", + state: "open", + draft: true, + }, + }); + expect(normalized.event?.kind).toBe("opened"); + expect(normalized.observation?.state).toBeNull(); + expect(normalized.entityStateSeed?.state).toBe("draft"); + }); +}); + describe("normalizeGithubEvent — pull_request", () => { it("opened: kind=opened with mentions + assignees + relatedRefs (reviewers deliberately excluded; see review_requested)", () => { const event = normalize("pull_request", { diff --git a/packages/server/src/__tests__/gitlab-attention-backfill.test.ts b/packages/server/src/__tests__/gitlab-attention-backfill.test.ts new file mode 100644 index 000000000..0488c3023 --- /dev/null +++ b/packages/server/src/__tests__/gitlab-attention-backfill.test.ts @@ -0,0 +1,297 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { agents } from "../db/schema/agents.js"; +import { chatMembership } from "../db/schema/chat-membership.js"; +import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; +import { backfillGitlabAttentionPairs } from "../services/gitlab-attention-backfill.js"; +import { createGitlabConnection, hashGitlabUrlBearer, withGitlabIngressFence } from "../services/gitlab-connections.js"; +import { + declareGitlabEntityFollowWithStatus, + observeGitlabEntityAndResolveFollowers, +} from "../services/gitlab-entity-follow.js"; +import { createMeChat } from "../services/me-chat.js"; +import { createTestAgent, useTestApp } from "./helpers.js"; + +describe("GitLab attention pair controlled backfill", () => { + const getApp = useTestApp(); + + it("pairs only deterministic legacy declarations and leaves ambiguous rows route-only", async () => { + const app = getApp(); + const primary = await createTestAgent(app, { name: `backfill-primary-${randomUUID().slice(0, 8)}` }); + const secondary = await createTestAgent(app, { name: `backfill-secondary-${randomUUID().slice(0, 8)}` }); + const { chatId } = await createMeChat(app.db, primary.humanAgentUuid, primary.organizationId, { + participantIds: [primary.agent.uuid], + }); + await app.db.insert(chatMembership).values([ + { + chatId, + agentId: secondary.humanAgentUuid, + role: "member", + accessMode: "speaker", + }, + { + chatId, + agentId: secondary.agent.uuid, + role: "member", + accessMode: "speaker", + }, + ]); + await app.db + .update(agents) + .set({ delegateMention: primary.agent.uuid }) + .where(eq(agents.uuid, primary.humanAgentUuid)); + const connection = await createGitlabConnection(app.db, { + organizationId: primary.organizationId, + memberId: primary.memberId, + displayName: "Backfill GitLab", + instanceOrigin: "https://gitlab.internal", + }); + + const base = { + organizationId: primary.organizationId, + connectionId: connection.connectionId, + chatId, + active: true, + entityType: "issue", + projectId: null, + projectPath: "Acme/API", + projectPathNormalized: "acme/api", + title: null, + entityState: "open", + }; + await app.db.insert(gitlabEntityChatMappings).values([ + { + ...base, + id: `legacy-agent-${randomUUID()}`, + declaredByAgentId: primary.agent.uuid, + boundVia: "agent_declared", + entityIid: 41, + entityUrl: "https://gitlab.internal/Acme/API/-/issues/41", + }, + { + ...base, + id: `legacy-human-${randomUUID()}`, + declaredByAgentId: primary.humanAgentUuid, + boundVia: "human_declared", + entityIid: 42, + entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", + }, + { + ...base, + id: `legacy-ambiguous-${randomUUID()}`, + declaredByAgentId: secondary.agent.uuid, + boundVia: "agent_declared", + entityIid: 43, + entityUrl: "https://gitlab.internal/Acme/API/-/issues/43", + }, + { + ...base, + id: `legacy-pending-ambiguous-${randomUUID()}`, + declaredByAgentId: secondary.agent.uuid, + boundVia: "agent_declared", + active: false, + entityIid: 44, + entityUrl: "https://gitlab.internal/Acme/API/-/issues/44", + }, + ]); + + await expect(backfillGitlabAttentionPairs(app.db)).resolves.toEqual({ + paired: 2, + legacyRouteOnly: 2, + }); + const rows = await app.db + .select() + .from(gitlabEntityChatMappings) + .where(eq(gitlabEntityChatMappings.chatId, chatId)); + expect(rows.find((row) => row.entityIid === 41)).toMatchObject({ + humanAgentId: primary.humanAgentUuid, + delegateAgentId: primary.agent.uuid, + attentionMode: "paired", + attentionBackfillVersion: 1, + }); + expect(rows.find((row) => row.entityIid === 42)).toMatchObject({ + humanAgentId: primary.humanAgentUuid, + delegateAgentId: primary.agent.uuid, + attentionMode: "paired", + attentionBackfillVersion: 1, + }); + expect(rows.find((row) => row.entityIid === 43)).toMatchObject({ + humanAgentId: null, + delegateAgentId: null, + attentionMode: "legacy_route_only", + attentionBackfillVersion: 1, + }); + expect(rows.find((row) => row.entityIid === 44)).toMatchObject({ + active: false, + humanAgentId: null, + delegateAgentId: null, + attentionMode: "legacy_route_only", + attentionBackfillVersion: 1, + }); + await app.db + .update(agents) + .set({ delegateMention: secondary.agent.uuid }) + .where(eq(agents.uuid, secondary.humanAgentUuid)); + await expect(backfillGitlabAttentionPairs(app.db)).resolves.toEqual({ + paired: 0, + legacyRouteOnly: 0, + }); + const [stillLegacy] = await app.db + .select() + .from(gitlabEntityChatMappings) + .where(eq(gitlabEntityChatMappings.entityIid, 43)); + expect(stillLegacy).toMatchObject({ + humanAgentId: null, + delegateAgentId: null, + attentionMode: "legacy_route_only", + attentionBackfillVersion: 1, + }); + const [stillPendingLegacy] = await app.db + .select() + .from(gitlabEntityChatMappings) + .where(eq(gitlabEntityChatMappings.entityIid, 44)); + expect(stillPendingLegacy).toMatchObject({ + active: false, + humanAgentId: null, + delegateAgentId: null, + attentionMode: "legacy_route_only", + attentionBackfillVersion: 1, + }); + }); + + it("marks the same pair and entity in two legacy chats ambiguous instead of choosing a home", async () => { + const app = getApp(); + const runtime = await createTestAgent(app, { name: `backfill-duplicate-${randomUUID().slice(0, 8)}` }); + await app.db + .update(agents) + .set({ delegateMention: runtime.agent.uuid }) + .where(eq(agents.uuid, runtime.humanAgentUuid)); + const first = await createMeChat(app.db, runtime.humanAgentUuid, runtime.organizationId, { + participantIds: [runtime.agent.uuid], + }); + const second = await createMeChat(app.db, runtime.humanAgentUuid, runtime.organizationId, { + participantIds: [runtime.agent.uuid], + }); + const connection = await createGitlabConnection(app.db, { + organizationId: runtime.organizationId, + memberId: runtime.memberId, + displayName: "Duplicate Backfill GitLab", + instanceOrigin: "https://gitlab.internal", + }); + const base = { + organizationId: runtime.organizationId, + connectionId: connection.connectionId, + declaredByAgentId: runtime.agent.uuid, + boundVia: "agent_declared", + active: true, + entityType: "issue", + entityIid: 51, + projectId: null, + projectPath: "Acme/API", + projectPathNormalized: "acme/api", + entityUrl: "https://gitlab.internal/Acme/API/-/issues/51", + title: null, + entityState: "open", + }; + await app.db.insert(gitlabEntityChatMappings).values([ + { ...base, id: `legacy-first-${randomUUID()}`, chatId: first.chatId }, + { ...base, id: `legacy-second-${randomUUID()}`, chatId: second.chatId }, + ]); + + await expect(backfillGitlabAttentionPairs(app.db)).resolves.toEqual({ + paired: 0, + legacyRouteOnly: 2, + }); + const rows = await app.db.select().from(gitlabEntityChatMappings).where(eq(gitlabEntityChatMappings.entityIid, 51)); + expect(rows).toHaveLength(2); + expect(rows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + chatId: first.chatId, + humanAgentId: null, + attentionMode: "legacy_route_only", + attentionBackfillVersion: 1, + }), + expect.objectContaining({ + chatId: second.chatId, + humanAgentId: null, + attentionMode: "legacy_route_only", + attentionBackfillVersion: 1, + }), + ]), + ); + }); + + it("serializes classification with live follow and ingress observation", async () => { + const app = getApp(); + const runtime = await createTestAgent(app, { name: `backfill-race-${randomUUID().slice(0, 8)}` }); + await app.db + .update(agents) + .set({ delegateMention: runtime.agent.uuid }) + .where(eq(agents.uuid, runtime.humanAgentUuid)); + const { chatId } = await createMeChat(app.db, runtime.humanAgentUuid, runtime.organizationId, { + participantIds: [runtime.agent.uuid], + }); + const connection = await createGitlabConnection(app.db, { + organizationId: runtime.organizationId, + memberId: runtime.memberId, + displayName: "Concurrent Backfill GitLab", + instanceOrigin: "https://gitlab.internal", + }); + const entityUrl = "https://gitlab.internal/Acme/API/-/issues/61"; + await app.db.insert(gitlabEntityChatMappings).values({ + id: `legacy-race-${randomUUID()}`, + organizationId: runtime.organizationId, + connectionId: connection.connectionId, + chatId, + declaredByAgentId: runtime.agent.uuid, + boundVia: "agent_declared", + active: true, + entityType: "issue", + entityIid: 61, + projectId: null, + projectPath: "Acme/API", + projectPathNormalized: "acme/api", + entityUrl, + title: null, + entityState: "open", + }); + + await Promise.all([ + backfillGitlabAttentionPairs(app.db), + declareGitlabEntityFollowWithStatus(app.db, { + organizationId: runtime.organizationId, + connectionId: connection.connectionId, + chatId, + declaredByAgentId: runtime.agent.uuid, + humanAgentId: runtime.humanAgentUuid, + delegateAgentId: runtime.agent.uuid, + entityUrl, + rebind: false, + }), + withGitlabIngressFence(app.db, connection.connectionId, hashGitlabUrlBearer(connection.bearer), async (tx) => + observeGitlabEntityAndResolveFollowers(tx, connection.connectionId, { + entityType: "issue", + entityIid: 61, + projectId: 701, + projectPath: "Acme/API", + entityUrl, + title: "Concurrent classification", + entityState: "open", + }), + ), + ]); + + const rows = await app.db.select().from(gitlabEntityChatMappings).where(eq(gitlabEntityChatMappings.entityIid, 61)); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + chatId, + projectId: 701, + humanAgentId: runtime.humanAgentUuid, + delegateAgentId: runtime.agent.uuid, + attentionMode: "paired", + attentionBackfillVersion: 1, + }); + }); +}); diff --git a/packages/server/src/__tests__/gitlab-entities-agent-route.test.ts b/packages/server/src/__tests__/gitlab-entities-agent-route.test.ts index 84953c5a6..d7e7b099b 100644 --- a/packages/server/src/__tests__/gitlab-entities-agent-route.test.ts +++ b/packages/server/src/__tests__/gitlab-entities-agent-route.test.ts @@ -72,7 +72,11 @@ describe("GitLab entity attention agent routes", () => { const secondChatFollow = await runtime.request("POST", `/api/v1/agent/chats/${second.chatId}/gitlab-entities`, { entityUrl, }); - expect(secondChatFollow.statusCode).toBe(201); + expect(secondChatFollow.statusCode).toBe(409); + expect(secondChatFollow.json()).toMatchObject({ + error: "ENTITY_FOLLOWED_ELSEWHERE", + conflict: { chatId }, + }); const pendingList = await runtime.request("GET", `/api/v1/agent/chats/${chatId}/gitlab-entities`); expect(pendingList.statusCode).toBe(200); @@ -117,11 +121,11 @@ describe("GitLab entity attention agent routes", () => { projectPath: "Acme/API", entityUrl, title: "Ship GitLab attention", - entityState: "opened", + entityState: "open", }); const activeList = await runtime.request("GET", `/api/v1/agent/chats/${chatId}/gitlab-entities`); expect(activeList.json()).toMatchObject({ - items: [{ status: "active", title: "Ship GitLab attention", state: "opened" }], + items: [{ status: "active", title: "Ship GitLab attention", state: "open" }], }); const identity = await createGitlabIdentityLink(app.db, { @@ -158,7 +162,7 @@ describe("GitLab entity attention agent routes", () => { projectPath: "Acme/API", entityIid: 42, title: "Ship GitLab attention", - state: "opened", + state: "open", status: "active", }, ], @@ -216,6 +220,21 @@ describe("GitLab entity attention agent routes", () => { ); expect(removed.statusCode).toBe(200); expect(removed.json()).toEqual({ removed: 2 }); + + const rebindSourceUrl = "https://gitlab.internal/Acme/API/-/issues/99"; + expect( + ( + await runtime.request("POST", `/api/v1/agent/chats/${chatId}/gitlab-entities`, { + entityUrl: rebindSourceUrl, + }) + ).statusCode, + ).toBe(201); + const rebound = await runtime.request("POST", `/api/v1/agent/chats/${second.chatId}/gitlab-entities`, { + entityUrl: rebindSourceUrl, + rebind: true, + }); + expect(rebound.statusCode).toBe(201); + expect(rebound.json()).toMatchObject({ status: "rebound", entity: { entityIid: 99 } }); const repeatedRemoval = await runtime.request( "DELETE", `/api/v1/agent/chats/${chatId}/gitlab-entities?entity=${encodeURIComponent(entityUrl)}`, diff --git a/packages/server/src/__tests__/gitlab-identity-fencing.test.ts b/packages/server/src/__tests__/gitlab-identity-fencing.test.ts index 35c0532bd..3c6754c1f 100644 --- a/packages/server/src/__tests__/gitlab-identity-fencing.test.ts +++ b/packages/server/src/__tests__/gitlab-identity-fencing.test.ts @@ -160,6 +160,8 @@ describe("GitLab identity authority fencing", () => { connectionId: fixture.connection.connectionId, chatId: chat.id, declaredByAgentId: boundVia === "human_declared" ? fixture.admin.humanAgentUuid : fixture.delegate.uuid, + humanAgentId: fixture.admin.humanAgentUuid, + delegateAgentId: fixture.delegate.uuid, boundVia, entityUrl: `https://gitlab.internal/Acme/Fenced/-/merge_requests/${iid}`, }); diff --git a/packages/server/src/__tests__/gitlab-webhook-normalize.test.ts b/packages/server/src/__tests__/gitlab-webhook-normalize.test.ts index 99dc6c951..bbe6d3c1d 100644 --- a/packages/server/src/__tests__/gitlab-webhook-normalize.test.ts +++ b/packages/server/src/__tests__/gitlab-webhook-normalize.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildClaimReadyGitlabDeliveryId } from "../services/gitlab-connections.js"; import { + applyGitlabPersonnelEvidence, extractStableGitlabDeliveryId, MAX_GITLAB_PERSONNEL_TARGETS, normalizeGitlabWebhook, @@ -85,6 +86,7 @@ describe("GitLab webhook normalization", () => { entity: { type: "issue" }, }); expect(note.personnel.mentions).toEqual(["Reviewer.One"]); + expect(note.observation?.state).toBeNull(); const editedNote = normalizeGitlabWebhook({ ...base, @@ -105,6 +107,112 @@ describe("GitLab webhook normalization", () => { expect(editedNote.event).toMatchObject({ kind: "edited", surface: { body: "edited comment" } }); }); + it("separates MR observation from semantic delivery and preserves actionable deltas", () => { + const normalizeMr = (objectAttributes: Record, changes?: Record) => + normalizeGitlabWebhook({ + ...base, + eventHeader: "Merge Request Hook", + body: { + object_kind: "merge_request", + project: project(), + user: { username: "alice" }, + reviewers: [{ username: "Reviewer.One" }], + assignees: [], + object_attributes: { + iid: 27, + title: "Envelope", + description: "Resolves #12 and `fixes #99`", + url: "https://gitlab.internal/Acme/API/-/merge_requests/27", + ...objectAttributes, + }, + ...(changes ? { changes } : {}), + }, + }); + + const synchronized = normalizeMr({ action: "update", oldrev: "abc123", state: "opened" }); + expect(synchronized).toMatchObject({ + observation: { state: "open", entity: { key: "99:pull_request:27" } }, + event: { kind: "synchronized" }, + }); + + const metadataOnly = normalizeMr( + { action: "update", state: "opened" }, + { labels: { previous: [], current: [{ title: "backend" }] } }, + ); + expect(metadataOnly.observation?.state).toBe("open"); + expect(metadataOnly.event).toBeNull(); + + const closed = normalizeMr({ action: "close", state: "closed" }); + expect(closed.observation?.state).toBe("closed"); + expect(closed.event).toBeNull(); + + const merged = normalizeMr({ action: "merge", state: "merged" }); + expect(merged.observation?.state).toBe("merged"); + expect(merged.event).toBeNull(); + + const ready = normalizeMr( + { action: "update", draft: false, state: "opened" }, + { draft: { previous: true, current: false } }, + ); + expect(ready.observation?.state).toBe("open"); + expect(ready.event?.kind).toBe("review_requested"); + expect(applyGitlabPersonnelEvidence(ready, "reviewers").event?.targets).toEqual([ + { externalUsername: "Reviewer.One", reason: "review_requested" }, + ]); + + const description = normalizeMr( + { + action: "update", + description: "Fixes #12, closes #12, resolves group/other#8 and ping @Target.One", + }, + { + description: { + previous: "old", + current: "Fixes #12, closes #12, resolves group/other#8 and ping @Target.One", + }, + }, + ); + expect(description.event).toMatchObject({ + kind: "edited", + relatedRefs: [{ type: "issue", key: "99:issue:12" }], + }); + expect(description.personnel.mentions).toEqual(["Target.One"]); + }); + + it("normalizes Issue lifecycle while dropping metadata-only updates", () => { + const normalizeIssue = (action: string, changes?: Record) => + normalizeGitlabWebhook({ + ...base, + eventHeader: "Issue Hook", + body: { + object_kind: "issue", + project: project(), + user: { username: "alice" }, + object_attributes: { + iid: 31, + action, + title: "Issue envelope", + description: "hello", + url: "https://gitlab.internal/Acme/API/-/issues/31", + }, + ...(changes ? { changes } : {}), + }, + }); + + expect(normalizeIssue("open")).toMatchObject({ observation: { state: "open" }, event: { kind: "opened" } }); + expect(normalizeIssue("close")).toMatchObject({ + observation: { state: "closed" }, + event: { kind: "closed" }, + }); + expect(normalizeIssue("reopen")).toMatchObject({ + observation: { state: "open" }, + event: { kind: "reopened" }, + }); + const labelOnly = normalizeIssue("update", { labels: { previous: [], current: [] } }); + expect(labelOnly.observation?.state).toBe("open"); + expect(labelOnly.event).toBeNull(); + }); + it("extracts personnel targets only from open and update actions", () => { const mr = (action: string | undefined, reviewerCount = 1) => normalizeGitlabWebhook({ diff --git a/packages/server/src/__tests__/gitlab-webhook-stage2a.test.ts b/packages/server/src/__tests__/gitlab-webhook-stage2a.test.ts index 374bcdedf..b4424b53c 100644 --- a/packages/server/src/__tests__/gitlab-webhook-stage2a.test.ts +++ b/packages/server/src/__tests__/gitlab-webhook-stage2a.test.ts @@ -7,6 +7,7 @@ import { chatMembership } from "../db/schema/chat-membership.js"; import { chats } from "../db/schema/chats.js"; import { gitlabConnections } from "../db/schema/gitlab-connections.js"; import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; +import { gitlabIdentityLinks } from "../db/schema/gitlab-identity-links.js"; import { members } from "../db/schema/members.js"; import { messages } from "../db/schema/messages.js"; import { processedEvents } from "../db/schema/processed-events.js"; @@ -16,13 +17,18 @@ import { deleteGitlabConnection, findActiveGitlabEndpoint, getGitlabConnectionSummary, + hashGitlabUrlBearer, markGitlabInboundSeen, regenerateGitlabConnectionBearer, replaceGitlabConnection, withCurrentGitlabConnectionFence, withGitlabIngressFence, } from "../services/gitlab-connections.js"; -import { declareGitlabEntityFollow, observeGitlabEntityAndResolveFollowers } from "../services/gitlab-entity-follow.js"; +import { + declareGitlabEntityFollow, + observeGitlabEntityAndResolveFollowers, + removeGitlabEntityFollow, +} from "../services/gitlab-entity-follow.js"; import { createOrganization } from "../services/organization.js"; import * as scmCardDelivery from "../services/scm-card-delivery.js"; import { createTestAdmin, createTestAgent, useTestApp } from "./helpers.js"; @@ -139,6 +145,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId: followedChatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }); const replaced = await replaceGitlabConnection(app.db, { @@ -284,7 +292,7 @@ describe("GitLab Stage 2A backend", () => { expect(afterWebhookId).toHaveLength(before + 1); }); - it("resolves a pending follow and delivers one basic card per chat without wake or outbound fetch", async () => { + it("resolves a pending follow and delivers one basic card per chat with wake and no outbound fetch", async () => { const app = getApp(); const first = await connection(app); const chatId = `chat_${randomUUID()}`; @@ -302,6 +310,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }); @@ -318,6 +328,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }); expect(repeated?.projectId).toBe(501); @@ -334,7 +346,7 @@ describe("GitLab Stage 2A backend", () => { expect(cards[0]).toMatchObject({ format: "card", senderId: first.admin.humanAgentUuid }); expect(cards[0]?.content).toMatchObject({ type: "gitlab_event", project: "Acme/API" }); expect(cards[0]?.metadata).toMatchObject({ source: "gitlab", systemSender: "gitlab" }); - expect(cards[0]?.metadata).not.toHaveProperty("mentions"); + expect(cards[0]?.metadata).toMatchObject({ mentions: [first.admin.humanAgentUuid] }); const stableId = `dedup-${randomUUID()}`; expect((await postWebhook(app, first.bearer, issuePayload(), { stableId })).json()).toMatchObject({ @@ -350,6 +362,85 @@ describe("GitLab Stage 2A backend", () => { expect(after).toHaveLength(3); }); + it("applies a terminal MR observation without emitting another card", async () => { + const app = getApp(); + const first = await connection(app); + const chatId = `chat_${randomUUID()}`; + await app.db + .insert(chats) + .values({ id: chatId, organizationId: first.admin.organizationId, type: "group", metadata: {} }); + await app.db.insert(chatMembership).values({ + chatId, + agentId: first.admin.humanAgentUuid, + role: "owner", + accessMode: "speaker", + }); + await declareGitlabEntityFollow(app.db, { + organizationId: first.admin.organizationId, + connectionId: first.connectionId, + chatId, + declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, + entityUrl: "https://gitlab.internal/Acme/API/-/merge_requests/52", + }); + const mrPayload = (action: "open" | "merge", state: "opened" | "merged") => ({ + object_kind: "merge_request", + project: { + id: 501, + path_with_namespace: "Acme/API", + web_url: "https://gitlab.internal/Acme/API", + }, + user: { username: "alice" }, + reviewers: [], + object_attributes: { + iid: 52, + action, + title: "Lifecycle MR", + description: "", + url: "https://gitlab.internal/Acme/API/-/merge_requests/52", + state, + }, + }); + expect( + (await postWebhook(app, first.bearer, mrPayload("open", "opened"), { event: "Merge Request Hook" })).json(), + ).toMatchObject({ outcome: "delivered" }); + expect( + (await postWebhook(app, first.bearer, mrPayload("merge", "merged"), { event: "Merge Request Hook" })).json(), + ).toMatchObject({ outcome: "provider_only" }); + expect( + await app.db + .select() + .from(messages) + .where(and(eq(messages.chatId, chatId), eq(messages.source, "gitlab"))), + ).toHaveLength(1); + const note = { + object_kind: "note", + project: { + id: 501, + path_with_namespace: "Acme/API", + web_url: "https://gitlab.internal/Acme/API", + }, + user: { username: "alice" }, + object_attributes: { + noteable_type: "MergeRequest", + note: "post-merge note", + action: "create", + }, + merge_request: { + iid: 52, + title: "Lifecycle MR", + url: "https://gitlab.internal/Acme/API/-/merge_requests/52", + }, + }; + expect((await postWebhook(app, first.bearer, note, { event: "Note Hook" })).statusCode).toBe(200); + const [mapping] = await app.db + .select() + .from(gitlabEntityChatMappings) + .where(eq(gitlabEntityChatMappings.chatId, chatId)); + expect(mapping?.entityState).toBe("merged"); + }); + it("rejects malformed or mismatched payloads before claiming", async () => { const app = getApp(); const first = await connection(app); @@ -396,6 +487,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: `https://gitlab.internal/${projectPath}/-/issues/42`, }); } @@ -406,7 +499,7 @@ describe("GitLab Stage 2A backend", () => { projectPath, entityUrl: `https://gitlab.internal/${projectPath}/-/issues/42`, title: "Webhook issue", - entityState: "opened", + entityState: "open" as const, }); expect( await observeGitlabEntityAndResolveFollowers(app.db, first.connectionId, identity(501, "Acme/API")), @@ -591,6 +684,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }); releaseFence(); @@ -652,6 +747,7 @@ describe("GitLab Stage 2A backend", () => { expect(unseen).toHaveLength(0); for (const suffix of ["a", "b"]) { + const routeAgent = await createTestAgent(app, { name: `pending-${suffix}-${randomUUID().slice(0, 8)}` }); const chatId = `chat_${suffix}_${randomUUID()}`; await app.db.insert(chats).values({ id: chatId, @@ -670,6 +766,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: routeAgent.agent.uuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }); expect(follow).toMatchObject({ projectId: null }); @@ -706,6 +804,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }) )?.projectId, @@ -718,6 +818,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, entityUrl: "https://gitlab.internal/Acme/Renamed/-/issues/42", }) )?.projectId, @@ -737,6 +839,7 @@ describe("GitLab Stage 2A backend", () => { const app = getApp(); const first = await connection(app); for (const suffix of ["a", "b"]) { + const routeAgent = await createTestAgent(app, { name: `failure-${suffix}-${randomUUID().slice(0, 8)}` }); const chatId = `chat_${suffix}_${randomUUID()}`; await app.db.insert(chats).values({ id: chatId, @@ -755,6 +858,8 @@ describe("GitLab Stage 2A backend", () => { connectionId: first.connectionId, chatId, declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: routeAgent.agent.uuid, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42", }); } @@ -970,8 +1075,12 @@ describe("GitLab Stage 2A backend", () => { chatId, agentId: manager.humanAgentUuid, role: "member", - accessMode: "watcher", + accessMode: "speaker", }); + await app.db + .update(agents) + .set({ delegateMention: manager.agent.uuid }) + .where(eq(agents.uuid, manager.humanAgentUuid)); const directFollow = await follow(); expect(directFollow.statusCode).toBe(201); const malformedEncoding = await app.inject({ @@ -984,15 +1093,185 @@ describe("GitLab Stage 2A backend", () => { }, }); expect(malformedEncoding.statusCode).toBe(400); - const mappingId = (directFollow.json() as { entity: { id: string } }).entity.id; + expect(JSON.stringify(directFollow.json())).not.toMatch( + /connectionId|organizationId|declaredByAgentId|humanAgentId|delegateAgentId|identityLinkId|mappingId/, + ); + const compatibleList = await app.inject({ + method: "GET", + url: `/api/v1/chats/${chatId}/gitlab-entities`, + headers: { authorization: `Bearer ${manager.accessToken}` }, + }); + expect(compatibleList.statusCode).toBe(200); + expect(compatibleList.json()).toMatchObject({ + entities: [ + { + id: expect.any(String), + organizationId: manager.organizationId, + connectionId: created.connectionId, + entityIid: 42, + }, + ], + items: [{ entityIid: 42, entityUrl: "https://gitlab.internal/Acme/API/-/issues/42" }], + }); + expect(JSON.stringify(compatibleList.json().items)).not.toMatch( + /connectionId|organizationId|declaredByAgentId|humanAgentId|delegateAgentId|identityLinkId|mappingId/, + ); + const canonicalDelete = await app.inject({ + method: "DELETE", + url: `/api/v1/chats/${chatId}/gitlab-entities?entity=${encodeURIComponent( + "https://gitlab.internal/Acme/API/-/issues/42", + )}`, + headers: { authorization: `Bearer ${manager.accessToken}` }, + }); + expect(canonicalDelete.statusCode).toBe(200); + expect(canonicalDelete.json()).toEqual({ removed: 1 }); + + const followLegacyAlias = async (iid: number) => + app.inject({ + method: "POST", + url: `/api/v1/chats/${chatId}/gitlab-entities`, + headers: { authorization: `Bearer ${manager.accessToken}` }, + payload: { + connectionId: created.connectionId, + entityUrl: `https://gitlab.internal/Acme/API/-/issues/${iid}`, + }, + }); + expect((await followLegacyAlias(44)).statusCode).toBe(201); + const [mapping] = await app.db + .select({ id: gitlabEntityChatMappings.id }) + .from(gitlabEntityChatMappings) + .where(eq(gitlabEntityChatMappings.chatId, chatId)); + if (!mapping) throw new Error("GitLab mapping missing"); + const legacyDelete = await app.inject({ + method: "DELETE", + url: `/api/v1/chats/${chatId}/gitlab-entities?mappingId=${mapping.id}`, + headers: { authorization: `Bearer ${manager.accessToken}` }, + }); + expect(legacyDelete.statusCode).toBe(200); + expect(legacyDelete.json()).toEqual({ removed: 1 }); + + expect((await followLegacyAlias(45)).statusCode).toBe(201); + const [protectedMapping] = await app.db + .select({ id: gitlabEntityChatMappings.id }) + .from(gitlabEntityChatMappings) + .where(eq(gitlabEntityChatMappings.chatId, chatId)); + if (!protectedMapping) throw new Error("GitLab protected mapping missing"); await app.db .delete(chatMembership) .where(and(eq(chatMembership.chatId, chatId), eq(chatMembership.agentId, manager.humanAgentUuid))); const supervisorDelete = await app.inject({ method: "DELETE", - url: `/api/v1/chats/${chatId}/gitlab-entities?mappingId=${mappingId}`, + url: `/api/v1/chats/${chatId}/gitlab-entities?mappingId=${protectedMapping.id}`, headers: { authorization: `Bearer ${manager.accessToken}` }, }); expect(supervisorDelete.statusCode).toBe(404); }); + + it("serializes legacy mapping-id unfollow with pending activation", async () => { + const app = getApp(); + const first = await connection(app); + const chatId = `chat_${randomUUID()}`; + await app.db.insert(chats).values({ + id: chatId, + organizationId: first.admin.organizationId, + type: "group", + metadata: {}, + }); + const pending = await declareGitlabEntityFollow(app.db, { + organizationId: first.admin.organizationId, + connectionId: first.connectionId, + chatId, + declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, + entityUrl: "https://gitlab.internal/Acme/API/-/issues/47", + }); + if (!pending) throw new Error("pending GitLab mapping missing"); + const identityLinkId = `identity-${randomUUID()}`; + await app.db.insert(gitlabIdentityLinks).values({ + id: identityLinkId, + organizationId: first.admin.organizationId, + membershipId: first.admin.memberId, + connectionId: first.connectionId, + displayUsername: "reviewer.one", + normalizedUsername: "reviewer.one", + state: "active", + }); + await app.db.insert(gitlabEntityChatMappings).values({ + id: `identity-route-${randomUUID()}`, + organizationId: first.admin.organizationId, + connectionId: first.connectionId, + chatId, + boundVia: "identity_target", + identityLinkId, + declaredByAgentId: first.admin.humanAgentUuid, + humanAgentId: first.admin.humanAgentUuid, + delegateAgentId: first.admin.humanAgentUuid, + attentionMode: "paired", + attentionBackfillVersion: 1, + active: true, + entityType: "issue", + entityIid: 47, + projectId: 501, + projectPath: "Acme/API", + projectPathNormalized: "acme/api", + entityUrl: "https://gitlab.internal/Acme/API/-/issues/47", + title: "Personnel route", + entityState: "open", + }); + + let ingressLocked!: () => void; + let releaseIngress!: () => void; + const ingressLock = new Promise((resolve) => { + ingressLocked = resolve; + }); + const ingressRelease = new Promise((resolve) => { + releaseIngress = resolve; + }); + const activating = withGitlabIngressFence( + app.db, + first.connectionId, + hashGitlabUrlBearer(first.bearer), + async (tx) => { + ingressLocked(); + await ingressRelease; + return observeGitlabEntityAndResolveFollowers(tx, first.connectionId, { + entityType: "issue", + entityIid: 47, + projectId: 501, + projectPath: "Acme/API", + entityUrl: "https://gitlab.internal/Acme/API/-/issues/47", + title: "Activated while unfollowing", + entityState: "open", + }); + }, + ); + await ingressLock; + + let unfollowSettled = false; + const unfollowing = removeGitlabEntityFollow(app.db, { + organizationId: first.admin.organizationId, + chatId, + mappingId: pending.id, + }).finally(() => { + unfollowSettled = true; + }); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(unfollowSettled).toBe(false); + + releaseIngress(); + await activating; + await expect(unfollowing).resolves.toBe(1); + const active = await app.db + .select({ id: gitlabEntityChatMappings.id, boundVia: gitlabEntityChatMappings.boundVia }) + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.chatId, chatId), + eq(gitlabEntityChatMappings.entityIid, 47), + eq(gitlabEntityChatMappings.active, true), + ), + ); + expect(active).toEqual([expect.objectContaining({ boundVia: "identity_target" })]); + }); }); diff --git a/packages/server/src/__tests__/gitlab-webhook-stage3.test.ts b/packages/server/src/__tests__/gitlab-webhook-stage3.test.ts index 79a5e4f92..3472fbe42 100644 --- a/packages/server/src/__tests__/gitlab-webhook-stage3.test.ts +++ b/packages/server/src/__tests__/gitlab-webhook-stage3.test.ts @@ -3,6 +3,7 @@ import { and, eq } from "drizzle-orm"; import { describe, expect, it } from "vitest"; import { agentChatSessions } from "../db/schema/agent-chat-sessions.js"; import { agents } from "../db/schema/agents.js"; +import { chats } from "../db/schema/chats.js"; import { gitlabConnections } from "../db/schema/gitlab-connections.js"; import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; import { gitlabIdentityLinks } from "../db/schema/gitlab-identity-links.js"; @@ -42,13 +43,16 @@ function mergeRequestPayload(input: { changes?: unknown; iid?: number; actor?: string; + projectPath?: string; + title?: string; }) { + const projectPath = input.projectPath ?? "Acme/Reviews"; return { object_kind: "merge_request", project: { id: 801, - path_with_namespace: "Acme/Reviews", - web_url: "https://gitlab.internal/Acme/Reviews", + path_with_namespace: projectPath, + web_url: `https://gitlab.internal/${projectPath}`, }, user: { username: input.actor ?? "author" }, ...(Object.hasOwn(input, "reviewers") ? { reviewers: input.reviewers } : {}), @@ -57,9 +61,9 @@ function mergeRequestPayload(input: { object_attributes: { iid: input.iid ?? 17, ...(input.action === null ? {} : { action: input.action ?? "open" }), - title: "Review this change", + title: input.title ?? "Review this change", description: "Please review", - url: `https://gitlab.internal/Acme/Reviews/-/merge_requests/${input.iid ?? 17}`, + url: `https://gitlab.internal/${projectPath}/-/merge_requests/${input.iid ?? 17}`, state: "opened", }, }; @@ -380,9 +384,10 @@ describe("GitLab Stage 3 personnel routing", () => { ).statusCode, ).toBe(200); const afterReroute = await app.db.select().from(gitlabEntityChatMappings).where(mappingScope); - expect(afterReroute).toHaveLength(2); + expect(afterReroute).toHaveLength(3); expect(afterReroute.filter((row) => row.active)).toMatchObject([{ delegateAgentId: nextDelegate.uuid }]); - expect(afterReroute.find((row) => row.delegateAgentId === setup.delegate.uuid)?.active).toBe(false); + expect(afterReroute.filter((row) => row.delegateAgentId === setup.delegate.uuid)).toHaveLength(1); + expect(afterReroute.filter((row) => !row.active)).toHaveLength(2); }); it("suspends the current binding on admin member removal", async () => { @@ -462,6 +467,8 @@ describe("GitLab Stage 3 personnel routing", () => { identityLinkId: setup.link.id, active: true, }); + const [automaticChat] = await app.db.select().from(chats).where(eq(chats.id, mapping.chatId)); + expect(automaticChat?.topic).toBe("MR Review Reviews!17: Review this change"); const visibleBindings = await app.inject({ method: "GET", url: `/api/v1/chats/${mapping.chatId}/gitlab-entities`, @@ -540,6 +547,182 @@ describe("GitLab Stage 3 personnel routing", () => { expect(queued).toEqual({ notify: true, status: "delivered" }); }); + it("refreshes an automatic GitLab anchor topic but preserves a manual rename", async () => { + const app = getApp(); + const setup = await setupTarget(app); + const iid = 25; + expect( + ( + await postMr( + app, + setup.connection.bearer, + mergeRequestPayload({ iid, reviewers: [{ username: "Reviewer.One" }] }), + ) + ).statusCode, + ).toBe(200); + const [mapping] = await app.db + .select() + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, setup.connection.connectionId), + eq(gitlabEntityChatMappings.entityIid, iid), + ), + ); + if (!mapping) throw new Error("automatic mapping missing"); + + expect( + ( + await postMr( + app, + setup.connection.bearer, + mergeRequestPayload({ + action: "update", + iid, + reviewers: [], + projectPath: "Acme/Renamed", + title: "Renamed title", + changes: { title: { previous: "Review this change", current: "Renamed title" } }, + }), + ) + ).statusCode, + ).toBe(200); + expect((await app.db.select().from(chats).where(eq(chats.id, mapping.chatId)))[0]?.topic).toBe( + "MR Review Renamed!25: Renamed title", + ); + + await app.db.update(chats).set({ topic: "Manual topic" }).where(eq(chats.id, mapping.chatId)); + expect( + ( + await postMr( + app, + setup.connection.bearer, + mergeRequestPayload({ + action: "update", + iid, + reviewers: [], + projectPath: "Acme/Renamed", + title: "Another title", + changes: { title: { previous: "Renamed title", current: "Another title" } }, + }), + ) + ).statusCode, + ).toBe(200); + expect((await app.db.select().from(chats).where(eq(chats.id, mapping.chatId)))[0]?.topic).toBe("Manual topic"); + }); + + it("reuses one reviewer membership chat without inventing a target line", async () => { + const app = getApp(); + const setup = await setupTarget(app); + const iid = 23; + const chat = await createChat(app.db, setup.admin.humanAgentUuid, { + type: "group", + participantIds: [setup.delegate.uuid], + }); + await app.db.insert(gitlabEntityChatMappings).values({ + id: randomUUID(), + organizationId: setup.admin.organizationId, + connectionId: setup.connection.connectionId, + chatId: chat.id, + declaredByAgentId: setup.delegate.uuid, + boundVia: "agent_declared", + humanAgentId: null, + delegateAgentId: null, + active: true, + entityType: "pull_request", + entityIid: iid, + projectId: 801, + projectPath: "Acme/Reviews", + projectPathNormalized: "acme/reviews", + entityUrl: `https://gitlab.internal/Acme/Reviews/-/merge_requests/${iid}`, + title: "Review this change", + entityState: "open", + }); + + expect( + ( + await postMr( + app, + setup.connection.bearer, + mergeRequestPayload({ iid, reviewers: [{ username: "Reviewer.One" }] }), + ) + ).statusCode, + ).toBe(200); + const rows = await app.db + .select() + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, setup.connection.connectionId), + eq(gitlabEntityChatMappings.entityIid, iid), + ), + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ chatId: chat.id, boundVia: "agent_declared" }); + expect( + await app.db + .select() + .from(messages) + .where(and(eq(messages.chatId, chat.id), eq(messages.source, "gitlab"))), + ).toHaveLength(1); + }); + + it("does not reuse entity membership for an assignment target", async () => { + const app = getApp(); + const setup = await setupTarget(app); + const iid = 24; + const existingChat = await createChat(app.db, setup.admin.humanAgentUuid, { + type: "group", + participantIds: [setup.delegate.uuid], + }); + await app.db.insert(gitlabEntityChatMappings).values({ + id: randomUUID(), + organizationId: setup.admin.organizationId, + connectionId: setup.connection.connectionId, + chatId: existingChat.id, + declaredByAgentId: setup.delegate.uuid, + boundVia: "agent_declared", + humanAgentId: null, + delegateAgentId: null, + active: true, + entityType: "pull_request", + entityIid: iid, + projectId: 801, + projectPath: "Acme/Reviews", + projectPathNormalized: "acme/reviews", + entityUrl: `https://gitlab.internal/Acme/Reviews/-/merge_requests/${iid}`, + title: "Review this change", + entityState: "open", + }); + + expect( + ( + await postMr( + app, + setup.connection.bearer, + mergeRequestPayload({ + iid, + reviewers: [], + assignees: [{ username: "Reviewer.One" }], + }), + ) + ).statusCode, + ).toBe(200); + const rows = await app.db + .select() + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, setup.connection.connectionId), + eq(gitlabEntityChatMappings.entityIid, iid), + ), + ); + expect(rows).toHaveLength(2); + const targetLine = rows.find((row) => row.boundVia === "identity_target"); + expect(targetLine?.chatId).toBeTruthy(); + expect(targetLine?.chatId).not.toBe(existingChat.id); + }); + it("unfollows an automatic route and lets a later reviewer event create a fresh chat", async () => { const app = getApp(); const setup = await setupTarget(app); @@ -635,6 +818,8 @@ describe("GitLab Stage 3 personnel routing", () => { connectionId: setup.connection.connectionId, chatId: chat.id, declaredByAgentId: declaredBy === "human" ? setup.admin.humanAgentUuid : setup.delegate.uuid, + humanAgentId: setup.admin.humanAgentUuid, + delegateAgentId: setup.delegate.uuid, boundVia, entityUrl: `https://gitlab.internal/Acme/Reviews/-/merge_requests/${iid}`, }); @@ -654,6 +839,49 @@ describe("GitLab Stage 3 personnel routing", () => { ).toHaveLength(0); }); + it("wakes the stored delegate for an ordinary event on an explicit attention line", async () => { + const app = getApp(); + const setup = await setupTarget(app); + const iid = 63; + const chat = await createChat(app.db, setup.admin.humanAgentUuid, { + type: "group", + participantIds: [setup.delegate.uuid], + topic: `Explicit GitLab follow ${iid}`, + metadata: {}, + }); + await declareGitlabEntityFollow(app.db, { + organizationId: setup.admin.organizationId, + connectionId: setup.connection.connectionId, + chatId: chat.id, + declaredByAgentId: setup.delegate.uuid, + humanAgentId: setup.admin.humanAgentUuid, + delegateAgentId: setup.delegate.uuid, + boundVia: "agent_declared", + entityUrl: `https://gitlab.internal/Acme/Reviews/-/merge_requests/${iid}`, + }); + + expect( + (await postMr(app, setup.connection.bearer, mergeRequestPayload({ iid, actor: "another.user", reviewers: [] }))) + .statusCode, + ).toBe(200); + const [card] = await app.db + .select() + .from(messages) + .where(and(eq(messages.chatId, chat.id), eq(messages.source, "gitlab"))); + expect(card?.content).toMatchObject({ reason: "subscribed" }); + expect(card?.metadata).toMatchObject({ mentions: [setup.delegate.uuid] }); + const [delegate] = await app.db + .select({ inboxId: agents.inboxId }) + .from(agents) + .where(eq(agents.uuid, setup.delegate.uuid)); + expect( + await app.db + .select({ notify: inboxEntries.notify }) + .from(inboxEntries) + .where(and(eq(inboxEntries.inboxId, delegate?.inboxId ?? ""), eq(inboxEntries.messageId, card?.id ?? ""))), + ).toEqual([{ notify: true }]); + }); + it("rejects oversized personnel payloads before claim or connection health mutation", async () => { const app = getApp(); const setup = await setupTarget(app); diff --git a/packages/server/src/__tests__/scm-attention-line.test.ts b/packages/server/src/__tests__/scm-attention-line.test.ts new file mode 100644 index 000000000..47f53e842 --- /dev/null +++ b/packages/server/src/__tests__/scm-attention-line.test.ts @@ -0,0 +1,147 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { agents } from "../db/schema/agents.js"; +import { chatMembership } from "../db/schema/chat-membership.js"; +import { createMeChat } from "../services/me-chat.js"; +import { + executeScmFollowLine, + resolveAgentScmBindingPair, + resolveHumanScmBindingPair, +} from "../services/scm-attention-line.js"; +import { createTestAgent, useTestApp } from "./helpers.js"; + +describe("SCM attention binding pair policy", () => { + const getApp = useTestApp(); + + it("resolves the same complete pair for agent and human follow", async () => { + const app = getApp(); + const runtime = await createTestAgent(app, { name: `pair-${randomUUID().slice(0, 8)}` }); + const { chatId } = await createMeChat(app.db, runtime.humanAgentUuid, runtime.organizationId, { + participantIds: [runtime.agent.uuid], + }); + await app.db + .update(agents) + .set({ delegateMention: runtime.agent.uuid }) + .where(eq(agents.uuid, runtime.humanAgentUuid)); + + await expect(resolveAgentScmBindingPair(app.db, chatId, runtime.agent.uuid)).resolves.toEqual({ + organizationId: runtime.organizationId, + humanAgentId: runtime.humanAgentUuid, + wakeAgentId: runtime.agent.uuid, + }); + await expect(resolveHumanScmBindingPair(app.db, chatId, runtime.humanAgentUuid)).resolves.toEqual({ + organizationId: runtime.organizationId, + humanAgentId: runtime.humanAgentUuid, + wakeAgentId: runtime.agent.uuid, + }); + }); + + it("fails closed when the human delegate is absent or the agent owner is ambiguous", async () => { + const app = getApp(); + const runtime = await createTestAgent(app, { name: `pair-ambiguous-${randomUUID().slice(0, 8)}` }); + const other = await createTestAgent(app, { name: `pair-other-${randomUUID().slice(0, 8)}` }); + const { chatId } = await createMeChat(app.db, runtime.humanAgentUuid, runtime.organizationId, { + participantIds: [runtime.agent.uuid], + }); + await expect(resolveHumanScmBindingPair(app.db, chatId, runtime.humanAgentUuid)).resolves.toBeNull(); + + await app.db.insert(chatMembership).values({ + chatId, + agentId: other.humanAgentUuid, + role: "member", + accessMode: "speaker", + }); + await expect(resolveAgentScmBindingPair(app.db, chatId, runtime.agent.uuid)).resolves.toBeNull(); + }); +}); + +describe.each(["github", "gitlab"] as const)("%s shared follow-line state machine", () => { + type Line = { id: string; chatId: string }; + + function harness(initial: Line[] = []) { + let lines = [...initial]; + let moveVanishes = false; + let concurrentCreate: Line | null = null; + const storage = { + listLines: async () => [...lines], + removeLines: async (removed: Line[]) => { + const ids = new Set(removed.map((line) => line.id)); + lines = lines.filter((line) => !ids.has(line.id)); + }, + getChatTopic: async (chatId: string) => `topic:${chatId}`, + moveLine: async (line: Line) => { + if (moveVanishes) { + lines = lines.filter((candidate) => candidate.id !== line.id); + if (concurrentCreate) lines.push(concurrentCreate); + return null; + } + const moved = { ...line, chatId: "target" }; + lines = lines.map((candidate) => (candidate.id === line.id ? moved : candidate)); + return moved; + }, + createLine: async () => { + const existing = lines[0]; + if (existing) return { record: existing, inserted: false }; + const created = { id: "created", chatId: "target" }; + lines.push(created); + return { record: created, inserted: true }; + }, + }; + return { + storage, + lines: () => lines, + vanishOnMove(concurrent?: Line) { + moveVanishes = true; + concurrentCreate = concurrent ?? null; + }, + }; + } + + it("shares idempotency, conflict, duplicate cleanup, and rebind outcomes", async () => { + const same = harness([ + { id: "same", chatId: "target" }, + { id: "duplicate", chatId: "other" }, + ]); + await expect( + executeScmFollowLine({ targetChatId: "target", rebind: false, storage: same.storage }), + ).resolves.toEqual({ outcome: "already_following", record: { id: "same", chatId: "target" } }); + expect(same.lines()).toEqual([{ id: "same", chatId: "target" }]); + + const conflict = harness([{ id: "existing", chatId: "other" }]); + await expect( + executeScmFollowLine({ targetChatId: "target", rebind: false, storage: conflict.storage }), + ).resolves.toEqual({ + outcome: "conflict", + conflict: { chatId: "other", topic: "topic:other" }, + }); + + const rebound = harness([{ id: "existing", chatId: "other" }]); + await expect( + executeScmFollowLine({ targetChatId: "target", rebind: true, storage: rebound.storage }), + ).resolves.toEqual({ + outcome: "rebound", + record: { id: "existing", chatId: "target" }, + }); + }); + + it("shares the vanished-row retry and third-writer conflict outcome", async () => { + const retry = harness([{ id: "existing", chatId: "other" }]); + retry.vanishOnMove(); + await expect( + executeScmFollowLine({ targetChatId: "target", rebind: true, storage: retry.storage }), + ).resolves.toEqual({ + outcome: "created", + record: { id: "created", chatId: "target" }, + }); + + const thirdWriter = harness([{ id: "existing", chatId: "other" }]); + thirdWriter.vanishOnMove({ id: "third", chatId: "third-chat" }); + await expect( + executeScmFollowLine({ targetChatId: "target", rebind: true, storage: thirdWriter.storage }), + ).resolves.toEqual({ + outcome: "conflict", + conflict: { chatId: "third-chat", topic: "topic:third-chat" }, + }); + }); +}); diff --git a/packages/server/src/__tests__/scm-entity-chat-topic.test.ts b/packages/server/src/__tests__/scm-entity-chat-topic.test.ts new file mode 100644 index 000000000..82739c926 --- /dev/null +++ b/packages/server/src/__tests__/scm-entity-chat-topic.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { + formatGitlabEntityTopic, + formatScmAutoTopic, + refreshGitlabEntityTopic, + refreshScmAutoTopic, +} from "../services/scm-entity-chat-topic.js"; + +describe("SCM automatic topics", () => { + it("renders the provider-neutral head without changing GitHub semantics", () => { + expect(formatScmAutoTopic("PR repo#7", "Ship it")).toBe("PR repo#7: Ship it"); + expect(formatScmAutoTopic("Issue repo#8", null)).toBe("Issue repo#8"); + expect(refreshScmAutoTopic("manual", "New", [{ matches: "PR repo#7", nextHead: "PR repo#7" }])).toBeNull(); + }); + + it("renders all GitLab automatic topic variants", () => { + const mr = { entityType: "pull_request" as const, entityIid: 17, projectPath: "group/project", title: "Ship" }; + expect(formatGitlabEntityTopic(mr)).toBe("MR project!17: Ship"); + expect(formatGitlabEntityTopic(mr, true)).toBe("MR Review project!17: Ship"); + expect( + formatGitlabEntityTopic({ + entityType: "issue", + entityIid: 18, + projectPath: "group/project", + title: "Bug", + }), + ).toBe("Issue project#18: Bug"); + }); + + it("refreshes title and project path while preserving the review head", () => { + const entity = { + entityType: "pull_request" as const, + entityIid: 17, + projectPath: "renamed/new-project", + title: "New title", + }; + expect(refreshGitlabEntityTopic("MR old-project!17: Old title", entity)).toBe("MR new-project!17: New title"); + expect(refreshGitlabEntityTopic("MR Review old-project!17: Old title", entity)).toBe( + "MR Review new-project!17: New title", + ); + expect(refreshGitlabEntityTopic("My manually renamed chat", entity)).toBeNull(); + expect(refreshGitlabEntityTopic("MR old-project!18: Other entity", entity)).toBeNull(); + }); +}); diff --git a/packages/server/src/__tests__/scm-provider-attention-contract.test.ts b/packages/server/src/__tests__/scm-provider-attention-contract.test.ts new file mode 100644 index 000000000..137e12a02 --- /dev/null +++ b/packages/server/src/__tests__/scm-provider-attention-contract.test.ts @@ -0,0 +1,223 @@ +import type { ScmIngressContext } from "@first-tree/shared"; +import { describe, expect, it } from "vitest"; +import { normalizeGithubWebhook } from "../services/github-normalize.js"; +import { applyGitlabPersonnelEvidence, normalizeGitlabWebhook } from "../services/gitlab-webhook.js"; +import { + planScmChatDeliveries, + type ScmAudienceTarget, + scmWakeAgentIds, + selectScmSenderId, +} from "../services/scm-chat-delivery-plan.js"; + +describe.each(["github", "gitlab"] as const)("%s SCM attention conformance", (provider) => { + it("routes an explicit line to one chat with its human sender and wake agent", async () => { + const target: ScmAudienceTarget = { + entry: { + kind: "existing_line", + line: { + kind: "attention_line", + humanAgentId: "human-1", + wakeAgentId: "delegate-1", + chatId: `${provider}-chat`, + provenance: "explicit", + }, + }, + }; + const planned = await planScmChatDeliveries({ + targets: [target], + actorHumanId: null, + resolveChat: async (candidate) => { + if (candidate.entry.kind !== "existing_line") return null; + return { chatId: candidate.entry.line.chatId, created: false }; + }, + onTargetError: () => { + throw new Error("unexpected target error"); + }, + }); + const delivery = planned.deliveries.get(`${provider}-chat`); + const entries = [...(delivery?.entries.values() ?? [])]; + expect(selectScmSenderId(entries)).toBe("human-1"); + expect(scmWakeAgentIds(entries)).toEqual(["delegate-1"]); + expect(entries[0]?.reasons).toEqual(new Set(["follow"])); + }); +}); + +it("keeps an explicit legacy GitLab route silent without representing a nullable attention line", async () => { + const planned = await planScmChatDeliveries({ + targets: [ + { + entry: { + kind: "legacy_route", + route: { + kind: "legacy_route_only", + chatId: "legacy-chat", + senderAgentId: "legacy-actor", + wakeAgentId: null, + provenance: "legacy_explicit", + }, + }, + }, + ], + actorHumanId: null, + resolveChat: async () => ({ chatId: "legacy-chat", created: false }), + onTargetError: () => { + throw new Error("unexpected target error"); + }, + }); + const entries = [...(planned.deliveries.get("legacy-chat")?.entries.values() ?? [])]; + expect(selectScmSenderId(entries)).toBe("legacy-actor"); + expect(scmWakeAgentIds(entries)).toEqual([]); +}); + +describe("GitHub/GitLab semantic webhook conformance", () => { + const githubIngress: ScmIngressContext = { + provider: "github", + source: { organizationId: "org-1", externalId: "installation:1" }, + stableDeliveryId: null, + ingressAuthority: "verified_signature", + }; + + it("normalizes code updates to the same semantic kind", () => { + const github = normalizeGithubWebhook( + "pull_request", + { + action: "synchronize", + sender: { login: "author", type: "User" }, + repository: { full_name: "acme/api" }, + pull_request: { + number: 7, + title: "Update", + html_url: "https://github.com/acme/api/pull/7", + state: "open", + }, + }, + githubIngress, + ); + const gitlab = normalizeGitlabWebhook({ + organizationId: "org-1", + connectionId: "connection-1", + instanceOrigin: "https://gitlab.internal", + stableDeliveryId: null, + eventHeader: "Merge Request Hook", + body: { + object_kind: "merge_request", + project: { + id: 11, + path_with_namespace: "acme/api", + web_url: "https://gitlab.internal/acme/api", + }, + user: { username: "author" }, + reviewers: [], + object_attributes: { + iid: 7, + action: "update", + oldrev: "abc123", + title: "Update", + url: "https://gitlab.internal/acme/api/-/merge_requests/7", + state: "opened", + }, + }, + }); + expect(github.event?.kind).toBe("synchronized"); + expect(gitlab.event?.kind).toBe(github.event?.kind); + }); + + it("normalizes ready-for-review to one reviewer wake target", () => { + const github = normalizeGithubWebhook( + "pull_request", + { + action: "ready_for_review", + sender: { login: "author", type: "User" }, + repository: { full_name: "acme/api" }, + pull_request: { + number: 8, + title: "Ready", + html_url: "https://github.com/acme/api/pull/8", + state: "open", + requested_reviewers: [{ login: "reviewer" }], + }, + }, + githubIngress, + ); + const gitlabRaw = normalizeGitlabWebhook({ + organizationId: "org-1", + connectionId: "connection-1", + instanceOrigin: "https://gitlab.internal", + stableDeliveryId: null, + eventHeader: "Merge Request Hook", + body: { + object_kind: "merge_request", + project: { + id: 11, + path_with_namespace: "acme/api", + web_url: "https://gitlab.internal/acme/api", + }, + user: { username: "author" }, + reviewers: [{ username: "reviewer" }], + changes: { draft: { previous: true, current: false } }, + object_attributes: { + iid: 8, + action: "update", + draft: false, + title: "Ready", + url: "https://gitlab.internal/acme/api/-/merge_requests/8", + state: "opened", + }, + }, + }); + const gitlab = applyGitlabPersonnelEvidence(gitlabRaw, "reviewers"); + expect(github.event).toMatchObject({ + kind: "review_requested", + targets: [{ externalUsername: "reviewer", reason: "review_requested" }], + }); + expect(gitlab.event).toMatchObject({ + kind: "review_requested", + targets: [{ externalUsername: "reviewer", reason: "review_requested" }], + }); + }); + + it("normalizes terminal pull requests to observation-only merged state", () => { + const github = normalizeGithubWebhook( + "pull_request", + { + action: "closed", + sender: { login: "author", type: "User" }, + repository: { full_name: "acme/api" }, + pull_request: { + number: 9, + title: "Merged", + html_url: "https://github.com/acme/api/pull/9", + state: "closed", + merged: true, + }, + }, + githubIngress, + ); + const gitlab = normalizeGitlabWebhook({ + organizationId: "org-1", + connectionId: "connection-1", + instanceOrigin: "https://gitlab.internal", + stableDeliveryId: null, + eventHeader: "Merge Request Hook", + body: { + object_kind: "merge_request", + project: { + id: 11, + path_with_namespace: "acme/api", + web_url: "https://gitlab.internal/acme/api", + }, + user: { username: "author" }, + reviewers: [], + object_attributes: { + iid: 9, + action: "merge", + title: "Merged", + url: "https://gitlab.internal/acme/api/-/merge_requests/9", + state: "merged", + }, + }, + }); + expect(github).toMatchObject({ observation: { state: "merged" }, event: null }); + expect(gitlab).toMatchObject({ observation: { state: "merged" }, event: null }); + }); +}); diff --git a/packages/server/src/__tests__/scm-related-refs.test.ts b/packages/server/src/__tests__/scm-related-refs.test.ts new file mode 100644 index 000000000..2a235d061 --- /dev/null +++ b/packages/server/src/__tests__/scm-related-refs.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { parseSameProjectClosingIssueRefs } from "../services/scm-related-refs.js"; + +describe("parseSameProjectClosingIssueRefs", () => { + it("parses and deduplicates the shared same-project closing subset", () => { + expect( + parseSameProjectClosingIssueRefs("Closes #12, fixes #12, RESOLVED #14, resolves group/other#9", "acme/api"), + ).toEqual([ + { type: "issue", key: "acme/api#12" }, + { type: "issue", key: "acme/api#14" }, + ]); + }); + + it("ignores references in inline and fenced code", () => { + expect( + parseSameProjectClosingIssueRefs("`fixes #1`\n```\ncloses #2\n```\nResolves #3", "77", (project, issue) => { + return `${project}:issue:${issue}`; + }), + ).toEqual([{ type: "issue", key: "77:issue:3" }]); + }); +}); diff --git a/packages/server/src/__tests__/scm-target-chat-policy.test.ts b/packages/server/src/__tests__/scm-target-chat-policy.test.ts new file mode 100644 index 000000000..88cf37c80 --- /dev/null +++ b/packages/server/src/__tests__/scm-target-chat-policy.test.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { createAgent } from "../services/agent.js"; +import { createChat } from "../services/chat.js"; +import { decideScmPersonnelTargetChat } from "../services/scm-target-chat-policy.js"; +import { createTestAdmin, useTestApp } from "./helpers.js"; + +describe("SCM personnel target chat policy", () => { + const getApp = useTestApp(); + + async function setupChat() { + const app = getApp(); + const human = await createTestAdmin(app, { + username: `target-policy-${randomUUID().slice(0, 8)}`, + }); + const wake = await createAgent(app.db, { + name: `target-agent-${randomUUID().slice(0, 8)}`, + type: "agent", + displayName: "Target Agent", + managerId: human.memberId, + organizationId: human.organizationId, + }); + const chat = await createChat(app.db, human.humanAgentUuid, { + type: "group", + participantIds: [wake.uuid], + }); + return { app, humanAgentId: human.humanAgentUuid, wakeAgentId: wake.uuid, chatId: chat.id }; + } + + it("reuses exactly one reviewer membership chat", async () => { + const setup = await setupChat(); + await expect( + decideScmPersonnelTargetChat(setup.app.db, { + reason: "review_requested", + candidateChatIds: [setup.chatId], + humanAgentId: setup.humanAgentId, + wakeAgentId: setup.wakeAgentId, + }), + ).resolves.toEqual({ kind: "reuse", chatId: setup.chatId }); + }); + + it("fails closed for zero or multiple reviewer candidates", async () => { + const first = await setupChat(); + const second = await createChat(first.app.db, first.humanAgentId, { + type: "group", + participantIds: [first.wakeAgentId], + }); + await expect( + decideScmPersonnelTargetChat(first.app.db, { + reason: "review_requested", + candidateChatIds: [], + humanAgentId: first.humanAgentId, + wakeAgentId: first.wakeAgentId, + }), + ).resolves.toEqual({ kind: "strict_new_line" }); + await expect( + decideScmPersonnelTargetChat(first.app.db, { + reason: "review_requested", + candidateChatIds: [first.chatId, second.id], + humanAgentId: first.humanAgentId, + wakeAgentId: first.wakeAgentId, + }), + ).resolves.toEqual({ kind: "strict_new_line" }); + }); + + it.each(["mentioned", "assigned"] as const)("%s always establishes a strict line", async (reason) => { + const setup = await setupChat(); + await expect( + decideScmPersonnelTargetChat(setup.app.db, { + reason, + candidateChatIds: [setup.chatId], + humanAgentId: setup.humanAgentId, + wakeAgentId: setup.wakeAgentId, + }), + ).resolves.toEqual({ kind: "strict_new_line" }); + }); +}); diff --git a/packages/server/src/__tests__/scm-webhook-processing.test.ts b/packages/server/src/__tests__/scm-webhook-processing.test.ts index 2085fc5a2..c8a699c8d 100644 --- a/packages/server/src/__tests__/scm-webhook-processing.test.ts +++ b/packages/server/src/__tests__/scm-webhook-processing.test.ts @@ -46,7 +46,9 @@ describe("processScmWebhookDelivery", () => { const first = await processScmWebhookDelivery({ db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork, resolveAudience, deliver, @@ -54,7 +56,9 @@ describe("processScmWebhookDelivery", () => { const duplicate = await processScmWebhookDelivery({ db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork, resolveAudience, deliver, @@ -78,7 +82,9 @@ describe("processScmWebhookDelivery", () => { const input = { db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork: async () => null, resolveAudience: async () => ({ targets: ["target-1"], actorHumanId: null }), deliver, @@ -89,6 +95,66 @@ describe("processScmWebhookDelivery", () => { expect(deliver).toHaveBeenCalledTimes(2); }); + it("applies an observation without resolving audience or delivering a card", async () => { + const app = getApp(); + const ingress = makeIngress(null); + const applyObservation = vi.fn(async () => undefined); + const resolveAudience = vi.fn(async () => ({ targets: ["unexpected"], actorHumanId: null })); + const deliver = vi.fn(async () => ({ delivered: 1 })); + + const result = await processScmWebhookDelivery({ + db: app.db, + ingress, + observation: { + entity: { + type: "pull_request", + projectKey: "owner/repo", + key: "owner/repo#2", + title: "Silent lifecycle update", + }, + state: "merged", + observedAt: new Date().toISOString(), + }, + event: null, + applyObservation, + runProviderWork: async () => "provider-result", + resolveAudience, + deliver, + }); + + expect(result).toEqual({ outcome: "provider_only", providerResult: "provider-result" }); + expect(applyObservation).toHaveBeenCalledOnce(); + expect(resolveAudience).not.toHaveBeenCalled(); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("does not suppress semantic delivery when projection refresh fails", async () => { + const app = getApp(); + const ingress = makeIngress(null); + const event = makeEvent(ingress); + const deliver = vi.fn(async () => ({ delivered: 1 })); + + await expect( + processScmWebhookDelivery({ + db: app.db, + ingress, + observation: { + entity: event.entity, + state: "open", + observedAt: new Date().toISOString(), + }, + event, + applyObservation: async () => { + throw new Error("projection unavailable"); + }, + runProviderWork: async () => null, + resolveAudience: async () => ({ targets: ["target-1"], actorHumanId: null }), + deliver, + }), + ).resolves.toMatchObject({ outcome: "delivered" }); + expect(deliver).toHaveBeenCalledOnce(); + }); + it("best-effort unclaims a stable delivery after an uncaught top-level failure", async () => { const app = getApp(); const ingress = makeIngress("delivery-retryable"); @@ -98,7 +164,9 @@ describe("processScmWebhookDelivery", () => { processScmWebhookDelivery({ db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork: async () => null, resolveAudience: async () => { throw new Error("audience unavailable"); @@ -110,7 +178,9 @@ describe("processScmWebhookDelivery", () => { const retried = await processScmWebhookDelivery({ db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork: async () => null, resolveAudience: async () => ({ targets: ["target-1"], actorHumanId: null }), deliver: async () => ({ delivered: 1 }), @@ -125,7 +195,9 @@ describe("processScmWebhookDelivery", () => { const input = { db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork: async () => null, resolveAudience: async () => ({ targets: ["chat-a", "chat-b"], actorHumanId: null }), deliver: async () => ({ delivered: 1, failed: 1 }), @@ -147,7 +219,9 @@ describe("processScmWebhookDelivery", () => { processScmWebhookDelivery({ db: app.db, ingress, + observation: null, event, + applyObservation: async () => undefined, runProviderWork: async () => null, resolveAudience: async () => ({ targets: [], actorHumanId: null }), deliver: async () => ({ delivered: 0 }), diff --git a/packages/server/src/api/agent/chats.ts b/packages/server/src/api/agent/chats.ts index d4b3a3cf4..f7e410a6a 100644 --- a/packages/server/src/api/agent/chats.ts +++ b/packages/server/src/api/agent/chats.ts @@ -28,6 +28,7 @@ import { } from "../../services/gitlab-entity-follow.js"; import { WIRE_RECIPIENT_MODE } from "../../services/message-dispatcher.js"; import { notifyRecipients } from "../../services/notifier.js"; +import { resolveAgentScmBindingPair } from "../../services/scm-attention-line.js"; import { sendFollowResult } from "../github-entity-reply.js"; const log = createLogger("AgentChatsRoute"); @@ -312,13 +313,32 @@ export async function agentChatRoutes(app: FastifyInstance): Promise { const identity = requireAgent(request); await chatService.assertParticipant(app.db, request.params.chatId, identity.uuid); const body = followChatGitlabEntityRequestSchema.parse(request.body); + const pair = await resolveAgentScmBindingPair(app.db, request.params.chatId, identity.uuid); + if (!pair) { + throw new BadRequestError( + "No eligible (human, wake-agent) attention pair: following from an agent session needs an active " + + "non-human caller and at least one active human speaker in the chat.", + ); + } const result = await declareCurrentGitlabEntityFollow(app.db, { - organizationId: identity.organizationId, + organizationId: pair.organizationId, chatId: request.params.chatId, declaredByAgentId: identity.uuid, + humanAgentId: pair.humanAgentId, + delegateAgentId: pair.wakeAgentId, entityUrl: body.entityUrl, + rebind: body.rebind, }); - return reply.status(result.status === "created" ? 201 : 200).send(result); + if (result.outcome === "conflict") { + return reply.status(409).send({ + error: "ENTITY_FOLLOWED_ELSEWHERE", + message: + `This GitLab attention line already lives in chat ${result.conflict.chatId}. ` + + "Work there, or re-issue with rebind to move it into this chat.", + conflict: result.conflict, + }); + } + return reply.status(result.response.status === "already_following" ? 200 : 201).send(result.response); }, ); diff --git a/packages/server/src/api/chats.ts b/packages/server/src/api/chats.ts index 117f1e7ab..1cb28bb31 100644 --- a/packages/server/src/api/chats.ts +++ b/packages/server/src/api/chats.ts @@ -27,9 +27,11 @@ import { getChatAgentStatuses } from "../services/agent-chat-status.js"; import { ensureParticipant, leaveChat, updateChatMetadata } from "../services/chat.js"; import { declareEntityFollow, listChatGithubEntities, removeEntityFollow } from "../services/github-entity-follow.js"; import { - declareGitlabEntityFollow, + declareGitlabEntityFollowWithStatus, listChatGitlabEntities, listVisibleChatGitlabEntities, + projectChatGitlabEntity, + removeCurrentGitlabEntityFollow, removeGitlabEntityFollow, } from "../services/gitlab-entity-follow.js"; import { @@ -50,6 +52,7 @@ import { import { listOpenRequestsForViewer, sendMessage } from "../services/message.js"; import { WIRE_RECIPIENT_MODE } from "../services/message-dispatcher.js"; import { notifyRecipients } from "../services/notifier.js"; +import { resolveHumanScmBindingPair } from "../services/scm-attention-line.js"; import { extractSummary } from "../services/session.js"; import { summarizeChatTokenUsage } from "../services/session-event.js"; import { sendFollowResult } from "./github-entity-reply.js"; @@ -272,36 +275,11 @@ export async function chatRoutes(app: FastifyInstance): Promise { const { chat, scope } = await requireChatAccess(request, app.db); const body = followGithubEntityRequestSchema.parse(request.body); - const [human] = await app.db - .select({ delegateMention: agents.delegateMention }) - .from(agents) - .where(eq(agents.uuid, scope.humanAgentId)) - .limit(1); - if (!human?.delegateMention) { + const pair = await resolveHumanScmBindingPair(app.db, chat.id, scope.humanAgentId); + if (!pair) { throw new BadRequestError( - "Following needs a delegate agent to receive the entity's events, and your account has no " + - "delegate_mention configured. Set one in your member settings, then retry.", - ); - } - - const [delegate] = await app.db - .select({ agentId: chatMembership.agentId }) - .from(chatMembership) - .innerJoin(agents, eq(chatMembership.agentId, agents.uuid)) - .where( - and( - eq(chatMembership.chatId, chat.id), - eq(chatMembership.agentId, human.delegateMention), - eq(chatMembership.accessMode, "speaker"), - eq(agents.status, "active"), - ), - ) - .limit(1); - if (!delegate) { - throw new BadRequestError( - "Your delegate agent is not an active speaker of this chat, so the entity's events could never wake " + - "it here. Invite the delegate into this chat (chat invite) — or follow from the chat where it " + - "already speaks — then retry.", + "Following needs delegate_mention to identify an active delegate speaker in this chat; the configured " + + "delegate is not an active speaker. Invite that delegate into the chat, then retry.", ); } @@ -310,9 +288,9 @@ export async function chatRoutes(app: FastifyInstance): Promise { { appCredentials: app.config.oauth?.githubApp }, { chatId: chat.id, - organizationId: scope.organizationId, - humanAgentId: scope.humanAgentId, - delegateAgentId: human.delegateMention, + organizationId: pair.organizationId, + humanAgentId: pair.humanAgentId, + delegateAgentId: pair.wakeAgentId, boundVia: "human_declared", entity: body.entity, rebind: body.rebind, @@ -355,25 +333,64 @@ export async function chatRoutes(app: FastifyInstance): Promise { const { chat, scope } = await requireChatAccess(request, app.db); await requireDirectHumanChatMembership(chat.id, scope.humanAgentId); const body = followGitlabEntitySchema.parse(request.body); - const entity = await declareGitlabEntityFollow(app.db, { - organizationId: scope.organizationId, + const pair = await resolveHumanScmBindingPair(app.db, chat.id, scope.humanAgentId); + if (!pair) { + throw new BadRequestError( + "Following needs delegate_mention to identify an active delegate speaker in this chat; the configured " + + "delegate is not an active speaker. Invite that delegate into the chat, then retry.", + ); + } + const result = await declareGitlabEntityFollowWithStatus(app.db, { + organizationId: pair.organizationId, connectionId: body.connectionId, chatId: chat.id, declaredByAgentId: scope.humanAgentId, + humanAgentId: pair.humanAgentId, + delegateAgentId: pair.wakeAgentId, boundVia: "human_declared", entityUrl: body.entityUrl, + rebind: body.rebind, + }); + if (result.outcome === "conflict") { + return reply.status(409).send({ + error: "ENTITY_FOLLOWED_ELSEWHERE", + message: + `This GitLab attention line already lives in chat ${result.conflict.chatId}. ` + + "Work there, or re-issue with rebind to move it into this chat.", + conflict: result.conflict, + }); + } + return reply.status(result.outcome === "already_following" ? 200 : 201).send({ + status: result.outcome, + entity: projectChatGitlabEntity(result.row), }); - return reply.status(201).send({ entity }); }, ); - app.delete<{ Params: { chatId: string }; Querystring: { mappingId?: string } }>( + app.delete<{ Params: { chatId: string }; Querystring: { entity?: string; mappingId?: string } }>( "/:chatId/gitlab-entities", async (request) => { const { chat, scope } = await requireChatAccess(request, app.db); await requireDirectHumanChatMembership(chat.id, scope.humanAgentId); - if (!request.query.mappingId) throw new BadRequestError("Pass ?mappingId= to unfollow."); - return { removed: await removeGitlabEntityFollow(app.db, chat.id, request.query.mappingId) }; + if (request.query.entity) { + return removeCurrentGitlabEntityFollow(app.db, { + organizationId: scope.organizationId, + chatId: chat.id, + entityUrl: request.query.entity, + }); + } + if (!request.query.mappingId) { + throw new BadRequestError( + "Pass ?entity= to unfollow. Legacy clients may pass ?mappingId=.", + ); + } + return { + removed: await removeGitlabEntityFollow(app.db, { + organizationId: scope.organizationId, + chatId: chat.id, + mappingId: request.query.mappingId, + }), + }; }, ); diff --git a/packages/server/src/api/webhooks/github-app.ts b/packages/server/src/api/webhooks/github-app.ts index 1d688983e..d10d9bda6 100644 --- a/packages/server/src/api/webhooks/github-app.ts +++ b/packages/server/src/api/webhooks/github-app.ts @@ -14,10 +14,10 @@ import { } from "../../services/github-app-installations.js"; import { resolveGithubAudience } from "../../services/github-audience.js"; import { deliverGithubEvent } from "../../services/github-delivery.js"; -import { type EntityStateSeed, setEntityState } from "../../services/github-entity-state.js"; -import { normalizeGithubEvent } from "../../services/github-normalize.js"; +import { setEntityState, setEntityTitle } from "../../services/github-entity-state.js"; +import { normalizeGithubWebhook } from "../../services/github-normalize.js"; import { processScmWebhookDelivery } from "../../services/scm-webhook-processing.js"; -import { isRecord, readNumber, readString } from "./github-entity.js"; +import { isRecord, readString } from "./github-entity.js"; const log = createLogger("GithubAppWebhook"); @@ -89,28 +89,6 @@ function parseInstallationMetadata(installation: Record): AppIn }; } -function pullRequestStateFromPayload(pr: Record, action: string): EntityStateSeed["state"] { - const state = readString(pr.state); - if (action === "closed" || state === "closed") { - return pr.merged === true ? "merged" : "closed"; - } - return pr.draft === true ? "draft" : "open"; -} - -function issueStateFromPayload(issue: Record, action: string): EntityStateSeed["state"] { - const state = readString(issue.state); - return action === "closed" || state === "closed" ? "closed" : "open"; -} - -function pullRequestStateFromIssuePayload(issue: Record, action: string): EntityStateSeed["state"] { - const state = readString(issue.state); - if (action === "closed" || state === "closed") { - const pr = isRecord(issue.pull_request) ? issue.pull_request : null; - return readString(pr?.merged_at) ? "merged" : "closed"; - } - return issue.draft === true ? "draft" : "open"; -} - function isContextReviewerCandidateEvent(eventType: string, action: string | null): boolean { if (eventType === "pull_request") { return action === "opened" || action === "synchronize" || action === "ready_for_review"; @@ -120,89 +98,6 @@ function isContextReviewerCandidateEvent(eventType: string, action: string | nul return false; } -/** - * Derive the current PR/Issue state from any payload that carries the entity. - * This is used only as an INSERT seed for mappings created by the same - * webhook delivery; it must not update existing rows for non-transition - * events such as late `opened` deliveries. - */ -function resolveEntityStateSeed( - eventType: string, - action: string, - payload: Record, - repoFullName: string, -): EntityStateSeed | null { - if ( - eventType === "pull_request" || - eventType === "pull_request_review" || - eventType === "pull_request_review_comment" - ) { - const pr = isRecord(payload.pull_request) ? payload.pull_request : null; - const number = readNumber(pr?.number); - if (!pr || number === null) return null; - return { - entityType: "pull_request", - entityKey: `${repoFullName}#${number}`, - state: pullRequestStateFromPayload(pr, action), - }; - } - if (eventType === "issues" || eventType === "issue_comment") { - const issue = isRecord(payload.issue) ? payload.issue : null; - const number = readNumber(issue?.number); - if (!issue || number === null) return null; - if (isRecord(issue.pull_request)) { - return { - entityType: "pull_request", - entityKey: `${repoFullName}#${number}`, - state: pullRequestStateFromIssuePayload(issue, action), - }; - } - return { entityType: "issue", entityKey: `${repoFullName}#${number}`, state: issueStateFromPayload(issue, action) }; - } - return null; -} - -/** - * Map lifecycle transitions to persisted `entity_state` updates for rows - * that already exist. Initial `opened` events are excluded on purpose: a - * retry or delayed opened delivery must not overwrite a newer draft/closed/ - * merged state. - */ -function resolveEntityStateUpdate( - eventType: string, - action: string, - payload: Record, - repoFullName: string, -): EntityStateSeed | null { - if (eventType === "pull_request") { - const pr = isRecord(payload.pull_request) ? payload.pull_request : null; - const number = readNumber(pr?.number); - if (!pr || number === null) return null; - if (action === "closed" || action === "reopened") { - return { - entityType: "pull_request", - entityKey: `${repoFullName}#${number}`, - state: pullRequestStateFromPayload(pr, action), - }; - } - if (action === "converted_to_draft") { - return { entityType: "pull_request", entityKey: `${repoFullName}#${number}`, state: "draft" }; - } - if (action === "ready_for_review") { - return { entityType: "pull_request", entityKey: `${repoFullName}#${number}`, state: "open" }; - } - return null; - } - if (eventType === "issues") { - const issue = isRecord(payload.issue) ? payload.issue : null; - const number = readNumber(issue?.number); - if (!issue || number === null) return null; - if (action !== "closed" && action !== "reopened") return null; - return { entityType: "issue", entityKey: `${repoFullName}#${number}`, state: issueStateFromPayload(issue, action) }; - } - return null; -} - async function handleInstallationLifecycle(app: FastifyInstance, eventType: string, payload: unknown): Promise { if (!isRecord(payload)) return "ignored:malformed"; // installation_repositories events carry repo add/remove deltas — we do @@ -359,51 +254,10 @@ export async function githubAppWebhookRoutes(app: FastifyInstance): Promise 0) { - log.info( - { entityKey: stateUpdate.entityKey, state: stateUpdate.state, rows: stats.updated }, - "synced github entity state", - ); - } - } catch (err) { - // Best-effort: state-sync failure must not block normalize/deliver. - log.error( - { err, entityKey: stateUpdate.entityKey, state: stateUpdate.state }, - "failed to sync github entity state", - ); - } - } - } - } - const rawAction = isRecord(payload) ? readString(payload.action) : null; - const event = normalizeGithubEvent(eventType, payload, ingress); + const normalized = normalizeGithubWebhook(eventType, payload, ingress); const shouldRunContextReviewer = isContextReviewerCandidateEvent(eventType, rawAction); - if (!event && !shouldRunContextReviewer) { + if (!normalized.event && !shouldRunContextReviewer && !normalized.observation) { log.debug({ eventType, action: rawAction }, "Stage 1 returned null"); return reply.status(200).send({ ok: true, event: eventType, handled: false }); } @@ -411,7 +265,26 @@ export async function githubAppWebhookRoutes(app: FastifyInstance): Promise { + if (current.state) { + await setEntityState(app.db, { + organizationId, + entityType: current.entity.type, + entityKey: current.entity.key, + state: current.state, + }); + } + if (current.entity.title) { + await setEntityTitle(app.db, { + organizationId, + entityType: current.entity.type, + entityKey: current.entity.key, + title: current.entity.title, + }); + } + }, runProviderWork: () => shouldRunContextReviewer ? handleContextReviewerPrEvent(app, { @@ -423,7 +296,7 @@ export async function githubAppWebhookRoutes(app: FastifyInstance): Promise resolveGithubAudience(app.db, normalizedEvent), deliver: (normalizedEvent, audience) => deliverGithubEvent(app, normalizedEvent, audience.targets, { - entityStateSeed, + entityStateSeed: normalized.entityStateSeed, actorHumanId: audience.actorHumanId, }), }); diff --git a/packages/server/src/api/webhooks/github-entity.ts b/packages/server/src/api/webhooks/github-entity.ts index aea44881d..9c1bccbb7 100644 --- a/packages/server/src/api/webhooks/github-entity.ts +++ b/packages/server/src/api/webhooks/github-entity.ts @@ -1,4 +1,5 @@ import type { GithubEntityType } from "@first-tree/shared"; +import { formatScmAutoTopic, refreshScmAutoTopic } from "../../services/scm-entity-chat-topic.js"; /** * GitHub entity model — the unit of clustering for webhook → chat routing. @@ -222,10 +223,7 @@ function shortEntityKey(key: string): string { export function formatEntityTitle(entity: GithubEntity, eventType: string, action: string): string { const prefix = entityTitlePrefix(entity, eventType, action); const head = `${prefix} ${shortEntityKey(entity.key)}`; - if (entity.title && entity.title.length > 0) { - return `${head}: ${entity.title}`; - } - return head; + return formatScmAutoTopic(head, entity.title); } /** @@ -262,10 +260,14 @@ export function refreshEntityTitle(storedTopic: string, entity: GithubEntity): s ? [`${baseTypePrefix(entity.type)} ${shortKey.replace(/#(\d+)$/, "#discussion-$1")}`] : []; // Longest first so "PR Review repo#7" is matched before "PR repo#7". - const matched = [...heads, ...legacyHeads] - .sort((a, b) => b.length - a.length) - .find((h) => storedTopic === h || storedTopic.startsWith(`${h}: `)); - if (!matched) return null; - const head = legacyHeads.includes(matched) ? (heads[0] ?? matched) : matched; - return `${head}: ${entity.title}`; + return refreshScmAutoTopic( + storedTopic, + entity.title, + [...heads, ...legacyHeads] + .sort((a, b) => b.length - a.length) + .map((head) => ({ + matches: head, + nextHead: legacyHeads.includes(head) ? (heads[0] ?? head) : head, + })), + ); } diff --git a/packages/server/src/api/webhooks/gitlab.ts b/packages/server/src/api/webhooks/gitlab.ts index 2da010632..ae049c89a 100644 --- a/packages/server/src/api/webhooks/gitlab.ts +++ b/packages/server/src/api/webhooks/gitlab.ts @@ -11,6 +11,10 @@ import { resolveGitlabReviewerMode, withGitlabIngressFence, } from "../../services/gitlab-connections.js"; +import { + observeGitlabEntityAndResolveFollowers, + refreshGitlabChatTopics, +} from "../../services/gitlab-entity-follow.js"; import { applyGitlabPersonnelEvidence, deliverGitlabCards, @@ -106,10 +110,21 @@ export async function gitlabWebhookRoutes(app: FastifyInstance): Promise { reviewerField: normalized.personnel.reviewerField, }); const applied = applyGitlabPersonnelEvidence(normalized, reviewerMode); + let observedFollowers: Awaited> = []; const processed = await processScmWebhookDelivery({ db: tx, ingress: normalized.ingress, + observation: normalized.observation, event: applied.event, + applyObservation: async () => { + if (!normalized.entityIdentity) return; + observedFollowers = await observeGitlabEntityAndResolveFollowers( + tx, + fencedConnection.id, + normalized.entityIdentity, + ); + await refreshGitlabChatTopics(tx, fencedConnection.id, normalized.entityIdentity); + }, runProviderWork: async () => { await markGitlabInboundSeen(tx, endpoint.connection.id, endpoint.connection.tokenHash); if (normalized.ingress.stableDeliveryId) { @@ -134,6 +149,7 @@ export async function gitlabWebhookRoutes(app: FastifyInstance): Promise { connectionId: fencedConnection.id, event, entityIdentity: normalized.entityIdentity, + followers: observedFollowers, }); }, deliver: async (event, audience) => { diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index efcd296e1..304193b4d 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -95,6 +95,7 @@ import { invalidateChatAudienceLocal, registerChatAudienceDispatcher } from "./s import { registerChatMessageDispatcher } from "./services/chat-projection.js"; import { createCommandVersionPoller } from "./services/command-version-poller.js"; import { createConfigService } from "./services/config-service.js"; +import { backfillGitlabAttentionPairs } from "./services/gitlab-attention-backfill.js"; import { repairMembershipHumanMirrors } from "./services/membership.js"; import { createNotifier, type Notifier } from "./services/notifier.js"; import { ensureDefaultOrganization } from "./services/organization.js"; @@ -693,6 +694,10 @@ export async function buildApp(config: Config) { await backfillResourcesPhase1(db).catch((err) => { app.log.warn({ err }, "resources phase1 backfill failed"); }); + const gitlabAttentionBackfill = await backfillGitlabAttentionPairs(db); + if (gitlabAttentionBackfill.paired > 0 || gitlabAttentionBackfill.legacyRouteOnly > 0) { + app.log.info(gitlabAttentionBackfill, "gitlab attention pair backfill complete"); + } await notifier.start(); backgroundTasks.start(); pulseAggregator.start(); diff --git a/packages/server/src/db/schema/gitlab-entity-chat-mappings.ts b/packages/server/src/db/schema/gitlab-entity-chat-mappings.ts index 472d78271..e7ea3889b 100644 --- a/packages/server/src/db/schema/gitlab-entity-chat-mappings.ts +++ b/packages/server/src/db/schema/gitlab-entity-chat-mappings.ts @@ -26,6 +26,8 @@ export const gitlabEntityChatMappings = pgTable( identityLinkId: text("identity_link_id").references(() => gitlabIdentityLinks.id, { onDelete: "cascade" }), humanAgentId: text("human_agent_id").references(() => agents.uuid, { onDelete: "cascade" }), delegateAgentId: text("delegate_agent_id").references(() => agents.uuid, { onDelete: "cascade" }), + attentionMode: text("attention_mode").notNull().default("legacy_route_only"), + attentionBackfillVersion: integer("attention_backfill_version").notNull().default(0), active: boolean("active").notNull().default(true), entityType: text("entity_type").notNull(), entityIid: integer("entity_iid").notNull(), @@ -39,12 +41,40 @@ export const gitlabEntityChatMappings = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => [ - uniqueIndex("uq_gitlab_entity_pending_chat") + uniqueIndex("uq_gitlab_entity_pending_pair") + .on( + table.connectionId, + table.humanAgentId, + table.delegateAgentId, + table.projectPathNormalized, + table.entityType, + table.entityIid, + ) + .where( + sql`${table.projectId} IS NULL AND ${table.active} AND ${table.boundVia} <> 'identity_target' AND ${table.humanAgentId} IS NOT NULL AND ${table.delegateAgentId} IS NOT NULL`, + ), + uniqueIndex("uq_gitlab_entity_observed_pair") + .on( + table.connectionId, + table.humanAgentId, + table.delegateAgentId, + table.projectId, + table.entityType, + table.entityIid, + ) + .where( + sql`${table.projectId} IS NOT NULL AND ${table.active} AND ${table.boundVia} <> 'identity_target' AND ${table.humanAgentId} IS NOT NULL AND ${table.delegateAgentId} IS NOT NULL`, + ), + uniqueIndex("uq_gitlab_entity_pending_legacy_chat") .on(table.connectionId, table.chatId, table.projectPathNormalized, table.entityType, table.entityIid) - .where(sql`${table.projectId} IS NULL AND ${table.active} AND ${table.boundVia} <> 'identity_target'`), - uniqueIndex("uq_gitlab_entity_observed_chat") + .where( + sql`${table.projectId} IS NULL AND ${table.active} AND ${table.boundVia} <> 'identity_target' AND ${table.humanAgentId} IS NULL AND ${table.delegateAgentId} IS NULL`, + ), + uniqueIndex("uq_gitlab_entity_observed_legacy_chat") .on(table.connectionId, table.chatId, table.projectId, table.entityType, table.entityIid) - .where(sql`${table.projectId} IS NOT NULL AND ${table.active} AND ${table.boundVia} <> 'identity_target'`), + .where( + sql`${table.projectId} IS NOT NULL AND ${table.active} AND ${table.boundVia} <> 'identity_target' AND ${table.humanAgentId} IS NULL AND ${table.delegateAgentId} IS NULL`, + ), uniqueIndex("uq_gitlab_entity_identity_target") .on(table.connectionId, table.identityLinkId, table.projectId, table.entityType, table.entityIid) .where(sql`${table.projectId} IS NOT NULL AND ${table.active} AND ${table.boundVia} = 'identity_target'`), @@ -67,5 +97,10 @@ export const gitlabEntityChatMappings = pgTable( "ck_gitlab_entity_identity_owner", sql`${table.boundVia} <> 'identity_target' OR (${table.identityLinkId} IS NOT NULL AND ${table.humanAgentId} IS NOT NULL AND ${table.delegateAgentId} IS NOT NULL AND ${table.projectId} IS NOT NULL)`, ), + check( + "ck_gitlab_entity_attention_pair", + sql`(${table.humanAgentId} IS NULL AND ${table.delegateAgentId} IS NULL) OR (${table.humanAgentId} IS NOT NULL AND ${table.delegateAgentId} IS NOT NULL)`, + ), + check("ck_gitlab_entity_attention_mode", sql`${table.attentionMode} IN ('paired', 'legacy_route_only')`), ], ); diff --git a/packages/server/src/services/background-tasks.ts b/packages/server/src/services/background-tasks.ts index 853317ce7..98dec7d5a 100644 --- a/packages/server/src/services/background-tasks.ts +++ b/packages/server/src/services/background-tasks.ts @@ -65,7 +65,7 @@ export function createBackgroundTasks(app: FastifyInstance, instanceId: string): // Chat auto-archive sweeper — cadence comes from runtime config so // ops can tune (or zero-disable) without touching code. See - // services/chat-archive.ts for the GitHub-source archive policy and + // services/chat-archive.ts for the SCM-source archive policy and // idle threshold (default: 1h). const archiveSweepSeconds = app.config.runtime.archiveSweepIntervalSeconds; if (archiveSweepSeconds > 0) { diff --git a/packages/server/src/services/chat-archive.ts b/packages/server/src/services/chat-archive.ts index 928d647d8..35bfa6183 100644 --- a/packages/server/src/services/chat-archive.ts +++ b/packages/server/src/services/chat-archive.ts @@ -5,18 +5,18 @@ * lived in the GitHub App webhook. Run from `background-tasks.ts` on a * configurable interval. * - * Only GitHub-originated chats (`chats.metadata.source = 'github'`) are - * eligible for automatic archive. The sweep has two GitHub-source branches, + * Only SCM-originated chats (`chats.metadata.source IN ('github','gitlab')`) are + * eligible for automatic archive. The sweep has two provider-neutral branches, * both keyed off `chats.last_message_at` as the idleness anchor: * - * Mapped branch — chats with at least one `github_entity_chat_mappings` row. + * Mapped branch — chats with at least one matching provider mapping row. * Archive (for every mapped human) once *all* bound entities are * terminal (`entity_state` in `('closed','merged')`) AND the chat has * been silent for `mappedIdleSeconds` (default 1h). * - * No-mapping branch — GitHub-originated chats with no mapping rows. Archive + * No-mapping branch — SCM-originated chats with no mapping rows. Archive * only acknowledged human views once the chat has been silent for the same - * idle threshold. This is an explicit GitHub orphan/no-binding cleanup, + * idle threshold. This is an explicit SCM orphan/no-binding cleanup, * not the old generic operational-chat idle sweep. * * A chat with any open structured request (`open_request_count > 0`) is never @@ -40,7 +40,7 @@ export const DEFAULT_MAPPED_IDLE_SECONDS = 60 * 60; // 1 hour export const DEFAULT_SWEEP_BATCH_SIZE = 1000; export type SweepChatArchiveOptions = { - /** Idle threshold for GitHub-originated archive branches. Default 1h. */ + /** Idle threshold for SCM-originated archive branches. Default 1h. */ mappedIdleSeconds?: number; /** Max candidate rows per route per tick. Default 1000. */ batchSize?: number; @@ -64,7 +64,7 @@ export async function sweepChatArchive( } /** - * Mapped branch: GitHub-originated chats whose every GitHub-mapped entity is + * Mapped branch: SCM-originated chats whose every provider-mapped entity is * terminal AND have been silent for at least `idleSeconds`. Archives every * (chat, human) pair from the mapping table, minus per-user unread and * chat-level open-request guards. @@ -86,14 +86,26 @@ export async function sweepChatArchive( */ async function sweepMapped(db: Database, idleSeconds: number, batchSize: number): Promise { const rows = await db.execute<{ chat_id: string }>(sql` - WITH archivable_rows AS ( - SELECT DISTINCT m.chat_id, m.human_agent_id + WITH scm_mappings AS ( + SELECT m.chat_id, m.human_agent_id, m.entity_state FROM github_entity_chat_mappings m JOIN chats c ON c.id = m.chat_id + WHERE c.metadata->>'source' = 'github' + UNION ALL + SELECT m.chat_id, m.human_agent_id, m.entity_state + FROM gitlab_entity_chat_mappings m + JOIN chats c ON c.id = m.chat_id + WHERE c.metadata->>'source' = 'gitlab' + AND m.active + AND m.human_agent_id IS NOT NULL + ), + archivable_rows AS ( + SELECT DISTINCT m.chat_id, m.human_agent_id + FROM scm_mappings m + JOIN chats c ON c.id = m.chat_id LEFT JOIN chat_user_state cus ON cus.chat_id = m.chat_id AND cus.agent_id = m.human_agent_id WHERE c.parent_chat_id IS NULL - AND c.metadata->>'source' = 'github' AND c.last_message_at IS NOT NULL AND c.last_message_at < NOW() - make_interval(secs => ${idleSeconds}) AND NOT EXISTS ( @@ -104,7 +116,13 @@ async function sweepMapped(db: Database, idleSeconds: number, batchSize: number) ) AND NOT EXISTS ( SELECT 1 - FROM github_entity_chat_mappings open_m + FROM agent_chat_sessions running + WHERE running.chat_id = c.id + AND running.runtime_state IN ('working', 'blocked') + ) + AND NOT EXISTS ( + SELECT 1 + FROM scm_mappings open_m WHERE open_m.chat_id = m.chat_id AND open_m.entity_state NOT IN ('closed', 'merged') ) @@ -125,7 +143,7 @@ async function sweepMapped(db: Database, idleSeconds: number, batchSize: number) } /** - * No-mapping branch: GitHub-originated chats with no mapping rows that have + * No-mapping branch: SCM-originated chats with no mapping rows that have * been silent past `idleSeconds`, restricted to per-(chat, user) rows that all * of: * @@ -139,7 +157,7 @@ async function sweepMapped(db: Database, idleSeconds: number, batchSize: number) * * Like the mapped branch, any open request in the chat blocks archive for the * entire chat. Unlike the retired generic Route B, this branch does not require - * "no human owner": GitHub-originated chats are normally human-owned. + * "no human owner": SCM-originated chats are normally human-owned. * * The `last_read_at IS NOT NULL` condition implies `cus` is materialised, * so the `COALESCE(cus.engagement_status, 'active') = 'active'` clause @@ -162,12 +180,18 @@ async function sweepUnmapped(db: Database, idleSeconds: number, batchSize: numbe JOIN agents a ON a.uuid = cm.agent_id JOIN chat_user_state cus ON cus.chat_id = cm.chat_id AND cus.agent_id = cm.agent_id - WHERE c.metadata->>'source' = 'github' + WHERE c.metadata->>'source' IN ('github', 'gitlab') AND NOT EXISTS ( SELECT 1 FROM github_entity_chat_mappings m WHERE m.chat_id = c.id ) + AND NOT EXISTS ( + SELECT 1 + FROM gitlab_entity_chat_mappings m + WHERE m.chat_id = c.id + AND m.active + ) AND a.type = 'human' AND c.parent_chat_id IS NULL AND c.last_message_at IS NOT NULL @@ -178,6 +202,12 @@ async function sweepUnmapped(db: Database, idleSeconds: number, batchSize: numbe WHERE req.chat_id = c.id AND req.open_request_count > 0 ) + AND NOT EXISTS ( + SELECT 1 + FROM agent_chat_sessions running + WHERE running.chat_id = c.id + AND running.runtime_state IN ('working', 'blocked') + ) AND cus.last_read_at IS NOT NULL AND cus.unread_mention_count = 0 AND cus.engagement_status = 'active' diff --git a/packages/server/src/services/github-audience.ts b/packages/server/src/services/github-audience.ts index b99dd860a..3bf032be2 100644 --- a/packages/server/src/services/github-audience.ts +++ b/packages/server/src/services/github-audience.ts @@ -81,6 +81,7 @@ export type AudienceTarget = { * reason. */ involveLogin: string | null; + provenance?: "explicit" | "identity_target" | "related_entity"; }; export type AudienceResolution = { @@ -184,6 +185,11 @@ export async function resolveGithubAudience(db: Database, event: NormalizedScmEv chatId: row.chatId, involveReason: null, involveLogin: null, + provenance: isDeclaredBoundVia(row.boundVia) + ? "explicit" + : row.boundVia === "fixes_link" + ? "related_entity" + : "identity_target", })); const subscribedByHuman = new Map(); diff --git a/packages/server/src/services/github-delivery.ts b/packages/server/src/services/github-delivery.ts index f9d909cae..c7b4a8b6b 100644 --- a/packages/server/src/services/github-delivery.ts +++ b/packages/server/src/services/github-delivery.ts @@ -10,6 +10,8 @@ import { compareScmDeliveryEntries, planScmChatDeliveries, type ScmAudienceTarget, + scmTargetHumanAgentId, + scmTargetWakeAgentId, scmWakeAgentIds, selectScmCardContext, selectScmSenderId, @@ -69,15 +71,35 @@ export async function deliverGithubEvent( // Phase 1 — shared SCM planner owns echo pruning and one-delivery-per-chat. const planned = await planScmChatDeliveries({ - targets: audience.map((target) => ({ - senderAgentId: target.humanAgentId, - humanAgentId: target.humanAgentId, - wakeAgentId: target.delegateAgentId, - kind: target.kind, - chatId: target.chatId, - involveReason: target.involveReason, - involveLogin: target.involveLogin, - })), + targets: audience.map( + (target): ScmAudienceTarget => + target.kind === "existing" + ? { + entry: { + kind: "existing_line", + line: { + kind: "attention_line", + humanAgentId: target.humanAgentId, + wakeAgentId: target.delegateAgentId, + chatId: target.chatId as string, + provenance: target.provenance ?? "identity_target", + }, + }, + directedContext: + target.involveReason && target.involveLogin + ? { reason: target.involveReason, externalUsername: target.involveLogin } + : null, + } + : { + entry: { + kind: "personnel_target", + reason: target.involveReason as InvolveReason, + humanAgentId: target.humanAgentId, + wakeAgentId: target.delegateAgentId, + externalUsername: target.involveLogin as string, + }, + }, + ), actorHumanId, resolveChat: (target) => resolveChatFor(app, event, target, options), onTargetError: (target, err) => { @@ -86,8 +108,8 @@ export async function deliverGithubEvent( err, metric: "github_delivery_failed_total", errorClass: err instanceof Error ? err.name : "Unknown", - humanAgent: target.humanAgentId, - delegateAgent: target.wakeAgentId, + humanAgent: scmTargetHumanAgentId(target), + delegateAgent: scmTargetWakeAgentId(target), entityType: event.entity.type, entityKey: event.entity.key, eventType: event.eventType, @@ -99,8 +121,8 @@ export async function deliverGithubEvent( onTargetDropped: (target) => { log.info( { - humanAgent: target.humanAgentId, - delegateAgent: target.wakeAgentId, + humanAgent: scmTargetHumanAgentId(target), + delegateAgent: scmTargetWakeAgentId(target), entityType: event.entity.type, entityKey: event.entity.key, eventType: event.eventType, @@ -242,15 +264,14 @@ async function resolveChatFor( target: ScmAudienceTarget, options: DeliveryOptions, ): Promise { - if (target.kind === "existing") { - if (!target.chatId) { - throw new Error("audience target kind=existing must carry chatId"); - } - return { chatId: target.chatId, created: false }; + if (target.entry.kind === "existing_line") { + return { chatId: target.entry.line.chatId, created: false }; } - if (!target.humanAgentId || !target.wakeAgentId) { - throw new Error("new GitHub audience target must carry human and delegate agents"); + if (target.entry.kind === "legacy_route") { + return { chatId: target.entry.route.chatId, created: false }; } + const humanAgentId = target.entry.humanAgentId; + const wakeAgentId = target.entry.wakeAgentId; const entity: GithubEntity = { type: event.entity.type, key: event.entity.key, @@ -265,13 +286,13 @@ async function resolveChatFor( // `mentioned` / `assigned` involves are deliberately NOT reused: a mention is // a directed call that must mint a fresh chat (S5 — mentions pierce into a // new chat, never back into an existing/unfollowed one). - if (target.involveReason === "review_requested") { + if (target.entry.reason === "review_requested") { const reuseChatId = await findReuseChatForInvolved( app.db, event.source.organizationId, entity, - target.humanAgentId, - target.wakeAgentId, + humanAgentId, + wakeAgentId, ); if (reuseChatId) return { chatId: reuseChatId, created: false }; } @@ -282,8 +303,8 @@ async function resolveChatFor( })); const resolved = await resolveTargetChat(app.db, { organizationId: event.source.organizationId, - humanAgentId: target.humanAgentId, - delegateAgentId: target.wakeAgentId, + humanAgentId, + delegateAgentId: wakeAgentId, entity, relatedEntities, eventType: event.eventType, diff --git a/packages/server/src/services/github-entity-chat.ts b/packages/server/src/services/github-entity-chat.ts index 918f14af7..9472f6cd5 100644 --- a/packages/server/src/services/github-entity-chat.ts +++ b/packages/server/src/services/github-entity-chat.ts @@ -3,8 +3,6 @@ import { and, asc, desc, eq, inArray, sql } from "drizzle-orm"; import type { GithubEntity } from "../api/webhooks/github-entity.js"; import { formatEntityTitle, refreshEntityTitle } from "../api/webhooks/github-entity.js"; import type { Database } from "../db/connection.js"; -import { agents } from "../db/schema/agents.js"; -import { chatMembership } from "../db/schema/chat-membership.js"; import { chats } from "../db/schema/chats.js"; import { githubEntityChatMappings } from "../db/schema/github-entity-chat-mappings.js"; import { createLogger } from "../observability/index.js"; @@ -15,6 +13,8 @@ import { githubEntityKeysEquivalent, } from "./github-entity-key.js"; import type { EntityState, EntityStateSeed } from "./github-entity-state.js"; +import { resolveAgentScmBindingPair } from "./scm-attention-line.js"; +import { decideScmPersonnelTargetChat } from "./scm-target-chat-policy.js"; const log = createLogger("GithubEntityChat"); @@ -99,23 +99,13 @@ export async function findReuseChatForInvolved( ); if (boundChats.length === 0) return null; - const reusable: string[] = []; - for (const { chatId } of boundChats) { - const speakerRows = await db - .select({ agentId: chatMembership.agentId }) - .from(chatMembership) - .where( - and( - eq(chatMembership.chatId, chatId), - eq(chatMembership.accessMode, "speaker"), - inArray(chatMembership.agentId, [humanAgentId, delegateAgentId]), - ), - ); - const ids = new Set(speakerRows.map((r) => r.agentId)); - if (ids.has(humanAgentId) && ids.has(delegateAgentId)) reusable.push(chatId); - if (reusable.length > 1) break; - } - return reusable.length === 1 ? (reusable[0] ?? null) : null; + const decision = await decideScmPersonnelTargetChat(db, { + reason: "review_requested", + candidateChatIds: boundChats.map((row) => row.chatId), + humanAgentId, + wakeAgentId: delegateAgentId, + }); + return decision.kind === "reuse" ? decision.chatId : null; } /** @@ -451,56 +441,12 @@ export async function resolveBindingPair( humanAgentId: string; delegateAgentId: string; } | null> { - const rows = await db - .select({ - chatOrganizationId: chats.organizationId, - agentId: chatMembership.agentId, - agentOrganizationId: agents.organizationId, - agentType: agents.type, - agentStatus: agents.status, - delegateMention: agents.delegateMention, - }) - .from(chatMembership) - .innerJoin(chats, eq(chatMembership.chatId, chats.id)) - .innerJoin(agents, eq(chatMembership.agentId, agents.uuid)) - .where(eq(chatMembership.chatId, chatId)) - .orderBy(asc(chatMembership.agentId)); - - if (rows.length === 0) return null; - - const reporter = rows.find((r) => r.agentId === reporterAgentId); - if (!reporter) return null; - if (reporter.agentType === "human") return null; - - // Defense-in-depth: `createChat` enforces same-org participants, but - // grandfathered cross-org chat_membership rows or admin-override paths - // can sneak through. If the reporter is from org A and the chat is in - // org B, writing the mapping under org B would orphan it from org A's - // installation webhooks (audience scopes by org). Refuse the binding - // rather than write a row that will silently fail to route. See #508. - if (reporter.agentOrganizationId !== reporter.chatOrganizationId) { - log.warn( - { - chatId, - reporterAgentId, - reporterOrg: reporter.agentOrganizationId, - chatOrg: reporter.chatOrganizationId, - }, - "agent_binding skipped: reporter/chat organization mismatch", - ); - return null; - } - - const humans = rows.filter((r) => r.agentType === "human" && r.agentStatus === "active"); - if (humans.length === 0) return null; - - const linkedHuman = humans.find((h) => h.delegateMention === reporterAgentId); - const representative = linkedHuman ?? humans[0]; - if (!representative) return null; + const pair = await resolveAgentScmBindingPair(db, chatId, reporterAgentId); + if (!pair) return null; return { - organizationId: representative.chatOrganizationId, - humanAgentId: representative.agentId, - delegateAgentId: reporterAgentId, + organizationId: pair.organizationId, + humanAgentId: pair.humanAgentId, + delegateAgentId: pair.wakeAgentId, }; } diff --git a/packages/server/src/services/github-entity-follow.ts b/packages/server/src/services/github-entity-follow.ts index 559303538..00843d91c 100644 --- a/packages/server/src/services/github-entity-follow.ts +++ b/packages/server/src/services/github-entity-follow.ts @@ -2,10 +2,12 @@ import type { ChatGithubEntity, ChatGithubEntityListResponse, DeclaredBoundVia, + GithubEntityBoundVia, GithubEntityLiveState, GithubEntityType, } from "@first-tree/shared"; -import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; +import { githubEntityBoundViaSchema } from "@first-tree/shared"; +import { and, asc, desc, eq, inArray, or, sql } from "drizzle-orm"; import type { Database } from "../db/connection.js"; import { chats } from "../db/schema/chats.js"; import { githubEntityChatMappings } from "../db/schema/github-entity-chat-mappings.js"; @@ -19,6 +21,7 @@ import { insertMappingIfAbsent } from "./github-entity-chat.js"; import { githubEntityDedupKey, githubEntityKeyCandidates, legacyDiscussionEntityKey } from "./github-entity-key.js"; import { materializeChatGithubEntity } from "./github-entity-live.js"; import { type EntityState, setEntityTitle } from "./github-entity-state.js"; +import { executeScmFollowLine } from "./scm-attention-line.js"; const log = createLogger("GithubEntityFollow"); @@ -424,120 +427,138 @@ export async function declareEntityFollow( number: entity.number, }; - const result = await insertMappingIfAbsent(db, { - organizationId: params.organizationId, - humanAgentId: params.humanAgentId, - delegateAgentId: params.delegateAgentId, - entity: { type: entity.entityType, key: entity.entityKey, url: entity.htmlUrl, title: entity.title ?? undefined }, - chatId: params.chatId, - boundVia: params.boundVia, - entityState: entity.entityState, - }); + type GithubFollowLine = { chatId: string; boundVia: GithubEntityBoundVia; entityKey: string }; + const entityKeyCandidates = githubEntityKeyCandidates(entity.entityType, entity.entityKey); + const listLines = async (): Promise => { + const rows = await db + .select({ + chatId: githubEntityChatMappings.chatId, + boundVia: githubEntityChatMappings.boundVia, + entityKey: githubEntityChatMappings.entityKey, + }) + .from(githubEntityChatMappings) + .where( + and( + eq(githubEntityChatMappings.organizationId, params.organizationId), + eq(githubEntityChatMappings.humanAgentId, params.humanAgentId), + eq(githubEntityChatMappings.delegateAgentId, params.delegateAgentId), + eq(githubEntityChatMappings.entityType, entity.entityType), + inArray(githubEntityChatMappings.entityKey, entityKeyCandidates), + ), + ) + .orderBy( + desc(sql`${githubEntityChatMappings.entityKey} = ${entity.entityKey}`), + asc(githubEntityChatMappings.boundAt), + ); + return rows.map((row) => { + const parsed = githubEntityBoundViaSchema.safeParse(row.boundVia); + return { + chatId: row.chatId, + boundVia: parsed.success ? parsed.data : "direct", + entityKey: row.entityKey, + }; + }); + }; - // Repair the persisted title across every row for this entity — not just a - // freshly inserted one. `insertMappingIfAbsent` only seeds title on a brand - // new row, but the already-following / rebind / conflict paths reuse a - // pre-existing row whose title may predate the column (0067) or have changed - // upstream. Follow-time resolution already fetched the live title, so refresh - // it now instead of waiting for the next webhook (the bug both reviewers - // flagged). Runs before the rebind UPDATE below so the moved row keeps the - // fresh title; the reinsert-race branch seeds its own row directly. No-op on - // a blank title (never clobbers a good label). + // Refresh an existing row before the shared state machine moves it. A + // vanished-row fallback insert seeds the same title through createLine. if (entity.title && entity.title.length > 0) { await setEntityTitle(db, { organizationId: params.organizationId, entityType: entity.entityType, - // Candidate keys (not just the canonical one) so a legacy discussion row - // stored as `owner/repo#discussion-N` — which rebind moves via the same - // candidate set — also gets its title refreshed. - entityKey: githubEntityKeyCandidates(entity.entityType, entity.entityKey), + entityKey: entityKeyCandidates, title: entity.title, }); } - if (result.inserted) { + const result = await executeScmFollowLine({ + targetChatId: params.chatId, + rebind: params.rebind, + storage: { + listLines, + removeLines: async (rows) => { + const keys = rows.map((row) => row.entityKey); + if (keys.length === 0) return; + await db + .delete(githubEntityChatMappings) + .where( + and( + eq(githubEntityChatMappings.organizationId, params.organizationId), + eq(githubEntityChatMappings.humanAgentId, params.humanAgentId), + eq(githubEntityChatMappings.delegateAgentId, params.delegateAgentId), + eq(githubEntityChatMappings.entityType, entity.entityType), + inArray(githubEntityChatMappings.entityKey, keys), + ), + ); + }, + getChatTopic: async (chatId) => { + const [chat] = await db.select({ topic: chats.topic }).from(chats).where(eq(chats.id, chatId)).limit(1); + return chat?.topic ?? null; + }, + moveLine: async (row) => { + const [moved] = await db + .update(githubEntityChatMappings) + .set({ + chatId: params.chatId, + boundVia: params.boundVia, + entityState: entity.entityState, + boundAt: new Date(), + }) + .where( + and( + eq(githubEntityChatMappings.organizationId, params.organizationId), + eq(githubEntityChatMappings.humanAgentId, params.humanAgentId), + eq(githubEntityChatMappings.delegateAgentId, params.delegateAgentId), + eq(githubEntityChatMappings.entityType, entity.entityType), + eq(githubEntityChatMappings.entityKey, row.entityKey), + ), + ) + .returning({ + chatId: githubEntityChatMappings.chatId, + boundVia: githubEntityChatMappings.boundVia, + entityKey: githubEntityChatMappings.entityKey, + }); + return moved ? { ...moved, boundVia: params.boundVia } : null; + }, + createLine: async () => { + const inserted = await insertMappingIfAbsent(db, { + organizationId: params.organizationId, + humanAgentId: params.humanAgentId, + delegateAgentId: params.delegateAgentId, + entity: { + type: entity.entityType, + key: entity.entityKey, + url: entity.htmlUrl, + title: entity.title ?? undefined, + }, + chatId: params.chatId, + boundVia: params.boundVia, + entityState: entity.entityState, + }); + const lines = await listLines(); + const record = lines.find((line) => line.chatId === inserted.chatId) ?? lines[0]; + if (!record) throw new Error("GitHub follow insert completed without a surviving mapping"); + return { record, inserted: inserted.inserted }; + }, + }, + }); + + if (result.outcome === "conflict") return result; + if (result.outcome === "created") { log.info( { chatId: params.chatId, entityKey: entity.entityKey, boundVia: params.boundVia }, "github follow recorded", ); - return { outcome: "created", entity: wireEntity }; + } else if (result.outcome === "rebound") { + log.info({ toChatId: params.chatId, entityKey: entity.entityKey }, "github follow rebound"); } - - if (result.chatId === params.chatId) { - return { outcome: "already_following", entity: { ...wireEntity, boundVia: result.boundVia } }; - } - - if (params.rebind) { - const entityKeyCandidates = githubEntityKeyCandidates(entity.entityType, entity.entityKey); - // Move the line: the entity's attention home follows the task. Rewrites - // `bound_via` to the declared value so a moved `direct` anchor row can't - // make the new chat impersonate a github-minted chat's anchor (R13), and - // refreshes `boundAt` so the listing dedup's "most recent binding" - // ordering reflects the move. - const moved = await db - .update(githubEntityChatMappings) - .set({ - chatId: params.chatId, - boundVia: params.boundVia, - entityState: entity.entityState, - boundAt: new Date(), - }) - .where( - and( - eq(githubEntityChatMappings.organizationId, params.organizationId), - eq(githubEntityChatMappings.humanAgentId, params.humanAgentId), - eq(githubEntityChatMappings.delegateAgentId, params.delegateAgentId), - eq(githubEntityChatMappings.entityType, entity.entityType), - inArray(githubEntityChatMappings.entityKey, entityKeyCandidates), - ), - ) - .returning({ chatId: githubEntityChatMappings.chatId }); - if (moved.length === 0) { - // The conflicting row vanished between the insert-conflict read and - // the UPDATE (concurrent unfollow). Don't report a ghost "rebound" — - // fall back to a plain insert so the follow is actually recorded. - const reinserted = await insertMappingIfAbsent(db, { - organizationId: params.organizationId, - humanAgentId: params.humanAgentId, - delegateAgentId: params.delegateAgentId, - // Seed the title here too: the prior `setEntityTitle` matched zero rows - // because the existing row vanished in the race, so this fresh row is - // the only one carrying the label. - entity: { - type: entity.entityType, - key: entity.entityKey, - url: entity.htmlUrl, - title: entity.title ?? undefined, - }, - chatId: params.chatId, - boundVia: params.boundVia, - entityState: entity.entityState, - }); - if (!reinserted.inserted && reinserted.chatId !== params.chatId) { - // Lost yet another race to a third writer — surface the conflict. - const [thirdChat] = await db - .select({ topic: chats.topic }) - .from(chats) - .where(eq(chats.id, reinserted.chatId)) - .limit(1); - return { outcome: "conflict", conflict: { chatId: reinserted.chatId, topic: thirdChat?.topic ?? null } }; - } - log.info({ chatId: params.chatId, entityKey: entity.entityKey }, "github follow recorded (rebind fallback)"); - return { outcome: "created", entity: wireEntity }; - } - log.info( - { fromChatId: result.chatId, toChatId: params.chatId, entityKey: entity.entityKey }, - "github follow rebound", - ); - return { outcome: "rebound", entity: wireEntity }; - } - - const [existingChat] = await db - .select({ topic: chats.topic }) - .from(chats) - .where(eq(chats.id, result.chatId)) - .limit(1); - return { outcome: "conflict", conflict: { chatId: result.chatId, topic: existingChat?.topic ?? null } }; + return { + outcome: result.outcome, + entity: { + ...wireEntity, + ...(result.outcome === "already_following" ? { boundVia: result.record.boundVia } : {}), + }, + }; } /** diff --git a/packages/server/src/services/github-normalize.ts b/packages/server/src/services/github-normalize.ts index 3b2ca464c..6cb23b4ab 100644 --- a/packages/server/src/services/github-normalize.ts +++ b/packages/server/src/services/github-normalize.ts @@ -1,5 +1,14 @@ -import type { InvolveReason, NormalizedEventKind, NormalizedScmEvent, ScmIngressContext } from "@first-tree/shared"; -import { extractEventEntity, type GithubEntity, isRecord, parseFixesRefs } from "../api/webhooks/github-entity.js"; +import type { + InvolveReason, + NormalizedEventKind, + NormalizedScmEvent, + ScmEntityObservation, + ScmIngressContext, + ScmNormalizedWebhook, +} from "@first-tree/shared"; +import { extractEventEntity, type GithubEntity, isRecord } from "../api/webhooks/github-entity.js"; +import type { EntityStateSeed } from "./github-entity-state.js"; +import { parseSameProjectClosingIssueRefs } from "./scm-related-refs.js"; const MENTION_REGEX = /(?, action: string): EntityStateSeed["state"] { + const state = readString(pr.state); + if (action === "closed" || state === "closed") { + return pr.merged === true ? "merged" : "closed"; + } + return pr.draft === true ? "draft" : "open"; +} + +function issueStateFromPayload(issue: Record, action: string): EntityStateSeed["state"] { + const state = readString(issue.state); + return action === "closed" || state === "closed" ? "closed" : "open"; +} + +function pullRequestStateFromIssuePayload(issue: Record, action: string): EntityStateSeed["state"] { + const state = readString(issue.state); + if (action === "closed" || state === "closed") { + const pr = isRecord(issue.pull_request) ? issue.pull_request : null; + return readString(pr?.merged_at) ? "merged" : "closed"; + } + return issue.draft === true ? "draft" : "open"; +} + +function resolveEntityStateSeed( + eventType: string, + action: string, + payload: Record, + repoFullName: string, +): EntityStateSeed | null { + if ( + eventType === "pull_request" || + eventType === "pull_request_review" || + eventType === "pull_request_review_comment" + ) { + const pr = isRecord(payload.pull_request) ? payload.pull_request : null; + const number = readNumber(pr?.number); + if (!pr || number === null) return null; + return { + entityType: "pull_request", + entityKey: `${repoFullName}#${number}`, + state: pullRequestStateFromPayload(pr, action), + }; + } + if (eventType === "issues" || eventType === "issue_comment") { + const issue = isRecord(payload.issue) ? payload.issue : null; + const number = readNumber(issue?.number); + if (!issue || number === null) return null; + if (isRecord(issue.pull_request)) { + return { + entityType: "pull_request", + entityKey: `${repoFullName}#${number}`, + state: pullRequestStateFromIssuePayload(issue, action), + }; + } + return { entityType: "issue", entityKey: `${repoFullName}#${number}`, state: issueStateFromPayload(issue, action) }; + } + return null; +} + +function resolveEntityStateUpdate( + eventType: string, + action: string, + payload: Record, + repoFullName: string, +): EntityStateSeed | null { + if (eventType === "pull_request") { + const pr = isRecord(payload.pull_request) ? payload.pull_request : null; + const number = readNumber(pr?.number); + if (!pr || number === null) return null; + if (action === "closed" || action === "reopened") { + return { + entityType: "pull_request", + entityKey: `${repoFullName}#${number}`, + state: pullRequestStateFromPayload(pr, action), + }; + } + if (action === "converted_to_draft") { + return { entityType: "pull_request", entityKey: `${repoFullName}#${number}`, state: "draft" }; + } + if (action === "ready_for_review") { + return { entityType: "pull_request", entityKey: `${repoFullName}#${number}`, state: "open" }; + } + return null; + } + if (eventType === "issues") { + const issue = isRecord(payload.issue) ? payload.issue : null; + const number = readNumber(issue?.number); + if (!issue || number === null || (action !== "closed" && action !== "reopened")) return null; + return { entityType: "issue", entityKey: `${repoFullName}#${number}`, state: issueStateFromPayload(issue, action) }; + } + return null; +} + +export type NormalizedGithubWebhook = ScmNormalizedWebhook & { + entityStateSeed: EntityStateSeed | null; +}; + +/** Pure GitHub adapter output: lifecycle observation and semantic event are independent. */ +export function normalizeGithubWebhook( + eventType: string, + payload: unknown, + ingress: ScmIngressContext, +): NormalizedGithubWebhook { + const event = normalizeGithubEvent(eventType, payload, ingress); + if (!isRecord(payload)) return { ingress, observation: null, event, entityStateSeed: null }; + const repo = isRecord(payload.repository) ? payload.repository : null; + const repoFullName = readString(repo?.full_name); + const action = readString(payload.action); + if (!repoFullName || !action) return { ingress, observation: null, event, entityStateSeed: null }; + + const entityStateSeed = resolveEntityStateSeed(eventType, action, payload, repoFullName); + const stateUpdate = resolveEntityStateUpdate(eventType, action, payload, repoFullName); + const entity = extractEventEntity(eventType, payload); + const observation: ScmEntityObservation | null = entity + ? { + entity: { + type: entity.type, + projectKey: repoFullName, + key: entity.key, + ...(entity.title ? { title: entity.title } : {}), + ...(entity.url ? { url: entity.url } : {}), + }, + state: stateUpdate?.state ?? null, + observedAt: new Date().toISOString(), + } + : null; + return { ingress, observation, event, entityStateSeed }; +} + function readStringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; const out: string[] = []; @@ -194,7 +331,7 @@ function buildPullRequestRule( { logins: mentionLogins, reason: "mentioned" }, ]), surface, - relatedRefs: parseFixesRefs(body, repo).map((ref) => ({ type: "issue", key: ref.key })), + relatedRefs: parseSameProjectClosingIssueRefs(body, repo), }; } case "edited": { diff --git a/packages/server/src/services/gitlab-attention-backfill.ts b/packages/server/src/services/gitlab-attention-backfill.ts new file mode 100644 index 000000000..781dfef51 --- /dev/null +++ b/packages/server/src/services/gitlab-attention-backfill.ts @@ -0,0 +1,212 @@ +import { and, asc, eq, isNotNull, isNull, or } from "drizzle-orm"; +import type { Database } from "../db/connection.js"; +import { agents } from "../db/schema/agents.js"; +import { chatMembership } from "../db/schema/chat-membership.js"; +import { gitlabConnections } from "../db/schema/gitlab-connections.js"; +import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; +import { withGitlabConnectionMaintenanceFence } from "./gitlab-connections.js"; + +const ATTENTION_BACKFILL_VERSION = 1; + +export type GitlabAttentionBackfillResult = { + paired: number; + legacyRouteOnly: number; +}; + +/** + * Classify every historical explicit row exactly once. + * + * The schema default is deliberately `legacy_route_only`/version 0 so an old + * replica that writes after the migration can never accidentally create a + * wake line. Under the same connection-row fence used by follow and ingress, + * this pass upgrades only a uniquely derivable pair and permanently stamps + * every ambiguous row as version 1. + */ +export async function backfillGitlabAttentionPairs(db: Database): Promise { + const result: GitlabAttentionBackfillResult = { paired: 0, legacyRouteOnly: 0 }; + const connections = await db + .select({ id: gitlabConnections.id }) + .from(gitlabConnections) + .orderBy(asc(gitlabConnections.id)); + + for (const connection of connections) { + const connectionResult = await withGitlabConnectionMaintenanceFence(db, connection.id, classifyConnectionRows); + if (!connectionResult) continue; + result.paired += connectionResult.paired; + result.legacyRouteOnly += connectionResult.legacyRouteOnly; + } + return result; +} + +async function classifyConnectionRows( + tx: Database, + connection: typeof gitlabConnections.$inferSelect, +): Promise { + const result: GitlabAttentionBackfillResult = { paired: 0, legacyRouteOnly: 0 }; + + // Rows already carrying a complete pair predate the classification column + // but are not ambiguous. Stamp them without changing ownership. + await tx + .update(gitlabEntityChatMappings) + .set({ + attentionMode: "paired", + attentionBackfillVersion: ATTENTION_BACKFILL_VERSION, + updatedAt: new Date(), + }) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, connection.id), + eq(gitlabEntityChatMappings.attentionBackfillVersion, 0), + isNotNull(gitlabEntityChatMappings.humanAgentId), + isNotNull(gitlabEntityChatMappings.delegateAgentId), + ), + ); + + const legacyRows = await tx + .select() + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, connection.id), + eq(gitlabEntityChatMappings.attentionBackfillVersion, 0), + isNull(gitlabEntityChatMappings.humanAgentId), + isNull(gitlabEntityChatMappings.delegateAgentId), + or( + eq(gitlabEntityChatMappings.boundVia, "agent_declared"), + eq(gitlabEntityChatMappings.boundVia, "human_declared"), + ), + ), + ) + .orderBy( + asc(gitlabEntityChatMappings.projectPathNormalized), + asc(gitlabEntityChatMappings.entityType), + asc(gitlabEntityChatMappings.entityIid), + asc(gitlabEntityChatMappings.chatId), + asc(gitlabEntityChatMappings.id), + ); + + const resolved = await Promise.all( + legacyRows.map(async (row) => ({ + row, + pair: await resolveLegacyPair(tx, row), + })), + ); + const groups = new Map(); + for (const candidate of resolved) { + if (!candidate.pair) continue; + const key = [entityIdentityKey(candidate.row), candidate.pair.humanAgentId, candidate.pair.delegateAgentId].join( + ":", + ); + const group = groups.get(key); + if (group) group.push(candidate); + else groups.set(key, [candidate]); + } + + for (const candidate of resolved) { + const pair = candidate.pair; + const group = pair + ? groups.get([entityIdentityKey(candidate.row), pair.humanAgentId, pair.delegateAgentId].join(":")) + : undefined; + const uniqueAcrossChats = group?.length === 1; + const conflict = pair ? await hasPairConflict(tx, candidate.row, pair) : true; + const canPair = Boolean(pair && uniqueAcrossChats && !conflict); + const [updated] = await tx + .update(gitlabEntityChatMappings) + .set({ + ...(canPair && pair + ? { + humanAgentId: pair.humanAgentId, + delegateAgentId: pair.delegateAgentId, + attentionMode: "paired", + } + : { attentionMode: "legacy_route_only" }), + attentionBackfillVersion: ATTENTION_BACKFILL_VERSION, + updatedAt: new Date(), + }) + .where( + and( + eq(gitlabEntityChatMappings.id, candidate.row.id), + eq(gitlabEntityChatMappings.attentionBackfillVersion, 0), + isNull(gitlabEntityChatMappings.humanAgentId), + isNull(gitlabEntityChatMappings.delegateAgentId), + ), + ) + .returning({ id: gitlabEntityChatMappings.id }); + if (!updated) continue; + if (canPair) result.paired++; + else result.legacyRouteOnly++; + } + return result; +} + +type LegacyRow = typeof gitlabEntityChatMappings.$inferSelect; +type Pair = { humanAgentId: string; delegateAgentId: string }; + +function entityIdentityKey(row: LegacyRow): string { + return row.projectId === null + ? `pending:${row.projectPathNormalized}:${row.entityType}:${row.entityIid}` + : `observed:${row.projectId}:${row.entityType}:${row.entityIid}`; +} + +async function resolveLegacyPair(db: Database, row: LegacyRow): Promise { + const speakers = await db + .select({ + agentId: agents.uuid, + type: agents.type, + status: agents.status, + delegateMention: agents.delegateMention, + }) + .from(chatMembership) + .innerJoin(agents, eq(chatMembership.agentId, agents.uuid)) + .where(and(eq(chatMembership.chatId, row.chatId), eq(chatMembership.accessMode, "speaker"))); + const active = speakers.filter((speaker) => speaker.status === "active"); + + if (row.boundVia === "agent_declared") { + const declarer = active.find((speaker) => speaker.agentId === row.declaredByAgentId); + if (!declarer || declarer.type === "human") return null; + const linkedHumans = active.filter( + (speaker) => speaker.type === "human" && speaker.delegateMention === declarer.agentId, + ); + if (linkedHumans.length === 1 && linkedHumans[0]) { + return { humanAgentId: linkedHumans[0].agentId, delegateAgentId: declarer.agentId }; + } + if (linkedHumans.length > 1) return null; + const humans = active.filter((speaker) => speaker.type === "human"); + return humans.length === 1 && humans[0] + ? { humanAgentId: humans[0].agentId, delegateAgentId: declarer.agentId } + : null; + } + + const declarer = active.find((speaker) => speaker.agentId === row.declaredByAgentId && speaker.type === "human"); + if (!declarer?.delegateMention) return null; + const delegates = active.filter( + (speaker) => speaker.agentId === declarer.delegateMention && speaker.type !== "human", + ); + return delegates.length === 1 && delegates[0] + ? { humanAgentId: declarer.agentId, delegateAgentId: delegates[0].agentId } + : null; +} + +async function hasPairConflict(db: Database, row: LegacyRow, pair: Pair): Promise { + const [existing] = await db + .select({ id: gitlabEntityChatMappings.id }) + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, row.connectionId), + eq(gitlabEntityChatMappings.entityType, row.entityType), + eq(gitlabEntityChatMappings.entityIid, row.entityIid), + row.projectId === null + ? and( + isNull(gitlabEntityChatMappings.projectId), + eq(gitlabEntityChatMappings.projectPathNormalized, row.projectPathNormalized), + ) + : eq(gitlabEntityChatMappings.projectId, row.projectId), + eq(gitlabEntityChatMappings.humanAgentId, pair.humanAgentId), + eq(gitlabEntityChatMappings.delegateAgentId, pair.delegateAgentId), + eq(gitlabEntityChatMappings.active, true), + ), + ) + .limit(1); + return existing !== undefined; +} diff --git a/packages/server/src/services/gitlab-connections.ts b/packages/server/src/services/gitlab-connections.ts index 6f9a3f423..8c04f2179 100644 --- a/packages/server/src/services/gitlab-connections.ts +++ b/packages/server/src/services/gitlab-connections.ts @@ -267,6 +267,27 @@ export async function withGitlabIngressFence( }); } +/** + * Serialize one-time maintenance against both current-connection writes and + * inbound webhook writes. Both live paths lock this same connection row. + */ +export async function withGitlabConnectionMaintenanceFence( + db: Database, + connectionId: string, + callback: (tx: Database, connection: typeof gitlabConnections.$inferSelect) => Promise, +): Promise { + return db.transaction(async (rawTx) => { + const tx = rawTx as unknown as Database; + const [connection] = await tx + .select() + .from(gitlabConnections) + .where(eq(gitlabConnections.id, connectionId)) + .for("update") + .limit(1); + return connection ? callback(tx, connection) : null; + }); +} + export async function markGitlabInboundSeen(db: Database, connectionId: string, tokenHash: string): Promise { const now = new Date(); await db diff --git a/packages/server/src/services/gitlab-entity-follow.ts b/packages/server/src/services/gitlab-entity-follow.ts index ba34b5fc1..283a3608b 100644 --- a/packages/server/src/services/gitlab-entity-follow.ts +++ b/packages/server/src/services/gitlab-entity-follow.ts @@ -2,14 +2,19 @@ import type { ChatGitlabEntity, ChatGitlabEntityListResponse, FollowChatGitlabEntityResponse, + ScmEntityState, UnfollowChatGitlabEntityResponse, } from "@first-tree/shared"; -import { and, asc, eq, isNull, or } from "drizzle-orm"; +import { chatMetadataSchema, normalizeScmEntityState } from "@first-tree/shared"; +import { and, asc, eq, inArray, isNull, or } from "drizzle-orm"; import type { Database } from "../db/connection.js"; +import { chats } from "../db/schema/chats.js"; import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; -import { BadRequestError } from "../errors.js"; +import { BadRequestError, ConflictError } from "../errors.js"; import { uuidv7 } from "../uuid.js"; import { withCurrentGitlabConnectionFence } from "./gitlab-connections.js"; +import { executeScmFollowLine } from "./scm-attention-line.js"; +import { refreshGitlabEntityTopic } from "./scm-entity-chat-topic.js"; export type GitlabEntityIdentity = { entityType: "issue" | "pull_request"; @@ -18,7 +23,7 @@ export type GitlabEntityIdentity = { projectPath: string; entityUrl: string; title: string | null; - entityState: string; + entityState: ScmEntityState | null; }; export function normalizeGitlabProjectPath(path: string): string { @@ -78,7 +83,7 @@ function explicitGitlabDeclarationPredicate() { ); } -function projectChatGitlabEntity(row: GitlabEntityMapping): ChatGitlabEntity { +export function projectChatGitlabEntity(row: GitlabEntityMapping): ChatGitlabEntity { if (row.entityType !== "issue" && row.entityType !== "pull_request") { throw new Error(`Unsupported persisted GitLab entity type: ${row.entityType}`); } @@ -91,24 +96,29 @@ function projectChatGitlabEntity(row: GitlabEntityMapping): ChatGitlabEntity { projectPath: row.projectPath, entityIid: row.entityIid, title: row.title, - state: row.projectId === null ? null : row.entityState, + state: row.projectId === null ? null : normalizeScmEntityState(row.entityState), status: row.projectId === null ? "pending" : "active", boundVia: row.boundVia, }; } -async function declareGitlabEntityFollowWithStatus( +export async function declareGitlabEntityFollowWithStatus( db: Database, input: { organizationId: string; connectionId?: string; chatId: string; declaredByAgentId: string; + humanAgentId: string; + delegateAgentId: string; boundVia?: "agent_declared" | "human_declared"; entityUrl: string; - acceptIdentityTarget?: boolean; + rebind: boolean; }, -): Promise<{ row: GitlabEntityMapping; created: boolean }> { +): Promise< + | { outcome: "created" | "already_following" | "rebound"; row: GitlabEntityMapping } + | { outcome: "conflict"; conflict: { chatId: string; topic: string | null } } +> { return withCurrentGitlabConnectionFence( db, { organizationId: input.organizationId, expectedConnectionId: input.connectionId }, @@ -116,41 +126,122 @@ async function declareGitlabEntityFollowWithStatus( const parsed = parseGitlabEntityUrl(connection.instanceOrigin, input.entityUrl); const boundVia = input.boundVia ?? "agent_declared"; const normalizedPath = normalizeGitlabProjectPath(parsed.projectPath); - const baseMatch = and( + const entityMatch = and( eq(gitlabEntityChatMappings.connectionId, connection.id), - eq(gitlabEntityChatMappings.chatId, input.chatId), eq(gitlabEntityChatMappings.projectPathNormalized, normalizedPath), eq(gitlabEntityChatMappings.entityType, parsed.entityType), eq(gitlabEntityChatMappings.entityIid, parsed.entityIid), eq(gitlabEntityChatMappings.active, true), - ...(input.acceptIdentityTarget ? [] : [explicitGitlabDeclarationPredicate()]), ); - const [existing] = await rawTx.select().from(gitlabEntityChatMappings).where(baseMatch).limit(1); - if (existing) return { row: existing, created: false }; - const [row] = await rawTx - .insert(gitlabEntityChatMappings) - .values({ - id: uuidv7(), - organizationId: input.organizationId, - connectionId: connection.id, - chatId: input.chatId, - declaredByAgentId: input.declaredByAgentId, - boundVia, - entityType: parsed.entityType, - entityIid: parsed.entityIid, - projectId: null, - projectPath: parsed.projectPath, - projectPathNormalized: normalizedPath, - entityUrl: parsed.entityUrl, - title: null, - entityState: "open", - }) - .onConflictDoNothing() - .returning(); - if (row) return { row, created: true }; - const [concurrent] = await rawTx.select().from(gitlabEntityChatMappings).where(baseMatch).limit(1); - if (!concurrent) throw new Error("GitLab follow declaration conflicted without a surviving mapping"); - return { row: concurrent, created: false }; + const pairMatch = and( + entityMatch, + eq(gitlabEntityChatMappings.humanAgentId, input.humanAgentId), + eq(gitlabEntityChatMappings.delegateAgentId, input.delegateAgentId), + ); + const listLines = () => + rawTx + .select() + .from(gitlabEntityChatMappings) + .where(pairMatch) + .orderBy(asc(gitlabEntityChatMappings.createdAt), asc(gitlabEntityChatMappings.id)); + + const result = await executeScmFollowLine({ + targetChatId: input.chatId, + rebind: input.rebind, + storage: { + listLines, + removeLines: async (rows) => { + const ids = rows.map((row) => row.id); + if (ids.length > 0) { + await rawTx.delete(gitlabEntityChatMappings).where(inArray(gitlabEntityChatMappings.id, ids)); + } + }, + getChatTopic: async (chatId) => { + const [chat] = await rawTx.select({ topic: chats.topic }).from(chats).where(eq(chats.id, chatId)).limit(1); + return chat?.topic ?? null; + }, + moveLine: async (row) => { + const [moved] = await rawTx + .update(gitlabEntityChatMappings) + .set({ + chatId: input.chatId, + declaredByAgentId: input.declaredByAgentId, + boundVia, + identityLinkId: null, + attentionMode: "paired", + attentionBackfillVersion: 1, + updatedAt: new Date(), + }) + .where(eq(gitlabEntityChatMappings.id, row.id)) + .returning(); + return moved ?? null; + }, + createLine: async () => { + // A legacy route in the destination chat can be upgraded only + // when the caller supplies the complete pair. + const [legacySameChat] = await rawTx + .select() + .from(gitlabEntityChatMappings) + .where( + and( + entityMatch, + eq(gitlabEntityChatMappings.chatId, input.chatId), + isNull(gitlabEntityChatMappings.humanAgentId), + isNull(gitlabEntityChatMappings.delegateAgentId), + explicitGitlabDeclarationPredicate(), + ), + ) + .orderBy(asc(gitlabEntityChatMappings.createdAt), asc(gitlabEntityChatMappings.id)) + .limit(1); + if (legacySameChat) { + const [upgraded] = await rawTx + .update(gitlabEntityChatMappings) + .set({ + declaredByAgentId: input.declaredByAgentId, + boundVia, + humanAgentId: input.humanAgentId, + delegateAgentId: input.delegateAgentId, + attentionMode: "paired", + attentionBackfillVersion: 1, + updatedAt: new Date(), + }) + .where(eq(gitlabEntityChatMappings.id, legacySameChat.id)) + .returning(); + if (upgraded) return { record: upgraded, inserted: true }; + } + + const [inserted] = await rawTx + .insert(gitlabEntityChatMappings) + .values({ + id: uuidv7(), + organizationId: input.organizationId, + connectionId: connection.id, + chatId: input.chatId, + declaredByAgentId: input.declaredByAgentId, + boundVia, + humanAgentId: input.humanAgentId, + delegateAgentId: input.delegateAgentId, + attentionMode: "paired", + attentionBackfillVersion: 1, + entityType: parsed.entityType, + entityIid: parsed.entityIid, + projectId: null, + projectPath: parsed.projectPath, + projectPathNormalized: normalizedPath, + entityUrl: parsed.entityUrl, + title: null, + entityState: "open", + }) + .onConflictDoNothing() + .returning(); + if (inserted) return { record: inserted, inserted: true }; + const [concurrent] = await listLines(); + if (!concurrent) throw new Error("GitLab follow declaration conflicted without a surviving mapping"); + return { record: concurrent, inserted: false }; + }, + }, + }); + return result.outcome === "conflict" ? result : { outcome: result.outcome, row: result.record }; }, ); } @@ -163,11 +254,18 @@ export async function declareGitlabEntityFollow( connectionId: string; chatId: string; declaredByAgentId: string; + humanAgentId: string; + delegateAgentId: string; boundVia?: "agent_declared" | "human_declared"; entityUrl: string; + rebind?: boolean; }, ): Promise { - return (await declareGitlabEntityFollowWithStatus(db, input)).row; + const result = await declareGitlabEntityFollowWithStatus(db, { ...input, rebind: input.rebind ?? false }); + if (result.outcome === "conflict") { + throw new ConflictError(`GitLab attention line already belongs to chat ${result.conflict.chatId}`); + } + return result.row; } /** Agent/CLI contract: resolve the Team's only current connection and return a stable public DTO. */ @@ -177,22 +275,35 @@ export async function declareCurrentGitlabEntityFollow( organizationId: string; chatId: string; declaredByAgentId: string; + humanAgentId: string; + delegateAgentId: string; entityUrl: string; + rebind: boolean; }, -): Promise { +): Promise< + | { outcome: "success"; response: FollowChatGitlabEntityResponse } + | { outcome: "conflict"; conflict: { chatId: string; topic: string | null } } +> { const result = await declareGitlabEntityFollowWithStatus(db, { ...input, boundVia: "agent_declared", - acceptIdentityTarget: true, }); + if (result.outcome === "conflict") return result; return { - status: result.created ? "created" : "already_following", - entity: projectChatGitlabEntity(result.row), + outcome: "success", + response: { + status: result.outcome, + entity: projectChatGitlabEntity(result.row), + }, }; } -/** Existing human/Web projection retained for response compatibility. */ -export async function listChatGitlabEntities(db: Database, chatId: string) { +/** + * Deprecated v1 Web alias. Preserve the original persisted-row shape, + * including `id`, so existing consumers can still issue mappingId deletes. + * New consumers must use the safe `items` projection. + */ +export async function listChatGitlabEntities(db: Database, chatId: string): Promise { return db .select() .from(gitlabEntityChatMappings) @@ -246,22 +357,55 @@ export async function listCurrentChatGitlabEntities( return listVisibleChatGitlabEntities(db, chatId); } -/** Existing mapping-id human/Web removal contract. */ -export async function removeGitlabEntityFollow(db: Database, chatId: string, mappingId: string): Promise { +type GitlabEntityDeleteTarget = { + connectionId: string; + chatId: string; +} & ( + | { + scope: "entity"; + projectPathNormalized: string; + entityType: GitlabEntityIdentity["entityType"]; + entityIid: number; + } + | { scope: "explicit_mapping"; mappingId: string } +); + +async function deleteGitlabEntityMappingsInChat(db: Database, target: GitlabEntityDeleteTarget): Promise { const removed = await db .delete(gitlabEntityChatMappings) .where( and( - eq(gitlabEntityChatMappings.id, mappingId), - eq(gitlabEntityChatMappings.chatId, chatId), + eq(gitlabEntityChatMappings.connectionId, target.connectionId), + eq(gitlabEntityChatMappings.chatId, target.chatId), eq(gitlabEntityChatMappings.active, true), - explicitGitlabDeclarationPredicate(), + target.scope === "explicit_mapping" + ? and(eq(gitlabEntityChatMappings.id, target.mappingId), explicitGitlabDeclarationPredicate()) + : and( + eq(gitlabEntityChatMappings.projectPathNormalized, target.projectPathNormalized), + eq(gitlabEntityChatMappings.entityType, target.entityType), + eq(gitlabEntityChatMappings.entityIid, target.entityIid), + ), ), ) .returning({ id: gitlabEntityChatMappings.id }); return removed.length; } +/** Legacy mapping-id wire adapter delegated to the canonical fenced deletion primitive. */ +export async function removeGitlabEntityFollow( + db: Database, + input: { organizationId: string; chatId: string; mappingId: string }, +): Promise { + return withCurrentGitlabConnectionFence(db, { organizationId: input.organizationId }, async (tx, connection) => { + return deleteGitlabEntityMappingsInChat(tx, { + scope: "explicit_mapping", + connectionId: connection.id, + chatId: input.chatId, + mappingId: input.mappingId, + }); + }); +} + /** Agent/CLI URL contract: remove every active binding for this entity in this chat. */ export async function removeCurrentGitlabEntityFollow( db: Database, @@ -269,20 +413,15 @@ export async function removeCurrentGitlabEntityFollow( ): Promise { return withCurrentGitlabConnectionFence(db, { organizationId: input.organizationId }, async (tx, connection) => { const parsed = parseGitlabEntityUrl(connection.instanceOrigin, input.entityUrl); - const removed = await tx - .delete(gitlabEntityChatMappings) - .where( - and( - eq(gitlabEntityChatMappings.connectionId, connection.id), - eq(gitlabEntityChatMappings.chatId, input.chatId), - eq(gitlabEntityChatMappings.projectPathNormalized, normalizeGitlabProjectPath(parsed.projectPath)), - eq(gitlabEntityChatMappings.entityType, parsed.entityType), - eq(gitlabEntityChatMappings.entityIid, parsed.entityIid), - eq(gitlabEntityChatMappings.active, true), - ), - ) - .returning({ id: gitlabEntityChatMappings.id }); - return { removed: removed.length }; + const removed = await deleteGitlabEntityMappingsInChat(tx, { + scope: "entity", + connectionId: connection.id, + chatId: input.chatId, + projectPathNormalized: normalizeGitlabProjectPath(parsed.projectPath), + entityType: parsed.entityType, + entityIid: parsed.entityIid, + }); + return { removed }; }); } @@ -313,13 +452,19 @@ export async function observeGitlabEntityAndResolveFollowers( ), ); const resolved: Array<(typeof candidates)[number]> = []; - const byChat = new Map(); + const byAttentionLine = new Map(); for (const row of candidates) { - const rows = byChat.get(row.chatId); + const ownerKey = + row.boundVia === "identity_target" + ? `identity:${row.identityLinkId ?? row.id}` + : row.humanAgentId && row.delegateAgentId + ? `pair:${row.humanAgentId}:${row.delegateAgentId}` + : `legacy:${row.chatId}`; + const rows = byAttentionLine.get(ownerKey); if (rows) rows.push(row); - else byChat.set(row.chatId, [row]); + else byAttentionLine.set(ownerKey, [row]); } - for (const rows of byChat.values()) { + for (const rows of byAttentionLine.values()) { const explicitRows = rows.filter((row) => isExplicitGitlabDeclaration(row.boundVia)); if (explicitRows.length > 0) { const observed = explicitRows.find((row) => row.projectId === entity.projectId); @@ -338,7 +483,7 @@ export async function observeGitlabEntityAndResolveFollowers( projectPathNormalized: normalizedPath, entityUrl: entity.entityUrl, title: entity.title, - entityState: entity.entityState, + ...(entity.entityState ? { entityState: entity.entityState } : {}), updatedAt: new Date(), }) .where(eq(gitlabEntityChatMappings.id, winner.id)) @@ -354,7 +499,7 @@ export async function observeGitlabEntityAndResolveFollowers( projectPathNormalized: normalizedPath, entityUrl: entity.entityUrl, title: entity.title, - entityState: entity.entityState, + ...(entity.entityState ? { entityState: entity.entityState } : {}), updatedAt: new Date(), }) .where(eq(gitlabEntityChatMappings.id, identityRow.id)) @@ -365,3 +510,45 @@ export async function observeGitlabEntityAndResolveFollowers( return resolved; }); } + +/** Refresh only GitLab-owned anchor chats whose topic still matches the automatic grammar. */ +export async function refreshGitlabChatTopics( + db: Database, + connectionId: string, + entity: GitlabEntityIdentity, +): Promise { + if (!entity.title) return; + const rows = await db + .select({ + chatId: gitlabEntityChatMappings.chatId, + topic: chats.topic, + metadata: chats.metadata, + }) + .from(gitlabEntityChatMappings) + .innerJoin(chats, eq(chats.id, gitlabEntityChatMappings.chatId)) + .where( + and( + eq(gitlabEntityChatMappings.connectionId, connectionId), + eq(gitlabEntityChatMappings.projectId, entity.projectId), + eq(gitlabEntityChatMappings.entityType, entity.entityType), + eq(gitlabEntityChatMappings.entityIid, entity.entityIid), + eq(gitlabEntityChatMappings.active, true), + ), + ); + const seen = new Set(); + for (const row of rows) { + if (seen.has(row.chatId) || !row.topic) continue; + seen.add(row.chatId); + const metadata = chatMetadataSchema.safeParse(row.metadata); + if ( + !metadata.success || + metadata.data.source !== "gitlab" || + metadata.data.entityKey !== `${entity.projectId}:${entity.entityType}:${entity.entityIid}` + ) { + continue; + } + const nextTopic = refreshGitlabEntityTopic(row.topic, entity); + if (!nextTopic || nextTopic === row.topic) continue; + await db.update(chats).set({ topic: nextTopic, updatedAt: new Date() }).where(eq(chats.id, row.chatId)); + } +} diff --git a/packages/server/src/services/gitlab-webhook.ts b/packages/server/src/services/gitlab-webhook.ts index 1b28a64a5..7cc3fe9db 100644 --- a/packages/server/src/services/gitlab-webhook.ts +++ b/packages/server/src/services/gitlab-webhook.ts @@ -4,17 +4,16 @@ import type { GitlabTargetClass, InvolveReason, NormalizedScmEvent, + ScmEntityObservation, ScmIngressContext, + ScmNormalizedWebhook, } from "@first-tree/shared"; import { chatMetadataSchema } from "@first-tree/shared"; import { and, eq, inArray } from "drizzle-orm"; import type { FastifyInstance } from "fastify"; import type { Database } from "../db/connection.js"; -import { agents } from "../db/schema/agents.js"; -import { chatMembership } from "../db/schema/chat-membership.js"; import { gitlabEntityChatMappings } from "../db/schema/gitlab-entity-chat-mappings.js"; import { gitlabIdentityLinks } from "../db/schema/gitlab-identity-links.js"; -import { members } from "../db/schema/members.js"; import { BadRequestError } from "../errors.js"; import { createLogger } from "../observability/index.js"; import { uuidv7 } from "../uuid.js"; @@ -35,10 +34,15 @@ import { compareScmDeliveryEntries, planScmChatDeliveries, type ScmAudienceTarget, + scmTargetHumanAgentId, + scmTargetWakeAgentId, scmWakeAgentIds, selectScmCardContext, selectScmSenderId, } from "./scm-chat-delivery-plan.js"; +import { formatGitlabEntityTopic } from "./scm-entity-chat-topic.js"; +import { parseSameProjectClosingIssueRefs } from "./scm-related-refs.js"; +import { decideScmPersonnelTargetChat } from "./scm-target-chat-policy.js"; const log = createLogger("GitlabWebhook"); @@ -52,9 +56,7 @@ export class GitlabPersonnelTargetLimitError extends BadRequestError { } } -export type NormalizedGitlabWebhook = { - ingress: ScmIngressContext; - event: NormalizedScmEvent | null; +export type NormalizedGitlabWebhook = ScmNormalizedWebhook & { entityIdentity: GitlabEntityIdentity | null; personnel: GitlabPersonnelEvidence; }; @@ -137,21 +139,21 @@ function gitlabUrl(value: unknown, instanceOrigin: string, label: string): strin return url.toString(); } -function actionKind(action: string | null, fallback: NormalizedScmEvent["kind"]): NormalizedScmEvent["kind"] { - switch (action) { - case "open": - return "opened"; - case "close": - return "closed"; - case "reopen": - return "reopened"; - case "merge": - return "merged"; - case "update": - return "edited"; - default: - return fallback; +function currentGitlabDraft(attrs: JsonObject): boolean { + return typeof attrs.draft === "boolean" + ? attrs.draft + : typeof attrs.work_in_progress === "boolean" + ? attrs.work_in_progress + : false; +} + +function draftBecameReady(changes: JsonObject | null): boolean { + for (const key of ["draft", "work_in_progress"]) { + if (!changes || !(key in changes)) continue; + const change = object(changes[key], `changes.${key}`); + if (change.previous === true && change.current === false) return true; } + return false; } function userUsername(value: unknown, label: string): string { @@ -312,6 +314,7 @@ export function normalizeGitlabWebhook(input: { if (!expected || expected === "test") { return { ingress, + observation: null, event: null, entityIdentity: null, personnel: { @@ -333,7 +336,7 @@ export function normalizeGitlabWebhook(input: { let attrs: JsonObject; let entityType: "issue" | "pull_request"; let eventType: string; - let kind: NormalizedScmEvent["kind"]; + let kind: NormalizedScmEvent["kind"] | null; let personnel: GitlabPersonnelEvidence = { reviewerField: "not_applicable", reviewerAdded: [], @@ -354,7 +357,30 @@ export function normalizeGitlabWebhook(input: { assigneeAdded: assigneeUsernames(payload, attrs, action), mentions: [], }; - kind = actionKind(action, "other"); + const changes = payload.changes ? object(payload.changes, "changes") : null; + const descriptionChanged = changes !== null && "description" in changes; + const titleChanged = changes !== null && "title" in changes; + const currentDescription = optionalString(attrs.description) ?? ""; + const becameReady = draftBecameReady(changes); + if (becameReady && "reviewers" in payload) { + const currentReviewers = optionalUserArray(payload.reviewers, "reviewers"); + personnel = { ...personnel, reviewerField: "valid", reviewerAdded: currentReviewers, anomalyCode: null }; + } + personnel.mentions = action === "open" || descriptionChanged ? explicitMentions(currentDescription) : []; + kind = + action === "open" + ? "opened" + : action === "update" + ? optionalString(attrs.oldrev) + ? "synchronized" + : descriptionChanged || titleChanged + ? "edited" + : becameReady || personnel.reviewerAdded.length > 0 + ? "review_requested" + : personnel.assigneeAdded.length > 0 + ? "assigned" + : null + : null; } else if (expected === "issue") { attrs = object(payload.object_attributes, "object_attributes"); entityType = "issue"; @@ -367,13 +393,32 @@ export function normalizeGitlabWebhook(input: { mentions: [], anomalyCode: null, }; - kind = actionKind(action, "other"); + const changes = payload.changes ? object(payload.changes, "changes") : null; + const descriptionChanged = changes !== null && "description" in changes; + const titleChanged = changes !== null && "title" in changes; + personnel.mentions = + action === "open" || descriptionChanged ? explicitMentions(optionalString(attrs.description) ?? "") : []; + kind = + action === "open" + ? "opened" + : action === "close" + ? "closed" + : action === "reopen" + ? "reopened" + : action === "update" + ? descriptionChanged || titleChanged + ? "edited" + : personnel.assigneeAdded.length > 0 + ? "assigned" + : null + : null; } else { attrs = object(payload.object_attributes, "object_attributes"); const noteableType = requiredString(attrs.noteable_type, "object_attributes.noteable_type"); if (noteableType !== "MergeRequest" && noteableType !== "Issue") { return { ingress, + observation: null, event: null, entityIdentity: null, personnel: { @@ -411,11 +456,15 @@ export function normalizeGitlabWebhook(input: { const url = gitlabUrl(optionalString(attrs.url) ?? fallbackUrl, input.instanceOrigin, "entity url"); const rawState = optionalString(attrs.state, 100); const state = - kind === "merged" || rawState === "merged" - ? "merged" - : kind === "closed" || rawState === "closed" - ? "closed" - : "open"; + eventType === "note" + ? null + : action === "merge" || rawState === "merged" + ? "merged" + : action === "close" || rawState === "closed" + ? "closed" + : entityType === "pull_request" && currentGitlabDraft(attrs) + ? "draft" + : "open"; const entityIdentity: GitlabEntityIdentity = { entityType, entityIid: iid, @@ -425,10 +474,7 @@ export function normalizeGitlabWebhook(input: { title: title || null, entityState: state, }; - const event: NormalizedScmEvent = { - ...ingress, - eventType, - action, + const observation: ScmEntityObservation = { entity: { type: entityType, projectKey: String(projectId), @@ -436,13 +482,37 @@ export function normalizeGitlabWebhook(input: { ...(title ? { title } : {}), url, }, - actor: { externalUsername: username, isBot: false }, - kind, - targets: [], - surface: { title, body: description, url }, - relatedRefs: [], + state, + observedAt: new Date().toISOString(), }; - return { ingress, event, entityIdentity, personnel }; + const descriptionChanged = + payload.changes !== null && + payload.changes !== undefined && + typeof payload.changes === "object" && + !Array.isArray(payload.changes) && + "description" in payload.changes; + const event: NormalizedScmEvent | null = + kind === null + ? null + : { + ...ingress, + eventType, + action, + entity: observation.entity, + actor: { externalUsername: username, isBot: false }, + kind, + targets: [], + surface: { title, body: description, url }, + relatedRefs: + entityType === "pull_request" && (action === "open" || descriptionChanged) + ? parseSameProjectClosingIssueRefs( + description, + String(projectId), + (project, issueNumber) => `${project}:issue:${issueNumber}`, + ) + : [], + }; + return { ingress, observation, event, entityIdentity, personnel }; } export function applyGitlabPersonnelEvidence( @@ -474,6 +544,7 @@ export function applyGitlabPersonnelEvidence( for (const username of normalized.personnel.mentions) add(username, "mention"); } else if (normalized.event.eventType === "issue") { for (const username of normalized.personnel.assigneeAdded) add(username, "assignee"); + for (const username of normalized.personnel.mentions) add(username, "mention"); } else if (normalized.event.eventType === "merge_request") { const reviewerEvidenceIsUsable = normalized.personnel.reviewerField === "valid" && !normalized.personnel.anomalyCode; @@ -487,6 +558,7 @@ export function applyGitlabPersonnelEvidence( } else { for (const username of normalized.personnel.assigneeAdded) add(username, "assignee"); } + for (const username of normalized.personnel.mentions) add(username, "mention"); } const targets = candidates.map((candidate) => ({ @@ -538,6 +610,7 @@ export async function resolveGitlabAudience( connectionId: string; event: NormalizedScmEvent; entityIdentity: GitlabEntityIdentity; + followers?: Awaited>; }, ): Promise { const actorNormalizedUsername = normalizeGitlabUsername(input.event.actor.externalUsername).normalized; @@ -570,26 +643,8 @@ export async function resolveGitlabAudience( lockForUpdate: true, }); actorHumanId = actor.outcome === "ok" ? actor.identity.humanAgentId : null; - const rows = await observeGitlabEntityAndResolveFollowers(db, input.connectionId, input.entityIdentity); - const explicitDeclaredByIds = [ - ...new Set(rows.filter((row) => row.boundVia !== "identity_target").map((row) => row.declaredByAgentId)), - ]; - const explicitOwnerRows = - explicitDeclaredByIds.length === 0 - ? [] - : await db - .select({ declaredByAgentId: agents.uuid, humanAgentId: members.agentId }) - .from(agents) - .innerJoin( - members, - and( - eq(agents.managerId, members.id), - eq(members.organizationId, input.organizationId), - eq(members.status, "active"), - ), - ) - .where(and(eq(agents.organizationId, input.organizationId), inArray(agents.uuid, explicitDeclaredByIds))); - const explicitOwnerHumanByAgent = new Map(explicitOwnerRows.map((row) => [row.declaredByAgentId, row.humanAgentId])); + const rows = + input.followers ?? (await observeGitlabEntityAndResolveFollowers(db, input.connectionId, input.entityIdentity)); const targets: ScmAudienceTarget[] = []; for (const row of rows) { if (row.boundVia === "identity_target") { @@ -621,24 +676,47 @@ export async function resolveGitlabAudience( continue; } targets.push({ - senderAgentId: row.humanAgentId, - humanAgentId: row.humanAgentId, - wakeAgentId: row.delegateAgentId, - kind: "existing", - chatId: row.chatId, - involveReason: null, - involveLogin: null, + entry: { + kind: "existing_line", + line: { + kind: "attention_line", + humanAgentId: row.humanAgentId, + wakeAgentId: row.delegateAgentId, + chatId: row.chatId, + provenance: "identity_target", + }, + }, }); } else { - targets.push({ - senderAgentId: row.declaredByAgentId, - humanAgentId: explicitOwnerHumanByAgent.get(row.declaredByAgentId) ?? null, - wakeAgentId: null, - kind: "existing", - chatId: row.chatId, - involveReason: null, - involveLogin: null, - }); + const humanAgentId = row.humanAgentId; + const wakeAgentId = row.delegateAgentId; + if (humanAgentId !== null && wakeAgentId !== null) { + targets.push({ + entry: { + kind: "existing_line", + line: { + kind: "attention_line", + humanAgentId, + wakeAgentId, + chatId: row.chatId, + provenance: "explicit", + }, + }, + }); + } else { + targets.push({ + entry: { + kind: "legacy_route", + route: { + kind: "legacy_route_only", + chatId: row.chatId, + senderAgentId: row.declaredByAgentId, + wakeAgentId: null, + provenance: "legacy_explicit", + }, + }, + }); + } } } @@ -663,29 +741,28 @@ export async function resolveGitlabAudience( } const existingIndex = targets.findIndex( (candidate) => - candidate.humanAgentId === resolved.identity.humanAgentId && - candidate.wakeAgentId === resolved.identity.delegateAgentId && - candidate.kind === "existing", + candidate.entry.kind === "existing_line" && + candidate.entry.line.humanAgentId === resolved.identity.humanAgentId && + candidate.entry.line.wakeAgentId === resolved.identity.delegateAgentId, ); if (existingIndex >= 0) { const existing = targets[existingIndex]; if (existing) { targets.push({ - ...existing, - involveReason: target.reason, - involveLogin: normalizedUsername, + entry: existing.entry, + directedContext: { reason: target.reason, externalUsername: normalizedUsername }, }); } continue; } targets.push({ - senderAgentId: resolved.identity.humanAgentId, - humanAgentId: resolved.identity.humanAgentId, - wakeAgentId: resolved.identity.delegateAgentId, - kind: "new", - chatId: null, - involveReason: target.reason, - involveLogin: normalizedUsername, + entry: { + kind: "personnel_target", + reason: target.reason, + humanAgentId: resolved.identity.humanAgentId, + wakeAgentId: resolved.identity.delegateAgentId, + externalUsername: normalizedUsername, + }, }); } @@ -702,17 +779,19 @@ async function resolveGitlabTargetChat( target: ScmAudienceTarget; }, ): Promise<{ chatId: string; created: boolean } | null> { - if (input.target.kind === "existing") { - if (!input.target.chatId) throw new Error("existing GitLab audience target must carry chatId"); - return { chatId: input.target.chatId, created: false }; + if (input.target.entry.kind === "existing_line") { + return { chatId: input.target.entry.line.chatId, created: false }; } - if (!input.target.humanAgentId || !input.target.wakeAgentId || !input.target.involveLogin) { - throw new Error("new GitLab audience target requires human, delegate, and identity username"); + if (input.target.entry.kind === "legacy_route") { + return { chatId: input.target.entry.route.chatId, created: false }; } + const humanAgentId = input.target.entry.humanAgentId; + const wakeAgentId = input.target.entry.wakeAgentId; + const involveLogin = input.target.entry.externalUsername; const resolvedIdentity = await resolveActiveGitlabIdentity(db, { organizationId: input.organizationId, connectionId: input.connectionId, - normalizedUsername: input.target.involveLogin, + normalizedUsername: involveLogin, lockForUpdate: true, }); if (resolvedIdentity.outcome !== "ok") return null; @@ -732,8 +811,8 @@ async function resolveGitlabTargetChat( (row) => row.active && row.identityLinkId === resolvedIdentity.identity.linkId && - row.humanAgentId === input.target.humanAgentId && - row.delegateAgentId === input.target.wakeAgentId, + row.humanAgentId === humanAgentId && + row.delegateAgentId === wakeAgentId, ); if (activeOwn) return { chatId: activeOwn.chatId, created: false }; @@ -746,59 +825,40 @@ async function resolveGitlabTargetChat( .set({ active: false, updatedAt: new Date() }) .where(inArray(gitlabEntityChatMappings.id, staleActiveOwnIds)); } - const inactiveOwn = existingRows.find( - (row) => - !row.active && - row.identityLinkId === resolvedIdentity.identity.linkId && - row.humanAgentId === input.target.humanAgentId && - row.delegateAgentId === input.target.wakeAgentId, - ); - if (inactiveOwn) { - const [reactivated] = await db - .update(gitlabEntityChatMappings) - .set({ active: true, updatedAt: new Date() }) - .where(eq(gitlabEntityChatMappings.id, inactiveOwn.id)) - .returning({ chatId: gitlabEntityChatMappings.chatId }); - if (reactivated) return { chatId: reactivated.chatId, created: false }; - } - const activeChatIds = [...new Set(existingRows.filter((row) => row.active).map((row) => row.chatId))]; - const reusable: string[] = []; - for (const chatId of activeChatIds) { - const participantRows = await db - .select({ agentId: chatMembership.agentId }) - .from(chatMembership) - .where( - and( - eq(chatMembership.chatId, chatId), - eq(chatMembership.accessMode, "speaker"), - inArray(chatMembership.agentId, [input.target.humanAgentId, input.target.wakeAgentId]), - ), - ); - const participantIds = new Set(participantRows.map((row) => row.agentId)); - if (participantIds.has(input.target.humanAgentId) && participantIds.has(input.target.wakeAgentId)) { - reusable.push(chatId); - } + const targetDecision = await decideScmPersonnelTargetChat(db, { + reason: input.target.entry.reason, + candidateChatIds: activeChatIds, + humanAgentId, + wakeAgentId, + }); + if (targetDecision.kind === "reuse") { + return { chatId: targetDecision.chatId, created: false }; } let chatId: string; let created = false; - if (reusable.length === 1) { - chatId = reusable[0] ?? ""; + const relatedChatId = await findGitlabRelatedEntityChat(db, { + organizationId: input.organizationId, + connectionId: input.connectionId, + relatedRefs: input.event.relatedRefs, + humanAgentId, + wakeAgentId, + }); + if (relatedChatId) { + chatId = relatedChatId; } else { const metadata = chatMetadataSchema.parse({ source: "gitlab", entityType: input.entity.entityType, entityKey: input.event.entity.key, entityUrl: input.entity.entityUrl, - ...(input.target.involveReason === "review_requested" ? { reviewRequestRouted: true } : {}), + ...(input.target.entry.reason === "review_requested" ? { reviewRequestRouted: true } : {}), }); - const prefix = input.entity.entityType === "pull_request" ? "MR" : "Issue"; - const sigil = input.entity.entityType === "pull_request" ? "!" : "#"; - const createdChat = await createChat(db, input.target.humanAgentId, { + const createdChat = await createChat(db, humanAgentId, { type: "group", - participantIds: [input.target.wakeAgentId], - topic: `${prefix} ${sigil}${input.entity.entityIid}: ${input.entity.title ?? input.event.surface.title}`, + participantIds: [wakeAgentId], + topic: formatGitlabEntityTopic(input.entity, input.target.entry.reason === "review_requested"), metadata, }); chatId = createdChat.id; @@ -810,11 +870,13 @@ async function resolveGitlabTargetChat( organizationId: input.organizationId, connectionId: input.connectionId, chatId, - declaredByAgentId: input.target.humanAgentId, + declaredByAgentId: humanAgentId, boundVia: "identity_target", identityLinkId: resolvedIdentity.identity.linkId, - humanAgentId: input.target.humanAgentId, - delegateAgentId: input.target.wakeAgentId, + humanAgentId, + delegateAgentId: wakeAgentId, + attentionMode: "paired", + attentionBackfillVersion: 1, active: true, entityType: input.entity.entityType, entityIid: input.entity.entityIid, @@ -823,13 +885,54 @@ async function resolveGitlabTargetChat( projectPathNormalized: normalizeGitlabProjectPath(input.entity.projectPath), entityUrl: input.entity.entityUrl, title: input.entity.title, - entityState: input.entity.entityState, + entityState: input.entity.entityState ?? "open", createdAt: new Date(), updatedAt: new Date(), }); return { chatId, created }; } +async function findGitlabRelatedEntityChat( + db: Database, + input: { + organizationId: string; + connectionId: string; + relatedRefs: NormalizedScmEvent["relatedRefs"]; + humanAgentId: string; + wakeAgentId: string; + }, +): Promise { + const issueRefs = input.relatedRefs.flatMap((ref) => { + if (ref.type !== "issue") return []; + const match = /^(\d+):issue:(\d+)$/.exec(ref.key); + if (!match?.[1] || !match[2]) return []; + return [{ projectId: Number(match[1]), issueIid: Number(match[2]) }]; + }); + if (issueRefs.length === 0) return null; + + const candidateChatIds = new Set(); + for (const ref of issueRefs) { + const rows = await db + .select({ chatId: gitlabEntityChatMappings.chatId }) + .from(gitlabEntityChatMappings) + .where( + and( + eq(gitlabEntityChatMappings.organizationId, input.organizationId), + eq(gitlabEntityChatMappings.connectionId, input.connectionId), + eq(gitlabEntityChatMappings.projectId, ref.projectId), + eq(gitlabEntityChatMappings.entityType, "issue"), + eq(gitlabEntityChatMappings.entityIid, ref.issueIid), + eq(gitlabEntityChatMappings.humanAgentId, input.humanAgentId), + eq(gitlabEntityChatMappings.delegateAgentId, input.wakeAgentId), + eq(gitlabEntityChatMappings.active, true), + ), + ); + for (const row of rows) candidateChatIds.add(row.chatId); + if (candidateChatIds.size > 1) return null; + } + return candidateChatIds.size === 1 ? ([...candidateChatIds][0] ?? null) : null; +} + export async function deliverGitlabCards( app: FastifyInstance, input: { @@ -863,8 +966,8 @@ export async function deliverGitlabCards( { err, metric: "gitlab_delivery_failed_total", - humanAgentId: target.humanAgentId, - delegateAgentId: target.wakeAgentId, + humanAgentId: scmTargetHumanAgentId(target), + delegateAgentId: scmTargetWakeAgentId(target), entityKey: input.event.entity.key, }, "failed to resolve chat for normalized GitLab target", diff --git a/packages/server/src/services/scm-attention-line.ts b/packages/server/src/services/scm-attention-line.ts new file mode 100644 index 000000000..8aad4dddd --- /dev/null +++ b/packages/server/src/services/scm-attention-line.ts @@ -0,0 +1,201 @@ +import { and, asc, eq } from "drizzle-orm"; +import type { Database } from "../db/connection.js"; +import { agents } from "../db/schema/agents.js"; +import { chatMembership } from "../db/schema/chat-membership.js"; +import { chats } from "../db/schema/chats.js"; + +export type ScmBindingPair = { + organizationId: string; + humanAgentId: string; + wakeAgentId: string; +}; + +export type ScmFollowLineRecord = { + chatId: string; +}; + +export type ScmFollowLineStorage = { + /** Deterministic order; the first row is the canonical existing line. */ + listLines: () => Promise; + /** Insert or provider-specific legacy upgrade, returning the surviving row. */ + createLine: () => Promise<{ record: TRecord; inserted: boolean }>; + /** Move the canonical row. Null means it vanished in a concurrent unfollow. */ + moveLine: (record: TRecord) => Promise; + /** Remove non-canonical rows for the same logical attention line. */ + removeLines: (records: TRecord[]) => Promise; + getChatTopic: (chatId: string) => Promise; +}; + +export type ScmFollowLineResult = + | { outcome: "created" | "already_following" | "rebound"; record: TRecord } + | { outcome: "conflict"; conflict: { chatId: string; topic: string | null } }; + +/** + * Provider-neutral follow state machine. + * + * Provider adapters own only atomic storage callbacks. This function is the + * single definition of pair-aware idempotency, one-line/one-room conflict, + * explicit rebind, duplicate cleanup, and the vanished-row race fallback. + */ +export async function executeScmFollowLine(input: { + targetChatId: string; + rebind: boolean; + storage: ScmFollowLineStorage; +}): Promise> { + const existing = await input.storage.listLines(); + const sameChatIndex = existing.findIndex((line) => line.chatId === input.targetChatId); + if (sameChatIndex >= 0) { + const sameChat = existing[sameChatIndex]; + if (!sameChat) throw new Error("SCM follow line disappeared from its deterministic snapshot"); + await input.storage.removeLines(existing.filter((_, index) => index !== sameChatIndex)); + return { outcome: "already_following", record: sameChat }; + } + + const elsewhere = existing[0]; + if (elsewhere && !input.rebind) { + return { + outcome: "conflict", + conflict: { + chatId: elsewhere.chatId, + topic: await input.storage.getChatTopic(elsewhere.chatId), + }, + }; + } + + let rebindFallback = false; + if (elsewhere) { + await input.storage.removeLines(existing.slice(1)); + const moved = await input.storage.moveLine(elsewhere); + if (moved) return { outcome: "rebound", record: moved }; + rebindFallback = true; + } + + const created = await input.storage.createLine(); + if (created.inserted) return { outcome: "created", record: created.record }; + if (created.record.chatId === input.targetChatId) { + return { + outcome: rebindFallback ? "created" : "already_following", + record: created.record, + }; + } + return { + outcome: "conflict", + conflict: { + chatId: created.record.chatId, + topic: await input.storage.getChatTopic(created.record.chatId), + }, + }; +} + +/** + * Resolve the durable attention owner for an agent-issued follow. + * + * The selected agent is always the wake side. The human side is its linked + * active human in the chat, or the chat's sole active human when no explicit + * delegate relationship exists. Ambiguous ownership fails closed. + */ +export async function resolveAgentScmBindingPair( + db: Database, + chatId: string, + wakeAgentId: string, +): Promise { + const rows = await db + .select({ + chatOrganizationId: chats.organizationId, + agentId: chatMembership.agentId, + agentOrganizationId: agents.organizationId, + agentType: agents.type, + agentStatus: agents.status, + delegateMention: agents.delegateMention, + accessMode: chatMembership.accessMode, + }) + .from(chatMembership) + .innerJoin(chats, eq(chatMembership.chatId, chats.id)) + .innerJoin(agents, eq(chatMembership.agentId, agents.uuid)) + .where(eq(chatMembership.chatId, chatId)) + .orderBy(asc(chatMembership.agentId)); + + const wakeAgent = rows.find((row) => row.agentId === wakeAgentId); + if ( + !wakeAgent || + wakeAgent.agentType === "human" || + wakeAgent.agentStatus !== "active" || + wakeAgent.accessMode !== "speaker" + ) { + return null; + } + if (wakeAgent.agentOrganizationId !== wakeAgent.chatOrganizationId) return null; + + const humans = rows.filter( + (row) => + row.agentType === "human" && row.agentStatus === "active" && row.agentOrganizationId === row.chatOrganizationId, + ); + const linkedHumans = humans.filter((human) => human.delegateMention === wakeAgentId); + const representative = linkedHumans.length === 1 ? linkedHumans[0] : linkedHumans.length === 0 ? humans[0] : null; + if (!representative || (linkedHumans.length === 0 && humans.length !== 1)) return null; + return { + organizationId: representative.chatOrganizationId, + humanAgentId: representative.agentId, + wakeAgentId, + }; +} + +/** + * Resolve the pair for a human-issued follow. A configured delegate that is + * not an active speaker would form a silent line, so this fails closed. + */ +export async function resolveHumanScmBindingPair( + db: Database, + chatId: string, + humanAgentId: string, +): Promise { + const [human] = await db + .select({ + organizationId: chats.organizationId, + agentOrganizationId: agents.organizationId, + agentType: agents.type, + agentStatus: agents.status, + delegateMention: agents.delegateMention, + }) + .from(chatMembership) + .innerJoin(chats, eq(chatMembership.chatId, chats.id)) + .innerJoin(agents, eq(chatMembership.agentId, agents.uuid)) + .where( + and( + eq(chatMembership.chatId, chatId), + eq(chatMembership.agentId, humanAgentId), + eq(chatMembership.accessMode, "speaker"), + ), + ) + .limit(1); + if ( + !human || + human.agentType !== "human" || + human.agentStatus !== "active" || + human.agentOrganizationId !== human.organizationId || + !human.delegateMention + ) { + return null; + } + + const [wakeAgent] = await db + .select({ id: agents.uuid }) + .from(chatMembership) + .innerJoin(agents, eq(chatMembership.agentId, agents.uuid)) + .where( + and( + eq(chatMembership.chatId, chatId), + eq(chatMembership.agentId, human.delegateMention), + eq(chatMembership.accessMode, "speaker"), + eq(agents.status, "active"), + eq(agents.organizationId, human.organizationId), + ), + ) + .limit(1); + if (!wakeAgent) return null; + return { + organizationId: human.organizationId, + humanAgentId, + wakeAgentId: wakeAgent.id, + }; +} diff --git a/packages/server/src/services/scm-chat-delivery-plan.ts b/packages/server/src/services/scm-chat-delivery-plan.ts index 23acbbd60..a12f094f6 100644 --- a/packages/server/src/services/scm-chat-delivery-plan.ts +++ b/packages/server/src/services/scm-chat-delivery-plan.ts @@ -1,13 +1,11 @@ -import type { InvolveReason } from "@first-tree/shared"; +import type { InvolveReason, ScmAudienceEntry } from "@first-tree/shared"; export type ScmAudienceTarget = { - senderAgentId: string; - humanAgentId: string | null; - wakeAgentId: string | null; - kind: "existing" | "new"; - chatId: string | null; - involveReason: InvolveReason | null; - involveLogin: string | null; + entry: ScmAudienceEntry; + directedContext?: { + reason: InvolveReason; + externalUsername: string; + } | null; }; export type ScmDeliveryEntry = { @@ -45,8 +43,9 @@ export async function planScmChatDeliveries(input: { const deliveries = new Map(); let failed = 0; for (const target of input.targets) { - const freshDirectedSelfInvolve = target.kind === "new" && target.involveReason !== null; - if (input.actorHumanId && target.humanAgentId === input.actorHumanId && !freshDirectedSelfInvolve) { + const humanAgentId = scmTargetHumanAgentId(target); + const freshDirectedSelfInvolve = target.entry.kind === "personnel_target"; + if (input.actorHumanId && humanAgentId === input.actorHumanId && !freshDirectedSelfInvolve) { continue; } @@ -75,32 +74,74 @@ export async function planScmChatDeliveries(input: { } function addScmDeliveryEntry(delivery: ScmPlannedChatDelivery, target: ScmAudienceTarget): void { - const key = `${target.senderAgentId}:${target.humanAgentId ?? "-"}:${target.wakeAgentId ?? "-"}`; + const senderAgentId = scmTargetSenderAgentId(target); + const humanAgentId = scmTargetHumanAgentId(target); + const wakeAgentId = scmTargetWakeAgentId(target); + const involveReason = + target.entry.kind === "personnel_target" ? target.entry.reason : (target.directedContext?.reason ?? null); + const involveLogin = + target.entry.kind === "personnel_target" + ? target.entry.externalUsername + : (target.directedContext?.externalUsername ?? null); + const key = `${senderAgentId}:${humanAgentId ?? "-"}:${wakeAgentId ?? "-"}`; const reasons = new Set<"follow" | InvolveReason>(); - if (target.kind === "existing") reasons.add("follow"); - if (target.involveReason) reasons.add(target.involveReason); + if (target.entry.kind !== "personnel_target") reasons.add("follow"); + if (involveReason) reasons.add(involveReason); const existing = delivery.entries.get(key); if (existing) { for (const reason of reasons) existing.reasons.add(reason); if ( - target.involveReason && - (!existing.involveReason || involveReasonRank(target.involveReason) < involveReasonRank(existing.involveReason)) + involveReason && + (!existing.involveReason || involveReasonRank(involveReason) < involveReasonRank(existing.involveReason)) ) { - existing.involveReason = target.involveReason; - existing.involveLogin = target.involveLogin; + existing.involveReason = involveReason; + existing.involveLogin = involveLogin; } return; } delivery.entries.set(key, { - senderAgentId: target.senderAgentId, - humanAgentId: target.humanAgentId, - wakeAgentId: target.wakeAgentId, + senderAgentId, + humanAgentId, + wakeAgentId, reasons, - involveReason: target.involveReason, - involveLogin: target.involveLogin, + involveReason, + involveLogin, }); } +export function scmTargetHumanAgentId(target: ScmAudienceTarget): string | null { + switch (target.entry.kind) { + case "existing_line": + return target.entry.line.humanAgentId; + case "personnel_target": + return target.entry.humanAgentId; + case "legacy_route": + return null; + } +} + +export function scmTargetWakeAgentId(target: ScmAudienceTarget): string | null { + switch (target.entry.kind) { + case "existing_line": + return target.entry.line.wakeAgentId; + case "personnel_target": + return target.entry.wakeAgentId; + case "legacy_route": + return null; + } +} + +export function scmTargetSenderAgentId(target: ScmAudienceTarget): string { + switch (target.entry.kind) { + case "existing_line": + return target.entry.line.humanAgentId; + case "personnel_target": + return target.entry.humanAgentId; + case "legacy_route": + return target.entry.route.senderAgentId; + } +} + export function compareScmDeliveryEntries(a: ScmDeliveryEntry, b: ScmDeliveryEntry): number { return ( (a.humanAgentId ?? a.senderAgentId).localeCompare(b.humanAgentId ?? b.senderAgentId) || diff --git a/packages/server/src/services/scm-entity-chat-topic.ts b/packages/server/src/services/scm-entity-chat-topic.ts new file mode 100644 index 000000000..37ac693d8 --- /dev/null +++ b/packages/server/src/services/scm-entity-chat-topic.ts @@ -0,0 +1,70 @@ +export type ScmAutoTopicVariant = { + matches: string | RegExp; + nextHead: string; +}; + +/** Provider-neutral topic rendering: provider adapters own only the compact head. */ +export function formatScmAutoTopic(head: string, title: string | null | undefined): string { + return title ? `${head}: ${title}` : head; +} + +/** + * Refresh only a recognised automatic topic. Each provider supplies its + * accepted grammar and the canonical next head, so manual names remain sticky. + */ +export function refreshScmAutoTopic( + storedTopic: string, + title: string | null | undefined, + variants: ScmAutoTopicVariant[], +): string | null { + if (!title) return null; + const matched = variants.find((variant) => + typeof variant.matches === "string" + ? storedTopic === variant.matches || storedTopic.startsWith(`${variant.matches}: `) + : variant.matches.test(storedTopic), + ); + return matched ? formatScmAutoTopic(matched.nextHead, title) : null; +} + +export type GitlabTopicEntity = { + entityType: "issue" | "pull_request"; + entityIid: number; + projectPath: string; + title: string | null; +}; + +function gitlabProjectSegment(projectPath: string): string { + return projectPath.split("/").filter(Boolean).at(-1) ?? projectPath; +} + +export function formatGitlabEntityTopic(entity: GitlabTopicEntity, reviewFirstTouch = false): string { + const project = gitlabProjectSegment(entity.projectPath); + const head = + entity.entityType === "pull_request" + ? `${reviewFirstTouch ? "MR Review" : "MR"} ${project}!${entity.entityIid}` + : `Issue ${project}#${entity.entityIid}`; + return formatScmAutoTopic(head, entity.title); +} + +export function refreshGitlabEntityTopic(storedTopic: string, entity: GitlabTopicEntity): string | null { + const project = gitlabProjectSegment(entity.projectPath); + const iid = String(entity.entityIid); + if (entity.entityType === "pull_request") { + return refreshScmAutoTopic(storedTopic, entity.title, [ + { + matches: new RegExp(`^MR Review [^\\s/:]+!${iid}(?:: .*)?$`, "u"), + nextHead: `MR Review ${project}!${entity.entityIid}`, + }, + { + matches: new RegExp(`^MR [^\\s/:]+!${iid}(?:: .*)?$`, "u"), + nextHead: `MR ${project}!${entity.entityIid}`, + }, + ]); + } + return refreshScmAutoTopic(storedTopic, entity.title, [ + { + matches: new RegExp(`^Issue [^\\s/:]+#${iid}(?:: .*)?$`, "u"), + nextHead: `Issue ${project}#${entity.entityIid}`, + }, + ]); +} diff --git a/packages/server/src/services/scm-related-refs.ts b/packages/server/src/services/scm-related-refs.ts new file mode 100644 index 000000000..984261270 --- /dev/null +++ b/packages/server/src/services/scm-related-refs.ts @@ -0,0 +1,23 @@ +const CLOSING_REFERENCE_RE = /\b(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)\s+#(\d+)\b/giu; + +/** Parse only the GitHub/GitLab common, same-project closing-reference subset. */ +export function parseSameProjectClosingIssueRefs( + text: string | null | undefined, + projectKey: string, + formatKey: (projectKey: string, issueNumber: string) => string = (project, issueNumber) => + `${project}#${issueNumber}`, +): Array<{ type: "issue"; key: string }> { + if (!text) return []; + const prose = text.replace(/```[\s\S]*?```/gu, " ").replace(/`[^`\n]*`/gu, " "); + const refs: Array<{ type: "issue"; key: string }> = []; + const seen = new Set(); + for (const match of prose.matchAll(CLOSING_REFERENCE_RE)) { + const number = match[1]; + if (!number) continue; + const key = formatKey(projectKey, number); + if (seen.has(key)) continue; + seen.add(key); + refs.push({ type: "issue", key }); + } + return refs; +} diff --git a/packages/server/src/services/scm-target-chat-policy.ts b/packages/server/src/services/scm-target-chat-policy.ts new file mode 100644 index 000000000..961a1295a --- /dev/null +++ b/packages/server/src/services/scm-target-chat-policy.ts @@ -0,0 +1,40 @@ +import type { InvolveReason } from "@first-tree/shared"; +import { and, eq, inArray } from "drizzle-orm"; +import type { Database } from "../db/connection.js"; +import { chatMembership } from "../db/schema/chat-membership.js"; + +export type ScmTargetChatDecision = { kind: "reuse"; chatId: string } | { kind: "strict_new_line" }; + +/** + * Only reviewer routing may reuse chat membership without writing a line. + * Mentions and assignments are directed calls and always establish a strict + * new attention home. + */ +export async function decideScmPersonnelTargetChat( + db: Database, + input: { + reason: InvolveReason; + candidateChatIds: string[]; + humanAgentId: string; + wakeAgentId: string; + }, +): Promise { + if (input.reason !== "review_requested") return { kind: "strict_new_line" }; + const reusable: string[] = []; + for (const chatId of [...new Set(input.candidateChatIds)]) { + const speakers = await db + .select({ agentId: chatMembership.agentId }) + .from(chatMembership) + .where( + and( + eq(chatMembership.chatId, chatId), + eq(chatMembership.accessMode, "speaker"), + inArray(chatMembership.agentId, [input.humanAgentId, input.wakeAgentId]), + ), + ); + const ids = new Set(speakers.map((speaker) => speaker.agentId)); + if (ids.has(input.humanAgentId) && ids.has(input.wakeAgentId)) reusable.push(chatId); + if (reusable.length > 1) return { kind: "strict_new_line" }; + } + return reusable.length === 1 && reusable[0] ? { kind: "reuse", chatId: reusable[0] } : { kind: "strict_new_line" }; +} diff --git a/packages/server/src/services/scm-webhook-processing.ts b/packages/server/src/services/scm-webhook-processing.ts index 54204fffc..66f8ed832 100644 --- a/packages/server/src/services/scm-webhook-processing.ts +++ b/packages/server/src/services/scm-webhook-processing.ts @@ -1,4 +1,4 @@ -import type { NormalizedScmEvent, ScmIngressContext } from "@first-tree/shared"; +import type { NormalizedScmEvent, ScmEntityObservation, ScmIngressContext } from "@first-tree/shared"; import type { Database } from "../db/connection.js"; import { createLogger } from "../observability/index.js"; import { claimEvent, unclaimEvent } from "./event-dedup.js"; @@ -23,7 +23,9 @@ export type ScmProcessingResult = type ProcessScmWebhookDeliveryInput = { db: Database; ingress: ScmIngressContext; + observation: ScmEntityObservation | null; event: NormalizedScmEvent | null; + applyObservation: (observation: ScmEntityObservation) => Promise; /** Provider-owned work covered by the same whole-request claim. */ runProviderWork: () => Promise; /** Provider-owned mapping and identity resolver. */ @@ -57,6 +59,23 @@ export async function processScmWebhookDelivery { + // Projection refresh is deliberately independent from notification + // delivery. A temporary projection failure must not suppress an + // otherwise valid card or make a provider retry duplicate it. + log.error( + { + err, + provider: input.ingress.provider, + organizationId: input.ingress.source.organizationId, + entityType: input.observation?.entity.type, + entityKey: input.observation?.entity.key, + }, + "failed to apply SCM entity observation", + ); + }); + } if (!input.event) return { outcome: "provider_only", providerResult }; const audience = await input.resolveAudience(input.event); diff --git a/packages/shared/src/__tests__/chat-gitlab-entities-schema.test.ts b/packages/shared/src/__tests__/chat-gitlab-entities-schema.test.ts index f64d4f802..0ea004d26 100644 --- a/packages/shared/src/__tests__/chat-gitlab-entities-schema.test.ts +++ b/packages/shared/src/__tests__/chat-gitlab-entities-schema.test.ts @@ -21,7 +21,7 @@ describe("chat GitLab entity schemas", () => { expect(chatGitlabEntitySchema.parse(pendingEntity)).toEqual(pendingEntity); expect( chatGitlabEntityListResponseSchema.parse({ - items: [{ ...pendingEntity, title: "Ship it", status: "active", state: "opened" }], + items: [{ ...pendingEntity, title: "Ship it", status: "active", state: "open" }], }), ).toMatchObject({ items: [{ status: "active", title: "Ship it" }] }); expect(followChatGitlabEntityResponseSchema.parse({ status: "created", entity: pendingEntity })).toMatchObject({ diff --git a/packages/shared/src/__tests__/normalized-event-schema.test.ts b/packages/shared/src/__tests__/normalized-event-schema.test.ts index 24e537b61..8ecabf0a2 100644 --- a/packages/shared/src/__tests__/normalized-event-schema.test.ts +++ b/packages/shared/src/__tests__/normalized-event-schema.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { githubEventCardSchema, normalizedScmEventSchema } from "../schemas/normalized-event.js"; +import { + githubEventCardSchema, + normalizedScmEventSchema, + scmNormalizedWebhookSchema, +} from "../schemas/normalized-event.js"; import { scmIngressContextSchema, scmSourceSchema } from "../schemas/scm-source.js"; const sampleSource = { @@ -112,6 +116,46 @@ describe("normalizedScmEventSchema", () => { }); }); +describe("scmNormalizedWebhookSchema", () => { + it("accepts an observation-only lifecycle update", () => { + expect( + scmNormalizedWebhookSchema.safeParse({ + ingress: { + provider: "gitlab", + source: { organizationId: "org-uuid", externalId: "connection-1" }, + stableDeliveryId: null, + ingressAuthority: "url_bearer", + }, + observation: { + entity: sampleEvent.entity, + state: "merged", + observedAt: new Date().toISOString(), + }, + event: null, + }).success, + ).toBe(true); + }); + + it("rejects an unknown lifecycle state", () => { + expect( + scmNormalizedWebhookSchema.safeParse({ + ingress: { + provider: "github", + source: sampleSource, + stableDeliveryId: "delivery-1", + ingressAuthority: "verified_signature", + }, + observation: { + entity: sampleEvent.entity, + state: "archived", + observedAt: new Date().toISOString(), + }, + event: sampleEvent, + }).success, + ).toBe(false); + }); +}); + describe("githubEventCardSchema", () => { const baseCard = { type: "github_event" as const, diff --git a/packages/shared/src/__tests__/scm-attention-schema.test.ts b/packages/shared/src/__tests__/scm-attention-schema.test.ts new file mode 100644 index 000000000..49bb4c7f6 --- /dev/null +++ b/packages/shared/src/__tests__/scm-attention-schema.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { scmAudienceEntrySchema } from "../schemas/scm-attention.js"; + +describe("SCM attention schema", () => { + it("requires complete human and wake ownership for a durable line", () => { + expect( + scmAudienceEntrySchema.safeParse({ + kind: "existing_line", + line: { + kind: "attention_line", + humanAgentId: "human-1", + wakeAgentId: null, + chatId: "chat-1", + provenance: "explicit", + }, + }).success, + ).toBe(false); + }); + + it("represents route-only legacy data as a separate discriminant", () => { + expect( + scmAudienceEntrySchema.parse({ + kind: "legacy_route", + route: { + kind: "legacy_route_only", + chatId: "chat-1", + senderAgentId: "legacy-actor", + wakeAgentId: null, + provenance: "legacy_explicit", + }, + }), + ).toMatchObject({ kind: "legacy_route" }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2f012a0d0..2001647ca 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -264,8 +264,10 @@ export { chatGitlabEntitySchema, chatGitlabEntityStatusSchema, chatGitlabEntityTypeSchema, + type FollowChatGitlabEntityConflict, type FollowChatGitlabEntityRequest, type FollowChatGitlabEntityResponse, + followChatGitlabEntityConflictSchema, followChatGitlabEntityRequestSchema, followChatGitlabEntityResponseSchema, type UnfollowChatGitlabEntityResponse, @@ -752,6 +754,14 @@ export { type NormalizedScmEvent, normalizedEventKindSchema, normalizedScmEventSchema, + normalizeScmEntityState, + SCM_ENTITY_STATES, + type ScmEntityObservation, + type ScmEntityState, + type ScmNormalizedWebhook, + scmEntityObservationSchema, + scmEntityStateSchema, + scmNormalizedWebhookSchema, } from "./schemas/normalized-event.js"; export { NOTIFICATION_SEVERITIES, @@ -982,6 +992,16 @@ export { type RuntimeProvider, runtimeProviderSchema, } from "./schemas/runtime-provider.js"; +export { + type ScmAttentionLine, + type ScmAttentionProvenance, + type ScmAudienceEntry, + type ScmLegacyRoute, + scmAttentionLineSchema, + scmAttentionProvenanceSchema, + scmAudienceEntrySchema, + scmLegacyRouteSchema, +} from "./schemas/scm-attention.js"; export { SCM_INGRESS_AUTHORITIES, SCM_PROVIDERS, diff --git a/packages/shared/src/schemas/chat-gitlab-entities.ts b/packages/shared/src/schemas/chat-gitlab-entities.ts index 6e08cc015..7a8d5c89b 100644 --- a/packages/shared/src/schemas/chat-gitlab-entities.ts +++ b/packages/shared/src/schemas/chat-gitlab-entities.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { scmEntityStateSchema } from "./normalized-event.js"; export const chatGitlabEntityTypeSchema = z.enum(["issue", "pull_request"]); export type ChatGitlabEntityType = z.infer; @@ -24,7 +25,7 @@ export const chatGitlabEntitySchema = z.object({ projectPath: z.string().min(1), entityIid: z.number().int().positive(), title: z.string().nullable(), - state: z.string().min(1).nullable(), + state: scmEntityStateSchema.nullable(), status: chatGitlabEntityStatusSchema, boundVia: chatGitlabEntityBoundViaSchema, }); @@ -39,16 +40,27 @@ export type ChatGitlabEntityListResponse = z.infer; +export type FollowChatGitlabEntityRequest = z.input; export const followChatGitlabEntityResponseSchema = z.object({ - status: z.enum(["created", "already_following"]), + status: z.enum(["created", "already_following", "rebound"]), entity: chatGitlabEntitySchema, }); export type FollowChatGitlabEntityResponse = z.infer; +export const followChatGitlabEntityConflictSchema = z.object({ + error: z.literal("ENTITY_FOLLOWED_ELSEWHERE"), + message: z.string().min(1), + conflict: z.object({ + chatId: z.string().min(1), + topic: z.string().nullable(), + }), +}); +export type FollowChatGitlabEntityConflict = z.infer; + export const unfollowChatGitlabEntityResponseSchema = z.object({ removed: z.number().int().min(0), }); diff --git a/packages/shared/src/schemas/gitlab.ts b/packages/shared/src/schemas/gitlab.ts index bd8a46f07..e96c7fde7 100644 --- a/packages/shared/src/schemas/gitlab.ts +++ b/packages/shared/src/schemas/gitlab.ts @@ -69,5 +69,6 @@ export type GitlabIdentityLinkSummary = z.infer; diff --git a/packages/shared/src/schemas/normalized-event.ts b/packages/shared/src/schemas/normalized-event.ts index 062ba6d85..e664849bd 100644 --- a/packages/shared/src/schemas/normalized-event.ts +++ b/packages/shared/src/schemas/normalized-event.ts @@ -89,6 +89,36 @@ export const normalizedScmEventSchema = scmIngressContextSchema.extend({ }); export type NormalizedScmEvent = z.infer; +export const SCM_ENTITY_STATES = ["open", "draft", "closed", "merged"] as const; +export const scmEntityStateSchema = z.enum(SCM_ENTITY_STATES); +export type ScmEntityState = z.infer; + +/** Normalize provider/legacy storage spellings at the projection boundary. */ +export function normalizeScmEntityState(value: unknown): ScmEntityState | null { + if (value === "opened") return "open"; + const parsed = scmEntityStateSchema.safeParse(value); + return parsed.success ? parsed.data : null; +} + +export const scmEntityObservationSchema = z.object({ + entity: normalizedScmEntitySchema, + state: scmEntityStateSchema.nullable(), + observedAt: z.string().datetime(), +}); +export type ScmEntityObservation = z.infer; + +/** + * Provider adapters always return one envelope. Observation is independent + * from semantic notification: metadata-only and terminal MR events can update + * the local projection while carrying `event: null`. + */ +export const scmNormalizedWebhookSchema = z.object({ + ingress: scmIngressContextSchema, + observation: scmEntityObservationSchema.nullable(), + event: normalizedScmEventSchema.nullable(), +}); +export type ScmNormalizedWebhook = z.infer; + /** * Why the recipient is being told about this event. `subscribed` covers * the persistent-subscription path (DP1); `mentioned` / `review_requested` diff --git a/packages/shared/src/schemas/scm-attention.ts b/packages/shared/src/schemas/scm-attention.ts new file mode 100644 index 000000000..e2472d7f9 --- /dev/null +++ b/packages/shared/src/schemas/scm-attention.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; +import { involveReasonSchema } from "./normalized-event.js"; + +export const scmAttentionProvenanceSchema = z.enum(["explicit", "identity_target", "related_entity"]); +export type ScmAttentionProvenance = z.infer; + +export const scmAttentionLineSchema = z.object({ + kind: z.literal("attention_line"), + humanAgentId: z.string().min(1), + wakeAgentId: z.string().min(1), + chatId: z.string().min(1), + provenance: scmAttentionProvenanceSchema, +}); +export type ScmAttentionLine = z.infer; + +export const scmLegacyRouteSchema = z.object({ + kind: z.literal("legacy_route_only"), + chatId: z.string().min(1), + senderAgentId: z.string().min(1), + wakeAgentId: z.null(), + provenance: z.literal("legacy_explicit"), +}); +export type ScmLegacyRoute = z.infer; + +export const scmAudienceEntrySchema = z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("existing_line"), + line: scmAttentionLineSchema, + }), + z.object({ + kind: z.literal("legacy_route"), + route: scmLegacyRouteSchema, + }), + z.object({ + kind: z.literal("personnel_target"), + reason: involveReasonSchema, + humanAgentId: z.string().min(1), + wakeAgentId: z.string().min(1), + externalUsername: z.string().min(1), + }), +]); +export type ScmAudienceEntry = z.infer; diff --git a/packages/web/src/api/__tests__/api-wrappers.test.ts b/packages/web/src/api/__tests__/api-wrappers.test.ts index 862f27f81..1f1694809 100644 --- a/packages/web/src/api/__tests__/api-wrappers.test.ts +++ b/packages/web/src/api/__tests__/api-wrappers.test.ts @@ -196,6 +196,8 @@ describe("api wrapper paths", () => { await chats.listChats({ limit: 3, cursor: "next" }); await chats.getChat("chat/id"); await chats.listChatGithubEntities("chat/id"); + await chats.listChatGitlabEntities("chat/id"); + await chats.unfollowChatGitlabEntity("chat/id", "https://gitlab.example/acme/api/-/merge_requests/42"); await chats.renameChat("chat/id", "Topic"); await chats.patchChatEngagement("chat/id", "archived"); await chats.sendChatMessage("chat/id", "hello", ["agent-1"]); @@ -286,6 +288,12 @@ describe("api wrapper paths", () => { expect(apiMock.get).toHaveBeenCalledWith("/orgs/current/agents/all?limit=5&cursor=older"); expect(apiMock.get).toHaveBeenCalledWith("/agents/agent%2Fid/skills"); expect(apiMock.get).toHaveBeenCalledWith("/orgs/current/agents/names/name%20with%20spaces/availability"); + expect(apiMock.get).toHaveBeenCalledWith("/chats/chat%2Fid/gitlab-entities"); + expect(apiMock.delete).toHaveBeenCalledWith( + `/chats/chat%2Fid/gitlab-entities?entity=${encodeURIComponent( + "https://gitlab.example/acme/api/-/merge_requests/42", + )}`, + ); expect(apiMock.post).toHaveBeenCalledWith("/chats/chat%2Fid/messages", { format: "text", content: "hello", diff --git a/packages/web/src/api/chats.ts b/packages/web/src/api/chats.ts index b2bdaac6c..5f423f710 100644 --- a/packages/web/src/api/chats.ts +++ b/packages/web/src/api/chats.ts @@ -61,6 +61,13 @@ export function listChatGitlabEntities(chatId: string): Promise(`/chats/${encodeURIComponent(chatId)}/gitlab-entities`); } +/** Canonical human/Web unfollow contract; legacy mapping-id deletion stays server-only. */ +export function unfollowChatGitlabEntity(chatId: string, entityUrl: string): Promise<{ removed: number }> { + return api.delete<{ removed: number }>( + `/chats/${encodeURIComponent(chatId)}/gitlab-entities?entity=${encodeURIComponent(entityUrl)}`, + ); +} + export function renameChat(chatId: string, topic: string | null): Promise { return api.patch(`/chats/${encodeURIComponent(chatId)}`, { topic }); } diff --git a/packages/web/src/pages/workspace/__tests__/workspace-sidebars-extra-dom.test.tsx b/packages/web/src/pages/workspace/__tests__/workspace-sidebars-extra-dom.test.tsx index 90d1b8ea1..36c8854c3 100644 --- a/packages/web/src/pages/workspace/__tests__/workspace-sidebars-extra-dom.test.tsx +++ b/packages/web/src/pages/workspace/__tests__/workspace-sidebars-extra-dom.test.tsx @@ -523,6 +523,15 @@ describe("GitLabSection extra DOM behavior", () => { state: "open", boundVia: "identity_target", }), + gitlabEntity({ + entityType: "pull_request", + entityUrl: "https://gitlab.internal/Acme/Reviews/-/merge_requests/18", + projectPath: "Acme/Reviews", + entityIid: 18, + title: "Draft change", + state: "draft", + boundVia: "identity_target", + }), ], }); @@ -530,10 +539,15 @@ describe("GitLabSection extra DOM behavior", () => { await waitForText(rendered.container, "Review this change"); const hrefs = [...rendered.container.querySelectorAll("a")].map((anchor) => anchor.href); - expect(hrefs).toEqual([mergeRequestUrl, "https://gitlab.internal/Acme/Reviews/-/issues/8"]); - expect(rendered.container.textContent).toContain("GitLab · 2"); + expect(hrefs).toEqual([ + mergeRequestUrl, + "https://gitlab.internal/Acme/Reviews/-/merge_requests/18", + "https://gitlab.internal/Acme/Reviews/-/issues/8", + ]); + expect(rendered.container.textContent).toContain("GitLab · 3"); expect(rendered.container.textContent).toContain("Reviews!17"); expect(rendered.container.textContent).toContain("Open"); + expect(rendered.container.textContent).toContain("Draft"); expect(rendered.container.textContent).toContain("Closed"); await act(async () => rendered.root.unmount()); }); diff --git a/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx b/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx index b243b140c..5ec18433c 100644 --- a/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx +++ b/packages/web/src/pages/workspace/center/__tests__/chat-view-dom.test.tsx @@ -748,6 +748,69 @@ afterEach(() => { }); describe("ChatView", () => { + it("renders typed GitHub/GitLab header links only for anchored provider chats", async () => { + const { ChatView } = await import("../chat-view.js"); + const cases = [ + { + id: "chat-github-link", + metadata: { + source: "github", + entityType: "pull_request", + entityKey: "acme/web#42", + entityUrl: "https://github.com/acme/web/pull/42", + }, + title: "View on GitHub", + href: "https://github.com/acme/web/pull/42", + }, + { + id: "chat-gitlab-link", + metadata: { + source: "gitlab", + entityType: "pull_request", + entityKey: "501:pull_request:42", + entityUrl: "https://gitlab.internal/acme/web/-/merge_requests/42", + }, + title: "View on GitLab", + href: "https://gitlab.internal/acme/web/-/merge_requests/42", + }, + { + id: "chat-gitlab-no-url", + metadata: { + source: "gitlab", + entityType: "pull_request", + entityKey: "501:pull_request:43", + }, + title: null, + href: null, + }, + { id: "chat-manual-link", metadata: {}, title: null, href: null }, + ] as const; + + for (const entry of cases) { + const detail = chatDetail({ + id: entry.id, + title: `Header ${entry.id}`, + topic: `Header ${entry.id}`, + metadata: entry.metadata, + }); + const { container, root } = await renderDom( + , + undefined, + "/", + ); + await waitForText(container, `Header ${entry.id}`); + const link = entry.title ? container.querySelector(`a[title="${entry.title}"]`) : null; + if (entry.href) { + expect(link?.href).toBe(entry.href); + expect(link?.target).toBe("_blank"); + expect(link?.rel).toBe("noopener noreferrer"); + } else { + expect(container.querySelector('a[title^="View on "]')).toBeNull(); + } + await act(async () => root.unmount()); + } + }); + it("hides chat-management affordances on the trial surface even when read-only (route-scoped)", async () => { const { ChatView } = await import("../chat-view.js"); // A persisted-open sidebar must NOT re-appear on the trial surface. diff --git a/packages/web/src/pages/workspace/center/chat-view.tsx b/packages/web/src/pages/workspace/center/chat-view.tsx index 3df6f770a..60cbda710 100644 --- a/packages/web/src/pages/workspace/center/chat-view.tsx +++ b/packages/web/src/pages/workspace/center/chat-view.tsx @@ -6,6 +6,7 @@ import { type ChatDetail, type ChatParticipantDetail, COMPOSER_ACCEPT_ATTRIBUTE, + chatMetadataSchema, type DocSnapshotFailReason, documentContextSchema, extractMentions, @@ -1439,10 +1440,9 @@ const ChatTimeline = memo(function ChatTimeline({ }); /** - * Renders a small "↗ View on GitHub" link beside the chat title when the chat - * was created by the GitHub webhook router. Reads `metadata.entityUrl` (set by - * `services/github-entity-chat.ts::createEntityChat`); shows nothing if the - * chat has no entity metadata or the URL is missing. + * Renders the provider entity link beside an SCM-owned chat title. The URL is + * persisted only after the provider ingress authority validates its origin; + * manual/follow-only chats carry no provider anchor metadata and render none. * * Defensive parsing: `metadata` is typed `Record` on the * wire, so we narrow inline rather than trust the shape. A schema parse would @@ -1450,15 +1450,17 @@ const ChatTimeline = memo(function ChatTimeline({ * 2-field check isn't worth it. */ function EntityLink({ metadata }: { metadata: Record | undefined }) { - if (!metadata || metadata.source !== "github") return null; - const url = typeof metadata.entityUrl === "string" ? metadata.entityUrl : null; + const parsed = chatMetadataSchema.safeParse(metadata); + if (!parsed.success || (parsed.data.source !== "github" && parsed.data.source !== "gitlab")) return null; + const url = parsed.data.entityUrl ?? null; if (!url) return null; + const provider = parsed.data.source === "github" ? "GitHub" : "GitLab"; return ( diff --git a/packages/web/src/pages/workspace/right-sidebar/gitlab-section.tsx b/packages/web/src/pages/workspace/right-sidebar/gitlab-section.tsx index 4027347ed..bb0ac8dca 100644 --- a/packages/web/src/pages/workspace/right-sidebar/gitlab-section.tsx +++ b/packages/web/src/pages/workspace/right-sidebar/gitlab-section.tsx @@ -119,13 +119,14 @@ function GitLabRow({ entity }: { entity: ChatGitlabEntity }) { ); } -function iconColor(state: string | null): string { +function iconColor(state: ChatGitlabEntity["state"]): string { switch (state) { case "merged": return "oklch(0.42 0.18 295)"; case "closed": return "var(--fg-3)"; case "open": + case "draft": return "var(--fg-success-strong)"; default: return "var(--fg-3)"; @@ -133,13 +134,15 @@ function iconColor(state: string | null): string { } function viewForState( - state: string | null, + state: ChatGitlabEntity["state"], status: ChatGitlabEntity["status"], ): { label: string; tone: DenseBadgeTone } | null { if (status === "pending") return { label: "Pending", tone: "outline" }; switch (state) { case "open": return { label: "Open", tone: "accent" }; + case "draft": + return { label: "Draft", tone: "outline" }; case "closed": return { label: "Closed", tone: "neutral" }; case "merged": From 53d5902f6db83e37e8ab83b8d1656954a873e1af Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Fri, 17 Jul 2026 17:56:34 +0800 Subject: [PATCH 2/4] fix: restore onboarding analytics delivery --- .../cloud-onboarding-analytics-funnel.md | 76 +++++++++++++++++++ .../src/__tests__/me-onboarding.test.ts | 8 +- packages/shared/src/schemas/me-extras.ts | 8 ++ packages/web/index.html | 8 +- packages/web/src/__tests__/analytics.test.ts | 10 +++ .../api/__tests__/api-edge-coverage.test.ts | 15 +++- packages/web/src/api/onboarding-events.ts | 23 ++++++ .../__tests__/web-dom-interactions.test.tsx | 1 + .../onboarding/__tests__/hooks-flow.test.tsx | 56 +++++++++++++- .../src/pages/onboarding/onboarding-flow.tsx | 74 ++++++++++++++---- .../web/src/pages/settings/onboarding.tsx | 2 + 11 files changed, 257 insertions(+), 24 deletions(-) create mode 100644 packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md diff --git a/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md b/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md new file mode 100644 index 000000000..c99b9f4c6 --- /dev/null +++ b/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md @@ -0,0 +1,76 @@ +--- +id: cloud-onboarding-analytics-funnel +description: Validate production GA delivery and the matching server-side onboarding step trail without leaking internal identifiers to GA. +areas: [cross-surface] +surfaces: [server, web] +--- + +# Cloud Onboarding Analytics Funnel + +## Goal + +Confirm that the production Cloud SPA sends real GA4 requests for route changes +and the small onboarding event vocabulary, while the existing server event +endpoint retains the authenticated funnel context needed for diagnosis. This +case owns the live browser/provider boundary that deterministic tests cannot +prove; event names, visible-step suppression, and parameter filtering remain +product-test responsibilities. + +## Preconditions + +- Use a throwaway production account and a disposable campaign URL. Coordinate + the low-volume production run with the analytics owner so the events can be + isolated in GA4 DebugView or Realtime without treating ordinary traffic as + evidence. +- Use a browser profile with analytics blocking disabled. Preserve redacted + network and server-log evidence; never retain OAuth state, cookies, invite + tokens, internal user IDs, or organization IDs in shared artifacts. +- Have a connected computer/runtime available so the new-user path can reach a + first chat. Also use staging or localhost for the production-gate negative + check; those environments must not contact Google Analytics. + +## Operate + +1. Enter production Cloud from the disposable campaign URL as a new user and + complete one normal onboarding path through the first chat. +2. In a second throwaway path, reach a visible setup step, choose "finish + later", then resume from Settings and continue. +3. Capture the GA script/config exchange and `collect` requests, the matching + GA4 DebugView or Realtime events, and the corresponding structured + `onboarding.*` server log lines. +4. Repeat a route change on staging or localhost and confirm that neither the + GA script nor a `collect` request is emitted. + +## Observe + +- Production emits one sanitized `page_view` per SPA route transition and GA + receives `onboarding_step_viewed`, `onboarding_step_completed`, + `onboarding_step_paused`, and `onboarding_resumed` at the exercised actions. +- Auto-skipped implementation states do not appear as viewed pages. Step events + carry only low-cardinality context such as `step`, `path`, `nextStep`, and + `outcome`; GA requests contain no user, member, organization, agent, or chat + identifiers and no query/hash credentials. +- The server trail contains the same milestones with the authenticated, + server-controlled user identity and the internal diagnostic context that is + intentionally excluded from GA. Correlate adjacent asynchronous posts by + timestamp; their server completion order is not the browser event order. +- The successful path reaches the first-chat completion outcome; pause and + resume remain distinguishable instead of looking like silent abandonment. +- Staging and localhost produce no GA traffic. + +## Expected Result + +`PASS` when the browser and GA provider view agree on the exercised visible +path, the server trail contains its diagnostic counterparts, and there is no +duplicate transition event or identifier leak. +`FAIL` for missing production collection, an incorrect event order, a visible +step omission, a skipped-step false positive, sensitive parameters, or any +staging/local GA traffic. `BLOCKED` when production analytics access, a +throwaway account/runtime, or an unblocked browser profile is unavailable. + +## Evidence + +Keep redacted request names/statuses and parameter keys, GA event timestamps, +the corresponding structured server-event order, and the deployed commit. +Never retain cookies, OAuth material, invite tokens, raw campaign linker values, +or authenticated internal identifiers. diff --git a/packages/server/src/__tests__/me-onboarding.test.ts b/packages/server/src/__tests__/me-onboarding.test.ts index 35b858e98..bfbaf35a4 100644 --- a/packages/server/src/__tests__/me-onboarding.test.ts +++ b/packages/server/src/__tests__/me-onboarding.test.ts @@ -539,10 +539,12 @@ describe("POST /me/onboarding/events", () => { url: "/api/v1/me/onboarding/events", headers: { authorization: `Bearer ${admin.accessToken}` }, payload: { - event: "agent_created", + event: "step_viewed", attrs: { event: "onboarding.fake_forged_event", userId: "attacker-controlled-user-id", + step: "connect-computer", + path: "admin", extra: "legit-attr-keeps-working", }, }, @@ -555,8 +557,10 @@ describe("POST /me/onboarding/events", () => { // Find the funnel line and confirm server-controlled fields stand. const funnelEntry = captured.find((c) => typeof c.event === "string" && String(c.event).startsWith("onboarding.")); expect(funnelEntry).toBeDefined(); - expect(funnelEntry?.event).toBe("onboarding.agent_created"); + expect(funnelEntry?.event).toBe("onboarding.step_viewed"); expect(funnelEntry?.userId).toBe(admin.userId); + expect(funnelEntry?.step).toBe("connect-computer"); + expect(funnelEntry?.path).toBe("admin"); // Non-conflicting attrs keys must still flow through — the schema lets // callers attach arbitrary primitives for funnel context. expect(funnelEntry?.extra).toBe("legit-attr-keeps-working"); diff --git a/packages/shared/src/schemas/me-extras.ts b/packages/shared/src/schemas/me-extras.ts index 685ae815c..d6bb3890c 100644 --- a/packages/shared/src/schemas/me-extras.ts +++ b/packages/shared/src/schemas/me-extras.ts @@ -127,6 +127,10 @@ export type KickoffOnboardingResult = z.infer { if (window.location.hostname !== "cloud.first-tree.ai") return; window.dataLayer = window.dataLayer || []; - window.gtag = (...args) => { - window.dataLayer.push(args); + // Keep the official gtag queue shape. gtag.js consumes the function's + // Arguments object; pushing the rest-parameter Array leaves commands + // queued without producing GA collect requests in production. + window.gtag = function gtag() { + // biome-ignore lint/complexity/noArguments: the official gtag.js queue contract uses Arguments. + window.dataLayer.push(arguments); }; const tag = document.createElement("script"); tag.async = true; diff --git a/packages/web/src/__tests__/analytics.test.ts b/packages/web/src/__tests__/analytics.test.ts index 6828c401c..da1cdb45e 100644 --- a/packages/web/src/__tests__/analytics.test.ts +++ b/packages/web/src/__tests__/analytics.test.ts @@ -1,6 +1,16 @@ +import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { sanitizePath } from "../analytics.js"; +const indexHtml = readFileSync(new URL("../../index.html", import.meta.url), "utf8"); + +describe("production gtag bootstrap", () => { + it("queues the official Arguments object so gtag.js processes commands", () => { + expect(indexHtml).toContain("window.dataLayer.push(arguments)"); + expect(indexHtml).not.toContain("window.dataLayer.push(args)"); + }); +}); + describe("sanitizePath", () => { it("templates invite tokens so the code never reaches GA", () => { expect(sanitizePath("/invite/abc123secret")).toBe("/invite/[token]"); diff --git a/packages/web/src/api/__tests__/api-edge-coverage.test.ts b/packages/web/src/api/__tests__/api-edge-coverage.test.ts index 72994d803..a800c2d6b 100644 --- a/packages/web/src/api/__tests__/api-edge-coverage.test.ts +++ b/packages/web/src/api/__tests__/api-edge-coverage.test.ts @@ -9,6 +9,9 @@ const apiMock = vi.hoisted(() => ({ })); const rawMock = vi.hoisted(() => vi.fn()); +const analyticsMock = vi.hoisted(() => ({ trackEvent: vi.fn() })); + +vi.mock("../../analytics.js", () => analyticsMock); vi.mock("../client.js", async (importOriginal) => { const actual = await importOriginal(); @@ -25,6 +28,7 @@ beforeEach(() => { apiMock.get.mockReset(); apiMock.post.mockReset(); rawMock.mockReset(); + analyticsMock.trackEvent.mockReset(); apiMock.get.mockResolvedValue({}); apiMock.post.mockResolvedValue({}); }); @@ -176,7 +180,13 @@ describe("onboarding and campaign API wrappers", () => { apiMock.get.mockResolvedValueOnce({ needsTreeSetup: true, hasTreeBinding: false, hasTreeSetupKickoff: true }); const onboarding = await import("../onboarding-events.js"); - await expect(onboarding.reportOnboardingEvent("team_renamed", { step: "connect-code" })).resolves.toBeUndefined(); + await expect( + onboarding.reportOnboardingEvent("team_renamed", { + step: "connect-code", + organizationId: "org-1", + chatId: "chat-1", + }), + ).resolves.toBeUndefined(); await expect(onboarding.markOnboardingCompleted("org/id")).resolves.toBeUndefined(); await expect( onboarding.postOnboardingStartChat({ agentUuid: "agent-1", bootstrap: "start", complete: true }), @@ -192,8 +202,9 @@ describe("onboarding and campaign API wrappers", () => { expect(apiMock.post).toHaveBeenNthCalledWith(1, "/me/onboarding/events", { event: "team_renamed", - attrs: { step: "connect-code" }, + attrs: { step: "connect-code", organizationId: "org-1", chatId: "chat-1" }, }); + expect(analyticsMock.trackEvent).toHaveBeenCalledWith("onboarding_team_renamed", { step: "connect-code" }); expect(apiMock.post).toHaveBeenNthCalledWith(2, "/me/onboarding-completed", { organizationId: "org/id" }); expect(apiMock.post).toHaveBeenNthCalledWith(3, "/me/onboarding/kickoff", { agentUuid: "agent-1", diff --git a/packages/web/src/api/onboarding-events.ts b/packages/web/src/api/onboarding-events.ts index e7b5ac985..ec9ab4280 100644 --- a/packages/web/src/api/onboarding-events.ts +++ b/packages/web/src/api/onboarding-events.ts @@ -1,6 +1,25 @@ import type { LandingCampaignActionContext, OnboardingEvent, OnboardingEventName } from "@first-tree/shared"; +import { trackEvent } from "../analytics.js"; import { api } from "./client.js"; +const GA_ONBOARDING_ATTRS = new Set([ + "step", + "path", + "nextStep", + "outcome", + "runtimeProvider", + "treeBindingPlan", + "startChatType", + "joinPath", + "source", +]); + +function analyticsAttrs(attrs: OnboardingEvent["attrs"]): OnboardingEvent["attrs"] { + if (!attrs) return undefined; + const safe = Object.fromEntries(Object.entries(attrs).filter(([key]) => GA_ONBOARDING_ATTRS.has(key))); + return Object.keys(safe).length > 0 ? safe : undefined; +} + export type StartOnboardingChatArgs = { organizationId?: string; agentUuid: string; @@ -35,6 +54,10 @@ export async function reportOnboardingEvent( event: OnboardingEventName, attrs?: OnboardingEvent["attrs"], ): Promise { + // Mirror the same small, validated event vocabulary into GA so campaign + // sessions can be analyzed without maintaining a second client taxonomy. + // Keep high-cardinality internal IDs in the server log only. + trackEvent(`onboarding_${event}`, analyticsAttrs(attrs)); try { await api.post("/me/onboarding/events", { event, attrs }); } catch { diff --git a/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx b/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx index a074e92f8..87043e813 100644 --- a/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx +++ b/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx @@ -1488,6 +1488,7 @@ describe("web DOM interaction coverage", () => { ) ?? null, ); expect(authMock.value.restoreOnboarding).toHaveBeenCalled(); + expect(onboardingEventMocks.reportOnboardingEvent).toHaveBeenCalledWith("resumed", { source: "settings" }); await unmountRoot(dismissed.root); authMock.value = { ...authMock.value, onboardingCompletedAt: "2026-05-02T00:00:00.000Z" }; diff --git a/packages/web/src/pages/onboarding/__tests__/hooks-flow.test.tsx b/packages/web/src/pages/onboarding/__tests__/hooks-flow.test.tsx index 98150d95a..f3afc7b2b 100644 --- a/packages/web/src/pages/onboarding/__tests__/hooks-flow.test.tsx +++ b/packages/web/src/pages/onboarding/__tests__/hooks-flow.test.tsx @@ -350,7 +350,9 @@ describe("onboarding hooks and flow", () => { it("wires onboarding-specific agent side effects in the onboarding provider", async () => { const latest = { current: null as OnboardingFlowValue | null }; - clientMocks.api.post.mockResolvedValueOnce({ uuid: "agent-onboarding" }); + // The create-agent step also enables the computer hook, which can mint a + // connect token through the same mocked API before the agent POST. + clientMocks.api.post.mockResolvedValue({ uuid: "agent-onboarding" }); agentConfigMocks.getAgentClientStatus .mockResolvedValueOnce({ online: false }) .mockResolvedValueOnce({ online: true }); @@ -368,6 +370,8 @@ describe("onboarding hooks and flow", () => { } await renderProbe(); + await act(async () => expectHookValue(latest.current).goTo(2)); + expect(expectHookValue(latest.current).activeStep).toBe("create-agent"); await act(async () => { await expectHookValue(latest.current).createAgent({ displayName: "Onboarding Bot", @@ -379,9 +383,19 @@ describe("onboarding hooks and flow", () => { }); expect(sessionStorage.getItem("onboarding:agentUuid")).toBe("agent-onboarding"); - expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("agent_created", { runtimeProvider: "codex" }); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("agent_created", { + runtimeProvider: "codex", + path: "admin", + organizationId: "org-1", + }); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("step_completed", { + step: "create-agent", + path: "admin", + organizationId: "org-1", + nextStep: "start-chat", + }); expect(authMock.value.refreshMe).toHaveBeenCalled(); - expect(expectHookValue(latest.current).activeStep).toBe("connect-computer"); + expect(expectHookValue(latest.current).activeStep).toBe("start-chat"); }); it("persists flow progress and navigates on finish actions", async () => { @@ -401,19 +415,48 @@ describe("onboarding hooks and flow", () => { const host = await renderProbe(); expect(host.textContent).toContain("team"); - await act(async () => expectHookValue(latest.current).goTo(1)); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("step_viewed", { + step: "create-team", + path: "admin", + organizationId: "org-1", + }); + + await act(async () => expectHookValue(latest.current).goNext()); expect(expectHookValue(latest.current).activeStep).toBe("connect-computer"); expect(sessionStorage.getItem("onboarding:v2:stepIndex:admin:org-1")).toBe("1"); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("step_completed", { + step: "create-team", + path: "admin", + organizationId: "org-1", + nextStep: "connect-computer", + }); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("step_viewed", { + step: "connect-computer", + path: "admin", + organizationId: "org-1", + }); await act(async () => expectHookValue(latest.current).finishLater()); expect(authMock.value.dismissOnboarding).toHaveBeenCalled(); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("step_paused", { + step: "connect-computer", + path: "admin", + organizationId: "org-1", + }); + await act(async () => expectHookValue(latest.current).goTo(3)); await act(async () => expectHookValue(latest.current).completeAndEnterChat("chat 1")); // Completing must stamp completed WITHOUT dismissing: the account-level // dismissal would short-circuit shouldEnterOnboarding's org-level gate // forever, so only the explicit finishLater above may have called it. expect(authMock.value.dismissOnboarding).toHaveBeenCalledTimes(1); expect(authMock.value.markOnboardingCompleted).toHaveBeenCalled(); + expect(eventMocks.reportOnboardingEvent).toHaveBeenCalledWith("step_completed", { + step: "start-chat", + path: "admin", + organizationId: "org-1", + outcome: "chat_started", + }); expect(sessionStorage.getItem("onboarding:v2:stepIndex:admin:org-1")).toBeNull(); }); @@ -574,5 +617,10 @@ describe("onboarding hooks and flow", () => { await rerender(); expect(expectHookValue(latest.current).activeStep).toBe("create-team"); expect(sessionStorage.getItem("onboarding:v2:stepIndex:admin:org-B")).not.toBe("3"); + expect(eventMocks.reportOnboardingEvent).toHaveBeenLastCalledWith("step_viewed", { + step: "create-team", + path: "admin", + organizationId: "org-B", + }); }); }); diff --git a/packages/web/src/pages/onboarding/onboarding-flow.tsx b/packages/web/src/pages/onboarding/onboarding-flow.tsx index 936630fb7..155b04c70 100644 --- a/packages/web/src/pages/onboarding/onboarding-flow.tsx +++ b/packages/web/src/pages/onboarding/onboarding-flow.tsx @@ -230,10 +230,46 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat writePersistedStep(path, organizationId, activeIndex); }, [path, organizationId, activeIndex]); - const goTo = useCallback((index: number) => setActiveIndex(clampStepIndex(path, index)), [path]); - const goNext = useCallback(() => setActiveIndex((i) => clampStepIndex(path, i + 1)), [path]); - const activeStep = sequence[clampStepIndex(path, activeIndex)] as StepId; + const offerTeamAgentStart = canOfferTeamAgentStart({ currentOrgHasUsableAgent, currentOrgHasPersonalAgent }); + // Two steps can mount only long enough to redirect themselves. Do not count + // those implementation-only states as pages the user actually saw. + const activeStepIsVisible = + !(activeStep === "get-started" && !offerTeamAgentStart) && + !(activeStep === "create-agent" && currentOrgHasPersonalAgent); + + const reportStepEvent = useCallback( + ( + event: "step_viewed" | "step_completed" | "step_paused", + step: StepId, + attrs: Record = {}, + ): void => { + void reportOnboardingEvent(event, { + ...attrs, + step, + path, + organizationId: organizationId ?? null, + }); + }, + [organizationId, path], + ); + + const lastViewedStepRef = useRef(null); + useEffect(() => { + const viewKey = `${organizationId ?? "none"}:${path}:${activeStep}`; + if (!activeStepIsVisible || lastViewedStepRef.current === viewKey) return; + lastViewedStepRef.current = viewKey; + reportStepEvent("step_viewed", activeStep); + }, [activeStep, activeStepIsVisible, organizationId, path, reportStepEvent]); + + const goTo = useCallback((index: number) => setActiveIndex(clampStepIndex(path, index)), [path]); + const goNext = useCallback(() => { + const nextIndex = clampStepIndex(path, activeIndex + 1); + if (activeStepIsVisible && nextIndex !== activeIndex) { + reportStepEvent("step_completed", activeStep, { nextStep: sequence[nextIndex] as StepId }); + } + setActiveIndex(nextIndex); + }, [activeIndex, activeStep, activeStepIsVisible, path, reportStepEvent, sequence]); // The computer poll only needs to run on the two steps that depend on it. const computerEnabled = activeStep === "connect-computer" || activeStep === "create-agent"; @@ -241,12 +277,20 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat const onAgentOnline = useCallback(() => { void refreshMe(); + reportStepEvent("step_completed", "create-agent", { nextStep: "start-chat" }); setActiveIndex((i) => clampStepIndex(path, i + 1)); - }, [refreshMe, path]); - const onAgentCreated = useCallback((info: CreatedAgentInfo) => { - writeOnboardingAgentUuid(info.agentUuid); - void reportOnboardingEvent("agent_created", { runtimeProvider: info.args.runtimeProvider }); - }, []); + }, [path, refreshMe, reportStepEvent]); + const onAgentCreated = useCallback( + (info: CreatedAgentInfo) => { + writeOnboardingAgentUuid(info.agentUuid); + void reportOnboardingEvent("agent_created", { + runtimeProvider: info.args.runtimeProvider, + path, + organizationId: organizationId ?? null, + }); + }, + [organizationId, path], + ); const { phase: agentPhase, error: agentError, @@ -332,9 +376,10 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat // keep the always-navigate invariant local to this flow rather than // depending on a callee's error handling. } + reportStepEvent("step_completed", "start-chat", { outcome: "chat_started" }); navigate(`/?c=${encodeURIComponent(chatId)}`); }, - [path, organizationId, markOnboardingCompleted, navigate], + [path, organizationId, markOnboardingCompleted, navigate, reportStepEvent], ); const skipAndEnterChat = useCallback( @@ -350,15 +395,17 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat // workspace gate reads it, and navigating with stale state would bounce // the user straight back into onboarding. await refreshMe(); + reportStepEvent("step_completed", "get-started", { outcome: "team_agent_quick_start" }); navigate(`/?c=${encodeURIComponent(chatId)}`); }, - [path, organizationId, refreshMe, navigate], + [path, organizationId, refreshMe, navigate, reportStepEvent], ); const finishLater = useCallback(async () => { + reportStepEvent("step_paused", activeStep); await dismissOnboarding(); navigate("/"); - }, [dismissOnboarding, navigate]); + }, [activeStep, dismissOnboarding, navigate, reportStepEvent]); const value = useMemo( () => ({ @@ -385,7 +432,7 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat retryAgent, createdAgentUuid, hasAgent: orgStep === "completed" || createdAgentUuid !== null, - offerTeamAgentStart: canOfferTeamAgentStart({ currentOrgHasUsableAgent, currentOrgHasPersonalAgent }), + offerTeamAgentStart, selectedRepoUrls, setSelectedRepoUrls, hasRepoDraft, @@ -421,8 +468,7 @@ export function OnboardingFlowProvider({ path, children }: { path: OnboardingPat retryAgent, createdAgentUuid, orgStep, - currentOrgHasUsableAgent, - currentOrgHasPersonalAgent, + offerTeamAgentStart, selectedRepoUrls, setSelectedRepoUrls, hasRepoDraft, diff --git a/packages/web/src/pages/settings/onboarding.tsx b/packages/web/src/pages/settings/onboarding.tsx index 6764fef72..0c1e952fb 100644 --- a/packages/web/src/pages/settings/onboarding.tsx +++ b/packages/web/src/pages/settings/onboarding.tsx @@ -1,5 +1,6 @@ import { Check } from "lucide-react"; import { Navigate, useNavigate } from "react-router"; +import { reportOnboardingEvent } from "../../api/onboarding-events.js"; import { useAuth } from "../../auth/auth-context.js"; import { Button } from "../../components/ui/button.js"; import { Section } from "../../components/ui/section.js"; @@ -34,6 +35,7 @@ export function SettingsOnboardingPage() { const handleResume = async (): Promise => { await restoreOnboarding(); + void reportOnboardingEvent("resumed", { source: "settings" }); navigate("/onboarding"); }; From 4c024089b0290de8de45ebc5afabf6f860defc8f Mon Sep 17 00:00:00 2001 From: Gandy2025 Date: Fri, 17 Jul 2026 18:44:53 +0800 Subject: [PATCH 3/4] fix: complete growth funnel observability --- .../cloud-onboarding-analytics-funnel.md | 72 +++++-- .../server/src/__tests__/oauth-flow.test.ts | 9 +- packages/server/src/api/auth/github.ts | 12 +- packages/server/src/api/auth/google.ts | 1 + .../src/__tests__/me-extras-schema.test.ts | 17 +- packages/shared/src/schemas/me-extras.ts | 2 + .../api/__tests__/api-edge-coverage.test.ts | 25 ++- packages/web/src/api/onboarding-events.ts | 23 +++ .../src/auth/__tests__/auth-analytics.test.ts | 101 ++++++++++ .../__tests__/auth-context-provider.test.tsx | 57 ------ .../auth/__tests__/require-auth-scan.test.tsx | 18 +- packages/web/src/auth/auth-analytics.ts | 175 ++++++++++++++++++ packages/web/src/auth/auth-context.tsx | 14 -- packages/web/src/auth/require-auth.tsx | 7 +- .../__tests__/setup-hooks.test.tsx | 4 +- .../agent-setup/use-agent-creation.ts | 14 +- .../agent-setup/use-computer-connection.ts | 13 +- .../pages/__tests__/page-ssr-smoke.test.tsx | 1 + .../__tests__/web-dom-interactions.test.tsx | 12 ++ packages/web/src/pages/invite-accept.tsx | 8 +- packages/web/src/pages/login.tsx | 5 +- packages/web/src/pages/oauth-complete.tsx | 72 ++++++- packages/web/src/pages/onboarding-preview.tsx | 1 + .../onboarding/__tests__/hooks-flow.test.tsx | 18 +- .../__tests__/shell-rail-team-dom.test.tsx | 2 + .../src/pages/onboarding/onboarding-flow.tsx | 48 ++++- .../step-connect-computer-dom.test.tsx | 1 + .../__tests__/step-get-started-dom.test.tsx | 1 + .../onboarding/steps/step-connect-code.tsx | 24 ++- .../onboarding/steps/step-get-started.tsx | 9 +- .../onboarding/steps/step-start-chat.tsx | 13 +- .../src/pages/onboarding/steps/step-team.tsx | 8 +- 32 files changed, 652 insertions(+), 135 deletions(-) create mode 100644 packages/web/src/auth/__tests__/auth-analytics.test.ts create mode 100644 packages/web/src/auth/auth-analytics.ts diff --git a/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md b/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md index c99b9f4c6..6468af626 100644 --- a/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md +++ b/packages/qa/cases/cross-surface/cloud-onboarding-analytics-funnel.md @@ -1,6 +1,6 @@ --- id: cloud-onboarding-analytics-funnel -description: Validate production GA delivery and the matching server-side onboarding step trail without leaking internal identifiers to GA. +description: Validate campaign attribution, OAuth continuity, production GA delivery, and the matching server-side onboarding trail without leaking internal identifiers to GA. areas: [cross-surface] surfaces: [server, web] --- @@ -9,12 +9,13 @@ surfaces: [server, web] ## Goal -Confirm that the production Cloud SPA sends real GA4 requests for route changes -and the small onboarding event vocabulary, while the existing server event -endpoint retains the authenticated funnel context needed for diagnosis. This -case owns the live browser/provider boundary that deterministic tests cannot -prove; event names, visible-step suppression, and parameter filtering remain -product-test responsibilities. +Confirm that one tagged acquisition remains observable through the website, +OAuth, Cloud onboarding, and first chat. The production Cloud SPA must send real +GA4 requests for route changes, anonymous auth attempts/results, and the small +onboarding event vocabulary, while the server retains the authenticated funnel +context needed for diagnosis. This case owns the live browser/provider boundary +that deterministic tests cannot prove; event names, visible-step suppression, +and parameter filtering remain product-test responsibilities. ## Preconditions @@ -28,41 +29,72 @@ product-test responsibilities. - Have a connected computer/runtime available so the new-user path can reach a first chat. Also use staging or localhost for the production-gate negative check; those environments must not contact Google Analytics. +- In GA4 property `539170414`, register the event-scoped custom dimensions used + for reporting before the run: `form_id`, `scan_attempt_id`, `variant`, + `auth_attempt_id`, `provider`, `result`, `entry_point`, `join_path`, + `account_type`, `reason_code`, `step`, `path`, `nextStep`, `reasonCode`, + `retryable`, and `outcome`. Mark `start_scan`, `sign_up`, and + `onboarding_kickoff_chat_started` as key events. Confirm downstream + campaign-export readers accept schema version 2. Standard GA traffic-source + dimensions own UTM attribution; do not duplicate them as custom dimensions. ## Operate -1. Enter production Cloud from the disposable campaign URL as a new user and - complete one normal onboarding path through the first chat. -2. In a second throwaway path, reach a visible setup step, choose "finish +1. In a clean profile, enter an ordinary tagged production website URL and use + the primary Cloud CTA. Confirm the decorated `_gl` handoff is consumed and + the Cloud GA client/session retains the original UTM source, medium, + campaign, and content through OAuth and onboarding. +2. In a second clean profile, enter the tagged production-scan page, submit the + campaign form, continue through OAuth, and complete Cloud onboarding through + the first chat. Keep only the anonymous attempt IDs needed for the + reconciliation. +3. Exercise one controlled OAuth failure (for example cancel/expired state), + then retry successfully. Confirm the retry creates a new auth attempt. +4. In a separate throwaway path, reach a visible setup step, choose "finish later", then resume from Settings and continue. -3. Capture the GA script/config exchange and `collect` requests, the matching +5. Exercise one safe, user-visible onboarding failure and retry, such as a + connect-token mint failure in a controlled environment or a runtime that + does not become ready before the timeout. +6. Capture the GA script/config exchange and `collect` requests, the matching GA4 DebugView or Realtime events, and the corresponding structured - `onboarding.*` server log lines. -4. Repeat a route change on staging or localhost and confirm that neither the + `oauth.*` / `onboarding.*` server log lines. Reconcile the campaign attempt + with the schema-v2 Campaign export and its action-conversion fields. +7. Repeat a route change on staging or localhost and confirm that neither the GA script nor a `collect` request is emitted. ## Observe +- The pre-OAuth `auth_started` and post-OAuth `auth_result` carry the same + anonymous `auth_attempt_id`, provider, and low-cardinality entry point. The + failed attempt has a fixed `reason_code`; the successful new-account result + has `account_type=created` and emits `sign_up` exactly once at account + creation, not again when onboarding completes. - Production emits one sanitized `page_view` per SPA route transition and GA receives `onboarding_step_viewed`, `onboarding_step_completed`, - `onboarding_step_paused`, and `onboarding_resumed` at the exercised actions. + `onboarding_step_failed`, `onboarding_step_paused`, and `onboarding_resumed` + at the exercised actions. - Auto-skipped implementation states do not appear as viewed pages. Step events - carry only low-cardinality context such as `step`, `path`, `nextStep`, and - `outcome`; GA requests contain no user, member, organization, agent, or chat - identifiers and no query/hash credentials. + carry only low-cardinality context such as `step`, `path`, `nextStep`, + `outcome`, `reasonCode`, and `retryable`; GA requests contain no user, member, + organization, agent, chat, repo, raw error, URL query, or hash credentials. - The server trail contains the same milestones with the authenticated, server-controlled user identity and the internal diagnostic context that is intentionally excluded from GA. Correlate adjacent asynchronous posts by timestamp; their server completion order is not the browser event order. - The successful path reaches the first-chat completion outcome; pause and resume remain distinguishable instead of looking like silent abandonment. +- The ordinary CTA path remains one GA user/session with its original standard + traffic-source dimensions before and after the cross-domain and OAuth hops. +- The Website scan attempt, Cloud auth attempt/result, first chat, Campaign + export row, and action conversion reconcile without relying on a raw user ID. - Staging and localhost produce no GA traffic. ## Expected Result -`PASS` when the browser and GA provider view agree on the exercised visible -path, the server trail contains its diagnostic counterparts, and there is no -duplicate transition event or identifier leak. +`PASS` when the browser and GA provider view agree on acquisition, auth, the +exercised visible path, and classified failure; the server/export trail contains +its diagnostic counterparts; registered dimensions are queryable; and there is +no duplicate transition event or identifier leak. `FAIL` for missing production collection, an incorrect event order, a visible step omission, a skipped-step false positive, sensitive parameters, or any staging/local GA traffic. `BLOCKED` when production analytics access, a diff --git a/packages/server/src/__tests__/oauth-flow.test.ts b/packages/server/src/__tests__/oauth-flow.test.ts index 708d4ff9b..1623f660c 100644 --- a/packages/server/src/__tests__/oauth-flow.test.ts +++ b/packages/server/src/__tests__/oauth-flow.test.ts @@ -103,6 +103,7 @@ describe("GitHub OAuth onboarding flow", () => { const params = new URLSearchParams(fragment); expect(params.get("next")).toBe("/"); expect(params.get("joinPath")).toBe("solo"); + expect(params.get("accountCreated")).toBe("1"); // Auth identity is recorded. const ids = await app.db.select().from(authIdentities).where(eq(authIdentities.identifier, "42")); @@ -228,14 +229,18 @@ describe("GitHub OAuth onboarding flow", () => { it("second sign-in for same github id reuses the user", async () => { const app = getApp(); - await app.inject({ + const first = await app.inject({ method: "GET", url: "/api/v1/auth/github/dev-callback?githubId=99&login=alice", }); - await app.inject({ + const firstParams = new URLSearchParams(first.headers.location?.split("#")[1] ?? ""); + expect(firstParams.get("accountCreated")).toBe("1"); + const second = await app.inject({ method: "GET", url: "/api/v1/auth/github/dev-callback?githubId=99&login=alice", }); + const secondParams = new URLSearchParams(second.headers.location?.split("#")[1] ?? ""); + expect(secondParams.get("accountCreated")).toBe("0"); const ids = await app.db.select().from(authIdentities).where(eq(authIdentities.identifier, "99")); expect(ids).toHaveLength(1); }); diff --git a/packages/server/src/api/auth/github.ts b/packages/server/src/api/auth/github.ts index 89e67d06b..b152f9331 100644 --- a/packages/server/src/api/auth/github.ts +++ b/packages/server/src/api/auth/github.ts @@ -100,6 +100,7 @@ export async function githubOauthRoutes(app: FastifyInstance): Promise { ); const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`; + app.log.info({ event: "oauth.start", provider: "github", intent: "sign-in" }, "OAuth flow started"); // App flow: scope/permissions are declared on the App's GitHub-side // settings page (D0b), so we don't pass them here. The user lands on // the combined OAuth + install dialog (first-time installer) or just @@ -221,7 +222,7 @@ export async function githubOauthRoutes(app: FastifyInstance): Promise { refreshTokenExpiresAt: result.refreshTokenExpiresAt, }; } catch (err) { - app.log.warn({ err }, "github sign-in code exchange failed"); + app.log.warn({ err, event: "oauth.exchange_failed", provider: "github" }, "GitHub OAuth exchange failed"); return redirectCallbackError(reply, "github-exchange-failed", next); } @@ -648,10 +649,19 @@ async function completeOauthFlow( refresh: tokens.refreshToken, next, joinPath, + accountCreated: account.created ? "1" : "0", }; if (resolvedOrganizationId) fragmentParams.org = resolvedOrganizationId; if (orgPinned) fragmentParams.orgPinned = "1"; const fragment = new URLSearchParams(fragmentParams).toString(); + app.log.info( + { + event: account.created ? "oauth.account_created" : "oauth.account_reused", + provider: "github", + userId, + }, + "OAuth sign-in completed", + ); return reply.redirect(`/auth/github/complete#${fragment}`, 302); } diff --git a/packages/server/src/api/auth/google.ts b/packages/server/src/api/auth/google.ts index db63ce261..c7dd944eb 100644 --- a/packages/server/src/api/auth/google.ts +++ b/packages/server/src/api/auth/google.ts @@ -176,6 +176,7 @@ async function completeGoogleSignIn( refresh: tokens.refreshToken, next: bootstrap.next, joinPath: bootstrap.joinPath, + accountCreated: account.created ? "1" : "0", org: bootstrap.organizationId, ...(bootstrap.orgPinned ? { orgPinned: "1" } : {}), }).toString(); diff --git a/packages/shared/src/__tests__/me-extras-schema.test.ts b/packages/shared/src/__tests__/me-extras-schema.test.ts index 3e83aeed8..5c6826d52 100644 --- a/packages/shared/src/__tests__/me-extras-schema.test.ts +++ b/packages/shared/src/__tests__/me-extras-schema.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { kickoffOnboardingSchema, treeSetupKickoffSchema } from "../schemas/me-extras.js"; +import { kickoffOnboardingSchema, onboardingEventSchema, treeSetupKickoffSchema } from "../schemas/me-extras.js"; describe("kickoffOnboardingSchema", () => { it("accepts a natural onboarding kickoff without an internal kind discriminator", () => { @@ -75,6 +75,21 @@ describe("kickoffOnboardingSchema", () => { }); }); +describe("onboardingEventSchema", () => { + it("accepts a classified step failure and rejects arbitrary event names", () => { + expect( + onboardingEventSchema.parse({ + event: "step_failed", + attrs: { step: "create-agent", reasonCode: "agent_create_failed", retryable: true }, + }), + ).toEqual({ + event: "step_failed", + attrs: { step: "create-agent", reasonCode: "agent_create_failed", retryable: true }, + }); + expect(onboardingEventSchema.safeParse({ event: "raw_exception" }).success).toBe(false); + }); +}); + describe("treeSetupKickoffSchema", () => { it("accepts only the selected agent for the dedicated setup intent", () => { const parsed = treeSetupKickoffSchema.parse({ agentUuid: "agent-1" }); diff --git a/packages/shared/src/schemas/me-extras.ts b/packages/shared/src/schemas/me-extras.ts index d6bb3890c..b3917474a 100644 --- a/packages/shared/src/schemas/me-extras.ts +++ b/packages/shared/src/schemas/me-extras.ts @@ -129,6 +129,7 @@ export type KickoffOnboardingResult = z.infer { const onboarding = await import("../onboarding-events.js"); await expect( - onboarding.reportOnboardingEvent("team_renamed", { - step: "connect-code", + onboarding.reportOnboardingEvent("step_failed", { + step: "create-agent", + path: "admin", + reasonCode: "agent_create_failed", + retryable: true, organizationId: "org-1", chatId: "chat-1", }), @@ -201,10 +204,22 @@ describe("onboarding and campaign API wrappers", () => { }); expect(apiMock.post).toHaveBeenNthCalledWith(1, "/me/onboarding/events", { - event: "team_renamed", - attrs: { step: "connect-code", organizationId: "org-1", chatId: "chat-1" }, + event: "step_failed", + attrs: { + step: "create-agent", + path: "admin", + reasonCode: "agent_create_failed", + retryable: true, + organizationId: "org-1", + chatId: "chat-1", + }, + }); + expect(analyticsMock.trackEvent).toHaveBeenCalledWith("onboarding_step_failed", { + step: "create-agent", + path: "admin", + reasonCode: "agent_create_failed", + retryable: true, }); - expect(analyticsMock.trackEvent).toHaveBeenCalledWith("onboarding_team_renamed", { step: "connect-code" }); expect(apiMock.post).toHaveBeenNthCalledWith(2, "/me/onboarding-completed", { organizationId: "org/id" }); expect(apiMock.post).toHaveBeenNthCalledWith(3, "/me/onboarding/kickoff", { agentUuid: "agent-1", diff --git a/packages/web/src/api/onboarding-events.ts b/packages/web/src/api/onboarding-events.ts index ec9ab4280..b593374c4 100644 --- a/packages/web/src/api/onboarding-events.ts +++ b/packages/web/src/api/onboarding-events.ts @@ -7,6 +7,8 @@ const GA_ONBOARDING_ATTRS = new Set([ "path", "nextStep", "outcome", + "reasonCode", + "retryable", "runtimeProvider", "treeBindingPlan", "startChatType", @@ -14,6 +16,27 @@ const GA_ONBOARDING_ATTRS = new Set([ "source", ]); +/** + * Stable, low-cardinality causes for user-visible onboarding failures. Keep + * raw exception text, URLs, repo names, and internal IDs out of this list: the + * same value is mirrored to GA and must stay safe to aggregate. + */ +export type OnboardingFailureReason = + | "team_load_failed" + | "team_rename_failed" + | "connect_token_mint_failed" + | "runtime_unavailable" + | "agent_create_failed" + | "agent_runtime_timeout" + | "github_install_not_configured" + | "github_install_forbidden" + | "github_install_url_failed" + | "github_repo_list_failed" + | "team_agent_list_failed" + | "repo_access_check_failed" + | "repo_resource_sync_failed" + | "start_chat_failed"; + function analyticsAttrs(attrs: OnboardingEvent["attrs"]): OnboardingEvent["attrs"] { if (!attrs) return undefined; const safe = Object.fromEntries(Object.entries(attrs).filter(([key]) => GA_ONBOARDING_ATTRS.has(key))); diff --git a/packages/web/src/auth/__tests__/auth-analytics.test.ts b/packages/web/src/auth/__tests__/auth-analytics.test.ts new file mode 100644 index 000000000..0e0f15104 --- /dev/null +++ b/packages/web/src/auth/__tests__/auth-analytics.test.ts @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const analyticsMocks = vi.hoisted(() => ({ trackEvent: vi.fn() })); + +vi.mock("../../analytics.js", () => analyticsMocks); + +import { + authEntryPoint, + authProviderForCallbackPath, + beginAuthAttempt, + finishAuthAttempt, + normalizeAuthFailureReason, + normalizeAuthJoinPath, +} from "../auth-analytics.js"; + +beforeEach(() => { + vi.clearAllMocks(); + window.sessionStorage.clear(); +}); + +describe("auth analytics", () => { + it("joins OAuth start and account creation without storing redirect details", () => { + const scanAttemptId = "123e4567-e89b-42d3-a456-426614174000"; + const attemptId = beginAuthAttempt( + "github", + `/quickstart?campaign=production-scan&repo=secret&attempt=${scanAttemptId}&variant=control`, + ); + + expect(attemptId).toEqual(expect.any(String)); + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("auth_started", { + auth_attempt_id: attemptId, + provider: "github", + entry_point: "campaign", + scan_attempt_id: scanAttemptId, + variant: "control", + }); + const stored = window.sessionStorage.getItem("first-tree:auth-attempt") ?? ""; + expect(stored).not.toContain("repo=secret"); + + finishAuthAttempt({ + provider: "github", + result: "success", + next: `/quickstart?campaign=production-scan&repo=secret&attempt=${scanAttemptId}&variant=control`, + joinPath: "solo", + accountCreated: true, + }); + + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("auth_result", { + auth_attempt_id: attemptId, + provider: "github", + result: "success", + entry_point: "campaign", + join_path: "solo", + account_type: "created", + scan_attempt_id: scanAttemptId, + variant: "control", + }); + expect(analyticsMocks.trackEvent).toHaveBeenCalledWith("sign_up", { + auth_attempt_id: attemptId, + method: "github", + entry_point: "campaign", + scan_attempt_id: scanAttemptId, + variant: "control", + }); + expect(window.sessionStorage.getItem("first-tree:auth-attempt")).toBeNull(); + }); + + it("keeps callback failures low-cardinality and does not emit sign_up", () => { + beginAuthAttempt("google", "/invite/private-token"); + finishAuthAttempt({ + provider: "google", + result: "failed", + next: "/invite/private-token", + joinPath: normalizeAuthJoinPath("unexpected"), + reasonCode: normalizeAuthFailureReason("attacker-controlled-value"), + accountCreated: null, + }); + + expect(analyticsMocks.trackEvent).toHaveBeenLastCalledWith( + "auth_result", + expect.objectContaining({ + provider: "google", + result: "failed", + entry_point: "invite", + join_path: "unknown", + account_type: "unknown", + reason_code: "unknown", + }), + ); + expect(analyticsMocks.trackEvent.mock.calls.some(([name]) => name === "sign_up")).toBe(false); + }); + + it("classifies callback paths and safe entry points", () => { + expect(authProviderForCallbackPath("/auth/complete")).toBe("google"); + expect(authProviderForCallbackPath("/auth/github/complete")).toBe("github"); + expect(authEntryPoint("/")).toBe("login"); + expect(authEntryPoint("/settings/github")).toBe("deep_link"); + }); +}); diff --git a/packages/web/src/auth/__tests__/auth-context-provider.test.tsx b/packages/web/src/auth/__tests__/auth-context-provider.test.tsx index 9eb9d5d3a..51445a339 100644 --- a/packages/web/src/auth/__tests__/auth-context-provider.test.tsx +++ b/packages/web/src/auth/__tests__/auth-context-provider.test.tsx @@ -19,7 +19,6 @@ const apiMocks = vi.hoisted(() => ({ const loginMock = vi.hoisted(() => vi.fn()); const onboardingCompletedMock = vi.hoisted(() => vi.fn()); -const trackEventMock = vi.hoisted(() => vi.fn()); const flagsMocks = vi.hoisted(() => ({ clearOnboardingJoinPath: vi.fn(), clearOnboardingSessionFlags: vi.fn(), @@ -45,10 +44,6 @@ vi.mock("../../api/onboarding-events.js", () => ({ markOnboardingCompleted: onboardingCompletedMock, })); -vi.mock("../../analytics.js", () => ({ - trackEvent: trackEventMock, -})); - vi.mock("../../utils/onboarding-flags.js", () => flagsMocks); let root: Root | null = null; @@ -387,58 +382,6 @@ describe("AuthProvider", () => { expect(latestAuth?.onboardingCompletedAt).toBeTruthy(); }); - it("fires the sign_up conversion exactly once when a fresh user first completes onboarding", async () => { - // Authenticated fresh signup: stored tokens present so fetchMe runs and - // populates memberships, and /me reports onboarding not yet completed in - // any membership. - apiMocks.getStoredTokens.mockReturnValue({ accessToken: "a", refreshToken: "r" }); - apiMocks.apiGet.mockResolvedValue({ - user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, - memberships: MEMBERSHIPS, // both fixtures carry onboardingCompletedAt: null - defaultOrganizationId: "org-1", - onboarding: { step: "create_agent", dismissedAt: null, completedAt: null }, - }); - await renderAuth(); - - await act(async () => { - await latestAuth?.markOnboardingCompleted(); - }); - expect(trackEventMock).toHaveBeenCalledWith("sign_up"); - expect(trackEventMock.mock.calls.filter(([name]) => name === "sign_up")).toHaveLength(1); - - // A second completion (e.g. a stray re-trigger) does not re-fire: the - // optimistic patch has now stamped the membership, so the account-level - // "never completed anywhere" guard is false. - trackEventMock.mockClear(); - await act(async () => { - await latestAuth?.markOnboardingCompleted(); - }); - expect(trackEventMock).not.toHaveBeenCalledWith("sign_up"); - }); - - it("does NOT fire sign_up when a returning user completes onboarding in a second org", async () => { - // Account-level conversion: this user already finished onboarding in org-1 - // (membership stamp set), and is now completing in a freshly-joined org-2. - // Joining a second team is not a new signup, so sign_up must not re-fire. - apiMocks.getStoredTokens.mockReturnValue({ accessToken: "a", refreshToken: "r" }); - apiMocks.apiGet.mockResolvedValue({ - user: { id: "user-1", username: "gandy", displayName: "Gandy", avatarUrl: null }, - memberships: [ - { ...MEMBERSHIPS[0], onboardingCompletedAt: "2026-05-01T00:00:00.000Z" }, - { ...MEMBERSHIPS[1], onboardingCompletedAt: null }, - ], - defaultOrganizationId: "org-2", - onboarding: { step: "create_agent", dismissedAt: null, completedAt: null }, - }); - await renderAuth(); - - await act(async () => { - await latestAuth?.markOnboardingCompleted(); - }); - expect(onboardingCompletedMock).toHaveBeenCalled(); - expect(trackEventMock).not.toHaveBeenCalledWith("sign_up"); - }); - it("adopts external token pairs and falls back when /me fails", async () => { apiMocks.apiGet.mockRejectedValueOnce(new Error("offline")); await renderAuth(); diff --git a/packages/web/src/auth/__tests__/require-auth-scan.test.tsx b/packages/web/src/auth/__tests__/require-auth-scan.test.tsx index 6dc5c2422..e969ed083 100644 --- a/packages/web/src/auth/__tests__/require-auth-scan.test.tsx +++ b/packages/web/src/auth/__tests__/require-auth-scan.test.tsx @@ -15,14 +15,19 @@ const authMock = vi.hoisted(() => ({ vi.mock("../auth-context.js", () => ({ useAuth: () => authMock.value })); vi.mock("../../pages/landing/index.js", () => ({ LandingPage: () =>
Landing content
})); -const SCAN_URL = "/quickstart?campaign=production-scan&repo=https%3A%2F%2Fgithub.com%2Facme%2Fbackend"; +const SCAN_ATTEMPT_ID = "123e4567-e89b-42d3-a456-426614174000"; +const SCAN_URL = + "/quickstart?campaign=production-scan&repo=https%3A%2F%2Fgithub.com%2Facme%2Fbackend" + + `&attempt=${SCAN_ATTEMPT_ID}&variant=control`; describe("scanCampaignOAuthNext", () => { it("returns the quickstart URL for a known campaign handoff", () => { expect( scanCampaignOAuthNext({ pathname: "/quickstart", - search: "?campaign=production-scan&repo=https%3A%2F%2Fgithub.com%2Facme%2Fbackend", + search: + "?campaign=production-scan&repo=https%3A%2F%2Fgithub.com%2Facme%2Fbackend" + + `&attempt=${SCAN_ATTEMPT_ID}&variant=control`, }), ).toBe(SCAN_URL); }); @@ -46,6 +51,7 @@ describe("RequireAuth — scan funnel login handoff", () => { beforeEach(() => { authMock.value = { isAuthenticated: false, meLoaded: false }; + window.sessionStorage.clear(); replaceSpy = vi.spyOn(window.location, "replace").mockImplementation(() => undefined); }); @@ -83,6 +89,14 @@ describe("RequireAuth — scan funnel login handoff", () => { const container = await renderRoute(SCAN_URL); expect(replaceSpy).toHaveBeenCalledWith(`/api/v1/auth/github/start?next=${encodeURIComponent(SCAN_URL)}`); expect(container.textContent).not.toContain("Login page"); + const stored = window.sessionStorage.getItem("first-tree:auth-attempt") ?? ""; + expect(JSON.parse(stored)).toMatchObject({ + provider: "github", + entryPoint: "campaign", + scanAttemptId: SCAN_ATTEMPT_ID, + variant: "control", + }); + expect(stored).not.toContain("repo"); }); it("keeps a normal unauthenticated deep link on the /login interstitial", async () => { diff --git a/packages/web/src/auth/auth-analytics.ts b/packages/web/src/auth/auth-analytics.ts new file mode 100644 index 000000000..cd3f35e9e --- /dev/null +++ b/packages/web/src/auth/auth-analytics.ts @@ -0,0 +1,175 @@ +import { trackEvent } from "../analytics.js"; + +const AUTH_ATTEMPT_KEY = "first-tree:auth-attempt"; + +export type AuthProvider = "google" | "github"; +export type AuthEntryPoint = "login" | "deep_link" | "invite" | "campaign"; +export type AuthJoinPath = "solo" | "invite" | "returning" | "unknown"; +export type AuthFailureReason = + | "state-expired" + | "provider-not-configured" + | "provider-exchange-failed" + | "identity-conflict" + | "identity-mismatch" + | "last-provider" + | "github-exchange-failed" + | "install-not-admin" + | "install-not-verified" + | "install-bind-failed" + | "invite-invalid" + | "invite-not-allowed" + | "invite-required" + | "membership-unresolved" + | "missing_tokens" + | "session_bootstrap_failed" + | "unknown"; + +type StoredAuthAttempt = { + id: string; + provider: AuthProvider; + entryPoint: AuthEntryPoint; + scanAttemptId?: string; + variant?: string; +}; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const VARIANT_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u; + +const CALLBACK_FAILURE_REASONS = new Set([ + "state-expired", + "provider-not-configured", + "provider-exchange-failed", + "identity-conflict", + "identity-mismatch", + "last-provider", + "github-exchange-failed", + "install-not-admin", + "install-not-verified", + "install-bind-failed", + "invite-invalid", + "invite-not-allowed", + "invite-required", + "membership-unresolved", +]); + +/** Collapse redirect targets into a small acquisition-safe taxonomy. */ +export function authEntryPoint(next: string): AuthEntryPoint { + if (next.startsWith("/quickstart")) return "campaign"; + if (next.startsWith("/invite/")) return "invite"; + if (next === "/") return "login"; + return "deep_link"; +} + +export function authProviderForCallbackPath(pathname: string): AuthProvider { + return pathname === "/auth/complete" ? "google" : "github"; +} + +export function normalizeAuthFailureReason(value: string | null): AuthFailureReason { + if (!value) return "unknown"; + return CALLBACK_FAILURE_REASONS.has(value as AuthFailureReason) ? (value as AuthFailureReason) : "unknown"; +} + +export function normalizeAuthJoinPath(value: string | null): AuthJoinPath { + return value === "solo" || value === "invite" || value === "returning" ? value : "unknown"; +} + +function campaignAttribution(next: string): Pick | null { + let parsed: URL; + try { + parsed = new URL(next, "https://cloud.first-tree.ai"); + } catch { + return null; + } + if (parsed.pathname !== "/quickstart") return null; + const scanAttemptId = parsed.searchParams.get("attempt") ?? ""; + const variant = parsed.searchParams.get("variant") ?? ""; + if (!UUID_RE.test(scanAttemptId) || !VARIANT_RE.test(variant)) return null; + return { scanAttemptId, variant }; +} + +function readAttempt(): StoredAuthAttempt | null { + if (typeof window === "undefined") return null; + const raw = window.sessionStorage.getItem(AUTH_ATTEMPT_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.id !== "string" || + (parsed.provider !== "google" && parsed.provider !== "github") || + !parsed.entryPoint || + !["login", "deep_link", "invite", "campaign"].includes(parsed.entryPoint) || + (parsed.scanAttemptId !== undefined && !UUID_RE.test(parsed.scanAttemptId)) || + (parsed.variant !== undefined && !VARIANT_RE.test(parsed.variant)) + ) { + throw new Error("invalid auth attempt"); + } + return parsed as StoredAuthAttempt; + } catch { + window.sessionStorage.removeItem(AUTH_ATTEMPT_KEY); + return null; + } +} + +/** + * Start one anonymous OAuth attempt. The UUID survives the full-page provider + * round-trip in sessionStorage, so start/result remain joinable even if GA's + * client session is interrupted by OAuth. + */ +export function beginAuthAttempt(provider: AuthProvider, next: string): string | null { + if (typeof window === "undefined") return null; + const attempt: StoredAuthAttempt = { + id: window.crypto.randomUUID(), + provider, + entryPoint: authEntryPoint(next), + ...(campaignAttribution(next) ?? {}), + }; + window.sessionStorage.setItem(AUTH_ATTEMPT_KEY, JSON.stringify(attempt)); + trackEvent("auth_started", { + auth_attempt_id: attempt.id, + provider, + entry_point: attempt.entryPoint, + ...(attempt.scanAttemptId ? { scan_attempt_id: attempt.scanAttemptId } : {}), + ...(attempt.variant ? { variant: attempt.variant } : {}), + }); + return attempt.id; +} + +export function finishAuthAttempt(input: { + provider: AuthProvider; + result: "success" | "failed"; + next: string; + joinPath?: AuthJoinPath; + reasonCode?: AuthFailureReason; + accountCreated?: boolean | null; +}): void { + if (typeof window === "undefined") return; + const stored = readAttempt(); + const attempt = stored?.provider === input.provider ? stored : null; + const entryPoint = attempt?.entryPoint ?? authEntryPoint(input.next); + const attribution = attempt ?? campaignAttribution(input.next); + const params: Record = { + provider: input.provider, + result: input.result, + entry_point: entryPoint, + join_path: input.joinPath ?? "unknown", + account_type: input.accountCreated === true ? "created" : input.accountCreated === false ? "reused" : "unknown", + ...(attempt ? { auth_attempt_id: attempt.id } : {}), + ...(attribution?.scanAttemptId ? { scan_attempt_id: attribution.scanAttemptId } : {}), + ...(attribution?.variant ? { variant: attribution.variant } : {}), + ...(input.reasonCode ? { reason_code: input.reasonCode } : {}), + }; + trackEvent("auth_result", params); + // GA's recommended sign_up event belongs to account creation, not to a + // later onboarding-completion proxy. This also counts a newly-created + // invitee account whose join path is not "solo". + if (input.accountCreated === true) { + trackEvent("sign_up", { + method: input.provider, + entry_point: entryPoint, + ...(attempt ? { auth_attempt_id: attempt.id } : {}), + ...(attribution?.scanAttemptId ? { scan_attempt_id: attribution.scanAttemptId } : {}), + ...(attribution?.variant ? { variant: attribution.variant } : {}), + }); + } + window.sessionStorage.removeItem(AUTH_ATTEMPT_KEY); +} diff --git a/packages/web/src/auth/auth-context.tsx b/packages/web/src/auth/auth-context.tsx index 955acac98..a91c3857c 100644 --- a/packages/web/src/auth/auth-context.tsx +++ b/packages/web/src/auth/auth-context.tsx @@ -1,7 +1,6 @@ import type { MeMembership, OrgBrief } from "@first-tree/shared"; import { useQueryClient } from "@tanstack/react-query"; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; -import { trackEvent } from "../analytics.js"; import { login as loginApi } from "../api/auth.js"; import { ADMIN_WS_ORG_CHANGED_EVENT, @@ -464,16 +463,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { // un-completing the user. const organizationId = currentMembership?.organizationId; const optimistic = new Date().toISOString(); - // Fire the GA `sign_up` conversion exactly once per *account*, on the - // user's first ever onboarding completion. Completion is stamped per - // membership (a user can belong to several orgs), so a per-membership gate - // would re-fire every time the same person finishes onboarding in a new - // team — that's not a new signup. The account-level "never completed - // anywhere" signal is: no membership carries a completion stamp AND the - // legacy top-level stamp is empty (older /me payloads). trackEvent - // self-gates to the production host, so dev / StrictMode re-invokes can't - // pollute GA either way. - const firstCompletion = !onboardingCompletedAt && memberships.every((m) => !m.onboardingCompletedAt); setOnboardingCompletedAt((prev) => prev ?? optimistic); setOnboardingDismissedAt((prev) => prev ?? optimistic); patchMembershipOnboarding({ @@ -481,11 +470,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { onboardingSuppressedAt: currentMembership?.onboardingSuppressedAt ?? optimistic, onboardingSuppressedReason: "completed", }); - if (firstCompletion) trackEvent("sign_up"); await postOnboardingCompleted(organizationId ?? undefined); }, [ - onboardingCompletedAt, - memberships, currentMembership?.onboardingCompletedAt, currentMembership?.onboardingSuppressedAt, currentMembership?.organizationId, diff --git a/packages/web/src/auth/require-auth.tsx b/packages/web/src/auth/require-auth.tsx index bd03634de..1c11d65b2 100644 --- a/packages/web/src/auth/require-auth.tsx +++ b/packages/web/src/auth/require-auth.tsx @@ -1,6 +1,7 @@ -import { lazy, Suspense, useEffect } from "react"; +import { lazy, Suspense, useEffect, useRef } from "react"; import { Navigate, Outlet, useLocation } from "react-router"; import { isKnownCampaign } from "../pages/quickstart/campaigns.js"; +import { beginAuthAttempt } from "./auth-analytics.js"; import { useAuth } from "./auth-context.js"; /** @@ -46,7 +47,11 @@ export function scanCampaignOAuthNext(loc: { pathname: string; search: string }) * ``. Renders the neutral landing fallback while the browser leaves. */ function OAuthStartRedirect({ next }: { next: string }) { + const startedRef = useRef(false); useEffect(() => { + if (startedRef.current) return; + startedRef.current = true; + beginAuthAttempt("github", next); window.location.replace(`/api/v1/auth/github/start?next=${encodeURIComponent(next)}`); }, [next]); return ; diff --git a/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx b/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx index 7053b3c39..3d0af51bc 100644 --- a/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx +++ b/packages/web/src/features/agent-setup/__tests__/setup-hooks.test.tsx @@ -305,6 +305,7 @@ describe("shared setup hooks", () => { it("mints connect commands, surfaces final token failures, and retries manually", async () => { const latest = { current: null as ComputerConnection | null }; + const onTokenMintFailed = vi.fn(); activityMocks.listClients.mockResolvedValue([]); clientMocks.api.post .mockResolvedValueOnce({ @@ -328,7 +329,7 @@ describe("shared setup hooks", () => { }); function Probe() { - latest.current = useComputerConnection(true); + latest.current = useComputerConnection(true, { onTokenMintFailed }); return
{latest.current.cliCommand ?? latest.current.tokenError ?? "pending"}
; } @@ -344,6 +345,7 @@ describe("shared setup hooks", () => { }); expect(expectHookValue(latest.current).tokenError).toBe("token failed"); + expect(onTokenMintFailed).toHaveBeenCalledTimes(1); await act(async () => expectHookValue(latest.current).retry()); await flush(); diff --git a/packages/web/src/features/agent-setup/use-agent-creation.ts b/packages/web/src/features/agent-setup/use-agent-creation.ts index 3bb924639..16da2a3da 100644 --- a/packages/web/src/features/agent-setup/use-agent-creation.ts +++ b/packages/web/src/features/agent-setup/use-agent-creation.ts @@ -30,9 +30,15 @@ export type CreatedAgentInfo = { args: CreateAgentArgs; }; +export type AgentCreationFailure = { + reasonCode: "agent_create_failed" | "agent_runtime_timeout"; + retryable: true; +}; + export type UseAgentCreationOptions = { onCreated?: (info: CreatedAgentInfo) => void | Promise; onOnline?: (uuid: string) => void; + onFailure?: (failure: AgentCreationFailure) => void; }; /** @@ -53,8 +59,10 @@ export function useAgentCreation(options: UseAgentCreationOptions = {}) { const pollCancelRef = useRef<{ cancelled: boolean } | null>(null); const onCreatedRef = useRef(options.onCreated); const onOnlineRef = useRef(options.onOnline); + const onFailureRef = useRef(options.onFailure); onCreatedRef.current = options.onCreated; onOnlineRef.current = options.onOnline; + onFailureRef.current = options.onFailure; useEffect( () => () => { @@ -84,7 +92,10 @@ export function useAgentCreation(options: UseAgentCreationOptions = {}) { return; } if (Date.now() - startedAt > RUNTIME_READY_TIMEOUT_MS) { - if (!token.cancelled) setPhase("timeout"); + if (!token.cancelled) { + setPhase("timeout"); + onFailureRef.current?.({ reasonCode: "agent_runtime_timeout", retryable: true }); + } return; } await new Promise((r) => setTimeout(r, RUNTIME_READY_POLL_MS)); @@ -116,6 +127,7 @@ export function useAgentCreation(options: UseAgentCreationOptions = {}) { } catch (err) { setError(err instanceof Error ? err.message : "Failed to add your agent to the team"); setPhase("idle"); + onFailureRef.current?.({ reasonCode: "agent_create_failed", retryable: true }); return; } await pollUntilReady(agentUuid); diff --git a/packages/web/src/features/agent-setup/use-computer-connection.ts b/packages/web/src/features/agent-setup/use-computer-connection.ts index 7fdea7085..bbdefa261 100644 --- a/packages/web/src/features/agent-setup/use-computer-connection.ts +++ b/packages/web/src/features/agent-setup/use-computer-connection.ts @@ -35,6 +35,11 @@ export type ComputerConnection = { retry: () => void; }; +export type UseComputerConnectionOptions = { + /** Called once after the final automatic connect-token mint attempt fails. */ + onTokenMintFailed?: () => void; +}; + /** Silent auto-retries before surfacing a token-mint failure to the user. */ const TOKEN_MINT_ATTEMPTS = 3; const TOKEN_MINT_BACKOFF_MS = [600, 1500]; @@ -55,7 +60,10 @@ function hasReportedCapabilities(caps: ClientCapabilities | null): caps is Clien return !!caps && Object.keys(caps).length > 0; } -export function useComputerConnection(enabled: boolean): ComputerConnection { +export function useComputerConnection( + enabled: boolean, + options: UseComputerConnectionOptions = {}, +): ComputerConnection { const [connectedClient, setConnectedClient] = useState(null); const [capabilities, setCapabilities] = useState(null); const [capabilitiesClientId, setCapabilitiesClientId] = useState(null); @@ -69,6 +77,8 @@ export function useComputerConnection(enabled: boolean): ComputerConnection { const capabilitiesClientIdRef = useRef(null); const detectSeqRef = useRef(0); + const onTokenMintFailedRef = useRef(options.onTokenMintFailed); + onTokenMintFailedRef.current = options.onTokenMintFailed; // Detect the connected computer + its capabilities. useEffect(() => { @@ -154,6 +164,7 @@ export function useComputerConnection(enabled: boolean): ComputerConnection { if (cancelled) return; if (attempt === TOKEN_MINT_ATTEMPTS - 1) { setTokenError(err instanceof Error ? err.message : "Failed to generate connect command"); + onTokenMintFailedRef.current?.(); return; } // Silent backoff before the next attempt. diff --git a/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx b/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx index fc2bf9274..4d3006578 100644 --- a/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx +++ b/packages/web/src/pages/__tests__/page-ssr-smoke.test.tsx @@ -746,6 +746,7 @@ function createFlowValue(overrides: FlowOverrides = {}): OnboardingFlowValue { activeStep, goNext: () => undefined, goTo: () => undefined, + reportStepFailure: () => undefined, organizationId: "org-1", memberId: "member-self", role: path === "admin" ? "admin" : "member", diff --git a/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx b/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx index 87043e813..9654c49c0 100644 --- a/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx +++ b/packages/web/src/pages/__tests__/web-dom-interactions.test.tsx @@ -464,6 +464,7 @@ function createFlowValue(overrides: FlowOverrides = {}): OnboardingFlowValue { activeStep, goNext: vi.fn(), goTo: vi.fn(), + reportStepFailure: vi.fn(), organizationId: "org-1", memberId: "member-self", role: path === "admin" ? "admin" : "member", @@ -1747,6 +1748,9 @@ describe("web DOM interaction coverage", () => { githubMocks.listOrgGithubRepos.mockRejectedValueOnce(new ApiError(403, "admin required")); const adminForbidden = await renderOnboardingDom(, { activeStep: "connect-code" }); await waitForText("Couldn't load your team's repos", adminForbidden.container); + expect(adminForbidden.flow.reportStepFailure).toHaveBeenCalledWith("github_repo_list_failed", { + step: "connect-code", + }); await unmountRoot(adminForbidden.root); // A 502 (upstream) / 503 (no_installation|suspended) failure shows the same @@ -1767,6 +1771,9 @@ describe("web DOM interaction coverage", () => { githubMocks.listOrgGithubRepos.mockRejectedValueOnce(new ApiError(503, "no installation")); const loadFailed = await renderOnboardingDom(, { activeStep: "connect-code" }); await waitForText("Couldn't load your team's repos", loadFailed.container); + expect(loadFailed.flow.reportStepFailure).toHaveBeenCalledWith("github_repo_list_failed", { + step: "connect-code", + }); await unmountRoot(loadFailed.root); githubAppMocks.getGithubAppInstallUrl.mockRejectedValueOnce(new ApiError(503, "not configured")); @@ -1778,6 +1785,10 @@ describe("web DOM interaction coverage", () => { ) ?? null, ); await waitForText("Couldn't connect a repo here right now", notConfigured.container); + expect(notConfigured.flow.reportStepFailure).toHaveBeenCalledWith("github_install_not_configured", { + step: "connect-code", + retryable: false, + }); await click( [...notConfigured.container.querySelectorAll("button")].find((button) => button.textContent?.includes("Skip for now"), @@ -2313,6 +2324,7 @@ describe("web DOM interaction coverage", () => { expect(resourceMocks.createTeamResourceForOrg).not.toHaveBeenCalled(); expect(chatApiMocks.createAgentChat).not.toHaveBeenCalled(); expect(view.flow.completeAndEnterChat).not.toHaveBeenCalled(); + expect(view.flow.reportStepFailure).toHaveBeenCalledWith("repo_access_check_failed", { step: "start-chat" }); await unmountRoot(view.root); }); diff --git a/packages/web/src/pages/invite-accept.tsx b/packages/web/src/pages/invite-accept.tsx index 6cd5bda75..63a3477f8 100644 --- a/packages/web/src/pages/invite-accept.tsx +++ b/packages/web/src/pages/invite-accept.tsx @@ -3,6 +3,7 @@ import { ArrowLeft, Github, TriangleAlert } from "lucide-react"; import { useEffect, useState } from "react"; import { Link, useNavigate, useParams } from "react-router"; import { api } from "../api/client.js"; +import { type AuthProvider, beginAuthAttempt } from "../auth/auth-analytics.js"; import { useAuth } from "../auth/auth-context.js"; import { FirstTreeLogo } from "../components/first-tree-logo.js"; import { Button } from "../components/ui/button.js"; @@ -117,6 +118,7 @@ export function InviteAcceptPage() { googleAvailable={providers.google} githubAvailable={providers.github} providersSettled={providersSettled} + onAuthStart={(provider) => beginAuthAttempt(provider, `/invite/${token}`)} /> ); @@ -152,6 +154,7 @@ export function InviteAcceptCard({ googleAvailable = true, githubAvailable = true, providersSettled = true, + onAuthStart, }: { preview: InvitationPreview; isAuthenticated: boolean; @@ -163,6 +166,7 @@ export function InviteAcceptCard({ googleAvailable?: boolean; githubAvailable?: boolean; providersSettled?: boolean; + onAuthStart?: (provider: AuthProvider) => void; }) { const switchingTeam = isAuthenticated && currentTeamName && currentTeamName !== preview.organizationDisplayName; const expiresHint = formatExpiresHint(preview.expiresAt); @@ -207,7 +211,7 @@ export function InviteAcceptCard({ <> {googleAvailable && (