Skip to content

Commit d22c09e

Browse files
committed
improvement(desktop): use shared helpers, confirm Redis MONITOR, stage desktop build on the workspace disk
1 parent af8a7fc commit d22c09e

3 files changed

Lines changed: 42 additions & 28 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -382,9 +382,9 @@ jobs:
382382
sudo apt-get update -q
383383
sudo apt-get install -yq xvfb libgtk-3-0t64 libnss3 libasound2t64 libgbm1 libxss1 \
384384
libxtst6 libatk-bridge2.0-0t64 libxkbcommon0 > /dev/null
385-
# Electron's Chromium sandbox needs unprivileged user namespaces, which Ubuntu 24.04 restricts.
386-
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
387-
(cd apps/desktop && bun run build)
385+
# The build stages node-pty prebuilds in TMPDIR and renames them into node_modules, so
386+
# TMPDIR must be on the workspace's filesystem.
387+
(cd apps/desktop && TMPDIR="$RUNNER_TEMP" bun run build)
388388
(cd apps/realtime && PORT=3023 SIM_DB_ROLE=realtime ALLOWED_ORIGINS="$NEXT_PUBLIC_APP_URL" \
389389
exec bun src/index.ts > "$report_dir/desktop-tools-realtime.log" 2>&1) &
390390
realtime_pid=$!

