Skip to content

Commit 8ad3e31

Browse files
committed
fix(mothership): preserve resource effects and execution recovery
1 parent 08c8579 commit 8ad3e31

51 files changed

Lines changed: 1480 additions & 462 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/copilot/chat/stop/route.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('copilot chat stop route', () => {
5454
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
5555
})
5656

57-
it('preserves task, plan and subagent identity through the partial-response contract', async () => {
57+
it('preserves task and subagent identity through the partial-response contract', async () => {
5858
mockReads({
5959
chat: { workspaceId: 'ws-1', conversationId: 'stream-1', model: null },
6060
last: { messageId: 'stream-1', role: 'user' },
@@ -70,7 +70,6 @@ describe('copilot chat stop route', () => {
7070
note: 'Watch invoice run',
7171
},
7272
},
73-
{ type: 'plan', planItems: [{ step: 'Check the result', status: 'active' }] },
7473
{
7574
type: 'span',
7675
kind: 'subagent',

apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('execution stream reconnect route', () => {
122122
expect(mockReadExecutionEventsState).toHaveBeenNthCalledWith(2, 'exec-1', 3)
123123
})
124124

125-
it('fails closed when terminal metadata has no terminal event to replay', async () => {
125+
it('leaves a missing terminal event as a transport error rather than a workflow failure', async () => {
126126
mockReadExecutionMetaState
127127
.mockResolvedValueOnce({
128128
status: 'found',
@@ -147,11 +147,9 @@ describe('execution stream reconnect route', () => {
147147
})
148148

149149
expect(response.status).toBe(200)
150-
const body = await response.text()
151-
152-
expect(body).toContain('"type":"execution:error"')
153-
expect(body).toContain('its final event could not be recovered')
154-
expect(body).toContain('data: [DONE]')
150+
await expect(response.text()).rejects.toThrow(
151+
'Execution terminal event is no longer available in the replay buffer'
152+
)
155153
})
156154

157155
it('allows replay event id gaps from reserved but unused writer ids', async () => {

apps/sim/app/api/workflows/[id]/executions/[executionId]/stream/route.ts

Lines changed: 3 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -230,21 +230,9 @@ export const GET = withRouteHandler(
230230

231231
const closeAfterTerminalEvent = (events: ExecutionEventEntry[]) => {
232232
if (!enqueueEvents(events)) {
233-
logger.warn('Execution reached terminal metadata without a terminal event', {
234-
executionId,
235-
})
236-
enqueue(
237-
formatSSEEvent({
238-
type: 'execution:error',
239-
timestamp: new Date().toISOString(),
240-
executionId,
241-
workflowId,
242-
data: {
243-
error:
244-
'Execution reached a terminal state, but its final event could not be recovered',
245-
duration: 0,
246-
},
247-
})
233+
/** The reconnect client resolves missing delivery from the durable status resource. */
234+
throw new Error(
235+
'Execution terminal event is no longer available in the replay buffer'
248236
)
249237
}
250238
closeWithDone()

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist/plan-checklist.tsx

Lines changed: 0 additions & 49 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from 'react'
1313
import { cn } from '@sim/emcn'
1414
import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1'
15-
import type { AgentPlanItem, TaskBlockInfo } from '@/lib/mothership/request/types'
15+
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
1616
import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools'
1717
import { resolveToolDisplay } from '@/lib/mothership/tools/client/store-utils'
1818
import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state'
@@ -22,7 +22,6 @@ import {
2222
humanizeToolName,
2323
} from '@/lib/mothership/tools/tool-display'
2424
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
25-
import { PlanChecklist } from '@/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist'
2625
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
2726
import { TaskPill } from '@/app/workspace/[workspaceId]/home/components/message-content/components/task-pill'
2827
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
@@ -78,11 +77,6 @@ interface StoppedSegment {
7877
type: 'stopped'
7978
}
8079

81-
interface PlanSegment {
82-
type: 'plan'
83-
items: AgentPlanItem[]
84-
}
85-
8680
interface TaskSegment {
8781
type: 'task'
8882
task: TaskBlockInfo
@@ -93,7 +87,6 @@ type MessageSegment =
9387
| AgentGroupSegment
9488
| OptionsSegment
9589
| StoppedSegment
96-
| PlanSegment
9790
| TaskSegment
9891

9992
function getAgentGroupActivityKey(items: AgentGroupItem[]): string {
@@ -135,9 +128,6 @@ function getVisibleStreamActivityKey(segments: MessageSegment[]): string {
135128
return `options:${segment.items.map((item) => `${item.id}:${item.label.length}`).join(',')}`
136129
}
137130
if (segment.type === 'stopped') return 'stopped'
138-
if (segment.type === 'plan') {
139-
return `plan:${segment.items.map((item) => `${item.status}:${item.step.length}`).join(',')}`
140-
}
141131
if (segment.type === 'task') {
142132
return `task:${segment.task.taskId}:${segment.task.status ?? 'pending'}`
143133
}
@@ -489,12 +479,6 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
489479
continue
490480
}
491481

492-
if (block.type === 'plan') {
493-
if (!block.planItems?.length) continue
494-
segments.push({ type: 'plan', items: block.planItems })
495-
continue
496-
}
497-
498482
if (block.type === 'task') {
499483
if (!block.task) continue
500484
segments.push({ type: 'task', task: block.task })
@@ -759,13 +743,6 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
759743
continue
760744
}
761745

762-
if (block.type === 'plan') {
763-
if (!block.planItems?.length) continue
764-
flushLanes()
765-
segments.push({ type: 'plan', items: block.planItems })
766-
continue
767-
}
768-
769746
if (block.type === 'task') {
770747
if (!block.task) continue
771748
flushLanes()
@@ -1107,8 +1084,6 @@ function MessageContentInner({
11071084
<Options items={segment.items} onSelect={onOptionSelect} />
11081085
</div>
11091086
)
1110-
case 'plan':
1111-
return <PlanChecklist key={`plan-${i}`} items={segment.items} />
11121087
case 'task':
11131088
return <TaskPill key={`task-${segment.task.taskId}`} task={segment.task} />
11141089
// The stopped row renders in the tail region below, in the

apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1+
export { invalidateResourceQueries } from './resource-invalidation'
12
export type { ResourceTypeConfig } from './resource-registry'
23
export {
34
byResourceMenuOrder,
45
getResourceConfig,
5-
invalidateResourceQueries,
66
MENTION_PREVIEW_DEFAULT_LIMIT,
77
RESOURCE_MENU_ORDER,
88
RESOURCE_REGISTRY,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/** @vitest-environment node */
2+
import { QueryClient } from '@tanstack/react-query'
3+
import { describe, expect, it } from 'vitest'
4+
import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-invalidation'
5+
import { deploymentKeys } from '@/hooks/queries/deployments'
6+
import { connectorDocumentKeys, connectorKeys } from '@/hooks/queries/kb/connectors'
7+
import { logKeys } from '@/hooks/queries/logs'
8+
import { mothershipChatKeys } from '@/hooks/queries/mothership-chats'
9+
import { folderKeys } from '@/hooks/queries/utils/folder-keys'
10+
import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys'
11+
import { tableKeys } from '@/hooks/queries/utils/table-keys'
12+
import { workflowKeys } from '@/hooks/queries/utils/workflow-keys'
13+
import { workspaceFilesKeys } from '@/hooks/queries/workspace-files'
14+
15+
describe('resource cache reconciliation', () => {
16+
it('refreshes table rows, schema and saved views without expiring another table', () => {
17+
const client = new QueryClient()
18+
const affected = [
19+
tableKeys.detail('t'),
20+
tableKeys.views('t'),
21+
tableKeys.infiniteRows('t', 'filter'),
22+
]
23+
for (const key of [...affected, tableKeys.views('other')]) client.setQueryData(key, {})
24+
invalidateResourceQueries(client, 'w', 'table', 't')
25+
for (const key of affected) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
26+
expect(client.getQueryState(tableKeys.views('other'))?.isInvalidated).toBe(false)
27+
})
28+
29+
it('covers knowledge documents, chunks, tags and connectors through the parent key', () => {
30+
const client = new QueryClient()
31+
const affected = [
32+
knowledgeKeys.documents('kb', 'page'),
33+
knowledgeKeys.chunks('kb', 'document', 'page'),
34+
knowledgeKeys.tagDefinitions('kb'),
35+
connectorKeys.detail('kb', 'connector'),
36+
connectorDocumentKeys.list('kb', 'connector', true),
37+
]
38+
for (const key of affected) client.setQueryData(key, {})
39+
invalidateResourceQueries(client, 'w', 'knowledgebase', 'kb')
40+
for (const key of affected) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
41+
})
42+
43+
it('invalidates stored workflow state and file contents, including collection refreshes', () => {
44+
const client = new QueryClient()
45+
const workflow = workflowKeys.state('wf')
46+
const file = workspaceFilesKeys.contentFile('w', 'file')
47+
client.setQueryData(workflow, {})
48+
client.setQueryData(file, {})
49+
invalidateResourceQueries(client, 'w', 'workflow', 'wf')
50+
invalidateResourceQueries(client, 'w', 'file')
51+
expect(client.getQueryState(workflow)?.isInvalidated).toBe(true)
52+
expect(client.getQueryState(file)?.isInvalidated).toBe(true)
53+
})
54+
55+
it('updates workflow publication metadata shown beside the draft', () => {
56+
const client = new QueryClient()
57+
const affected = [
58+
deploymentKeys.info('wf'),
59+
deploymentKeys.deployedState('wf'),
60+
deploymentKeys.versions('wf'),
61+
deploymentKeys.chatStatus('wf'),
62+
]
63+
for (const key of affected) client.setQueryData(key, {})
64+
invalidateResourceQueries(client, 'w', 'workflow', 'wf')
65+
for (const key of affected) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
66+
})
67+
68+
it('refreshes every folder family and both task navigation scopes', () => {
69+
const client = new QueryClient()
70+
const affected = [
71+
folderKeys.list('w', 'active', 'workflow'),
72+
folderKeys.list('w', 'active', 'table'),
73+
folderKeys.list('w', 'archived', 'knowledge_base'),
74+
mothershipChatKeys.list('w', 'active'),
75+
mothershipChatKeys.list('w', 'archived'),
76+
mothershipChatKeys.detail('chat'),
77+
]
78+
for (const key of affected) client.setQueryData(key, {})
79+
invalidateResourceQueries(client, 'w', 'folder')
80+
invalidateResourceQueries(client, 'w', 'task', 'chat')
81+
for (const key of affected) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
82+
})
83+
84+
it('leaves desktop and ephemeral panels under their existing owners', () => {
85+
const client = new QueryClient()
86+
client.setQueryData(tableKeys.detail('t'), {})
87+
for (const type of ['browser', 'terminal', 'generic'] as const)
88+
invalidateResourceQueries(client, 'w', type)
89+
expect(client.getQueryState(tableKeys.detail('t'))?.isInvalidated).toBe(false)
90+
})
91+
92+
it('refreshes log navigation and execution lookup while preserving other workspaces', () => {
93+
const client = new QueryClient()
94+
const affected = [
95+
[...logKeys.lists(), 'w', { workflowIds: ['workflow'] }],
96+
logKeys.stat('w', {}),
97+
logKeys.detail('w', 'row'),
98+
logKeys.byExecution('w', 'execution'),
99+
]
100+
const unrelated = [logKeys.detail('other', 'row'), logKeys.byExecution('other', 'execution')]
101+
for (const key of [...affected, ...unrelated]) client.setQueryData(key, {})
102+
invalidateResourceQueries(client, 'w', 'log', 'row')
103+
for (const key of affected) expect(client.getQueryState(key)?.isInvalidated).toBe(true)
104+
for (const key of unrelated) expect(client.getQueryState(key)?.isInvalidated).toBe(false)
105+
})
106+
})

0 commit comments

Comments
 (0)