diff --git a/packages/junior-github/src/tool-support/attribution.ts b/packages/junior-github/src/tool-support/attribution.ts index 52dc3fe36e..bbb9419a9d 100644 --- a/packages/junior-github/src/tool-support/attribution.ts +++ b/packages/junior-github/src/tool-support/attribution.ts @@ -2,6 +2,7 @@ import type { Actor, Identity, PluginLogger, + Source, User, } from "@sentry/junior-plugin-api"; @@ -53,25 +54,50 @@ function cleanDisplayValue( return cleaned; } +/** Name the Automation run that made this write. */ +function automationLabel( + source: Source | undefined, + automationUrl: string | undefined, +): string | undefined { + if ( + (source?.kind !== "event_automation" && + source?.kind !== "scheduled_automation") || + !source.automationId + ) { + return undefined; + } + const automation = `automation \`${source.automationId}\``; + const label = automationUrl + ? `[${automation}](${automationUrl})` + : automation; + return source.automationRunId + ? `${label} · Run ID \`${source.automationRunId}\`` + : label; +} + /** * Resolve the requester display label through the host identity path first. * - * Prefer a linked GitHub login as an `@` mention so GitHub subscribes the - * requester. Otherwise use the linked user name, then the stored identity - * name/handle, then the already-hydrated actor profile. Never publish a raw - * actor id. + * Automation runs use the automation label. Other system actors get no label + * because their names are internal. For people, prefer a linked GitHub login + * as an `@` mention so GitHub subscribes the requester. Otherwise use the + * linked user name, then the stored identity name/handle, then the + * already-hydrated actor profile. Never publish a raw actor id. */ function requesterLabel(args: { actor: Actor | undefined; + automationUrl?: string; identity?: Identity; + source?: Source; user?: User; }): string | undefined { const { actor, identity, user } = args; - if (!actor) { - return undefined; + const automation = automationLabel(args.source, args.automationUrl); + if (automation) { + return automation; } - if (actor.platform === "system") { - return `Junior system actor \`${actor.name}\``; + if (!actor || actor.platform === "system") { + return undefined; } const login = linkedGitHubLogin(user); @@ -101,10 +127,11 @@ function parseExistingLabels(blockContents: string): string[] { } const withoutLeadIn = trimmed.replace(/^(?:Requested by|via)\s+/i, ""); // Only split on a comma that starts the next label (bold markdown, a GitHub - // mention, a system-actor label, or a legacy repeated "via") so a display - // name that itself contains a comma is not broken into extra labels. + // mention, an automation label, a system-actor label, or a legacy repeated + // "via") so a display name that itself contains a comma is not broken into + // extra labels. return withoutLeadIn - .split(/,\s*(?=\*\*|@|Junior system actor `|via\s)/i) + .split(/,\s*(?=\*\*|@|\[?automation `|Junior system actor `|via\s)/i) .map((entry) => entry.trim().replace(/^via\s+/i, "")) .filter(Boolean); } @@ -152,7 +179,9 @@ export async function appendGitHubRequesterAttribution( body: string, ctx: { actor?: Actor; + automationLink?: { url: string }; log: PluginLogger; + source?: Source; users: { resolveActor(): Promise<{ identity?: Identity; user?: User } | undefined>; }; @@ -178,7 +207,9 @@ export async function appendGitHubRequesterAttribution( body, requesterLabel({ actor: ctx.actor, + automationUrl: ctx.automationLink?.url, identity: resolved?.identity, + source: ctx.source, user: resolved?.user, }), ); diff --git a/packages/junior-github/src/tools/update-issue.ts b/packages/junior-github/src/tools/update-issue.ts index 8f27872f22..ea170a9897 100644 --- a/packages/junior-github/src/tools/update-issue.ts +++ b/packages/junior-github/src/tools/update-issue.ts @@ -6,6 +6,7 @@ import { type Actor, type PluginLogger, type PluginEgress, + type Source, definePluginTool, PluginToolInputError, pluginToolOutputSchema, @@ -100,11 +101,13 @@ function githubApiErrorMessage(payload: unknown): string { /** Update mutable issue metadata while preserving runtime-owned body attribution. */ export function createGitHubUpdateIssueTool(ctx: { actor?: Actor; + automationLink?: { url: string }; conversationId?: string; egress: PluginEgress; log: PluginLogger; events: { canSubscribe: boolean }; slack?: { conversationLink?: { url?: string } }; + source?: Source; users: { resolveActor(): Promise<{ identity?: Identity; user?: User } | undefined>; }; diff --git a/packages/junior-github/src/tools/update-pull-request.ts b/packages/junior-github/src/tools/update-pull-request.ts index 37ad507649..4c7e6dbb63 100644 --- a/packages/junior-github/src/tools/update-pull-request.ts +++ b/packages/junior-github/src/tools/update-pull-request.ts @@ -6,6 +6,7 @@ import { type Actor, type PluginLogger, type PluginEgress, + type Source, definePluginTool, PluginToolInputError, pluginToolOutputSchema, @@ -181,11 +182,13 @@ function githubGraphqlErrorMessage(payload: unknown): string { /** Update mutable PR metadata while preserving runtime-owned body attribution. */ export function createGitHubUpdatePullRequestTool(ctx: { actor?: Actor; + automationLink?: { url: string }; conversationId?: string; egress: PluginEgress; log: PluginLogger; events: { canSubscribe: boolean }; slack?: { conversationLink?: { url?: string } }; + source?: Source; users: { resolveActor(): Promise<{ identity?: Identity; user?: User } | undefined>; }; diff --git a/packages/junior-github/tests/github-plugin.test.ts b/packages/junior-github/tests/github-plugin.test.ts index c884f90517..f4c41ed510 100644 --- a/packages/junior-github/tests/github-plugin.test.ts +++ b/packages/junior-github/tests/github-plugin.test.ts @@ -62,6 +62,7 @@ class TestTokenStore { type TestActor = { email?: string; fullName?: string; + name?: string; platform?: string; teamId?: string; userId?: string; @@ -343,6 +344,7 @@ function githubToolsContext(input?: { request: Request; }) => Promise; resolveActor?: ToolRegistrationHookContext["users"]["resolveActor"]; + source?: ToolRegistrationHookContext["source"]; stateSet?: (input: { key: string; value: unknown }) => Promise | void; subscribe?: ToolRegistrationHookContext["events"]["subscribe"]; }) { @@ -360,7 +362,7 @@ function githubToolsContext(input?: { ...(input?.actor ? { actor: input.actor } : undefined), conversationId, destination: { platform: "local" as const, conversationId }, - source: { + source: input?.source ?? { kind: "local" as const, visibility: "private" as const, conversationId, @@ -1029,6 +1031,38 @@ describe("github plugin", () => { }); }); + it("attributes automation writes to the Automation run", async () => { + process.env.GITHUB_WEBHOOK_SECRET = "test-secret"; + const ctx = { + ...githubToolsContext({ + actor: { platform: "system", name: "junior" }, + conversationId: "agent-dispatch:dispatch_1", + source: { + kind: "event_automation", + automationId: "evt_1", + automationRunId: "dispatch_1", + }, + }), + automationLink: { url: "https://junior.example.com/automations/evt_1" }, + }; + const tool = githubPlugin().hooks?.tools?.(ctx as any)?.createIssue; + + await tool?.execute?.( + { + repo: "getsentry/junior", + title: "Typed issue", + body: "Issue body", + labels: ["bug"], + }, + { toolCallId: "call-create-issue-automation" }, + ); + + const request = ctx.egressRequests()[0]; + await expect(request?.request.json()).resolves.toMatchObject({ + body: "Issue body\n\n\nvia [automation `evt_1`](https://junior.example.com/automations/evt_1) · Run ID `dispatch_1`.\n", + }); + }); + it("keeps issue annotation labels compact for long titles", async () => { const ctx = githubToolsContext(); const tool = githubPlugin().hooks?.tools?.(ctx as any)?.createIssue; diff --git a/packages/junior-plugin-api/src/schemas.ts b/packages/junior-plugin-api/src/schemas.ts index 895ab1632e..967898fa05 100644 --- a/packages/junior-plugin-api/src/schemas.ts +++ b/packages/junior-plugin-api/src/schemas.ts @@ -130,14 +130,26 @@ export const eventSourceSchema = z }) .strict(); +// Stored automation Sources from before these fields existed omit them. +const automationSourceFields = { + automationId: exactNonBlankStringSchema.optional(), + automationRunId: exactNonBlankStringSchema.optional(), +}; + /** Runtime-owned Scheduled automation input Source. */ export const scheduledAutomationSourceSchema = z - .object({ kind: z.literal("scheduled_automation") }) + .object({ + kind: z.literal("scheduled_automation"), + ...automationSourceFields, + }) .strict(); /** Runtime-owned Event automation input Source. */ export const eventAutomationSourceSchema = z - .object({ kind: z.literal("event_automation") }) + .object({ + kind: z.literal("event_automation"), + ...automationSourceFields, + }) .strict(); /** Runtime-owned Plugin dispatch input Source. */ diff --git a/packages/junior-plugin-api/src/tools.ts b/packages/junior-plugin-api/src/tools.ts index 7822da1513..b379c280ed 100644 --- a/packages/junior-plugin-api/src/tools.ts +++ b/packages/junior-plugin-api/src/tools.ts @@ -571,6 +571,8 @@ interface BaseToolRegistrationHookContext extends PluginContext { */ conversationId?: string; annotations?: PluginAnnotations; + /** Host-owned dashboard link to the Automation that started this run. */ + automationLink?: { url: string }; embedder: PluginEmbedder; egress: PluginEgress; mcp?: PluginMcp; diff --git a/packages/junior/src/chat/agent-dispatch/context.ts b/packages/junior/src/chat/agent-dispatch/context.ts index be438ed8a8..ac630becee 100644 --- a/packages/junior/src/chat/agent-dispatch/context.ts +++ b/packages/junior/src/chat/agent-dispatch/context.ts @@ -12,6 +12,7 @@ import { createPluginLogger } from "@/chat/plugins/logging"; import { createPluginState } from "@/chat/plugins/state"; import type { ConversationWorkQueue } from "@/chat/task-execution/queue"; import { + buildDispatchId, createOrGetDispatch, getPluginDispatchProjection, isTerminalDispatchStatus, @@ -130,6 +131,7 @@ export function createHeartbeatContext(args: { /** Create and enqueue one core-owned event automation dispatch. */ export async function dispatchEventAutomation(args: { + automationId: string; conversationWorkQueue: ConversationWorkQueue; nowMs: number; options: Omit & { @@ -157,7 +159,12 @@ export async function dispatchEventAutomation(args: { ...unboundOptions, ...(outcomes !== undefined ? { outcomes } : undefined), ...(boundSubject ? { credentialSubject: boundSubject } : undefined), - source: { kind: "event_automation" }, + source: { + kind: "event_automation", + automationId: args.automationId, + // An event automation run id is its dispatch id. + automationRunId: buildDispatchId(plugin, unboundOptions.idempotencyKey), + }, }; return await dispatch({ conversationWorkQueue: args.conversationWorkQueue, @@ -169,6 +176,7 @@ export async function dispatchEventAutomation(args: { /** Create and enqueue one core-owned scheduled automation dispatch. */ export async function dispatchScheduledAutomation(args: { + automationId: string; conversationWorkQueue: ConversationWorkQueue; nowMs: number; options: Omit & { @@ -196,7 +204,12 @@ export async function dispatchScheduledAutomation(args: { ...unboundOptions, ...(outcomes !== undefined ? { outcomes } : undefined), ...(boundSubject ? { credentialSubject: boundSubject } : undefined), - source: { kind: "scheduled_automation" }, + source: { + kind: "scheduled_automation", + automationId: args.automationId, + // A scheduled automation run id is its idempotency key. + automationRunId: unboundOptions.idempotencyKey, + }, }; return await dispatch({ conversationWorkQueue: args.conversationWorkQueue, diff --git a/packages/junior/src/chat/agent-dispatch/store.ts b/packages/junior/src/chat/agent-dispatch/store.ts index e2372be10d..370aea70dc 100644 --- a/packages/junior/src/chat/agent-dispatch/store.ts +++ b/packages/junior/src/chat/agent-dispatch/store.ts @@ -163,7 +163,11 @@ function normalizeMetadata( const DISPATCH_ID_PREFIX = "agent-dispatch:"; -function buildDispatchId(plugin: string, idempotencyKey: string): string { +/** Return the stable dispatch id for one plugin idempotency key. */ +export function buildDispatchId( + plugin: string, + idempotencyKey: string, +): string { const digest = createHash("sha256") .update(plugin) .update("\0") diff --git a/packages/junior/src/chat/event-automations/ingest.ts b/packages/junior/src/chat/event-automations/ingest.ts index 08df389dce..a3f649c64e 100644 --- a/packages/junior/src/chat/event-automations/ingest.ts +++ b/packages/junior/src/chat/event-automations/ingest.ts @@ -154,6 +154,7 @@ export async function ingestEventAutomations( } : undefined; const dispatch = await dispatchEventAutomation({ + automationId: task.id, conversationWorkQueue: options.queue, nowMs, options: { diff --git a/packages/junior/src/chat/plugins/agent-hooks.ts b/packages/junior/src/chat/plugins/agent-hooks.ts index 05ccbb50ec..cdb64aa0c5 100644 --- a/packages/junior/src/chat/plugins/agent-hooks.ts +++ b/packages/junior/src/chat/plugins/agent-hooks.ts @@ -46,7 +46,10 @@ import { createPluginState } from "@/chat/plugins/state"; import { SANDBOX_WORKSPACE_ROOT } from "@/chat/sandbox/paths"; import { runNonInteractiveCommand } from "@/chat/sandbox/noninteractive-command"; import type { AnyToolDefinition } from "@/chat/tools/definition"; -import { getDashboardConversationLink } from "@/chat/dashboard-link"; +import { + getDashboardConversationLink, + getDashboardTaskLink, +} from "@/chat/dashboard-link"; import { createWatch } from "@/chat/events/store"; import { RESOURCE_SUBSCRIPTION_DEFAULT_TTL_MS } from "@/chat/events/tool-support"; @@ -701,9 +704,18 @@ export function getPluginTools( }; const resolveActor = context.resolveActorIdentity ?? (async () => undefined); + const automationUrl = + (context.source.kind === "event_automation" || + context.source.kind === "scheduled_automation") && + context.source.automationId + ? getDashboardTaskLink(context.source.automationId) + : undefined; const common = { ...basePluginContext(plugin), annotations, + ...(automationUrl + ? { automationLink: { url: automationUrl } } + : undefined), conversationId: context.conversationId, locationId: context.locationId, ...(slackContext ? { slack: slackContext } : undefined), diff --git a/packages/junior/src/chat/scheduled-automations/heartbeat.ts b/packages/junior/src/chat/scheduled-automations/heartbeat.ts index 973e98e063..55bb73b551 100644 --- a/packages/junior/src/chat/scheduled-automations/heartbeat.ts +++ b/packages/junior/src/chat/scheduled-automations/heartbeat.ts @@ -364,6 +364,7 @@ export async function runScheduledAutomationHeartbeat(args: { let dispatch; try { dispatch = await dispatchScheduledAutomation({ + automationId: task.id, conversationWorkQueue: args.conversationWorkQueue, nowMs: args.nowMs, options: { diff --git a/packages/junior/tests/integration/event-automations.test.ts b/packages/junior/tests/integration/event-automations.test.ts index e1bc56f81a..140638a3e4 100644 --- a/packages/junior/tests/integration/event-automations.test.ts +++ b/packages/junior/tests/integration/event-automations.test.ts @@ -343,6 +343,14 @@ describe("event automations", () => { }), ]), ); + // Each dispatch Source names its Automation and run. + for (const dispatch of dispatches) { + expect(dispatch!.source).toEqual({ + kind: "event_automation", + automationId: expect.any(String), + automationRunId: dispatch!.id, + }); + } // Each dispatch starts one run that links its Conversation. const readRuns = () => db diff --git a/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts b/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts index b2b64b558b..b0cd86fd2a 100644 --- a/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts +++ b/packages/junior/tests/integration/heartbeat-scheduled-automations.test.ts @@ -206,7 +206,11 @@ describe("scheduled automation heartbeat", () => { ); expect(dispatchRecord?.destination).toEqual(SLACK_DESTINATION); expect(dispatchRecord?.destinationVisibility).toBe("public"); - expect(dispatchRecord?.source).toEqual({ kind: "scheduled_automation" }); + expect(dispatchRecord?.source).toEqual({ + kind: "scheduled_automation", + automationId: "sched_plugin_1", + automationRunId: `sched_plugin_1:${TEST_RUN_AT_MS}`, + }); expect(dispatchRecord?.metadata).toMatchObject({ runId: `sched_plugin_1:${TEST_RUN_AT_MS}`, schedule: "Once at noon",