Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,22 @@ non-engineers, with engineering words only under 「詳しい人向け」
successful data for the same key during failed polls; build/draft pages show
a load-error card only when no data has loaded, never in place of cached work.
Data from another draft, app or project is not used as placeholder data.
- API requests use manual redirects: an edge 401, OIDC redirect (including an
opaque cross-origin redirect), or successful HTML login page ends the local
session instead of masquerading as an API result. Platform and Console/Directory
requests share this check. The page shows 「セッションが切れました。再サインインしてください」
and a 「再サインイン」 action; query observers are removed and further API calls
are blocked until sign-in. The action navigates to the same URL through the edge,
preserving the draft and its session storage. There is no automatic redirect
loop or never-settling authentication promise.
- A successful build kickoff resumes plan generation from the draft even when
session storage is absent. A queued kickoff also persists its plan reservation
atomically. An idle conversation without a plan has a visible 「この内容で提案して」
primary action rather than a fake progress state. The handoff reads the draft
under the existing mutation lock and uses an idempotency key bound to the latest
turn and prior generation; an already accepted/current plan is not recreated.
Active draft polling continues while the tab is hidden (subject to browser
suspension); normal focus refetch catches up after resume.
- A source repository kept from a failed create attempt is reused only when
its origin (URL name, entry, picked repository, template) matches the retry.
- AI build sessions default an absent/disabled plan publish to the wizard's
Expand Down
2 changes: 2 additions & 0 deletions web/src/api/build-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export function buildDraftOptions(projectId: string, draftId: string) {
queryFn: projectId && draftId ? () => getDraft(projectId, draftId) : skipToken,
retry: retryReadQuery,
retryDelay: readRetryDelay,
// This page drives the turn → plan handoff; hiding its tab must not suspend that writer.
refetchIntervalInBackground: true,
refetchInterval: (query) => draftPending(query.state.data) ? BUILD_POLL_MS : false,
})
}
Expand Down
15 changes: 15 additions & 0 deletions web/src/api/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ test('all build API adapters use contract routes, payloads and idempotency keys
expect(new Set(keys).size).toBe(requests.length)
})

test('plan handoff reuses its attempt key across interrupted responses', async () => {
await api.createGeneration('project-1', 'draft-1', {}, 'build-plan-draft-1-kickoff-initial')
await api.createGeneration('project-1', 'draft-1', {}, 'build-plan-draft-1-kickoff-initial')
expect(requests.map((request) => request.headers.get('Idempotency-Key'))).toEqual(['build-plan-draft-1-kickoff-initial', 'build-plan-draft-1-kickoff-initial'])
})

