diff --git a/TERMINOLOGY.md b/TERMINOLOGY.md index ba9b9728ca..6ee28cf68e 100644 --- a/TERMINOLOGY.md +++ b/TERMINOLOGY.md @@ -71,6 +71,12 @@ Canonical words used across Junior's code and documentation. its outcome. It is not a Run. - **Scheduled automation**: an Automation that has a Schedule trigger. - **Event automation**: an Automation that has an Event trigger. +- **Event reach**: which event automation triggers can select one event type. + Each source declares it once for each of its event types. `"all"` lets a + trigger with no identifier run for every resource of the source. + `{ anyOf }` lets such a trigger run only when it sets one of the named + filters. `"resource"` needs a trigger with an identifier. Reach limits scope + and noise. It is not access control. - **Inbound message**: one normalized source event made available to the agent. - **Agent input**: the inbound content, context, and runtime metadata selected for a turn. diff --git a/packages/docs/src/content/docs/concepts/automations.md b/packages/docs/src/content/docs/concepts/automations.md index dfe94e1842..919bced9a2 100644 --- a/packages/docs/src/content/docs/concepts/automations.md +++ b/packages/docs/src/content/docs/concepts/automations.md @@ -52,6 +52,17 @@ whenever a new issue is created in Linear team SRE, investigate it and comment f when someone posts a new message in this channel, answer it if you can, otherwise tag me ``` +An event automation can also run for every resource of a plugin, with no exact resource. Filters then narrow it: + +```text +whenever any Sentry metric alert for project javascript goes critical, summarize it here +whenever a pull request opens in getsentry/junior, review it +``` + +Each plugin decides which events can run this way, and each plugin page lists them. Some events need a filter. For example, GitHub issue, pull request, and release events need a `repo` filter, and Linear issue events need a `teamKey` or `issueIdentifier` filter. Junior names the missing filter when it cannot save the automation. Junior saves filter values in the case that event data uses, so `getsentry/Junior` matches `getsentry/junior`. One event runs the automation once, even when the plugin publishes the event for more than one resource, such as each project of a metric alert. Slack channel-message automations always need their own channel. + +A required filter limits how much an automation covers and how noisy it is. It is not access control. Junior does not check that the person who creates the automation can see the repository, project, or team in the filter. An automation with no exact resource posts what it receives to its destination channel. For example, a broad Sentry trigger or GitHub deployment trigger can post private project or repository names in that channel. Create broad automations only in channels where everyone can see those resources. + A Slack channel-message automation runs for each new top-level message in the channel where you create it. It does not run for thread replies or for messages that mention Junior. It runs for a bot message, such as a Sentry alert, only when the automation names that bot's Slack user ID in `match.userId`. Junior replies in the thread of the message. Junior must be a member of the channel. The automation can watch only its own channel, and it always runs with Junior's own credentials, because anyone in the channel can write the message. Tell the channel that Junior reads every new message there. If the plugin is disabled, the automation remains visible but cannot receive events until a compatible plugin is enabled again. Each plugin page lists the resources and events it can publish. diff --git a/packages/docs/src/content/docs/extend/github-plugin.md b/packages/docs/src/content/docs/extend/github-plugin.md index a46ceae00c..b0527f2f0d 100644 --- a/packages/docs/src/content/docs/extend/github-plugin.md +++ b/packages/docs/src/content/docs/extend/github-plugin.md @@ -326,6 +326,16 @@ repository automation to one environment or one workflow. For example, an automation on `getsentry/toolkit` for `deployment.failed` can match `environment: "production"` and `workflowPath: ".github/workflows/deploy.yml"`. +An event automation can also run for every repository, with no identifier. +Deployment events (`deployment.created`, `deployment.queued`, +`deployment.pending`, `deployment.in_progress`, `deployment.succeeded`, +`deployment.failed`, `deployment.error`) can run that way with no filter. +Release, issue, and pull request events need a `repo` filter, because they +carry text from repositories that can be private. Junior saves `repo` in +lowercase. The filter does not check that the creator can see the repository, +so a broad deployment automation can post private repository names in its +channel. See [Automations](/concepts/automations/). + To always watch selected events after Junior creates a pull request in this app, set `pullRequestEvents.subscribeAfterCreate` in `plugins.ts`. To add short app guidance for one pull request event type, set diff --git a/packages/docs/src/content/docs/extend/linear-plugin.md b/packages/docs/src/content/docs/extend/linear-plugin.md index fdfd44d15f..bb79feba06 100644 --- a/packages/docs/src/content/docs/extend/linear-plugin.md +++ b/packages/docs/src/content/docs/extend/linear-plugin.md @@ -185,6 +185,13 @@ Create the watch or event automation before the issue arrives. Junior does not r Identifiers are normalized to uppercase. Prefer team-scoped event automations for monitor workflows that create many new issues. +An event automation can also run for every team, with no identifier. +`issue.created` can run that way only with a `teamKey` or an +`issueIdentifier` filter, because an issue carries its title and description +and a team can be private. Junior saves both in uppercase. The filter does not +check that the creator can see the team. See +[Automations](/concepts/automations/). + Optional `match` values come from the resource type. For Linear issue and team events, `teamKey` is the uppercase team key such as `SRE`. Junior drops events that do not match before it wakes the agent. Prefer a team identifier for “all new issues in SRE”. Use `match.teamKey` when an issue-scoped watch or event automation needs an extra team guard. ## Verify diff --git a/packages/docs/src/content/docs/extend/sentry-plugin.md b/packages/docs/src/content/docs/extend/sentry-plugin.md index 3c9dfb78db..b520d4df24 100644 --- a/packages/docs/src/content/docs/extend/sentry-plugin.md +++ b/packages/docs/src/content/docs/extend/sentry-plugin.md @@ -230,6 +230,14 @@ The alert resolved. Junior publishes each state once for each alert incident. +An event automation can also run for every project in the Sentry org, with no +identifier. `issue.created`, `metric_alert.critical`, `metric_alert.warning`, +and `metric_alert.resolved` can run that way with no filter. Use the +`projects` filter to limit one to some projects. Junior saves project slugs in +lowercase. Junior does not check that the creator can see the projects, so a +broad automation can post private project names in its channel. See +[Automations](/concepts/automations/). + Create the watch or event automation before the webhook arrives. Junior does not replay earlier webhooks. ## Verify diff --git a/packages/docs/src/content/docs/extend/vercel-plugin.md b/packages/docs/src/content/docs/extend/vercel-plugin.md index b6423b6b69..1e98d6bb65 100644 --- a/packages/docs/src/content/docs/extend/vercel-plugin.md +++ b/packages/docs/src/content/docs/extend/vercel-plugin.md @@ -154,6 +154,13 @@ The deployment was canceled. +An event automation can also run for every project, with no identifier. +`deployment.succeeded`, `deployment.error`, and `deployment.canceled` can run +that way with no filter. Use the `projectId` or `target` filter to limit one. +Junior does not check that the creator can see the projects, so a broad +automation can post private project names in its channel. See +[Automations](/concepts/automations/). + Create the watch or event automation before the deployment finishes. Junior does not replay earlier webhooks. Project- and target-scoped watches keep receiving later deployments; commit-scoped watches complete on the terminal diff --git a/packages/junior-dashboard/e2e/automation-editor-fixture.ts b/packages/junior-dashboard/e2e/automation-editor-fixture.ts index 5fbada03dd..0a7d06c11c 100644 --- a/packages/junior-dashboard/e2e/automation-editor-fixture.ts +++ b/packages/junior-dashboard/e2e/automation-editor-fixture.ts @@ -86,13 +86,17 @@ export async function mockAutomationEditor( json: [ { namespace: "github", - type: "issue", - supportedEvents: ["issue.closed", "issue.opened"], - matchFields: { + displayName: "GitHub", + events: { + "issue.closed": { reach: { anyOf: ["state"] } }, + "issue.opened": { reach: "all" }, + }, + anyResourceFields: { state: { kind: "string", description: "Issue state", enum: ["open", "closed"], + events: ["issue.closed", "issue.opened"], }, closedBy: { kind: "string", @@ -100,6 +104,24 @@ export async function mockAutomationEditor( events: ["issue.closed"], }, }, + resourceTypes: [ + { + type: "issue", + supportedEvents: ["issue.closed", "issue.opened"], + matchFields: { + state: { + kind: "string", + description: "Issue state", + enum: ["open", "closed"], + }, + closedBy: { + kind: "string", + description: "Login of who closed the issue", + events: ["issue.closed"], + }, + }, + }, + ], }, ], }), diff --git a/packages/junior-dashboard/e2e/automation-editor.spec.ts b/packages/junior-dashboard/e2e/automation-editor.spec.ts index 1bd8f4de7b..31ac8afcd0 100644 --- a/packages/junior-dashboard/e2e/automation-editor.spec.ts +++ b/packages/junior-dashboard/e2e/automation-editor.spec.ts @@ -165,6 +165,42 @@ test("keeps Event edits through validation, failed saves, and concurrent changes ]); }); +test("replaces an Event trigger with one for any resource", async ({ + page, + dashboard, +}) => { + const state = await mockAutomationEditor(page, "event"); + await page.goto(`${dashboard.baseURL}/automations/event-1/edit`); + await page.getByLabel("Resource type").selectOption("github:*"); + await page.getByRole("button", { name: "Replace trigger" }).click(); + await expect(page.getByLabel("Resource identifier")).toHaveCount(0); + // A trigger with no identifier names no resource. + await page.getByLabel("Trigger name").fill("Closed issues"); + await page.getByRole("checkbox", { name: /^issue closed/ }).check(); + await screenshot(page, "automation-event-editor-any-resource"); + // The shared check names the filter that this event needs. + await expect( + page.getByText('This trigger needs a "state" filter'), + ).toBeVisible(); + await page.getByLabel("state", { exact: true }).selectOption("closed"); + await expect( + page.getByText('This trigger needs a "state" filter'), + ).toHaveCount(0); + await page.getByRole("button", { name: "Save changes" }).click(); + await expect(page).toHaveURL(`${dashboard.baseURL}/automations/list`); + expect(state.writes).toEqual([ + expect.objectContaining({ + kind: "event", + trigger: { + namespace: "github", + label: "Closed issues", + events: ["issue.closed"], + match: { state: "closed" }, + }, + }), + ]); +}); + test("public non-creators edit settings without creator-only controls", async ({ page, dashboard, diff --git a/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx b/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx index c67438890f..11ef54cb25 100644 --- a/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx +++ b/packages/junior-dashboard/src/client/pages/automations/AutomationEventFields.tsx @@ -1,11 +1,14 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { + anyResourceEvents, describeEventTrigger, + eventTriggerChoices, eventMatchFilterText, eventTriggerProblems, eventTypeText, eventsWithoutMatchField, + type EventTriggerSource, } from "@sentry/junior-plugin-api"; import { automationEventCatalogSchema, @@ -36,6 +39,33 @@ import { fetchDashboardJson } from "../../http"; type Trigger = Extract["trigger"]; +// Select value for a trigger with no identifier, which runs for any resource. +const ANY_RESOURCE = "*"; + +const NO_SOURCE: EventTriggerSource = { + resourceTypes: [], + events: {}, + anyResourceFields: {}, +}; + +/** + * Say which filter an event needs when a trigger has no identifier, with the + * filter descriptions, such as "needs a filter: lowercase repository". + */ +function reachText( + source: EventTriggerSource, + eventType: string, +): string | undefined { + const reach = source.events[eventType]?.reach; + if (typeof reach !== "object") return undefined; + const filters = reach.anyOf.map( + (key) => source.anyResourceFields[key]?.description ?? key, + ); + return filters.length === 1 + ? `needs a filter: ${filters[0]}` + : `needs one of these filters: ${filters.join("; or ")}`; +} + /** Render registered Event choices without discarding unsupported saved selectors. */ export function AutomationEventFields(props: { value: Trigger; @@ -55,20 +85,29 @@ export function AutomationEventFields(props: { ), retry: false, }); - const sourceResourceTypes = - catalog.data?.filter((item) => item.namespace === props.value.namespace) ?? - []; - const resource = sourceResourceTypes.find( - (item) => item.type === props.value.resourceType, + const entry = catalog.data?.find( + (item) => item.namespace === props.value.namespace, ); + const source: EventTriggerSource = entry ?? NO_SOURCE; + const displayName = entry?.displayName ?? props.value.namespace; + const anyResource = props.value.resourceType === undefined; + const choices = eventTriggerChoices(source, props.value); const problems = catalog.data - ? eventTriggerProblems(sourceResourceTypes, props.value) + ? eventTriggerProblems(source, props.value) : []; - // A filter that a selected event does not set is fixable here. Other - // problems mean the catalog changed after the trigger was saved. + // A filter that a selected event does not set, or a filter that an event + // needs, is fixable here. Other problems mean the catalog changed after the + // trigger was saved. const unsupported = - !resource || problems.some((problem) => problem.reason !== "field_not_set"); - const matchFields = Object.entries(resource?.matchFields ?? {}); + !choices || + problems.some( + (problem) => + problem.reason !== "field_not_set" && + problem.reason !== "filter_required", + ); + const anyResourceSources = + catalog.data?.filter((item) => anyResourceEvents(item).length > 0) ?? []; + const matchFields = Object.entries(choices?.fields ?? {}); const shownMatchFields = matchFields.filter( ([key, field]) => props.value.match?.[key] !== undefined || @@ -98,7 +137,8 @@ export function AutomationEventFields(props: { Some saved values are not in the current catalog. You can edit other fields, or select a new resource type to replace the trigger. @@ -112,15 +152,20 @@ export function AutomationEventFields(props: { value={ unsupported ? "" - : `${props.value.namespace}:${props.value.resourceType}` + : `${props.value.namespace}:${props.value.resourceType ?? ANY_RESOURCE}` } onChange={(e) => { - const next = catalog.data?.find( - (item) => `${item.namespace}:${item.type}` === e.target.value, - ); - if (!next) return; + const [namespace, type] = e.target.value.split(":"); + if (type === ANY_RESOURCE && namespace) { + setReplacement({ namespace, label: "", events: [] }); + return; + } + const next = catalog.data + ?.find((item) => item.namespace === namespace) + ?.resourceTypes.find((item) => item.type === type); + if (!namespace || !next) return; setReplacement({ - namespace: next.namespace, + namespace, resourceType: next.type, identifier: "", label: "", @@ -131,14 +176,24 @@ export function AutomationEventFields(props: { - {catalog.data?.map((item) => ( + {anyResourceSources.map((item) => ( ))} + {catalog.data?.flatMap((item) => + item.resourceTypes.map((resourceType) => ( + + )), + )} - {!unsupported && resource ? ( + {!unsupported && choices ? ( <> - Resource name + + {anyResource ? "Trigger name" : "Resource name"} + - - - Resource identifier - - - props.onChange({ ...props.value, identifier: e.target.value }) - } - /> - - Use the exact provider resource identifier, not its display name. - - + {anyResource ? ( +

+ Runs for every resource that the filters match. +

