Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/commands/demo/card-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ export const CardFlow: React.FC<CardFlowProps> = ({
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') {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/commands/demo/spt-flow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ export const SptFlow: React.FC<SptFlowProps> = ({
spendRequestRepo,
result.id,
);
if (approved.status !== 'approved') {
throw new Error(
`Spend request did not reach approved (status: ${approved.status})`,
);
}
setSpendRequest(approved);
break;
} catch (err) {
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/commands/spend-request/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
};
Expand Down Expand Up @@ -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',
},
};
Expand Down
41 changes: 40 additions & 1 deletion packages/cli/src/commands/spend-request/retrieve.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = new Set([
'approved',
'denied',
'expired',
'succeeded',
'failed',
'canceled',
]);

export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
repository,
id,
timeout = 300,
timeout = 600,
include,
outputFile,
force,
Expand Down Expand Up @@ -87,6 +100,9 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
} 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');
Expand Down Expand Up @@ -136,6 +152,11 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
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
Expand Down Expand Up @@ -205,6 +226,24 @@ export const RetrieveSpendRequest: React.FC<RetrieveSpendRequestProps> = ({
);
}

if (phase === 'finalized') {
return (
<Box flexDirection="column">
<Text color="yellow">
Spend request reached terminal status: {request?.status}
</Text>
<Box flexDirection="column" marginTop={1} paddingX={2}>
<Text>
ID: <Text bold>{request?.id}</Text>
</Text>
<Text>
Status: <Text bold>{request?.status}</Text>
</Text>
</Box>
</Box>
);
}

if (phase === 'declined') {
return (
<Box flexDirection="column">
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/spend-request/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
13 changes: 10 additions & 3 deletions packages/cli/src/commands/spend-request/use-approval-polling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down