test('workbook set and structured turns use the draft contract routes, payloads and the caller key', async () => {
const workbook = { fileName: '台帳.xlsx', sheets: [{ name: '台帳', rows: [['名前'], ['案件']] }] }
await api.addWorkbook('project-1', 'draft-1', workbook, 'file-key-1')
Expand Down Expand Up @@ -146,6 +152,15 @@ test('operation polling is 3 seconds only while pending/running, including lapse
expect(listOptions.refetchInterval({ state: { data: { data: [draft] } } } as never)).toBe(3000)
})

test('draft handoff keeps polling in background, and succeeded turns without a lease are idle', () => {
const options = buildDraftOptions('p1', 'd1')
expect(options.refetchIntervalInBackground).toBe(true)
if (typeof options.refetchInterval !== 'function') throw new Error('Draft polling callback missing')
const completed = { ...draft, creationExecutions: [], conversationTurns: [{ status: 'succeeded' }] }
expect(options.refetchInterval({ state: { data: completed } } as never)).toBe(false)
expect(options.refetchInterval({ state: { data: { ...completed, conversationTurns: [{ status: 'running' }] } } } as never)).toBe(BUILD_POLL_MS)
})

test('implementation mutations use the run envelope and leave task execution to the server', async () => {
const run = { id: 'impl-1', status: 'pending' as const, tasks: [], taskIds: [], devWorkspaceId: '', startedAt }
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
Expand Down
8 changes: 4 additions & 4 deletions web/src/api/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import type {
} from '@appthrust/api-schema/platform-api'
import { client } from './client'

// Keep the stable mutation envelope intact. Draft mutations get a fresh
// Idempotency-Key per attempt; publish repair explicitly reuses its key.
// Keep the stable mutation envelope intact. Ordinary mutations get a fresh key;
// resumable plan handoffs and publish repair explicitly reuse their attempt key.
export function createDraft(projectId: string, payload: CreateApplicationDraftRequest) {
return client.platformApiCreateApplicationDraft({ projectId, ...payload })
}
Expand Down Expand Up @@ -52,8 +52,8 @@ export function getTurn(projectId: string, draftId: string, turnId: string) {
return client.platformApiGetApplicationDraftConversationTurn({ projectId, id: draftId, turnId })
}

export function createGeneration(projectId: string, draftId: string, payload: CreateApplicationDraftCreationPlanGenerationRequest = {}) {
return client.platformApiCreateApplicationDraftCreationPlanGeneration({ projectId, id: draftId, ...payload })
export function createGeneration(projectId: string, draftId: string, payload: CreateApplicationDraftCreationPlanGenerationRequest = {}, idempotencyKey?: string) {
return client.platformApiCreateApplicationDraftCreationPlanGeneration({ projectId, id: draftId, ...payload }, { context: { idempotencyKey } })
}

export function getGeneration(projectId: string, draftId: string, generationId: string) {
Expand Down
21 changes: 3 additions & 18 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,15 @@ import { platformApiRouter } from '@appthrust/api-schema/platform-api-router'
import { createORPCClient, ORPCError } from '@orpc/client'
import type { ContractRouterClient } from '@orpc/contract'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { assertSessionActive, checkSessionResponse } from './session-expiry'

type PlatformClientContext = { idempotencyKey?: string }
export type PlatformApiClient = ContractRouterClient<typeof platformApiRouter, PlatformClientContext>

const READ_TIMEOUT_MS = 8_000

// A 401 first sends the browser to `/` so the edge re-runs sign-in. Another 401
// within this window of that round-trip means the edge forwards a token the
// Platform API rejects (e.g. AudienceNotAllowed); redirecting again would loop,
// so the 401 reaches the screens, which show `copy.common.accessDenied`.
const REAUTH_WINDOW_MS = 60_000
const REAUTH_AT_KEY = 'builder-console:reauth-at'

export async function platformFetch(request: Request): Promise<Response> {
assertSessionActive()
const headers = new Headers(request.headers)
const read = request.method === 'GET' || request.method === 'HEAD'
if (!read && !headers.has('Idempotency-Key')) {
Expand All @@ -30,17 +25,7 @@ export async function platformFetch(request: Request): Promise<Response> {
const signal = read
? AbortSignal.any([request.signal, AbortSignal.timeout(READ_TIMEOUT_MS)])
: request.signal
const response = await fetch(new Request(request, { headers, signal }))
if (response.status !== 401) {
if (response.ok) sessionStorage.removeItem(REAUTH_AT_KEY)
return response
}
const lastReauthAt = Number(sessionStorage.getItem(REAUTH_AT_KEY))
if (Date.now() - lastReauthAt < REAUTH_WINDOW_MS) return response
sessionStorage.setItem(REAUTH_AT_KEY, String(Date.now()))
window.location.assign('/')
// The page is unloading; keep the call pending so screens do not flash an error.
return new Promise<Response>(() => {})
return checkSessionResponse(await fetch(new Request(request, { headers, signal, redirect: 'manual' })))
}

// Contract paths already start with `/api/v1`, so the base is the bare origin
Expand Down
3 changes: 1 addition & 2 deletions web/src/api/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ function recorder(responses: Response[]) {
calls.push({ input, init })
return responses.shift() ?? new Response(null, { status: 500 })
},
() => false,
)
return { api, calls }
}
Expand Down Expand Up @@ -105,7 +104,7 @@ describe('createConsoleApi', () => {
const { api } = recorder([Response.json({ error: 'upstream_unavailable', message: 'platform down' }, { status: 502 })])
await expect(api.me()).rejects.toMatchObject({ status: 502, code: 'upstream_unavailable', transient: false, message: 'platform down' })

const offline = createConsoleApi(async () => Promise.reject(new TypeError('Failed to fetch')), () => false)
const offline = createConsoleApi(async () => Promise.reject(new TypeError('Failed to fetch')))
await expect(offline.me()).rejects.toMatchObject({ status: 0, transient: true, message: copy.errors.transient })
})

Expand Down
20 changes: 5 additions & 15 deletions web/src/api/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// same-origin under `/api/console/directory/*` (→ `/api/directory/v1/*`). Same-origin
// JSON; the OIDC edge attaches Authorization, the SPA never sees a token.
import { copy } from '../copy'
import { assertSessionActive, checkSessionResponse } from './session-expiry'

/** A Directory person. `avatarVersion`: null without a profile picture, else when it last changed (the image URL's cache-buster). */
export type Person = { id: string; subject: string | null; pending: boolean; email: string; displayName: string; avatarVersion: string | null }
Expand Down Expand Up @@ -200,11 +201,6 @@ export function isConsoleError(error: unknown, code?: string): error is ConsoleA
}

const READ_TIMEOUT_MS = 8_000
// Shared with the Platform API client (api/client.ts): a 401 sends the browser
// to `/` once so the edge re-runs sign-in; a second 401 inside the window
// surfaces to the screens instead of looping.
const REAUTH_WINDOW_MS = 60_000
const REAUTH_AT_KEY = 'builder-console:reauth-at'

type Fetcher = (input: string, init: RequestInit) => Promise<Response>
export type AppListParams = { scope?: AppScope; groupId?: string; q?: string }
Expand Down Expand Up @@ -274,8 +270,9 @@ export interface ConsoleApi {
}
}

export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input, init), reauth = browserReauth): ConsoleApi {
export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input, init)): ConsoleApi {
async function send<T>(method: string, path: string, body: BodyInit | undefined, contentType: string | undefined): Promise<T> {
assertSessionActive()
const read = method === 'GET'
let response: Response
try {
Expand All @@ -284,13 +281,14 @@ export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input
headers: contentType === undefined ? { Accept: 'application/json' } : { Accept: 'application/json', 'Content-Type': contentType },
body,
credentials: 'same-origin',
redirect: 'manual',
signal: read ? AbortSignal.timeout(READ_TIMEOUT_MS) : undefined,
})
} catch {
// Network failure or read timeout: nothing answered (status 0).
throw new ConsoleApiError(0, undefined)
}
if (response.status === 401 && reauth()) return new Promise<T>(() => {})
checkSessionResponse(response)
if (!response.ok) {
let parsed: Partial<ApiError> | undefined
try {
Expand Down Expand Up @@ -384,14 +382,6 @@ export function createConsoleApi(fetcher: Fetcher = (input, init) => fetch(input
}
}

function browserReauth(): boolean {
const lastReauthAt = Number(sessionStorage.getItem(REAUTH_AT_KEY))
if (Date.now() - lastReauthAt < REAUTH_WINDOW_MS) return false
sessionStorage.setItem(REAUTH_AT_KEY, String(Date.now()))
window.location.assign('/')
return true
}

export const consoleApi = createConsoleApi()

export const screenshotUrl = (appId: string, screenshotId: string) =>
Expand Down
111 changes: 111 additions & 0 deletions web/src/api/session-expiry.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import type { ApplicationDraft } from '@appthrust/api-schema/platform-api'
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { copy } from '../copy'
import { SessionExpiryBoundary } from '../ui/session-expired'
import { getDraft } from './build'
import { buildDraftOptions } from './build-queries'
import { platformFetch } from './client'
import { createConsoleApi } from './console'
import { retryQuery, retryReadQuery } from './queries'
import { checkSessionResponse, isSessionExpiredError, sessionExpiryStore, signInAgain } from './session-expiry'

const originalFetch = globalThis.fetch
const windowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window')
beforeEach(() => { sessionExpiryStore.setState(() => false) })
afterEach(() => {
globalThis.fetch = originalFetch
sessionExpiryStore.setState(() => false)
if (windowDescriptor) Object.defineProperty(globalThis, 'window', windowDescriptor)
})

const expiredResponses: Array<[string, () => Response]> = [
['401', () => Response.json({ error: 'unauthorized' }, { status: 401 })],
['302 to IAM', () => new Response(null, { status: 302, headers: { location: 'https://iam.example.test/sign-in' } })],
['opaque redirect', () => Object.defineProperty(new Response(null), 'type', { value: 'opaqueredirect' })],
['followed IAM redirect', () => Object.defineProperty(Response.json({}), 'redirected', { value: true })],
['HTML login page', () => new Response('<!doctype html><html>Sign in</html>', { headers: { 'content-type': 'text/html; charset=utf-8' } })],
]

test.each(expiredResponses)('Platform API %s settles as session expiry and blocks subsequent polling', async (_name, response) => {
const fetch = mock(async () => response())
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
const request = new Request('https://console.example.test/api/v1/projects/p/application-drafts/d')
const error = await platformFetch(request).catch((error: unknown) => error)
expect(isSessionExpiredError(error)).toBe(true)
expect(error).toMatchObject({ status: 401, code: 'SESSION_EXPIRED' })
expect(retryReadQuery(0, error)).toBe(false)
expect(retryQuery(0, error)).toBe(false)
expect(sessionExpiryStore.state).toBe(true)
expect((fetch.mock.calls[0] as unknown as [Request])[0].redirect).toBe('manual')
await expect(platformFetch(request)).rejects.toMatchObject({ code: 'SESSION_EXPIRED' })
expect(fetch).toHaveBeenCalledTimes(1)
})

test.each(expiredResponses)('Console/Directory %s uses the same session expiry latch', async (_name, response) => {
const fetcher = mock(async (_input: string, _init: RequestInit) => response())
const api = createConsoleApi(fetcher)
await expect(api.me()).rejects.toMatchObject({ status: 401, code: 'SESSION_EXPIRED' })
await expect(api.directory.groups()).rejects.toMatchObject({ code: 'SESSION_EXPIRED' })
expect(fetcher).toHaveBeenCalledTimes(1)
expect(fetcher.mock.calls[0]?.[1].redirect).toBe('manual')
})

test('gateway HTML, forbidden JSON and successful JSON are not mistaken for expired sessions', () => {
for (const response of [new Response('<html>Unavailable</html>', { status: 503, headers: { 'content-type': 'text/html' } }), Response.json({ error: 'forbidden' }, { status: 403 }), Response.json({ id: 'draft' })]) {
expect(checkSessionResponse(response)).toBe(response)
expect(sessionExpiryStore.state).toBe(false)
}
})

test('the real oRPC adapter preserves the typed session failure rather than parsing an IAM page', async () => {
globalThis.fetch = mock(async () => new Response(null, { status: 302, headers: { location: 'https://iam.example.test/sign-in' } })) as unknown as typeof fetch
const error = await getDraft('project', 'draft').catch((error: unknown) => error)
expect(isSessionExpiredError(error)).toBe(true)
expect(retryReadQuery(0, error)).toBe(false)
})

test('cached running draft becomes a visible sign-in notice, removes polling observers and retains data', async () => {
const at = '2026-11-09T01:55:44Z'
const draft: ApplicationDraft = { id: 'draft', projectId: 'project', title: 'App', intent: 'Build app', phase: 'specifying', specMarkdown: '', architectureMarkdown: '', creationExecutions: [], ownerUserId: 'owner', createdAt: at, updatedAt: at, conversationTurns: [{ id: 'kickoff', kind: 'conversation', userMessage: 'この内容で提案して', status: 'running', startedAt: at, devWorkspaceId: 'workspace' }] }
const options = buildDraftOptions('project', 'draft')
const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } } })
client.setQueryData(options.queryKey, draft)
const fetch = mock(async () => new Response(null, { status: 302 }))
globalThis.fetch = fetch as unknown as typeof globalThis.fetch
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
function Poll() {
useQuery({ ...options, refetchOnMount: false })
return <p>Thinking</p>
}
try {
await act(async () => { root.render(<QueryClientProvider client={client}><SessionExpiryBoundary><Poll /></SessionExpiryBoundary></QueryClientProvider>) })
expect(container.textContent).toContain('Thinking')
await act(async () => { await client.refetchQueries({ queryKey: options.queryKey }) })
expect(container.textContent).toContain(copy.common.sessionExpired)
expect(container.querySelector('button')?.textContent).toBe(copy.common.signInAgain)
expect(container.textContent).not.toContain('Thinking')
expect(client.getQueryCache().find({ queryKey: options.queryKey })?.getObserversCount()).toBe(0)
expect(client.getQueryData<ApplicationDraft>(options.queryKey)).toEqual(draft)
await client.refetchQueries({ queryKey: options.queryKey, type: 'active' })
expect(fetch).toHaveBeenCalledTimes(1)
} finally {
await act(async () => { root.unmount() })
container.remove()
client.clear()
}
})

