From 17706f60a467f0082316889b75733f492dfd50a9 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sat, 14 Mar 2026 05:37:28 +0000 Subject: [PATCH 1/2] feat(run-links): add per-project run links in agent comments --- src/api/routers/projects.ts | 2 + src/backends/adapter.ts | 53 ++++- src/backends/progress.ts | 9 + src/backends/progressMonitor.ts | 50 ++++ src/cli/dashboard/projects/update.ts | 7 + src/config/schema.ts | 1 + src/db/migrations/0037_add_run_links.sql | 4 + src/db/migrations/meta/_journal.json | 7 + src/db/repositories/configMapper.ts | 3 + src/db/repositories/projectsRepository.ts | 3 + src/db/schema/projects.ts | 3 +- src/gadgets/github/core/createPR.ts | 30 ++- src/gadgets/github/core/createPRReview.ts | 30 ++- src/gadgets/github/core/postPRComment.ts | 29 ++- src/gadgets/pm/core/postComment.ts | 32 ++- src/router/adapters/github.ts | 38 ++- src/router/adapters/jira.ts | 19 +- src/router/adapters/trello.ts | 19 +- src/utils/runLink.ts | 99 ++++++++ tests/unit/utils/runLink.test.ts | 220 ++++++++++++++++++ .../projects/project-general-form.tsx | 16 ++ 21 files changed, 662 insertions(+), 12 deletions(-) create mode 100644 src/db/migrations/0037_add_run_links.sql create mode 100644 src/utils/runLink.ts create mode 100644 tests/unit/utils/runLink.test.ts diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts index adab697e..a26ab05c 100644 --- a/src/api/routers/projects.ts +++ b/src/api/routers/projects.ts @@ -91,6 +91,7 @@ export const projectsRouter = router({ workItemBudgetUsd: z.string().nullish(), agentEngine: z.string().nullish(), engineSettings: EngineSettingsSchema.nullish(), + runLinksEnabled: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -113,6 +114,7 @@ export const projectsRouter = router({ workItemBudgetUsd: z.string().nullish(), agentEngine: z.string().nullish(), engineSettings: EngineSettingsSchema.nullish(), + runLinksEnabled: z.boolean().optional(), }), ) .mutation(async ({ ctx, input }) => { diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index 6bfd90e2..1dd74c89 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -32,6 +32,7 @@ import { import { withGitHubToken } from '../github/client.js'; import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../types/index.js'; import { logger } from '../utils/logging.js'; +import { getDashboardUrl } from '../utils/runLink.js'; import { readCompletionEvidence } from './completion.js'; import { createNativeToolRuntimeArtifacts } from './nativeToolRuntime.js'; import { postProcessResult } from './postProcess.js'; @@ -325,13 +326,27 @@ async function hydratePrSidecar(sidecarPath: string): Promise<{ * Build progress-monitor config from pipeline inputs. */ function buildProgressMonitorConfig( - input: AgentInput & { config: CascadeConfig }, + input: AgentInput & { config: CascadeConfig; project: ProjectConfig }, agentType: string, logWriter: LogWriter, repoDir: string | null, isGitHubAck: boolean, + engineId: string, + model: string, ) { const { workItemId } = input; + + // Build run link config when the project has run links enabled and dashboard URL is set + const runLink = + input.project.runLinksEnabled && getDashboardUrl() + ? { + engineLabel: engineId, + model, + projectId: input.project.id, + workItemId: workItemId ?? undefined, + } + : undefined; + return { logWriter, agentType, @@ -342,6 +357,7 @@ function buildProgressMonitorConfig( repoDir: repoDir ?? undefined, trello: workItemId ? { workItemId } : undefined, preSeededCommentId: isGitHubAck ? undefined : (input.ackCommentId as string | undefined), + runLink, ...(input.prNumber && input.repoFullName ? { github: { @@ -442,6 +458,7 @@ export async function executeWithEngine( outputSummary: outcome.outputSummary, }), + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: webhook pipeline with sequential guard checks execute: async (ctx: PipelineContext) => { const { repoDir, fileLogger, logWriter, setRunId } = ctx; const log = createAgentLogger(fileLogger); @@ -480,9 +497,41 @@ export async function executeWithEngine( } const monitor = createProgressMonitor( - buildProgressMonitorConfig(input, agentType, logWriter, repoDir, isGitHubAck), + buildProgressMonitorConfig( + input, + agentType, + logWriter, + repoDir, + isGitHubAck, + engine.definition.id, + partialInput.model ?? '', + ), ); + // Inject the runId into the progress monitor so links point to the specific run + if (runId && monitor) { + monitor.setRunId(runId); + } + + // Inject run link env vars into project secrets for subprocess agents (claude-code/codex) + if (input.project.runLinksEnabled) { + partialInput.projectSecrets ??= {}; + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + partialInput.projectSecrets.CASCADE_RUN_LINKS_ENABLED = 'true'; + partialInput.projectSecrets.CASCADE_DASHBOARD_URL = dashboardUrl; + partialInput.projectSecrets.CASCADE_ENGINE_LABEL = engine.definition.id; + partialInput.projectSecrets.CASCADE_MODEL = partialInput.model ?? ''; + partialInput.projectSecrets.CASCADE_PROJECT_ID = input.project.id; + if (workItemId) { + partialInput.projectSecrets.CASCADE_WORK_ITEM_ID = workItemId; + } + if (runId) { + partialInput.projectSecrets.CASCADE_RUN_ID = runId; + } + } + } + const executionPlan: AgentExecutionPlan = { ...partialInput, progressReporter: monitor ?? { diff --git a/src/backends/progress.ts b/src/backends/progress.ts index d2dbfc81..e0779773 100644 --- a/src/backends/progress.ts +++ b/src/backends/progress.ts @@ -16,6 +16,14 @@ export interface ProgressMonitorOptions { github?: { owner: string; repo: string }; /** Pre-seeded comment ID from router ack — skip initial comment posting */ preSeededCommentId?: string; + /** Run link config — when set, appends a dashboard link to progress comments */ + runLink?: { + runId?: string; + engineLabel: string; + model: string; + projectId: string; + workItemId?: string; + }; } /** @@ -41,6 +49,7 @@ export function createProgressMonitor(options: ProgressMonitorOptions): Progress trello: options.trello, github: options.github, preSeededCommentId: options.preSeededCommentId, + runLink: options.runLink, }; return new ProgressMonitor(config); diff --git a/src/backends/progressMonitor.ts b/src/backends/progressMonitor.ts index 711df6dc..97e4e4a5 100644 --- a/src/backends/progressMonitor.ts +++ b/src/backends/progressMonitor.ts @@ -21,6 +21,7 @@ import type { ModelSpec } from 'llmist'; import { syncCompletedTodosToChecklist } from '../agents/utils/checklistSync.js'; import { formatStatusMessage } from '../config/statusUpdateConfig.js'; import { captureException } from '../sentry.js'; +import { buildRunLink, buildWorkItemRunsLink, getDashboardUrl } from '../utils/runLink.js'; import { callProgressModel } from './progressModel.js'; import { clearProgressCommentId, writeProgressCommentId } from './progressState.js'; import { ProgressAccumulator } from './progressState/accumulator.js'; @@ -49,6 +50,14 @@ export interface ProgressMonitorConfig { * Defaults to DEFAULT_SCHEDULE_MINUTES = [1, 3, 5]. */ scheduleMinutes?: number[]; + /** Run link config — when set, appends a dashboard link to progress comments */ + runLink?: { + runId?: string; + engineLabel: string; + model: string; + projectId: string; + workItemId?: string; + }; } const PROGRESS_MODEL_TIMEOUT_MS = 20_000; @@ -92,6 +101,13 @@ export class ProgressMonitor implements ProgressReporter { return this.pmPoster?.getCommentId() ?? null; } + /** Update the run ID (available after the run record is created). */ + setRunId(runId: string): void { + if (this.config.runLink) { + this.config.runLink.runId = runId; + } + } + // ── ProgressReporter interface (accumulate only, no posting) ── async onIteration(iteration: number, maxIterations: number): Promise { @@ -152,6 +168,36 @@ export class ProgressMonitor implements ProgressReporter { // ── Internal ── + private buildRunLinkFooter(): string { + const { runLink } = this.config; + if (!runLink) return ''; + + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return ''; + + if (runLink.runId) { + return buildRunLink({ + dashboardUrl, + runId: runLink.runId, + engineLabel: runLink.engineLabel, + model: runLink.model, + }); + } + + if (runLink.workItemId) { + return buildWorkItemRunsLink({ + dashboardUrl, + projectId: runLink.projectId, + workItemId: runLink.workItemId, + engineLabel: runLink.engineLabel, + model: runLink.model, + }); + } + + return ''; + } + + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: progress reporting with multiple posting targets private async tick(): Promise { // Wait for initial comment to complete before proceeding so the first // tick updates the same comment instead of creating a duplicate @@ -192,6 +238,10 @@ export class ProgressMonitor implements ProgressReporter { summary = formatStatusMessage(this.config.agentType); } + // Append run link footer if configured + const runLinkFooter = this.buildRunLinkFooter(); + if (runLinkFooter) summary += runLinkFooter; + // Post to PM provider (Trello/JIRA) if (this.pmPoster) { try { diff --git a/src/cli/dashboard/projects/update.ts b/src/cli/dashboard/projects/update.ts index 38b66ec1..06127a57 100644 --- a/src/cli/dashboard/projects/update.ts +++ b/src/cli/dashboard/projects/update.ts @@ -17,6 +17,10 @@ export default class ProjectsUpdate extends DashboardCommand { model: Flags.string({ description: 'Default model' }), 'work-item-budget': Flags.string({ description: 'Per-work-item budget in USD' }), 'agent-engine': Flags.string({ description: 'Agent engine' }), + 'run-links-enabled': Flags.boolean({ + description: 'Enable run links in agent comments (requires CASCADE_DASHBOARD_URL env var)', + allowNo: true, + }), }; async run(): Promise { @@ -32,6 +36,9 @@ export default class ProjectsUpdate extends DashboardCommand { model: flags.model, workItemBudgetUsd: flags['work-item-budget'], agentEngine: flags['agent-engine'], + ...(flags['run-links-enabled'] !== undefined + ? { runLinksEnabled: flags['run-links-enabled'] } + : {}), }); if (flags.json) { diff --git a/src/config/schema.ts b/src/config/schema.ts index 78920688..216a7ad4 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -64,6 +64,7 @@ export const ProjectConfigSchema = z.object({ agentEngine: AgentEngineConfigSchema.optional(), engineSettings: EngineSettingsSchema.optional(), squintDbUrl: z.string().url().optional(), + runLinksEnabled: z.boolean().default(false), }); export const CascadeConfigSchema = z.object({ diff --git a/src/db/migrations/0037_add_run_links.sql b/src/db/migrations/0037_add_run_links.sql new file mode 100644 index 00000000..ff59caa9 --- /dev/null +++ b/src/db/migrations/0037_add_run_links.sql @@ -0,0 +1,4 @@ +-- Migration 0037: Add run_links_enabled to projects +-- Enables per-project opt-in for including dashboard run links in agent comments. + +ALTER TABLE projects ADD COLUMN IF NOT EXISTS run_links_enabled BOOLEAN NOT NULL DEFAULT false; diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 17aab923..45af4339 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -260,6 +260,13 @@ "when": 1771000000000, "tag": "0036_project_only_agent_configs", "breakpoints": false + }, + { + "idx": 37, + "version": "7", + "when": 1772000000000, + "tag": "0037_add_run_links", + "breakpoints": false } ] } diff --git a/src/db/repositories/configMapper.ts b/src/db/repositories/configMapper.ts index 7684c881..6db78b70 100644 --- a/src/db/repositories/configMapper.ts +++ b/src/db/repositories/configMapper.ts @@ -90,6 +90,7 @@ export interface ProjectConfigRaw { workItemBudgetUsd?: number; squintDbUrl?: string; engineSettings?: EngineSettings; + runLinksEnabled?: boolean; trello?: { boardId: string; lists: Record; @@ -126,6 +127,7 @@ type ProjectRow = { squintDbUrl: string | null; agentEngine: string | null; agentEngineSettings: EngineSettings | null; + runLinksEnabled: boolean; }; export function buildAgentMaps(configs: AgentConfigRow[]): { @@ -238,6 +240,7 @@ export function mapProjectRow({ workItemBudgetUsd: row.workItemBudgetUsd ? Number(row.workItemBudgetUsd) : undefined, engineSettings: row.agentEngineSettings ?? undefined, squintDbUrl: row.squintDbUrl ?? undefined, + runLinksEnabled: row.runLinksEnabled ?? false, }; if (trelloConfig) { diff --git a/src/db/repositories/projectsRepository.ts b/src/db/repositories/projectsRepository.ts index f74cd206..6766b41f 100644 --- a/src/db/repositories/projectsRepository.ts +++ b/src/db/repositories/projectsRepository.ts @@ -38,6 +38,7 @@ export async function createProject( workItemBudgetUsd?: string | null; agentEngine?: string | null; engineSettings?: EngineSettings | null; + runLinksEnabled?: boolean; }, ) { const db = getDb(); @@ -54,6 +55,7 @@ export async function createProject( model: rest.model, workItemBudgetUsd: rest.workItemBudgetUsd, agentEngine: rest.agentEngine, + runLinksEnabled: rest.runLinksEnabled ?? false, ...(engineSettings !== undefined ? { agentEngineSettings: normalizeEngineSettings(engineSettings) } : {}), @@ -74,6 +76,7 @@ export async function updateProject( workItemBudgetUsd?: string | null; agentEngine?: string | null; engineSettings?: EngineSettings | null; + runLinksEnabled?: boolean; }, ) { const db = getDb(); diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 6ab7c02f..c3534abb 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -1,4 +1,4 @@ -import { jsonb, numeric, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; +import { boolean, jsonb, numeric, pgTable, text, timestamp } from 'drizzle-orm/pg-core'; import type { EngineSettings } from '../../config/engineSettings.js'; import { organizations } from './organizations.js'; @@ -20,6 +20,7 @@ export const projects = pgTable( agentEngine: text('agent_engine'), agentEngineSettings: jsonb('agent_engine_settings').$type(), squintDbUrl: text('squint_db_url'), + runLinksEnabled: boolean('run_links_enabled').default(false).notNull(), createdAt: timestamp('created_at').defaultNow(), updatedAt: timestamp('updated_at') diff --git a/src/gadgets/github/core/createPR.ts b/src/gadgets/github/core/createPR.ts index e29edbbf..3c91e6b3 100644 --- a/src/gadgets/github/core/createPR.ts +++ b/src/gadgets/github/core/createPR.ts @@ -1,5 +1,30 @@ import { githubClient } from '../../../github/client.js'; import { runCommand } from '../../../utils/repo.js'; +import { buildRunLink, buildWorkItemRunsLink, getDashboardUrl } from '../../../utils/runLink.js'; + +/** + * Build the run link footer for PR body, reading env vars injected + * by the secretBuilder for subprocess agents (claude-code/codex/opencode). + */ +function buildRunLinkFooter(): string { + if (process.env.CASCADE_RUN_LINKS_ENABLED !== 'true') return ''; + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return ''; + + const runId = process.env.CASCADE_RUN_ID; + const engineLabel = process.env.CASCADE_ENGINE_LABEL ?? ''; + const model = process.env.CASCADE_MODEL ?? ''; + const projectId = process.env.CASCADE_PROJECT_ID ?? ''; + const workItemId = process.env.CASCADE_WORK_ITEM_ID ?? ''; + + if (runId) { + return buildRunLink({ dashboardUrl, runId, engineLabel, model }); + } + if (projectId && workItemId) { + return buildWorkItemRunsLink({ dashboardUrl, projectId, workItemId, engineLabel, model }); + } + return ''; +} export interface CreatePRParams { title: string; @@ -100,10 +125,13 @@ export async function createPR(params: CreatePRParams): Promise ); } + const runLinkFooter = buildRunLinkFooter(); + const prBody = runLinkFooter ? params.body + runLinkFooter : params.body; + try { const pr = await githubClient.createPR(owner, repo, { title: params.title, - body: params.body, + body: prBody, head: params.head, base: params.base, draft: params.draft, diff --git a/src/gadgets/github/core/createPRReview.ts b/src/gadgets/github/core/createPRReview.ts index 0b7c371b..a2be845d 100644 --- a/src/gadgets/github/core/createPRReview.ts +++ b/src/gadgets/github/core/createPRReview.ts @@ -1,4 +1,5 @@ import { githubClient } from '../../../github/client.js'; +import { buildRunLink, buildWorkItemRunsLink, getDashboardUrl } from '../../../utils/runLink.js'; export interface CreatePRReviewParams { owner: string; @@ -14,13 +15,40 @@ export interface CreatePRReviewResult { event: string; } +/** + * Build the run link footer for PR reviews, reading env vars injected + * by the secretBuilder for subprocess agents (claude-code/codex/opencode). + */ +function buildRunLinkFooter(): string { + if (process.env.CASCADE_RUN_LINKS_ENABLED !== 'true') return ''; + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return ''; + + const runId = process.env.CASCADE_RUN_ID; + const engineLabel = process.env.CASCADE_ENGINE_LABEL ?? ''; + const model = process.env.CASCADE_MODEL ?? ''; + const projectId = process.env.CASCADE_PROJECT_ID ?? ''; + const workItemId = process.env.CASCADE_WORK_ITEM_ID ?? ''; + + if (runId) { + return buildRunLink({ dashboardUrl, runId, engineLabel, model }); + } + if (projectId && workItemId) { + return buildWorkItemRunsLink({ dashboardUrl, projectId, workItemId, engineLabel, model }); + } + return ''; +} + export async function createPRReview(params: CreatePRReviewParams): Promise { + const runLinkFooter = buildRunLinkFooter(); + const body = runLinkFooter ? params.body + runLinkFooter : params.body; + const review = await githubClient.createPRReview( params.owner, params.repo, params.prNumber, params.event, - params.body, + body, params.comments, ); return { reviewUrl: review.htmlUrl, event: params.event }; diff --git a/src/gadgets/github/core/postPRComment.ts b/src/gadgets/github/core/postPRComment.ts index 53e2adaa..974657ea 100644 --- a/src/gadgets/github/core/postPRComment.ts +++ b/src/gadgets/github/core/postPRComment.ts @@ -1,4 +1,29 @@ import { githubClient } from '../../../github/client.js'; +import { buildRunLink, buildWorkItemRunsLink, getDashboardUrl } from '../../../utils/runLink.js'; + +/** + * Build the run link footer for GitHub PR comments, reading env vars injected + * by the secretBuilder for subprocess agents (claude-code/codex/opencode). + */ +function buildRunLinkFooter(): string { + if (process.env.CASCADE_RUN_LINKS_ENABLED !== 'true') return ''; + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return ''; + + const runId = process.env.CASCADE_RUN_ID; + const engineLabel = process.env.CASCADE_ENGINE_LABEL ?? ''; + const model = process.env.CASCADE_MODEL ?? ''; + const projectId = process.env.CASCADE_PROJECT_ID ?? ''; + const workItemId = process.env.CASCADE_WORK_ITEM_ID ?? ''; + + if (runId) { + return buildRunLink({ dashboardUrl, runId, engineLabel, model }); + } + if (projectId && workItemId) { + return buildWorkItemRunsLink({ dashboardUrl, projectId, workItemId, engineLabel, model }); + } + return ''; +} export async function postPRComment( owner: string, @@ -7,7 +32,9 @@ export async function postPRComment( body: string, ): Promise { try { - const result = await githubClient.createPRComment(owner, repo, prNumber, body); + const runLinkFooter = buildRunLinkFooter(); + const fullBody = runLinkFooter ? body + runLinkFooter : body; + const result = await githubClient.createPRComment(owner, repo, prNumber, fullBody); return `Comment posted (id: ${result.id}): ${result.htmlUrl}`; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/src/gadgets/pm/core/postComment.ts b/src/gadgets/pm/core/postComment.ts index bc244037..0a05dd60 100644 --- a/src/gadgets/pm/core/postComment.ts +++ b/src/gadgets/pm/core/postComment.ts @@ -1,16 +1,44 @@ import { clearProgressCommentId, readProgressCommentId } from '../../../backends/progressState.js'; import { getPMProvider } from '../../../pm/index.js'; import { logger } from '../../../utils/logging.js'; +import { buildRunLink, buildWorkItemRunsLink, getDashboardUrl } from '../../../utils/runLink.js'; + +/** + * Build the run link footer for agent-posted comments, reading env vars injected + * by the secretBuilder for subprocess agents (claude-code/codex/opencode). + */ +function buildRunLinkFooter(workItemId: string): string { + if (process.env.CASCADE_RUN_LINKS_ENABLED !== 'true') return ''; + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return ''; + + const runId = process.env.CASCADE_RUN_ID; + const engineLabel = process.env.CASCADE_ENGINE_LABEL ?? ''; + const model = process.env.CASCADE_MODEL ?? ''; + const projectId = process.env.CASCADE_PROJECT_ID ?? ''; + + if (runId) { + return buildRunLink({ dashboardUrl, runId, engineLabel, model }); + } + if (projectId && workItemId) { + return buildWorkItemRunsLink({ dashboardUrl, projectId, workItemId, engineLabel, model }); + } + return ''; +} export async function postComment(workItemId: string, text: string): Promise { try { const provider = getPMProvider(); + // Append run link footer when enabled via env vars (injected by secretBuilder for subprocesses) + const runLinkFooter = buildRunLinkFooter(workItemId); + const fullText = runLinkFooter ? text + runLinkFooter : text; + // Check if there is a progress comment we should update instead of creating new const progressState = readProgressCommentId(); if (progressState && progressState.workItemId === workItemId) { try { - await provider.updateComment(workItemId, progressState.commentId, text); + await provider.updateComment(workItemId, progressState.commentId, fullText); clearProgressCommentId(); return 'Comment posted successfully'; } catch (error) { @@ -24,7 +52,7 @@ export async function postComment(workItemId: string, text: string): Promise { try { + // Load full project config to check run link settings + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + const runLinksEnabled = fullProject?.runLinksEnabled ?? false; + + // Helper to append run link footer to a message when enabled + const withRunLink = (message: string, workItemId?: string): string => { + if (!runLinksEnabled || !workItemId) return message; + const dashboardUrl = getDashboardUrl(); + if (!dashboardUrl) return message; + const link = buildWorkItemRunsLink({ dashboardUrl, projectId: project.id, workItemId }); + return link ? message + link : message; + }; + // PM-focused agents (e.g. backlog-manager) should have ack posted to the PM tool // (Trello/JIRA card), not to the already-merged GitHub PR. if (await isPMFocusedAgent(agentType)) { @@ -256,17 +271,36 @@ export class GitHubRouterAdapter implements RouterPlatformAdapter { return undefined; } const context = extractGitHubContext(payload, event.eventType); - const message = await generateAckMessage(agentType, context, project.id); + const baseMessage = await generateAckMessage(agentType, context, project.id); + const message = withRunLink(baseMessage, workItemId); return postPMAck(project.id, workItemId, project.pmType, agentType, message); } - return postGitHubPRAck( + const ackResult = await postGitHubPRAck( (event as GitHubParsedEvent).repoFullName, event.eventType, payload, agentType, project.id, ); + + // For GitHub PR acks, append run link to the message if enabled + // (Note: the comment is already posted, we just update the returned message for reference) + if (ackResult && runLinksEnabled && event.workItemId) { + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + const link = buildWorkItemRunsLink({ + dashboardUrl, + projectId: project.id, + workItemId: event.workItemId, + }); + if (link) { + return { ...ackResult, message: (ackResult.message ?? '') + link }; + } + } + } + + return ackResult; } catch (err) { logger.warn('GitHub ack comment failed (non-fatal)', { error: String(err) }); } diff --git a/src/router/adapters/jira.ts b/src/router/adapters/jira.ts index 065a88a1..91475d3b 100644 --- a/src/router/adapters/jira.ts +++ b/src/router/adapters/jira.ts @@ -11,6 +11,7 @@ import { withJiraCredentials } from '../../jira/client.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; import type { TriggerContext, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; +import { buildWorkItemRunsLink, getDashboardUrl } from '../../utils/runLink.js'; import { extractJiraContext, generateAckMessage } from '../ackMessageGenerator.js'; import { postJiraAck, resolveJiraBotAccountId } from '../acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; @@ -141,7 +142,23 @@ export class JiraRouterAdapter implements RouterPlatformAdapter { if (!issueKey) return undefined; try { const context = extractJiraContext(payload); - const message = await generateAckMessage(agentType, context, project.id); + let message = await generateAckMessage(agentType, context, project.id); + + // Append run link footer when enabled for this project + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (fullProject?.runLinksEnabled && event.workItemId) { + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + const link = buildWorkItemRunsLink({ + dashboardUrl, + projectId: project.id, + workItemId: event.workItemId, + }); + if (link) message += link; + } + } + const commentId = await postJiraAck(project.id, issueKey, message); if (commentId) return { commentId, message }; return undefined; diff --git a/src/router/adapters/trello.ts b/src/router/adapters/trello.ts index 48e00c58..b9c60d4a 100644 --- a/src/router/adapters/trello.ts +++ b/src/router/adapters/trello.ts @@ -11,6 +11,7 @@ import { withTrelloCredentials } from '../../trello/client.js'; import type { TriggerRegistry } from '../../triggers/registry.js'; import type { TriggerContext, TriggerResult } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; +import { buildWorkItemRunsLink, getDashboardUrl } from '../../utils/runLink.js'; import { extractTrelloContext, generateAckMessage } from '../ackMessageGenerator.js'; import { postTrelloAck } from '../acknowledgments.js'; import { type RouterProjectConfig, loadProjectConfig } from '../config.js'; @@ -137,7 +138,23 @@ export class TrelloRouterAdapter implements RouterPlatformAdapter { if (!event.workItemId) return undefined; try { const context = extractTrelloContext(payload); - const message = await generateAckMessage(agentType, context, project.id); + let message = await generateAckMessage(agentType, context, project.id); + + // Append run link footer when enabled for this project + const config = await loadProjectConfig(); + const fullProject = config.fullProjects.find((fp) => fp.id === project.id); + if (fullProject?.runLinksEnabled && event.workItemId) { + const dashboardUrl = getDashboardUrl(); + if (dashboardUrl) { + const link = buildWorkItemRunsLink({ + dashboardUrl, + projectId: project.id, + workItemId: event.workItemId, + }); + if (link) message += link; + } + } + const commentId = await postTrelloAck(project.id, event.workItemId, message); if (commentId) return { commentId, message }; return undefined; diff --git a/src/utils/runLink.ts b/src/utils/runLink.ts new file mode 100644 index 00000000..c7175d28 --- /dev/null +++ b/src/utils/runLink.ts @@ -0,0 +1,99 @@ +/** + * Run link utility — builds subtle dashboard links for agent comments. + * + * Generates markdown footer lines that link agents' comments back to the + * CASCADE dashboard run or work-item-runs page, making it easy to navigate + * to the right run for debugging. + * + * All functions are pure or read-only (only reads env vars) and return + * empty strings when the dashboard URL is unset or run links are disabled. + */ + +/** + * Read the CASCADE_DASHBOARD_URL env var. + * Returns empty string if unset (graceful no-op). + */ +export function getDashboardUrl(): string { + const url = process.env.CASCADE_DASHBOARD_URL; + return url && url !== 'undefined' ? url : ''; +} + +/** + * Shorten a model name for display. + * Strips provider prefixes and long suffixes to keep the label concise. + * + * Examples: + * 'openrouter:anthropic/claude-haiku-4.5' → 'claude-haiku-4.5' + * 'anthropic:claude-sonnet-4-5-20250929' → 'claude-sonnet-4-5-20250929' + * 'claude-haiku-4.5' → 'claude-haiku-4.5' + */ +export function shortenModelName(model: string): string { + if (!model) return model; + + // Strip provider prefix (e.g. 'openrouter:', 'anthropic:', 'gemini:') + const withoutProvider = model.includes(':') ? model.split(':').slice(1).join(':') : model; + + // Strip sub-provider prefix for openrouter models (e.g. 'anthropic/claude-haiku-4.5' → 'claude-haiku-4.5') + const withoutSubProvider = withoutProvider.includes('/') + ? withoutProvider.split('/').slice(1).join('/') + : withoutProvider; + + return withoutSubProvider; +} + +/** + * Build a markdown run-details footer link. + * + * Format: `🕵️ engineLabel · modelShort · [run details](url)` + * + * Returns empty string if dashboardUrl is empty or runId is missing. + */ +export function buildRunLink({ + dashboardUrl, + runId, + engineLabel, + model, +}: { + dashboardUrl: string; + runId: string; + engineLabel: string; + model: string; +}): string { + if (!dashboardUrl || !runId) return ''; + + const modelShort = shortenModelName(model); + const url = `${dashboardUrl.replace(/\/$/, '')}/runs/${runId}`; + + const parts = [engineLabel, modelShort].filter(Boolean).join(' · '); + return `\n\n---\n🕵️ ${parts} · [run details](${url})`; +} + +/** + * Build a markdown work-item-runs footer link. + * Used at ack time when a runId is not yet available. + * + * Format: `🕵️ engineLabel · modelShort · [run details](url)` + * + * Returns empty string if dashboardUrl, projectId, or workItemId is missing. + */ +export function buildWorkItemRunsLink({ + dashboardUrl, + projectId, + workItemId, + engineLabel, + model, +}: { + dashboardUrl: string; + projectId: string; + workItemId: string; + engineLabel?: string; + model?: string; +}): string { + if (!dashboardUrl || !projectId || !workItemId) return ''; + + const url = `${dashboardUrl.replace(/\/$/, '')}/work-items/${projectId}/${workItemId}`; + const modelShort = model ? shortenModelName(model) : ''; + const parts = [engineLabel, modelShort].filter(Boolean).join(' · '); + const label = parts ? `${parts} · [run details](${url})` : `[run details](${url})`; + return `\n\n---\n🕵️ ${label}`; +} diff --git a/tests/unit/utils/runLink.test.ts b/tests/unit/utils/runLink.test.ts new file mode 100644 index 00000000..2b080d3f --- /dev/null +++ b/tests/unit/utils/runLink.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + buildRunLink, + buildWorkItemRunsLink, + getDashboardUrl, + shortenModelName, +} from '../../../src/utils/runLink.js'; + +describe('runLink utility', () => { + describe('getDashboardUrl', () => { + const originalEnv = process.env.CASCADE_DASHBOARD_URL; + + afterEach(() => { + if (originalEnv === undefined) { + process.env.CASCADE_DASHBOARD_URL = undefined; + } else { + process.env.CASCADE_DASHBOARD_URL = originalEnv; + } + }); + + it('returns the CASCADE_DASHBOARD_URL env var when set', () => { + process.env.CASCADE_DASHBOARD_URL = 'https://dashboard.example.com'; + expect(getDashboardUrl()).toBe('https://dashboard.example.com'); + }); + + it('returns empty string when CASCADE_DASHBOARD_URL is not set', () => { + process.env.CASCADE_DASHBOARD_URL = undefined; + expect(getDashboardUrl()).toBe(''); + }); + }); + + describe('shortenModelName', () => { + it('strips openrouter prefix and sub-provider', () => { + expect(shortenModelName('openrouter:anthropic/claude-haiku-4.5')).toBe('claude-haiku-4.5'); + }); + + it('strips provider prefix only (no sub-provider slash)', () => { + expect(shortenModelName('anthropic:claude-sonnet-4-5-20250929')).toBe( + 'claude-sonnet-4-5-20250929', + ); + }); + + it('returns model as-is when no prefix', () => { + expect(shortenModelName('claude-haiku-4.5')).toBe('claude-haiku-4.5'); + }); + + it('handles gemini models', () => { + expect(shortenModelName('gemini:gemini-2.5-flash-lite')).toBe('gemini-2.5-flash-lite'); + }); + + it('handles openrouter google models', () => { + expect(shortenModelName('openrouter:google/gemini-2.5-flash-lite')).toBe( + 'gemini-2.5-flash-lite', + ); + }); + + it('returns empty string for empty input', () => { + expect(shortenModelName('')).toBe(''); + }); + }); + + describe('buildRunLink', () => { + it('builds a markdown run details link', () => { + const result = buildRunLink({ + dashboardUrl: 'https://dashboard.example.com', + runId: 'run-123', + engineLabel: 'claude-code', + model: 'anthropic:claude-haiku-4.5', + }); + + expect(result).toContain('🕵️'); + expect(result).toContain('claude-code'); + expect(result).toContain('claude-haiku-4.5'); + expect(result).toContain('[run details](https://dashboard.example.com/runs/run-123)'); + }); + + it('strips trailing slash from dashboard URL', () => { + const result = buildRunLink({ + dashboardUrl: 'https://dashboard.example.com/', + runId: 'run-abc', + engineLabel: 'llmist', + model: 'gemini:gemini-2.5-flash', + }); + + expect(result).toContain('https://dashboard.example.com/runs/run-abc'); + expect(result).not.toContain('//runs/'); + }); + + it('returns empty string when dashboardUrl is empty', () => { + const result = buildRunLink({ + dashboardUrl: '', + runId: 'run-123', + engineLabel: 'claude-code', + model: 'anthropic:claude-haiku-4.5', + }); + + expect(result).toBe(''); + }); + + it('returns empty string when runId is empty', () => { + const result = buildRunLink({ + dashboardUrl: 'https://dashboard.example.com', + runId: '', + engineLabel: 'claude-code', + model: 'anthropic:claude-haiku-4.5', + }); + + expect(result).toBe(''); + }); + + it('includes separator and newlines', () => { + const result = buildRunLink({ + dashboardUrl: 'https://dashboard.example.com', + runId: 'run-123', + engineLabel: 'claude-code', + model: 'claude-haiku-4.5', + }); + + expect(result).toMatch(/^\n\n---\n/); + }); + }); + + describe('buildWorkItemRunsLink', () => { + it('builds a markdown work-item-runs link', () => { + const result = buildWorkItemRunsLink({ + dashboardUrl: 'https://dashboard.example.com', + projectId: 'proj-1', + workItemId: 'card-abc', + engineLabel: 'llmist', + model: 'openrouter:google/gemini-2.5-flash', + }); + + expect(result).toContain('🕵️'); + expect(result).toContain('llmist'); + expect(result).toContain('gemini-2.5-flash'); + expect(result).toContain( + '[run details](https://dashboard.example.com/work-items/proj-1/card-abc)', + ); + }); + + it('returns empty string when dashboardUrl is empty', () => { + const result = buildWorkItemRunsLink({ + dashboardUrl: '', + projectId: 'proj-1', + workItemId: 'card-abc', + }); + + expect(result).toBe(''); + }); + + it('returns empty string when projectId is empty', () => { + const result = buildWorkItemRunsLink({ + dashboardUrl: 'https://dashboard.example.com', + projectId: '', + workItemId: 'card-abc', + }); + + expect(result).toBe(''); + }); + + it('returns empty string when workItemId is empty', () => { + const result = buildWorkItemRunsLink({ + dashboardUrl: 'https://dashboard.example.com', + projectId: 'proj-1', + workItemId: '', + }); + + expect(result).toBe(''); + }); + + it('works without optional engineLabel and model', () => { + const result = buildWorkItemRunsLink({ + dashboardUrl: 'https://dashboard.example.com', + projectId: 'proj-1', + workItemId: 'card-abc', + }); + + expect(result).toContain('🕵️'); + expect(result).toContain( + '[run details](https://dashboard.example.com/work-items/proj-1/card-abc)', + ); + }); + }); + + describe('env-var injection for subprocess agents', () => { + let originalEnv: Record; + + beforeEach(() => { + originalEnv = { + CASCADE_RUN_LINKS_ENABLED: process.env.CASCADE_RUN_LINKS_ENABLED, + CASCADE_DASHBOARD_URL: process.env.CASCADE_DASHBOARD_URL, + CASCADE_RUN_ID: process.env.CASCADE_RUN_ID, + CASCADE_ENGINE_LABEL: process.env.CASCADE_ENGINE_LABEL, + CASCADE_MODEL: process.env.CASCADE_MODEL, + CASCADE_PROJECT_ID: process.env.CASCADE_PROJECT_ID, + CASCADE_WORK_ITEM_ID: process.env.CASCADE_WORK_ITEM_ID, + }; + }); + + afterEach(() => { + for (const [key, val] of Object.entries(originalEnv)) { + if (val === undefined) { + delete process.env[key]; + } else { + process.env[key] = val; + } + } + }); + + it('getDashboardUrl reads CASCADE_DASHBOARD_URL env var', () => { + process.env.CASCADE_DASHBOARD_URL = 'https://my-dashboard.example.com'; + expect(getDashboardUrl()).toBe('https://my-dashboard.example.com'); + }); + + it('getDashboardUrl returns empty string when unset', () => { + process.env.CASCADE_DASHBOARD_URL = undefined; + expect(getDashboardUrl()).toBe(''); + }); + }); +}); diff --git a/web/src/components/projects/project-general-form.tsx b/web/src/components/projects/project-general-form.tsx index 4a586d03..77f21be7 100644 --- a/web/src/components/projects/project-general-form.tsx +++ b/web/src/components/projects/project-general-form.tsx @@ -23,6 +23,7 @@ interface Project { workItemBudgetUsd: string | null; agentEngine: string | null; engineSettings: Record> | null; + runLinksEnabled?: boolean | null; } export function ProjectGeneralForm({ project }: { project: Project }) { @@ -39,6 +40,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { const [engineSettings, setEngineSettings] = useState>>( project.engineSettings ?? {}, ); + const [runLinksEnabled, setRunLinksEnabled] = useState(project.runLinksEnabled ?? false); const updateMutation = useMutation({ mutationFn: (data: Record) => @@ -64,6 +66,7 @@ export function ProjectGeneralForm({ project }: { project: Project }) { workItemBudgetUsd: workItemBudgetUsd || null, agentEngine: agentEngine || null, engineSettings: Object.keys(engineSettings).length > 0 ? engineSettings : null, + runLinksEnabled, }); } @@ -145,6 +148,19 @@ export function ProjectGeneralForm({ project }: { project: Project }) { value={engineSettings} onChange={(next) => setEngineSettings(next ?? {})} /> +
+ setRunLinksEnabled(e.target.checked)} + className="h-4 w-4 rounded border-border" + /> + +