diff --git a/.agent/context/20260913T120000Z-session-details-resume-result.md b/.agent/context/20260913T120000Z-session-details-resume-result.md index 59f0492..93fb784 100644 --- a/.agent/context/20260913T120000Z-session-details-resume-result.md +++ b/.agent/context/20260913T120000Z-session-details-resume-result.md @@ -17,26 +17,30 @@ Clone `SWOFART/OneShot`, create a branch from `develop`, fix the session-details - The project URL is `https://github.com/SWOFART/OneShot.git`, identified from the user’s already-open GitHub tab. - The requested branch name was not specified; use `fix/session-details-resume-result`. - The resume endpoint is asynchronous (`202 Accepted`), so the UI must observe the durable job state after enqueueing rather than treating the first pending response as final. +- A user report confirmed that a single one-second full-list refresh is too early: the API can legitimately remain `PENDING` while the supplier worker finishes. Follow-up reads must target the same `job_id`, not list the whole workspace. - The payment proof view remains read-only; result retrieval must never submit a payment. ## Plan 1. Inspect the web components, API client, styles, and existing tests. 2. Add wallet-specific copy behavior and session-details layout styles. -3. Make resume refresh/poll until the API reports `result`/`AVAILABLE`, with safe timeout/error handling, and add regression tests. +3. Make resume read the same job until the API reports `result`/`AVAILABLE`, with bounded timeout/error handling, and add regression tests. 4. Run focused web checks and broader repository checks as feasible. ## Key decisions - Keep copy-session-ID behavior and add a separate wallet-address copy button so both identifiers remain available. -- Poll the existing `GET /v1/jobs` projection after `POST /resume`; do not add payment or result endpoint behavior that could bypass durable state. +- Send one `POST /v1/jobs/:jobId/resume`, then read only `GET /v1/jobs/:jobId` until the durable job reports a result or the bounded wait expires. Do not add payment or result endpoint behavior that could bypass durable state. +- A response with `payment_state: COMMITTED` and `delivery_state: PENDING` proves payment only; it is not evidence that a supplier result is ready. The UI must not fabricate `Result ready` for that response. ## Files/components touched - `apps/web/src/components/LoginGate.tsx` - separate session-ID and wallet-address copy actions, with a disabled state when no wallet is connected. -- `apps/web/src/components/JobWorkspace.tsx` - observes the asynchronous resume projection until a result is available or retrieval fails/times out. +- `apps/web/src/api/job-client.ts` - adds a read-only single-job status method for asynchronous delivery observation. +- `apps/web/src/components/JobWorkspace.tsx` - observes the same job until a result is available or retrieval fails/times out, without repeatedly listing all jobs. - `apps/web/src/styles.css` - wallet address gets the DID pill treatment and a spaced wallet block. -- `apps/web/test/login-gate.test.tsx` and `apps/web/test/components.test.tsx` - regression coverage for clipboard behavior and async result availability. +- `apps/web/test/login-gate.test.tsx`, `apps/web/test/components.test.tsx`, and `apps/web/test/job-client.test.ts` - regression coverage for clipboard behavior, exact-one resume, bounded single-job reads, and async result availability. +- `apps/web/browser/p5.spec.ts` - serves the single-job read endpoint in browser acceptance mocks. ## Commands/checks @@ -51,10 +55,16 @@ Clone `SWOFART/OneShot`, create a branch from `develop`, fix the session-details - `pnpm --filter @oneshot/web test` - passed, 17 files / 95 tests. - `pnpm --filter @oneshot/web test:browser` - passed, 8 browser tests. - `pnpm format:check` - passed. +- Follow-up focused web test after the single-job polling fix - passed, 23 tests. +- Follow-up full web test after the single-job polling fix - passed, 17 files / 96 tests. +- Follow-up `pnpm --filter @oneshot/web lint` - passed. +- Follow-up `pnpm --filter @oneshot/web typecheck` - passed. +- Follow-up `pnpm --filter @oneshot/web test:browser` - passed, 8 browser tests. +- Follow-up `pnpm format:check` - passed after Prettier formatting. - `pnpm lint` - passed. - `pnpm build` - passed. - `pnpm test` - passed, 80 files / 1,053 tests. -- `git diff --check` - passed; only the five intended frontend files and this context record are changed. +- `git diff --check` - passed; only the intended web files and this context record are changed. ## External-doc findings @@ -68,16 +78,18 @@ Clone `SWOFART/OneShot`, create a branch from `develop`, fix the session-details - Branch: `fix/session-details-resume-result` - Base: `develop` at `d671af1da36878b7aaafcfc3049bc958e0daeb34` -- Commit: uncommitted -- PR: not created -- CI: not run +- Previous commit: `26dfab5989f39c111d22f626b69a3b38d17a197e` +- Follow-up fix: uncommitted; candidate changes are not yet staged. +- PR: [#135](https://github.com/SWOFART/OneShot/pull/135), open against `develop`, currently at the previous commit. +- CI: not run for the follow-up candidate yet. ## Review gates -- Gate A: NOT RUN +- Gate A: fresh review required for the follow-up candidate before push. - Gate B: NOT RUN ## Handoff/next steps -1. Review and commit the scoped changes on `fix/session-details-resume-result` when ready. -2. If a PR is requested, run the repository’s Gate A/B review workflow before pushing or opening it. +1. Stage only the follow-up web changes and this context record after reviewing the diff. +2. Capture a fresh Gate A verdict for the exact candidate tree before pushing. +3. Commit and push the candidate to PR #135 after Gate A passes; do not start Gate B unless the user explicitly requests it. diff --git a/apps/web/browser/p5.spec.ts b/apps/web/browser/p5.spec.ts index c0411ae..a0c7aba 100644 --- a/apps/web/browser/p5.spec.ts +++ b/apps/web/browser/p5.spec.ts @@ -60,6 +60,8 @@ async function mockJobApi( calls.push(`${request.method()} ${pathname}`); if (pathname === '/v1/jobs' && request.method() === 'GET') return json(route, 200, { jobs: [current] }); + if (pathname === `/v1/jobs/${JOB_ID}` && request.method() === 'GET') + return json(route, 200, current); if (pathname === '/v1/jobs/quote' && request.method() === 'POST') return json(route, 200, current.supplier); if (pathname === '/v1/jobs' && request.method() === 'POST') { diff --git a/apps/web/src/api/job-client.ts b/apps/web/src/api/job-client.ts index 317ff4d..f65cd47 100644 --- a/apps/web/src/api/job-client.ts +++ b/apps/web/src/api/job-client.ts @@ -52,6 +52,15 @@ export class JobApiClient { return response.ok ? ((await responseJson(response))?.jobs ?? []) : []; } + async get(jobId: string): Promise { + const response = await this.#fetch(`${this.#baseUrl}/v1/jobs/${encodeURIComponent(jobId)}`, { + headers: this.#headers(), + }); + const body = await responseJson(response); + if (!response.ok || !body) throw new Error('Could not load the job status'); + return body; + } + async start(request: CreateJobRequest): Promise { const response = await this.#fetch(`${this.#baseUrl}/v1/jobs`, { method: 'POST', diff --git a/apps/web/src/components/JobWorkspace.tsx b/apps/web/src/components/JobWorkspace.tsx index 4d19095..11744dc 100644 --- a/apps/web/src/components/JobWorkspace.tsx +++ b/apps/web/src/components/JobWorkspace.tsx @@ -38,6 +38,7 @@ function explorerHref(transactionHash: string | undefined): string | undefined { const USER_WALLET_PAYMENT_CHECK_DELAY_MS = 500; const USER_WALLET_PAYMENT_CHECK_ATTEMPTS = 30; const RESULT_REFRESH_DELAY_MS = 1000; +const RESULT_REFRESH_ATTEMPTS = 15; function waitForPaymentCheck(): Promise { return new Promise((resolve) => { @@ -51,6 +52,23 @@ function waitForResultRefresh(): Promise { }); } +async function waitForSupplierResult( + client: JobApiClient, + jobId: string, + initial: JobView, +): Promise { + let latest = initial; + for ( + let attempt = 0; + attempt < RESULT_REFRESH_ATTEMPTS && latest.delivery_state === 'PENDING' && !latest.result; + attempt += 1 + ) { + await waitForResultRefresh(); + latest = await client.get(jobId); + } + return latest; +} + async function resolveUserWalletPayment( client: JobApiClient, jobId: string, @@ -492,12 +510,17 @@ export function JobList(props: { setError(''); try { const resumed = await props.client.resume(jobId); - if (!resumed.result && resumed.delivery_state === 'PENDING') { - await waitForResultRefresh(); - } - const listed = await refresh(); - const latest = listed?.find((job) => job.job_id === jobId); - if (latest && !latest.result) { + setRequests((current) => + current.map((job) => (job.job_id === jobId ? resumed : job)), + ); + const latest = + resumed.result || resumed.delivery_state !== 'PENDING' + ? resumed + : await waitForSupplierResult(props.client, jobId, resumed); + setRequests((current) => + current.map((job) => (job.job_id === jobId ? latest : job)), + ); + if (!latest.result) { setError( latest.delivery_state === 'RETRIEVAL_FAILED' ? 'The supplier result could not be retrieved. No new payment was submitted.' diff --git a/apps/web/test/components.test.tsx b/apps/web/test/components.test.tsx index 2057d83..f262e97 100644 --- a/apps/web/test/components.test.tsx +++ b/apps/web/test/components.test.tsx @@ -181,7 +181,7 @@ describe('IntentStatusView', () => { }); describe('JobWorkspace payment inputs', () => { - it('refreshes once after the queued resume without polling jobs repeatedly', async () => { + it('reads the resumed job until the supplier result is available without listing jobs repeatedly', async () => { const user = userEvent.setup(); const pendingJob = resumableJob('PENDING'); const availableJob: JobView = { @@ -193,11 +193,12 @@ describe('JobWorkspace payment inputs', () => { report: 'Recovered original supplier report.', }, }; - let listCalls = 0; + let getCalls = 0; const client = { - list: vi.fn(async () => { - listCalls += 1; - return [listCalls === 1 ? pendingJob : availableJob]; + list: vi.fn(async () => [pendingJob]), + get: vi.fn(async () => { + getCalls += 1; + return getCalls === 1 ? pendingJob : availableJob; }), resume: vi.fn(async () => pendingJob), }; @@ -208,10 +209,12 @@ describe('JobWorkspace payment inputs', () => { await waitFor( () => expect(screen.getByText('Recovered original supplier report.')).toBeTruthy(), - { timeout: 3000 }, + { timeout: 5000 }, ); expect(client.resume).toHaveBeenCalledWith(pendingJob.job_id); - expect(listCalls).toBe(2); + expect(client.resume).toHaveBeenCalledTimes(1); + expect(client.list).toHaveBeenCalledTimes(1); + expect(getCalls).toBe(2); expect(screen.getByText('Result ready:')).toBeTruthy(); }); diff --git a/apps/web/test/job-client.test.ts b/apps/web/test/job-client.test.ts index 351f64f..876b89f 100644 --- a/apps/web/test/job-client.test.ts +++ b/apps/web/test/job-client.test.ts @@ -22,6 +22,23 @@ const quote: SupplierQuote = { }; describe('JobApiClient quote flow', () => { + it('reads one job without listing the workspace jobs', async () => { + let calledUrl = ''; + const job = { job_id: 'job-1', delivery_state: 'PENDING' }; + const client = new JobApiClient({ + fetchFn: async (input) => { + calledUrl = String(input); + return new Response(JSON.stringify(job), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + await expect(client.get('job-1')).resolves.toEqual(job); + expect(calledUrl).toBe('/v1/jobs/job-1'); + }); + it('requests a non-chargeable quote with the authenticated task payload', async () => { let calledUrl = ''; let calledBody = '';