test('sign-in action navigates to the same draft URL without clearing its reservation', () => {
const href = 'https://console.example.test/-/build/draft-41cb28ef'
const assign = mock()
Object.defineProperty(globalThis, 'window', { configurable: true, value: { location: { href, assign } } })
sessionExpiryStore.setState(() => true)
signInAgain()
expect(assign).toHaveBeenCalledWith(href)
expect(sessionExpiryStore.state).toBe(true)
})
34 changes: 34 additions & 0 deletions web/src/api/session-expiry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ORPCError } from '@orpc/client'
import { Store } from '@tanstack/react-store'

/** Latches until a full-page sign-in; never discard draft/query/session data here. */
export const sessionExpiryStore = new Store(false)

function expiredError() {
return new ORPCError('SESSION_EXPIRED', { status: 401, message: 'Session expired' })
}

export function isSessionExpiredError(error: unknown): boolean {
return error instanceof ORPCError && error.code === 'SESSION_EXPIRED'
}

export function assertSessionActive(): void {
if (sessionExpiryStore.state) throw expiredError()
}

/** JSON APIs never redirect to a page. Manual redirects also catch cross-origin IAM without CORS. */
export function checkSessionResponse(response: Response): Response {
const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
if (response.status === 401 || response.type === 'opaqueredirect' || response.redirected
|| (response.status >= 300 && response.status < 400 && response.status !== 304)
|| (response.ok && (contentType === 'text/html' || contentType === 'application/xhtml+xml'))) {
sessionExpiryStore.setState(() => true)
throw expiredError()
}
return response
}

/** Re-enter the edge's OIDC flow at the same draft URL; no tokens or API redirects enter the SPA. */
export function signInAgain(): void {
window.location.assign(window.location.href)
}
Loading