Skip to content

Commit 577b402

Browse files
authored
fix(chat): scope troubleshoot handoff to its workspace (#5344)
* fix(chat): scope troubleshoot handoff to its workspace MothershipHandoffStorage now records the target workspaceId and only the matching workspace consumes (and clears) it; a different workspace leaves it untouched for its owner. Prevents a workspace-A handoff from firing in workspace B, where A's executionId can't resolve and the run context is dropped. * test(chat): cover legacy no-workspaceId handoff tombstoning
1 parent 4df352f commit 577b402

4 files changed

Lines changed: 73 additions & 25 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/home.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,9 +343,9 @@ export function Home({ chatId, userName, userId }: HomeProps) {
343343
*/
344344
useEffect(() => {
345345
if (chatId) return
346-
const handoff = MothershipHandoffStorage.consume()
346+
const handoff = MothershipHandoffStorage.consume(workspaceId)
347347
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
348-
}, [chatId, sendMessage])
348+
}, [chatId, workspaceId, sendMessage])
349349

350350
function resolveResourceFromContext(
351351
context: ChatContext

apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
418418
? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.`
419419
: 'This workflow run failed. Investigate the error in this run and help me fix it.'
420420
if (sendMothershipMessage(message, [context])) return
421-
if (MothershipHandoffStorage.store({ message, contexts: [context] })) {
421+
if (MothershipHandoffStorage.store({ message, contexts: [context] }, workspaceId)) {
422422
router.push(`/workspace/${workspaceId}/home`)
423423
}
424424
}, [log.executionId, log.workflow?.name, workspaceId, router])

apps/sim/lib/core/utils/browser-storage.test.ts

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,46 +5,81 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import { MothershipHandoffStorage, STORAGE_KEYS } from '@/lib/core/utils/browser-storage'
66
import type { ChatContext } from '@/stores/panel'
77

8+
const WS = 'ws-1'
9+
810
describe('MothershipHandoffStorage', () => {
911
beforeEach(() => {
1012
localStorage.clear()
1113
})
1214

1315
it('round-trips a handoff and trims the message, preserving contexts', () => {
1416
const contexts: ChatContext[] = [{ kind: 'logs', executionId: 'run-1', label: 'My Flow' }]
15-
expect(MothershipHandoffStorage.store({ message: ' fix it ', contexts })).toBe(true)
17+
expect(MothershipHandoffStorage.store({ message: ' fix it ', contexts }, WS)).toBe(true)
1618

17-
expect(MothershipHandoffStorage.consume()).toEqual({ message: 'fix it', contexts })
19+
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts })
1820
})
1921

2022
it('is one-shot — a second consume returns null', () => {
21-
MothershipHandoffStorage.store({ message: 'fix it' })
23+
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
24+
25+
expect(MothershipHandoffStorage.consume(WS)).not.toBeNull()
26+
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
27+
})
2228

23-
expect(MothershipHandoffStorage.consume()).not.toBeNull()
24-
expect(MothershipHandoffStorage.consume()).toBeNull()
29+
it('refuses to store without a message or workspace', () => {
30+
expect(MothershipHandoffStorage.store({ message: ' ' }, WS)).toBe(false)
31+
expect(MothershipHandoffStorage.store({ message: 'fix it' }, '')).toBe(false)
32+
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
2533
})
2634

27-
it('refuses to store an empty message', () => {
28-
expect(MothershipHandoffStorage.store({ message: ' ' })).toBe(false)
29-
expect(MothershipHandoffStorage.consume()).toBeNull()
35+
it('leaves a handoff owned by another workspace untouched for its owner', () => {
36+
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
37+
38+
// A different workspace must not claim it, and must not clear it.
39+
expect(MothershipHandoffStorage.consume('ws-other')).toBeNull()
40+
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).not.toBeNull()
41+
42+
// The owning workspace still consumes it.
43+
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: undefined })
3044
})
3145

3246
it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => {
33-
localStorage.setItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF, JSON.stringify({ message: 'fix it' }))
47+
localStorage.setItem(
48+
STORAGE_KEYS.MOTHERSHIP_HANDOFF,
49+
JSON.stringify({ message: 'fix it', workspaceId: WS })
50+
)
3451

35-
expect(MothershipHandoffStorage.consume()).toBeNull()
52+
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
3653
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
3754
})
3855

56+
it('tombstones a legacy entry (message + timestamp, no workspaceId) rather than firing it', () => {
57+
// The old pre-scoping format could be sitting in storage across a deploy —
58+
// it must be discarded, not attributed to the current workspace.
59+
vi.useFakeTimers()
60+
try {
61+
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
62+
localStorage.setItem(
63+
STORAGE_KEYS.MOTHERSHIP_HANDOFF,
64+
JSON.stringify({ message: 'fix it', timestamp: Date.now() })
65+
)
66+
67+
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
68+
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
69+
} finally {
70+
vi.useRealTimers()
71+
}
72+
})
73+
3974
it('drops and clears a handoff older than maxAge', () => {
4075
vi.useFakeTimers()
4176
try {
4277
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
43-
MothershipHandoffStorage.store({ message: 'fix it' })
78+
MothershipHandoffStorage.store({ message: 'fix it' }, WS)
4479

4580
vi.advanceTimersByTime(61 * 1000)
4681

47-
expect(MothershipHandoffStorage.consume()).toBeNull()
82+
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
4883
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
4984
} finally {
5085
vi.useRealTimers()

apps/sim/lib/core/utils/browser-storage.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -320,43 +320,56 @@ export class MothershipHandoffStorage {
320320
private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF
321321

322322
/**
323-
* Store a handoff to be auto-sent on the next home-surface mount.
324-
* @returns True if stored, false when the message is empty.
323+
* Store a handoff to be auto-sent on the next home-surface mount, scoped to
324+
* the workspace it targets so a different workspace never claims it.
325+
* @returns True if stored, false when the message or workspace is empty.
325326
*/
326-
static store(handoff: MothershipHandoff): boolean {
327+
static store(handoff: MothershipHandoff, workspaceId: string): boolean {
327328
const message = handoff.message.trim()
328-
if (!message) {
329+
if (!message || !workspaceId) {
329330
return false
330331
}
331332

332333
return BrowserStorage.setItem(MothershipHandoffStorage.KEY, {
333334
message,
334335
contexts: handoff.contexts,
336+
workspaceId,
335337
timestamp: Date.now(),
336338
})
337339
}
338340

339341
/**
340-
* Retrieve and consume the stored handoff. Clears the entry as soon as one
341-
* exists — before the validity and expiry checks — so a malformed or expired
342-
* handoff is tombstoned rather than lingering across future mounts, and a
343-
* valid handoff fires exactly once.
342+
* Retrieve and consume the stored handoff for `workspaceId`. A handoff owned
343+
* by a different workspace is left untouched for its owner — its tagged run
344+
* only resolves in its own workspace, so misfiring it elsewhere would drop the
345+
* context. The owner (and any legacy/corrupt entry) is tombstoned via `clear`
346+
* before the validity/expiry checks so it fires at most once and never lingers.
344347
* @param maxAge - Maximum age in milliseconds (default: 60 seconds)
345348
*/
346-
static consume(maxAge: number = 60 * 1000): MothershipHandoff | null {
349+
static consume(workspaceId: string, maxAge: number = 60 * 1000): MothershipHandoff | null {
347350
const data = BrowserStorage.getItem<{
348351
message?: string
349352
contexts?: ChatContext[]
353+
workspaceId?: string
350354
timestamp?: number
351355
} | null>(MothershipHandoffStorage.KEY, null)
352356

353357
if (!data) {
354358
return null
355359
}
356360

361+
if (data.workspaceId && data.workspaceId !== workspaceId) {
362+
return null
363+
}
364+
357365
MothershipHandoffStorage.clear()
358366

359-
if (!data.message || !data.timestamp || Date.now() - data.timestamp > maxAge) {
367+
if (
368+
!data.workspaceId ||
369+
!data.message ||
370+
!data.timestamp ||
371+
Date.now() - data.timestamp > maxAge
372+
) {
360373
return null
361374
}
362375

0 commit comments

Comments
 (0)