Skip to content

Commit bf50837

Browse files
committed
feat(desktop): import local files from the background executor
A chat whose turn runs on the desktop can now import a local file or folder into the workspace while the user is elsewhere, instead of the background executor answering import_local_files as not run. - Sim: PUT /api/desktop/tool/import stores one entry of a claimed import. The entry is admitted before its body is read: the caller's session must own the device, the call must be a running import_local_files bound to that device, and the request must carry the token that claimed it. The target workspace and folder come from Sim's record of the call, never from the request, and writing there is authorized as any file creation is. Each path segment becomes a folder under the target, reusing folders already there (find or create, converging when two land at once); a file never overwrites one already there. - Desktop: the runner reads the manifest from the call's own arguments, sends each directory, then each file in chunks checked against the manifest's revision, and reports what landed. A failure part way reports the partial result as outcome unknown, not to be retried. Stop aborts between entries and mid-upload. - The chunked read, the size check and the result projections move to @sim/desktop-bridge/tool-results, so the chat view's import and the background one report the same shapes.
1 parent c8897d1 commit bf50837

16 files changed

Lines changed: 1154 additions & 95 deletions

File tree

‎apps/desktop/e2e/background-executor.spec.ts‎

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execFileSync } from 'node:child_process'
2-
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
2+
import { createHash } from 'node:crypto'
3+
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
34
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
45
import { tmpdir } from 'node:os'
56
import { join } from 'node:path'
@@ -17,7 +18,8 @@ import { sleep } from '@sim/utils/helpers'
1718

1819
/**
1920
* The Sim desktop app's background executor against a fixture Sim that speaks the executor's
20-
* device protocol (register, inbox, doorbell, claim, lease, complete) the way Sim's routes do.
21+
* device protocol (register, inbox, doorbell, claim, lease, complete, import) the way Sim's
22+
* routes do.
2123
* The window navigates, reloads and leaves the chats while their calls run: nothing in this
2224
* suite depends on a chat view, which is the point. Each scenario's checks land in a JSON
2325
* report at BACKGROUND_EXECUTOR_REPORT_PATH.
@@ -66,12 +68,26 @@ interface ReportCheck {
6668

6769
const report: ReportCheck[] = []
6870

71+
interface ImportedEntry {
72+
toolCallId: string
73+
kind: string
74+
sourceName: string
75+
relativePath: string
76+
sha256?: string
77+
bytes?: number
78+
}
79+
80+
function sha256(bytes: Buffer): string {
81+
return createHash('sha256').update(bytes).digest('hex')
82+
}
83+
6984
class FixtureSim {
7085
readonly calls = new Map<string, FixtureCall>()
7186
readonly devices = new Map<string, { name: string; platform: string }>()
7287
readonly requests: string[] = []
7388
readonly hits = new Map<string, number>()
7489
readonly streams = new Map<string, Set<ServerResponse>>()
90+
readonly imported: ImportedEntry[] = []
7591
enabled = true
7692
offline = false
7793
droppedWhileOffline = 0
@@ -100,6 +116,7 @@ class FixtureSim {
100116
this.devices.clear()
101117
this.requests.length = 0
102118
this.hits.clear()
119+
this.imported.length = 0
103120
this.enabled = true
104121
this.offline = false
105122
this.droppedWhileOffline = 0
@@ -156,12 +173,51 @@ class FixtureSim {
156173
}
157174
}
158175

176+
private async raw(request: IncomingMessage): Promise<Buffer> {
177+
const chunks: Buffer[] = []
178+
for await (const chunk of request) chunks.push(Buffer.from(chunk))
179+
return Buffer.concat(chunks)
180+
}
181+
159182
private async body(request: IncomingMessage): Promise<Record<string, unknown>> {
160-
let text = ''
161-
for await (const chunk of request) text += chunk.toString()
183+
const text = (await this.raw(request)).toString()
162184
return text ? (JSON.parse(text) as Record<string, unknown>) : {}
163185
}
164186

187+
/** Stores one entry of a claimed, running import, as Sim's import route does. */
188+
private async importEntry(
189+
url: URL,
190+
request: IncomingMessage,
191+
response: ServerResponse
192+
): Promise<void> {
193+
const query = Object.fromEntries(url.searchParams)
194+
const call = this.calls.get(query.toolCallId ?? '')
195+
if (
196+
request.method !== 'PUT' ||
197+
!call ||
198+
call.deviceId !== query.deviceId ||
199+
call.toolName !== 'import_local_files' ||
200+
call.token !== query.executionToken ||
201+
call.status !== 'running'
202+
) {
203+
this.json(response, 404, { error: 'Desktop import not found' })
204+
return
205+
}
206+
const content = await this.raw(request)
207+
const entry: ImportedEntry = {
208+
toolCallId: call.toolCallId,
209+
kind: query.kind ?? '',
210+
sourceName: query.sourceName ?? '',
211+
relativePath: query.relativePath ?? '',
212+
...(query.kind === 'file' ? { sha256: sha256(content), bytes: content.length } : {}),
213+
}
214+
this.imported.push(entry)
215+
this.json(response, 200, {
216+
id: `entry-${this.imported.length}`,
217+
name: entry.relativePath.split('/').at(-1) || entry.sourceName,
218+
})
219+
}
220+
165221
private json(response: ServerResponse, status: number, body: unknown): void {
166222
response.writeHead(status, { 'Content-Type': 'application/json' })
167223
response.end(JSON.stringify(body))
@@ -261,6 +317,10 @@ class FixtureSim {
261317
this.json(response, 200, { items })
262318
return
263319
}
320+
if (path === '/api/desktop/tool/import') {
321+
await this.importEntry(url, request, response)
322+
return
323+
}
264324
if (path.startsWith('/api/desktop/tool/')) {
265325
const body = await this.body(request)
266326
const call = this.calls.get(String(body.toolCallId))
@@ -585,6 +645,67 @@ test.describe('background executor', () => {
585645
})
586646
})
587647

