Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/home/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -343,9 +343,9 @@ export function Home({ chatId, userName, userId }: HomeProps) {
*/
useEffect(() => {
if (chatId) return
const handoff = MothershipHandoffStorage.consume()
const handoff = MothershipHandoffStorage.consume(workspaceId)
if (handoff) sendMessage(handoff.message, undefined, handoff.contexts)
}, [chatId, sendMessage])
}, [chatId, workspaceId, sendMessage])

function resolveResourceFromContext(
context: ChatContext
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ export function LogDetailsContent({ log, onActiveTabChange }: LogDetailsContentP
? `The "${workflowName}" workflow run failed. Investigate the error in this run and help me fix it.`
: 'This workflow run failed. Investigate the error in this run and help me fix it.'
if (sendMothershipMessage(message, [context])) return
if (MothershipHandoffStorage.store({ message, contexts: [context] })) {
if (MothershipHandoffStorage.store({ message, contexts: [context] }, workspaceId)) {
router.push(`/workspace/${workspaceId}/home`)
}
}, [log.executionId, log.workflow?.name, workspaceId, router])
Expand Down
59 changes: 47 additions & 12 deletions apps/sim/lib/core/utils/browser-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,46 +5,81 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { MothershipHandoffStorage, STORAGE_KEYS } from '@/lib/core/utils/browser-storage'
import type { ChatContext } from '@/stores/panel'

const WS = 'ws-1'

describe('MothershipHandoffStorage', () => {
beforeEach(() => {
localStorage.clear()
})

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

expect(MothershipHandoffStorage.consume()).toEqual({ message: 'fix it', contexts })
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts })
})

it('is one-shot — a second consume returns null', () => {
MothershipHandoffStorage.store({ message: 'fix it' })
MothershipHandoffStorage.store({ message: 'fix it' }, WS)

expect(MothershipHandoffStorage.consume(WS)).not.toBeNull()
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
})

expect(MothershipHandoffStorage.consume()).not.toBeNull()
expect(MothershipHandoffStorage.consume()).toBeNull()
it('refuses to store without a message or workspace', () => {
expect(MothershipHandoffStorage.store({ message: ' ' }, WS)).toBe(false)
expect(MothershipHandoffStorage.store({ message: 'fix it' }, '')).toBe(false)
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
})

it('refuses to store an empty message', () => {
expect(MothershipHandoffStorage.store({ message: ' ' })).toBe(false)
expect(MothershipHandoffStorage.consume()).toBeNull()
it('leaves a handoff owned by another workspace untouched for its owner', () => {
MothershipHandoffStorage.store({ message: 'fix it' }, WS)

// A different workspace must not claim it, and must not clear it.
expect(MothershipHandoffStorage.consume('ws-other')).toBeNull()
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).not.toBeNull()

// The owning workspace still consumes it.
expect(MothershipHandoffStorage.consume(WS)).toEqual({ message: 'fix it', contexts: undefined })
})

it('tombstones a corrupted entry (missing timestamp) instead of leaving it forever', () => {
localStorage.setItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF, JSON.stringify({ message: 'fix it' }))
localStorage.setItem(
STORAGE_KEYS.MOTHERSHIP_HANDOFF,
JSON.stringify({ message: 'fix it', workspaceId: WS })
)

expect(MothershipHandoffStorage.consume()).toBeNull()
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
})
Comment thread
waleedlatif1 marked this conversation as resolved.

it('tombstones a legacy entry (message + timestamp, no workspaceId) rather than firing it', () => {
// The old pre-scoping format could be sitting in storage across a deploy —
// it must be discarded, not attributed to the current workspace.
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
localStorage.setItem(
STORAGE_KEYS.MOTHERSHIP_HANDOFF,
JSON.stringify({ message: 'fix it', timestamp: Date.now() })
)

expect(MothershipHandoffStorage.consume(WS)).toBeNull()
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
} finally {
vi.useRealTimers()
}
})

it('drops and clears a handoff older than maxAge', () => {
vi.useFakeTimers()
try {
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'))
MothershipHandoffStorage.store({ message: 'fix it' })
MothershipHandoffStorage.store({ message: 'fix it' }, WS)

vi.advanceTimersByTime(61 * 1000)

expect(MothershipHandoffStorage.consume()).toBeNull()
expect(MothershipHandoffStorage.consume(WS)).toBeNull()
expect(localStorage.getItem(STORAGE_KEYS.MOTHERSHIP_HANDOFF)).toBeNull()
} finally {
vi.useRealTimers()
Expand Down
33 changes: 23 additions & 10 deletions apps/sim/lib/core/utils/browser-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,43 +320,56 @@ export class MothershipHandoffStorage {
private static readonly KEY = STORAGE_KEYS.MOTHERSHIP_HANDOFF

/**
* Store a handoff to be auto-sent on the next home-surface mount.
* @returns True if stored, false when the message is empty.
* Store a handoff to be auto-sent on the next home-surface mount, scoped to
* the workspace it targets so a different workspace never claims it.
* @returns True if stored, false when the message or workspace is empty.
*/
static store(handoff: MothershipHandoff): boolean {
static store(handoff: MothershipHandoff, workspaceId: string): boolean {
const message = handoff.message.trim()
if (!message) {
if (!message || !workspaceId) {
return false
}

return BrowserStorage.setItem(MothershipHandoffStorage.KEY, {
message,
contexts: handoff.contexts,
workspaceId,
timestamp: Date.now(),
})
}

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

if (!data) {
return null
}

if (data.workspaceId && data.workspaceId !== workspaceId) {
return null
}

MothershipHandoffStorage.clear()

if (!data.message || !data.timestamp || Date.now() - data.timestamp > maxAge) {
if (
!data.workspaceId ||
!data.message ||
!data.timestamp ||
Date.now() - data.timestamp > maxAge
) {
return null
}

Expand Down
Loading