Skip to content

Commit b41f394

Browse files
committed
feat(desktop): bind turns to a desktop and supervise their calls in the background
Wires the run loop to the background executor protocol, still inert until a desktop that speaks it registers with the flag on: - A desktop composer whose shell has a background executor sends its device id and protocol version. Admission binds the run to that device only when the flag is on for the user and the device is registered, as an executor, to the caller's own session; otherwise the turn stays with the chat view. - On a bound run every desktop call is persisted pending. Once it may run, a supervisor offers it to the device and rings its doorbell, fails it at once as not started when the device is offline, fails it as not started when no one claims it within 15 s, and fails it as outcome unknown when a claimed call's lease lapses. Every failure is sealed like a device result and woken through the confirmation channel. A gated call rings an approval doorbell. - The resume gate leaves bound desktop calls to that supervisor, so a long terminal command lives as long as the device renews its lease, and the Sim tool lease sweep no longer claims executor leases. - The chat view's authorize and confirm routes refuse a bound run's calls, and Stop rings the device so it cancels what it is running.
1 parent 4c3615c commit b41f394

18 files changed

Lines changed: 988 additions & 4 deletions

File tree

‎apps/sim/app/api/copilot/confirm/route.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,26 @@ describe('Copilot Confirm API Route', () => {
131131
expect(JSON.stringify(publishToolConfirmation.mock.calls)).not.toContain('resolved-secret')
132132
})
133133

