feat(jira): load attachment images outside issue Markdown (#28631)

Load Jira attachment images separately from issue Markdown so descriptions and comments stay small while screenshots load lazily and open in the existing lightbox. Downloads begin when an image mounts, capture its owning host, require negotiated image support, and use bounded authenticated byte streams and caches. Older clients retain their inline-image handlers.

Keep legacy and cancellable downloads in separate in-flight domains so cancelling a new image reader cannot abort a legacy reader; completed bytes share the bounded cache. Preserve the landed assignee, board, transition/comment queue and request lifetimes. Move unchanged project metadata IPC handlers into their domain module and tighten two inherited focus-check ceilings without changing behavior.

Validation:410 tests/54 suites; all changed-source quality stages, lint, format, routing and contract checks; exact final node/web types; independent source review;48/48 fresh hidden Electron checks with226 stable source hashes and nine inspected screenshots. Synthetic transports exercise production clients and UI. Current CI static checks, mobile bundle, wire compatibility, Linux/Windows packaging and Windows serve pass; five test shards were pending at review.

Extends the existing attachment work credited to Frank (@lifefloating) and Brennan Benson in PR #8938; existing contribution credits and media remain in this PR.
This commit is contained in:
Neil
2026-10-14 05:33:30 -07:00
committed by GitHub
parent 624c4148cc
commit a0c46b405f
64 changed files with 3119 additions and 177 deletions
+2 -2
View File
@@ -219,7 +219,7 @@
1 src/renderer/src/components/tab-group/workspace-tab-close-commands.ts
1 src/renderer/src/components/task-page-jira-status-order.ts
2 src/renderer/src/components/task-page-linear-issue-model.tsx
10 src/renderer/src/components/task-page/TaskPage.tsx
9 src/renderer/src/components/task-page/TaskPage.tsx
2 src/renderer/src/components/task-page/jira/IssueAssigneeField.tsx
1 src/renderer/src/components/task-page/linear/Filters.tsx
1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx
@@ -254,7 +254,7 @@
2 src/renderer/src/components/use-new-linear-issue-projects.ts
4 src/renderer/src/components/use-task-page-jira-creation-metadata.ts
4 src/renderer/src/components/use-task-page-jira-issue-creation.ts
2 src/renderer/src/components/use-task-page-jira-list-effects.ts
1 src/renderer/src/components/use-task-page-jira-list-effects.ts
4 src/renderer/src/components/use-task-page-linear-board.ts
6 src/renderer/src/components/use-task-page-linear-creation-state.ts
4 src/renderer/src/components/use-task-page-linear-issue-creation.ts
@@ -330,10 +330,10 @@ describe('the shell policy this page is tested under', () => {
it('admits data: and https: for images and for nothing else', () => {
expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([
"img-src 'self' data: https:"
"img-src 'self' data: blob: https:"
])
expect(cspHeader.split('; ').filter((entry) => entry.includes('https:'))).toEqual([
"img-src 'self' data: https:"
"img-src 'self' data: blob: https:"
])
// `http:` is not a substring of `https:`, so this still refuses a cleartext source.
expect(cspHeader).not.toContain('http:')
@@ -243,13 +243,13 @@ describeRender(
it('serves the shipped image policy and neither route violates it', async () => {
// The policy the shells send, read from the Kotlin source rather than restated, so this
// cannot pass against a header the app does not use.
expect(cspHeader).toContain("img-src 'self' data: https:")
expect(cspHeader).toContain("img-src 'self' data: blob: https:")
const swift = await readFile(
join(projectDir, 'mobile/modules/orca-mobile-web-shell/ios/MobileWebShellCsp.swift'),
'utf8'
)
// The other shell says the same thing, which no served header can show.
expect(swift).toContain(`"img-src 'self' data: https:"`)
expect(swift).toContain(`"img-src 'self' data: blob: https:"`)
for (const [route, text] of [
[HUB_ROUTE, 'Source Control'],
@@ -36,7 +36,7 @@ internal val MOBILE_WEB_SHELL_CSP = listOf(
//
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable and
// replaceable in flight by anything on the path.
"img-src 'self' data: https:",
"img-src 'self' data: blob: https:",
"font-src 'none'",
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the
@@ -15,7 +15,7 @@ class MobileWebShellCspTest {
assertTrue(directives.contains("style-src 'self' 'unsafe-inline'"))
// A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the
// page already renders, and the sealed preview frame. See MobileWebShellCsp.
assertTrue(directives.contains("img-src 'self' data: https:"))
assertTrue(directives.contains("img-src 'self' data: blob: https:"))
// The bootstrap page reads ./manifest.json from its own origin, which is one read-only
// directory behind the manifest map, so 'self' reaches nothing it cannot already read.
assertTrue(directives.contains("connect-src 'self'"))
@@ -42,17 +42,17 @@ class MobileWebShellCspTest {
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
// directive that grew one would fail here instead of passing a blanket absence check.
assertEquals(
listOf("img-src 'self' data: https:"),
listOf("img-src 'self' data: blob: https:"),
directives.filter { it.contains("data:") }
)
assertEquals(
listOf("media-src blob:"),
listOf("img-src 'self' data: blob: https:", "media-src blob:"),
directives.filter { it.contains("blob:") }
)
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
// this still refuses a cleartext source anywhere in the header.
assertEquals(
listOf("img-src 'self' data: https:"),
listOf("img-src 'self' data: blob: https:"),
directives.filter { it.contains("https:") }
)
assertFalse(MOBILE_WEB_SHELL_CSP.contains("http:"))
@@ -33,7 +33,7 @@ enum MobileWebShellCsp {
//
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable
// and replaceable in flight by anything on the path.
"img-src 'self' data: https:",
"img-src 'self' data: blob: https:",
"font-src 'none'",
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is
@@ -211,7 +211,7 @@ import Foundation
precondition(directives.contains("style-src 'self' 'unsafe-inline'"))
// A file preview is a `data:` URI; `https:` is the favicon, project icon and avatar the
// page already renders, and the sealed preview frame. See MobileWebShellCsp.
precondition(directives.contains("img-src 'self' data: https:"))
precondition(directives.contains("img-src 'self' data: blob: https:"))
precondition(directives.contains("connect-src 'self'"))
precondition(directives.contains("worker-src 'none'"))
precondition(directives.contains("frame-src 'none'"))
@@ -224,11 +224,11 @@ import Foundation
precondition(!header.contains("unsafe-eval"))
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
// directive that grew one would fail here instead of passing a blanket absence check.
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: https:"])
precondition(!header.contains("blob:"))
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:"])
precondition(directives.filter { $0.contains("blob:") } == ["img-src 'self' data: blob: https:", "media-src blob:"])
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
// this still refuses a cleartext source anywhere in the header.
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: https:"])
precondition(directives.filter { $0.contains("https:") } == ["img-src 'self' data: blob: https:"])
precondition(!header.contains("http:"))
precondition(!header.contains("\r") && !header.contains("\n"))
}
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
handle: vi.fn(),
listProjects: vi.fn(),
listIssueTypes: vi.fn(),
listCreateFields: vi.fn(),
listPriorities: vi.fn()
}))
vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } }))
vi.mock('./preflight', () => ({ _resetPreflightCache: vi.fn() }))
vi.mock('../jira/client', () => ({
connect: vi.fn(),
disconnect: vi.fn(),
getStatus: vi.fn(),
selectSite: vi.fn(),
testConnection: vi.fn()
}))
vi.mock('../jira/issues', () => ({
addIssueComment: vi.fn(),
createIssue: vi.fn(),
getIssue: vi.fn(),
getIssueSummary: vi.fn(),
getIssueComments: vi.fn(),
getProjectStatusOrder: vi.fn(),
listCreateFields: mocks.listCreateFields,
listIssueTypes: mocks.listIssueTypes,
listIssues: vi.fn(),
listPriorities: mocks.listPriorities,
listProjects: mocks.listProjects,
listTransitions: vi.fn(),
searchIssues: vi.fn(),
updateIssue: vi.fn(),
listAssignableUsers: vi.fn(),
listAssignableUsersForProject: vi.fn(),
searchUsers: vi.fn()
}))
import { registerJiraHandlers } from './jira'
function handler(channel: string) {
const callback = mocks.handle.mock.calls.find(([name]) => name === channel)?.[1]
if (typeof callback !== 'function') {
throw new Error(`Missing handler: ${channel}`)
}
return callback
}
beforeEach(() => {
vi.clearAllMocks()
registerJiraHandlers()
})
describe('Jira project metadata IPC registration', () => {
it('registers each metadata channel once through the main Jira registration', () => {
for (const channel of [
'jira:listProjects',
'jira:listIssueTypes',
'jira:listCreateFields',
'jira:listPriorities'
]) {
expect(mocks.handle.mock.calls.filter(([name]) => name === channel)).toHaveLength(1)
}
})
it('retains all-sites selection and site normalization', async () => {
await handler('jira:listProjects')(undefined, { siteId: ' all ' })
expect(mocks.listProjects).toHaveBeenLastCalledWith('all')
await handler('jira:listProjects')(undefined, { siteId: ' ' })
expect(mocks.listProjects).toHaveBeenLastCalledWith(undefined)
await handler('jira:listPriorities')(undefined, { siteId: ' site-1 ' })
expect(mocks.listPriorities).toHaveBeenCalledWith('site-1')
})
it('preserves invalid project and issue type sentinels', async () => {
await expect(
handler('jira:listIssueTypes')(undefined, { projectIdOrKey: ' ' })
).resolves.toEqual([])
await expect(
handler('jira:listCreateFields')(undefined, { projectIdOrKey: 'ABC', issueTypeId: 1 })
).resolves.toEqual([])
expect(mocks.listIssueTypes).not.toHaveBeenCalled()
expect(mocks.listCreateFields).not.toHaveBeenCalled()
})
it('forwards trimmed project and issue type identifiers to the same site', async () => {
await handler('jira:listIssueTypes')(undefined, {
projectIdOrKey: ' ABC ',
siteId: ' site-1 '
})
expect(mocks.listIssueTypes).toHaveBeenCalledWith('ABC', 'site-1')
await handler('jira:listCreateFields')(undefined, {
projectIdOrKey: ' ABC ',
issueTypeId: ' 10001 ',
siteId: ' site-1 '
})
expect(mocks.listCreateFields).toHaveBeenCalledWith('ABC', '10001', 'site-1')
})
})
+42
View File
@@ -0,0 +1,42 @@
import { ipcMain } from 'electron'
import { listCreateFields, listIssueTypes, listPriorities, listProjects } from '../jira/issues'
import type { JiraSiteSelection } from '../../shared/jira-types'
export function registerJiraProjectMetadataHandlers(
normalizeSiteId: (value: unknown) => string | undefined
): void {
ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => {
return listProjects(normalizeSiteId(args?.siteId))
})
ipcMain.handle(
'jira:listIssueTypes',
async (_event, args: { projectIdOrKey: string; siteId?: string }) => {
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
return []
}
return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId))
}
)
ipcMain.handle(
'jira:listCreateFields',
async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => {
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
return []
}
if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) {
return []
}
return listCreateFields(
args.projectIdOrKey.trim(),
args.issueTypeId.trim(),
normalizeSiteId(args.siteId)
)
}
)
ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => {
return listPriorities(normalizeSiteId(args?.siteId))
})
}
+24 -43
View File
@@ -1,9 +1,11 @@
import { JiraIssueListFiltersParam } from '../../shared/rpc-contract/jira-quick-filter-params'
import { ipcMain } from 'electron'
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
import { _resetPreflightCache } from './preflight'
import { JiraCancellableRequests } from './jira-cancellable-requests'
import { registerJiraUserSearchHandlers } from './jira-user-search'
import { registerJiraProjectMetadataHandlers } from './jira-project-metadata'
import {
addIssueComment,
createIssue,
@@ -11,11 +13,7 @@ import {
getIssueSummary,
getIssueComments,
getProjectStatusOrder,
listCreateFields,
listIssueTypes,
listIssues,
listPriorities,
listProjects,
listTransitions,
searchIssues,
updateIssue
@@ -187,12 +185,20 @@ export function registerJiraHandlers(): void {
}
)
ipcMain.handle('jira:getIssue', async (_event, args: { key: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return null
ipcMain.handle(
'jira:getIssue',
async (_event, args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return null
}
return getIssue(
args.key.trim(),
normalizeSiteId(args.siteId),
false,
args.mediaFormat === 'reference' ? 'reference' : undefined
)
}
return getIssue(args.key.trim(), normalizeSiteId(args.siteId))
})
)
ipcMain.handle(
'jira:lookupIssueSummary',
@@ -266,47 +272,22 @@ export function registerJiraHandlers(): void {
}
)
ipcMain.handle('jira:issueComments', async (_event, args: { key: string; siteId?: string }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
}
return getIssueComments(args.key.trim(), normalizeSiteId(args.siteId))
})
ipcMain.handle('jira:listProjects', async (_event, args?: { siteId?: JiraSiteSelection }) => {
return listProjects(normalizeSiteSelection(args?.siteId))
})
ipcMain.handle(
'jira:listIssueTypes',
async (_event, args: { projectIdOrKey: string; siteId?: string }) => {
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
'jira:issueComments',
async (_event, args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) => {
if (typeof args?.key !== 'string' || !args.key.trim()) {
return []
}
return listIssueTypes(args.projectIdOrKey.trim(), normalizeSiteId(args.siteId))
}
)
ipcMain.handle(
'jira:listCreateFields',
async (_event, args: { projectIdOrKey: string; issueTypeId: string; siteId?: string }) => {
if (typeof args?.projectIdOrKey !== 'string' || !args.projectIdOrKey.trim()) {
return []
}
if (typeof args?.issueTypeId !== 'string' || !args.issueTypeId.trim()) {
return []
}
return listCreateFields(
args.projectIdOrKey.trim(),
args.issueTypeId.trim(),
normalizeSiteId(args.siteId)
return getIssueComments(
args.key.trim(),
normalizeSiteId(args.siteId),
false,
args.mediaFormat === 'reference' ? 'reference' : undefined
)
}
)
ipcMain.handle('jira:listPriorities', async (_event, args?: { siteId?: string }) => {
return listPriorities(normalizeSiteId(args?.siteId))
})
registerJiraProjectMetadataHandlers(normalizeSiteId)
registerJiraUserSearchHandlers()
+27
View File
@@ -0,0 +1,27 @@
import type { JiraSite } from '../../shared/jira-types'
import { apiBasePath } from './authenticated-request'
import { isJiraGatewayBaseUrl } from './gateway-base-url'
import type { AttachmentMeta } from './attachment-meta'
export function attachmentContentUrl(site: JiraSite, meta: AttachmentMeta): URL | null {
if (!meta.contentUrl && site.authType === 'server') {
return null
}
const contentUrl = meta.contentUrl ? new URL(meta.contentUrl, `${site.siteUrl}/`) : null
const scopedSiteAttachment =
contentUrl?.origin === new URL(site.siteUrl).origin &&
site.authType !== 'server' &&
isJiraGatewayBaseUrl(site.apiBaseUrl) &&
!contentUrl.pathname.startsWith('/rest/')
const url =
contentUrl && !scopedSiteAttachment
? contentUrl
: new URL(
`${apiBasePath(site)}/attachment/content/${encodeURIComponent(meta.id)}`,
site.siteUrl
)
if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(url.pathname)) {
url.searchParams.set('redirect', 'false')
}
return url
}
+5 -3
View File
@@ -31,6 +31,8 @@ export function selectPreferredAttachmentIds(args: {
renderedHtmlIds: string[]
attachmentField: unknown
mediaAttrs: readonly JiraAdfMediaAttrs[]
maxBytes?: number
maxImages?: number
}): { preferredIds: string[]; fallbackRan: boolean; needCount: number } {
const needing = args.mediaAttrs.filter((attrs) => !(attrs.url && /^https?:\/\//i.test(attrs.url)))
const needCount = needing.length
@@ -44,10 +46,10 @@ export function selectPreferredAttachmentIds(args: {
let fallbackRan = false
if (needCount > preferredIds.length) {
const metas = parseImageAttachmentMetas(args.attachmentField)
const metas = parseImageAttachmentMetas(args.attachmentField, args.maxBytes)
// Why: multiple Jira screenshots often share image.png — assign next unused meta per node.
for (const node of needing) {
if (preferredIds.length >= MAX_IMAGES) {
if (preferredIds.length >= (args.maxImages ?? MAX_IMAGES)) {
break
}
const alt = (node.alt ?? '').trim()
@@ -68,7 +70,7 @@ export function selectPreferredAttachmentIds(args: {
}
return {
preferredIds: preferredIds.slice(0, MAX_IMAGES),
preferredIds: preferredIds.slice(0, args.maxImages ?? MAX_IMAGES),
fallbackRan,
needCount
}
+92
View File
@@ -0,0 +1,92 @@
import { jiraRequestBinary, type JiraClientForSite } from './authenticated-request'
import type { AttachmentMeta } from './attachment-meta'
import { attachmentContentUrl } from './attachment-content-url'
import {
loadAttachmentBytesWithCache,
type AttachmentImageBytes,
attachmentImagesEpoch,
getCachedAttachmentBytes
} from './attachment-image-cache'
import {
JIRA_IMAGE_MAX_BYTES,
JIRA_IMAGE_MAX_PENDING_READS,
isJiraImageMimeType
} from '../../shared/jira-attachment-image'
import { withJiraDeadline } from './jira-read-failure'
import { PrioritySemaphore } from '../../shared/priority-semaphore'
import { waitForPromiseWithSignal } from '../../shared/abort-signal-reason'
const downloads = new PrioritySemaphore(3)
const interests = new Map<string, { controller: AbortController; readers: number }>()
export async function readAttachmentImageBytes(
client: JiraClientForSite,
meta: AttachmentMeta,
signal?: AbortSignal
): Promise<AttachmentImageBytes | null> {
if (signal?.aborted) {
throw signal.reason ?? new Error('Jira image request cancelled')
}
const cached = getCachedAttachmentBytes(client.site.id, meta.id)
if (cached) {
return cached
}
const key = `${client.site.id}::${attachmentImagesEpoch(client.site.id)}::${meta.id}`
let interest = interests.get(key)
if (!interest || interest.controller.signal.aborted) {
if (interests.size >= JIRA_IMAGE_MAX_PENDING_READS) {
throw new Error('Jira image queue is full.')
}
interest = { controller: new AbortController(), readers: 0 }
interests.set(key, interest)
}
const owned = interest
owned.readers += 1
try {
return await waitForPromiseWithSignal(
loadAttachmentBytesWithCache({
siteId: client.site.id,
attachmentId: meta.id,
signal: owned.controller.signal,
load: async () => {
const release = await downloads.acquire(0, owned.controller.signal)
try {
const url = attachmentContentUrl(client.site, meta)
if (!url) {
return null
}
const binary = await withJiraDeadline(owned.controller.signal, 30_000, (deadline) =>
waitForPromiseWithSignal(
jiraRequestBinary(client, url.toString(), {
maxBytes: JIRA_IMAGE_MAX_BYTES,
signal: deadline
}),
deadline
)
)
const reported = binary.contentType.split(';')[0]?.trim().toLowerCase()
const mimeType =
!reported || reported === 'application/octet-stream'
? meta.mimeType.toLowerCase()
: reported
if (!isJiraImageMimeType(mimeType) || binary.data.byteLength === 0) {
return null
}
return { data: new Uint8Array(binary.data), mimeType }
} finally {
release()
}
}
}),
signal
)
} finally {
owned.readers -= 1
if (owned.readers === 0) {
owned.controller.abort()
if (interests.get(key) === owned) {
interests.delete(key)
}
}
}
}
+105 -28
View File
@@ -1,6 +1,4 @@
// Why: description and comments fetch attachments independently; comments also
// refetch after every post. Cache finished data URLs in main so the second path
// does not re-download or re-base64 the same attachment bytes.
// Description, comments, and lazy image reads share one bounded cache.
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
@@ -8,14 +6,20 @@ const CACHE_TTL_MS = 30 * 60_000
const MAX_CACHE_ENTRIES = 96
const MAX_CACHE_BYTES = 24 * 1024 * 1024
export type AttachmentImageBytes = { data: Uint8Array<ArrayBuffer>; mimeType: string }
type ImageValue = string | AttachmentImageBytes
type CacheEntry = {
dataUrl: string
image: ImageValue
byteSize: number
storedAt: number
}
const cache = new Map<string, CacheEntry>()
const inFlight = new Map<string, Promise<string | null>>()
const inFlight = new Map<
string,
{ promise: Promise<ImageValue | null>; signal?: AbortSignal; generation: symbol }
>()
let expiryTimer: ReturnType<typeof setTimeout> | null = null
let scheduledExpiryAt = Infinity
// Why: mid-flight downloads must not repopulate cache after disconnect/clearToken.
@@ -34,7 +38,7 @@ function nextEpoch(): number {
return epochTicker
}
function currentEpoch(siteId: string): number {
export function attachmentImagesEpoch(siteId: string): number {
return Math.max(globalEpoch, siteEpoch.get(siteId) ?? 0)
}
@@ -101,7 +105,7 @@ function evictUntilWithinBounds(): void {
}
}
export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null {
function getCachedImage(siteId: string, attachmentId: string): ImageValue | null {
pruneExpired()
const key = cacheKey(siteId, attachmentId)
const entry = cache.get(key)
@@ -115,19 +119,19 @@ export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string)
// Why: delete-then-set refreshes insertion order for LRU-style eviction.
cache.delete(key)
cache.set(key, entry)
return entry.dataUrl
return entry.image
}
export function setCachedAttachmentDataUrl(args: {
function setCachedImage(args: {
siteId: string
attachmentId: string
dataUrl: string
image: ImageValue
byteSize: number
}): void {
pruneExpired()
const key = cacheKey(args.siteId, args.attachmentId)
const entry: CacheEntry = {
dataUrl: args.dataUrl,
image: args.image,
byteSize: args.byteSize,
storedAt: Date.now()
}
@@ -138,54 +142,127 @@ export function setCachedAttachmentDataUrl(args: {
}
/**
* Singleflight loader: concurrent cold misses for the same attachment share one
* download. Failed loads are not cached so a later retry can succeed.
* Singleflight loader: concurrent cold misses share one download within their
* cancellation domain. Failed loads are not cached so a later retry can succeed.
*/
export async function loadAttachmentDataUrlWithCache(args: {
async function loadImageWithCache(args: {
siteId: string
attachmentId: string
load: () => Promise<{ dataUrl: string; byteSize: number } | null>
}): Promise<string | null> {
const cached = getCachedAttachmentDataUrl(args.siteId, args.attachmentId)
load: () => Promise<{ image: ImageValue; byteSize: number } | null>
signal?: AbortSignal
}): Promise<ImageValue | null> {
const cached = getCachedImage(args.siteId, args.attachmentId)
if (cached) {
return cached
}
const key = cacheKey(args.siteId, args.attachmentId)
// Legacy waiters must not inherit a cancellable reader's download.
const key = `${cacheKey(args.siteId, args.attachmentId)}::${args.signal ? 'cancellable' : 'legacy'}`
const existing = inFlight.get(key)
if (existing) {
return existing
if (existing && !existing.signal?.aborted) {
return existing.promise
}
const epochAtStart = currentEpoch(args.siteId)
const promise = (async (): Promise<string | null> => {
const epochAtStart = attachmentImagesEpoch(args.siteId)
const generation = Symbol()
const promise = (async (): Promise<ImageValue | null> => {
try {
const loaded = await args.load()
if (!loaded) {
return null
}
// Why: return bytes to the waiter but skip cache if site was cleared mid-flight.
if (currentEpoch(args.siteId) === epochAtStart) {
setCachedAttachmentDataUrl({
if (!args.signal?.aborted && attachmentImagesEpoch(args.siteId) === epochAtStart) {
setCachedImage({
siteId: args.siteId,
attachmentId: args.attachmentId,
dataUrl: loaded.dataUrl,
image: loaded.image,
byteSize: loaded.byteSize
})
}
return loaded.dataUrl
return loaded.image
} finally {
// A cleared generation no longer owns the current download's singleflight slot.
if (currentEpoch(args.siteId) === epochAtStart) {
if (inFlight.get(key)?.generation === generation) {
inFlight.delete(key)
}
}
})()
inFlight.set(key, promise)
inFlight.set(key, { promise, signal: args.signal, generation })
return promise
}
function imageDataUrl(image: ImageValue): string {
return typeof image === 'string'
? image
: `data:${image.mimeType};base64,${Buffer.from(image.data).toString('base64')}`
}
function imageBytes(image: ImageValue): AttachmentImageBytes | null {
if (typeof image !== 'string') {
return image
}
const match = /^data:(image\/[^;]+);base64,(.*)$/s.exec(image)
if (!match?.[1] || match[2] === undefined) {
return null
}
return { data: Uint8Array.from(Buffer.from(match[2], 'base64')), mimeType: match[1] }
}
export function getCachedAttachmentBytes(
siteId: string,
attachmentId: string
): AttachmentImageBytes | null {
const image = getCachedImage(siteId, attachmentId)
return image === null ? null : imageBytes(image)
}
export function getCachedAttachmentDataUrl(siteId: string, attachmentId: string): string | null {
const image = getCachedImage(siteId, attachmentId)
return image === null ? null : imageDataUrl(image)
}
export function setCachedAttachmentDataUrl(args: {
siteId: string
attachmentId: string
dataUrl: string
byteSize: number
}): void {
setCachedImage({ ...args, image: args.dataUrl })
}
export async function loadAttachmentDataUrlWithCache(args: {
siteId: string
attachmentId: string
load: () => Promise<{ dataUrl: string; byteSize: number } | null>
}): Promise<string | null> {
const image = await loadImageWithCache({
...args,
load: async () => {
const loaded = await args.load()
return loaded && { image: loaded.dataUrl, byteSize: loaded.byteSize }
}
})
return image === null ? null : imageDataUrl(image)
}
export async function loadAttachmentBytesWithCache(args: {
siteId: string
attachmentId: string
load: () => Promise<AttachmentImageBytes | null>
signal?: AbortSignal
}): Promise<AttachmentImageBytes | null> {
const image = await loadImageWithCache({
...args,
load: async () => {
const loaded = await args.load()
return loaded && { image: loaded, byteSize: loaded.data.byteLength }
}
})
return image === null ? null : imageBytes(image)
}
export function clearAttachmentImagesForSite(siteId?: string): void {
if (siteId == null || siteId === '') {
cache.clear()
+71
View File
@@ -0,0 +1,71 @@
import { randomUUID } from 'node:crypto'
import { JIRA_IMAGE_SCHEME } from '../../shared/jira-attachment-image'
import { attachmentImagesEpoch } from './attachment-image-cache'
import type { JiraClientForSite } from './authenticated-request'
import type { AttachmentMeta } from './attachment-meta'
const GRANT_TTL_MS = 30 * 60_000
const MAX_GRANTS = 2048
type ImageGrant = { siteId: string; meta: AttachmentMeta; epoch: number; expiresAt: number }
const grants = new Map<string, ImageGrant>()
const references = new Map<string, string>()
function revokeExpired(): void {
for (const [reference, grant] of grants) {
if (grant.expiresAt <= Date.now() || grant.epoch !== attachmentImagesEpoch(grant.siteId)) {
grants.delete(reference)
references.delete(`${grant.siteId}::${grant.meta.id}`)
}
}
}
export function registerAttachmentImageGrant(
client: JiraClientForSite,
meta: AttachmentMeta
): string {
revokeExpired()
const identity = `${client.site.id}::${meta.id}`
const existing = references.get(identity)
if (existing) {
const grant = grants.get(existing)
if (grant) {
grant.meta = meta
grant.expiresAt = Date.now() + GRANT_TTL_MS
return `${JIRA_IMAGE_SCHEME}://attachment/${existing}`
}
}
while (grants.size >= MAX_GRANTS) {
const oldest = grants.keys().next().value
if (!oldest) {
break
}
const grant = grants.get(oldest)
grants.delete(oldest)
if (grant) {
references.delete(`${grant.siteId}::${grant.meta.id}`)
}
}
const reference = randomUUID()
grants.set(reference, {
siteId: client.site.id,
meta,
epoch: attachmentImagesEpoch(client.site.id),
expiresAt: Date.now() + GRANT_TTL_MS
})
references.set(identity, reference)
return `${JIRA_IMAGE_SCHEME}://attachment/${reference}`
}
export function attachmentImageGrant(reference: string): ImageGrant | null {
revokeExpired()
return grants.get(reference) ?? null
}
export function isAttachmentImageGrantCurrent(reference: string, grant: ImageGrant): boolean {
return attachmentImageGrant(reference) === grant
}
export function resetAttachmentImageGrantsForTest(): void {
grants.clear()
references.clear()
}
@@ -0,0 +1,38 @@
import { protocol } from 'electron'
import { JIRA_IMAGE_SCHEME, jiraImageReference } from '../../shared/jira-attachment-image'
import { readGrantedAttachmentImage } from './attachment-image-read'
export const JIRA_IMAGE_CUSTOM_SCHEME: Electron.CustomScheme = {
scheme: JIRA_IMAGE_SCHEME,
privileges: { standard: true, secure: true, stream: true }
}
export async function handleJiraImageRequest(request: Request): Promise<Response> {
const reference = jiraImageReference(request.url)
if (!reference) {
return new Response(null, { status: 404 })
}
if (request.method !== 'GET' && request.method !== 'HEAD') {
return new Response(null, { status: 405 })
}
try {
const image = await readGrantedAttachmentImage(reference, request.signal)
if (!image) {
return new Response(null, { status: 404 })
}
return new Response(request.method === 'HEAD' ? null : image.data, {
headers: {
'Content-Type': image.mimeType,
'Content-Length': String(image.data.byteLength),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff'
}
})
} catch {
return new Response(null, { status: 404 })
}
}
export function installJiraImageProtocolHandler(): void {
protocol.handle(JIRA_IMAGE_SCHEME, handleJiraImageRequest)
}
+217
View File
@@ -0,0 +1,217 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraClientForSite } from './authenticated-request'
import { JIRA_IMAGE_MAX_PENDING_READS } from '../../shared/jira-attachment-image'
import { readAttachmentImageBytes } from './attachment-image-bytes'
import {
registerAttachmentImageGrant,
attachmentImageGrant,
resetAttachmentImageGrantsForTest
} from './attachment-image-grants'
import { readGrantedAttachmentImage } from './attachment-image-read'
import { handleJiraImageRequest } from './attachment-image-protocol'
import { clearAttachmentImagesForSite, _resetAttachmentImageCache } from './attachment-image-cache'
const { binary, clients } = vi.hoisted(() => ({ binary: vi.fn(), clients: vi.fn() }))
vi.mock('./authenticated-request', () => ({
apiBasePath: () => '/rest/api/3',
jiraRequestBinary: binary
}))
vi.mock('./client', () => ({ getClients: clients }))
vi.mock('electron', () => ({ protocol: { handle: vi.fn() } }))
const client: JiraClientForSite = {
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
authorization: 'private-token'
}
const meta = { id: '1', filename: 'shot.png', mimeType: 'image/png', size: 3 }
const bytes = Uint8Array.from([1, 2, 3])
function grant(): string {
return registerAttachmentImageGrant(client, meta).split('/').at(-1) ?? ''
}
beforeEach(() => {
_resetAttachmentImageCache()
resetAttachmentImageGrantsForTest()
binary.mockReset().mockResolvedValue({ data: bytes.buffer, contentType: 'image/png' })
clients.mockReset().mockReturnValue([client])
})
afterEach(() => {
_resetAttachmentImageCache()
vi.useRealTimers()
})
describe('authenticated Jira image reads', () => {
it('uses only a live opaque grant, never a caller URL or stored credential', async () => {
const reference = grant()
expect(attachmentImageGrant(reference)).not.toHaveProperty('authorization')
expect(await readGrantedAttachmentImage('unknown')).toBeNull()
expect(binary).not.toHaveBeenCalled()
expect(await readGrantedAttachmentImage(reference)).toEqual({
data: bytes,
mimeType: 'image/png'
})
expect(binary).toHaveBeenCalledWith(
client,
'https://jira.test/rest/api/3/attachment/content/1?redirect=false',
expect.objectContaining({ maxBytes: 8 * 1024 * 1024, signal: expect.any(AbortSignal) })
)
clients.mockReturnValue([])
expect(await readGrantedAttachmentImage(reference)).toBeNull()
})
it('discards a download if the site disconnects before it finishes', async () => {
let finish: ((value: { data: ArrayBuffer; contentType: string }) => void) | undefined
binary.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const reading = readGrantedAttachmentImage(grant())
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1))
clearAttachmentImagesForSite('site')
finish?.({ data: bytes.buffer, contentType: 'image/png' })
expect(await reading).toBeNull()
})
it('shares a download and aborts only after the last reader leaves', async () => {
let downloadSignal: AbortSignal | undefined
binary.mockImplementation((_client, _url, options: { signal: AbortSignal }) => {
downloadSignal = options.signal
return new Promise((_resolve, reject) =>
options.signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true
})
)
})
const first = new AbortController()
const second = new AbortController()
const a = readAttachmentImageBytes(client, meta, first.signal).catch((error) => error)
const b = readAttachmentImageBytes(client, meta, second.signal).catch((error) => error)
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1))
first.abort()
await a
expect(downloadSignal?.aborted).toBe(false)
second.abort()
await b
expect(downloadSignal?.aborted).toBe(true)
binary.mockResolvedValue({ data: bytes.buffer, contentType: 'image/png' })
expect(await readAttachmentImageBytes(client, meta)).toEqual({
data: bytes,
mimeType: 'image/png'
})
})
it('allows three concurrent downloads across different issues, with a bounded queue', async () => {
const controller = new AbortController()
binary.mockImplementation(
(_client, _url, options: { signal: AbortSignal }) =>
new Promise((_resolve, reject) =>
options.signal.addEventListener('abort', () => reject(new Error('cancelled')), {
once: true
})
)
)
const reads = Array.from({ length: JIRA_IMAGE_MAX_PENDING_READS }, (_, id) =>
readAttachmentImageBytes(client, { ...meta, id: String(id) }, controller.signal).catch(
(error) => error
)
)
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(3))
await expect(readAttachmentImageBytes(client, { ...meta, id: 'overflow' })).rejects.toThrow(
'queue is full'
)
controller.abort()
await Promise.all(reads)
expect(binary).toHaveBeenCalledTimes(3)
})
it('loads the combined 48 description and comment references without rejecting a reader', async () => {
const releases: (() => void)[] = []
binary.mockImplementation(
() =>
new Promise((resolve) => {
releases.push(() => resolve({ data: bytes.buffer, contentType: 'image/png' }))
})
)
const reads = Array.from({ length: 48 }, (_, id) =>
readAttachmentImageBytes(client, { ...meta, id: String(id) })
)
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(3))
for (let batch = 0; batch < 16; batch += 1) {
await vi.waitFor(() => expect(releases).toHaveLength(3))
releases.splice(0).forEach((release) => release())
}
expect((await Promise.all(reads)).filter(Boolean)).toHaveLength(48)
expect(binary).toHaveBeenCalledTimes(48)
})
it('uses the REST attachment fallback for scoped site links while preserving Server paths', async () => {
const scoped = {
...client,
site: {
...client.site,
apiBaseUrl: 'https://api.atlassian.com/ex/jira/tenant',
authType: 'cloud' as const
}
}
await readAttachmentImageBytes(scoped, { ...meta, contentUrl: '/secure/attachment/1/shot.png' })
expect(binary).toHaveBeenCalledWith(
scoped,
'https://jira.test/rest/api/3/attachment/content/1?redirect=false',
expect.anything()
)
})
it('expires grants and renews them when the issue is read again', () => {
vi.useFakeTimers()
const reference = grant()
vi.advanceTimersByTime(29 * 60_000)
expect(grant()).toBe(reference)
vi.advanceTimersByTime(29 * 60_000)
expect(attachmentImageGrant(reference)).not.toBeNull()
vi.advanceTimersByTime(2 * 60_000)
expect(attachmentImageGrant(reference)).toBeNull()
})
it('preserves Server content paths and rejects SVG responses', async () => {
const server = { ...client, site: { ...client.site, authType: 'server' as const } }
expect(await readAttachmentImageBytes(server, meta)).toBeNull()
binary.mockResolvedValue({ data: bytes.buffer, contentType: 'image/svg+xml' })
expect(
await readAttachmentImageBytes(server, {
...meta,
contentUrl: '/secure/attachment/1/shot.png'
})
).toBeNull()
expect(binary).toHaveBeenCalledWith(
server,
'https://jira.test/secure/attachment/1/shot.png',
expect.any(Object)
)
})
})
describe('local Jira image protocol', () => {
it('serves bounded image bytes and headers without exposing authorization', async () => {
const url = `orca-jira-image://attachment/${grant()}`
const response = await handleJiraImageRequest(new Request(url))
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('image/png')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('authorization')).toBeNull()
expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes)
const head = await handleJiraImageRequest(new Request(url, { method: 'HEAD' }))
expect(head.headers.get('content-length')).toBe('3')
expect(await head.text()).toBe('')
})
it('refuses unknown, malformed, revoked and non-GET image requests', async () => {
const url = `orca-jira-image://attachment/${grant()}`
expect((await handleJiraImageRequest(new Request(`${url}?url=https://evil.test`))).status).toBe(
404
)
expect((await handleJiraImageRequest(new Request(url, { method: 'POST' }))).status).toBe(405)
clearAttachmentImagesForSite('site')
expect((await handleJiraImageRequest(new Request(url))).status).toBe(404)
expect(binary).not.toHaveBeenCalled()
})
})
+23
View File
@@ -0,0 +1,23 @@
import { getClients } from './client'
import { attachmentImageGrant, isAttachmentImageGrantCurrent } from './attachment-image-grants'
import { readAttachmentImageBytes } from './attachment-image-bytes'
import type { AttachmentImageBytes } from './attachment-image-cache'
export async function readGrantedAttachmentImage(
reference: string,
signal?: AbortSignal
): Promise<AttachmentImageBytes | null> {
const grant = attachmentImageGrant(reference)
if (!grant) {
return null
}
const client = getClients(grant.siteId).find((entry) => entry.site.id === grant.siteId)
if (!client) {
return null
}
const image = await readAttachmentImageBytes(client, grant.meta, signal)
if (!isAttachmentImageGrantCurrent(reference, grant) || signal?.aborted) {
return null
}
return image
}
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { JIRA_IMAGE_MAX_BYTES } from '../../shared/jira-attachment-image'
import { loadIssueImageAttachments, createMediaMarkdownResolver } from './attachment-images'
import { attachmentImageGrant, resetAttachmentImageGrantsForTest } from './attachment-image-grants'
import { clearAttachmentImagesForSite, _resetAttachmentImageCache } from './attachment-image-cache'
import type { JiraClientForSite } from './authenticated-request'
vi.mock('./authenticated-request', () => ({
apiBasePath: () => '/rest/api/3',
jiraRequestBinary: vi.fn(),
JiraApiError: class extends Error {}
}))
const client: JiraClientForSite = {
site: {
id: 'site-1',
siteUrl: 'https://example.atlassian.net',
email: '',
accountId: '',
displayName: ''
},
authorization: 'secret'
}
const metadata = [
{
id: '1',
filename: 'shot.png',
mimeType: 'image/png',
size: 3 * 1024 * 1024,
content: 'https://example.atlassian.net/secure/attachment/1/shot.png'
}
]
describe('lazy Jira image references', () => {
beforeEach(() => {
_resetAttachmentImageCache()
resetAttachmentImageGrantsForTest()
vi.clearAllMocks()
})
it('keeps image bytes out of Markdown without downloading the attachment', async () => {
const { jiraRequestBinary } = await import('./authenticated-request')
const images = await loadIssueImageAttachments(client, metadata, ['1'], 'reference')
const markdown = createMediaMarkdownResolver(images, ['1'])({ id: 'media-id', alt: 'shot.png' })
expect(markdown).toMatch(/^!\[shot.png\]\(orca-jira-image:\/\/attachment\/[0-9a-f-]+\)$/)
expect(markdown).not.toContain('data:')
expect(jiraRequestBinary).not.toHaveBeenCalled()
})
it('keeps grants isolated by site and revokes them on disconnect', async () => {
const images = await loadIssueImageAttachments(client, metadata, ['1'], 'reference')
const reference = images[0]?.dataUrl.split('/').at(-1) ?? ''
expect(attachmentImageGrant(reference)?.siteId).toBe('site-1')
clearAttachmentImagesForSite('other-site')
expect(attachmentImageGrant(reference)).not.toBeNull()
clearAttachmentImagesForSite('site-1')
expect(attachmentImageGrant(reference)).toBeNull()
})
it('retains per-image limits while removing the legacy aggregate-byte cap', async () => {
const entries = [1, 2, 3].map((id) => ({ ...metadata[0], id: String(id) }))
expect(
await loadIssueImageAttachments(client, entries, ['1', '2', '3'], 'reference')
).toHaveLength(3)
const oversized = [{ ...metadata[0], size: JIRA_IMAGE_MAX_BYTES + 1 }]
expect(await loadIssueImageAttachments(client, oversized, ['1'], 'reference')).toEqual([])
})
})
it('discovers larger images by filename when rendered HTML has no attachment IDs', async () => {
const { collectIssueMediaRequest } = await import('./jira-issue-media')
const raw = {
key: 'JIRA-1',
fields: {
attachment: metadata,
description: {
type: 'doc',
content: [
{
type: 'mediaSingle',
content: [{ type: 'media', attrs: { id: 'media-id', alt: 'shot.png' } }]
}
]
}
}
}
expect(collectIssueMediaRequest(raw)?.preferredIds).toEqual([])
expect(collectIssueMediaRequest(raw, 'reference')?.preferredIds).toEqual(['1'])
})
it('keeps the old per-image cap when an old client reuses a newer byte-cache entry', async () => {
const { loadAttachmentBytesWithCache } = await import('./attachment-image-cache')
_resetAttachmentImageCache()
await loadAttachmentBytesWithCache({
siteId: client.site.id,
attachmentId: '1',
load: async () => ({ data: new Uint8Array(3 * 1024 * 1024), mimeType: 'image/png' })
})
expect(await loadIssueImageAttachments(client, [{ ...metadata[0], size: 0 }], ['1'])).toEqual([])
})
+32 -15
View File
@@ -1,5 +1,6 @@
import type { JiraClientForSite } from './authenticated-request'
import { JiraApiError, apiBasePath, jiraRequestBinary } from './authenticated-request'
import { attachmentContentUrl } from './attachment-content-url'
import { JiraApiError, jiraRequestBinary } from './authenticated-request'
import type { JiraAdfMediaAttrs, JiraAdfMediaResolver } from './adf-markdown'
import { escapeMarkdownAlt, unresolvedMediaPlaceholder } from './adf-markdown'
import { escapeMarkdownLinkDestination } from './adf-media-destination'
@@ -13,6 +14,12 @@ import {
type AttachmentMeta
} from './attachment-meta'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
import {
JIRA_IMAGE_MAX_BYTES,
JIRA_IMAGE_MAX_REFERENCES,
type JiraMediaFormat
} from '../../shared/jira-attachment-image'
import { registerAttachmentImageGrant } from './attachment-image-grants'
const DOWNLOAD_CONCURRENCY = 3
@@ -38,16 +45,9 @@ async function downloadImageAttachment(
attachmentId: meta.id,
load: async () => {
try {
const contentUrl = meta.contentUrl
? new URL(meta.contentUrl, `${client.site.siteUrl}/`)
: // Why: Cloud fallback uses apiBasePath so Server sites that lack contentUrl
// still hit /rest/api/2 if ever called; Server still requires content metadata.
new URL(
`${apiBasePath(client.site)}/attachment/content/${encodeURIComponent(meta.id)}`,
client.site.siteUrl
)
if (/\/rest\/api\/(?:2|3)\/attachment\/content\/[^/]+$/i.test(contentUrl.pathname)) {
contentUrl.searchParams.set('redirect', 'false')
const contentUrl = attachmentContentUrl(client.site, meta)
if (!contentUrl) {
return null
}
const binary = await jiraRequestBinary(client, contentUrl.toString())
if (binary.data.byteLength === 0 || binary.data.byteLength > MAX_IMAGE_BYTES) {
@@ -83,6 +83,9 @@ async function downloadImageAttachment(
// Approximate byte size from base64 payload when served from cache.
const base64Part = dataUrl.includes(',') ? dataUrl.slice(dataUrl.indexOf(',') + 1) : ''
const byteSize = Math.floor((base64Part.length * 3) / 4)
if (byteSize === 0 || byteSize > MAX_IMAGE_BYTES) {
return null
}
return {
id: meta.id,
@@ -96,9 +99,13 @@ async function downloadImageAttachment(
export async function loadIssueImageAttachments(
client: JiraClientForSite,
attachmentField: unknown,
preferredIds: string[] = []
preferredIds: string[] = [],
mediaFormat?: JiraMediaFormat
): Promise<JiraImageAttachment[]> {
const metas = parseImageAttachmentMetas(attachmentField)
const metas = parseImageAttachmentMetas(
attachmentField,
mediaFormat ? JIRA_IMAGE_MAX_BYTES : MAX_IMAGE_BYTES
)
if (metas.length === 0 || preferredIds.length === 0) {
return []
}
@@ -115,6 +122,16 @@ export async function loadIssueImageAttachments(
}
}
if (mediaFormat === 'reference') {
return ordered.slice(0, JIRA_IMAGE_MAX_REFERENCES).map((meta) => ({
id: meta.id,
filename: meta.filename,
mimeType: meta.mimeType,
byteSize: meta.size,
dataUrl: registerAttachmentImageGrant(client, meta)
}))
}
// Why: pre-select by declared size so concurrent downloads do not fetch bodies
// that will be dropped by the total budget after completion.
const toDownload: AttachmentMeta[] = []
@@ -149,7 +166,7 @@ export async function loadIssueImageAttachments(
}
export type MediaResolutionStats = {
/** Attachment-needing media nodes that successfully resolved to a data: image. */
/** Attachment-needing media nodes that resolved to an image destination. */
attachmentResolvedCount: number
}
@@ -202,7 +219,7 @@ export function createMediaMarkdownResolver(
if (attrs.id) {
const cached = resolvedByMediaId.get(attrs.id)
if (cached) {
if (stats && !cached.startsWith('*[') && cached.includes('data:')) {
if (stats && cached.startsWith('![')) {
stats.attachmentResolvedCount += 1
}
return cached
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { readAttachmentImageBytes } from './attachment-image-bytes'
import { loadIssueImageAttachments, type JiraImageAttachment } from './attachment-images'
import * as cache from './attachment-image-cache'
import type { JiraClientForSite } from './authenticated-request'
const { binary } = vi.hoisted(() => ({ binary: vi.fn() }))
vi.mock('./authenticated-request', () => ({
apiBasePath: () => '/rest/api/3',
jiraRequestBinary: binary,
JiraApiError: class extends Error {}
}))
const client: JiraClientForSite = {
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
authorization: 'fixture-token'
}
const meta = { id: '1', filename: 'shot.png', mimeType: 'image/png', size: 3 }
const bytes = Uint8Array.from([1, 2, 3])
beforeEach(() => {
binary.mockReset()
})
afterEach(() => {
cache._resetAttachmentImageCache()
vi.restoreAllMocks()
})
it.each(['cancellable-first', 'legacy-first'] as const)(
'retains a legacy inline image after new reader cancellation (%s)',
async (order) => {
const finish: (() => void)[] = []
binary.mockImplementation(
(_client, _url, options?: { signal?: AbortSignal }) =>
new Promise((resolve, reject) => {
finish.push(() => resolve({ data: bytes.buffer, contentType: 'image/png' }))
options?.signal?.addEventListener(
'abort',
() => reject(new Error('new reader cancelled')),
{
once: true
}
)
})
)
const legacyLoading = vi.spyOn(cache, 'loadAttachmentDataUrlWithCache')
const controller = new AbortController()
const readLegacy = (): Promise<{ images: JiraImageAttachment[] } | { error: unknown }> =>
loadIssueImageAttachments(client, [meta], ['1']).then(
(images) => ({ images }),
(error) => ({ error })
)
const readNew = () =>
readAttachmentImageBytes(client, meta, controller.signal).catch(() => null)
const first = order === 'cancellable-first' ? readNew() : readLegacy()
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(1))
const second = order === 'cancellable-first' ? readLegacy() : readNew()
const newReading = order === 'cancellable-first' ? first : second
const legacyReading = order === 'cancellable-first' ? second : first
await vi.waitFor(() => expect(legacyLoading).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(binary).toHaveBeenCalledTimes(2))
controller.abort()
await newReading
finish.forEach((resolve) => resolve())
expect(await legacyReading).toMatchObject({
images: [{ id: '1', dataUrl: 'data:image/png;base64,AQID' }]
})
expect(await readAttachmentImageBytes(client, meta)).toEqual({
data: bytes,
mimeType: 'image/png'
})
expect(binary).toHaveBeenCalledTimes(2)
}
)
+5 -2
View File
@@ -26,7 +26,10 @@ export function isImageMimeType(mimeType: string): boolean {
)
}
export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentMeta[] {
export function parseImageAttachmentMetas(
attachmentField: unknown,
maxBytes = MAX_IMAGE_BYTES
): AttachmentMeta[] {
if (!Array.isArray(attachmentField)) {
return []
}
@@ -40,7 +43,7 @@ export function parseImageAttachmentMetas(attachmentField: unknown): AttachmentM
if (!id || !isImageMimeType(mimeType)) {
continue
}
if (size > MAX_IMAGE_BYTES) {
if (size > maxBytes) {
continue
}
const contentUrl = asString(record.content)
@@ -0,0 +1,64 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { jiraRequestBinary, type JiraClientForSite } from './authenticated-request'
const { fetchImage } = vi.hoisted(() => ({ fetchImage: vi.fn() }))
vi.mock('../network/http-client', () => ({
getMainHttpClient: () => ({ fetch: fetchImage, proxySession: () => null })
}))
vi.mock('../network/proxy-settings', () => ({
ensureElectronProxyFromEnvironment: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('../observability/tracer', () => ({
withSpan: (
_name: string,
run: (span: { setAttribute: () => void; addEvent: () => void }) => Promise<unknown>
) => run({ setAttribute: () => {}, addEvent: () => {} })
}))
const client: JiraClientForSite = {
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
authorization: 'secret'
}
beforeEach(() => fetchImage.mockReset())
describe('bounded Jira attachment downloads', () => {
it('rejects oversized declared bodies before consuming any bytes', async () => {
const cancel = vi.fn()
const response = new Response(new ReadableStream({ cancel }), {
headers: { 'content-length': '9' }
})
const arrayBuffer = vi.spyOn(response, 'arrayBuffer')
fetchImage.mockResolvedValue(response)
await expect(jiraRequestBinary(client, '/image', { maxBytes: 8 })).rejects.toThrow('byte limit')
expect(cancel).toHaveBeenCalledOnce()
expect(arrayBuffer).not.toHaveBeenCalled()
})
it('caps chunked bodies even when Content-Length is missing', async () => {
let part = 0
const cancel = vi.fn()
const response = new Response(
new ReadableStream({
pull(controller) {
controller.enqueue(Uint8Array.from([++part, 0, 0]))
},
cancel
})
)
fetchImage.mockResolvedValue(response)
await expect(jiraRequestBinary(client, '/image', { maxBytes: 8 })).rejects.toThrow('byte limit')
expect(cancel).toHaveBeenCalledOnce()
expect(part).toBeLessThanOrEqual(4)
})
it('returns bounded bytes and forwards caller cancellation to the HTTP client', async () => {
const controller = new AbortController()
fetchImage.mockResolvedValue(
new Response(Uint8Array.from([1, 2, 3]), { headers: { 'content-type': 'image/png' } })
)
expect(
await jiraRequestBinary(client, '/image', { maxBytes: 8, signal: controller.signal })
).toEqual({ data: Uint8Array.from([1, 2, 3]).buffer, contentType: 'image/png' })
expect(fetchImage).toHaveBeenCalledWith(
'https://jira.test/image',
expect.objectContaining({ signal: controller.signal })
)
})
})
+9 -2
View File
@@ -6,6 +6,7 @@ import { isJiraGatewayBaseUrl } from './gateway-base-url'
import { JiraApiError, toJiraApiError } from './jira-api-error'
export { JiraApiError } from './jira-api-error'
import { readFetchResponseBytesWithinLimit } from '../../shared/fetch-response-body'
// Why: Atlassian's XSRF filter rejects POST/PUT REST calls that carry a browser
// User-Agent, failing them with "XSRF check failed" even under API-token auth.
@@ -177,7 +178,8 @@ function resolveBinaryRequestUrl(site: JiraSite, pathOrUrl: string): URL {
export async function jiraRequestBinary(
client: JiraClientForSite,
pathOrUrl: string
pathOrUrl: string,
options: { maxBytes?: number; signal?: AbortSignal } = {}
): Promise<{ data: ArrayBuffer; contentType: string }> {
const requestUrl = resolveBinaryRequestUrl(client.site, pathOrUrl)
const headers = new Headers()
@@ -188,14 +190,19 @@ export async function jiraRequestBinary(
headers.set('Authorization', client.authorization)
const response = await jiraFetch(requestUrl.toString(), {
headers,
signal: options.signal,
...(isJiraGatewayBaseUrl(apiBaseUrl(client.site)) ? { redirect: 'error' as const } : {})
})
if (!response.ok) {
throw await toJiraApiError(response, requestUrl.toString())
}
const contentType = response.headers.get('content-type') || 'application/octet-stream'
const bytes = await readFetchResponseBytesWithinLimit(
response,
options.maxBytes ?? 2 * 1024 * 1024
)
return {
data: await response.arrayBuffer(),
data: Uint8Array.from(bytes).buffer,
contentType
}
}
@@ -98,16 +98,19 @@ describe('scoped Jira Cloud tokens over HTTP', () => {
dataUrl: 'data:image/png;base64,AQ==',
byteSize: 1
})
const epoch = cache.attachmentImagesEpoch(client.site.id)
authorized = false
await expect(
jira.connect({ ...CREDENTIAL, apiToken: 'rejected-token' })
).resolves.toMatchObject({ ok: false })
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).not.toBeNull()
expect(cache.attachmentImagesEpoch(client.site.id)).toBe(epoch)
authorized = true
await expect(
jira.connect({ ...CREDENTIAL, apiToken: 'replacement-token' })
).resolves.toMatchObject({ ok: true })
expect(cache.getCachedAttachmentDataUrl(client.site.id, 'image')).toBeNull()
expect(cache.attachmentImagesEpoch(client.site.id)).toBeGreaterThan(epoch)
expect(jira.getClients()[0]?.authorization).toBe(
`Basic ${Buffer.from('ada@example.com:replacement-token').toString('base64')}`
)
+15 -5
View File
@@ -1,5 +1,10 @@
import { requireStrictJiraSite } from './jira-read-failure'
import type { JiraComment } from '../../shared/jira-types'
import {
JIRA_IMAGE_MAX_BYTES,
JIRA_IMAGE_MAX_REFERENCES,
type JiraMediaFormat
} from '../../shared/jira-attachment-image'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
import { clearToken, getClients, isAuthError } from './client'
@@ -39,7 +44,8 @@ function mapComment(raw: JiraRecord, adfOptions?: AdfToMarkdownOptions): JiraCom
async function collectCommentMediaRequest(
client: JiraClientForSite,
key: string,
comments: JiraRecord[]
comments: JiraRecord[],
mediaFormat?: JiraMediaFormat
): Promise<MediaRequest | undefined> {
const htmlIds: string[] = []
const seen = new Set<string>()
@@ -80,7 +86,8 @@ async function collectCommentMediaRequest(
const selection = selectPreferredAttachmentIds({
renderedHtmlIds: htmlIds,
attachmentField,
mediaAttrs
mediaAttrs,
...(mediaFormat ? { maxBytes: JIRA_IMAGE_MAX_BYTES, maxImages: JIRA_IMAGE_MAX_REFERENCES } : {})
})
if (selection.needCount === 0 && selection.preferredIds.length === 0) {
return undefined
@@ -97,7 +104,8 @@ async function collectCommentMediaRequest(
export async function getIssueComments(
key: string,
siteId?: string | null,
strict = false
strict = false,
mediaFormat?: JiraMediaFormat
): Promise<JiraComment[]> {
const entries = getClients(siteId)
requireStrictJiraSite(entries, siteId, strict)
@@ -121,7 +129,7 @@ export async function getIssueComments(
})
return `${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}/comment?${params.toString()}`
})
mediaRequest = await collectCommentMediaRequest(entry, key, comments)
mediaRequest = await collectCommentMediaRequest(entry, key, comments, mediaFormat)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
@@ -140,7 +148,9 @@ export async function getIssueComments(
}
try {
const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined
const prepared = mediaRequest
? await prepareMediaResolver(entry, mediaRequest, mediaFormat)
: undefined
const mapped = comments.map((comment) => mapComment(comment, prepared?.options))
if (prepared) {
flushMediaResolutionWarn(entry, prepared)
+15 -4
View File
@@ -11,6 +11,11 @@ import {
} from './attachment-images'
import type { JiraClientForSite } from './authenticated-request'
import { asRecord, asString, type JiraRecord } from './jira-record-pages'
import {
JIRA_IMAGE_MAX_BYTES,
JIRA_IMAGE_MAX_REFERENCES,
type JiraMediaFormat
} from '../../shared/jira-attachment-image'
export type MediaRequest = {
attachmentField: unknown
@@ -21,7 +26,10 @@ export type MediaRequest = {
}
/** Pooled: HTML/ADF selection only — no binary downloads. */
export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefined {
export function collectIssueMediaRequest(
raw: JiraRecord,
mediaFormat?: JiraMediaFormat
): MediaRequest | undefined {
const fields = asRecord(raw.fields)
const renderedFields = asRecord(raw.renderedFields)
const htmlIds = extractAttachmentContentIdsFromHtml(
@@ -31,7 +39,8 @@ export function collectIssueMediaRequest(raw: JiraRecord): MediaRequest | undefi
const selection = selectPreferredAttachmentIds({
renderedHtmlIds: htmlIds,
attachmentField: fields.attachment,
mediaAttrs
mediaAttrs,
...(mediaFormat ? { maxBytes: JIRA_IMAGE_MAX_BYTES, maxImages: JIRA_IMAGE_MAX_REFERENCES } : {})
})
if (selection.needCount === 0 && selection.preferredIds.length === 0) {
return undefined
@@ -54,7 +63,8 @@ export type PreparedMedia = {
/** Unpooled: binary downloads + resolver (outside the Jira API semaphore). */
export async function prepareMediaResolver(
client: JiraClientForSite,
request: MediaRequest
request: MediaRequest,
mediaFormat?: JiraMediaFormat
): Promise<PreparedMedia | undefined> {
if (request.preferredIds.length === 0) {
warnIfMediaResolutionIncomplete({
@@ -70,7 +80,8 @@ export async function prepareMediaResolver(
const images = await loadIssueImageAttachments(
client,
request.attachmentField,
request.preferredIds
request.preferredIds,
mediaFormat
)
if (images.length === 0) {
warnIfMediaResolutionIncomplete({
+7 -3
View File
@@ -1,4 +1,5 @@
import type { JiraIssue, JiraSiteSelection } from '../../shared/jira-types'
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
import { JiraSummaryLookupError } from '../../shared/jira-summary-lookup'
import { acquire, release } from './request-queue'
import { apiBasePath, jiraRequest, type JiraClientForSite } from './authenticated-request'
@@ -30,7 +31,8 @@ const ISSUE_SUMMARY_TIMEOUT_MS = 30_000
export async function getIssue(
key: string,
siteId?: JiraSiteSelection | null,
strict = false
strict = false,
mediaFormat?: JiraMediaFormat
): Promise<JiraIssue | null> {
const entries = getClients(siteId)
requireStrictJiraSite(entries, siteId, strict)
@@ -52,7 +54,7 @@ export async function getIssue(
`${apiBasePath(entry.site)}/issue/${encodeURIComponent(key)}?${params.toString()}`
)
// Why: keep only JSON under the pool; binary downloads fan out after release.
mediaRequest = collectIssueMediaRequest(issue)
mediaRequest = collectIssueMediaRequest(issue, mediaFormat)
} catch (error) {
if (isAuthError(error)) {
clearToken(entry.site.id)
@@ -77,7 +79,9 @@ export async function getIssue(
if (!issue) {
continue
}
const prepared = mediaRequest ? await prepareMediaResolver(entry, mediaRequest) : undefined
const prepared = mediaRequest
? await prepareMediaResolver(entry, mediaRequest, mediaFormat)
: undefined
const mapped = mapJiraIssue(entry.site, issue, prepared?.options, agileFields)
if (prepared) {
flushMediaResolutionWarn(entry, prepared)
@@ -0,0 +1,100 @@
import '../unused-default-rpc-methods.test-fixture'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image'
import {
registerAttachmentImageGrant,
resetAttachmentImageGrantsForTest
} from '../../../jira/attachment-image-grants'
import {
clearAttachmentImagesForSite,
_resetAttachmentImageCache
} from '../../../jira/attachment-image-cache'
import { decodeJiraImageFrame } from '../../../../shared/jira-image-stream-frame'
const { read } = vi.hoisted(() => ({ read: vi.fn() }))
vi.mock('../../../jira/attachment-image-read', () => ({ readGrantedAttachmentImage: read }))
const transferId = '12345678-1234-4234-8234-123456789012'
const runtime = { getRuntimeId: () => 'runtime' }
const dispatcher = new RpcDispatcher({
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The image handler never calls runtime; this fixture implements the dispatcher's metadata read.
runtime: runtime as unknown as OrcaRuntimeService,
methods: JIRA_ATTACHMENT_IMAGE_METHODS
})
function request() {
const url = registerAttachmentImageGrant(
{
site: { id: 'site', siteUrl: 'https://jira.test', email: '', accountId: '', displayName: '' },
authorization: 'secret'
},
{ id: '1', filename: 'image.png', mimeType: 'image/png', size: 1 }
)
return {
id: 'image',
authToken: 'token',
method: 'jira.attachmentImage',
params: { reference: url.split('/').at(-1), transferId }
}
}
beforeEach(() => {
_resetAttachmentImageCache()
resetAttachmentImageGrantsForTest()
read.mockReset().mockResolvedValue({ data: new Uint8Array(130_000), mimeType: 'image/png' })
})
describe('Jira image RPC carrier', () => {
it('sends metadata, bounded addressed binary chunks and an end marker', async () => {
const replies: string[] = []
const frames: Uint8Array<ArrayBufferLike>[] = []
await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply), {
sendBinary: (bytes) => {
frames.push(bytes)
}
})
expect(replies[0]).toContain('"byteLength":130000')
expect(replies.at(-1)).toContain('"type":"end"')
expect(frames).toHaveLength(2)
expect(decodeJiraImageFrame(frames[0] ?? new Uint8Array(), transferId)?.bytes.byteLength).toBe(
64 * 1024
)
expect(decodeJiraImageFrame(frames[1] ?? new Uint8Array(), transferId)?.sequence).toBe(1)
expect(replies.join('')).not.toContain('secret')
})
it.each(['cancel', 'revoke', 'refuse'] as const)(
'stops sending chunks after %s',
async (action) => {
const replies: string[] = []
const controller = new AbortController()
const binary = vi.fn(() => {
if (action === 'cancel') {
controller.abort()
}
if (action === 'revoke') {
clearAttachmentImagesForSite('site')
}
return action !== 'refuse'
})
await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply), {
sendBinary: binary,
signal: controller.signal
})
expect(binary).toHaveBeenCalledOnce()
expect(replies.join('')).not.toContain('"type":"end"')
expect(replies.at(-1)).toContain('"ok":false')
}
)
it('requires a binary transport and rejects unknown grant references', async () => {
const replies: string[] = []
await dispatcher.dispatchStreaming(request(), (reply) => replies.push(reply))
expect(replies.join('')).toContain('binary subscription transport')
expect(read).not.toHaveBeenCalled()
const unknown = request()
unknown.params.reference = '00000000-0000-0000-0000-000000000000'
await dispatcher.dispatchStreaming(unknown, (reply) => replies.push(reply), {
sendBinary: vi.fn()
})
expect(replies.join('')).toContain('unavailable')
expect(read).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,52 @@
import { encodeJiraImageFrame } from '../../../../shared/jira-image-stream-frame'
import { JIRA_IMAGE_CHUNK_BYTES } from '../../../../shared/jira-attachment-image'
import { yieldToEventLoop } from '../../../../shared/event-loop-yield'
import { throwIfSignalAborted } from '../../../../shared/abort-signal-reason'
import {
attachmentImageGrant,
isAttachmentImageGrantCurrent
} from '../../../jira/attachment-image-grants'
import { readGrantedAttachmentImage } from '../../../jira/attachment-image-read'
import { AttachmentImage } from '../../../../shared/rpc-contract/jira-params'
import { defineStreamingMethod } from '../core'
export const JIRA_ATTACHMENT_IMAGE_METHODS = [
defineStreamingMethod({
name: 'jira.attachmentImage',
permission: 'workspace',
params: AttachmentImage,
handler: async ({ reference, transferId }, { sendBinary, signal }, emit) => {
if (!sendBinary) {
throw new Error('Jira images require a binary subscription transport.')
}
const grant = attachmentImageGrant(reference)
const image = grant && (await readGrantedAttachmentImage(reference, signal))
if (!image || !grant) {
throw new Error('Jira image is unavailable. Reload the issue to try again.')
}
emit({ type: 'image', mimeType: image.mimeType, byteLength: image.data.byteLength })
for (let offset = 0; offset < image.data.byteLength; offset += JIRA_IMAGE_CHUNK_BYTES) {
throwIfSignalAborted(signal)
if (!isAttachmentImageGrantCurrent(reference, grant)) {
throw new Error('Jira image access was revoked.')
}
const accepted = sendBinary(
encodeJiraImageFrame(
transferId,
offset / JIRA_IMAGE_CHUNK_BYTES,
image.data.subarray(offset, offset + JIRA_IMAGE_CHUNK_BYTES)
)
)
if (accepted === false || accepted === 'backlogged') {
throw new Error('Jira image transport closed.')
}
await yieldToEventLoop()
}
throwIfSignalAborted(signal)
if (!isAttachmentImageGrantCurrent(reference, grant)) {
throw new Error('Jira image access was revoked.')
}
emit({ type: 'end' })
}
})
]
+16 -6
View File
@@ -116,7 +116,7 @@ describe('jira RPC methods', () => {
undefined,
undefined
)
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-3', 'site-1', undefined, undefined)
expect(runtime.jiraLookupIssueSummary).toHaveBeenCalledWith(
'ABC-3',
'site-1',
@@ -142,7 +142,7 @@ describe('jira RPC methods', () => {
'site-1'
)
expect(runtime.jiraAddIssueComment).toHaveBeenCalledWith('ABC-3', 'Looks good', 'site-1')
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1', undefined)
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-3', 'site-1', undefined, undefined)
})
it('passes verified read requests and atomic label operations through RPC', async () => {
@@ -158,10 +158,20 @@ describe('jira RPC methods', () => {
methods: JIRA_METHODS
})
await dispatcher.dispatch(
makeRequest('jira.getIssue', { key: 'ABC-1', siteId: 'site-1', strict: true })
makeRequest('jira.getIssue', {
key: 'ABC-1',
siteId: 'site-1',
strict: true,
mediaFormat: 'reference'
})
)
await dispatcher.dispatch(
makeRequest('jira.issueComments', { key: 'ABC-1', siteId: 'site-1', strict: true })
makeRequest('jira.issueComments', {
key: 'ABC-1',
siteId: 'site-1',
strict: true,
mediaFormat: 'reference'
})
)
await dispatcher.dispatch(
makeRequest('jira.updateIssue', {
@@ -170,8 +180,8 @@ describe('jira RPC methods', () => {
updates: { labelOperations: { add: ['bug'] } }
})
)
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-1', 'site-1', true)
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-1', 'site-1', true)
expect(runtime.jiraGetIssue).toHaveBeenCalledWith('ABC-1', 'site-1', true, 'reference')
expect(runtime.jiraIssueComments).toHaveBeenCalledWith('ABC-1', 'site-1', true, 'reference')
expect(runtime.jiraUpdateIssue).toHaveBeenCalledWith(
'ABC-1',
{ labelOperations: { add: ['bug'] } },
+16 -4
View File
@@ -3,6 +3,7 @@ import {
JIRA_PAYLOAD_MAX_CHARS
} from '../../../../shared/jira-payload-stream'
import { defineMethod, defineStreamingMethod, type RpcContext } from '../core'
import { JIRA_ATTACHMENT_IMAGE_METHODS } from './jira-attachment-image'
import {
AssignableUsers,
GenerateIssueSummary,
@@ -51,6 +52,7 @@ function generationOwner(context: Pick<RpcContext, 'pairedDeviceId' | 'caller'>)
}
export const JIRA_METHODS = [
...JIRA_ATTACHMENT_IMAGE_METHODS,
defineMethod({
name: 'jira.generateIssueSummary',
permission: 'workspace',
@@ -132,7 +134,7 @@ export const JIRA_METHODS = [
permission: 'workspace',
params: IssueKey,
handler: async (params, { runtime }) =>
runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict)
runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict, params.mediaFormat)
}),
defineMethod({
name: 'jira.lookupIssueSummary',
@@ -151,7 +153,12 @@ export const JIRA_METHODS = [
params: IssueKey,
handler: async (params, { runtime }, emit) => {
emitJiraPayload(
await runtime.jiraGetIssue(params.key.trim(), params.siteId, params.strict),
await runtime.jiraGetIssue(
params.key.trim(),
params.siteId,
params.strict,
params.mediaFormat
),
emit
)
}
@@ -190,7 +197,7 @@ export const JIRA_METHODS = [
permission: 'workspace',
params: IssueKey,
handler: async (params, { runtime }) =>
runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict)
runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict, params.mediaFormat)
}),
defineStreamingMethod({
name: 'jira.issueCommentsStream',
@@ -198,7 +205,12 @@ export const JIRA_METHODS = [
params: IssueKey,
handler: async (params, { runtime }, emit) => {
emitJiraPayload(
await runtime.jiraIssueComments(params.key.trim(), params.siteId, params.strict),
await runtime.jiraIssueComments(
params.key.trim(),
params.siteId,
params.strict,
params.mediaFormat
),
emit
)
}
+11 -4
View File
@@ -15,6 +15,7 @@ import type {
JiraIssueUpdate,
JiraSiteSelection
} from '../../shared/jira-types'
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
import { connect, disconnect, getStatus, selectSite, testConnection } from '../jira/client'
import {
addIssueComment,
@@ -103,8 +104,13 @@ export class RuntimeJiraCommands {
return createIssue(args)
}
jiraGetIssue(key: string, siteId?: string, strict = false): ReturnType<typeof getIssue> {
return getIssue(key, siteId, strict)
jiraGetIssue(
key: string,
siteId?: string,
strict = false,
mediaFormat?: JiraMediaFormat
): ReturnType<typeof getIssue> {
return getIssue(key, siteId, strict, mediaFormat)
}
jiraLookupIssueSummary(
@@ -134,9 +140,10 @@ export class RuntimeJiraCommands {
jiraIssueComments(
key: string,
siteId?: string,
strict = false
strict = false,
mediaFormat?: JiraMediaFormat
): ReturnType<typeof getIssueComments> {
return getIssueComments(key, siteId, strict)
return getIssueComments(key, siteId, strict, mediaFormat)
}
jiraListProjects(siteId?: JiraSiteSelection, strict = false): ReturnType<typeof listProjects> {
@@ -43,6 +43,7 @@ export const PAIRED_DESKTOP_METHODS = [
'accounts.subscribe',
'accounts.unsubscribe',
// Jira credentials and issue mutations remain desktop-only.
'jira.attachmentImage',
'jira.getIssue',
'jira.issueComments',
'jira.listIssues',
+7 -7
View File
@@ -86,6 +86,7 @@ import { initOpenCodeUsagePath } from '../opencode-usage/store'
import { initMuseUsagePath } from '../muse-usage/store'
import { registerDocPreviewSchemePrivileges } from '../browser/doc-preview-protocol'
import { MEDIA_PREVIEW_CUSTOM_SCHEME } from '../media/media-preview-protocol'
import { JIRA_IMAGE_CUSTOM_SCHEME } from '../jira/attachment-image-protocol'
import { startCrashpadCapture } from '../crash-reporting/crashpad-capture'
import { CrashReportStore } from '../crash-reporting/crash-report-store'
import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store'
@@ -207,8 +208,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
// Why before any spawn: `signalProcessTree` is shared with the CLI and relay, so
// it can only reach the main-process guard and breadcrumb store once this is registered.
installMainProcessTreeKillGate()
const isDev = is.dev
configureDevUserDataPath(isDev)
configureDevUserDataPath(is.dev)
configureOrcaUserDataPathEnv()
// Why these four lines are one step (#16761): the two above decide where userData lives, and
// everything below may resolve a path. Installing the accessor any later leaves a window where an
@@ -253,8 +253,8 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) })
// Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces.
// Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy.
const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode })
const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode })
const bypass = shouldBypassSingleInstanceLock({ isDev: is.dev, isServeMode: state.isServeMode })
const skip = shouldSkipSingleInstanceLock({ isDev: is.dev, isServeMode: state.isServeMode })
if (bypass) {
// Why: diagnostic escape hatch for macOS builds where Electron reports a false lock loss before any app logs exist.
logSingleInstanceLockBypass()
@@ -271,7 +271,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
// Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line.
// In dev it is the line `pnpm dev` prints before exiting.
logSingleInstanceLockFailure({
isDevDesktop: isDev && !state.isServeMode,
isDevDesktop: is.dev && !state.isServeMode,
userDataPath: app.getPath('userData')
})
// Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935).
@@ -325,7 +325,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
setSpeechServiceFactories(electronSpeechServiceFactories)
setWorktreeWatcherRemoval(desktopWorktreeWatcherRemoval)
// Why: couple to dev-parent only for electron-vite desktop runs; `orca serve`'s parent (CLI shim/background shell) isn't the intended server lifetime.
const shouldCoupleToDevParent = isDev && !state.isServeMode
const shouldCoupleToDevParent = is.dev && !state.isServeMode
installDevParentDisconnectQuit(shouldCoupleToDevParent)
installDevParentWatchdog(shouldCoupleToDevParent)
installDevParentSignalQuit(shouldCoupleToDevParent)
@@ -360,7 +360,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b
initMuseUsagePath()
// Why: Electron freezes the privileged scheme table at ready, so the doc-preview
// scheme must be declared here or its webview loses fetch/secure-origin privileges.
registerDocPreviewSchemePrivileges([MEDIA_PREVIEW_CUSTOM_SCHEME])
registerDocPreviewSchemePrivileges([MEDIA_PREVIEW_CUSTOM_SCHEME, JIRA_IMAGE_CUSTOM_SCHEME])
// Why: must precede app.whenReady() so Crashpad is installed before the
// first renderer spawns; a CHECK before this point is still exit-code-only.
startCrashpadCapture()
@@ -33,6 +33,7 @@ import {
} from '../browser/browser-session-proxy'
import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol'
import { installMediaPreviewProtocolHandler } from '../media/media-preview-protocol'
import { installJiraImageProtocolHandler } from '../jira/attachment-image-protocol'
import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc'
import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup'
import { browserSessionRegistry } from '../browser/browser-session-registry'
@@ -259,6 +260,7 @@ export async function initializeReadyFoundation(): Promise<void> {
setBrowserNetworkProxySettingsResolver(() => state.store!.getSettings())
// Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches.
installMediaPreviewProtocolHandler()
installJiraImageProtocolHandler()
installDocPreviewProtocolHandler()
registerDocPreviewGrantHandlers()
// Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path.
+11 -2
View File
@@ -1,3 +1,4 @@
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
import type {
JiraIssueSummaryGenerationRequest,
JiraIssueSummaryGenerationCancellation,
@@ -49,7 +50,11 @@ export type JiraApi = {
siteId?: JiraSiteSelection
filters?: JiraIssueListFilters
}) => Promise<JiraIssue[]>
getIssue: (args: { key: string; siteId?: string }) => Promise<JiraIssue | null>
getIssue: (args: {
key: string
siteId?: string
mediaFormat?: JiraMediaFormat
}) => Promise<JiraIssue | null>
lookupIssueSummary: (args: {
key: string
siteId: string
@@ -73,7 +78,11 @@ export type JiraApi = {
body: string
siteId?: string
}) => Promise<{ ok: true; id: string } | { ok: false; error: string }>
issueComments: (args: { key: string; siteId?: string }) => Promise<JiraComment[]>
issueComments: (args: {
key: string
siteId?: string
mediaFormat?: JiraMediaFormat
}) => Promise<JiraComment[]>
listProjects: (args?: { siteId?: JiraSiteSelection }) => Promise<JiraProject[]>
listIssueTypes: (args: { projectIdOrKey: string; siteId?: string }) => Promise<JiraIssueType[]>
listCreateFields: (args: {
+4 -2
View File
@@ -1,3 +1,4 @@
import type { JiraMediaFormat } from '../../shared/jira-attachment-image'
import type {
JiraIssueSummaryGenerationRequest,
JiraIssueSummaryGenerationCancellation
@@ -37,7 +38,8 @@ export const jiraApi = {
siteId?: string
}) => ipcRenderer.invoke('jira:listIssues', args),
getIssue: (args: { key: string; siteId?: string }) => ipcRenderer.invoke('jira:getIssue', args),
getIssue: (args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) =>
ipcRenderer.invoke('jira:getIssue', args),
lookupIssueSummary: (args: { key: string; siteId: string; requestId?: string }) =>
ipcRenderer.invoke('jira:lookupIssueSummary', args),
@@ -74,7 +76,7 @@ export const jiraApi = {
}): Promise<{ ok: true; id: string } | { ok: false; error: string }> =>
ipcRenderer.invoke('jira:addIssueComment', args),
issueComments: (args: { key: string; siteId?: string }) =>
issueComments: (args: { key: string; siteId?: string; mediaFormat?: JiraMediaFormat }) =>
ipcRenderer.invoke('jira:issueComments', args),
listProjects: (args?: { siteId?: string }) => ipcRenderer.invoke('jira:listProjects', args),
+4
View File
@@ -2,6 +2,10 @@
<html class="native-shell">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="img-src 'self' data: blob: http: https: file: orca-media: orca-preview: orca-jira-image:"
/>
<title>Orca</title>
<!-- CSP is relaxed during development; electron-vite injects a stricter policy for production builds -->
</head>
@@ -385,6 +385,7 @@ export default function JiraIssueWorkspace({
<JiraIssueWorkspaceContent
displayed={displayed}
imageTarget={owner}
titleDraft={titleDraft}
setTitleDraft={setTitleDraft}
labelsDraft={labelsDraft}
@@ -0,0 +1,101 @@
// @vitest-environment happy-dom
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { JiraAttachmentImage } from './jira-attachment-image'
const { acquire, release, listeners } = vi.hoisted(() => ({
acquire: vi.fn(),
release: vi.fn(),
listeners: new Set<(environmentId: string | null) => void>()
}))
vi.mock('@/runtime/runtime-jira-image-cache', () => ({
acquireJiraImageUrl: acquire,
hasCachedJiraImage: () => false,
onJiraImageCacheInvalidated: (listener: (environmentId: string | null) => void) => {
listeners.add(listener)
return () => listeners.delete(listener)
}
}))
const target = { kind: 'environment' as const, environmentId: 'first-host' }
beforeEach(() => {
acquire.mockReset()
release.mockReset()
listeners.clear()
})
afterEach(cleanup)
it('removes the previous image immediately when its mounted owner or reference changes', async () => {
acquire
.mockResolvedValueOnce({ url: 'blob:first', release })
.mockImplementationOnce(() => new Promise(() => {}))
const view = render(
<JiraAttachmentImage target={target} reference="first" alt="shot" compact={false} />
)
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:first'))
view.rerender(
<JiraAttachmentImage
target={{ kind: 'environment', environmentId: 'second-host' }}
reference="second"
alt="shot"
compact={false}
/>
)
expect(screen.queryByRole('img')).toBeNull()
expect(release).toHaveBeenCalledOnce()
})
it('lets a new reference load after the previous reference failed', async () => {
acquire
.mockRejectedValueOnce(new Error('unavailable'))
.mockResolvedValueOnce({ url: 'blob:second', release })
const view = render(
<JiraAttachmentImage target={target} reference="first" alt="shot" compact={false} />
)
await waitFor(() => expect(acquire).toHaveBeenCalledOnce())
await new Promise((resolve) => setTimeout(resolve, 0))
view.rerender(
<JiraAttachmentImage target={target} reference="second" alt="shot" compact={false} />
)
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:second'))
})
it('reacquires the same reference after disconnect and reconnect invalidations', async () => {
acquire
.mockResolvedValueOnce({ url: 'blob:first', release })
.mockRejectedValueOnce(new Error('disconnected'))
.mockResolvedValueOnce({ url: 'blob:reconnected', release })
render(<JiraAttachmentImage target={target} reference="same" alt="shot" compact={false} />)
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:first'))
await act(async () => listeners.forEach((listener) => listener('first-host')))
expect(screen.queryByRole('img')).toBeNull()
await waitFor(() => expect(acquire).toHaveBeenCalledTimes(2))
await act(async () => listeners.forEach((listener) => listener('other-host')))
expect(acquire).toHaveBeenCalledTimes(2)
await act(async () => listeners.forEach((listener) => listener('first-host')))
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:reconnected'))
expect(acquire).toHaveBeenCalledTimes(3)
expect(release).toHaveBeenCalledOnce()
})
it('fences a pending rejection synchronously when its cache is invalidated', async () => {
let requestSignal: AbortSignal | undefined
let rejectFirst: ((reason: Error) => void) | undefined
acquire
.mockImplementationOnce((_target, _reference, signal: AbortSignal) => {
requestSignal = signal
return new Promise((_resolve, reject) => {
rejectFirst = reject
})
})
.mockResolvedValueOnce({ url: 'blob:retried', release })
render(<JiraAttachmentImage target={target} reference="same" alt="shot" compact={false} />)
await waitFor(() => expect(acquire).toHaveBeenCalledOnce())
await act(async () => {
listeners.forEach((listener) => listener('first-host'))
expect(requestSignal?.aborted).toBe(true)
rejectFirst?.(new Error('Cache invalidated'))
await Promise.resolve()
})
await waitFor(() => expect(screen.getByRole('img').getAttribute('src')).toBe('blob:retried'))
expect(acquire).toHaveBeenCalledTimes(2)
})
@@ -0,0 +1,101 @@
import { useEffect, useRef, useState } from 'react'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import {
acquireJiraImageUrl,
hasCachedJiraImage,
onJiraImageCacheInvalidated
} from '@/runtime/runtime-jira-image-cache'
import { jiraImageProtocolUrl } from '@/runtime/runtime-jira-image-client'
import { translate } from '@/i18n/i18n'
import { ExpandableMarkdownImage } from './sidebar/MarkdownImageLightbox'
type JiraAttachmentImageProps = {
target: RuntimeClientTarget
reference: string
alt?: string
compact: boolean
}
export function JiraAttachmentImage(props: JiraAttachmentImageProps): React.JSX.Element {
const owner = props.target.kind === 'environment' ? props.target.environmentId : 'local'
return <ResolvedJiraAttachmentImage key={`${owner}:${props.reference}`} {...props} />
}
function ResolvedJiraAttachmentImage({
target,
reference,
alt,
compact
}: JiraAttachmentImageProps): React.JSX.Element {
const [resolved, setResolved] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
const [generation, setGeneration] = useState(0)
const activeRequest = useRef<AbortController | null>(null)
const localProtocol = target.kind === 'local' && !hasCachedJiraImage(target, reference)
const src = localProtocol ? jiraImageProtocolUrl(reference) : resolved
useEffect(
() =>
onJiraImageCacheInvalidated((environmentId) => {
if (
environmentId === null ||
(target.kind === 'environment' && environmentId === target.environmentId)
) {
activeRequest.current?.abort()
setResolved(null)
setFailed(false)
setGeneration((value) => value + 1)
}
}),
[target]
)
useEffect(() => {
if (localProtocol) {
return
}
const controller = new AbortController()
activeRequest.current = controller
let release: (() => void) | undefined
void acquireJiraImageUrl(target, reference, controller.signal)
.then((image) => {
if (controller.signal.aborted) {
image.release()
return
}
release = image.release
setResolved(image.url)
})
.catch(() => {
if (!controller.signal.aborted) {
setFailed(true)
}
})
return () => {
controller.abort()
if (activeRequest.current === controller) {
activeRequest.current = null
}
release?.()
}
}, [generation, localProtocol, reference, target])
if (!src || failed) {
return (
<span>
{alt || translate('auto.components.sidebar.MarkdownImageLightbox.image', 'Image')}
</span>
)
}
return (
<ExpandableMarkdownImage
key={generation}
src={src}
onError={() => setFailed(true)}
alt={alt}
triggerClassName={compact ? 'my-1' : undefined}
className={
compact
? 'max-h-32 max-w-full rounded-sm object-contain outline outline-1 outline-border/70'
: 'max-h-96 max-w-full rounded-md object-contain outline outline-1 outline-border/70'
}
/>
)
}
@@ -0,0 +1,69 @@
import { createContext, useContext, useMemo } from 'react'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { jiraImageReference } from '../../../shared/jira-attachment-image'
import CommentMarkdown from './sidebar/CommentMarkdown'
import { selectCommentMarkdownComponents } from './sidebar/comment-markdown-element-renderers'
import { JiraAttachmentImage } from './jira-attachment-image'
const JiraMarkdownContext = createContext<{ target: RuntimeClientTarget; compact: boolean }>({
target: { kind: 'local' },
compact: false
})
function JiraMarkdownImage(props: React.ComponentProps<'img'>): React.JSX.Element | null {
const { target, compact } = useContext(JiraMarkdownContext)
const reference = jiraImageReference(props.src)
if (reference) {
return (
<JiraAttachmentImage
target={target}
reference={reference}
alt={props.alt}
compact={compact}
/>
)
}
const BaseImage = selectCommentMarkdownComponents({
variant: compact ? 'compact' : 'document',
expandImages: true
}).img
return BaseImage ? <BaseImage src={props.src} alt={props.alt} /> : null
}
const imageExtension = {
remarkPlugins: [],
sanitizeAttributes: {},
components: { img: JiraMarkdownImage }
}
export function JiraIssueMarkdown({
target,
content,
compact = false,
className
}: {
target: RuntimeClientTarget
content: string
compact?: boolean
className?: string
}): React.JSX.Element {
const environmentId = target.kind === 'environment' ? target.environmentId : null
const value = useMemo<{ target: RuntimeClientTarget; compact: boolean }>(
() => ({
target: environmentId ? { kind: 'environment', environmentId } : { kind: 'local' },
compact
}),
[environmentId, compact]
)
return (
<JiraMarkdownContext.Provider value={value}>
<CommentMarkdown
content={content}
variant={compact ? 'compact' : 'document'}
expandImages
extension={imageExtension}
className={className}
/>
</JiraMarkdownContext.Provider>
)
}
@@ -1,6 +1,7 @@
import type { LucideIcon } from 'lucide-react'
import { ArrowRight, LoaderCircle, RefreshCw, Save, Send } from 'lucide-react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { JiraIssueMarkdown } from './jira-issue-markdown'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { JiraIcon } from '@/components/icons/JiraIcon'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -17,6 +18,7 @@ export type JiraIssueWorkspaceAction = {
export function JiraIssueWorkspaceContent({
displayed,
imageTarget,
titleDraft,
setTitleDraft,
labelsDraft,
@@ -32,6 +34,7 @@ export function JiraIssueWorkspaceContent({
actionItems
}: {
displayed: JiraIssue
imageTarget: RuntimeClientTarget
titleDraft: string
setTitleDraft: (value: string) => void
labelsDraft: string
@@ -119,9 +122,9 @@ export function JiraIssueWorkspaceContent({
</span>
</div>
{displayed.description?.trim() ? (
<CommentMarkdown
<JiraIssueMarkdown
target={imageTarget}
content={displayed.description}
variant="document"
className="text-[14px] leading-relaxed"
/>
) : (
@@ -196,9 +199,10 @@ export function JiraIssueWorkspaceContent({
<div className="px-3 py-2">
{/* Why: Jira comment screenshots need the same preview
affordance without changing compact comment typography. */}
<CommentMarkdown
<JiraIssueMarkdown
target={imageTarget}
content={comment.body}
expandImages
compact
className="text-[13px] leading-relaxed"
/>
</div>
@@ -0,0 +1,291 @@
// @vitest-environment happy-dom
import { act, type ComponentProps, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { JiraComment, JiraIssue, JiraTransition } from '../../../shared/jira-types'
import type { TaskSourceContext } from '../../../shared/task-source-context'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { AppState } from '../store/types'
import type {
JiraIssueCommentComposer,
JiraIssueWorkspaceContent
} from './jira-issue-workspace-content'
import type { JiraIssueMetadataBar } from './jira-issue-workspace-chrome'
import JiraIssueWorkspace from './JiraIssueWorkspace'
type PendingRead<T> = {
target: RuntimeClientTarget
key: string
resolve: (value: T) => void
reject: (error: Error) => void
}
type ComposerProps = ComponentProps<typeof JiraIssueCommentComposer>
type ContentProps = ComponentProps<typeof JiraIssueWorkspaceContent>
type MetadataProps = ComponentProps<typeof JiraIssueMetadataBar>
type JiraStoreBoundary = Pick<AppState, 'patchJiraIssue'> & {
settings: Pick<NonNullable<AppState['settings']>, 'activeRuntimeEnvironmentId'>
}
const boundary = vi.hoisted(
(): {
issues: PendingRead<JiraIssue | null>[]
comments: PendingRead<JiraComment[]>[]
transitions: PendingRead<JiraTransition[]>[]
composer: ComposerProps | null
settings: JiraStoreBoundary['settings']
content: ContentProps | null
metadata: MetadataProps | null
} => ({
issues: [],
comments: [],
transitions: [],
composer: null,
settings: { activeRuntimeEnvironmentId: 'environment-1' },
content: null,
metadata: null
})
)
vi.mock('@/store', () => {
return {
useAppStore: (select: (state: JiraStoreBoundary) => unknown) =>
select({
settings: boundary.settings,
patchJiraIssue: () => {}
})
}
})
vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback }))
vi.mock('@/runtime/runtime-jira-client', () => {
function request<T>(
queue: PendingRead<T>[],
target: RuntimeClientTarget,
key: string
): Promise<T> {
return new Promise((resolve, reject) => queue.push({ target, key, resolve, reject }))
}
return {
jiraGetIssueForTarget: (target: RuntimeClientTarget, key: string) =>
request(boundary.issues, target, key),
jiraIssueCommentsForTarget: (target: RuntimeClientTarget, key: string) =>
request(boundary.comments, target, key),
jiraListTransitionsForTarget: (target: RuntimeClientTarget, key: string) =>
request(boundary.transitions, target, key),
jiraListPrioritiesForTarget: async () => [],
jiraListAssignableUsersForTarget: async () => [],
jiraUpdateIssueForTarget: async () => ({ ok: true }),
jiraAddIssueCommentForTarget: async () => ({ ok: true, id: 'added' })
}
})
vi.mock('@/components/ui/sheet', () => {
const Content = ({ children }: { children: ReactNode }) => <div>{children}</div>
return {
// Keep the owner mounted, including its closed content, so stale state is observable.
Sheet: ({ open, children }: { open: boolean; children: ReactNode }) => (
<div data-open={open}>{children}</div>
),
SheetContent: Content,
SheetTitle: Content,
SheetDescription: Content
}
})
vi.mock('./jira-issue-workspace-chrome', () => ({
JiraIssueWorkspaceHeader: ({ issueLoading }: { issueLoading: boolean }) => (
<div data-testid="issue-loading">{String(issueLoading)}</div>
),
JiraIssueMetadataBar: (props: MetadataProps) => (
<div
data-testid="transitions"
ref={(node) => {
boundary.metadata = node ? props : null
}}
>
{props.transitions.map((transition) => transition.id).join(',')}
</div>
)
}))
vi.mock('./jira-issue-workspace-content', () => ({
JiraIssueWorkspaceContent: (props: ComponentProps<typeof JiraIssueWorkspaceContent>) => (
<div
data-testid="detail"
ref={(node) => {
boundary.content = node ? props : null
}}
>
<span data-testid="title">{props.displayed.title}</span>
<span data-testid="labels">{props.displayed.labels.join(',')}</span>
<span data-testid="description">{props.displayed.description}</span>
<span data-testid="image-owner">{JSON.stringify(props.imageTarget)}</span>
<span data-testid="comments">{props.comments.map((comment) => comment.id).join(',')}</span>
<span data-testid="comments-loading">{String(props.commentsLoading)}</span>
<span data-testid="comments-error">{props.commentsError}</span>
<button type="button" onClick={props.retryComments}>
Retry
</button>
</div>
),
JiraIssueCommentComposer: (props: ComposerProps) => (
<div
ref={(node) => {
boundary.composer = node ? props : null
}}
/>
)
}))
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
const sourceContext: TaskSourceContext = {
kind: 'task-source',
provider: 'jira',
projectId: 'project-1',
hostId: 'runtime:environment-1'
}
let root: Root | null = null
let container: HTMLDivElement
function issue(key = 'JIR-1', title = key): JiraIssue {
return {
id: key,
key,
title,
siteId: 'site-1',
description: '',
url: `https://jira.invalid/browse/${key}`,
project: { id: 'project', key: 'PRJ', name: 'Project' },
issueType: { id: 'task', name: 'Task' },
status: { id: 'open', name: 'Open', categoryKey: 'new', categoryName: 'New' },
labels: [],
createdAt: '2026-10-02',
updatedAt: '2026-10-02'
}
}
function comment(id: string): JiraComment {
return { id, body: id, createdAt: '2026-10-02' }
}
async function render(
selected: JiraIssue | null,
context: TaskSourceContext | null = sourceContext
): Promise<void> {
if (!root) {
throw new Error('Missing React root')
}
const mountedRoot = root
await act(async () => {
mountedRoot.render(
<JiraIssueWorkspace
issue={selected}
sourceContext={context}
onUse={() => {}}
onClose={() => {}}
/>
)
})
}
function take<T>(queue: PendingRead<T>[]): PendingRead<T> {
const pending = queue.shift()
if (!pending) {
throw new Error('Missing pending Jira read')
}
return pending
}
function text(id: string): string | null | undefined {
return container.querySelector(`[data-testid="${id}"]`)?.textContent
}
beforeEach(() => {
boundary.settings = { activeRuntimeEnvironmentId: 'environment-1' }
boundary.issues.length = 0
boundary.comments.length = 0
boundary.transitions.length = 0
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
const mountedRoot = root
if (mountedRoot) {
await act(async () => mountedRoot.unmount())
}
root = null
await act(async () => {
for (const pending of [...boundary.issues, ...boundary.comments, ...boundary.transitions]) {
pending.reject(new Error('Test cleanup'))
}
})
boundary.issues.length = 0
boundary.comments.length = 0
boundary.transitions.length = 0
boundary.composer = null
boundary.content = null
boundary.metadata = null
document.body.replaceChildren()
})
describe('Jira issue workspace image ownership', () => {
it('keeps explicit ownership without duplicate reads when the default host changes', async () => {
const selected = issue()
const owner: RuntimeClientTarget = { kind: 'environment', environmentId: 'environment-1' }
await render(selected)
boundary.settings = { activeRuntimeEnvironmentId: 'environment-2' }
await render(selected)
for (const reads of [boundary.issues, boundary.comments, boundary.transitions]) {
expect(reads).toHaveLength(1)
expect(reads[0]?.target).toEqual(owner)
}
expect(text('image-owner')).toBe(JSON.stringify(owner))
})
it('fences late detail, comments and transitions when the same issue switches owners', async () => {
const selected = issue()
await render(selected)
const oldIssue = take(boundary.issues)
const oldComments = take(boundary.comments)
const oldTransitions = take(boundary.transitions)
await render(selected, { ...sourceContext, hostId: 'runtime:environment-2' })
const nextIssue = take(boundary.issues)
const nextComments = take(boundary.comments)
const nextTransitions = take(boundary.transitions)
expect(nextIssue.target).toEqual({ kind: 'environment', environmentId: 'environment-2' })
expect(nextComments.target).toEqual(nextIssue.target)
expect(nextTransitions.target).toEqual(nextIssue.target)
expect(text('image-owner')).toBe(JSON.stringify(nextIssue.target))
await act(async () => {
oldIssue.resolve(issue('JIR-1', 'Old owner'))
oldComments.resolve([comment('old')])
oldTransitions.resolve([{ id: 'old', name: 'Old', to: selected.status }])
})
expect(text('title')).toBe('JIR-1')
expect(text('comments')).toBe('')
expect(text('transitions')).toBe('')
await act(async () => {
nextIssue.resolve(issue('JIR-1', 'New owner'))
nextComments.resolve([comment('new')])
nextTransitions.resolve([{ id: 'new', name: 'New', to: selected.status }])
})
expect(text('title')).toBe('New owner')
expect(text('comments')).toBe('new')
expect(text('transitions')).toBe('new')
})
it('uses one account owner for row-less detail, comments, transitions and images', async () => {
await render(issue(), null)
const detail = take(boundary.issues)
const comments = take(boundary.comments)
const transitions = take(boundary.transitions)
expect(detail.target).toEqual({ kind: 'environment', environmentId: 'environment-1' })
expect(comments.target).toBe(detail.target)
expect(transitions.target).toBe(detail.target)
expect(text('image-owner')).toBe(JSON.stringify(detail.target))
await act(async () => {
detail.resolve(null)
comments.resolve([])
transitions.resolve([])
})
})
})
@@ -195,7 +195,7 @@ const commentMarkdownSanitizeSchema = {
// Why: native chat opts into file URI links after sanitize; the URL
// transform below still strips them for all other markdown surfaces.
href: [...(defaultSchema.protocols?.href ?? []), 'file'],
src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob']
src: [...(defaultSchema.protocols?.src ?? []), 'data', 'blob', 'orca-jira-image']
}
}
@@ -16,6 +16,7 @@ type ExpandableMarkdownImageProps = {
alt?: string
className?: string
triggerClassName?: string
onError?: () => void
}
/**
@@ -26,7 +27,8 @@ export function ExpandableMarkdownImage({
src,
alt,
className,
triggerClassName
triggerClassName,
onError
}: ExpandableMarkdownImageProps): React.JSX.Element {
const [open, setOpen] = React.useState(false)
const label =
@@ -51,7 +53,12 @@ export function ExpandableMarkdownImage({
'Expand image'
)}
>
<img src={src} alt={alt ?? ''} className={cn(className, 'pointer-events-none')} />
<img
src={src}
alt={alt ?? ''}
className={cn(className, 'pointer-events-none')}
onError={onError}
/>
</button>
</DialogTrigger>
<DialogContent
@@ -1,4 +1,5 @@
import React from 'react'
import { jiraImageReference } from '../../../../shared/jira-attachment-image'
import type { Components } from 'react-markdown'
import { NATIVE_CHAT_FILE_HREF_PREFIX } from '../../../../shared/native-chat-href-routing'
import {
@@ -35,7 +36,9 @@ export function isTrustedCompactImageSrc(src: string | undefined): src is string
}
const normalized = src.trim().toLowerCase()
return (
normalized.startsWith('blob:') || /^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized)
jiraImageReference(src) !== null ||
normalized.startsWith('blob:') ||
/^data:image\/(?:png|jpe?g|gif|webp);base64,/.test(normalized)
)
}
@@ -1,3 +1,4 @@
import { observeJiraImageHostStatus } from '@/runtime/runtime-jira-image-cache'
import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status'
import { getTabIdsAwaitingHostHydrationRemount } from '@/lib/parked-terminal-host-hydration'
import { emitAutomationsChangedWindowEvent } from '@/lib/automations-changed-window-event'
@@ -71,6 +72,7 @@ export function installAppLifetimeIpcEvents(
const apply = (snapshot: RuntimeHostStatusSnapshot): void => {
useAppStore.getState().applyRuntimeHostStatusSnapshot(snapshot)
remoteRuntimeTerminalColorPush.observeStatusSnapshot(snapshot)
observeJiraImageHostStatus(snapshot)
}
let stopped = false
unsubs.push(statusApi.onStatusChanged(apply), () => {
@@ -16,6 +16,8 @@ import {
jiraSearchUsers,
jiraListIssues
} from './runtime-jira-client'
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
import { clearJiraImageCache } from './runtime-jira-image-cache'
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture'
import {
@@ -75,6 +77,7 @@ beforeEach(() => {
})
afterEach(() => {
clearJiraImageCache()
vi.unstubAllGlobals()
})
describe('current sprint runtime compatibility', () => {
@@ -451,7 +454,15 @@ describe('runtime Jira client search bounds', () => {
)
})
it('streams image-bearing issue and comment payloads from remote runtimes', async () => {
it('streams image-bearing issue and comment payloads from older remote runtimes', async () => {
const status = createCompatibleRuntimeStatusResponse()
if (!status.ok) {
throw new Error('Expected successful status')
}
status.result.capabilities = status.result.capabilities?.filter(
(capability) => capability !== JIRA_IMAGE_CAPABILITY
)
runtimeCall.mockResolvedValue(status)
runtimeSubscribe.mockImplementation(
async (args: RuntimeSubscribeArgs, callbacks: RuntimeSubscribeCallbacks) => {
const payload =
@@ -501,7 +512,7 @@ describe('runtime Jira client search bounds', () => {
},
expect.anything()
)
expect(runtimeCall).not.toHaveBeenCalled()
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'status.get' }))
})
})
@@ -23,6 +23,7 @@ import type {
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { searchLocalJiraIssues } from './local-jira-search-cancellation'
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
import { clearJiraImageCache } from './runtime-jira-image-cache'
import { isRuntimeProviderSearchQueryWithinLimit } from './runtime-provider-search-bounds'
import { getJiraRuntimeTarget, type RuntimeJiraSettings } from './runtime-jira-target'
import { parseJiraConnectionStatus } from './runtime-jira-connection-status'
@@ -85,6 +86,7 @@ export async function jiraDisconnect(
settings: RuntimeJiraSettings,
siteId?: string | null
): Promise<void> {
clearJiraImageCache()
const target = getJiraRuntimeTarget(settings)
if (target.kind === 'environment') {
await callRuntimeRpc<{ ok: true }>(target, 'jira.disconnect', siteId ? { siteId } : undefined, {
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
jiraAddIssueCommentForTarget,
@@ -54,8 +55,16 @@ describe('captured Jira detail owners', () => {
await jiraListPrioritiesForTarget(owner, 'site-1')
await jiraListAssignableUsersForTarget(owner, 'ENG-1', 'Ada', 'site-1')
expect(localMethods.getIssue).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1' })
expect(localMethods.issueComments).toHaveBeenCalledWith({ key: 'ENG-1', siteId: 'site-1' })
expect(localMethods.getIssue).toHaveBeenCalledWith({
key: 'ENG-1',
siteId: 'site-1',
mediaFormat: 'reference'
})
expect(localMethods.issueComments).toHaveBeenCalledWith({
key: 'ENG-1',
siteId: 'site-1',
mediaFormat: 'reference'
})
expect(localMethods.updateIssue).toHaveBeenCalledWith({
key: 'ENG-1',
siteId: 'site-1',
@@ -128,9 +137,16 @@ describe('captured Jira detail owners', () => {
error: { code: 'method_not_found', message: 'Old host' }
})
)
const status = createCompatibleRuntimeStatusResponse()
if (!status.ok) {
throw new Error('Expected successful status')
}
status.result.capabilities = status.result.capabilities?.filter(
(capability) => capability !== JIRA_IMAGE_CAPABILITY
)
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'status.get'
? createCompatibleRuntimeStatusResponse()
? status
: {
id: 'rpc-1',
ok: true,
@@ -1,3 +1,5 @@
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
import { migrateLegacyJiraImages } from './runtime-jira-image-cache'
import type {
JiraComment,
JiraIssue,
@@ -7,7 +9,11 @@ import type {
JiraTransition
} from '../../../shared/jira-types'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { assertRuntimeEnvironmentCapability, callRuntimeRpc } from './runtime-rpc-client'
import {
assertRuntimeEnvironmentCapability,
callRuntimeRpc,
runtimeEnvironmentSupportsCapability
} from './runtime-rpc-client'
import { JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import { RuntimeRpcCallError } from './runtime-rpc-result'
import { readRuntimeJiraPayload } from './runtime-jira-payload-stream'
@@ -37,10 +43,30 @@ export async function jiraGetIssueForTarget(
key: string,
siteId?: string | null
): Promise<JiraIssue | null> {
const args = { key, siteId: siteId ?? undefined }
return target.kind === 'environment'
? readRemoteJiraPayload<JiraIssue | null>(target, 'jira.getIssueStream', 'jira.getIssue', args)
: window.api.jira.getIssue(args)
const references =
target.kind === 'local' ||
(await runtimeEnvironmentSupportsCapability(target.environmentId, JIRA_IMAGE_CAPABILITY))
const args = {
key,
siteId: siteId ?? undefined,
...(references ? { mediaFormat: 'reference' as const } : {})
}
const issue =
target.kind === 'environment'
? references
? await callRuntimeRpc<JiraIssue | null>(target, 'jira.getIssue', args, {
timeoutMs: 30_000
})
: await readRemoteJiraPayload<JiraIssue | null>(
target,
'jira.getIssueStream',
'jira.getIssue',
args
)
: await window.api.jira.getIssue(args)
return issue
? { ...issue, description: migrateLegacyJiraImages(issue.description ?? '', target) }
: null
}
export async function jiraUpdateIssueForTarget(
@@ -84,15 +110,31 @@ export async function jiraIssueCommentsForTarget(
key: string,
siteId?: string | null
): Promise<JiraComment[]> {
const args = { key, siteId: siteId ?? undefined }
return target.kind === 'environment'
? readRemoteJiraPayload<JiraComment[]>(
target,
'jira.issueCommentsStream',
'jira.issueComments',
args
)
: window.api.jira.issueComments(args)
const references =
target.kind === 'local' ||
(await runtimeEnvironmentSupportsCapability(target.environmentId, JIRA_IMAGE_CAPABILITY))
const args = {
key,
siteId: siteId ?? undefined,
...(references ? { mediaFormat: 'reference' as const } : {})
}
const comments =
target.kind === 'environment'
? references
? await callRuntimeRpc<JiraComment[]>(target, 'jira.issueComments', args, {
timeoutMs: 30_000
})
: await readRemoteJiraPayload<JiraComment[]>(
target,
'jira.issueCommentsStream',
'jira.issueComments',
args
)
: await window.api.jira.issueComments(args)
return comments.map((comment) => ({
...comment,
body: migrateLegacyJiraImages(comment.body, target)
}))
}
export async function jiraListPrioritiesForTarget(
@@ -0,0 +1,226 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { jiraGetIssueForTarget, jiraIssueCommentsForTarget } from './runtime-jira-detail-client'
import { JIRA_IMAGE_CAPABILITY } from '../../../shared/jira-attachment-image'
import { clearJiraImageCache, hasCachedJiraImage } from './runtime-jira-image-cache'
import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client'
import { createCompatibleRuntimeStatusResponse } from './runtime-compatibility-test-fixture'
type SubscribeArgs = Parameters<typeof window.api.runtimeEnvironments.subscribe>[0]
type SubscribeCallbacks = Parameters<typeof window.api.runtimeEnvironments.subscribe>[1]
const target = { kind: 'environment' as const, environmentId: 'issue-owner' }
const image = '![shot](orca-jira-image://attachment/12345678-1234-1234-1234-123456789012)'
const localIssue = vi.fn()
const localComments = vi.fn()
const runtimeCall = vi.fn()
const runtimeSubscribe = vi.fn()
const close = vi.fn()
function statusWithoutImages() {
const response = createCompatibleRuntimeStatusResponse()
if (!response.ok) {
throw new Error('Expected a successful compatibility fixture')
}
response.result.capabilities = response.result.capabilities?.filter(
(capability) => capability !== JIRA_IMAGE_CAPABILITY
)
return response
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
localIssue.mockReset()
localComments.mockReset()
runtimeCall.mockReset()
runtimeSubscribe.mockReset()
close.mockReset()
vi.stubGlobal('window', {
api: {
jira: { getIssue: localIssue, issueComments: localComments },
runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe }
}
})
})
afterEach(() => {
clearJiraImageCache()
vi.unstubAllGlobals()
})
describe('captured Jira detail owners', () => {
it('requests image references through local issue and comment IPC', async () => {
localIssue.mockResolvedValue({ key: 'JIR-1', description: image })
localComments.mockResolvedValue([{ id: 'comment', body: image }])
await expect(jiraGetIssueForTarget({ kind: 'local' }, 'JIR-1', 'site')).resolves.toMatchObject({
description: image
})
await expect(jiraIssueCommentsForTarget({ kind: 'local' }, 'JIR-1', 'site')).resolves.toEqual([
{ id: 'comment', body: image }
])
const args = { key: 'JIR-1', siteId: 'site', mediaFormat: 'reference' }
expect(localIssue).toHaveBeenCalledWith(args)
expect(localComments).toHaveBeenCalledWith(args)
expect(runtimeCall).not.toHaveBeenCalled()
expect(runtimeSubscribe).not.toHaveBeenCalled()
})
it('checks the captured remote owner capability and reads short image references there', async () => {
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'status.get'
? createCompatibleRuntimeStatusResponse()
: {
id: 'detail',
ok: true,
result:
args.method === 'jira.getIssue'
? { key: 'JIR-1', description: image }
: [{ id: 'comment', body: image }],
_meta: { runtimeId: 'remote-runtime' }
}
)
await expect(jiraGetIssueForTarget(target, 'JIR-1', 'site')).resolves.toMatchObject({
description: image
})
await expect(jiraIssueCommentsForTarget(target, 'JIR-1', 'site')).resolves.toEqual([
{ id: 'comment', body: image }
])
for (const method of ['status.get', 'jira.getIssue', 'jira.issueComments']) {
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'issue-owner',
method
})
)
}
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
method: 'jira.getIssue',
params: { key: 'JIR-1', siteId: 'site', mediaFormat: 'reference' }
})
)
expect(localIssue).not.toHaveBeenCalled()
expect(localComments).not.toHaveBeenCalled()
expect(runtimeSubscribe).not.toHaveBeenCalled()
})
it('keeps old-host streams and migrated image bytes under their captured owner', async () => {
runtimeCall.mockResolvedValue(statusWithoutImages())
runtimeSubscribe.mockImplementation(
async (args: SubscribeArgs, callbacks: SubscribeCallbacks) => {
const body = '![shot](data:image/png;base64,AQ==)'
const payload =
args.method === 'jira.getIssueStream'
? { key: 'JIR-1', description: body }
: [{ id: 'comment', body }]
for (const result of [
{ type: 'chunk', content: JSON.stringify(payload) },
{ type: 'end' }
]) {
callbacks.onResponse({
id: 'detail',
ok: true,
result,
_meta: { runtimeId: 'remote-runtime' }
})
}
return { unsubscribe: close, sendBinary: vi.fn() }
}
)
const issue = await jiraGetIssueForTarget(target, 'JIR-1', 'site')
const comments = await jiraIssueCommentsForTarget(target, 'JIR-1', 'site')
for (const body of [issue?.description, comments[0]?.body]) {
const reference = body?.match(/orca-jira-image:\/\/attachment\/([^)]+)/)?.[1]
if (!reference) {
throw new Error('Expected a migrated image reference')
}
expect(hasCachedJiraImage(target, reference)).toBe(true)
expect(
hasCachedJiraImage({ kind: 'environment', environmentId: 'other-owner' }, reference)
).toBe(false)
}
for (const method of ['jira.getIssueStream', 'jira.issueCommentsStream']) {
expect(runtimeSubscribe).toHaveBeenCalledWith(
{
selector: 'issue-owner',
method,
params: { key: 'JIR-1', siteId: 'site' },
timeoutMs: 60_000
},
expect.anything()
)
}
await Promise.resolve()
expect(close).toHaveBeenCalledTimes(2)
})
it('falls back to the original owner RPC only when an old host lacks the stream method', async () => {
runtimeCall.mockImplementation(async (args: { method: string }) =>
args.method === 'status.get'
? statusWithoutImages()
: {
id: 'detail',
ok: true,
result: args.method === 'jira.getIssue' ? null : [],
_meta: { runtimeId: 'remote-runtime' }
}
)
runtimeSubscribe.mockImplementation(
async (_args: SubscribeArgs, callbacks: SubscribeCallbacks) => {
callbacks.onResponse({
id: 'detail',
ok: false,
error: {
code: 'method_not_found',
message: 'Unknown method'
}
})
return { unsubscribe: close, sendBinary: vi.fn() }
}
)
await expect(jiraGetIssueForTarget(target, 'JIR-1')).resolves.toBeNull()
await expect(jiraIssueCommentsForTarget(target, 'JIR-1')).resolves.toEqual([])
for (const method of ['jira.getIssue', 'jira.issueComments']) {
expect(runtimeCall).toHaveBeenCalledWith(
expect.objectContaining({
selector: 'issue-owner',
method,
params: { key: 'JIR-1', siteId: undefined },
timeoutMs: 30_000
})
)
}
expect(localIssue).not.toHaveBeenCalled()
expect(localComments).not.toHaveBeenCalled()
})
it('preserves stream errors instead of retrying failed reads through another path', async () => {
runtimeCall.mockResolvedValue(statusWithoutImages())
runtimeSubscribe.mockImplementation(
async (_args: SubscribeArgs, callbacks: SubscribeCallbacks) => {
callbacks.onResponse({
id: 'detail',
ok: false,
error: {
code: 'forbidden',
message: 'Access denied'
}
})
return { unsubscribe: close, sendBinary: vi.fn() }
}
)
await expect(jiraGetIssueForTarget(target, 'JIR-1')).rejects.toMatchObject({
code: 'forbidden'
})
await expect(jiraIssueCommentsForTarget(target, 'JIR-1')).rejects.toMatchObject({
code: 'forbidden'
})
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'jira.getIssue' })
)
expect(runtimeCall).not.toHaveBeenCalledWith(
expect.objectContaining({ method: 'jira.issueComments' })
)
expect(localIssue).not.toHaveBeenCalled()
expect(localComments).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,255 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
acquireJiraImageUrl,
migrateLegacyJiraImages,
hasCachedJiraImage,
clearJiraImageCache,
observeJiraImageHostStatus,
onJiraImageCacheInvalidated
} from './runtime-jira-image-cache'
import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision'
import { jiraImageReference } from '../../../shared/jira-attachment-image'
const { download } = vi.hoisted(() => ({ download: vi.fn() }))
vi.mock('./runtime-jira-image-client', () => ({ readRuntimeJiraImage: download }))
const target = { kind: 'environment' as const, environmentId: 'host' }
const createUrl = vi.fn()
const revoke = vi.fn()
const originalUrl = URL
beforeEach(() => {
vi.stubGlobal(
'URL',
class extends originalUrl {
static createObjectURL = createUrl
static revokeObjectURL = revoke
}
)
clearJiraImageCache()
download.mockReset().mockResolvedValue(new Blob([Uint8Array.of(1)], { type: 'image/png' }))
createUrl.mockReset().mockImplementation(() => `blob:${createUrl.mock.calls.length}`)
revoke.mockReset()
replaceRuntimeEnvironmentRevisions([])
replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 1 }])
})
afterEach(() => {
clearJiraImageCache()
vi.useRealTimers()
vi.unstubAllGlobals()
})
function acquire(reference = 'image') {
return acquireJiraImageUrl(target, reference, new AbortController().signal)
}
describe('bounded Jira Blob image cache', () => {
it('shares concurrent downloads, retains active URLs and revokes on clear', async () => {
const [a, b] = await Promise.all([acquire(), acquire()])
expect(download).toHaveBeenCalledOnce()
expect(a.url).toBe(b.url)
a.release()
expect(revoke).not.toHaveBeenCalled()
b.release()
clearJiraImageCache()
expect(revoke).toHaveBeenCalledWith(a.url)
})
it('aborts the shared transfer only after both readers cancel', async () => {
let requestSignal: AbortSignal | undefined
download.mockImplementation((_target, _reference, signal: AbortSignal) => {
requestSignal = signal
return new Promise((_resolve, reject) =>
signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })
)
})
const a = new AbortController()
const b = new AbortController()
const first = acquireJiraImageUrl(target, 'same', a.signal).catch((error) => error)
const second = acquireJiraImageUrl(target, 'same', b.signal).catch((error) => error)
await vi.waitFor(() => expect(download).toHaveBeenCalledOnce())
a.abort()
await first
expect(requestSignal?.aborted).toBe(false)
b.abort()
await second
expect(requestSignal?.aborted).toBe(true)
})
it('admits no more than 24 MiB of active image URLs', async () => {
download.mockResolvedValue(new Blob([new Uint8Array(8 * 1024 * 1024)], { type: 'image/png' }))
const held = await Promise.all([acquire('1'), acquire('2'), acquire('3')])
await expect(acquire('4')).rejects.toThrow('cache is full')
expect(createUrl).toHaveBeenCalledTimes(3)
held[0]?.release()
const next = await acquire('4')
expect(revoke).toHaveBeenCalledOnce()
next.release()
held.forEach((image) => image.release())
})
it('expires unused URLs and fences a same-ID host replacement', async () => {
vi.useFakeTimers()
const image = await acquire()
image.release()
await vi.advanceTimersByTimeAsync(30 * 60_000)
expect(hasCachedJiraImage(target, 'image')).toBe(false)
expect(revoke).toHaveBeenCalledWith(image.url)
const replacement = await acquire()
replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 2 }], {
retired: ['host'],
sameHost: []
})
expect(hasCachedJiraImage(target, 'image')).toBe(false)
expect(revoke).toHaveBeenCalledWith(replacement.url)
replacement.release()
})
it('clears a host cache when the connection is lost or revoked', async () => {
const image = await acquire()
observeJiraImageHostStatus({
environmentId: 'host',
pairingRevision: 1,
sequence: 2,
checkedAt: 0,
status: null,
verification: 'unavailable',
transport: 'disconnected'
})
expect(hasCachedJiraImage(target, 'image')).toBe(false)
expect(revoke).toHaveBeenCalledWith(image.url)
image.release()
})
it('allows the combined description and comment queue while keeping three active downloads', async () => {
const releases: (() => void)[] = []
download.mockImplementation(
() =>
new Promise((resolve) => {
releases.push(() => resolve(new Blob([Uint8Array.of(1)], { type: 'image/png' })))
})
)
const reads = Array.from({ length: 48 }, (_, id) => acquire(String(id)))
await vi.waitFor(() => expect(download).toHaveBeenCalledTimes(3))
for (let batch = 0; batch < 16; batch += 1) {
await vi.waitFor(() => expect(releases).toHaveLength(3))
releases.splice(0).forEach((release) => release())
}
const images = await Promise.all(reads)
expect(images).toHaveLength(48)
expect(download).toHaveBeenCalledTimes(48)
images.forEach((image) => image.release())
})
it('notifies mounted readers once when a disconnected host is verified again', () => {
const listener = vi.fn()
const stop = onJiraImageCacheInvalidated(listener)
const status = {
environmentId: 'host',
pairingRevision: 1,
sequence: 2,
checkedAt: 0,
status: null,
verification: 'unavailable' as const,
transport: 'disconnected' as const
}
observeJiraImageHostStatus(status)
expect(listener).toHaveBeenCalledTimes(1)
observeJiraImageHostStatus({ ...status, sequence: 3, transport: 'connecting' })
expect(listener).toHaveBeenCalledTimes(1)
observeJiraImageHostStatus({
...status,
sequence: 4,
verification: 'verified',
transport: 'ready'
})
expect(listener).toHaveBeenCalledTimes(2)
observeJiraImageHostStatus({
...status,
sequence: 4,
verification: 'verified',
transport: 'ready'
})
expect(listener).toHaveBeenCalledTimes(2)
stop()
})
it('does not let stale host snapshots consume the reconnect retry', () => {
const listener = vi.fn()
const stop = onJiraImageCacheInvalidated(listener)
const status = {
environmentId: 'host',
pairingRevision: 1,
sequence: 3,
checkedAt: 0,
status: null,
verification: 'unavailable' as const,
transport: 'disconnected' as const
}
observeJiraImageHostStatus(status)
observeJiraImageHostStatus({
...status,
sequence: 2,
verification: 'verified',
transport: 'ready'
})
observeJiraImageHostStatus({
...status,
pairingRevision: 0,
sequence: 10,
verification: 'verified',
transport: 'ready'
})
expect(listener).toHaveBeenCalledTimes(1)
observeJiraImageHostStatus({
...status,
sequence: 4,
verification: 'verified',
transport: 'ready'
})
expect(listener).toHaveBeenCalledTimes(2)
stop()
})
it('forgets the old reconnect marker when a host is replaced', () => {
observeJiraImageHostStatus({
environmentId: 'host',
pairingRevision: 1,
sequence: 3,
checkedAt: 0,
status: null,
verification: 'unavailable',
transport: 'disconnected'
})
replaceRuntimeEnvironmentRevisions([{ id: 'host', createdAt: 2 }], {
retired: ['host'],
sameHost: []
})
const listener = vi.fn()
const stop = onJiraImageCacheInvalidated(listener)
observeJiraImageHostStatus({
environmentId: 'host',
pairingRevision: 2,
sequence: 1,
checkedAt: 0,
status: null,
verification: 'verified',
transport: 'ready'
})
expect(listener).not.toHaveBeenCalled()
stop()
})
it('converts old-host image data into short references before retaining Markdown', async () => {
const body = migrateLegacyJiraImages(
'![shot](data:image/png;base64,AQID) and literal data:image/png;base64,AQID',
target
)
expect(body).toMatch(
/^!\[shot\]\(orca-jira-image:\/\/attachment\/[0-9a-f-]+\) and literal data:/
)
const url = body.match(/orca-jira-image:\/\/attachment\/[0-9a-f-]+/)?.[0]
const reference = jiraImageReference(url)
if (!reference) {
throw new Error('Expected migrated image reference')
}
const image = await acquire(reference)
expect(image.url).toMatch(/^blob:/)
expect(download).not.toHaveBeenCalled()
image.release()
expect(migrateLegacyJiraImages('![bad](data:image/png;base64,A===)', target)).toBe('![bad]()')
})
})
@@ -0,0 +1,264 @@
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import type { RuntimeHostStatusSnapshot } from '../../../shared/runtime-host-status'
import {
JIRA_IMAGE_MAX_BYTES,
JIRA_IMAGE_MAX_REFERENCES,
JIRA_IMAGE_MAX_PENDING_READS
} from '../../../shared/jira-attachment-image'
import { PrioritySemaphore } from '../../../shared/priority-semaphore'
import { waitForPromiseWithSignal } from '../../../shared/abort-signal-reason'
import { readRuntimeJiraImage } from './runtime-jira-image-client'
import {
getRuntimeEnvironmentRevision,
onRuntimeEnvironmentsRetired,
onRuntimeEnvironmentRevisionsChanged
} from './runtime-environment-revision'
import { createBrowserUuid } from '@/lib/browser-uuid'
const MAX_CACHE_BYTES = 24 * 1024 * 1024
const MAX_CACHE_ENTRIES = 96
const CACHE_TTL_MS = 30 * 60_000
const downloads = new PrioritySemaphore(3)
type ImageEntry = { url: string; bytes: number; users: number; expiresAt: number }
const entries = new Map<string, ImageEntry>()
const pending = new Map<
string,
{ promise: Promise<void>; controller: AbortController; users: number }
>()
const invalidationListeners = new Set<(environmentId: string | null) => void>()
const disconnectedHosts = new Set<string>()
const lastHostStatusSequences = new Map<string, number>()
let expiryTimer: ReturnType<typeof setTimeout> | undefined
function imageOwner(target: RuntimeClientTarget): string {
return target.kind === 'environment' ? target.environmentId : 'local'
}
function imageKey(target: RuntimeClientTarget, reference: string): string {
const revision =
target.kind === 'environment' ? getRuntimeEnvironmentRevision(target.environmentId) : 0
return `${imageOwner(target)}::${revision ?? 0}::${reference}`
}
function removeEntry(key: string, entry: ImageEntry): void {
URL.revokeObjectURL(entry.url)
entries.delete(key)
}
function scheduleExpiry(): void {
clearTimeout(expiryTimer)
const expiresAt = Math.min(
...[...entries.values()].filter((entry) => entry.users === 0).map((entry) => entry.expiresAt)
)
expiryTimer = Number.isFinite(expiresAt)
? setTimeout(
() => {
evictUnused()
scheduleExpiry()
},
Math.max(1, expiresAt - Date.now())
)
: undefined
}
function evictUnused(requiredBytes = 0): boolean {
for (const [key, entry] of entries) {
if (entry.users === 0 && entry.expiresAt <= Date.now()) {
removeEntry(key, entry)
}
}
let bytes = [...entries.values()].reduce((total, entry) => total + entry.bytes, 0)
for (const [key, entry] of entries) {
if (bytes + requiredBytes <= MAX_CACHE_BYTES && entries.size < MAX_CACHE_ENTRIES) {
break
}
if (entry.users > 0) {
continue
}
removeEntry(key, entry)
bytes -= entry.bytes
}
return bytes + requiredBytes <= MAX_CACHE_BYTES && entries.size < MAX_CACHE_ENTRIES
}
function saveImage(key: string, blob: Blob): boolean {
if (!evictUnused(blob.size)) {
return false
}
entries.set(key, {
url: URL.createObjectURL(blob),
bytes: blob.size,
users: 0,
expiresAt: Date.now() + CACHE_TTL_MS
})
scheduleExpiry()
return true
}
export async function acquireJiraImageUrl(
target: RuntimeClientTarget,
reference: string,
signal: AbortSignal
): Promise<{ url: string; release: () => void }> {
if (signal.aborted) {
throw signal.reason
}
evictUnused()
const key = imageKey(target, reference)
let entry = entries.get(key)
if (!entry) {
let loading = pending.get(key)
if (!loading || loading.controller.signal.aborted) {
if (pending.size >= JIRA_IMAGE_MAX_PENDING_READS) {
throw new Error('Jira image queue is full.')
}
const controller = new AbortController()
loading = {
controller,
users: 0,
promise: (async () => {
const release = await downloads.acquire(0, controller.signal)
try {
const blob = await readRuntimeJiraImage(target, reference, controller.signal)
if (controller.signal.aborted || key !== imageKey(target, reference)) {
return
}
if (!saveImage(key, blob)) {
throw new Error('Jira image cache is full.')
}
} finally {
release()
}
})().finally(() => {
if (pending.get(key)?.controller === controller) {
pending.delete(key)
}
})
}
pending.set(key, loading)
}
const owned = loading
owned.users += 1
try {
await waitForPromiseWithSignal(owned.promise, signal)
} finally {
owned.users -= 1
if (owned.users === 0 && pending.get(key) === owned) {
owned.controller.abort()
}
}
entry = entries.get(key)
}
if (!entry || signal.aborted) {
throw new Error('Jira image is unavailable.')
}
entries.delete(key)
entries.set(key, entry)
entry.users += 1
scheduleExpiry()
const owned = entry
let released = false
return {
url: owned.url,
release: () => {
if (released) {
return
}
released = true
owned.users -= 1
evictUnused()
scheduleExpiry()
}
}
}
export function migrateLegacyJiraImages(content: string, target: RuntimeClientTarget): string {
let admitted = 0
return content.replace(
/(!\[(?:\\.|[^\\\]])*\]\()data:(image\/(?:png|jpeg|gif|webp|bmp|avif|tiff|x-icon));base64,([A-Za-z0-9+/=]+)(\))/g,
(_url, prefix: string, mimeType: string, encoded: string, suffix: string) => {
if (
++admitted > JIRA_IMAGE_MAX_REFERENCES ||
encoded.length > Math.ceil(JIRA_IMAGE_MAX_BYTES / 3) * 4
) {
return `${prefix}${suffix}`
}
try {
const bytes = Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))
if (bytes.byteLength === 0 || bytes.byteLength > JIRA_IMAGE_MAX_BYTES) {
return `${prefix}${suffix}`
}
const reference = createBrowserUuid()
if (!saveImage(imageKey(target, reference), new Blob([bytes], { type: mimeType }))) {
return `${prefix}${suffix}`
}
return `${prefix}orca-jira-image://attachment/${reference}${suffix}`
} catch {
return `${prefix}${suffix}`
}
}
)
}
export function hasCachedJiraImage(target: RuntimeClientTarget, reference: string): boolean {
evictUnused()
return entries.has(imageKey(target, reference))
}
export function onJiraImageCacheInvalidated(
listener: (environmentId: string | null) => void
): () => void {
invalidationListeners.add(listener)
return () => invalidationListeners.delete(listener)
}
export function clearJiraImageCache(environmentId?: string): void {
const prefix = environmentId ? `${environmentId}::` : null
for (const [key, entry] of entries) {
if (!prefix || key.startsWith(prefix)) {
removeEntry(key, entry)
}
}
for (const [key, download] of pending) {
if (!prefix || key.startsWith(prefix)) {
download.controller.abort()
pending.delete(key)
}
}
scheduleExpiry()
for (const listener of invalidationListeners) {
listener(environmentId ?? null)
}
}
export function observeJiraImageHostStatus(snapshot: RuntimeHostStatusSnapshot): void {
if (
getRuntimeEnvironmentRevision(snapshot.environmentId) !== snapshot.pairingRevision ||
(lastHostStatusSequences.get(snapshot.environmentId) ?? -1) >= snapshot.sequence
) {
return
}
lastHostStatusSequences.set(snapshot.environmentId, snapshot.sequence)
if (snapshot.retired || snapshot.verification === 'blocked') {
disconnectedHosts.delete(snapshot.environmentId)
clearJiraImageCache(snapshot.environmentId)
} else if (snapshot.transport === 'disconnected') {
disconnectedHosts.add(snapshot.environmentId)
clearJiraImageCache(snapshot.environmentId)
} else if (
snapshot.transport === 'ready' &&
snapshot.verification === 'verified' &&
disconnectedHosts.delete(snapshot.environmentId)
) {
clearJiraImageCache(snapshot.environmentId)
}
}
function forgetJiraImageHost(environmentId: string): void {
disconnectedHosts.delete(environmentId)
lastHostStatusSequences.delete(environmentId)
clearJiraImageCache(environmentId)
}
onRuntimeEnvironmentsRetired((ids) => ids.forEach(forgetJiraImageHost))
onRuntimeEnvironmentRevisionsChanged((ids) => ids.forEach(forgetJiraImageHost))
@@ -0,0 +1,153 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readRuntimeJiraImage } from './runtime-jira-image-client'
import { encodeJiraImageFrame } from '../../../shared/jira-image-stream-frame'
type Callbacks = Parameters<typeof window.api.runtimeEnvironments.subscribe>[1]
const subscribe = vi.fn()
const streams: { callbacks: Callbacks; transferId: string; close: ReturnType<typeof vi.fn> }[] = []
const target = { kind: 'environment' as const, environmentId: 'remote' }
const reference = '12345678-1234-4234-8234-123456789012'
function stream(index = 0) {
const value = streams[index]
if (!value) {
throw new Error('Missing image stream')
}
return value
}
function emit(result: unknown, index = 0): void {
stream(index).callbacks.onResponse({
id: 'image',
ok: true,
result,
_meta: { runtimeId: 'remote' }
})
}
function send(bytes: Uint8Array, sequence = 0, index = 0): void {
stream(index).callbacks.onBinary?.(
encodeJiraImageFrame(stream(index).transferId, sequence, bytes)
)
}
function read() {
return readRuntimeJiraImage(target, reference, new AbortController().signal)
}
beforeEach(() => {
streams.length = 0
subscribe
.mockReset()
.mockImplementation(async (args: { params: unknown }, callbacks: Callbacks) => {
if (
!args.params ||
typeof args.params !== 'object' ||
!('transferId' in args.params) ||
typeof args.params.transferId !== 'string'
) {
throw new Error('Missing transfer identity')
}
const close = vi.fn()
streams.push({ callbacks, transferId: args.params.transferId, close })
return { unsubscribe: close, sendBinary: vi.fn() }
})
vi.stubGlobal('window', { api: { runtimeEnvironments: { subscribe } } })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('remote Jira binary images', () => {
it('assembles only its own ordered image frames and closes the subscription', async () => {
const image = read()
emit({ type: 'image', mimeType: 'image/png', byteLength: 3 })
stream().callbacks.onBinary?.(Uint8Array.from([0x74, 1, 0, 0]))
stream().callbacks.onBinary?.(
encodeJiraImageFrame('87654321-4321-4321-8321-210987654321', 0, Uint8Array.of(99))
)
send(Uint8Array.from([1, 2]))
send(Uint8Array.of(3), 1)
emit({ type: 'end' })
const blob = await image
expect(blob.type).toBe('image/png')
expect(new Uint8Array(await blob.arrayBuffer())).toEqual(Uint8Array.from([1, 2, 3]))
await Promise.resolve()
expect(stream().close).toHaveBeenCalledOnce()
})
it.each([
{ mimeType: 'image/svg+xml', byteLength: 1 },
{ mimeType: 'image/png', byteLength: 8 * 1024 * 1024 + 1 },
{ mimeType: 'image/png', byteLength: -1 }
])('rejects invalid or oversized metadata before retaining bytes: %o', async (header) => {
const image = read().catch((error) => error)
emit({ type: 'image', ...header })
expect(await image).toBeInstanceOf(Error)
expect(stream().close).toHaveBeenCalledOnce()
})
it.each(['before metadata', 'out of order', 'oversized', 'incomplete'] as const)(
'rejects %s image chunks',
async (mode) => {
const image = read().catch((error) => error)
if (mode !== 'before metadata') {
emit({ type: 'image', mimeType: 'image/png', byteLength: 1 })
}
if (mode === 'incomplete') {
emit({ type: 'end' })
} else {
send(
mode === 'oversized' ? Uint8Array.from([1, 2]) : Uint8Array.of(1),
mode === 'out of order' ? 1 : 0
)
}
expect(await image).toBeInstanceOf(Error)
expect(stream().close).toHaveBeenCalledOnce()
}
)
it('separates simultaneous image transfers on a shared binary carrier', async () => {
const a = read()
const b = read()
emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }, 0)
emit({ type: 'image', mimeType: 'image/png', byteLength: 1 }, 1)
for (const [index, byte] of [
[0, 1],
[1, 2]
]) {
const frame = encodeJiraImageFrame(stream(index).transferId, 0, Uint8Array.of(byte))
for (const opened of streams) {
opened.callbacks.onBinary?.(frame)
}
}
emit({ type: 'end' }, 0)
emit({ type: 'end' }, 1)
expect(new Uint8Array(await (await a).arrayBuffer())).toEqual(Uint8Array.of(1))
expect(new Uint8Array(await (await b).arrayBuffer())).toEqual(Uint8Array.of(2))
})
it('cancels before the subscribe handle is returned', async () => {
let finish: ((value: { unsubscribe: () => void; sendBinary: () => void }) => void) | undefined
const close = vi.fn()
subscribe.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
})
)
const controller = new AbortController()
const image = readRuntimeJiraImage(target, reference, controller.signal).catch((error) => error)
controller.abort()
expect(await image).toBeInstanceOf(Error)
finish?.({ unsubscribe: close, sendBinary: vi.fn() })
await Promise.resolve()
expect(close).toHaveBeenCalledOnce()
})
it('ends stalled image downloads after the transfer deadline', async () => {
vi.useFakeTimers()
const image = read().catch((error) => error)
await vi.advanceTimersByTimeAsync(60_000)
expect(await image).toMatchObject({ message: 'Jira image transfer timed out.' })
expect(stream().close).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,155 @@
import {
JIRA_IMAGE_MAX_BYTES,
jiraImageReference,
isJiraImageMimeType
} from '../../../shared/jira-attachment-image'
import { decodeJiraImageFrame } from '../../../shared/jira-image-stream-frame'
import { createBrowserUuid } from '@/lib/browser-uuid'
import type { RuntimeClientTarget } from '../../../shared/runtime-client-target'
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
import { subscribeRuntimeEnvironment } from './runtime-environment-pairing-refresh'
import { RuntimeRpcCallError } from './runtime-rpc-result'
export async function readRuntimeJiraImage(
target: RuntimeClientTarget,
reference: string,
signal: AbortSignal
): Promise<Blob> {
if (target.kind !== 'environment') {
throw new Error('Local Jira images use the image protocol.')
}
const transferId = createBrowserUuid()
let sequence = 0
const revision = getRuntimeEnvironmentRevision(target.environmentId)
let close: (() => void) | undefined
let closeWhenReady = false
const chunks: Uint8Array<ArrayBuffer>[] = []
let received = 0
let mimeType = ''
let expected = -1
return new Promise<Blob>((resolve, reject) => {
let settled = false
const timeout = setTimeout(() => fail(new Error('Jira image transfer timed out.')), 60_000)
const cleanup = (): void => {
clearTimeout(timeout)
signal.removeEventListener('abort', abort)
if (close) {
close()
} else {
closeWhenReady = true
}
}
const fail = (error: unknown): void => {
if (settled) {
return
}
settled = true
chunks.length = 0
cleanup()
reject(error)
}
const abort = (): void => fail(signal.reason ?? new Error('Jira image request cancelled.'))
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
return
}
void subscribeRuntimeEnvironment(
{
selector: target.environmentId,
method: 'jira.attachmentImage',
params: { reference, transferId },
timeoutMs: 60_000,
expectedEnvironmentPairingRevision: revision
},
{
onResponse: (response) => {
if (settled) {
return
}
if (!response.ok) {
fail(new RuntimeRpcCallError(response))
return
}
const value = response.result
if (!value || typeof value !== 'object' || !('type' in value)) {
fail(new Error('Invalid Jira image response.'))
return
}
if (
value.type === 'image' &&
'mimeType' in value &&
'byteLength' in value &&
typeof value.mimeType === 'string' &&
isJiraImageMimeType(value.mimeType) &&
typeof value.byteLength === 'number' &&
Number.isSafeInteger(value.byteLength) &&
value.byteLength > 0 &&
value.byteLength <= JIRA_IMAGE_MAX_BYTES &&
expected === -1
) {
mimeType = value.mimeType
expected = value.byteLength
return
}
if (value.type === 'end' && received === expected && expected > 0) {
settled = true
const blob = new Blob(chunks, { type: mimeType })
chunks.length = 0
cleanup()
resolve(blob)
return
}
fail(new Error('Jira image was invalid or incomplete.'))
},
onBinary: (raw) => {
if (settled) {
return
}
let frame
try {
frame = decodeJiraImageFrame(raw, transferId)
} catch (error) {
fail(error)
return
}
if (!frame) {
return
}
if (frame.sequence !== sequence++) {
fail(new Error('Jira image chunks arrived out of order.'))
return
}
const chunk = frame.bytes
if (expected < 0 || chunk.byteLength === 0) {
fail(new Error('Jira image bytes arrived before metadata.'))
return
}
received += chunk.byteLength
if (received > JIRA_IMAGE_MAX_BYTES || (expected >= 0 && received > expected)) {
fail(new Error('Jira image exceeded its transfer limit.'))
return
}
chunks.push(Uint8Array.from(chunk))
},
onError: fail,
onClose: () => fail(new Error('Jira image transfer closed before completion.'))
}
)
.then((handle) => {
close = handle.unsubscribe
if (closeWhenReady) {
close()
}
})
.catch(fail)
})
}
export function jiraImageProtocolUrl(reference: string): string {
const url = `orca-jira-image://attachment/${reference}`
if (!jiraImageReference(url)) {
throw new Error('Invalid Jira image reference.')
}
return url
}
+24
View File
@@ -0,0 +1,24 @@
export const JIRA_IMAGE_SCHEME = 'orca-jira-image'
export const JIRA_IMAGE_CAPABILITY = 'jira.attachment-images.v1' as const
export const JIRA_IMAGE_MAX_BYTES = 8 * 1024 * 1024
export const JIRA_IMAGE_MAX_REFERENCES = 24
export const JIRA_IMAGE_MAX_PENDING_READS = 96
export const JIRA_IMAGE_CHUNK_BYTES = 64 * 1024
export type JiraMediaFormat = 'reference'
export function jiraImageReference(url: string | undefined): string | null {
if (!url) {
return null
}
const match =
/^orca-jira-image:\/\/attachment\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i.exec(
url
)
return match?.[1] ?? null
}
export function isJiraImageMimeType(value: string): boolean {
return /^image\/(?:png|apng|jpeg|jpg|gif|webp|bmp|avif|tiff|x-icon|vnd\.microsoft\.icon)$/.test(
value
)
}
+8
View File
@@ -1,6 +1,14 @@
import { JIRA_IMAGE_CAPABILITY } from './jira-attachment-image'
export const JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY = 'jira.issue-summary-generation.v1' as const
export const JIRA_USER_FIELDS_RUNTIME_CAPABILITY = 'jira.user-fields.v1' as const
export const JIRA_TRANSITION_FIELDS_RUNTIME_CAPABILITY = 'jira.transition-fields.v1' as const
export const JIRA_CURRENT_SPRINT_RUNTIME_CAPABILITY = 'jira.current-sprint.v1' as const
export const JIRA_USER_FIELDS_UPDATE_REQUIRED_MESSAGE =
'Creating Jira issues with user fields requires a newer Orca server. Update the server and try again.'
export const JIRA_DETAIL_RUNTIME_CAPABILITIES = [
JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
JIRA_IMAGE_CAPABILITY,
JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY
] as const
+43
View File
@@ -0,0 +1,43 @@
import { JIRA_IMAGE_CHUNK_BYTES } from './jira-attachment-image'
const IMAGE_KIND = 0x6a
const IMAGE_VERSION = 1
const HEADER_BYTES = 42
const encoder = new TextEncoder()
const decoder = new TextDecoder()
export function encodeJiraImageFrame(
transferId: string,
sequence: number,
bytes: Uint8Array
): Uint8Array<ArrayBuffer> {
const frame = new Uint8Array(HEADER_BYTES + bytes.byteLength)
frame[0] = IMAGE_KIND
frame[1] = IMAGE_VERSION
new DataView(frame.buffer).setUint32(2, sequence, true)
frame.set(encoder.encode(transferId), 6)
frame.set(bytes, HEADER_BYTES)
return frame
}
export function decodeJiraImageFrame(
bytes: Uint8Array<ArrayBufferLike>,
transferId: string
): { sequence: number; bytes: Uint8Array<ArrayBufferLike> } | null {
if (
bytes.byteLength < HEADER_BYTES ||
bytes[0] !== IMAGE_KIND ||
bytes[1] !== IMAGE_VERSION ||
decoder.decode(bytes.subarray(6, HEADER_BYTES)) !== transferId
) {
return null
}
const payload = bytes.subarray(HEADER_BYTES)
if (payload.byteLength === 0 || payload.byteLength > JIRA_IMAGE_CHUNK_BYTES) {
throw new Error('Invalid Jira image chunk size.')
}
return {
sequence: new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(2, true),
bytes: payload
}
}
+1 -2
View File
@@ -385,8 +385,7 @@ export const RUNTIME_CAPABILITIES = [
REPO_SEARCH_QUALIFIED_REFS_RUNTIME_CAPABILITY,
FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY,
LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY,
jiraDetailCapabilities.JIRA_USER_FIELDS_RUNTIME_CAPABILITY,
jiraDetailCapabilities.JIRA_ISSUE_SUMMARY_RUNTIME_CAPABILITY,
...jiraDetailCapabilities.JIRA_DETAIL_RUNTIME_CAPABILITIES,
JIRA_SAVED_FILTER_RUNTIME_CAPABILITY,
JIRA_STATUS_FILTER_RUNTIME_CAPABILITY,
AI_VAULT_RUNTIME_CAPABILITY,
+7 -1
View File
@@ -48,7 +48,13 @@ export const ListIssues = z
export const IssueKey = z.object({
key: requiredString('Issue key is required'),
siteId: OptionalString,
strict: z.boolean().optional()
strict: z.boolean().optional(),
mediaFormat: z.literal('reference').optional()
})
export const AttachmentImage = z.object({
reference: z.string().uuid(),
transferId: z.string().uuid()
})
export const CreateIssue = z.object({
+2
View File
@@ -316,6 +316,7 @@ import {
} from './hosted-review-params'
import {
AssignableUsers,
AttachmentImage,
CancelGenerateIssueSummary,
Connect,
CreateIssue as CreateIssueOfJiraParams,
@@ -984,6 +985,7 @@ export const RPC_PARAMS_BY_METHOD = {
'hostedReview.forBranch': HostedReviewForBranch,
'hostedReview.getCreationEligibility': HostedReviewCreationEligibility,
'jira.addIssueComment': IssueCommentOfJiraParams,
'jira.attachmentImage': AttachmentImage,
'jira.cancelGenerateIssueSummary': CancelGenerateIssueSummary,
'jira.connect': Connect,
'jira.createIssue': CreateIssueOfJiraParams,