‎apps/desktop/e2e/desktop-tools-live-sim.spec.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { randomUUID } from 'node:crypto'
21
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
32
import { tmpdir } from 'node:os'
43
import { dirname, join } from 'node:path'
@@ -11,6 +10,7 @@ import {
1110
test,
1211
} from '@playwright/test'
1312
import type { SimDesktopApi } from '@sim/desktop-bridge'
13+
import { generateId } from '@sim/utils/id'
1414
import {
1515
type LiveSimConfig,
1616
liveSimConfig,
@@ -168,7 +168,7 @@ test.describe('desktop tools against a live Sim', () => {
168168

169169
test('leaving the chat does not end a local file read already under way', async () => {
170170
const user = await db.seedUser(['Read chat', 'Other chat'])
171-
const marker = randomUUID()
171+
const marker = generateId()
172172
const file = writeFile(join(scratch, 'notes.txt'), `notes from disk ${marker}`)
173173
let callId = ''
174174
agent.script('[leave-read]', (turn) => {
@@ -283,7 +283,7 @@ test.describe('desktop tools against a live Sim', () => {
283283

284284
test("a local read awaiting the user's approval cannot be claimed", async () => {
285285
const user = await db.seedUser(['Approval chat'])
286-
const secret = randomUUID()
286+
const secret = generateId()
287287
const file = writeFile(join(scratch, 'secret.txt'), `private ${secret}`)
288288
agent.script('[unapproved-read]', (turn) => {
289289
turn.toolCall({
@@ -365,9 +365,9 @@ test.describe('desktop tools against a live Sim', () => {
365365

366366
test('with the background executor off, a foreground desktop round trip never binds a device or rings a doorbell', async () => {
367367
const user = await db.seedUser(['Round trip'])
368-
const marker = randomUUID()
368+
const marker = generateId()
369369
const file = writeFile(join(scratch, 'plan.txt'), `plan ${marker}`)
370-
const deviceId = randomUUID()
370+
const deviceId = generateId()
371371
const monitor = new RedisMonitor(sim.redisUrl)
372372
await monitor.start()
373373
try {

‎apps/desktop/e2e/fixtures/live-sim.ts‎

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createHmac, randomUUID } from 'node:crypto'
1+
import { createHmac } from 'node:crypto'
22
import {
33
createServer,
44
request as httpRequest,
@@ -9,6 +9,8 @@ import {
99
} from 'node:http'
1010
import { connect, type Socket } from 'node:net'
1111
import type { Duplex } from 'node:stream'
12+
import { generateId, generateShortId } from '@sim/utils/id'
13+
import { toArray } from '@sim/utils/object'
1214
import postgres from 'postgres'
1315

1416
/**
@@ -47,7 +49,7 @@ export function liveSimConfig(): LiveSimConfig | string {
4749
].filter((name) => !env[name])
4850
if (missing.length > 0) return `Needs a local Sim app: set ${missing.join(', ')}`
4951
const databaseUrl = env.SIM_DESKTOP_E2E_DATABASE_URL ?? ''
50-
if (!/test/i.test(new URL(databaseUrl).pathname))
52+
if (!/(?:^|[_-])test$/i.test(new URL(databaseUrl).pathname.slice(1)))
5153
return 'SIM_DESKTOP_E2E_DATABASE_URL must name a dedicated test database'
5254
return {
5355
upstream: env.SIM_DESKTOP_E2E_SIM_URL ?? '',
@@ -330,7 +332,7 @@ class AgentTurn {
330332

331333
/** Issues a client-executed tool call and returns its id. */
332334
toolCall(call: ScriptedToolCall): string {
333-
const toolCallId = `toolu_${randomUUID().replaceAll('-', '')}`
335+
const toolCallId = `toolu_${generateShortId()}`
334336
this.toolCallIds.push(toolCallId)
335337
this.emit('tool', {
336338
phase: 'call',
@@ -349,9 +351,9 @@ class AgentTurn {
349351
pause(): void {
350352
this.emit('run', {
351353
kind: 'checkpoint_pause',
352-
checkpointId: randomUUID(),
353-
executionId: randomUUID(),
354-
runId: randomUUID(),
354+
checkpointId: generateId(),
355+
executionId: generateId(),
356+
runId: generateId(),
355357
pendingToolCallIds: [...this.toolCallIds],
356358
})
357359
this.finish()
@@ -452,7 +454,7 @@ export class ScriptedAgent {
452454
const body: Record<string, unknown> = raw ? JSON.parse(raw) : {}
453455
if (path === '/api/mothership' || path === '/api/copilot') {
454456
const message = typeof body.message === 'string' ? body.message : ''
455-
const streamId = typeof body.messageId === 'string' ? body.messageId : randomUUID()
457+
const streamId = typeof body.messageId === 'string' ? body.messageId : generateId()
456458
const turn = new AgentTurn(body, streamId, response)
457459
this.turns.push(turn)
458460
const entry = [...this.scripts].find(([marker]) => message.includes(marker))
@@ -465,7 +467,7 @@ export class ScriptedAgent {
465467
}
466468
if (path === '/api/tools/resume') {
467469
const streamId = typeof body.streamId === 'string' ? body.streamId : ''
468-
const results = Array.isArray(body.results) ? (body.results as ResumedResult[]) : []
470+
const results = toArray<ResumedResult>(body.results)
469471
const resume: Resume = { at: Date.now(), streamId, results }
470472
this.resumes.push(resume)
471473
for (const wake of this.waiters.splice(0)) wake()
@@ -518,25 +520,25 @@ export class SimDatabase {
518520

519521
/** A user with a workspace, a session as the desktop sign-in creates, and one chat per title. */
520522
async seedUser(chatTitles: string[]): Promise<SeededUser> {
521-
const userId = randomUUID()
522-
const workspaceId = randomUUID()
523-
const sessionId = randomUUID()
524-
const token = randomUUID().replaceAll('-', '')
523+
const userId = generateId()
524+
const workspaceId = generateId()
525+
const sessionId = generateId()
526+
const token = generateShortId()
525527
const name = `Desktop E2E ${userId.slice(0, 6)}`
526528
const email = `${userId}@desktop-tools-e2e.test`
527529
const chats: Record<string, string> = {}
528530
await this.sql.begin(async (tx) => {
529531
await tx`insert into "user" (id, name, email, normalized_email, email_verified, created_at, updated_at)
530532
values (${userId}, ${name}, ${email}, ${email}, true, now(), now())`
531-
await tx`insert into user_stats (id, user_id) values (${randomUUID()}, ${userId})`
533+
await tx`insert into user_stats (id, user_id) values (${generateId()}, ${userId})`
532534
await tx`insert into workspace (id, name, owner_id, billed_account_user_id)
533535
values (${workspaceId}, 'Desktop tools E2E', ${userId}, ${userId})`
534536
await tx`insert into permissions (id, user_id, entity_type, entity_id, permission_type)
535-
values (${randomUUID()}, ${userId}, 'workspace', ${workspaceId}, 'admin')`
537+
values (${generateId()}, ${userId}, 'workspace', ${workspaceId}, 'admin')`
536538
await tx`insert into session (id, token, user_id, user_agent, expires_at, created_at, updated_at)
537539
values (${sessionId}, ${token}, ${userId}, 'Sim Desktop', now() + interval '1 day', now(), now())`
538540
for (const title of chatTitles) {
539-
const chatId = randomUUID()
541+
const chatId = generateId()
540542
chats[title] = chatId
541543
await tx`insert into copilot_chats (id, user_id, workspace_id, type, title)
542544
values (${chatId}, ${userId}, ${workspaceId}, 'mothership', ${title})`
@@ -626,15 +628,27 @@ export class RedisMonitor {
626628
socket.once('connect', resolve)
627629
socket.once('error', reject)
628630
})
631+
// Each command sent before MONITOR answers `+OK`; MONITOR's own `+OK` means it is recording.
632+
let pendingAcks = target.password ? 2 : 1
629633
let buffer = ''
630-
socket.on('data', (chunk) => {
631-
buffer += chunk.toString('utf8')
632-
const lines = buffer.split('\r\n')
633-
buffer = lines.pop() ?? ''
634-
this.lines.push(...lines.filter((line) => line.startsWith('+') && line !== '+OK'))
634+
const acknowledged = new Promise<void>((resolve, reject) => {
635+
socket.on('data', (chunk) => {
636+
buffer += chunk.toString('utf8')
637+
const lines = buffer.split('\r\n')
638+
buffer = lines.pop() ?? ''
639+
for (const line of lines) {
640+
if (pendingAcks > 0) {
641+
if (line.startsWith('-')) reject(new Error(`Redis refused MONITOR: ${line}`))
642+
else if (line === '+OK' && --pendingAcks === 0) resolve()
643+
continue
644+
}
645+
if (line.startsWith('+')) this.lines.push(line)
646+
}
647+
})
635648
})
636649
if (target.password) socket.write(`AUTH ${decodeURIComponent(target.password)}\r\n`)
637650
socket.write('MONITOR\r\n')
651+
await acknowledged
638652
}
639653

640654
/** Commands seen that publish to a channel whose name contains `channel`. */

0 commit comments

Comments
 (0)