From a432676b0371750beb7a15211a024dd6f70556c7 Mon Sep 17 00:00:00 2001 From: Shubham Mathur Date: Sat, 9 May 2026 09:31:11 +0530 Subject: [PATCH 1/2] fix: recognize all terminal statuses in approval polling --- packages/cli/src/commands/demo/card-flow.tsx | 7 +++- packages/cli/src/commands/demo/spt-flow.tsx | 5 +++ .../src/commands/spend-request/retrieve.tsx | 41 ++++++++++++++++++- .../spend-request/use-approval-polling.ts | 13 ++++-- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/commands/demo/card-flow.tsx b/packages/cli/src/commands/demo/card-flow.tsx index 52a8b53..c6d0162 100644 --- a/packages/cli/src/commands/demo/card-flow.tsx +++ b/packages/cli/src/commands/demo/card-flow.tsx @@ -189,7 +189,12 @@ export const CardFlow: React.FC = ({ setStep('await-approval'); for (;;) { try { - await pollUntilApproved(spendRequestRepo, result.id); + const final = await pollUntilApproved(spendRequestRepo, result.id); + if (final.status !== 'approved') { + throw new Error( + `Spend request did not reach approved (status: ${final.status})`, + ); + } break; } catch (err) { if ((err as Error).message === 'Approval polling timed out') { diff --git a/packages/cli/src/commands/demo/spt-flow.tsx b/packages/cli/src/commands/demo/spt-flow.tsx index 99ee3a8..bd7aef2 100644 --- a/packages/cli/src/commands/demo/spt-flow.tsx +++ b/packages/cli/src/commands/demo/spt-flow.tsx @@ -187,6 +187,11 @@ export const SptFlow: React.FC = ({ spendRequestRepo, result.id, ); + if (approved.status !== 'approved') { + throw new Error( + `Spend request did not reach approved (status: ${approved.status})`, + ); + } setSpendRequest(approved); break; } catch (err) { diff --git a/packages/cli/src/commands/spend-request/retrieve.tsx b/packages/cli/src/commands/spend-request/retrieve.tsx index 7849fa3..22e699a 100644 --- a/packages/cli/src/commands/spend-request/retrieve.tsx +++ b/packages/cli/src/commands/spend-request/retrieve.tsx @@ -21,13 +21,26 @@ type Phase = | 'polling' | 'success' | 'declined' + | 'finalized' | 'timeout' | 'error'; +// Statuses past which polling should stop. Mirrors the JSON path in +// commands/spend-request/index.tsx so an `expired`/`canceled`/`failed` request +// doesn't keep the TUI spinning until the local timeout fires. +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'approved', + 'denied', + 'expired', + 'succeeded', + 'failed', + 'canceled', +]); + export const RetrieveSpendRequest: React.FC = ({ repository, id, - timeout = 300, + timeout = 600, include, outputFile, force, @@ -87,6 +100,9 @@ export const RetrieveSpendRequest: React.FC = ({ } else if (result.status === 'denied') { setPhase('declined'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + } else if (TERMINAL_STATUSES.has(result.status)) { + setPhase('finalized'); + setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); } else { startTimeRef.current = Date.now(); setPhase('polling'); @@ -136,6 +152,11 @@ export const RetrieveSpendRequest: React.FC = ({ if (timerRef.current) clearInterval(timerRef.current); setPhase('declined'); setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); + } else if (TERMINAL_STATUSES.has(result.status)) { + if (pollRef.current) clearInterval(pollRef.current); + if (timerRef.current) clearInterval(timerRef.current); + setPhase('finalized'); + setTimeout(() => onComplete(result), DISPLAY_DELAY_MS); } } catch { // Ignore transient poll errors, keep polling @@ -205,6 +226,24 @@ export const RetrieveSpendRequest: React.FC = ({ ); } + if (phase === 'finalized') { + return ( + + + Spend request reached terminal status: {request?.status} + + + + ID: {request?.id} + + + Status: {request?.status} + + + + ); + } + if (phase === 'declined') { return ( diff --git a/packages/cli/src/commands/spend-request/use-approval-polling.ts b/packages/cli/src/commands/spend-request/use-approval-polling.ts index 35e98de..599a3dd 100644 --- a/packages/cli/src/commands/spend-request/use-approval-polling.ts +++ b/packages/cli/src/commands/spend-request/use-approval-polling.ts @@ -51,11 +51,18 @@ export function useApprovalPolling({ const poll = async () => { try { const final = await pollUntilApproved(repository, requestId); - if (!cancelled) { - onSuccess(final); - setStatus('success'); + if (cancelled) return; + if (final.status !== 'approved') { + onError( + `Spend request did not reach approved (status: ${final.status})`, + ); + setStatus('error'); setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); + return; } + onSuccess(final); + setStatus('success'); + setTimeout(() => onComplete(final), DISPLAY_DELAY_MS); } catch (err) { if (!cancelled) { onError((err as Error).message); From 11da4ee7c8f9e3269bc76fdb19c7f3fe1e293314 Mon Sep 17 00:00:00 2001 From: Shubham Mathur Date: Sat, 9 May 2026 09:31:41 +0530 Subject: [PATCH 2/2] fix: extend default spend-request polling window past server-side expiry --- README.md | 2 +- packages/cli/src/commands/spend-request/index.tsx | 8 ++++---- packages/cli/src/commands/spend-request/schema.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e12f41e..3df5e56 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ The file is created with `0600` permissions. If the file already exists, the com For agent polling, pass `--interval` and optionally `--max-attempts`: ```bash -link-cli spend-request retrieve lsrq_001 --interval 2 --max-attempts 150 +link-cli spend-request retrieve lsrq_001 --interval 2 --max-attempts 300 ``` Polling exits successfully only after the request reaches a terminal status such as `approved`, `denied`, `expired`, or `canceled`. If polling reaches `--timeout` or exhausts `--max-attempts` while the request is still non-terminal, the command exits non-zero with `code: "POLLING_TIMEOUT"` so callers do not treat a still-pending request as complete. diff --git a/packages/cli/src/commands/spend-request/index.tsx b/packages/cli/src/commands/spend-request/index.tsx index 987a4c5..1ba14dc 100644 --- a/packages/cli/src/commands/spend-request/index.tsx +++ b/packages/cli/src/commands/spend-request/index.tsx @@ -171,9 +171,9 @@ export function createSpendRequestCli( } yield { ...created, - instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${created.id} --interval 2 --max-attempts 150\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, + instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${created.id} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, _next: { - command: `spend-request retrieve ${created.id} --interval 2 --max-attempts 150`, + command: `spend-request retrieve ${created.id} --interval 2 --max-attempts 300`, until: 'status changes from pending_approval', }, }; @@ -267,9 +267,9 @@ export function createSpendRequestCli( const approval = await repository.requestApproval(id); yield { ...approval, - instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${id} --interval 2 --max-attempts 150\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, + instruction: `Present the approval_url to the user and ask them to approve in the Link app. Then call \`spend-request retrieve ${id} --interval 2 --max-attempts 300\` to poll until approved. Do not wait for the user to reply — start polling immediately.`, _next: { - command: `spend-request retrieve ${id} --interval 2 --max-attempts 150`, + command: `spend-request retrieve ${id} --interval 2 --max-attempts 300`, until: 'status changes from pending_approval', }, }; diff --git a/packages/cli/src/commands/spend-request/schema.ts b/packages/cli/src/commands/spend-request/schema.ts index c227e55..da4afb9 100644 --- a/packages/cli/src/commands/spend-request/schema.ts +++ b/packages/cli/src/commands/spend-request/schema.ts @@ -76,9 +76,9 @@ export const createOptions = z.object({ export const retrieveOptions = z.object({ timeout: z.coerce .number() - .default(300) + .default(600) .describe( - 'Polling timeout in seconds. When reached during active polling, exits non-zero with POLLING_TIMEOUT.', + 'Polling timeout in seconds. When reached during active polling, exits non-zero with POLLING_TIMEOUT. Default exceeds the server-side spend-request expiry so polling outlives the request itself.', ), interval: z.coerce .number()