Skip to content

Commit cb0dde1

Browse files
committed
feat(desktop): bind turns to a desktop and enforce its deadlines from the row
A turn sent from a desktop whose background executor is registered to the same session (and with mothership-desktop-background-executor on) is bound to that device at admission: copilot_runs.desktop_device_id. Its desktop calls are persisted pending, offered to the device and claimed through the executor's own fenced routes; the chat view's authorize and confirm answer 409 for them. There is no supervisor. The single desktop wait (waitForDesktopToolCall) branches on the binding: a bound call is offered (pickup_deadline_at, a new nullable column) and the device's doorbell rung, then the existing durable wait enforces every deadline from the row on each 5 s check, beside the lease revocation that already lives there: - an unclaimed call whose device is offline fails at once as not started (reason offline); one still unclaimed past its pickup window fails the same way (reason not_responding), as the inverse CAS of the claim; - a claimed call whose lease lapsed fails as outcome unknown, revoking the device's token so its late result is superseded, and rings it to cancel. Every settlement is sealed like the device's own result. The stale-execution cron settles, the same way, bound calls whose waiter died with its process. One not-started builder carries a reason (chat_not_open, offline, not_responding), and the server-owned failure helper now settles through the shared client settlement. The device is rung when a bound call needs approval, when the user answers, and on Stop.
1 parent ba12945 commit cb0dde1

33 files changed

Lines changed: 31498 additions & 57 deletions

File tree

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,28 @@ 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 report 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+
expect(completePendingAsyncToolCall).not.toHaveBeenCalled()
153+
expect(completeAsyncToolCall).not.toHaveBeenCalled()
154+
})
155+
134156
it('atomically detaches a live background confirmation', async () => {
135157
const response = await POST(
136158
createMockPostRequest({

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Span } from '@opentelemetry/api'
22
import { createLogger } from '@sim/logger'
33
import { getErrorMessage, toError } from '@sim/utils/errors'
4-
import { isPlainRecord } from '@sim/utils/object'
4+
import { isPlainRecord, toRecord } from '@sim/utils/object'
55
import { type NextRequest, NextResponse } from 'next/server'
66
import { copilotConfirmContract } from '@/lib/api/contracts/copilot'
77
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
@@ -40,7 +40,11 @@ import {
4040
settleClientToolCall,
4141
} from '@/lib/mothership/request/tools/client-settlement.server'
4242
import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
43-
import { getDesktopToolClaimOwner, isNativeDesktopTool } from '@/lib/mothership/tools/desktop-tools'
43+
import {
44+
getDesktopToolClaimOwner,
45+
isDesktopToolCall,
46+
isNativeDesktopTool,
47+
} from '@/lib/mothership/tools/desktop-tools'
4448
import {
4549
createStructuralWorkflowToolCompletionData,
4650
getWorkflowToolCompletionExecutionId,
@@ -194,6 +198,17 @@ export const POST = withRouteHandler((req: NextRequest) => {
194198
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
195199
}
196200

201+
if (run.desktopDeviceId && isDesktopToolCall(existing.toolName, toRecord(existing.args))) {
202+
span.setAttribute(TraceAttr.CopilotConfirmOutcome, CopilotConfirmOutcome.Forbidden)
203+
return NextResponse.json(
204+
{
205+
error:
206+
"This chat's desktop actions report through the desktop app's background executor",
207+
},
208+
{ status: 409 }
209+
)
210+
}
211+
197212
const isWorkflowTool = isWorkflowToolName(existing.toolName || '')
198213
const workflowId = isWorkflowTool
199214
? resolveWorkflowToolTargetId(existing.args, run.workflowId)

‎apps/sim/app/api/copilot/tool-permission/route.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { copilotToolPermissionContract } from '@/lib/api/contracts/copilot'
55
import { parseRequest, validationErrorResponse } from '@/lib/api/server'
66
import { isCopilotToolPermissionsEnabled } from '@/lib/core/config/env-flags'
77
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
8+
import { ringDesktopInbox } from '@/lib/desktop/executor/doorbell'
89
import {
910
getAsyncToolCall,
1011
getRunSegment,
@@ -136,6 +137,8 @@ async function applyDecision(
136137
toolName: claimed.toolName,
137138
decidedAt: claimed.permissionDecidedAt?.toISOString(),
138139
})
140+
// A bound device lists the call for approval; the answer turns it into a call or drops it.
141+
if (run.desktopDeviceId) ringDesktopInbox(run.desktopDeviceId, 'approval')
139142

140143
return { toolCallId, decision, applied: true }
141144
}

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,27 @@ 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+
expect(claimDesktopToolCall).not.toHaveBeenCalled()
94+
})
95+
7596
it('rejects retired browser tools retained only for history', async () => {
7697
getAsyncToolCall.mockResolvedValueOnce({
7798
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
@@ -74,6 +74,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
7474
if (run.status === 'complete' || run.status === 'error' || run.status === 'cancelled') {
7575
return createNotFoundResponse('Pending client tool call not found')
7676
}
77+
// The device's background executor claims a bound run's calls through its own fenced route.
78+
if (run.desktopDeviceId)
79+
return NextResponse.json(
80+
{ error: "This chat's desktop actions run in the desktop app's background executor" },
81+
{ status: 409 }
82+
)
7783

7884
const args = isRecordLike(toolCall.args) ? (toolCall.args as Record<string, unknown>) : {}
7985
if (!isDesktopToolCall(toolCall.toolName, args)) {

‎apps/sim/background/cleanup-stale-executions.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
type StaleSweepableExecutionStatus,
3232
} from '@/lib/logs/types'
3333
import { sweepOrphanedRuns } from '@/lib/mothership/async-runs/orphaned-runs'
34+
import { settleAbandonedDesktopToolCalls } from '@/lib/mothership/request/tools/desktop-wait'
3435
import { cancelStaleDispatches } from '@/lib/table/dispatcher'
3536
import { deleteFile } from '@/lib/uploads/core/storage-service'
3637
import {
@@ -743,6 +744,19 @@ export async function runCleanupStaleExecutions() {
743744
})
744745
}
745746

747+
/**
748+
* Settle desktop calls on device-bound runs whose waiter died with its process: an offered call
749+
* nobody claimed, or a claimed one whose device stopped renewing its lease.
750+
*/
751+
let abandonedDesktopCallsSettled = 0
752+
try {
753+
abandonedDesktopCallsSettled = await settleAbandonedDesktopToolCalls()
754+
} catch (error) {
755+
logger.error('Failed to settle abandoned desktop tool calls:', {
756+
error: toError(error).message,
757+
})
758+
}
759+
746760
return {
747761
executions: {
748762
found: staleExecutionsFound,
@@ -774,6 +788,7 @@ export async function runCleanupStaleExecutions() {
774788
},
775789
chatRuns: {
776790
orphanedSettled: orphanedRunsSettled,
791+
abandonedDesktopCallsSettled,
777792
},
778793
}
779794
}

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

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { classifyDesktopInbox, type DesktopInboxEntry } from '@/lib/desktop/exec
2626
import { markDesktopPresent } from '@/lib/desktop/executor/presence'
2727
import {
2828
acknowledgeDesktopCallResult,
29+
getBindableDesktopDevice,
2930
getBoundDesktopCall,
3031
getBoundDesktopDevice,
3132
listDesktopInboxRows,
@@ -68,6 +69,31 @@ async function requireBoundDevice(principal: SessionPrincipal, deviceId: string)
6869
return device
6970
}
7071

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

0 commit comments

Comments
 (0)