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
28 changes: 18 additions & 10 deletions src/client/ConnectionActionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { api } from './api';
import { computerToolResult } from './ComputerToolCard';
type Receipt = {
approvalId: string | null;
status: 'running' | 'done';
status: 'running' | 'interrupted' | 'done';
result: ConnectionActionResult | null;
};
const conversation = (threadId: string) =>
Expand Down Expand Up @@ -69,6 +69,7 @@ export function ConnectionActionCard({
const own = mismatch ? null : receipt;
// A receipt that is still running is checked until the server finishes.
const running = own?.status === 'running';
const interrupted = own?.status === 'interrupted';
useEffect(() => {
let active = true;
const load = () =>
Expand Down Expand Up @@ -110,6 +111,7 @@ export function ConnectionActionCard({
});
return;
}
if (!outcome) setReceipt({ approvalId, status: 'running', result: null });
// A previous approval may have run even if its response never arrived.
const value =
outcome ??
Expand Down Expand Up @@ -151,11 +153,13 @@ export function ConnectionActionCard({
? 'Declined'
: running
? 'Running'
: finished
? 'Ended'
: !ready
? 'Checking'
: 'Needs your approval'}
: interrupted
? 'Interrupted'
: finished
? 'Ended'
: !ready
? 'Checking'
: 'Needs your approval'}
</span>
</header>
<div className="page-review-body">
Expand Down Expand Up @@ -218,7 +222,9 @@ export function ConnectionActionCard({
? 'Running…'
: outcome
? 'Continue conversation'
: 'Approve & run'}
: interrupted
? 'Retry & run'
: 'Approve & run'}
</button>
{!outcome && (
<button
Expand All @@ -234,9 +240,11 @@ export function ConnectionActionCard({
<small>
{running
? 'This action is still running on the server.'
: approved || declined || finished
? ''
: 'Nothing runs until you approve. These are the exact arguments.'}
: interrupted
? 'No result was saved for the previous run. Retrying can repeat the action if the service completed it before the interruption.'
: approved || declined || finished
? ''
: 'Nothing runs until you approve. These are the exact arguments.'}
</small>
</footer>
</section>
Expand Down
29 changes: 24 additions & 5 deletions src/server/connection-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,14 @@ export function connectionRoutes(
{ error: 'This action belongs to a different approval request.' },
409,
);
return previous.result
? c.json(previous.result)
if (previous.result) return c.json(previous.result);
return previous.status === 'interrupted'
? undefined
: c.json({ error: 'This action is already running.' }, 409);
};
const earlier = recovered();
if (earlier) return earlier;
const previous = connections.store.action(thread.id, body.toolCallId);
const { approval, exposed } = approvalFor(thread.id, body.approvalId);
// An approval runs its stored request once. The claim below binds one
// tool call id, so without this check the same approval could execute
Expand All @@ -141,16 +143,33 @@ export function connectionRoutes(
{ error: 'This approval request already ran its stored action.' },
409,
);
if (
if (previous) {
// Only the same approval can reclaim its interrupted action. The store's
// conditional update also prevents two retries from starting together.
if (
!connections.store.reclaimInterruptedAction(
thread.id,
body.toolCallId,
approval.id,
)
)
return (
recovered() ??
c.json({ error: 'This action is already running.' }, 409)
);
} else if (
!connections.store.claimAction(
thread.id,
body.toolCallId,
approval.id,
exposed.connection.id,
exposed.tool.name,
)
)
return recovered()!;
) {
return (
recovered() ?? c.json({ error: 'This action is already running.' }, 409)
);
}
// Not tied to the request: once approved, finish even if the tab closes.
const result = await connections.call(exposed, approval.arguments);
connections.store.finishAction(thread.id, body.toolCallId, result);
Expand Down
59 changes: 48 additions & 11 deletions src/server/connection-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,26 @@ type Row = Omit<Connection, 'hasToken' | 'tools'> & {
token: string | null;
tools: string;
};
// MCP calls are bounded to 60 seconds. Keep a generous margin before treating
// a result-less claim as interrupted, so a slow but live call cannot be run twice.
export const INTERRUPTED_ACTION_THRESHOLD_MS = 5 * 60_000;

export class ConnectionStore {
constructor(private db: DatabaseSync) {
db.exec(`CREATE TABLE IF NOT EXISTS mcp_connections(id TEXT PRIMARY KEY, dotId TEXT NOT NULL, name TEXT NOT NULL, url TEXT NOT NULL, token TEXT, tools TEXT NOT NULL, error TEXT, createdAt INTEGER NOT NULL, updatedAt INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS mcp_approvals(id TEXT PRIMARY KEY, threadId TEXT NOT NULL, dotId TEXT NOT NULL, connectionId TEXT NOT NULL, tool TEXT NOT NULL, arguments TEXT NOT NULL, createdAt INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS mcp_actions(threadId TEXT NOT NULL, toolCallId TEXT NOT NULL, connectionId TEXT NOT NULL, tool TEXT NOT NULL, status TEXT NOT NULL, result TEXT, createdAt INTEGER NOT NULL, PRIMARY KEY(threadId, toolCallId));`);
CREATE TABLE IF NOT EXISTS mcp_actions(threadId TEXT NOT NULL, toolCallId TEXT NOT NULL, connectionId TEXT NOT NULL, tool TEXT NOT NULL, status TEXT NOT NULL, result TEXT, createdAt INTEGER NOT NULL, claimedAt INTEGER, approvalId TEXT, PRIMARY KEY(threadId, toolCallId));`);
// A receipt belongs to the approval that produced it.
if (
!db
.prepare('PRAGMA table_info(mcp_actions)')
.all()
.some((column) => column.name === 'approvalId')
)
const actionColumns = db
.prepare('PRAGMA table_info(mcp_actions)')
.all()
.map((column) => column.name);
if (!actionColumns.includes('approvalId'))
db.exec('ALTER TABLE mcp_actions ADD COLUMN approvalId TEXT');
// claimedAt tracks the most recent execution attempt. Older rows fall
// back to createdAt, which was their original claim time.
if (!actionColumns.includes('claimedAt'))
db.exec('ALTER TABLE mcp_actions ADD COLUMN claimedAt INTEGER');
}
private rows(dotId?: string) {
return this.db
Expand Down Expand Up @@ -140,13 +147,20 @@ export class ConnectionStore {
action(threadId: string, toolCallId: string) {
const row = this.db
.prepare(
'SELECT status, result, approvalId FROM mcp_actions WHERE threadId=? AND toolCallId=?',
'SELECT status, result, approvalId, createdAt, claimedAt FROM mcp_actions WHERE threadId=? AND toolCallId=?',
)
.get(threadId, toolCallId);
if (!row) return undefined;
const claimedAt = Number(row.claimedAt ?? row.createdAt);
const status = String(row.status) as 'running' | 'done';
return {
approvalId: typeof row.approvalId === 'string' ? row.approvalId : null,
status: String(row.status) as 'running' | 'done',
status:
status === 'running' &&
row.result === null &&
Date.now() - claimedAt >= INTERRUPTED_ACTION_THRESHOLD_MS
? ('interrupted' as const)
: status,
result:
typeof row.result === 'string'
? (JSON.parse(row.result) as ConnectionActionResult)
Expand Down Expand Up @@ -174,15 +188,38 @@ export class ConnectionStore {
connectionId: string,
tool: string,
) {
const now = Date.now();
return (
this.db
.prepare(
"INSERT OR IGNORE INTO mcp_actions (threadId, toolCallId, connectionId, tool, status, result, createdAt, approvalId) VALUES (?, ?, ?, ?, 'running', NULL, ?, ?)",
"INSERT OR IGNORE INTO mcp_actions (threadId, toolCallId, connectionId, tool, status, result, createdAt, claimedAt, approvalId) VALUES (?, ?, ?, ?, 'running', NULL, ?, ?, ?)",
)
.run(threadId, toolCallId, connectionId, tool, Date.now(), approvalId)
.run(threadId, toolCallId, connectionId, tool, now, now, approvalId)
.changes > 0
);
}
// Reclaim only this approval's stale, result-less claim. The conditional
// update makes concurrent retries race safely: only one can refresh claimedAt.
reclaimInterruptedAction(
threadId: string,
toolCallId: string,
approvalId: string,
) {
const now = Date.now();
return (
this.db
.prepare(
"UPDATE mcp_actions SET claimedAt=? WHERE threadId=? AND toolCallId=? AND approvalId=? AND status='running' AND result IS NULL AND COALESCE(claimedAt, createdAt)<=?",
)
.run(
now,
threadId,
toolCallId,
approvalId,
now - INTERRUPTED_ACTION_THRESHOLD_MS,
).changes > 0
);
}
finishAction(
threadId: string,
toolCallId: string,
Expand Down
61 changes: 59 additions & 2 deletions tests/connection-card.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,25 @@
import { expect, it, vi } from 'vitest';
import { createElement } from 'react';
import { act, create } from 'react-test-renderer';
import { beforeEach, expect, it, vi } from 'vitest';
import { renderToStaticMarkup } from 'react-dom/server';
vi.mock('../src/client/api', () => ({ api: vi.fn() }));
const api = vi.hoisted(() => vi.fn());
vi.mock('../src/client/api', () => ({ api }));
import { ConnectionActionCard } from '../src/client/ConnectionActionCard';

const text = (node: unknown): string =>
typeof node === 'string'
? node
: Array.isArray(node)
? node.map(text).join('')
: node && typeof node === 'object' && 'children' in node
? text((node as { children: unknown }).children)
: '';

beforeEach(() => {
api.mockReset();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
});

it('shows the summary and waits for the server record before deciding', () => {
const html = renderToStaticMarkup(
<ConnectionActionCard
Expand All @@ -20,3 +38,42 @@ it('shows the summary and waits for the server record before deciding', () => {
// Decisions wait until the server confirms the action has not already run.
expect(html).not.toContain('Approve &amp; run');
});

it('offers an explicit retry when the server marks a claim interrupted', async () => {
api.mockImplementation(async (path: string) =>
path.includes('/connection-approvals/')
? {
id: 'approval-1',
connection: 'Mail',
title: 'send mail!',
description: 'Send an email.',
arguments: { to: 'a@example.com', body: 'Hi' },
expiresAt: Date.now() + 60_000,
}
: { approvalId: 'approval-1', status: 'interrupted', result: null },
);
const respond = vi.fn(async () => {});
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(
createElement(ConnectionActionCard, {
args: { approvalId: 'approval-1', summary: 'Send an email.' },
status: 'executing',
respond,
threadId: 'thread',
toolCallId: 'call',
}),
);
});
const view = text(root.toJSON());
expect(view).toContain('Interrupted');
expect(view).toContain('No result was saved for the previous run.');
expect(view).toContain('Retrying can repeat the action');
const retry = root.root.findAll(
(node) =>
node.type === 'button' && text(node.props.children) === 'Retry & run',
)[0];
expect(retry.props.disabled).toBe(false);
expect(respond).not.toHaveBeenCalled();
await act(async () => root.unmount());
});
Loading
Loading