// Per-instance triage state. Each canvas instance gets its own server, and each // server gets its own state object via createState(), so opening two triage // canvases never cross-broadcasts one instance's org / results into the other. import { getDefaultOrg, saveDefaultOrg } from './prefs.mjs' // Sentry search windows we expose, in order. Values are the exact `period` // strings the Sentry SDK's issue.list accepts (see sentryClient.mjs); labels // drive the dropdown. export const PERIODS = [ { value: '24h', label: 'Last 24 hours' }, { value: '7d', label: 'Last 7 days' }, { value: '14d', label: 'Last 14 days' }, { value: '30d', label: 'Last 30 days' }, { value: '90d', label: 'Last 90 days' }, ] const PERIOD_VALUES = new Set(PERIODS.map((p) => p.value)) // Keys that, if written into a plain object, would mutate its prototype chain // instead of adding an own property. Work-status keys can come from untrusted // agent/Sentry data, so any of these must never be used to index workByIssueKey. const UNSAFE_STATUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']) function isUnsafeStatusKey(key) { return UNSAFE_STATUS_KEYS.has(key) } export function createState() { const categories = [] let scanError = '' let scannedTotal = 0 let scannedCapped = false let scannedOlder = 0 let scannedOlderCapped = false let org = '' let orgOptions = [] // Monotonic scope generation. Bumped whenever the user switches org/project // (the only callers of clearWorkStatuses). In-flight work hand-offs capture the // generation at start; a late result or PR callback that resolves after a scope // switch is rejected instead of repopulating the new scope's board (where a // colliding short key could show links from the previous org). let scopeGen = 0 // The user's explicitly-saved default org (persisted to disk via prefs.mjs), or // '' if they never set one. Read once at startup so a saved choice survives // canvas reopens. It takes precedence over the runtime-derived default below. let savedDefaultOrg = getDefaultOrg() // The slug the setup screen prefills. Seeds from the saved default; otherwise // filled in by setOrgOptions once orgs are discovered. let orgDefault = savedDefaultOrg let project = '' let projects = [] // The org the current `projects` list belongs to, so clients can attribute an // SSE project broadcast to the right org (setup screen switches org before any // scan, so the panel's own org isn't a reliable signal). let projectsOrg = '' // Registered app projects the "Work on selected" local hand-off can spawn the // fix session in. Enumerated eagerly on canvas open via the agent's // list_projects tool (there is no direct SDK API), each entry is // { id, name, repo, defaultBranch, path }. let projectOptions = [] // Sentry search window. Defaults to the last day; the user can widen it from // the issues list to look further back. Only Sentry's supported periods are // accepted (see PERIODS below). let period = '24h' // Connection preflight status for the MCP servers this canvas depends on. // Starts optimistic (checked:false) so the UI never flashes a setup gate // before an actual check has run. let connections = { checked: false, sentry: { configured: false, reachable: false, transient: false, error: '' }, } let prSettingsOpen = false // Whether card titles show the model's plain-English summary instead of the raw // Sentry error. Canvas-wide, defaults to the raw error so on-call sees exactly // what Sentry reported first; the user can flip to plain English from the header. let plainEnglishView = false let selectedTracker = 'github' const workByIssueKey = {} // The issue trackers the "Work on selected" hand-off can file into. The agent // does the actual filing with its own MCP access, so every tracker here is // selectable; if the chosen one isn't connected the hand-off returns an error // the user sees on the card. GitHub is always first/default. const issueTrackers = [ { id: 'github', label: 'GitHub Issues', connected: true }, { id: 'linear', label: 'Linear', connected: true }, { id: 'atlassian', label: 'Jira (Atlassian)', connected: true }, ] // Models the spawned remediation session can run under. The empty id means // "let the session pick its default model"; every other id must match a value // the host create_session tool accepts, or the hand-off would fail. const availableModels = [ { id: '', label: 'Auto (session default)' }, { id: 'claude-sonnet-5', label: 'Claude Sonnet 5' }, { id: 'claude-opus-4.8', label: 'Claude Opus 4.8' }, { id: 'gpt-5.5', label: 'GPT-5.5' }, { id: 'gpt-5.4', label: 'GPT-5.4' }, { id: 'gpt-5.3-codex', label: 'GPT-5.3-Codex' }, ] const MODEL_IDS = new Set(availableModels.map((model) => model.id)) const prTargets = { mode: 'local', model: '', local: { path: '', baseBranch: '', projectId: '', projectName: '' }, cloud: { repo: '', baseBranch: '' }, } function normalizePrTargets(input) { const src = input && typeof input === 'object' ? input : {} const local = src.local && typeof src.local === 'object' ? src.local : {} const cloud = src.cloud && typeof src.cloud === 'object' ? src.cloud : {} const str = (value) => (typeof value === 'string' ? value.trim() : '') return { mode: src.mode === 'cloud' ? 'cloud' : 'local', model: MODEL_IDS.has(str(src.model)) ? str(src.model) : '', local: { path: str(local.path), baseBranch: str(local.baseBranch), projectId: str(local.projectId), projectName: str(local.projectName), }, cloud: { repo: str(cloud.repo), baseBranch: str(cloud.baseBranch), }, } } function normalizeIssueTrackers(list) { if (!Array.isArray(list)) return [{ id: 'github', label: 'GitHub Issues', connected: true }] const seen = new Set() const out = [] for (const tracker of list) { if (!tracker || typeof tracker !== 'object') continue const id = typeof tracker.id === 'string' ? tracker.id.trim() : '' if (!id || seen.has(id)) continue seen.add(id) out.push({ id, label: typeof tracker.label === 'string' && tracker.label.trim() ? tracker.label.trim() : id, connected: tracker.connected !== false, }) } if (!seen.has('github')) out.unshift({ id: 'github', label: 'GitHub Issues', connected: true }) return out.length ? out : [{ id: 'github', label: 'GitHub Issues', connected: true }] } return { getCategories() { return categories }, getScanError() { return scanError }, setScanError(msg) { scanError = typeof msg === 'string' ? msg : '' return scanError }, getScannedTotal() { return scannedTotal }, getScannedCapped() { return scannedCapped }, getScannedOlder() { return scannedOlder }, getScannedOlderCapped() { return scannedOlderCapped }, // How many distinct open issues the last scan examined, and whether the // primary search hit its configured cap (so more may exist). Drives the // "prioritized from N open" subtitle and the empty-state count. `older` is // how many unresolved issues exist OUTSIDE the current window (only set when // the window itself was empty) so the empty state can nudge widening. setScannedInfo({ total = 0, capped = false, older = 0, olderCapped = false } = {}) { scannedTotal = Number.isFinite(total) && total >= 0 ? total : 0 scannedCapped = Boolean(capped) scannedOlder = Number.isFinite(older) && older >= 0 ? older : 0 scannedOlderCapped = Boolean(olderCapped) return { total: scannedTotal, capped: scannedCapped, older: scannedOlder, olderCapped: scannedOlderCapped } }, getOrg() { return org }, getOrgOptions() { return orgOptions }, getOrgDefault() { return orgDefault }, // The org the user has explicitly saved as their default (or '' if none). // Distinct from getOrgDefault(), which may be a runtime-derived best guess. getSavedDefaultOrg() { return savedDefaultOrg }, // Cache the org slugs discovered from the Sentry connection so the setup // form can prefill / offer a dropdown. `def` is the slug to preselect. A // user-saved default wins over the runtime-derived guess, but ONLY when it // is actually one of the discovered orgs — otherwise it's stale (e.g. left // over from a different Sentry account) and we fall back to the runtime pick // so the setup form still auto-detects a valid org. setOrgOptions(list, def) { orgOptions = Array.isArray(list) ? list.filter((s) => typeof s === 'string' && s) : [] const inOptions = (s) => orgOptions.some((o) => o.toLowerCase() === String(s || '').toLowerCase()) if (savedDefaultOrg && inOptions(savedDefaultOrg)) orgDefault = savedDefaultOrg else if (typeof def === 'string' && def) orgDefault = def else if (orgOptions.length) orgDefault = orgOptions[0] return orgOptions }, // Persist (or, with an empty slug, clear) the user's default org. Only ever // called from an explicit user action in the UI. Updates the prefilled // orgDefault to match so the setup screen reflects the change immediately. setSavedDefaultOrg(slug) { savedDefaultOrg = saveDefaultOrg(slug) if (savedDefaultOrg) orgDefault = savedDefaultOrg else if (orgOptions.length) orgDefault = orgOptions[0] return savedDefaultOrg }, getProject() { return project }, getPeriod() { return period }, // Set the Sentry search window. Ignores unknown values so a bad client // payload can never send an invalid period to the Sentry API. setPeriod(next) { if (typeof next === 'string' && PERIOD_VALUES.has(next)) period = next return period }, getProjects() { return projects }, getProjectsOrg() { return projectsOrg }, getProjectOptions() { return projectOptions }, setProjectOptions(list) { projectOptions = Array.isArray(list) ? list .filter((p) => p && typeof p === 'object' && typeof p.id === 'string' && p.id) .map((p) => ({ id: p.id, name: typeof p.name === 'string' && p.name ? p.name : p.id, repo: typeof p.repo === 'string' ? p.repo : '', defaultBranch: typeof p.defaultBranch === 'string' ? p.defaultBranch : '', path: typeof p.path === 'string' ? p.path : '', })) : [] return projectOptions }, getConnections() { return connections }, setConnections(next) { if (next && typeof next === 'object') connections = next return connections }, getPrTargets() { return prTargets }, getAvailableModels() { return availableModels }, getIssueTrackers() { return issueTrackers }, getSelectedTracker() { return selectedTracker }, getPrSettingsOpen() { return prSettingsOpen }, getWorkByIssueKey() { return workByIssueKey }, getWorkStatus(key) { return workByIssueKey[key] }, setOrg(slug) { org = typeof slug === 'string' ? slug.trim().toLowerCase() : '' }, setProject(slug) { project = typeof slug === 'string' ? slug.trim() : '' }, setProjects(list, org) { const seen = new Set() const out = [] for (const item of Array.isArray(list) ? list : []) { const slug = typeof item === 'string' ? item.trim() : '' if (!slug || seen.has(slug)) continue seen.add(slug) out.push(slug) } projects = out if (typeof org === 'string') projectsOrg = org.trim().toLowerCase() return projects }, setPrTargets(targets) { const normalized = normalizePrTargets(targets) prTargets.mode = normalized.mode prTargets.model = normalized.model prTargets.local.path = normalized.local.path prTargets.local.baseBranch = normalized.local.baseBranch prTargets.local.projectId = normalized.local.projectId prTargets.local.projectName = normalized.local.projectName prTargets.cloud.repo = normalized.cloud.repo prTargets.cloud.baseBranch = normalized.cloud.baseBranch return prTargets }, setIssueTrackers(trackers) { const normalized = normalizeIssueTrackers(trackers) issueTrackers.length = 0 issueTrackers.push(...normalized) if (!issueTrackers.some((tracker) => tracker.id === selectedTracker)) { selectedTracker = issueTrackers[0]?.id || 'github' } return issueTrackers }, setSelectedTracker(trackerId) { if (typeof trackerId !== 'string') return selectedTracker if (issueTrackers.some((tracker) => tracker.id === trackerId)) { selectedTracker = trackerId } return selectedTracker }, togglePrSettingsOpen() { prSettingsOpen = !prSettingsOpen return prSettingsOpen }, setPrSettingsOpen(open) { prSettingsOpen = Boolean(open) return prSettingsOpen }, getPlainEnglishView() { return plainEnglishView }, togglePlainEnglishView() { plainEnglishView = !plainEnglishView return plainEnglishView }, setPlainEnglishView(on) { plainEnglishView = Boolean(on) return plainEnglishView }, setCategories(cats) { categories.length = 0 categories.push(...cats) return categories }, removeIssues(categoryId, issueKeys) { const category = categories.find((c) => c.id === categoryId) if (!category) return // issueKeys arrives straight from a request body, so it may be any JSON // type. Coerce to an array before `new Set(...)` — a non-iterable value // (e.g. a number) would otherwise throw inside an unhandled promise and // could take down the extension process. const keys = Array.isArray(issueKeys) ? issueKeys : [] const keysToRemove = new Set(keys) category.issues = category.issues.filter((i) => !keysToRemove.has(i.key)) }, setWorkStatus(key, status) { if (!key || !status || typeof status !== 'object') return null // `key` can originate from the agent's submit_tracking map, whose keys are // derived from the untrusted Sentry issue list. Writing a special key like // "__proto__" (or "constructor"/"prototype") into this plain object would // invoke the prototype setter and poison every subsequent lookup for an // untracked issue, mislabeling the whole board. Reject those keys outright. if (isUnsafeStatusKey(key)) return null workByIssueKey[key] = { ...(workByIssueKey[key] || {}), ...status, } return workByIssueKey[key] }, // Start a fresh work attempt for a key, discarding any terminal artifacts from // a prior run. setWorkStatus shallow-merges, so retrying a tracked/done/ // skipped/error card would otherwise carry its old issue/PR/session links (and // the `existing*` tracking fields) into the new run, and the status renderer // could show duplicate or stale links beside the new result. Replacing the // entry outright guarantees the queued state is clean. startWorkAttempt(key) { if (!key || isUnsafeStatusKey(key)) return null workByIssueKey[key] = { phase: 'queued' } return workByIssueKey[key] }, setSkippedWorkStatus(key, existing = {}) { return this.setWorkStatus(key, { phase: 'skipped', existingIssueNumber: Number.isFinite(Number(existing.issueNumber)) ? Number(existing.issueNumber) : undefined, existingIssueUrl: typeof existing.issueUrl === 'string' ? existing.issueUrl : undefined, existingPrNumber: Number.isFinite(Number(existing.prNumber)) ? Number(existing.prNumber) : undefined, existingPrUrl: typeof existing.prUrl === 'string' ? existing.prUrl : undefined, existingPrState: typeof existing.prState === 'string' ? existing.prState.trim().toLowerCase() : undefined, }) }, // Mark an issue as already tracked by a pre-existing GitHub issue/PR that was // filed via the canvas convention (sentry-triage label + key in title). Unlike // setSkippedWorkStatus this is detected proactively during a scan, so it must // never clobber an active canvas-initiated status (working/done/handed-off). setTrackedWorkStatus(key, tracking = {}) { const current = workByIssueKey[key] const activePhase = current && ['working', 'queued', 'done', 'handed-off', 'skipped'].includes(current.phase) if (activePhase) return current const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : undefined) const str = (v) => (typeof v === 'string' && v ? v : undefined) // The tracking ISSUE is the source of truth for "being worked on". As long as // it is open, the work is still tracked — even if a PR attempt was closed or a // PR merged without closing it yet. Only a closed tracking issue means the work // is resolved/abandoned, so drop it (and any stale entry) back to the board. // The tracking ISSUE is the primary source of truth, but a still-active PR // (open/draft) is the AUTHORITATIVE work signal used by the work-start // duplicate guard. So a closed tracking issue only drops back to the board // when its linked PR is NOT still active — otherwise the board would offer // work that the guard would immediately skip. const issueState = (str(tracking.issueState) || '').toLowerCase() const prState = (str(tracking.prState) || '').toLowerCase() const prActive = prState === 'open' || prState === 'draft' if (issueState === 'closed' && !prActive) { if (current && current.phase === 'tracked') delete workByIssueKey[key] return null } return this.setWorkStatus(key, { phase: 'tracked', existingIssueNumber: num(tracking.issueNumber), existingIssueUrl: str(tracking.issueUrl), existingIssueState: str(tracking.issueState), existingPrNumber: num(tracking.prNumber), existingPrUrl: str(tracking.prUrl), existingPrState: str(tracking.prState), }) }, // Drop only the proactively-detected 'tracked' entries before a fresh scan // re-detects them, so stale tracking (e.g. a closed issue) doesn't linger, // while canvas-initiated statuses are preserved. clearTrackedWorkStatuses() { for (const key of Object.keys(workByIssueKey)) { if (workByIssueKey[key] && workByIssueKey[key].phase === 'tracked') { delete workByIssueKey[key] } } return workByIssueKey }, // Strip the "possibly related" hints off every issue. These are searched in // the currently selected repo, so a repo change makes them stale; drop them // immediately rather than waiting for the next scan to rebuild fresh objects. clearRelatedIncidents() { for (const category of categories) { for (const issue of category.issues || []) { if (issue && issue.relatedIncidents) delete issue.relatedIncidents } } return categories }, // Advance the scope generation. Any in-flight operation that captured an // older generation (a multi-item onWorkSelected loop, a pending PR callback) // will fail its scopeCurrent() check and stop/reject rather than writing onto // a scope that is no longer active. advanceScopeGen() { scopeGen += 1 return scopeGen }, clearWorkStatuses() { // A scope switch invalidates every in-flight operation bound to the old // scope; bump the generation so their late callbacks are rejected. scopeGen += 1 for (const key of Object.keys(workByIssueKey)) { delete workByIssueKey[key] } return workByIssueKey }, getScopeGen() { return scopeGen }, applyRepoDefaults({ repo = '', baseBranch = '', localPath = '' } = {}) { if (!prTargets.cloud.repo && repo) prTargets.cloud.repo = repo if (!prTargets.cloud.baseBranch && baseBranch) prTargets.cloud.baseBranch = baseBranch if (!prTargets.local.path && localPath) prTargets.local.path = localPath if (!prTargets.local.baseBranch && baseBranch) prTargets.local.baseBranch = baseBranch return prTargets }, } }