648+
test('D: a folder import lands in Sim while the user is in another chat', async () => {
649+
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-d-'))
650+
const launched = await launch(userData)
651+
app = launched.app
652+
const deviceId = await registeredDevice()
653+
const source = join(userData, 'Reports')
654+
mkdirSync(join(source, 'q3'), { recursive: true })
655+
writeFileSync(join(source, 'notes.txt'), 'remember the numbers')
656+
// Larger than one 8 MB read, so the file crosses Electron in several chunks.
657+
const large = Buffer.alloc(9 * 1024 * 1024, 7)
658+
writeFileSync(join(source, 'q3', 'export.bin'), large)
659+
await launched.window.goto(`${sim.origin}/workspace/${WORKSPACE}/chat/${CHAT_C}`)
660+
661+
const call = sim.issue(deviceId, CHAT_B, 'import_local_files', {
662+
path: source,
663+
targetWorkspaceId: WORKSPACE,
664+
folderId: 'folder-e2e',
665+
})
666+
667+
await check('D: the import completes with every entry it stored', async () => {
668+
const completion = await settled(call, 60_000)
669+
expect(completion.status).toBe('success')
670+
expect(completion.data).toMatchObject({
671+
success: true,
672+
workspaceId: WORKSPACE,
673+
folders: [
674+
{ id: 'entry-1', relativePath: '' },
675+
{ id: 'entry-3', relativePath: 'q3' },
676+
],
677+
files: [
678+
{ id: 'entry-2', relativePath: 'notes.txt' },
679+
{ id: 'entry-4', relativePath: 'q3/export.bin' },
680+
],
681+
})
682+
})
683+
684+
await check('D: Sim received the tree with the bytes on disk, once', async () => {
685+
expect(sim.imported).toEqual([
686+
{ toolCallId: call, kind: 'directory', sourceName: 'Reports', relativePath: '' },
687+
{
688+
toolCallId: call,
689+
kind: 'file',
690+
sourceName: 'Reports',
691+
relativePath: 'notes.txt',
692+
sha256: sha256(Buffer.from('remember the numbers')),
693+
bytes: 20,
694+
},
695+
{ toolCallId: call, kind: 'directory', sourceName: 'Reports', relativePath: 'q3' },
696+
{
697+
toolCallId: call,
698+
kind: 'file',
699+
sourceName: 'Reports',
700+
relativePath: 'q3/export.bin',
701+
sha256: sha256(large),
702+
bytes: large.length,
703+
},
704+
])
705+
expect(sim.requireCall(call).claims).toBe(1)
706+
})
707+
})
708+
588709
test('E: Stop from another chat stops a running browser wait and terminal command', async () => {
589710
const userData = mkdtempSync(join(tmpdir(), 'sim-executor-e-'))
590711
app = (await launch(userData)).app

‎apps/desktop/src/main/desktop-executor/client.ts‎

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@ import {
1313
type DesktopCompletionRequest,
1414
type DesktopDeviceRegistration,
1515
type DesktopExecutorTiming,
16+
type DesktopImportEntryRequest,
17+
type DesktopImportedEntry,
1618
type DesktopInboxItem,
1719
parseClaim,
1820
parseCompletionOutcome,
21+
parseImportedEntry,
1922
parseInbox,
2023
parseRegistration,
2124
} from '@/main/desktop-executor/protocol'
2225

2326
const REQUEST_TIMEOUT_MS = 15_000
27+
/** An import entry carries up to a 64 MB file, so it gets longer than a control request. */
28+
const IMPORT_TIMEOUT_MS = 300_000
2429

2530
/** A request Sim answered with a failure, or that never got an answer (`status` 0). */
2631
export class DeviceRequestError extends Error {
@@ -54,6 +59,10 @@ export interface DesktopExecutorClient {
5459
claim(toolCallId: string): Promise<ClaimedDesktopCall>
5560
renewLease(toolCallId: string, executionToken: string): Promise<void>
5661
complete(request: DesktopCompletionRequest): Promise<DesktopCompletionOutcome>
62+
importEntry(
63+
request: DesktopImportEntryRequest,
64+
signal: AbortSignal
65+
): Promise<DesktopImportedEntry>
5766
}
5867

5968
interface DesktopExecutorClientOptions {
@@ -78,22 +87,26 @@ export function createDesktopExecutorClient(
7887
const { deviceId } = options
7988

8089
async function send(
81-
method: 'GET' | 'POST',
90+
method: 'GET' | 'POST' | 'PUT',
8291
path: string,
83-
body?: Record<string, unknown>,
84-
signal?: AbortSignal
92+
body?: Record<string, unknown> | Blob,
93+
signal?: AbortSignal,
94+
timeoutMs = REQUEST_TIMEOUT_MS
8595
): Promise<Response> {
86-
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
96+
const timeout = AbortSignal.timeout(timeoutMs)
97+
const raw = body instanceof Blob
8798
let response: Response
8899
try {
89100
response = await options.fetch(`${options.origin()}${path}`, {
90101
method,
91102
credentials: 'include',
92103
headers: {
93104
Accept: 'application/json',
94-
...(body ? { 'Content-Type': 'application/json' } : {}),
105+
...(body
106+
? { 'Content-Type': raw ? 'application/octet-stream' : 'application/json' }
107+
: {}),
95108
},
96-
...(body ? { body: JSON.stringify(body) } : {}),
109+
...(body ? { body: raw ? body : JSON.stringify(body) } : {}),
97110
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
98111
})
99112
} catch (error) {
@@ -184,5 +197,25 @@ export function createDesktopExecutorClient(
184197
if (!outcome) throw malformed('completion')
185198
return outcome
186199
},
200+
async importEntry({ call, kind, sourceName, relativePath, content }, signal) {
201+
const query = new URLSearchParams({
202+
deviceId,
203+
toolCallId: call.toolCallId,
204+
executionToken: call.executionToken,
205+
kind,
206+
sourceName,
207+
relativePath,
208+
})
209+
const response = await send(
210+
'PUT',
211+
`/api/desktop/tool/import?${query}`,
212+
content,
213+
signal,
214+
IMPORT_TIMEOUT_MS
215+
)
216+
const entry = parseImportedEntry(await response.json().catch(() => null))
217+
if (!entry) throw malformed('import')
218+
return entry
219+
},
187220
}
188221
}

‎apps/desktop/src/main/desktop-executor/executor.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ class FakeSim {
5959
openInboxStream: async () => {
6060
throw new Error('not used')
6161
},
62+
importEntry: async () => {
63+
throw new Error('not used')
64+
},
6265
claim: async (toolCallId) => {
6366
this.claims.push(toolCallId)
6467
const error = this.claimError?.(toolCallId)

‎apps/desktop/src/main/desktop-executor/protocol.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
* The device side of Sim's background executor protocol (`/api/desktop/devices`, `/inbox`,
3-
* `/inbox/stream`, `/tool/claim`, `/tool/lease`, `/tool/complete`). Sim's contracts are the
3+
* `/inbox/stream`, `/tool/claim`, `/tool/lease`, `/tool/complete`, `/tool/import`). Sim's contracts are the
44
* source of truth; responses are parsed defensively here because a malformed one must never
55
* reach a tool.
66
*/
@@ -62,6 +62,25 @@ export interface ClaimedDesktopCall {
6262

6363
export type DesktopCompletionOutcome = 'recorded' | 'duplicate' | 'superseded'
6464

65+
/**
66+
* One entry of a claimed import, stored under the call's target folder: `sourceName` is the
67+
* import source's own name and `relativePath` the entry's place inside it (`''` for the source).
68+
*/
69+
export interface DesktopImportEntryRequest {
70+
call: ClaimedDesktopCall
71+
kind: 'file' | 'directory'
72+
sourceName: string
73+
relativePath: string
74+
/** A file's bytes; a directory has none. */
75+
content?: Blob
76+
}
77+
78+
/** What Sim stored an import entry as: the file, or the folder it reused or created. */
79+
export interface DesktopImportedEntry {
80+
id: string
81+
name: string
82+
}
83+
6584
export interface DesktopCompletionRequest {
6685
toolCallId: string
6786
executionToken: string
@@ -175,3 +194,9 @@ export function parseCompletionOutcome(body: unknown): DesktopCompletionOutcome
175194
? body.outcome
176195
: null
177196
}
197+
198+
export function parseImportedEntry(body: unknown): DesktopImportedEntry | null {
199+
if (!isRecordLike(body) || typeof body.id !== 'string' || typeof body.name !== 'string')
200+
return null
201+
return body.id && body.name ? { id: body.id, name: body.name } : null
202+
}

0 commit comments

Comments
 (0)