+ ) : ( + + + Resource identifier + + + props.onChange({ + ...props.value, + identifier: e.target.value, + }) + } + /> + + Use the exact provider resource identifier, not its display + name. + + + )}
Run on these events
- {resource.supportedEvents.map((event) => ( + {choices.events.map((event) => ( ))} @@ -320,10 +393,11 @@ export function AutomationEventFields(props: { /** Show the saved trigger and its problems in the same words as the details. */ function SavedTrigger(props: { - resourceTypes: Parameters[0]; + displayName: string; + source: EventTriggerSource; trigger: Trigger; }) { - const description = describeEventTrigger(props.resourceTypes, props.trigger); + const description = describeEventTrigger(props.source, props.trigger); return ( <>
    @@ -333,9 +407,9 @@ function SavedTrigger(props: {

- {props.trigger.namespace} ·{" "} - {props.trigger.resourceType.replaceAll("_", " ")} ·{" "} - {props.trigger.identifier} + {props.displayName} ·{" "} + {props.trigger.resourceType?.replaceAll("_", " ") ?? "any resource"} + {props.trigger.identifier ? ` · ${props.trigger.identifier}` : null}

{description.events.join(", ")} diff --git a/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsPage.tsx b/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsPage.tsx index 35a3ea9df9..3fb4a3e625 100644 --- a/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsPage.tsx +++ b/packages/junior-dashboard/src/client/pages/automations/AutomationVersionsPage.tsx @@ -336,8 +336,10 @@ function VersionSettings(props: { ) ) : ( <> - {version.definition.trigger.label} ·{" "} - {version.definition.trigger.identifier} + {version.definition.trigger.label} + {version.definition.trigger.identifier + ? ` · ${version.definition.trigger.identifier}` + : null}
{version.trigger.events.join(", ")} diff --git a/packages/junior-github/src/events/declarations.ts b/packages/junior-github/src/events/declarations.ts new file mode 100644 index 0000000000..bcbe64799d --- /dev/null +++ b/packages/junior-github/src/events/declarations.ts @@ -0,0 +1,47 @@ +import type { + EventReach, + PluginEventDeclaration, +} from "@sentry/junior-plugin-api"; +import { GITHUB_DEPLOYMENT_EVENTS } from "./deployment.js"; +import { GITHUB_ISSUE_EVENTS } from "./issue.js"; +import { + GITHUB_PULL_REQUEST_EVENTS, + type GitHubPullRequestEvent, +} from "./pull-request.js"; +import { GITHUB_RELEASE_EVENTS } from "./release.js"; + +const NEEDS_REPO: EventReach = { anyOf: ["repo"] }; + +/** + * Declare the reach and guidance of every GitHub event. + * + * - Deployment events reach every repository. They carry status facts, + * Junior adds no reaction, and they load nothing from GitHub. + * - Release, issue, and pull request events need a `repo` filter. They carry + * release notes, titles, bodies, comments, or reviews from repositories + * that can be private, and comments and reviews make Junior add a + * reaction. The filter limits how many repositories a trigger covers. It + * does not check that the person can see the repository. + * + * Pull request filters such as `authors` and `openerRole` load data from + * GitHub. Core skips a trigger with no identifier when its `repo` filter does + * not match the event, so it loads nothing for other repositories. + */ +export function gitHubEventDeclarations( + pullRequestGuidance: Partial>, +): Record { + const declarations: Record = {}; + for (const eventType of GITHUB_DEPLOYMENT_EVENTS) { + declarations[eventType] = { reach: "all" }; + } + for (const eventType of [...GITHUB_RELEASE_EVENTS, ...GITHUB_ISSUE_EVENTS]) { + declarations[eventType] = { reach: NEEDS_REPO }; + } + for (const eventType of GITHUB_PULL_REQUEST_EVENTS) { + const declaration: PluginEventDeclaration = { reach: NEEDS_REPO }; + const guidance = pullRequestGuidance[eventType]; + if (guidance) declaration.guidance = guidance; + declarations[eventType] = declaration; + } + return declarations; +} diff --git a/packages/junior-github/src/events/deployment.ts b/packages/junior-github/src/events/deployment.ts index e5ed583dc1..8d6fd861a6 100644 --- a/packages/junior-github/src/events/deployment.ts +++ b/packages/junior-github/src/events/deployment.ts @@ -35,6 +35,7 @@ export const GITHUB_DEPLOYMENT_MATCH_FIELDS = eventMatchFieldsSchema.parse({ }, environment: { kind: "string", + case: "lower", description: "lowercase deployment environment name, such as production", events: [...GITHUB_DEPLOYMENT_EVENTS], }, diff --git a/packages/junior-github/src/events/scope.ts b/packages/junior-github/src/events/scope.ts index 2884a65f6a..6949007879 100644 --- a/packages/junior-github/src/events/scope.ts +++ b/packages/junior-github/src/events/scope.ts @@ -26,6 +26,7 @@ export function gitHubRepoMatchFields( return eventMatchFieldsSchema.parse({ repo: { kind: "string", + case: "lower", description: "lowercase repository as owner/repo, such as getsentry/junior", events, @@ -40,6 +41,7 @@ export function gitHubCommitMatchFields( return eventMatchFieldsSchema.parse({ commitSha: { kind: "string", + case: "lower", description: "full lowercase Git commit SHA", events, }, diff --git a/packages/junior-github/src/plugin.ts b/packages/junior-github/src/plugin.ts index 7915367a71..6f486dc36c 100644 --- a/packages/junior-github/src/plugin.ts +++ b/packages/junior-github/src/plugin.ts @@ -16,6 +16,7 @@ import { } from "./permissions.js"; import { createGitHubTools } from "./tools.js"; import { createGitHubWebhookRoute } from "./webhooks/handler.js"; +import { gitHubEventDeclarations } from "./events/declarations.js"; import { loadPullRequestCommitPages, loadRepositoryRole, @@ -177,9 +178,6 @@ export function githubPlugin( supportedEvents: [...GITHUB_PULL_REQUEST_EVENTS], suggestedEvents: [...GITHUB_PULL_REQUEST_SUGGESTED_EVENTS], matchFields: GITHUB_PULL_REQUEST_MATCH_FIELDS, - guidance: gitHubPullRequestEventGuidance( - options.pullRequestEvents?.guidance, - ), }, { type: "release_source", @@ -198,11 +196,11 @@ export function githubPlugin( GITHUB_ISSUE_MATCH_FIELDS, GITHUB_PULL_REQUEST_MATCH_FIELDS, ), - guidance: gitHubPullRequestEventGuidance( - options.pullRequestEvents?.guidance, - ), }, ], + events: gitHubEventDeclarations( + gitHubPullRequestEventGuidance(options.pullRequestEvents?.guidance), + ), isEnabled: () => Boolean(readEnv("GITHUB_WEBHOOK_SECRET")), normalizeIdentifier: (identifier) => identifier.toLowerCase(), }, diff --git a/packages/junior-github/src/webhooks/check-suite.ts b/packages/junior-github/src/webhooks/check-suite.ts index 46746a074c..f026d1a925 100644 --- a/packages/junior-github/src/webhooks/check-suite.ts +++ b/packages/junior-github/src/webhooks/check-suite.ts @@ -451,6 +451,7 @@ export function normalizeCheckSuiteEvents( /** Identifiers and event types one completed check suite may publish. */ export function parseCheckSuitePublishTargets(body: unknown): | { + data: { repo: string }; eventTypes: string[]; identifiers: string[]; } @@ -473,6 +474,9 @@ export function parseCheckSuitePublishTargets(body: unknown): ); } return { + // Every copy sets this repo, so a broad trigger for another repo adds + // no lazy keys. + data: { repo: gitHubRepoDataSchema.parse(repo) }, eventTypes: [eventType], identifiers: [...identifiers], }; diff --git a/packages/junior-github/src/webhooks/pull-request-match-data.ts b/packages/junior-github/src/webhooks/pull-request-match-data.ts index a7026515b5..d5565f51be 100644 --- a/packages/junior-github/src/webhooks/pull-request-match-data.ts +++ b/packages/junior-github/src/webhooks/pull-request-match-data.ts @@ -5,6 +5,7 @@ import type { } from "@sentry/junior-plugin-api"; import { z } from "zod"; import type { GitHubDb } from "../db/database.js"; +import { gitHubRepoDataSchema } from "../events/scope.js"; import { listGitHubPullRequestOpeners } from "../pull-request-requesters/store.js"; import { definedValues } from "./event-targets.js"; import { isBotLogin } from "./ownership.js"; @@ -258,6 +259,9 @@ export async function withGitHubPullRequestMatchData(args: { // A match decision needs every value that a filter uses. const keys = (await args.neededMatchKeys?.({ + // Every copy sets this repo, so a broad trigger for another repo adds + // no lazy keys. + data: { repo: gitHubRepoDataSchema.parse(context.repositoryFullName) }, eventTypes: [...new Set(pullRequestEvents.map((e) => e.eventType))], identifiers: [...new Set(pullRequestEvents.map((e) => e.identifier))], })) ?? []; diff --git a/packages/junior-github/tests/event-data.test.ts b/packages/junior-github/tests/event-data.test.ts index 500a019c3b..f140ebcaea 100644 --- a/packages/junior-github/tests/event-data.test.ts +++ b/packages/junior-github/tests/event-data.test.ts @@ -217,32 +217,42 @@ async function publish( const events: EventInput[] = []; for (const [index, delivery] of deliveries.entries()) { const deliveryId = `${deliveryPrefix}-${index}`; - events.push( - ...(await withGitHubPullRequestMatchData({ - body: delivery.body, + const known: Record[] = []; + const published = await withGitHubPullRequestMatchData({ + body: delivery.body, + botEmail, + db, + deliveryId, + events: normalizeGitHubEvents({ + ...delivery, botEmail, - db, - deliveryId, - events: normalizeGitHubEvents({ - ...delivery, - botEmail, - checkSuiteFacts: { - pullRequestFactsByNumber: { - [BASE_SCOPE.pullRequest]: { - isDraft: false, - openerEmail: opener.email, - openerUsername: opener.login, - }, + checkSuiteFacts: { + pullRequestFactsByNumber: { + [BASE_SCOPE.pullRequest]: { + isDraft: false, + openerEmail: opener.email, + openerUsername: opener.login, }, }, - deliveryId, - }), - hasMatch: async () => true, - loadAuthors: async () => ["dcramer"], - loadRole: async () => "write", - neededMatchKeys: async () => fieldNames, - })), - ); + }, + deliveryId, + }), + hasMatch: async () => true, + loadAuthors: async () => ["dcramer"], + loadRole: async () => "write", + neededMatchKeys: async (input) => { + known.push(input.data ?? {}); + return fieldNames; + }, + }); + // Core skips broad triggers whose filters fail on the data it gets + // before the lookups, so every published copy must have that data. + for (const data of known) { + for (const event of published) { + expect(event.data).toMatchObject(data); + } + } + events.push(...published); } return events; } diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index de0adcf3cf..0ad740d34f 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -605,24 +605,34 @@ describe("github plugin", () => { }); it("registers app guidance for pull request events", () => { - const pullRequest = githubPlugin({ + const events = githubPlugin({ pullRequestEvents: { guidance: { "pull_request.checks.failed": "Inspect the failed checks.", }, }, - }).events?.resourceTypes.find( + }).events; + const pullRequest = events?.resourceTypes.find( (resourceType) => resourceType.type === "pull_request", ); - expect(pullRequest?.guidance).toMatchObject({ - "pull_request.checks.failed": "Inspect the failed checks.", - "pull_request.comment.created": expect.stringContaining( - "exact repo, commentId, and commentKind from Verified details", - ), - "pull_request.review_comment.created": expect.stringContaining( - "github_resolvePullRequestReviewThread", - ), + // Guidance is declared once for each event, with its reach. + expect(events?.events).toMatchObject({ + "pull_request.checks.failed": { + reach: { anyOf: ["repo"] }, + guidance: "Inspect the failed checks.", + }, + "pull_request.comment.created": { + guidance: expect.stringContaining( + "exact repo, commentId, and commentKind from Verified details", + ), + }, + "pull_request.review_comment.created": { + guidance: expect.stringContaining( + "github_resolvePullRequestReviewThread", + ), + }, + "deployment.failed": { reach: "all" }, }); expect(pullRequest?.matchFields).toMatchObject({ authors: { diff --git a/packages/junior-github/tests/webhook-outcomes.test.ts b/packages/junior-github/tests/webhook-outcomes.test.ts index f25bbe154a..50420d0014 100644 --- a/packages/junior-github/tests/webhook-outcomes.test.ts +++ b/packages/junior-github/tests/webhook-outcomes.test.ts @@ -1340,6 +1340,8 @@ describe("GitHub webhook events", () => { }, }), ).toEqual({ + // Core uses the repo to skip broad triggers for other repositories. + data: { repo: "getsentry/junior" }, eventTypes: ["pull_request.checks.recovered"], identifiers: ["getsentry/junior", "getsentry/junior#10"], }); diff --git a/packages/junior-linear/src/events/issue.ts b/packages/junior-linear/src/events/issue.ts index defce196e2..7bf1e142a0 100644 --- a/packages/junior-linear/src/events/issue.ts +++ b/packages/junior-linear/src/events/issue.ts @@ -12,11 +12,13 @@ export const LINEAR_ISSUE_EVENTS = ["issue.created"] as const; export const LINEAR_ISSUE_MATCH_FIELDS = eventMatchFieldsSchema.parse({ issueIdentifier: { kind: "string", + case: "upper", description: "uppercase Linear issue identifier, such as SRE-123", events: [...LINEAR_ISSUE_EVENTS], }, teamKey: { kind: "string", + case: "upper", description: "uppercase Linear team key for the issue, such as SRE", events: [...LINEAR_ISSUE_EVENTS], }, diff --git a/packages/junior-linear/src/plugin.ts b/packages/junior-linear/src/plugin.ts index 2fefd1b326..716d7f5165 100644 --- a/packages/junior-linear/src/plugin.ts +++ b/packages/junior-linear/src/plugin.ts @@ -182,6 +182,15 @@ export function linearPlugin(): PluginRegistration { matchFields: LINEAR_ISSUE_MATCH_FIELDS, }, ], + // An issue carries its title and description, and a team can be + // private. So a trigger with no identifier must name its team or one + // issue. + events: Object.fromEntries( + LINEAR_ISSUE_EVENTS.map((eventType) => [ + eventType, + { reach: { anyOf: ["teamKey", "issueIdentifier"] } }, + ]), + ), isEnabled: () => Boolean(linearWebhookSecret()), normalizeIdentifier: (identifier) => identifier.toUpperCase(), }, diff --git a/packages/junior-plugin-api/src/event-triggers.ts b/packages/junior-plugin-api/src/event-triggers.ts index 8ef5492711..1b75c43658 100644 --- a/packages/junior-plugin-api/src/event-triggers.ts +++ b/packages/junior-plugin-api/src/event-triggers.ts @@ -6,7 +6,12 @@ * find saved triggers that can no longer work. The dashboard runs it in the * editor, so all of them use the same rules. */ -import type { EventMatch, EventMatchFields, PluginEventType } from "./events"; +import { + type EventMatch, + type EventMatchFields, + type EventReach, + type PluginEventType, +} from "./events"; type EventMatchField = EventMatchFields[string]; @@ -15,6 +20,10 @@ export interface EventTriggerProblem { /** * - `unknown_resource_type`: the source has no such enabled resource type. * - `unknown_event`: the resource type has no such event. + * - `identifier_required`: a trigger with no identifier selects an event + * that cannot run for every resource. + * - `filter_required`: a trigger with no identifier selects an event whose + * reach names filters, and the trigger sets none of them. * - `unknown_field`: the resource type has no such filter. Saved triggers * get this after a plugin renames or removes a filter. * - `field_not_set`: a selected event never sets the filter, so the filter @@ -24,6 +33,8 @@ export interface EventTriggerProblem { reason: | "unknown_resource_type" | "unknown_event" + | "identifier_required" + | "filter_required" | "unknown_field" | "field_not_set" | "invalid_value"; @@ -33,13 +44,40 @@ export interface EventTriggerProblem { message: string; } -/** The selection to check: one resource type, its events, and its filters. */ +/** + * The selection to check: one resource type, its events, and its filters. + * Leave out the resource type for a trigger with no identifier. It runs for + * every resource of the source that its filters match. + */ export interface EventTriggerSelection { - resourceType: string; + resourceType?: string; events: readonly string[]; match?: EventMatch; } +/** The enabled resource types of one source, and the reach of each of its events. */ +export interface EventTriggerSource { + resourceTypes: readonly Pick< + PluginEventType, + "type" | "matchFields" | "supportedEvents" + >[]; + events: Readonly>; + /** + * The filters of every resource type, for a trigger with no identifier. + * Merge them once with `anyResourceMatchFields` when the catalog is built, + * not for each check. + */ + anyResourceFields: EventMatchFields; +} + +/** The events of a source that a trigger with no identifier can select. */ +export function anyResourceEvents(source: EventTriggerSource): string[] { + return Object.entries(source.events) + .filter(([, declaration]) => declaration.reach !== "resource") + .map(([eventType]) => eventType) + .sort(); +} + function quoted(values: readonly string[]): string { return values.map((value) => `"${value}"`).join(", "); } @@ -84,61 +122,121 @@ function valueProblem( return undefined; } +/** The events and filters that one selection can use. */ +function selectionScope( + source: EventTriggerSource, + selection: Pick, +): + | { + events: readonly string[]; + fields: EventMatchFields; + owner: "resource type" | "source"; + } + | EventTriggerProblem { + if (selection.resourceType === undefined) { + const events = anyResourceEvents(source); + if (events.length === 0) { + return { + reason: "identifier_required", + message: + "This source cannot run for every resource. Set a resource type and an identifier.", + }; + } + return { events, fields: source.anyResourceFields, owner: "source" }; + } + const resourceType = source.resourceTypes.find( + (candidate) => candidate.type === selection.resourceType, + ); + if (!resourceType) { + return { + reason: "unknown_resource_type", + message: `"${selection.resourceType}" is not an enabled resource type. Use one of: ${quoted(source.resourceTypes.map((candidate) => candidate.type))}.`, + }; + } + return { + events: resourceType.supportedEvents, + fields: resourceType.matchFields ?? {}, + owner: "resource type", + }; +} + +/** + * Return the events and filters that a selection can use: those of its + * resource type, or with no resource type, the events whose reach is not + * `"resource"`, with the filters of every resource type. Return nothing when the source + * has no such resource type or cannot run for every resource. + */ +export function eventTriggerChoices( + source: EventTriggerSource, + selection: Pick, +): { events: readonly string[]; fields: EventMatchFields } | undefined { + if (source.resourceTypes.length === 0) return undefined; + const scope = selectionScope(source, selection); + return "reason" in scope + ? undefined + : { events: scope.events, fields: scope.fields }; +} + /** * List why a selection cannot work with the resource types of one source. An * empty list means the resource type exists, every selected event is * supported, and every selected event can match every filter. A filter on a * list field (`list: true`) matches when the event list includes the value. + * A selection with no resource type can use only events whose reach is not + * `"resource"`, with the filters of every resource type, and must set one of + * the filters that the reach of each selected event names. * Set `inputKeys` for the agent, so messages name the `match.` input. */ export function eventTriggerProblems( - resourceTypes: readonly Pick< - PluginEventType, - "type" | "matchFields" | "supportedEvents" - >[], + source: EventTriggerSource, selection: EventTriggerSelection, options: { inputKeys?: boolean } = {}, ): EventTriggerProblem[] { - const resourceType = resourceTypes.find( - (candidate) => candidate.type === selection.resourceType, - ); - if (!resourceType) { + if (source.resourceTypes.length === 0) { return [ { reason: "unknown_resource_type", - message: - resourceTypes.length === 0 - ? "The plugin for this source is not enabled." - : `"${selection.resourceType}" is not an enabled resource type. Use one of: ${quoted(resourceTypes.map((candidate) => candidate.type))}.`, + message: "The plugin for this source is not enabled.", }, ]; } + const scope = selectionScope(source, selection); + if ("reason" in scope) return [scope]; + const filterName = (key: string) => + options.inputKeys ? `Filter "${key}" (match.${key})` : `Filter "${key}"`; const problems: EventTriggerProblem[] = []; const events = selection.events.filter((eventType) => - resourceType.supportedEvents.includes(eventType), + scope.events.includes(eventType), ); for (const eventType of selection.events) { if (events.includes(eventType)) continue; - problems.push({ - reason: "unknown_event", - message: `"${eventType}" is not an event for this resource type. Use one of: ${quoted(resourceType.supportedEvents)}.`, - }); + const anySupports = source.resourceTypes.some((resourceType) => + resourceType.supportedEvents.includes(eventType), + ); + problems.push( + selection.resourceType === undefined && anySupports + ? { + reason: "identifier_required", + message: `"${eventType}" cannot run for every resource. Set a resource type and an identifier, or use one of: ${quoted(scope.events)}.`, + } + : { + reason: "unknown_event", + message: `"${eventType}" is not an event for this ${scope.owner}. Use one of: ${quoted(scope.events)}.`, + }, + ); } - const fields = resourceType.matchFields ?? {}; - const fieldNames = Object.keys(fields); + const fieldNames = Object.keys(scope.fields); for (const [key, value] of Object.entries(selection.match ?? {})) { - const filter = options.inputKeys - ? `Filter "${key}" (match.${key})` - : `Filter "${key}"`; - const field = fields[key]; + const filter = filterName(key); + const field = scope.fields[key]; if (!field) { problems.push({ reason: "unknown_field", field: key, message: fieldNames.length === 0 - ? `${filter} does not exist. This resource type has no filters, so remove it.` - : `${filter} does not exist for this resource type. Remove it, or use one of: ${quoted(fieldNames)}.`, + ? `${filter} does not exist. This ${scope.owner} has no filters, so remove it.` + : `${filter} does not exist for this ${scope.owner}. Remove it, or use one of: ${quoted(fieldNames)}.`, }); continue; } @@ -155,9 +253,45 @@ export function eventTriggerProblems( problems.push({ reason: "invalid_value", field: key, message: invalid }); } } + if (selection.resourceType === undefined) { + problems.push( + ...requiredFilterProblems(source, events, selection, options), + ); + } return problems; } +/** Name the filters that the selected events need and the trigger does not set. */ +function requiredFilterProblems( + source: EventTriggerSource, + events: readonly string[], + selection: EventTriggerSelection, + options: { inputKeys?: boolean }, +): EventTriggerProblem[] { + const missing = new Map(); + for (const eventType of events) { + const reach = source.events[eventType]?.reach; + if (typeof reach !== "object") continue; + if (reach.anyOf.some((filter) => selection.match?.[filter] !== undefined)) { + continue; + } + const key = [...reach.anyOf].sort().join(","); + const entry = missing.get(key) ?? { anyOf: reach.anyOf, events: [] }; + entry.events.push(eventType); + missing.set(key, entry); + } + const inputKey = (filter: string) => + options.inputKeys ? ` (match.${filter})` : ""; + return [...missing.values()].map(({ anyOf, events: requiredBy }) => ({ + reason: "filter_required", + field: anyOf[0], + message: + anyOf.length === 1 + ? `This trigger needs a "${anyOf[0]}" filter${inputKey(anyOf[0]!)}, because ${quoted(requiredBy)} cannot run for every resource.` + : `This trigger needs one of these filters: ${anyOf.map((filter) => `"${filter}"${inputKey(filter)}`).join(", ")}, because ${quoted(requiredBy)} cannot run for every resource.`, + })); +} + /** How people see one Event trigger. Each entry is one line of plain text. */ export interface EventTriggerDescription { /** Each selected event in plain words, such as "pull request opened". */ @@ -190,25 +324,20 @@ export function eventMatchFilterText( /** * Describe a selection for the dashboard and Slack, so both show the same - * words. Pass the resource types of the selection's source, as for - * `eventTriggerProblems`. Its problems are the messages of that check. + * words. Pass the selection's source, as for `eventTriggerProblems`. Its + * problems are the messages of that check. */ export function describeEventTrigger( - resourceTypes: readonly Pick< - PluginEventType, - "type" | "matchFields" | "supportedEvents" - >[], + source: EventTriggerSource, selection: EventTriggerSelection, ): EventTriggerDescription { - const fields = - resourceTypes.find((candidate) => candidate.type === selection.resourceType) - ?.matchFields ?? {}; + const fields = eventTriggerChoices(source, selection)?.fields ?? {}; return { events: selection.events.map(eventTypeText), filters: Object.entries(selection.match ?? {}).map(([key, value]) => eventMatchFilterText(key, fields[key], value), ), - problems: eventTriggerProblems(resourceTypes, selection).map( + problems: eventTriggerProblems(source, selection).map( (problem) => problem.message, ), }; diff --git a/packages/junior-plugin-api/src/events.ts b/packages/junior-plugin-api/src/events.ts index 4d1cbf13fb..c1e6d92070 100644 --- a/packages/junior-plugin-api/src/events.ts +++ b/packages/junior-plugin-api/src/events.ts @@ -57,6 +57,11 @@ export const eventMatchFieldSchema = z * the list includes it. */ list: z.literal(true).optional(), + /** + * The case of the text in event data. Core changes a saved filter value + * to this case, so `getsentry/Junior` still matches `getsentry/junior`. + */ + case: z.enum(["lower", "upper"]).optional(), /** * The events that set this field. Leave it out when every event of the * resource type sets it. A filter on this field never matches other @@ -73,6 +78,13 @@ export const eventMatchFieldSchema = z path: ["enum"], }); } + if (field.case && field.kind !== "string") { + context.addIssue({ + code: "custom", + message: 'Event match field case requires kind "string".', + path: ["case"], + }); + } if (field.list && field.kind === "boolean") { context.addIssue({ code: "custom", @@ -158,10 +170,11 @@ export function mergeEventMatchFields( if ( current.kind !== field.kind || current.list !== field.list || + current.case !== field.case || !sameValues(current.enum, field.enum) ) { throw new Error( - `Match field "${key}" has a different kind, enum, or list in two maps.`, + `Match field "${key}" has a different kind, case, enum, or list in two maps.`, ); } if (!current.events || !field.events) { @@ -178,6 +191,34 @@ export function mergeEventMatchFields( return eventMatchFieldsSchema.parse(merged); } +/** + * Merge the match fields of every resource type of one source, for a trigger + * with no identifier. Such a trigger can get the events of every resource + * type. A field with no `events` list is set on every event of its own + * resource type, so it gets that list before the merge. This throws like + * `mergeEventMatchFields` when two resource types describe one field + * differently. + */ +export function anyResourceMatchFields( + resourceTypes: readonly Pick< + PluginEventType, + "matchFields" | "supportedEvents" + >[], +): EventMatchFields { + return mergeEventMatchFields( + ...resourceTypes.map((resourceType) => + Object.fromEntries( + Object.entries(resourceType.matchFields ?? {}).map(([key, field]) => [ + key, + field.events + ? field + : { ...field, events: [...resourceType.supportedEvents] }, + ]), + ), + ), + ); +} + function stableMatchValue(value: EventMatch[string]): unknown { if (!Array.isArray(value)) return value; return [...value].sort((left, right) => { @@ -230,6 +271,33 @@ export function eventMatches( return true; } +/** + * Return a match with each text value in the case that its field declares, + * so a saved filter has the same case as event data. Values of other fields + * do not change. + */ +export function normalizeEventMatch( + fields: EventMatchFields, + match: EventMatch | undefined, +): EventMatch | undefined { + if (!match) return undefined; + const toCase = (key: string, value: string | number) => { + const fieldCase = fields[key]?.case; + if (typeof value !== "string" || !fieldCase) return value; + return fieldCase === "lower" ? value.toLowerCase() : value.toUpperCase(); + }; + return Object.fromEntries( + Object.entries(match).map(([key, value]) => [ + key, + Array.isArray(value) + ? value.map((entry) => toCase(key, entry)) + : typeof value === "boolean" + ? value + : toCase(key, value), + ]), + ) as EventMatch; +} + function isRegExpSource(value: string): boolean { try { new RegExp(value); @@ -258,12 +326,6 @@ export const pluginEventTypeSchema = z supportedEvents: z.array(eventTypeSchema).min(1), suggestedEvents: z.array(eventTypeSchema).optional(), matchFields: eventMatchFieldsSchema.optional(), - guidance: z - .record( - eventTypeSchema, - z.string().trim().min(1).max(EVENT_GUIDANCE_MAX_LENGTH), - ) - .optional(), }) .strict() .superRefine((resourceType, context) => { @@ -278,15 +340,6 @@ export const pluginEventTypeSchema = z } supported.add(eventType); }); - for (const eventType of Object.keys(resourceType.guidance ?? {})) { - if (!supported.has(eventType)) { - context.addIssue({ - code: "custom", - message: `Guidance event type "${eventType}" is not supported.`, - path: ["guidance", eventType], - }); - } - } const suggested = new Set(); resourceType.suggestedEvents?.forEach((eventType, index) => { if (suggested.has(eventType)) { @@ -318,9 +371,61 @@ export const pluginEventTypeSchema = z } }); +/** + * The event reach of one event type: which event automation triggers can + * select it. + * + * - `"all"`: a trigger can run for every resource of the source, with no + * identifier and no filter. Use it only for events that carry status facts + * and cause no side effects. + * - `{ anyOf }`: a trigger with no identifier must set at least one of these + * filters. Use it when an event carries private text, such as titles or + * comments, or makes Junior act, such as add a reaction. + * - `"resource"`: only a trigger for one resource, with an identifier, can + * select the event. + */ +export const eventReachSchema = z.union([ + z.literal("all"), + z.literal("resource"), + z + .object({ + anyOf: z.array(z.string().regex(EVENT_MATCH_FIELD_NAME)).min(1), + }) + .strict(), +]); + +export type EventReach = z.output; + +/** What a source declares once for one of its event types. */ +export const pluginEventDeclarationSchema = z + .object({ + reach: eventReachSchema, + /** + * App guidance that the agent gets with each Event of this type, for every + * Watch and event automation. + */ + guidance: z + .string() + .trim() + .min(1) + .max(EVENT_GUIDANCE_MAX_LENGTH) + .optional(), + }) + .strict(); + +export type PluginEventDeclaration = z.output< + typeof pluginEventDeclarationSchema +>; + export const pluginEventsSchema = z .object({ resourceTypes: z.array(pluginEventTypeSchema).min(1), + /** + * One declaration for each event type that a resource type supports. An + * event that two resource types share has one declaration, so its reach + * and guidance are the same for both. + */ + events: z.record(eventTypeSchema, pluginEventDeclarationSchema), isEnabled: z.function({ input: [], output: z.boolean() }).optional(), normalizeIdentifier: z .function({ input: [z.string()], output: z.string() }) @@ -339,6 +444,52 @@ export const pluginEventsSchema = z } seen.add(resourceType.type); }); + const supported = new Set( + registration.resourceTypes.flatMap( + (resourceType) => resourceType.supportedEvents, + ), + ); + for (const eventType of supported) { + if (!registration.events[eventType]) { + context.addIssue({ + code: "custom", + message: `Event type "${eventType}" has no declaration in events.`, + path: ["events"], + }); + } + } + let fields: EventMatchFields; + try { + fields = anyResourceMatchFields(registration.resourceTypes); + } catch (error) { + context.addIssue({ + code: "custom", + message: (error as Error).message, + path: ["resourceTypes"], + }); + return; + } + for (const [eventType, declaration] of Object.entries( + registration.events, + )) { + if (!supported.has(eventType)) { + context.addIssue({ + code: "custom", + message: `Declared event type "${eventType}" is not supported.`, + path: ["events", eventType], + }); + } + if (typeof declaration.reach === "string") continue; + for (const filter of declaration.reach.anyOf) { + if (!fields[filter]?.events?.includes(eventType)) { + context.addIssue({ + code: "custom", + message: `Reach filter "${filter}" is not set on "${eventType}".`, + path: ["events", eventType, "reach", "anyOf"], + }); + } + } + } }); export type PluginEventType = z.output; @@ -448,10 +599,17 @@ export interface EventPublisher { publish(event: EventInput): Promise; /** * Return match keys used by active watches or event automations for these - * identifiers and event types. Plugins use this to load optional trusted - * data only when a filter needs it. + * identifiers and event types, and by event automations with no + * identifier. Plugins use this to load optional trusted data only when a + * filter needs it. + * + * Pass `data` with values that every copy of the events sets and that the + * plugin already has, such as `repo`. An event automation with no + * identifier whose filters on those keys fail adds no keys, so a trigger + * for one repository does not load data for events of other repositories. */ neededMatchKeys?(input: { + data?: EventData; eventTypes: string[]; identifiers: string[]; }): Promise; diff --git a/packages/junior-sentry/src/events/scope.ts b/packages/junior-sentry/src/events/scope.ts index d441514173..ddec6a6d2c 100644 --- a/packages/junior-sentry/src/events/scope.ts +++ b/packages/junior-sentry/src/events/scope.ts @@ -25,6 +25,7 @@ export function sentryProjectsMatchFields( return eventMatchFieldsSchema.parse({ projects: { kind: "string", + case: "lower", list: true, description: "lowercase Sentry project slugs, such as junior; an issue has one, a metric alert has the projects of its rule", diff --git a/packages/junior-sentry/src/index.ts b/packages/junior-sentry/src/index.ts index b2a723da7d..d1e32ee6b3 100644 --- a/packages/junior-sentry/src/index.ts +++ b/packages/junior-sentry/src/index.ts @@ -64,6 +64,14 @@ export function sentryPlugin(): PluginRegistration { matchFields: SENTRY_METRIC_ALERT_MATCH_FIELDS, }, ], + // One install is one Sentry org. Issue and metric alert events carry + // alert facts, and Junior adds nothing to Sentry, so they can run for + // every project. Use the `projects` filter to narrow them. + events: Object.fromEntries( + [...SENTRY_ISSUE_EVENTS, ...SENTRY_METRIC_ALERT_EVENTS].map( + (eventType) => [eventType, { reach: "all" }], + ), + ), isEnabled: () => Boolean(sentryWebhookSecret()), normalizeIdentifier: (identifier) => identifier.toLowerCase(), }, diff --git a/packages/junior-vercel/src/events/deployment.ts b/packages/junior-vercel/src/events/deployment.ts index 8be0ecb74c..b8bb9fec1d 100644 --- a/packages/junior-vercel/src/events/deployment.ts +++ b/packages/junior-vercel/src/events/deployment.ts @@ -49,6 +49,7 @@ export const VERCEL_DEPLOYMENT_MATCH_FIELDS = eventMatchFieldsSchema.parse({ }, commitSha: { kind: "string", + case: "lower", description: "full lowercase Git commit SHA of the deployment, when it came from a Git commit", events: [...VERCEL_DEPLOYMENT_EVENTS], diff --git a/packages/junior-vercel/src/index.ts b/packages/junior-vercel/src/index.ts index b99fdad3b4..ccc6b5f43d 100644 --- a/packages/junior-vercel/src/index.ts +++ b/packages/junior-vercel/src/index.ts @@ -32,6 +32,15 @@ export function vercelPlugin(): PluginRegistration { matchFields: VERCEL_DEPLOYMENT_MATCH_FIELDS, }, ], + // Deployment events carry status facts, and Junior adds nothing to + // Vercel, so they can run for every project. Use the `projectId` + // filter to narrow them. + events: Object.fromEntries( + VERCEL_DEPLOYMENT_EVENTS.map((eventType) => [ + eventType, + { reach: "all" }, + ]), + ), isEnabled: () => Boolean(vercelWebhookSecret()), }, manifest: { diff --git a/packages/junior/migrations/0064_any_resource_event_triggers.sql b/packages/junior/migrations/0064_any_resource_event_triggers.sql new file mode 100644 index 0000000000..27f89d4a6f --- /dev/null +++ b/packages/junior/migrations/0064_any_resource_event_triggers.sql @@ -0,0 +1,2 @@ +ALTER TABLE "junior_automations" DROP CONSTRAINT "junior_automations_trigger_check";--> statement-breakpoint +ALTER TABLE "junior_automations" ADD CONSTRAINT "junior_automations_trigger_check" CHECK (("junior_automations"."kind" = 'scheduled' and "junior_automations"."namespace" is null and "junior_automations"."identifier" is null) or ("junior_automations"."kind" = 'event' and "junior_automations"."namespace" is not null and "junior_automations"."next_run_at_ms" is null and "junior_automations"."run_now_at_ms" is null and "junior_automations"."status" <> 'completed')); \ No newline at end of file diff --git a/packages/junior/migrations/meta/0064_snapshot.json b/packages/junior/migrations/meta/0064_snapshot.json new file mode 100644 index 0000000000..f48b4d8712 --- /dev/null +++ b/packages/junior/migrations/meta/0064_snapshot.json @@ -0,0 +1,3505 @@ +{ + "id": "86385a6e-f66a-4381-8d81-540ce7c59aa5", + "prevId": "cc66b510-d9f2-4926-a86f-97f38ab69432", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": ["automation_id", "automation_version"], + "columnsTo": ["automation_id", "version"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": ["automation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": ["conversation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_plugin_report_idx": { + "name": "junior_conversation_events_plugin_report_idx", + "columns": [ + { + "expression": "(\"payload\"->>'namespace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'name')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'structured_event'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": ["conversation_id", "run_id", "metric"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": ["user_id", "root_conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["forked_from_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": ["location_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": ["workspace_id", "provider", "repo"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "vcpus": { + "name": "vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index d89871bfb7..a444ff22f4 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -449,6 +449,13 @@ "when": 1793202150781, "tag": "0063_agent_invocation_type", "breakpoints": true + }, + { + "idx": 64, + "version": "7", + "when": 1793326683095, + "tag": "0064_any_resource_event_triggers", + "breakpoints": true } ] } diff --git a/packages/junior/src/api/schema/automation.ts b/packages/junior/src/api/schema/automation.ts index 00adb58caf..b598bdac43 100644 --- a/packages/junior/src/api/schema/automation.ts +++ b/packages/junior/src/api/schema/automation.ts @@ -10,6 +10,8 @@ import { eventAutomationEditSchema, } from "@/chat/automations/edit-schema"; import { + eventMatchFieldsSchema, + eventReachSchema, pluginEventTypeSchema, taskOutcomeSchema, type EventTriggerDescription, @@ -406,9 +408,22 @@ export const automationEditErrorSchema = z export type AutomationEdit = z.output; export type AutomationUpdate = z.output; -/** Catalog metadata only; callbacks and provider connections stay on the server. */ +/** + * Catalog metadata only, one entry for each namespace. Callbacks, guidance, + * and provider connections stay on the server. `events` gives the reach of + * each event once, and `anyResourceFields` gives the filters of every + * resource type, for a trigger with no identifier. + */ export const automationEventCatalogSchema = z.array( - pluginEventTypeSchema.safeExtend({ namespace: z.string() }), + z + .object({ + namespace: z.string(), + displayName: z.string(), + events: z.record(z.string(), z.object({ reach: eventReachSchema })), + anyResourceFields: eventMatchFieldsSchema, + resourceTypes: z.array(pluginEventTypeSchema), + }) + .strict(), ); export const automationScheduleIntentSchema = scheduleIntentSchema; export const automationSchedulePreviewSchema = scheduledAutomationSchema diff --git a/packages/junior/src/chat/automations/collection.ts b/packages/junior/src/chat/automations/collection.ts index d4c979fc5c..c1eb7dc70c 100644 --- a/packages/junior/src/chat/automations/collection.ts +++ b/packages/junior/src/chat/automations/collection.ts @@ -1,7 +1,22 @@ -import { automationEventCatalog } from "@/chat/events/catalog"; +import { + automationEventCatalog, + registeredEventTriggerProblems, +} from "@/chat/events/catalog"; import { getEventCatalog } from "@/chat/events/runtime-catalog"; -import type { EventMatchFields, User } from "@sentry/junior-plugin-api"; -import { and, asc, count, desc, eq, inArray, ne, or, sql } from "drizzle-orm"; +import { eventAutomationTriggerSchema } from "@/chat/event-automations/types"; +import type { User } from "@sentry/junior-plugin-api"; +import { + and, + asc, + count, + desc, + eq, + inArray, + ne, + notInArray, + or, + sql, +} from "drizzle-orm"; import type { AutomationListQuery } from "@/api/schema/automation"; import { getDb } from "@/chat/db"; import { @@ -37,41 +52,6 @@ export function viewerAutomationCollection(user: User) { publicDestination, ne(automation.status, "completed"), )!; - // Mirror `eventTriggerProblems`, which the direct reader uses: a trigger is - // available when no resource type, event, or filter problem applies. - const trigger = sql`${automation.record}->'trigger'`; - const match = sql`coalesce(${trigger}->'match', '{}'::jsonb)`; - const json = (value: unknown) => sql`${JSON.stringify(value)}::jsonb`; - const fieldWorks = (key: string, field: EventMatchFields[string]) => { - const value = sql`${match}->(${key}::text)`; - const type = sql`jsonb_typeof(${value})`; - return sql`(${value} is null or (${and( - field.events - ? sql`${trigger}->'events' <@ ${json(field.events)}` - : undefined, - field.kind === "boolean" - ? sql`${type} = 'boolean'` - : sql`(${type} = ${field.kind} or (${type} = 'array' and not exists (select 1 from jsonb_array_elements(${value}) entry where jsonb_typeof(entry) <> ${field.kind})))`, - field.enum ? sql`${value} <@ ${json(field.enum)}` : undefined, - )}))`; - }; - const available = - or( - isScheduled, - ...Object.entries(automationEventCatalog(getEventCatalog())).flatMap( - ([namespace, registration]) => - registration.resourceTypes.map((resourceType) => { - const fields = Object.entries(resourceType.matchFields ?? {}); - return and( - eq(automation.namespace, namespace), - sql`${trigger}->>'resourceType' = ${resourceType.type}`, - sql`${trigger}->'events' <@ ${json(resourceType.supportedEvents)}`, - sql`not exists (select 1 from jsonb_object_keys(${match}) match_key where not (${json(fields.map(([key]) => key))} @> to_jsonb(match_key)))`, - ...fields.map(([key, field]) => fieldWorks(key, field)), - ); - }), - ), - ) ?? sql`false`; const runs = juniorAutomationRuns; const lastRunNeedsAttention = sql`coalesce((select ${runs.status} in ('failed', 'blocked') from ${runs} where ${runs.automationId} = ${automation.id} and ${runs.status} in ('blocked', 'completed', 'failed') and ${runs.completedAtMs} is not null @@ -83,11 +63,10 @@ export function viewerAutomationCollection(user: User) { title: automation.title, createdAtMs: automation.createdAtMs, state: sql`${automation.status}`.as("state"), - unavailable: sql`not (${available})`.as("unavailable"), - attention: - sql`${automation.status} not in ('paused', 'completed') and (${automation.status} = 'blocked' or not (${available}) or ${lastRunNeedsAttention})`.as( - "attention", - ), + lastRunNeedsAttention: lastRunNeedsAttention.as( + "last_run_needs_attention", + ), + trigger: sql`${automation.record}->'trigger'`.as("trigger"), owned: sql`${owned}`.as("owned"), resource: sql`case when ${isScheduled} then '' else concat_ws(' ', ${automation.identifier}, ${automation.record}->'trigger'->>'label', ${automation.namespace}) end`.as( @@ -125,6 +104,28 @@ export function viewerAutomationCollection(user: User) { .as("accessible_automations"); } +/** + * List the accessible event automations whose trigger cannot work with the + * enabled plugins. `eventTriggerProblems` decides, the same check that the + * tools and the dashboard use, so SQL does not keep its own copy of the rules. + */ +async function unavailableEventAutomationIds( + collection: ReturnType, +): Promise { + const catalog = automationEventCatalog(getEventCatalog()); + const rows = await getDb() + .select({ id: collection.id, trigger: collection.trigger }) + .from(collection) + .where(eq(collection.kind, "event")); + return rows.flatMap((row) => { + const trigger = eventAutomationTriggerSchema.safeParse(row.trigger); + const available = + trigger.success && + registeredEventTriggerProblems(catalog, trigger.data).length === 0; + return available ? [] : [row.id]; + }); +} + /** Read an SQL page and complete, access-scoped counts and filter options. */ export async function readAutomationCollection( user: User, @@ -132,19 +133,32 @@ export async function readAutomationCollection( ) { const db = getDb(); const collection = viewerAutomationCollection(user); + const unavailableIds = await unavailableEventAutomationIds(collection); + const unavailable = + unavailableIds.length > 0 + ? inArray(collection.id, unavailableIds) + : sql`false`; + const attention = and( + notInArray(collection.state, ["paused", "completed"]), + or( + eq(collection.state, "blocked"), + unavailable, + eq(collection.lastRunNeedsAttention, true), + ), + ); const where = and( input.scope === "mine" ? eq(collection.owned, true) : input.scope === "public" ? eq(collection.isPublic, true) : input.scope === "attention" - ? eq(collection.attention, true) + ? attention : undefined, input.type === "all" ? undefined : eq(collection.kind, input.type), input.state === "all" ? undefined : input.state === "unavailable" - ? eq(collection.unavailable, true) + ? unavailable : eq(collection.state, input.state), input.creator ? eq(collection.creator, input.creator) : undefined, input.destination diff --git a/packages/junior/src/chat/automations/edit-schema.ts b/packages/junior/src/chat/automations/edit-schema.ts index 57f8e182f9..728dccd9a6 100644 --- a/packages/junior/src/chat/automations/edit-schema.ts +++ b/packages/junior/src/chat/automations/edit-schema.ts @@ -42,12 +42,13 @@ export const scheduledAutomationEditSchema = automationEditFieldsSchema export const eventAutomationEditSchema = automationEditFieldsSchema .extend({ trigger: eventAutomationTriggerSchema - .extend({ + .safeExtend({ identifier: z .string() .trim() .min(1) - .max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH), + .max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH) + .optional(), label: z.string().trim().min(1).max(500), }) .optional(), diff --git a/packages/junior/src/chat/automations/editor.ts b/packages/junior/src/chat/automations/editor.ts index 0d72355f99..0bcbdb11d8 100644 --- a/packages/junior/src/chat/automations/editor.ts +++ b/packages/junior/src/chat/automations/editor.ts @@ -10,14 +10,24 @@ import { AutomationEditError } from "./edit-rules"; import { readViewerAutomationEdit } from "./edit"; import type { User } from "@sentry/junior-plugin-api"; -/** Return only Event types that can start a durable Automation. */ +/** + * Return only Event types that can start a durable Automation, one entry for + * each namespace, with the reach of each event. + */ export function readAutomationEventCatalog() { - return Object.entries(automationEventCatalog(getEventCatalog())).flatMap( - ([namespace, registration]) => - registration.resourceTypes.map((resource) => ({ - namespace, - ...resource, - })), + return Object.entries(automationEventCatalog(getEventCatalog())).map( + ([namespace, registration]) => ({ + namespace, + displayName: registration.displayName ?? namespace, + events: Object.fromEntries( + Object.entries(registration.events).map(([eventType, declaration]) => [ + eventType, + { reach: declaration.reach }, + ]), + ), + anyResourceFields: registration.anyResourceFields, + resourceTypes: registration.resourceTypes, + }), ); } diff --git a/packages/junior/src/chat/automations/read.ts b/packages/junior/src/chat/automations/read.ts index 4d8ba8181e..901434e546 100644 --- a/packages/junior/src/chat/automations/read.ts +++ b/packages/junior/src/chat/automations/read.ts @@ -316,7 +316,9 @@ function eventAutomationSummary( kind: "event", ...executionSummaryFields(stats), ownedByViewer, - resource: `${task.trigger.label} · ${task.trigger.identifier}`, + resource: task.trigger.identifier + ? `${task.trigger.label} · ${task.trigger.identifier}` + : task.trigger.label, source: task.trigger.namespace, credentialMode: task.credentialMode, outcomes: effectiveTaskOutcomes(task.outcomes, task.destination), diff --git a/packages/junior/src/chat/event-automations/README.md b/packages/junior/src/chat/event-automations/README.md index 3d28fc2e05..b2913ba7e4 100644 --- a/packages/junior/src/chat/event-automations/README.md +++ b/packages/junior/src/chat/event-automations/README.md @@ -21,9 +21,21 @@ Watch delivers into the Conversation that created it, so this needs a decision about which credentials those Turns use. The consecutive automated-turn limit also stops a Watch on a busy channel. -One selector has one Slack workspace, namespace, identifier, and one or more -event types. Multiple automations can use the same selector. -`resourceType` and `label` are presentation metadata, not match keys. +One selector has one Slack workspace, namespace, and one or more event types. +It has one identifier, or none. A selector with no identifier and no resource +type runs for every resource of its namespace that its `match` filters accept. +It can select only events whose Event reach allows it, and it must set the +filters that the reach names. Its `identifier` column is null. Multiple +automations can use the same selector. `resourceType` and `label` are +presentation metadata, not match keys. + +The runtime gate checks each row before it runs. A saved trigger with an +identifier must have the same identifier in its row. A row with no identifier +must have a trigger that the enabled catalog accepts, so a plugin change that +narrows an event also stops old broad rows. Core logs and skips a row that does +not parse or that the gate rejects, so one bad row cannot stop ingest for its +namespace. The update tool keeps the saved identifier and resource type when +they are left out. Only an explicit `null` removes them. `searchEventTypes` exposes the same enabled plugin catalog used by watches and event automations. Create and update tools accept only diff --git a/packages/junior/src/chat/event-automations/edit.ts b/packages/junior/src/chat/event-automations/edit.ts index e259257957..b0480fb195 100644 --- a/packages/junior/src/chat/event-automations/edit.ts +++ b/packages/junior/src/chat/event-automations/edit.ts @@ -5,13 +5,10 @@ import { AutomationEditError, editAutomationFields, } from "@/chat/automations/edit-rules"; -import { - requireEventIdentifier, - type EventCatalog, -} from "@/chat/events/catalog"; +import type { EventCatalog } from "@/chat/events/catalog"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { requireSlackChannelMessageScope } from "@/chat/slack/channel-message-events"; -import { requireSupportedEventAutomationTrigger } from "./tool-support"; +import { requireEventAutomationTrigger } from "./tool-support"; import type { EventAutomation } from "./types"; import type { z } from "zod"; @@ -45,16 +42,7 @@ export async function editEventAutomation( current.trigger.resourceType !== input.trigger.resourceType ) { try { - const match = requireSupportedEventAutomationTrigger( - catalog, - input.trigger, - ); - trigger = { - ...input.trigger, - identifier: requireEventIdentifier(catalog, input.trigger), - events: [...new Set(input.trigger.events)], - match, - }; + trigger = requireEventAutomationTrigger(catalog, input.trigger); } catch (error) { if (error instanceof ToolInputError) throw new AutomationEditError(error.message, "trigger"); diff --git a/packages/junior/src/chat/event-automations/ingest.ts b/packages/junior/src/chat/event-automations/ingest.ts index fc2379c00f..f284e6a317 100644 --- a/packages/junior/src/chat/event-automations/ingest.ts +++ b/packages/junior/src/chat/event-automations/ingest.ts @@ -37,7 +37,7 @@ import { logWarn } from "@/chat/logging"; import { captureMessage } from "@/chat/sentry"; import type { EventAutomation } from "@/chat/event-automations/types"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; -import { eventGuidance } from "@/chat/events/catalog"; +import { eventGuidance, type EventCatalog } from "@/chat/events/catalog"; import { getEventCatalog } from "@/chat/events/runtime-catalog"; /** Bind provider delivery identity to one automation's durable dispatch. */ @@ -57,13 +57,9 @@ function eventInput( task: EventAutomation, event: Event, outcomes: TaskOutcome[], + catalog: EventCatalog, ): string { - const guidance = eventGuidance( - getEventCatalog(), - event.namespace, - task.trigger.resourceType, - event.eventType, - ); + const guidance = eventGuidance(catalog, event.namespace, event.eventType); return renderTaskInput({ about: task.trigger.label, creator: slackMention(task.createdBy.slackUserId), @@ -78,7 +74,14 @@ function eventInput( }); } -/** Match a normalized event and dispatch every matching automation. */ +/** + * Match a normalized event and dispatch every matching automation. + * + * Plugins publish one copy of an occurrence for each scope, such as a pull + * request and its repository. All copies share one event key. An automation + * with no identifier matches every copy, and the dispatch key and the run + * limit both use the event key, so it runs and counts once. + */ export async function ingestEventAutomations( input: unknown, options: { @@ -90,8 +93,9 @@ export async function ingestEventAutomations( const event = eventSchema.parse(input); const db = getDb(); const nowMs = options.nowMs ?? Date.now(); + const catalog = getEventCatalog(); const tasks = ( - await findMatchingEventAutomations(db, event, options.teamId) + await findMatchingEventAutomations(db, event, options.teamId, catalog) ).filter((task) => slackChannelMessageRunsAutomation(event, task)); let dispatched = 0; const errors: unknown[] = []; @@ -128,7 +132,8 @@ export async function ingestEventAutomations( "app.event_automation.max_runs": botConfig.maxEventAutomationRuns, "app.event_automation.run_window_ms": botConfig.eventAutomationRunWindowMs, - "app.event_automation.resource_type": task.trigger.resourceType, + "app.event_automation.resource_type": + task.trigger.resourceType ?? "any", "app.task.id": task.id, }, }); @@ -163,7 +168,7 @@ export async function ingestEventAutomations( ...(credentialSubject ? { credentialSubject } : undefined), destination, destinationVisibility: task.destinationVisibility, - input: eventInput(task, event, outcomes), + input: eventInput(task, event, outcomes, catalog), metadata: { eventAutomationId: task.id }, replyAttribution: automationReplyAttribution(task.title), outcomes, diff --git a/packages/junior/src/chat/event-automations/run-limit.ts b/packages/junior/src/chat/event-automations/run-limit.ts index 7e4db17d92..4550dd56fe 100644 --- a/packages/junior/src/chat/event-automations/run-limit.ts +++ b/packages/junior/src/chat/event-automations/run-limit.ts @@ -12,8 +12,14 @@ * entry with its own key. The append is atomic, so the list order decides * which runs fit, even when Events arrive at the same moment. The first runs up to the cap * pass, and the rest are refused. A provider retry has the same run key, so it - * gets the same answer and does not count again. No lock and no - * read-modify-write are needed. + * gets the same answer and does not count again. No lock is needed. + * + * A run does not append its key when the list already has it. A trigger with + * no identifier gets one copy of an Event for each scope, such as each + * project of a metric alert, and all copies have one run key. Repeated + * entries would fill the list and block the automation, so the limit stays + * the same however broad the trigger is. Two copies at the same moment can + * still both append, and they count as one run. */ import type { StateAdapter } from "chat"; import { z } from "zod"; @@ -79,6 +85,16 @@ export function eventAutomationRunLimitReason(): string { ); } +// True when the key has an entry after the last reset entry. +function hasRunKey(entries: readonly RunEntry[], runKey: string): boolean { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]!; + if ("reset" in entry) return false; + if (entry.id === runKey) return true; + } + return false; +} + /** * Count one run of an Event automation, or refuse when its window is full. * @@ -92,16 +108,23 @@ export async function admitEventAutomationRun(args: { state?: StateAdapter; }): Promise<"allowed" | "limited"> { const state = args.state ?? getStateAdapter(); - await appendRunEntry(state, args.automationId, { - atMs: args.nowMs, - id: args.runKey, - }); + await state.connect(); + const readEntries = async () => { + const values = await state.getList(runListKey(args.automationId)); + const entries = values.flatMap((value) => { + const entry = runEntrySchema.safeParse(value); + return entry.success ? [entry.data] : []; + }); + return { entries, length: values.length }; + }; + if (!hasRunKey((await readEntries()).entries, args.runKey)) { + await appendRunEntry(state, args.automationId, { + atMs: args.nowMs, + id: args.runKey, + }); + } const windowStartMs = args.nowMs - botConfig.eventAutomationRunWindowMs; - const values = await state.getList(runListKey(args.automationId)); - const entries = values.flatMap((value) => { - const entry = runEntrySchema.safeParse(value); - return entry.success ? [entry.data] : []; - }); + const { entries, length } = await readEntries(); // Only runs after the last reset count. A retry finds its entry there too, // so a resume or revive also clears the answer for an earlier delivery. let lastReset = -1; @@ -113,7 +136,7 @@ export async function admitEventAutomationRun(args: { const oldest = entries[0]; const full = lastReset === -1 && - values.length >= runListMaxLength() && + length >= runListMaxLength() && oldest !== undefined && "atMs" in oldest && oldest.atMs > windowStartMs; diff --git a/packages/junior/src/chat/event-automations/store.ts b/packages/junior/src/chat/event-automations/store.ts index 4af6b39b75..80aba23238 100644 --- a/packages/junior/src/chat/event-automations/store.ts +++ b/packages/junior/src/chat/event-automations/store.ts @@ -1,9 +1,21 @@ import { requireAutomationRevision } from "@/chat/automations/revision"; import { recordAutomationVersion } from "@/chat/automations/versions"; -import { eventMatches, type Event, type User } from "@sentry/junior-plugin-api"; -import { and, asc, desc, eq, inArray, ne, or, sql } from "drizzle-orm"; +import { + eventMatches, + type Event, + type EventData, + type EventMatch, + type User, +} from "@sentry/junior-plugin-api"; +import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; import { juniorAutomations } from "@/db/schema/automations"; +import { + automationEventCatalog, + registeredEventTriggerProblems, + type EventCatalog, +} from "@/chat/events/catalog"; +import { logWarn } from "@/chat/logging"; import { clearEventAutomationRuns } from "./run-limit"; import { eventAutomationSchema, @@ -41,7 +53,7 @@ function eventAutomationColumns(task: EventAutomation | StoredEventAutomation) { channelId: record.destination.channelId, creatorIdentityId, creatorSlackUserId: record.createdBy.slackUserId, - identifier: record.trigger.identifier, + identifier: record.trigger.identifier ?? null, instruction: record.task.text, namespace: record.trigger.namespace, record, @@ -50,16 +62,46 @@ function eventAutomationColumns(task: EventAutomation | StoredEventAutomation) { }; } -/** Decode an event Automation row with its SQL-backed fields. */ -export function parseEventAutomationRow( - row: AutomationRow, -): StoredEventAutomation { - const task = eventAutomationSchema.parse({ +function eventAutomationRowInput(row: AutomationRow) { + return { ...row.record, createdAtMs: row.createdAtMs, creatorIdentityId: row.creatorIdentityId, id: row.id, + }; +} + +/** Decode an event Automation row with its SQL-backed fields. */ +export function parseEventAutomationRow( + row: AutomationRow, +): StoredEventAutomation { + return withRowFields( + row, + eventAutomationSchema.parse(eventAutomationRowInput(row)), + ); +} + +/** + * Decode a row for Event matching. Log and skip a row that does not parse, + * so one bad row cannot stop matching for its whole namespace. + */ +function readMatchableEventAutomationRow( + row: AutomationRow, +): StoredEventAutomation | undefined { + const parsed = eventAutomationSchema.safeParse(eventAutomationRowInput(row)); + if (parsed.success) return withRowFields(row, parsed.data); + logWarn("event_automation.row.invalid", { + "app.task.id": row.id, + "app.task.type": "event", + "error.message": parsed.error.message, }); + return undefined; +} + +function withRowFields( + row: AutomationRow, + task: EventAutomation, +): StoredEventAutomation { const title = row.title?.trim(); return { ...task, @@ -243,25 +285,75 @@ export async function listDeletedEventAutomationsCreatedBy( } /** - * Collect match keys from live event automations for these identifiers and event types. - * Does not evaluate match values — only reports keys that filters use. + * Select live rows whose identifier is one of these, and rows with no + * identifier. A row with no identifier runs for every resource of its + * namespace, so it gets every copy of an Event. */ -export async function collectEventAutomationMatchKeys( +function eventIdentifierWhere(identifiers: readonly string[]) { + return or( + isNull(juniorAutomations.identifier), + ...(identifiers.length > 0 + ? [inArray(juniorAutomations.identifier, [...identifiers])] + : []), + ); +} + +/** SQL for rows whose trigger selects any of these event types. */ +function eventTypesWhere(eventTypes: readonly string[]) { + return or( + ...eventTypes.map( + (eventType) => + sql`${juniorAutomations.record}->'trigger'->'events' @> ${JSON.stringify([eventType])}::jsonb`, + ), + ); +} + +/** + * Return why a row must not run for an Event with one of these identifiers, + * or nothing when it may. + * + * The identifier column and the saved trigger must agree. A row with no + * identifier must also have a trigger that the enabled `catalog` accepts. A + * plugin can later require a filter or stop letting an event run for every + * resource, and the row must not stay broad. + */ +function eventAutomationGateProblem( + row: AutomationRow, + task: StoredEventAutomation, + identifiers: readonly string[], + catalog: EventCatalog, +): string | undefined { + const identifier = task.trigger.identifier; + if (identifier !== undefined) { + return identifier === row.identifier && identifiers.includes(identifier) + ? undefined + : "The saved trigger has a different identifier from its row."; + } + if (row.identifier !== null) { + return "The saved trigger has no identifier, but its row has one."; + } + const problems = registeredEventTriggerProblems(catalog, task.trigger); + return problems.length > 0 + ? problems.map((problem) => problem.message).join(" ") + : undefined; +} + +/** + * Read the rows that can match an Event: live rows of one team and + * namespace whose identifier is one of these, or that have no identifier, + * and that select one of these event types. Skip and log a row that does + * not parse or that the gate rejects. + */ +async function readGatedEventAutomations( db: JuniorDatabase, input: { - eventTypes: string[]; - identifiers: string[]; + catalog: EventCatalog; + eventTypes: readonly string[]; + identifiers: readonly string[]; namespace: string; teamId: string; }, -): Promise { - const eventTypes = new Set( - input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean), - ); - const identifiers = [ - ...new Set(input.identifiers.map((value) => value.trim()).filter(Boolean)), - ]; - if (eventTypes.size === 0 || identifiers.length === 0) return []; +): Promise { const rows = await db .select() .from(juniorAutomations) @@ -270,15 +362,84 @@ export async function collectEventAutomationMatchKeys( eq(juniorAutomations.kind, "event"), eq(juniorAutomations.teamId, input.teamId), eq(juniorAutomations.namespace, input.namespace), - inArray(juniorAutomations.identifier, identifiers), + eventIdentifierWhere(input.identifiers), + eventTypesWhere(input.eventTypes), eq(juniorAutomations.status, "active"), ), ) .orderBy(asc(juniorAutomations.createdAtMs), asc(juniorAutomations.id)); + const plugins = automationEventCatalog(input.catalog); + return rows.flatMap((row) => { + const task = readMatchableEventAutomationRow(row); + if (!task) return []; + const problem = eventAutomationGateProblem( + row, + task, + input.identifiers, + plugins, + ); + if (problem === undefined) return [task]; + logWarn("event_automation.gate.rejected", { + "app.task.id": task.id, + "app.task.type": "event", + "app.event_automation.skip_reason": problem, + }); + return []; + }); +} + +/** The filters of a match whose keys are in this data. */ +function knownMatch( + match: EventMatch | undefined, + data: EventData, +): EventMatch | undefined { + if (!match) return undefined; + return Object.fromEntries( + Object.entries(match).filter(([key]) => data[key] !== undefined), + ) as EventMatch; +} + +/** + * Collect match keys from live event automations for these identifiers and + * event types, and from automations with no identifier. Does not evaluate + * match values, except for automations with no identifier: one whose + * filters on the keys in `data` fail adds no keys. So a broad trigger for + * one repository does not make every repository load its lazy data. + */ +export async function collectEventAutomationMatchKeys( + db: JuniorDatabase, + input: { + catalog: EventCatalog; + data?: EventData; + eventTypes: string[]; + identifiers: string[]; + namespace: string; + teamId: string; + }, +): Promise { + const eventTypes = [ + ...new Set( + input.eventTypes.map((eventType) => eventType.trim()).filter(Boolean), + ), + ]; + const identifiers = [ + ...new Set(input.identifiers.map((value) => value.trim()).filter(Boolean)), + ]; + if (eventTypes.length === 0) return []; + const tasks = await readGatedEventAutomations(db, { + catalog: input.catalog, + eventTypes, + identifiers, + namespace: input.namespace, + teamId: input.teamId, + }); const keys = new Set(); - for (const row of rows) { - const task = parseEventAutomationRow(row); - if (!task.trigger.events.some((eventType) => eventTypes.has(eventType))) { + for (const task of tasks) { + if ( + task.trigger.identifier === undefined && + input.data && + !eventMatches(knownMatch(task.trigger.match, input.data), input.data) + ) { continue; } for (const key of Object.keys(task.trigger.match ?? {})) { @@ -288,32 +449,30 @@ export async function collectEventAutomationMatchKeys( return [...keys].sort(); } -/** Find every live task matching one normalized event. */ +/** + * Find every live task matching one normalized event: tasks for its exact + * identifier, and tasks with no identifier whose filters match. The gate + * skips a row whose trigger and identifier column disagree, and a row with + * no identifier whose trigger the enabled `catalog` rejects. + */ export async function findMatchingEventAutomations( db: JuniorDatabase, event: Event, teamId: string, + catalog: EventCatalog, ): Promise { - const rows = await db - .select() - .from(juniorAutomations) - .where( - and( - eq(juniorAutomations.kind, "event"), - eq(juniorAutomations.teamId, teamId), - eq(juniorAutomations.namespace, event.namespace), - eq(juniorAutomations.identifier, event.identifier), - eq(juniorAutomations.status, "active"), - ), - ) - .orderBy(asc(juniorAutomations.createdAtMs), asc(juniorAutomations.id)); - return rows - .map(parseEventAutomationRow) - .filter( - (task) => - task.trigger.events.includes(event.eventType) && - eventMatches(task.trigger.match, event.data), - ); + const tasks = await readGatedEventAutomations(db, { + catalog, + eventTypes: [event.eventType], + identifiers: [event.identifier], + namespace: event.namespace, + teamId, + }); + return tasks.filter( + (task) => + task.trigger.events.includes(event.eventType) && + eventMatches(task.trigger.match, event.data), + ); } /** diff --git a/packages/junior/src/chat/event-automations/tool-support.ts b/packages/junior/src/chat/event-automations/tool-support.ts index c6650e6556..6caca2fb6e 100644 --- a/packages/junior/src/chat/event-automations/tool-support.ts +++ b/packages/junior/src/chat/event-automations/tool-support.ts @@ -24,6 +24,7 @@ import { registeredEventTypeSchema, registeredEventMatchSchema, registeredResourceTypeSchema, + requireEventIdentifier, requireValidEventTrigger, type EventCatalog, } from "@/chat/events/catalog"; @@ -43,8 +44,8 @@ const compactEventAutomationResultSchema = z trigger: z .object({ namespace: z.string().min(1), - identifier: z.string().min(1), - resourceType: z.string().min(1), + identifier: z.string().min(1).nullable(), + resourceType: z.string().min(1).nullable(), label: z.string().min(1), events: z.array(z.string().min(1)).min(1), match: z.record(z.string(), z.unknown()).optional(), @@ -104,8 +105,18 @@ export function registeredEventAutomationTriggerSchema(catalog: EventCatalog) { .string() .trim() .min(1) - .max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH), - resourceType: registeredResourceTypeSchema(plugins), + .max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH) + .nullable() + .describe( + 'Exact resource identifier. Use null, with a null resourceType, to run for every resource of the namespace. Only events whose searchEventTypes reach is "all" or { anyOf } can run that way, and match must set one of the anyOf filters. When creating, omit it to run for every resource. When updating, omit it to keep the saved identifier.', + ) + .optional(), + resourceType: registeredResourceTypeSchema(plugins) + .nullable() + .describe( + "Resource type of the identifier. Use null when identifier is null. When updating, omit it to keep the saved resource type.", + ) + .optional(), label: z.string().trim().min(1).max(500), events: z.array(registeredEventTypeSchema(plugins)).min(1), match: registeredEventMatchSchema(), @@ -113,21 +124,52 @@ export function registeredEventAutomationTriggerSchema(catalog: EventCatalog) { .strict(); } -/** Reject an event-automation trigger that cannot work with the enabled plugins. */ -export function requireSupportedEventAutomationTrigger( +/** + * Check a new event-automation trigger with the enabled plugins and return + * it as it is saved. It names a resource type and an identifier, or neither. + * The identifier gets its plugin's convention. Events and list values lose + * repeats. + */ +export function requireEventAutomationTrigger( catalog: EventCatalog, - trigger: { - events: string[]; + input: { + events: readonly string[]; + identifier?: string | null; + label: string; match?: EventAutomation["trigger"]["match"]; namespace: string; - resourceType: string; + resourceType?: string | null; }, -): EventAutomation["trigger"]["match"] | undefined { - return requireValidEventTrigger( +): EventAutomation["trigger"] { + const identifier = input.identifier ?? undefined; + const resourceType = input.resourceType ?? undefined; + if ((identifier === undefined) !== (resourceType === undefined)) { + throw new ToolInputError( + "Set both resourceType and identifier for one resource, or leave out both to run for every resource.", + ); + } + const events = [...new Set(input.events)]; + const match = requireValidEventTrigger( automationEventCatalog(catalog), - trigger, + { namespace: input.namespace, resourceType, events, match: input.match }, "event_automation", ); + return { + namespace: input.namespace, + ...(identifier !== undefined && resourceType !== undefined + ? { + identifier: requireEventIdentifier(catalog, { + identifier, + namespace: input.namespace, + resourceType, + }), + resourceType, + } + : undefined), + label: input.label, + events, + ...(match ? { match } : undefined), + }; } /** Require the active Slack authority used for event-automation management. */ @@ -233,8 +275,8 @@ export function compactEventAutomation( instruction: task.task.text, trigger: { namespace: task.trigger.namespace, - identifier: task.trigger.identifier, - resourceType: task.trigger.resourceType, + identifier: task.trigger.identifier ?? null, + resourceType: task.trigger.resourceType ?? null, label: task.trigger.label, events: task.trigger.events, ...(task.trigger.match ? { match: task.trigger.match } : undefined), diff --git a/packages/junior/src/chat/event-automations/types.ts b/packages/junior/src/chat/event-automations/types.ts index f088750c0c..fa5e3841f7 100644 --- a/packages/junior/src/chat/event-automations/types.ts +++ b/packages/junior/src/chat/event-automations/types.ts @@ -20,17 +20,36 @@ export const eventAutomationPrincipalSchema = z }) .strict(); -/** Validate one persisted event-automation selector and its presentation metadata. */ +/** + * Validate one persisted event-automation selector and its presentation + * metadata. A trigger names one resource with a resource type and an + * identifier, or it names neither. With neither, it runs for every resource + * of the namespace that its filters match. + */ export const eventAutomationTriggerSchema = z .object({ events: z.array(eventTypeSchema).min(1), label: z.string().min(1), match: eventMatchSchema.optional(), namespace: z.string().min(1), - identifier: z.string().min(1).max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH), - resourceType: z.string().min(1), + identifier: z + .string() + .min(1) + .max(EVENT_AUTOMATION_IDENTIFIER_MAX_LENGTH) + .optional(), + resourceType: z.string().min(1).optional(), }) - .strict(); + .strict() + .refine( + (trigger) => + (trigger.identifier === undefined) === + (trigger.resourceType === undefined), + { + message: + "Set both a resource type and an identifier, or leave out both to run for every resource.", + path: ["identifier"], + }, + ); /** Event automations target a Slack channel or DM, never a message thread. */ const eventAutomationDestinationSchema = slackDestinationSchema.omit({ diff --git a/packages/junior/src/chat/events/README.md b/packages/junior/src/chat/events/README.md index 3a32e161bb..f68493dd15 100644 --- a/packages/junior/src/chat/events/README.md +++ b/packages/junior/src/chat/events/README.md @@ -112,9 +112,19 @@ Watches route events back into an existing conversation. and the dashboard editor both use it. Resource types that share match fields, such as a repository, merge them with `mergeEventMatchFields`. - An unchanged saved trigger is kept. When a plugin change gives it a problem, - core reports the trigger as unavailable and lists the problems. The SQL rule - in `automations/collection.ts` must give the same result as - `eventTriggerProblems`. + core reports the trigger as unavailable and lists the problems. The + dashboard list, the runtime gate, and the tools all use + `eventTriggerProblems`, so no other copy of the rules exists. +- Each source declares the Event reach and optional guidance of every event + type once, in `events`. Registration fails when an event type has no + declaration, or when an `{ anyOf }` filter is not set on that event. An event + automation trigger with no identifier can select only events with reach + `"all"` or `{ anyOf }`, and must set one of the `anyOf` filters. Core merges + the filters of every resource type once, when it builds the catalog. +- A match field may declare `case: "lower"` or `case: "upper"`. Core changes a + filter value to that case when it saves a trigger or a Watch, so a value such + as `getsentry/Junior` still matches lowercase event data. The plugin decides + the case; core only applies it. - A resource type may declare the shape of its identifiers. Resource types can share event types, so the identifier shows which one a selector names. Core rejects a watch or event automation whose identifier does not fit its @@ -144,9 +154,10 @@ Watches route events back into an existing conversation. one Slack user. If the lookup fails, core logs it and delivers the Event without people. People are only for mentions. They never grant authority. Guidance says when to address them. -- Resource types may declare optional app guidance per event type. Core inserts - that text only for the matching resource type. The prompt applies it within - the subscription intent or stored event automation instruction. Keep it separate +- A source may declare optional app guidance per event type, in `events`. Core + inserts that text for every Watch and event automation that gets the event, + with or without a resource type. The prompt applies it within the + subscription intent or stored event automation instruction. Keep it separate from trusted data and untrusted provider content. ## Add an Event Type @@ -159,13 +170,22 @@ Watches route events back into an existing conversation. 3. In the normalizer, parse the webhook with Zod. Return one `EventInput` for each identifier that should receive the event, such as the exact resource and its broader scopes. -4. Set the same `eventKey` on every copy, and a provider `occurredAtMs`. Set - `terminal` only on the copy for the exact resource, and only when the change - ends it. +4. Set the same `eventKey` and the same `data` on every copy, and a provider + `occurredAtMs`. A trigger with no identifier gets every copy, and core + matches its filters on each one. Set `terminal` only on the copy for the + exact resource, and only when the change ends it. 5. Put the values that filters use in `data`, and list them in `matchFields`. - Use the same keys as other events of the same kind from that service. -6. Add `guidance` when the agent needs a rule for this event. -7. Extend the plugin's webhook test with a delivery for the new event. + Use the same keys as other events of the same kind from that service. Set + `case` on a text field whose values have one case. +6. Declare the event in the source `events` with its reach. Use `"all"` only + for status facts that cause no side effects. Use `{ anyOf }` with the + filters that keep a trigger narrow when the event carries private text or + makes Junior act. Use `"resource"` when a trigger must name one resource. + Add `guidance` there when the agent needs a rule for this event. +7. When `data` needs a lookup, call `neededMatchKeys` with the values that the + plugin already has, such as `repo`. Core then skips triggers with no + identifier whose filters on those values fail. +8. Extend the plugin's webhook test with a delivery for the new event. ## Timer Watches diff --git a/packages/junior/src/chat/events/app-publisher.ts b/packages/junior/src/chat/events/app-publisher.ts index ce3a3bba92..f12cad6ddf 100644 --- a/packages/junior/src/chat/events/app-publisher.ts +++ b/packages/junior/src/chat/events/app-publisher.ts @@ -1,4 +1,4 @@ -import type { Event } from "@sentry/junior-plugin-api"; +import type { Event, EventData } from "@sentry/junior-plugin-api"; import type { StateAdapter } from "chat"; import { getDb } from "@/chat/db"; import { ingestEventAutomations } from "@/chat/event-automations/ingest"; @@ -11,6 +11,7 @@ import { collectEventMatchKeys, findMatchingWatches, } from "@/chat/events/store"; +import { getEventCatalog } from "@/chat/events/runtime-catalog"; import { createEventTeamIdResolver } from "@/chat/events/workspace"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-queue"; @@ -18,6 +19,7 @@ import { getVercelConversationWorkQueue } from "@/chat/task-execution/vercel-que export type EventAppPublisher = { hasMatch(event: Event): Promise; neededMatchKeys(input: { + data?: EventData; eventTypes: string[]; identifiers: string[]; namespace: string; @@ -48,7 +50,12 @@ export function createEventAppPublisher(args: { state: work.state, }), eventAutomationTeamId - ? findMatchingEventAutomations(getDb(), event, eventAutomationTeamId) + ? findMatchingEventAutomations( + getDb(), + event, + eventAutomationTeamId, + getEventCatalog(), + ) : Promise.resolve([]), ]); return subscriptions.length > 0 || tasks.length > 0; @@ -65,6 +72,8 @@ export function createEventAppPublisher(args: { }), eventAutomationTeamId ? collectEventAutomationMatchKeys(getDb(), { + catalog: getEventCatalog(), + ...(input.data ? { data: input.data } : undefined), eventTypes: input.eventTypes, identifiers: input.identifiers, namespace: input.namespace, diff --git a/packages/junior/src/chat/events/catalog.ts b/packages/junior/src/chat/events/catalog.ts index ceb2f9326f..e9128fd20d 100644 --- a/packages/junior/src/chat/events/catalog.ts +++ b/packages/junior/src/chat/events/catalog.ts @@ -1,11 +1,16 @@ import { + anyResourceMatchFields, describeEventTrigger, + eventTriggerChoices, eventTriggerProblems, normalizeEventIdentifier, + normalizeEventMatch, eventMatchSchema, + type EventMatchFields, type EventTriggerDescription, type EventTriggerProblem, type EventTriggerSelection, + type EventTriggerSource, type PluginEvents, type EventMatch, } from "@sentry/junior-plugin-api"; @@ -30,9 +35,10 @@ export type EventResourceTypeLimits = { } ); -type EventRegistration = Pick< +/** One source as a plugin or core registers it, before the catalog adds merged filters. */ +export type EventRegistrationInput = Pick< PluginEvents, - "resourceTypes" | "normalizeIdentifier" + "events" | "resourceTypes" | "normalizeIdentifier" > & { /** Name people see for the source, such as GitHub. Defaults to the namespace. */ displayName?: string; @@ -40,8 +46,34 @@ type EventRegistration = Pick< limits?: Readonly>; }; +type EventRegistration = EventRegistrationInput & { + /** The filters of every resource type, merged once when the catalog is built. */ + anyResourceFields: EventMatchFields; +}; + export type EventCatalog = Readonly>; +/** A source with nothing enabled, for a namespace that the catalog lacks. */ +const NO_SOURCE: EventTriggerSource = { + resourceTypes: [], + events: {}, + anyResourceFields: {}, +}; + +/** + * Build one catalog entry. It merges the filters of every resource type here, + * once, so trigger checks for each event copy and each row do not merge them + * again. + */ +export function eventCatalogEntry( + registration: EventRegistrationInput, +): EventRegistration { + return { + ...registration, + anyResourceFields: anyResourceMatchFields(registration.resourceTypes), + }; +} + /** Core-owned event namespace. Not a plugin publisher. */ export const CORE_EVENT_NAMESPACE = "junior"; @@ -64,9 +96,27 @@ export function automationEventCatalog(catalog: EventCatalog): EventCatalog { (resourceType) => registration.limits?.[resourceType.type]?.automation !== false, ); - return resourceTypes.length > 0 - ? [[namespace, { ...registration, resourceTypes }]] - : []; + if (resourceTypes.length === 0) return []; + if (resourceTypes.length === registration.resourceTypes.length) { + return [[namespace, registration]]; + } + const supported = new Set( + resourceTypes.flatMap((resourceType) => resourceType.supportedEvents), + ); + return [ + [ + namespace, + eventCatalogEntry({ + ...registration, + resourceTypes, + events: Object.fromEntries( + Object.entries(registration.events).filter(([eventType]) => + supported.has(eventType), + ), + ), + }), + ], + ]; }), ); } @@ -126,16 +176,17 @@ export function registeredResourceTypeSchema(catalog: EventCatalog) { ); } -/** Return app guidance for one registered resource type and event type. */ +/** + * Return app guidance for one event of a namespace. The source declares it + * once for the event, so every resource type and a trigger with no resource + * type get the same guidance. + */ export function eventGuidance( catalog: EventCatalog, namespace: string, - resourceType: string, eventType: string, ): string | undefined { - return catalog[namespace]?.resourceTypes.find( - (candidate) => candidate.type === resourceType, - )?.guidance?.[eventType]; + return catalog[namespace]?.events[eventType]?.guidance; } /** Build the match object the tools accept for one resource type. */ @@ -163,7 +214,7 @@ export function registeredEventTriggerProblems( options?: { inputKeys?: boolean }, ): EventTriggerProblem[] { return eventTriggerProblems( - catalog[selection.namespace]?.resourceTypes ?? [], + catalog[selection.namespace] ?? NO_SOURCE, selection, options, ); @@ -178,7 +229,7 @@ export function describeRegisteredEventTrigger( selection: RegisteredEventSelection, ): EventTriggerDescription { return describeEventTrigger( - catalog[selection.namespace]?.resourceTypes ?? [], + catalog[selection.namespace] ?? NO_SOURCE, selection, ); } @@ -195,7 +246,8 @@ function uniqueMatchValues(match: EventMatch | undefined) { /** * Reject an event automation trigger or a Watch that cannot work, with every - * problem in one message for the agent. Return its match without repeated + * problem in one message for the agent. Return its match as it is saved: + * each text value in the case that its plugin declares, with no repeated * list values. */ export function requireValidEventTrigger( @@ -203,13 +255,22 @@ export function requireValidEventTrigger( selection: RegisteredEventSelection, target: "event_automation" | "watch", ): EventMatch | undefined { - const problems = registeredEventTriggerProblems(catalog, selection, { + const fields = + eventTriggerChoices(catalog[selection.namespace] ?? NO_SOURCE, selection) + ?.fields ?? {}; + const normalized = { + ...selection, + match: normalizeEventMatch(fields, selection.match), + }; + const problems = registeredEventTriggerProblems(catalog, normalized, { inputKeys: true, }); if (problems.length > 0) { const source = catalog[selection.namespace]?.displayName ?? selection.namespace; - const resource = `${source} ${selection.resourceType.replaceAll("_", " ")}`; + const resource = selection.resourceType + ? `${source} ${selection.resourceType.replaceAll("_", " ")}` + : source; throw new ToolInputError( [ target === "watch" @@ -219,7 +280,7 @@ export function requireValidEventTrigger( ].join(" "), ); } - return uniqueMatchValues(selection.match); + return uniqueMatchValues(normalized.match); } /** diff --git a/packages/junior/src/chat/events/notification.ts b/packages/junior/src/chat/events/notification.ts index eadaa84743..94f116b77a 100644 --- a/packages/junior/src/chat/events/notification.ts +++ b/packages/junior/src/chat/events/notification.ts @@ -76,15 +76,10 @@ export function eventTaskFacts( /** Resolved person who did what the Event describes. */ doneBy?: EventDoneBy, ): EventTaskFacts { - // Resource type guidance applies to every Watch. Watch guidance comes from + // Plugin guidance for the event applies to every Watch. Watch guidance comes from // the tool that created this Watch and applies only here. const guidance = [ - eventGuidance( - getEventCatalog(), - event.namespace, - subscription.resourceType, - event.eventType, - ), + eventGuidance(getEventCatalog(), event.namespace, event.eventType), subscription.guidance?.[event.eventType], ] .filter((text): text is string => Boolean(text?.trim())) diff --git a/packages/junior/src/chat/events/runtime-catalog.ts b/packages/junior/src/chat/events/runtime-catalog.ts index f178a96079..10acebeda5 100644 --- a/packages/junior/src/chat/events/runtime-catalog.ts +++ b/packages/junior/src/chat/events/runtime-catalog.ts @@ -1,6 +1,10 @@ import { botConfig } from "@/chat/config"; import { getPlugins } from "@/chat/plugins/agent-hooks"; -import { CORE_EVENT_NAMESPACE, type EventCatalog } from "@/chat/events/catalog"; +import { + CORE_EVENT_NAMESPACE, + eventCatalogEntry, + type EventCatalog, +} from "@/chat/events/catalog"; import { workspaceSnapshotEvents } from "@/chat/sandbox/snapshot/events"; import { SLACK_EVENT_NAMESPACE, @@ -17,7 +21,7 @@ import { export function getEventCatalog(): EventCatalog { const snapshot = workspaceSnapshotEvents(); const catalog: Record = { - [CORE_EVENT_NAMESPACE]: { + [CORE_EVENT_NAMESPACE]: eventCatalogEntry({ displayName: botConfig.userName, resourceTypes: [ ...snapshot.resourceTypes, @@ -27,6 +31,10 @@ export function getEventCatalog(): EventCatalog { suggestedEvents: [], }, ], + events: { + ...snapshot.events, + "timer.fired": { reach: "resource" }, + }, limits: { ...snapshot.limits, timer: { @@ -35,11 +43,11 @@ export function getEventCatalog(): EventCatalog { watchUnavailableMessage: "Use watchTimer to create a timer Watch.", }, }, - }, - [SLACK_EVENT_NAMESPACE]: { + }), + [SLACK_EVENT_NAMESPACE]: eventCatalogEntry({ ...slackChannelMessageEvents(), displayName: "Slack", - }, + }), }; for (const plugin of getPlugins()) { const registration = plugin.events; @@ -54,10 +62,10 @@ export function getEventCatalog(): EventCatalog { `Plugin "${plugin.manifest.name}" cannot register events; that namespace is reserved for core events`, ); } - catalog[plugin.manifest.name] = { + catalog[plugin.manifest.name] = eventCatalogEntry({ ...registration, displayName: plugin.manifest.displayName, - }; + }); } return catalog; } diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index b3b333e952..17c2386367 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -6,6 +6,7 @@ import { pluginEventsSchema, promptContextSchema, promptMessageSchema, + eventDataSchema, eventInputSchema, } from "@sentry/junior-plugin-api"; import type { @@ -22,6 +23,7 @@ import type { PluginRouteApp, Platform, Event, + EventData, SlackConversationLink, PluginRegistration, SlackToolRegistrationHookContext, @@ -912,6 +914,7 @@ export function getPluginRoutes(options: { events: { hasMatch?(event: Event): Promise; neededMatchKeys?(input: { + data?: EventData; eventTypes: string[]; identifiers: string[]; namespace: string; @@ -970,6 +973,9 @@ export function getPluginRoutes(options: { return []; } return await options.events.neededMatchKeys({ + ...(input.data + ? { data: eventDataSchema.parse(input.data) } + : undefined), eventTypes: input.eventTypes, identifiers, namespace: pluginName, diff --git a/packages/junior/src/chat/sandbox/snapshot/events.ts b/packages/junior/src/chat/sandbox/snapshot/events.ts index 0a39b6ec20..5afbde3b29 100644 --- a/packages/junior/src/chat/sandbox/snapshot/events.ts +++ b/packages/junior/src/chat/sandbox/snapshot/events.ts @@ -1,5 +1,8 @@ import type { Event, SubscribableResource } from "@sentry/junior-plugin-api"; -import { CORE_EVENT_NAMESPACE, type EventCatalog } from "@/chat/events/catalog"; +import { + CORE_EVENT_NAMESPACE, + type EventRegistrationInput, +} from "@/chat/events/catalog"; export const WORKSPACE_SNAPSHOT_NAMESPACE = CORE_EVENT_NAMESPACE; export const WORKSPACE_SNAPSHOT_RESOURCE_TYPE = "workspace_snapshot"; @@ -13,7 +16,7 @@ export const WORKSPACE_SNAPSHOT_FAILED_EVENT = "workspace_snapshot.failed"; * schemas treat these events like any other resource type. Snapshot finish * does not run event-automation ingest, so only watches can use these events. */ -export function workspaceSnapshotEvents(): EventCatalog[string] { +export function workspaceSnapshotEvents(): EventRegistrationInput { return { resourceTypes: [ { @@ -24,14 +27,20 @@ export function workspaceSnapshotEvents(): EventCatalog[string] { ], // switchWorkspace already creates the temporary subscription. suggestedEvents: [], - guidance: { - [WORKSPACE_SNAPSHOT_READY_EVENT]: - "Call switchWorkspace again with the same Workspace name. Do not create another watch when switchWorkspace already returned a subscription.", - [WORKSPACE_SNAPSHOT_FAILED_EVENT]: - "Report the snapshot failure. Do not keep waiting for this Workspace unless the user asks to retry.", - }, }, ], + events: { + [WORKSPACE_SNAPSHOT_READY_EVENT]: { + reach: "resource", + guidance: + "Call switchWorkspace again with the same Workspace name. Do not create another watch when switchWorkspace already returned a subscription.", + }, + [WORKSPACE_SNAPSHOT_FAILED_EVENT]: { + reach: "resource", + guidance: + "Report the snapshot failure. Do not keep waiting for this Workspace unless the user asks to retry.", + }, + }, limits: { [WORKSPACE_SNAPSHOT_RESOURCE_TYPE]: { automation: false } }, }; } diff --git a/packages/junior/src/chat/slack/channel-message-events.ts b/packages/junior/src/chat/slack/channel-message-events.ts index 8fa2df589b..f64b60b5d7 100644 --- a/packages/junior/src/chat/slack/channel-message-events.ts +++ b/packages/junior/src/chat/slack/channel-message-events.ts @@ -11,7 +11,7 @@ import { type EventInput, } from "@sentry/junior-plugin-api"; import type { EventAutomation } from "@/chat/event-automations/types"; -import type { EventCatalog } from "@/chat/events/catalog"; +import type { EventRegistrationInput } from "@/chat/events/catalog"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; /** Core-owned event namespace for Slack Events. Not a plugin publisher. */ @@ -36,7 +36,7 @@ const CHANNEL_MESSAGE_MATCH_FIELDS = eventMatchFieldsSchema.parse({ * Event registration for new top-level Slack channel messages. Only event * automations can use it, so watchEvents rejects it. */ -export function slackChannelMessageEvents(): EventCatalog[string] { +export function slackChannelMessageEvents(): EventRegistrationInput { return { resourceTypes: [ { @@ -48,12 +48,17 @@ export function slackChannelMessageEvents(): EventCatalog[string] { supportedEvents: [MESSAGE_POSTED_EVENT], suggestedEvents: [], matchFields: CHANNEL_MESSAGE_MATCH_FIELDS, - guidance: { - [MESSAGE_POSTED_EVENT]: - "A person or bot posted a new top-level message in this channel. A bot post runs the automation only when match names the bot in userId. A reply goes in that message's thread. If the instructions do not apply to this message, end with no action.", - }, }, ], + events: { + // A channel automation stays in its own channel, so it needs the + // channel identifier until a channel filter can replace it. + [MESSAGE_POSTED_EVENT]: { + reach: "resource", + guidance: + "A person or bot posted a new top-level message in this channel. A bot post runs the automation only when match names the bot in userId. A reply goes in that message's thread. If the instructions do not apply to this message, end with no action.", + }, + }, limits: { [CHANNEL_RESOURCE_TYPE]: { watch: false, @@ -134,6 +139,9 @@ export function slackChannelMessageEvent(input: { /** * Decide whether a channel-message Event may run one automation. * + * A message runs only automations that post in its own channel, so private + * channel text cannot reach another channel. The trigger identifier already + * names that channel, and this check keeps the rule when it does not. * A bot post runs only automations whose match names its author in `userId`. * Bots can post often and would use up the run limit of every other * automation in the channel. @@ -143,6 +151,7 @@ export function slackChannelMessageRunsAutomation( automation: EventAutomation, ): boolean { if (event.namespace !== SLACK_EVENT_NAMESPACE) return true; + if (event.data?.channelId !== automation.destination.channelId) return false; if (event.data?.postedByBot !== true) return true; return automation.trigger.match?.userId !== undefined; } diff --git a/packages/junior/src/chat/tools/create-event-automation.ts b/packages/junior/src/chat/tools/create-event-automation.ts index f12f4b4b3f..5eb1df8f5c 100644 --- a/packages/junior/src/chat/tools/create-event-automation.ts +++ b/packages/junior/src/chat/tools/create-event-automation.ts @@ -17,15 +17,12 @@ import { eventAutomationToolResultSchema, registeredEventAutomationTriggerSchema, requireEventAutomationSlackContext, - requireSupportedEventAutomationTrigger, + requireEventAutomationTrigger, } from "@/chat/event-automations/tool-support"; import type { EventAutomation } from "@/chat/event-automations/types"; import { completeText } from "@/chat/pi/client"; import { requireSlackChannelMessageScope } from "@/chat/slack/channel-message-events"; -import { - requireEventIdentifier, - type EventCatalog, -} from "@/chat/events/catalog"; +import type { EventCatalog } from "@/chat/events/catalog"; import { resolveTaskTitle, SHORT_TITLE_MAX_LENGTH, @@ -76,7 +73,7 @@ export function createEventAutomationTool( }, executionMode: "sequential", description: - "Create a durable event automation in the active Slack channel or DM, never a thread. It executes the supplied instruction for every matching event. Use only when the user explicitly asks for an event automation or durable whenever-this-happens-do-X automation; ordinary watch, notify, or tell-me-when requests use watchEvents instead. When the resource and events are known, create it without redundant confirmation. The automation may use the creator's connected credentials. Prefer a subscribable tool result when available.", + "Create a durable event automation in the active Slack channel or DM, never a thread. It executes the supplied instruction for every matching event. Use only when the user explicitly asks for an event automation or durable whenever-this-happens-do-X automation; ordinary watch, notify, or tell-me-when requests use watchEvents instead. When the resource and events are known, create it without redundant confirmation. To run for every resource of a namespace, such as any Sentry metric alert, leave out identifier and resourceType and use match filters; searchEventTypes gives the reach of each event in namespaces, and an { anyOf } reach names the filters to choose from. The automation may use the creator's connected credentials. Prefer a subscribable tool result when available.", inputSchema: z .object({ instruction: automationInstructionToolSchema, @@ -132,11 +129,7 @@ export function createEventAutomationTool( const { actor, destination, source } = requireEventAutomationSlackContext(context); requireRequesterMention(input.instruction, actor); - const match = requireSupportedEventAutomationTrigger( - catalog, - input.trigger, - ); - const identifier = requireEventIdentifier(catalog, input.trigger); + const trigger = requireEventAutomationTrigger(catalog, input.trigger); const id = buildEventAutomationId({ channelId: destination.channelId, teamId: destination.teamId, @@ -200,14 +193,7 @@ export function createEventAutomationTool( ), task: { text: input.instruction }, ...(title ? { title } : undefined), - trigger: { - namespace: input.trigger.namespace, - identifier, - resourceType: input.trigger.resourceType, - label: input.trigger.label, - events: [...new Set(input.trigger.events)], - ...(match ? { match } : undefined), - }, + trigger, }); return eventAutomationToolResult( context.conversationId, diff --git a/packages/junior/src/chat/tools/search-event-types.ts b/packages/junior/src/chat/tools/search-event-types.ts index 96b12dbf6d..8ee82de9c1 100644 --- a/packages/junior/src/chat/tools/search-event-types.ts +++ b/packages/junior/src/chat/tools/search-event-types.ts @@ -1,4 +1,7 @@ -import { eventMatchFieldSchema } from "@sentry/junior-plugin-api"; +import { + eventMatchFieldSchema, + eventReachSchema, +} from "@sentry/junior-plugin-api"; import { z } from "zod"; import { eventNamespaceSchema, type EventCatalog } from "@/chat/events/catalog"; import { juniorToolOutputSchema } from "@/chat/tool-support/structured-result"; @@ -20,12 +23,21 @@ const searchedResourceTypeSchema = z }) .strict(); +// The reach of each event, once for each namespace in the results. +const searchedNamespaceSchema = z + .object({ + namespace: z.string(), + reach: z.record(z.string(), eventReachSchema), + }) + .strict(); + const outputSchema = juniorToolOutputSchema .extend({ query: z.string().nullable(), namespace: z.string().nullable(), totalMatches: z.number().int().nonnegative(), resourceTypes: z.array(searchedResourceTypeSchema), + namespaces: z.array(searchedNamespaceSchema), }) .strict(); @@ -36,6 +48,28 @@ function normalizeSearchText(value: string): string { .trim(); } +// The reach of the events of these resource types, once for each namespace. +function namespaceReach( + catalog: EventCatalog, + resourceTypes: readonly { namespace: string; supportedEvents: string[] }[], +) { + const events = new Map>(); + for (const resourceType of resourceTypes) { + const set = events.get(resourceType.namespace) ?? new Set(); + for (const eventType of resourceType.supportedEvents) set.add(eventType); + events.set(resourceType.namespace, set); + } + return [...events].map(([namespace, eventTypes]) => ({ + namespace, + reach: Object.fromEntries( + [...eventTypes].sort().flatMap((eventType) => { + const reach = catalog[namespace]?.events[eventType]?.reach; + return reach ? [[eventType, reach]] : []; + }), + ), + })); +} + function searchableResourceTypes(catalog: EventCatalog) { return Object.entries(catalog) .flatMap(([namespace, registration]) => @@ -71,7 +105,7 @@ export function createSearchEventTypesTool(catalog: EventCatalog) { readOnlyHint: true, }, description: - "Search the event types currently enabled without creating anything. Use only when the user asks what events are supported or the required resource type or event name is unclear. Includes plugin resources and core Workspace snapshot events. Use watchEvents for temporary updates in the current conversation; use createEventAutomation for durable automation. This tool does not watch a resource, create an automation, or list concrete resources.", + 'Search the event types currently enabled without creating anything. Use only when the user asks what events are supported or the required resource type or event name is unclear. Includes plugin resources and core Workspace snapshot events. namespaces gives the reach of each event. With reach "all" or { anyOf }, an event can start an event automation for every resource of its namespace: leave out identifier and resourceType, and narrow it with match filters from that namespace. For { anyOf }, set at least one of those filters. With reach "resource", the automation needs an identifier. Use watchEvents for temporary updates in the current conversation; use createEventAutomation for durable automation. This tool does not watch a resource, create an automation, or list concrete resources.', inputSchema: z .object({ query: z @@ -135,6 +169,7 @@ export function createSearchEventTypesTool(catalog: EventCatalog) { namespace: namespace ?? null, totalMatches: matches.length, resourceTypes, + namespaces: namespaceReach(catalog, resourceTypes), }; return { ...details, diff --git a/packages/junior/src/chat/tools/update-event-automation.ts b/packages/junior/src/chat/tools/update-event-automation.ts index be1753f2f5..58c540e2f5 100644 --- a/packages/junior/src/chat/tools/update-event-automation.ts +++ b/packages/junior/src/chat/tools/update-event-automation.ts @@ -139,7 +139,21 @@ export function createUpdateEventAutomationTool( { title: input.title, instruction: input.instruction ?? undefined, - trigger: input.trigger ?? undefined, + // An omitted identifier or resource type keeps the saved one. + // Only an explicit null makes the trigger run for every resource. + trigger: input.trigger + ? { + ...input.trigger, + identifier: + input.trigger.identifier === undefined + ? current.trigger.identifier + : (input.trigger.identifier ?? undefined), + resourceType: + input.trigger.resourceType === undefined + ? current.trigger.resourceType + : (input.trigger.resourceType ?? undefined), + } + : undefined, outcomes: input.outcomes ?? undefined, credentialMode: input.credentialMode ?? undefined, }, diff --git a/packages/junior/src/db/schema/automations.ts b/packages/junior/src/db/schema/automations.ts index b584bc603d..f627bddc69 100644 --- a/packages/junior/src/db/schema/automations.ts +++ b/packages/junior/src/db/schema/automations.ts @@ -15,7 +15,10 @@ import type { ScheduledAutomationRecord } from "@/chat/scheduled-automations/typ * One row per Automation of either kind. `record` holds the fields that have * no column. Each write also copies the nested fields that queries filter or * search on into columns: `team_id`, `channel_id`, `creator_slack_user_id`, - * `instruction`, and the event trigger `namespace` and `identifier`. + * `instruction`, and the event trigger `namespace` and `identifier`. An event + * trigger with no identifier runs for every resource of its namespace, so its + * `identifier` is null. The match index finds those rows with + * `identifier is null`. */ export const juniorAutomations = pgTable( "junior_automations", @@ -60,7 +63,7 @@ export const juniorAutomations = pgTable( ), check( "junior_automations_trigger_check", - sql`(${table.kind} = 'scheduled' and ${table.namespace} is null and ${table.identifier} is null) or (${table.kind} = 'event' and ${table.namespace} is not null and ${table.identifier} is not null and ${table.nextRunAtMs} is null and ${table.runNowAtMs} is null and ${table.status} <> 'completed')`, + sql`(${table.kind} = 'scheduled' and ${table.namespace} is null and ${table.identifier} is null) or (${table.kind} = 'event' and ${table.namespace} is not null and ${table.nextRunAtMs} is null and ${table.runNowAtMs} is null and ${table.status} <> 'completed')`, ), index("junior_automations_team_idx") .on(table.teamId, table.createdAtMs, table.id) diff --git a/packages/junior/tests/component/events/event-automation-match-data.test.ts b/packages/junior/tests/component/events/event-automation-match-data.test.ts new file mode 100644 index 0000000000..e6127f9d03 --- /dev/null +++ b/packages/junior/tests/component/events/event-automation-match-data.test.ts @@ -0,0 +1,105 @@ +import { createMemoryState } from "@chat-adapter/state-memory"; +import { githubPlugin } from "@sentry/junior-github"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineJuniorPlugins } from "@/app"; +import { closeDb, getDb, getSqlExecutor } from "@/chat/db"; +import { + createEventAutomation, + deleteEventAutomation, +} from "@/chat/event-automations/store"; +import { createEventAppPublisher } from "@/chat/events/app-publisher"; +import { upsertIdentity } from "@/chat/identities/sql"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { SLACK_DESTINATION } from "../../fixtures/conversation-work"; + +describe("event automation match data", () => { + afterEach(async () => { + await closeDb(); + await disconnectStateAdapter(); + }); + + it("loads filters for event automations with no identifier", async () => { + const automationId = `evt_any_resource_${Date.now()}`; + vi.stubEnv("GITHUB_WEBHOOK_SECRET", "test-secret"); + vi.stubEnv("SLACK_BOT_TOKEN", "xoxb-event-test"); + try { + await createApp({ plugins: defineJuniorPlugins([githubPlugin()]) }); + const creator = await upsertIdentity(getSqlExecutor(), { + kind: "user", + provider: "slack", + providerTenantId: SLACK_DESTINATION.teamId, + providerSubjectId: "U123", + }); + await createEventAutomation(getDb(), { + id: automationId, + createdAtMs: Date.now(), + createdBy: { slackUserId: "U123" }, + creatorIdentityId: creator.id, + credentialMode: "system", + destination: SLACK_DESTINATION, + destinationVisibility: "public", + outcomes: [], + task: { text: "Review pull requests that admins open." }, + trigger: { + events: ["pull_request.opened"], + label: "Admin pull requests in getsentry/junior", + match: { openerRole: "admin", repo: "getsentry/junior" }, + namespace: "github", + }, + }); + const publisher = createEventAppPublisher({ + conversationWork: () => ({ state: createMemoryState() }), + }); + + // The plugin loads the opener role only when a filter needs it, and + // this automation names no resource. + await expect( + publisher.neededMatchKeys({ + eventTypes: ["pull_request.opened"], + identifiers: ["getsentry/junior#7", "getsentry/junior"], + namespace: "github", + }), + ).resolves.toEqual(["openerRole", "repo"]); + // The plugin passes the repo it already knows. The filter for + // getsentry/junior fails for another repository, so that repository + // loads nothing from GitHub. + await expect( + publisher.neededMatchKeys({ + data: { repo: "getsentry/sentry" }, + eventTypes: ["pull_request.opened"], + identifiers: ["getsentry/sentry#7", "getsentry/sentry"], + namespace: "github", + }), + ).resolves.toEqual([]); + await expect( + publisher.neededMatchKeys({ + data: { repo: "getsentry/junior" }, + eventTypes: ["pull_request.opened"], + identifiers: ["getsentry/junior#7", "getsentry/junior"], + namespace: "github", + }), + ).resolves.toEqual(["openerRole", "repo"]); + await expect( + publisher.neededMatchKeys({ + eventTypes: ["pull_request.merged"], + identifiers: ["getsentry/junior#7"], + namespace: "github", + }), + ).resolves.toEqual([]); + const event = (openerRole: string) => ({ + eventKey: `github:any-resource:${openerRole}`, + eventType: "pull_request.opened", + identifier: "getsentry/junior#7", + namespace: "github", + occurredAtMs: Date.now(), + trustedSummary: "A pull request was opened.", + data: { openerRole, pullRequest: 7, repo: "getsentry/junior" }, + }); + await expect(publisher.hasMatch(event("admin"))).resolves.toBe(true); + await expect(publisher.hasMatch(event("write"))).resolves.toBe(false); + } finally { + await deleteEventAutomation(getDb(), automationId); + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/junior/tests/component/events/watch-people.test.ts b/packages/junior/tests/component/events/watch-people.test.ts index 769f749510..3825650792 100644 --- a/packages/junior/tests/component/events/watch-people.test.ts +++ b/packages/junior/tests/component/events/watch-people.test.ts @@ -58,6 +58,10 @@ describe("Watch Event people", () => { resourceTypes: [ { type: "job", supportedEvents: ["job.failed", "job.succeeded"] }, ], + events: { + "job.failed": { reach: "resource" }, + "job.succeeded": { reach: "resource" }, + }, }, hooks: { tools(ctx) { diff --git a/packages/junior/tests/fixtures/event-automations.ts b/packages/junior/tests/fixtures/event-automations.ts index e4047abbc8..49233b0f35 100644 --- a/packages/junior/tests/fixtures/event-automations.ts +++ b/packages/junior/tests/fixtures/event-automations.ts @@ -1,13 +1,14 @@ import { createSlackSource, type SlackActor } from "@sentry/junior-plugin-api"; import { getSqlExecutor } from "@/chat/db"; +import { eventCatalogEntry, type EventCatalog } from "@/chat/events/catalog"; import { upsertIdentity } from "@/chat/identities/sql"; import { readActorIdentity } from "@/chat/plugins/viewer"; import { createEventAutomationTool } from "@/chat/tools/create-event-automation"; import type { ToolRuntimeContext } from "@/chat/tools/types"; export const teamId = `TEVENT${Date.now()}`; -export const EVENT_CATALOG = { - github: { +export const EVENT_CATALOG: EventCatalog = { + github: eventCatalogEntry({ resourceTypes: [ { type: "pull_request", @@ -24,11 +25,16 @@ export const EVENT_CATALOG = { ], }, ], + events: { + "pull_request.review.changes_requested": { reach: "resource" }, + "pull_request.review.commented": { reach: "resource" }, + }, normalizeIdentifier: (identifier: string) => identifier.toLowerCase(), - }, - sentry: { + }), + sentry: eventCatalogEntry({ resourceTypes: [{ type: "issue", supportedEvents: ["issue.closed"] }], - }, + events: { "issue.closed": { reach: "resource" } }, + }), }; /** Build the source and actor for an Automation tool call. */ diff --git a/packages/junior/tests/integration/any-resource-event-automations.test.ts b/packages/junior/tests/integration/any-resource-event-automations.test.ts new file mode 100644 index 0000000000..1a479ef16c --- /dev/null +++ b/packages/junior/tests/integration/any-resource-event-automations.test.ts @@ -0,0 +1,249 @@ +import { githubPlugin } from "@sentry/junior-github"; +import { eq, sql } from "drizzle-orm"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createApp, defineJuniorPlugins } from "@/app"; +import { getDispatchRecord } from "@/chat/agent-dispatch/store"; +import { migrateSchema } from "@/chat/conversations/sql/migrations"; +import { getConversationStore } from "@/chat/db"; +import { ingestEventAutomations } from "@/chat/event-automations/ingest"; +import { getEventCatalog } from "@/chat/events/runtime-catalog"; +import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { createEventAutomationTool } from "@/chat/tools/create-event-automation"; +import { createUpdateEventAutomationTool } from "@/chat/tools/update-event-automation"; +import type { EventAutomationRecord } from "@/chat/event-automations/types"; +import { juniorAutomations } from "@/db/schema/automations"; +import { + createConversationWorkQueueTestAdapter, + type ConversationWorkQueueTestAdapter, +} from "../fixtures/conversation-work"; +import { context, execute, teamId } from "../fixtures/event-automations"; +import { + createConfiguredJuniorSqlFixture, + type LocalJuniorSqlFixture, +} from "../fixtures/sql"; + +vi.hoisted(() => { + process.env.JUNIOR_STATE_ADAPTER = "memory"; + process.env.JUNIOR_SECRET = "event-automation-test-secret"; +}); + +let fixture: LocalJuniorSqlFixture; +let queue: ConversationWorkQueueTestAdapter; + +describe("event automations with no identifier", () => { + beforeEach(async () => { + await disconnectStateAdapter(); + fixture = createConfiguredJuniorSqlFixture(); + await migrateSchema(fixture.sql); + queue = createConversationWorkQueueTestAdapter(); + // The automation tools save cards to the creation Conversation. + await getConversationStore().recordActivity({ + conversationId: "test:event-annotations", + destination: { + platform: "local", + conversationId: "test:event-annotations", + }, + source: "local", + nowMs: Date.now(), + }); + }); + + afterEach(async () => { + await fixture.sql + .db() + .delete(juniorAutomations) + .where(eq(juniorAutomations.teamId, teamId)); + await fixture.close(); + await disconnectStateAdapter(); + vi.restoreAllMocks(); + }); + + it("runs a trigger with no identifier once for each event, within its filters", async () => { + vi.stubEnv("GITHUB_WEBHOOK_SECRET", "test-secret"); + await createApp({ + plugins: defineJuniorPlugins([ + githubPlugin({ + pullRequestEvents: { + guidance: { "pull_request.opened": "Check the description." }, + }, + }), + ]), + }); + try { + const catalog = getEventCatalog(); + const create = (trigger: Record, toolCallId: string) => + execute( + createEventAutomationTool(context(), catalog), + { instruction: "Review the new pull request.", trigger }, + toolCallId, + ) as Promise<{ + automation: { + id: string; + trigger: { identifier: unknown; resourceType: unknown }; + }; + }>; + const trigger = { + namespace: "github", + label: "New pull requests in getsentry/junior", + events: ["pull_request.opened"], + }; + + // Pull request text can come from a private repository, so the + // trigger must name one. + await expect(create(trigger, "no-repo")).rejects.toThrow( + 'This GitHub trigger cannot be saved. This trigger needs a "repo" filter (match.repo), because "pull_request.opened" cannot run for every resource.', + ); + await expect( + create( + { ...trigger, identifier: "getsentry/junior#1", match: {} }, + "half-resource", + ), + ).rejects.toThrow("Set both resourceType and identifier"); + // Event data has a lowercase repo, so the saved filter does too. + const created = await create( + { ...trigger, match: { repo: "getsentry/Junior" } }, + "any-pull-request", + ); + expect(created.automation.trigger).toMatchObject({ + identifier: null, + resourceType: null, + match: { repo: "getsentry/junior" }, + }); + const [row] = await fixture.sql + .db() + .select() + .from(juniorAutomations) + .where(eq(juniorAutomations.id, created.automation.id)); + expect(row?.identifier).toBeNull(); + // The runtime gate skips rows that no tool can save: a broad pull + // request row with no repo filter, a row whose identifier column and + // saved trigger disagree, and a row that does not parse. The good row + // still runs. + const record = row!.record as EventAutomationRecord; + await fixture.sql + .db() + .insert(juniorAutomations) + .values([ + { + ...row!, + id: "evt_no_repo_filter", + record: { + ...record, + trigger: { ...record.trigger, match: undefined }, + }, + }, + { + ...row!, + id: "evt_column_without_identifier", + record: { + ...record, + trigger: { + ...record.trigger, + identifier: "getsentry/sentry#7", + resourceType: "pull_request", + match: undefined, + }, + }, + }, + { ...row!, id: "evt_bad_record" }, + ]); + await fixture.sql + .db() + .update(juniorAutomations) + .set({ + record: sql`jsonb_set(${juniorAutomations.record}, '{task}', 'null'::jsonb)`, + }) + .where(eq(juniorAutomations.id, "evt_bad_record")); + // Deployment facts can run for every repository with no filter. + await create( + { + namespace: "github", + label: "Failed deployments", + events: ["deployment.failed"], + }, + "any-deployment", + ); + + // The plugin publishes one copy for the pull request and one for its + // repository. Both have one event key, so the automation runs once. + const opened = (eventKey: string, repo: string) => + [`${repo}#7`, repo].map((identifier) => ({ + eventKey, + eventType: "pull_request.opened", + occurredAtMs: Date.now(), + namespace: "github", + identifier, + trustedSummary: "A pull request was opened.", + data: { pullRequest: 7, repo }, + })); + const ingestAll = async (events: ReturnType) => { + let dispatched = 0; + for (const event of events) { + dispatched += (await ingestEventAutomations(event, { queue, teamId })) + .dispatched; + } + return dispatched; + }; + expect( + await ingestAll(opened("github:opened-1", "getsentry/junior")), + ).toBe(1); + expect( + await ingestAll(opened("github:opened-2", "getsentry/sentry")), + ).toBe(0); + const [sent] = queue.sentRecords(); + const dispatch = await getDispatchRecord( + sent!.conversationId.replace(/^agent-dispatch:/, ""), + ); + // Guidance belongs to the event, so a trigger with no resource type + // gets it too. + expect(dispatch?.input).toContain("Check the description."); + expect(dispatch?.input).toContain( + "About: New pull requests in getsentry/junior", + ); + + // An update that leaves out identifier and resourceType keeps them, + // so it cannot make a trigger broad by omission. + const one = await create( + { + ...trigger, + identifier: "getsentry/junior#7", + resourceType: "pull_request", + label: "getsentry/junior#7", + }, + "one-pull-request", + ); + const update = (next: Record, toolCallId: string) => + execute( + createUpdateEventAutomationTool(context(), catalog), + { automationId: one.automation.id, trigger: next }, + toolCallId, + ) as Promise<{ + automation: { trigger: Record }; + }>; + const edited = { ...trigger, label: "Opened getsentry/junior#7" }; + await expect(update(edited, "keep-identifier")).resolves.toMatchObject({ + automation: { + trigger: { + identifier: "getsentry/junior#7", + resourceType: "pull_request", + }, + }, + }); + // Only explicit nulls drop them, and then the trigger needs a repo. + const broad = { ...edited, identifier: null, resourceType: null }; + await expect(update(broad, "drop-identifier")).rejects.toThrow( + 'This trigger needs a "repo" filter (match.repo)', + ); + await expect( + update( + { ...broad, match: { repo: "getsentry/junior" } }, + "drop-identifier-with-repo", + ), + ).resolves.toMatchObject({ + automation: { trigger: { identifier: null, resourceType: null } }, + }); + } finally { + vi.unstubAllEnvs(); + } + }); +}); diff --git a/packages/junior/tests/integration/api/automation-edits.test.ts b/packages/junior/tests/integration/api/automation-edits.test.ts index fe9995d715..15d44a763d 100644 --- a/packages/junior/tests/integration/api/automation-edits.test.ts +++ b/packages/junior/tests/integration/api/automation-edits.test.ts @@ -365,15 +365,11 @@ describe("Automation edit API", () => { expect(catalog.status).toBe(200); // The dashboard parses the JSON body with this same schema. const choices = automationEventCatalogSchema.parse(await catalog.json()); - expect(choices).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - namespace: "github", - type: "issue", - supportedEvents: expect.arrayContaining(["issue.closed"]), - }), - ]), - ); + // One entry for each namespace, with the reach of each event once. + const github = choices.find((choice) => choice.namespace === "github"); + expect(github?.events["issue.closed"]).toEqual({ + reach: { anyOf: ["repo"] }, + }); expect(choices.some((choice) => choice.namespace === "junior")).toBe( false, ); @@ -956,6 +952,43 @@ describe("Automation edit API", () => { }, }); expect(await unavailableIds()).toEqual([id]); + + // With no identifier, pull request events need a repo filter. + const anyResource = { + namespace: "github", + label: "New pull requests", + events: ["pull_request.opened"], + }; + const unscoped = await patch({ + kind: "event", + revision: (await read()).revision, + trigger: anyResource, + }); + expect(unscoped.status).toBe(400); + expect(await unscoped.json()).toMatchObject({ + fields: { + trigger: [ + 'This GitHub trigger cannot be saved. This trigger needs a "repo" filter (match.repo), because "pull_request.opened" cannot run for every resource.', + ], + }, + }); + const scoped = await patch({ + kind: "event", + revision: (await read()).revision, + trigger: { ...anyResource, match: { repo: "getsentry/junior" } }, + }); + expect(scoped.status).toBe(200); + expect(await unavailableIds()).toEqual([]); + expect( + await (await app.request(`/api/automations/${id}`)).json(), + ).toMatchObject({ + resource: "New pull requests", + trigger: { + events: ["pull request opened"], + filters: ["repo is getsentry/junior"], + problems: [], + }, + }); } finally { await fixture.close(); } diff --git a/packages/junior/tests/integration/event-automation-blocking.test.ts b/packages/junior/tests/integration/event-automation-blocking.test.ts index 4d89a8f13f..c5acf3cdb0 100644 --- a/packages/junior/tests/integration/event-automation-blocking.test.ts +++ b/packages/junior/tests/integration/event-automation-blocking.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; +import { createApp, defineJuniorPlugins } from "@/app"; import { markDispatchBlocked } from "@/chat/agent-dispatch/store"; import { getConversationStore } from "@/chat/db"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; @@ -7,6 +8,9 @@ import { ingestEventAutomations } from "@/chat/event-automations/ingest"; import { getEventAutomation } from "@/chat/event-automations/store"; import { botConfig } from "@/chat/config"; import { disconnectStateAdapter } from "@/chat/state/adapter"; +import { githubPlugin } from "@sentry/junior-github"; +import { getEventCatalog } from "@/chat/events/runtime-catalog"; +import { createEventAutomationTool } from "@/chat/tools/create-event-automation"; import { createDeleteEventAutomationTool } from "@/chat/tools/delete-event-automation"; import { createUpdateEventAutomationTool } from "@/chat/tools/update-event-automation"; import { juniorAutomations } from "@/db/schema/automations"; @@ -232,4 +236,63 @@ describe("event automation blocking", () => { }); // Filling the window takes about three times the cap in sequential ingests. }, 30_000); + + it("counts each event once, however many resources it is published for", async () => { + vi.stubEnv("GITHUB_WEBHOOK_SECRET", "test-secret"); + await createApp({ plugins: defineJuniorPlugins([githubPlugin()]) }); + try { + const { automation } = (await execute( + createEventAutomationTool(context(), getEventCatalog()), + { + instruction: "Summarize the failed deployment.", + trigger: { + namespace: "github", + label: "Failed deployments", + events: ["deployment.failed"], + }, + }, + "any-deployment", + )) as { automation: { id: string } }; + // A trigger with no identifier gets every copy of an event. Repeated + // copies must not fill the run list and block it early. + const copies = 12; + const occur = async (eventKey: string, nowMs: number) => { + let dispatched = 0; + for (let index = 0; index < copies; index += 1) { + const result = await ingestEventAutomations( + { + eventKey, + eventType: "deployment.failed", + occurredAtMs: nowMs, + namespace: "github", + identifier: `deployment-source:getsentry/repo-${index}:abc`, + trustedSummary: "A deployment failed.", + }, + { nowMs, queue, teamId }, + ); + dispatched += result.dispatched; + } + return dispatched; + }; + const nowMs = Date.now(); + for ( + let index = 0; + index < botConfig.maxEventAutomationRuns; + index += 1 + ) { + expect(await occur(`github:deploy-${index}`, nowMs)).toBe(1); + } + const db = fixture.sql.db(); + expect(await getEventAutomation(db, automation.id)).toMatchObject({ + status: "active", + }); + // The limit is the same as for a trigger with an identifier. + expect(await occur("github:deploy-over", nowMs)).toBe(0); + expect(await getEventAutomation(db, automation.id)).toMatchObject({ + status: "blocked", + }); + } finally { + vi.unstubAllEnvs(); + } + }, 30_000); }); diff --git a/packages/junior/tests/integration/slack/channel-message-automations.test.ts b/packages/junior/tests/integration/slack/channel-message-automations.test.ts index 094fc6b619..85bca193e4 100644 --- a/packages/junior/tests/integration/slack/channel-message-automations.test.ts +++ b/packages/junior/tests/integration/slack/channel-message-automations.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { getDispatchRecord } from "@/chat/agent-dispatch/store"; import { getConversationStore } from "@/chat/db"; import { migrateSchema } from "@/chat/conversations/sql/migrations"; @@ -251,6 +251,43 @@ describe("Slack channel-message automations", () => { }), ).rejects.toThrow("can watch only the channel where it posts"); + // A trigger with no identifier would read every channel. + const { + identifier: _identifier, + resourceType: _type, + ...anyChannel + } = trigger(); + await expect( + execute(createEventAutomationTool(channelContext, catalog), { + instruction: "Summarize new messages.", + title: "Summarize every channel", + outcomes: [], + trigger: anyChannel, + }), + ).rejects.toThrow("This source cannot run for every resource."); + + // A message runs only automations that post in its own channel, even + // when a saved row names that channel and posts somewhere else. + const { automation } = (await execute( + createEventAutomationTool(channelContext, catalog), + { + instruction: "Summarize new messages.", + title: "Summarize messages", + outcomes: [], + trigger: trigger(), + }, + )) as { automation: { id: string } }; + await fixture.sql + .db() + .update(juniorAutomations) + .set({ + channelId: "C999", + record: sql`jsonb_set(${juniorAutomations.record}, '{destination,channelId}', '"C999"')`, + }) + .where(eq(juniorAutomations.id, automation.id)); + expect((await postChannelMessage({ ts: "1712345.0006" })).status).toBe(200); + expect(queue.sentRecords()).toEqual([]); + // A DM is not a channel, so the automation could never run there. await expect( execute( diff --git a/packages/junior/tests/unit/app-config.test.ts b/packages/junior/tests/unit/app-config.test.ts index a42051282f..3bb1bd2455 100644 --- a/packages/junior/tests/unit/app-config.test.ts +++ b/packages/junior/tests/unit/app-config.test.ts @@ -758,6 +758,7 @@ describe("createApp plugin config", () => { }, events: { resourceTypes: [{ type: "issue", supportedEvents: ["closed"] }], + events: { closed: { reach: "resource" } }, }, }), ).toThrow('Junior plugin "event-demo" events is invalid.'); @@ -776,6 +777,7 @@ describe("createApp plugin config", () => { supportedEvents: ["issue.closed", "issue.closed"], }, ], + events: { "issue.closed": { reach: "resource" } }, }, }), ).toThrow('Junior plugin "event-demo" events is invalid.'); @@ -795,6 +797,7 @@ describe("createApp plugin config", () => { supportedEvents: ["issue.closed"], }, ], + events: { "issue.closed": { reach: "resource" } }, }, }), ).toThrow('Junior plugin "event-demo" events is invalid.'); diff --git a/packages/junior/tests/unit/events/catalog.test.ts b/packages/junior/tests/unit/events/catalog.test.ts index b0b3255f61..72e9b5f01c 100644 --- a/packages/junior/tests/unit/events/catalog.test.ts +++ b/packages/junior/tests/unit/events/catalog.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { CORE_EVENT_NAMESPACE, automationEventCatalog, + eventCatalogEntry, eventGuidance, requireEventIdentifier, type EventCatalog, @@ -9,7 +10,7 @@ import { import { describeEventAutomationTrigger, registeredEventAutomationTriggerSchema, - requireSupportedEventAutomationTrigger, + requireEventAutomationTrigger, } from "@/chat/event-automations/tool-support"; import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; import { @@ -20,47 +21,35 @@ import { } from "@/chat/sandbox/snapshot/events"; const catalog: EventCatalog = { - github: { + github: eventCatalogEntry({ resourceTypes: [ { type: "pull_request", supportedEvents: ["pull_request.comment.created"], - guidance: { - "pull_request.comment.created": "Address actionable feedback.", - }, - }, - { - type: "repository", - supportedEvents: ["pull_request.comment.created"], }, + { type: "repository", supportedEvents: ["pull_request.comment.created"] }, ], - }, - [CORE_EVENT_NAMESPACE]: workspaceSnapshotEvents(), + events: { + "pull_request.comment.created": { + reach: "resource", + guidance: "Address actionable feedback.", + }, + }, + }), + [CORE_EVENT_NAMESPACE]: eventCatalogEntry(workspaceSnapshotEvents()), }; describe("event catalog", () => { - it("scopes app guidance to the registered resource type", () => { + it("gives the declared guidance of an event for every resource type", () => { expect( - eventGuidance( - catalog, - "github", - "pull_request", - "pull_request.comment.created", - ), + eventGuidance(catalog, "github", "pull_request.comment.created"), ).toBe("Address actionable feedback."); - expect( - eventGuidance( - catalog, - "github", - "repository", - "pull_request.comment.created", - ), - ).toBeUndefined(); + expect(eventGuidance(catalog, "github", "issue.closed")).toBeUndefined(); }); it("rejects an identifier that does not fit its resource type", () => { const declared: EventCatalog = { - github: { + github: eventCatalogEntry({ resourceTypes: [ { type: "issue", @@ -80,8 +69,12 @@ describe("event catalog", () => { }, { type: "release_source", supportedEvents: ["release.published"] }, ], + events: { + "issue.closed": { reach: "resource" }, + "release.published": { reach: "resource" }, + }, normalizeIdentifier: (identifier) => identifier.toLowerCase(), - }, + }), }; const identifier = (resourceType: string, value: string) => requireEventIdentifier(declared, { @@ -122,9 +115,11 @@ describe("event catalog", () => { ); expect(() => - requireSupportedEventAutomationTrigger(catalog, { + requireEventAutomationTrigger(catalog, { namespace: CORE_EVENT_NAMESPACE, + identifier: "11111111-1111-4111-8111-111111111111", resourceType: WORKSPACE_SNAPSHOT_RESOURCE_TYPE, + label: "Workspace sentry snapshot", events: [WORKSPACE_SNAPSHOT_READY_EVENT], }), ).toThrow(ToolInputError); diff --git a/packages/junior/tests/unit/events/match.test.ts b/packages/junior/tests/unit/events/match.test.ts index 672a02a8ee..6b17459be2 100644 --- a/packages/junior/tests/unit/events/match.test.ts +++ b/packages/junior/tests/unit/events/match.test.ts @@ -3,11 +3,13 @@ import { eventMatches, eventTriggerText, mergeEventMatchFields, + pluginEventsSchema, stableEventMatchKey, type EventMatchFields, } from "@sentry/junior-plugin-api"; import { describeRegisteredEventTrigger, + eventCatalogEntry, registeredEventTriggerProblems, requireValidEventTrigger, type EventCatalog, @@ -15,7 +17,7 @@ import { import { ToolInputError } from "@/chat/tools/execution/tool-input-error"; const catalog: EventCatalog = { - github: { + github: eventCatalogEntry({ displayName: "GitHub", resourceTypes: [ { @@ -51,7 +53,12 @@ const catalog: EventCatalog = { supportedEvents: ["issue.opened"], }, ], - }, + events: { + "issue.opened": { reach: "resource" }, + "pull_request.comment.created": { reach: "resource" }, + "pull_request.opened": { reach: "resource" }, + }, + }), }; describe("eventMatches", () => { @@ -197,7 +204,9 @@ describe("mergeEventMatchFields", () => { }, pullRequestFields, ), - ).toThrow('Match field "senderId" has a different kind, enum, or list'); + ).toThrow( + 'Match field "senderId" has a different kind, case, enum, or list', + ); expect(() => mergeEventMatchFields( { senderId: { kind: "number", description: "every commenter" } }, @@ -255,3 +264,197 @@ describe("describeRegisteredEventTrigger", () => { ]); }); }); + +describe("triggers with no identifier", () => { + const repo = { + kind: "string", + case: "lower", + description: "repository full name", + } as const; + const anyCatalog: EventCatalog = { + github: eventCatalogEntry({ + displayName: "GitHub", + resourceTypes: [ + { + type: "pull_request", + supportedEvents: ["pull_request.opened", "pull_request.merged"], + matchFields: { + repo: { ...repo, events: ["pull_request.opened"] }, + isDraft: { + kind: "boolean", + description: "true when the pull request is a draft", + }, + }, + }, + { + type: "deployment_source", + supportedEvents: ["deployment.failed"], + matchFields: { + repo: { ...repo, events: ["deployment.failed"] }, + environment: { kind: "string", description: "environment" }, + }, + }, + ], + events: { + "deployment.failed": { reach: "all" }, + "pull_request.opened": { reach: { anyOf: ["repo"] } }, + "pull_request.merged": { reach: "resource" }, + }, + }), + sentry: eventCatalogEntry({ + displayName: "Sentry", + resourceTypes: [ + { + type: "metric_alert", + supportedEvents: ["metric_alert.critical"], + matchFields: { + projects: { kind: "string", list: true, description: "projects" }, + }, + }, + ], + events: { "metric_alert.critical": { reach: "all" } }, + }), + linear: eventCatalogEntry({ + resourceTypes: [ + { + type: "team", + supportedEvents: ["issue.created", "issue.updated"], + matchFields: { + teamKey: { kind: "string", case: "upper", description: "team key" }, + issueIdentifier: { + kind: "string", + case: "upper", + description: "issue identifier", + }, + }, + }, + ], + events: { + "issue.created": { reach: "resource" }, + "issue.updated": { reach: { anyOf: ["teamKey", "issueIdentifier"] } }, + }, + }), + }; + const problems = ( + namespace: string, + events: string[], + match?: Record, + ) => + registeredEventTriggerProblems(anyCatalog, { + namespace, + events, + match, + }).map(({ reason, field }) => ({ reason, field })); + + it("uses the filters of every resource type, and each event must set each one", () => { + expect( + problems("github", ["deployment.failed"], { environment: "prod" }), + ).toEqual([]); + expect( + problems("github", ["deployment.failed", "pull_request.opened"], { + repo: "getsentry/junior", + }), + ).toEqual([]); + expect( + problems("github", ["deployment.failed", "pull_request.opened"], { + environment: "prod", + repo: "getsentry/junior", + }), + ).toEqual([{ reason: "field_not_set", field: "environment" }]); + }); + + it("names the filters that an event needs, and accepts any one of them", () => { + expect(problems("github", ["pull_request.opened"])).toEqual([ + { reason: "filter_required", field: "repo" }, + ]); + expect(() => + requireValidEventTrigger( + anyCatalog, + { namespace: "github", events: ["pull_request.opened"] }, + "event_automation", + ), + ).toThrow( + new ToolInputError( + 'This GitHub trigger cannot be saved. This trigger needs a "repo" filter (match.repo), because "pull_request.opened" cannot run for every resource.', + ), + ); + expect(problems("linear", ["issue.updated"])).toEqual([ + { reason: "filter_required", field: "teamKey" }, + ]); + expect( + problems("linear", ["issue.updated"], { issueIdentifier: "JR-1" }), + ).toEqual([]); + }); + + it("saves filter values in the case that the plugin declares", () => { + expect( + requireValidEventTrigger( + anyCatalog, + { + namespace: "github", + events: ["pull_request.opened"], + match: { repo: "getsentry/Junior" }, + }, + "event_automation", + ), + ).toEqual({ repo: "getsentry/junior" }); + expect( + requireValidEventTrigger( + anyCatalog, + { + namespace: "linear", + events: ["issue.updated"], + match: { teamKey: "jr" }, + }, + "event_automation", + ), + ).toEqual({ teamKey: "JR" }); + }); + + it("rejects events and sources that need an identifier", () => { + expect( + problems("github", ["pull_request.merged"], { repo: "getsentry/junior" }), + ).toEqual([{ reason: "identifier_required", field: undefined }]); + expect(problems("linear", ["issue.created"])).toEqual([ + { reason: "identifier_required", field: undefined }, + ]); + }); + + it("rejects a plugin registration without one valid declaration for each event", () => { + const issues = (events: unknown) => + pluginEventsSchema + .safeParse(events) + .error?.issues.map((issue) => issue.message); + const { + displayName: _name, + anyResourceFields: _fields, + ...github + } = anyCatalog.github!; + expect(issues(github)).toBeUndefined(); + expect( + issues({ + ...github, + events: { + "deployment.failed": { reach: { anyOf: ["isDraft"] } }, + "pull_request.opened": { reach: "all" }, + "issue.closed": { reach: "all" }, + }, + }), + ).toEqual([ + 'Event type "pull_request.merged" has no declaration in events.', + 'Reach filter "isDraft" is not set on "deployment.failed".', + 'Declared event type "issue.closed" is not supported.', + ]); + }); + + it("describes the trigger with no identifier in the same words", () => { + const description = describeRegisteredEventTrigger(anyCatalog, { + namespace: "sentry", + events: ["metric_alert.critical"], + match: { projects: "javascript" }, + }); + expect(eventTriggerText("Sentry", description)).toBe( + "Sentry · metric alert critical · filters: projects includes javascript", + ); + }); +}); diff --git a/packages/junior/tests/unit/plugins/agent-hooks.test.ts b/packages/junior/tests/unit/plugins/agent-hooks.test.ts index d55d27fbbc..c058359b57 100644 --- a/packages/junior/tests/unit/plugins/agent-hooks.test.ts +++ b/packages/junior/tests/unit/plugins/agent-hooks.test.ts @@ -621,6 +621,7 @@ describe("agent plugin hooks", () => { resourceTypes: [ { type: "demo", supportedEvents: ["demo.completed"] }, ], + events: { "demo.completed": { reach: "resource" } }, }, hooks: { tools(ctx) { @@ -693,6 +694,7 @@ describe("agent plugin hooks", () => { resourceTypes: [ { type: "demo", supportedEvents: ["demo.completed"] }, ], + events: { "demo.completed": { reach: "resource" } }, isEnabled: () => enabled, }, hooks: { @@ -1098,6 +1100,7 @@ describe("agent plugin hooks", () => { defineJuniorPlugin({ events: { resourceTypes: [{ type: "demo", supportedEvents: ["demo.created"] }], + events: { "demo.created": { reach: "resource" } }, normalizeIdentifier: (identifier) => identifier.toLowerCase(), }, manifest: { @@ -1206,6 +1209,7 @@ describe("agent plugin hooks", () => { label: "while its registration is disabled", events: { resourceTypes: [{ type: "demo", supportedEvents: ["demo.created"] }], + events: { "demo.created": { reach: "resource" as const } }, isEnabled: () => false, }, error: "without an active registration", @@ -1214,6 +1218,7 @@ describe("agent plugin hooks", () => { label: "when the event type is undeclared", events: { resourceTypes: [{ type: "demo", supportedEvents: ["demo.created"] }], + events: { "demo.created": { reach: "resource" as const } }, }, error: 'did not register event "demo.deleted"', }, diff --git a/packages/junior/tests/unit/sandbox/snapshot-events.test.ts b/packages/junior/tests/unit/sandbox/snapshot-events.test.ts index e951cb6a57..c5cfbf93b0 100644 --- a/packages/junior/tests/unit/sandbox/snapshot-events.test.ts +++ b/packages/junior/tests/unit/sandbox/snapshot-events.test.ts @@ -65,7 +65,6 @@ describe("Workspace snapshot events", () => { eventGuidance( catalog, WORKSPACE_SNAPSHOT_NAMESPACE, - WORKSPACE_SNAPSHOT_RESOURCE_TYPE, WORKSPACE_SNAPSHOT_READY_EVENT, ), ).toContain("switchWorkspace"); @@ -73,7 +72,6 @@ describe("Workspace snapshot events", () => { eventGuidance( catalog, WORKSPACE_SNAPSHOT_NAMESPACE, - WORKSPACE_SNAPSHOT_RESOURCE_TYPE, WORKSPACE_SNAPSHOT_FAILED_EVENT, ), ).toContain("Report the snapshot failure"); diff --git a/packages/junior/tests/unit/slack/tool-registration.test.ts b/packages/junior/tests/unit/slack/tool-registration.test.ts index 52e64d0750..d9b829a776 100644 --- a/packages/junior/tests/unit/slack/tool-registration.test.ts +++ b/packages/junior/tests/unit/slack/tool-registration.test.ts @@ -31,6 +31,7 @@ function eventPlugin() { }, events: { resourceTypes: [{ type: "issue", supportedEvents: ["issue.closed"] }], + events: { "issue.closed": { reach: "resource" } }, }, }); } diff --git a/packages/junior/tests/unit/tools/events.test.ts b/packages/junior/tests/unit/tools/events.test.ts index 6992ea9031..af54af4f00 100644 --- a/packages/junior/tests/unit/tools/events.test.ts +++ b/packages/junior/tests/unit/tools/events.test.ts @@ -1,5 +1,6 @@ import { createSlackSource } from "@sentry/junior-plugin-api"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { eventCatalogEntry } from "@/chat/events/catalog"; import { planToolExposure } from "@/chat/tool-exposure"; import type { ToolRuntimeContext } from "@/chat/tools/types"; @@ -35,7 +36,7 @@ const context: ToolRuntimeContext = { }; const GITHUB_EVENTS = { - github: { + github: eventCatalogEntry({ resourceTypes: [ { type: "pull_request", @@ -46,8 +47,21 @@ const GITHUB_EVENTS = { suggestedEvents: ["pull_request.checks.failed"], }, ], + events: { + "pull_request.checks.failed": { reach: "resource" }, + "pull_request.review.changes_requested": { reach: "resource" }, + }, normalizeIdentifier: (identifier: string) => identifier.toLowerCase(), - }, + }), +}; + +const VERCEL_EVENTS = { + vercel: eventCatalogEntry({ + resourceTypes: [ + { type: "deployment", supportedEvents: ["deployment.succeeded"] }, + ], + events: { "deployment.succeeded": { reach: "resource" } }, + }), }; describe("event tools", () => { @@ -151,20 +165,17 @@ describe("event tools", () => { events: ["pull_request.checks.failed"], }; const tools = createEventTools(context, { - vercel: { - resourceTypes: [ - { - type: "deployment", - supportedEvents: ["deployment.succeeded"], - }, - ], - }, - github: { + ...VERCEL_EVENTS, + github: eventCatalogEntry({ ...GITHUB_EVENTS.github, resourceTypes: GITHUB_EVENTS.github.resourceTypes.map( (resourceType) => ({ ...resourceType, matchFields: { authors } }), ), - }, + events: { + ...GITHUB_EVENTS.github.events, + "pull_request.checks.failed": { reach: { anyOf: ["authors"] } }, + }, + }), }); await expect( @@ -186,20 +197,23 @@ describe("event tools", () => { matchFields: { authors }, }, ], + // The reach of each event is given once for its namespace. + namespaces: [ + { + namespace: "github", + reach: { + "pull_request.checks.failed": { anyOf: ["authors"] }, + "pull_request.review.changes_requested": "resource", + }, + }, + ], }); expect(createSubscription).not.toHaveBeenCalled(); }); it("exposes active plugin namespaces as an enum", () => { const tool = createEventTools(context, { - vercel: { - resourceTypes: [ - { - type: "deployment", - supportedEvents: ["deployment.succeeded"], - }, - ], - }, + ...VERCEL_EVENTS, ...GITHUB_EVENTS, }).watchEvents!; @@ -222,7 +236,7 @@ describe("event tools", () => { it("rejects events unsupported by the selected resource type", async () => { const tool = createEventTools(context, { - github: { + github: eventCatalogEntry({ resourceTypes: [ { type: "issue", supportedEvents: ["issue.closed"] }, { @@ -230,7 +244,11 @@ describe("event tools", () => { supportedEvents: ["pull_request.merged"], }, ], - }, + events: { + "issue.closed": { reach: "resource" }, + "pull_request.merged": { reach: "resource" }, + }, + }), }).watchEvents!; await expect(