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
34 changes: 23 additions & 11 deletions .agent/context/20260913T120000Z-session-details-resume-result.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.
2 changes: 2 additions & 0 deletions apps/web/browser/p5.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/api/job-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ export class JobApiClient {
return response.ok ? ((await responseJson<JobListResponse>(response))?.jobs ?? []) : [];
}

async get(jobId: string): Promise<JobView> {
const response = await this.#fetch(`${this.#baseUrl}/v1/jobs/${encodeURIComponent(jobId)}`, {
headers: this.#headers(),
});
const body = await responseJson<JobView>(response);
if (!response.ok || !body) throw new Error('Could not load the job status');
return body;
}

async start(request: CreateJobRequest): Promise<JobView> {
const response = await this.#fetch(`${this.#baseUrl}/v1/jobs`, {
method: 'POST',
Expand Down
35 changes: 29 additions & 6 deletions apps/web/src/components/JobWorkspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return new Promise((resolve) => {
Expand All @@ -51,6 +52,23 @@ function waitForResultRefresh(): Promise<void> {
});
}

async function waitForSupplierResult(
client: JobApiClient,
jobId: string,
initial: JobView,
): Promise<JobView> {
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,
Expand Down Expand Up @@ -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.'
Expand Down
17 changes: 10 additions & 7 deletions apps/web/test/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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),
};
Expand All @@ -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();
});

Expand Down
17 changes: 17 additions & 0 deletions apps/web/test/job-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down
Loading