134+
it("refuses a chat view's failure for a call a desktop's background executor owns", async () => {
135+
getAsyncToolCall.mockResolvedValue({
136+
...existingRow,
137+
toolName: 'browser_click',
138+
status: 'pending',
139+
claimedBy: null,
140+
})
141+
getRunSegment.mockResolvedValue({ id: 'run-1', userId: 'user-1', desktopDeviceId: 'device-1' })
142+
143+
const response = await POST(
144+
createMockPostRequest({
145+
toolCallId: 'tool-call-123',
146+
status: 'error',
147+
message: 'The desktop refused this claim',
148+
})
149+
)
150+
151+
expect(response.status).toBe(409)
152+
})
153+
134154
it('atomically detaches a live background confirmation', async () => {
135155
const response = await POST(
136156
createMockPostRequest({

‎apps/sim/app/api/copilot/confirm/route.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,12 @@ import { isBrowserToolName, isCurrentBrowserToolName } from '@sim/browser-protoc
22
import { createLogger } from '@sim/logger'
33
import { isTerminalToolName } from '@sim/terminal-protocol'
44
import { getErrorMessage, toError } from '@sim/utils/errors'
5-
import { isPlainRecord } from '@sim/utils/object'
5+
import { isPlainRecord, toRecord } from '@sim/utils/object'
66
import { type NextRequest, NextResponse } from 'next/server'
77
import { copilotConfirmContract } from '@/lib/api/contracts/copilot'
88
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
import { isDesktopExecutorTool } from '@/lib/desktop/executor/tools'
1011
import {
1112
ASYNC_TOOL_CONFIRMATION_STATUS,
1213
ASYNC_TOOL_STATUS,
@@ -228,6 +229,20 @@ export const POST = withRouteHandler((req: NextRequest) => {
228229
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
229230
}
230231

232+
if (
233+
run.desktopDeviceId &&
234+
isDesktopExecutorTool(existing.toolName, toRecord(existing.args))
235+
) {
236+
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Forbidden)
237+
return NextResponse.json(
238+
{
239+
error:
240+
"This chat's desktop actions report through the desktop app's background executor",
241+
},
242+
{ status: 409 }
243+
)
244+
}
245+
231246
const isWorkflowTool = isWorkflowToolName(existing.toolName || '')
232247
const workflowId = isWorkflowTool
233248
? resolveWorkflowToolTargetId(existing.args, run.workflowId)

‎apps/sim/app/api/desktop/tool/authorize/route.test.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,26 @@ describe('desktop tool authorization', () => {
7272
})
7373
})
7474

75+
it("refuses a chat view's claim on a run bound to a desktop's background executor", async () => {
76+
getAsyncToolCall.mockResolvedValueOnce({
77+
toolCallId: 'bound-click',
78+
runId: 'run-1',
79+
status: 'pending',
80+
toolName: 'browser_click',
81+
args: { ref: 'e1' },
82+
})
83+
getRunSegment.mockResolvedValueOnce({
84+
id: 'run-1',
85+
chatId: 'chat-1',
86+
userId: 'user-1',
87+
status: 'active',
88+
desktopDeviceId: 'device-1',
89+
})
90+
91+
const response = await POST(request('bound-click'))
92+
expect(response.status).toBe(409)
93+
})
94+
7595
it('rejects retired browser tools retained only for history', async () => {
7696
getAsyncToolCall.mockResolvedValueOnce({
7797
toolCallId: 'retired-browser-tool',

‎apps/sim/app/api/desktop/tool/authorize/route.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4747
if (run.status === 'complete' || run.status === 'error' || run.status === 'cancelled') {
4848
return createNotFoundResponse('Pending client tool call not found')
4949
}
50+
// The device's background executor claims a bound run's calls through its own fenced route.
51+
if (run.desktopDeviceId)
52+
return NextResponse.json(
53+
{ error: "This chat's desktop actions run in the desktop app's background executor" },
54+
{ status: 409 }
55+
)
5056

5157
const args = isRecordLike(toolCall.args) ? (toolCall.args as Record<string, unknown>) : {}
5258
const isBrowserTool = isCurrentBrowserToolName(toolCall.toolName)

‎apps/sim/lib/desktop/application/executor.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { markDesktopPresent, releaseDesktopPresence } from '@/lib/desktop/execut
2828
import {
2929
acknowledgeDesktopCallResult,
3030
claimOfferedDesktopCall,
31+
getBindableDesktopDevice,
3132
getBoundDesktopCall,
3233
getBoundDesktopDevice,
3334
listDesktopInboxRows,
@@ -60,6 +61,31 @@ async function requireBoundDevice(principal: SessionPrincipal, deviceId: string)
6061
return device
6162
}
6263

64+
/**
65+
* The device a new turn binds to: the composer's own, but only while the executor is on for this
66+
* user and the device is registered to this very session as an executor. Anything else leaves
67+
* the turn to the chat view, as before the executor existed.
68+
*/
69+
export async function resolveTurnDesktopDevice(
70+
principal: SessionPrincipal,
71+
deviceId: string
72+
): Promise<string | null> {
73+
if (!(await isDesktopBackgroundExecutorEnabled(principal.userId))) return null
74+
const device = await getBindableDesktopDevice({
75+
deviceId,
76+
userId: principal.userId,
77+
sessionId: principal.sessionId,
78+
})
79+
if (!device) {
80+
logger.warn('Turn not bound: its desktop is not registered to this session', {
81+
userId: principal.userId,
82+
deviceId,
83+
})
84+
return null
85+
}
86+
return device.id
87+
}
88+
6389
export interface RegisterDesktopDeviceInput extends DeviceInput {
6490
name: string
6591
appVersion: string

‎apps/sim/lib/desktop/executor/repository.ts‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,147 @@ export async function acknowledgeDesktopCallResult(input: {
417417
return { outcome: 'superseded', status: row.status }
418418
})
419419
}
420+
421+
/** A device the caller may bind a new turn to: theirs, on this session, and able to execute. */
422+
export async function getBindableDesktopDevice(identity: DesktopDeviceIdentity) {
423+
const [row] = await db
424+
.select({ id: desktopDevices.id })
425+
.from(desktopDevices)
426+
.where(
427+
and(
428+
eq(desktopDevices.id, identity.deviceId),
429+
eq(desktopDevices.userId, identity.userId),
430+
eq(desktopDevices.sessionId, identity.sessionId),
431+
isNull(desktopDevices.revokedAt),
432+
sql`coalesce((${desktopDevices.capabilities} ->> 'executor')::int, 0) >= 1`
433+
)
434+
)
435+
.limit(1)
436+
return row ?? null
437+
}
438+
439+
/** The device a run's desktop calls are bound to, or null for a run the chat view serves. */
440+
export async function getRunDesktopDeviceId(runId: string): Promise<string | null> {
441+
const [row] = await db
442+
.select({ desktopDeviceId: copilotRuns.desktopDeviceId })
443+
.from(copilotRuns)
444+
.where(eq(copilotRuns.id, runId))
445+
.limit(1)
446+
return row?.desktopDeviceId ?? null
447+
}
448+
449+
/**
450+
* Offers a pending call to its device by opening its pickup window. Only an unowned pending call
451+
* on a bound run can be offered, and only once.
452+
*/
453+
export async function offerDesktopCall(input: {
454+
toolCallId: string
455+
runId: string
456+
pickupGraceMs: number
457+
}): Promise<boolean> {
458+
const [row] = await db
459+
.update(copilotAsyncToolCalls)
460+
.set({
461+
executionLeaseExpiresAt: sql`clock_timestamp() + ${input.pickupGraceMs} * interval '1 millisecond'`,
462+
})
463+
.where(
464+
and(
465+
eq(copilotAsyncToolCalls.toolCallId, input.toolCallId),
466+
eq(copilotAsyncToolCalls.runId, input.runId),
467+
eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending),
468+
isNull(copilotAsyncToolCalls.executionOwnerToken),
469+
isNull(copilotAsyncToolCalls.executionLeaseExpiresAt),
470+
sql`EXISTS (SELECT 1 FROM ${copilotRuns} r WHERE r.id = ${copilotAsyncToolCalls.runId}
471+
AND r.desktop_device_id IS NOT NULL)`
472+
)
473+
)
474+
.returning({ toolCallId: copilotAsyncToolCalls.toolCallId })
475+
return Boolean(row)
476+
}
477+
478+
/** Where a supervised call stands, measured on the database clock every CAS uses. */
479+
export async function getDesktopCallState(toolCallId: string) {
480+
const [row] = await db
481+
.select({
482+
status: copilotAsyncToolCalls.status,
483+
ownerToken: copilotAsyncToolCalls.executionOwnerToken,
484+
result: copilotAsyncToolCalls.result,
485+
msUntilLeaseEnd: sql<
486+
number | null
487+
>`(extract(epoch from (${copilotAsyncToolCalls.executionLeaseExpiresAt} - clock_timestamp())) * 1000)::float8`,
488+
})
489+
.from(copilotAsyncToolCalls)
490+
.where(eq(copilotAsyncToolCalls.toolCallId, toolCallId))
491+
.limit(1)
492+
return row ?? null
493+
}
494+
495+
interface DesktopCallFailure {
496+
toolCallId: string
497+
runId: string
498+
result: AsyncCompletionData
499+
error: string
500+
}
501+
502+
/**
503+
* Fails a call nobody claimed: the inverse CAS of the claim, so exactly one of the two wins.
504+
* `deadlinePassed` limits it to a call whose pickup window has closed.
505+
*/
506+
export async function failUnclaimedDesktopCall(
507+
input: DesktopCallFailure & { deadlinePassed: boolean }
508+
): Promise<boolean> {
509+
const [row] = await db
510+
.update(copilotAsyncToolCalls)
511+
.set({
512+
status: ASYNC_TOOL_STATUS.failed,
513+
result: sanitizeValueForJsonb(input.result),
514+
error: input.error,
515+
completedAt: sql`now()`,
516+
updatedAt: sql`now()`,
517+
})
518+
.where(
519+
and(
520+
eq(copilotAsyncToolCalls.toolCallId, input.toolCallId),
521+
eq(copilotAsyncToolCalls.runId, input.runId),
522+
eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.pending),
523+
isNull(copilotAsyncToolCalls.executionOwnerToken),
524+
input.deadlinePassed
525+
? sql`${copilotAsyncToolCalls.executionLeaseExpiresAt} <= clock_timestamp()`
526+
: undefined
527+
)
528+
)
529+
.returning({ toolCallId: copilotAsyncToolCalls.toolCallId })
530+
return Boolean(row)
531+
}
532+
533+
/**
534+
* Fails a claimed call whose lease lapsed with this token still on it. The token stays on the row,
535+
* so the device's late result is answered as superseded and acknowledges the cancellation.
536+
*/
537+
export async function failLapsedDesktopCall(
538+
input: DesktopCallFailure & { ownerToken: string }
539+
): Promise<boolean> {
540+
const [row] = await db
541+
.update(copilotAsyncToolCalls)
542+
.set({
543+
status: ASYNC_TOOL_STATUS.failed,
544+
result: sanitizeValueForJsonb(input.result),
545+
error: input.error,
546+
claimedBy: null,
547+
claimedAt: null,
548+
completedAt: sql`now()`,
549+
updatedAt: sql`now()`,
550+
})
551+
.where(
552+
and(
553+
eq(copilotAsyncToolCalls.toolCallId, input.toolCallId),
554+
eq(copilotAsyncToolCalls.runId, input.runId),
555+
eq(copilotAsyncToolCalls.status, ASYNC_TOOL_STATUS.running),
556+
eq(copilotAsyncToolCalls.executionOwnerToken, input.ownerToken),
557+
isNull(copilotAsyncToolCalls.executionRevokedAt),
558+
sql`${copilotAsyncToolCalls.executionLeaseExpiresAt} <= clock_timestamp()`
559+
)
560+
)
561+
.returning({ toolCallId: copilotAsyncToolCalls.toolCallId })
562+
return Boolean(row)
563+
}

0 commit comments

Comments
 (0)