Skip to content

Commit eaec7be

Browse files
committed
chore(release): merge staging into the v0.9.42 release
2 parents 0ba2186 + 32af8bc commit eaec7be

8 files changed

Lines changed: 106 additions & 21 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/home/components/chat-plan-modal/chat-plan-modal.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useRouter } from 'next/navigation'
1212
import { CHAT_PLAN_REQUIRED_UPGRADE } from '@/lib/billing/chat-plan'
1313
import { rememberSettingsReturnUrl } from '@/lib/navigation/settings-return'
1414
import { usePlanUpgradeLink } from '@/app/workspace/[workspaceId]/home/hooks/use-plan-upgrade-link'
15+
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'
1516
import { useChatPlanModalStore } from '@/stores/modals/chat-plan/store'
1617

1718
/**
@@ -20,7 +21,8 @@ import { useChatPlanModalStore } from '@/stores/modals/chat-plan/store'
2021
* leaving the shell closes it, so a refusal never reopens in the next workspace or organization.
2122
*/
2223
export function ChatPlanModal() {
23-
const isOpen = useChatPlanModalStore((state) => state.isOpen)
24+
const workspaceId = useOptionalWorkspaceHostContext()?.workspace.id
25+
const isOpen = useChatPlanModalStore((state) => state.workspaceId === workspaceId)
2426
const close = useChatPlanModalStore((state) => state.close)
2527

2628
useEffect(() => close, [close])

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
533533

534534
const handleSubmit = useCallback(() => {
535535
if (chatPlanRequired) {
536-
useChatPlanModalStore.getState().open()
536+
useChatPlanModalStore.getState().open(workspaceId)
537537
return
538538
}
539539
const currentFiles = filesRef.current
@@ -561,7 +561,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
561561
)
562562
if (accepted === false) return
563563
clearComposer()
564-
}, [chatPlanRequired, onSubmit, clearComposer])
564+
}, [chatPlanRequired, workspaceId, onSubmit, clearComposer])
565565

566566
/**
567567
* Enter policy for the editor: mirror canSubmit's uploading guard (Enter

‎apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4443,7 +4443,7 @@ export function useChat(
44434443
) => {
44444444
if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return
44454445
if (chatPlanRequired) {
4446-
useChatPlanModalStore.getState().open()
4446+
useChatPlanModalStore.getState().open(workspaceId)
44474447
return
44484448
}
44494449

@@ -5307,7 +5307,7 @@ export function useChat(
53075307
if (!msg || queueState.editing[chatKey] === msg.id) return
53085308
if (queuedMessageDispatchIds.has(msg.id)) return
53095309
if (chatPlanRequired) {
5310-
useChatPlanModalStore.getState().open()
5310+
useChatPlanModalStore.getState().open(workspaceId)
53115311
return
53125312
}
53135313
/* Sent by hand, it still must not run a second turn: if the history shows it

‎apps/sim/lib/execution/remote-sandbox/index.ts‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -327,10 +327,15 @@ async function throwIfRunTimedOut(
327327
if (!result.timedOut) return
328328
if (!run.lease.session) throw new DOMException('timeout', 'AbortError')
329329
const { outputSandboxDir, sandbox } = run
330+
// Half of what the call has left, so the report lands before the call's deadline.
331+
const listingMs = Math.min(
332+
TIMED_OUT_OUTPUT_LISTING_MS,
333+
(getRemainingExecutionMs(run.signal) ?? Number.POSITIVE_INFINITY) / 2
334+
)
330335
const listed = outputSandboxDir
331336
? await withinDeadline(
332337
() => sandbox.listFiles(outputSandboxDir, { depth: SANDBOX_OUTPUT_DIR_MAX_DEPTH }),
333-
Date.now() + TIMED_OUT_OUTPUT_LISTING_MS,
338+
Date.now() + listingMs,
334339
run.signal
335340
).catch(() => [])
336341
: []
@@ -908,21 +913,32 @@ function installBudgetMs(timeoutMs: number): number {
908913
*/
909914
const MIN_EXPORT_BUDGET_MS = 10_000
910915

916+
/**
917+
* Held back from a workbench run without an export reserve, capped at a quarter of its budget,
918+
* so the run's own timeout answers and reports its progress before the call's deadline.
919+
*/
920+
const MAX_TIMEOUT_REPORT_BUDGET_MS = 4_000
921+
911922
/**
912923
* The budget handed to user code, less an export reserve when this request will
913924
* read files back. Short budgets are left alone: taking the reserve out of one
914-
* would starve the code to buy time for an export it never reaches.
925+
* would starve the code to buy time for an export it never reaches. A workbench
926+
* run, whose timeout reports progress, still keeps a smaller report reserve.
915927
*/
916928
function codeBudgetMs(
917929
req: { outputSandboxPath?: string; outputSandboxPaths?: string[]; outputSandboxDir?: string },
918-
signal: AbortSignal
930+
signal: AbortSignal,
931+
reportsTimeout: boolean
919932
): number {
920933
const remainingMs = remainingSandboxBudgetMs(signal)
921934
const exportsFiles = Boolean(
922935
req.outputSandboxDir || req.outputSandboxPath || req.outputSandboxPaths?.length
923936
)
924-
if (!exportsFiles || remainingMs <= MIN_EXPORT_BUDGET_MS * 2) return remainingMs
925-
return remainingMs - MIN_EXPORT_BUDGET_MS
937+
if (exportsFiles && remainingMs > MIN_EXPORT_BUDGET_MS * 2) {
938+
return remainingMs - MIN_EXPORT_BUDGET_MS
939+
}
940+
if (!reportsTimeout) return remainingMs
941+
return remainingMs - Math.min(MAX_TIMEOUT_REPORT_BUDGET_MS, Math.floor(remainingMs / 4))
926942
}
927943

928944
/**
@@ -1060,7 +1076,7 @@ async function executeInSandboxWithinBudget(
10601076
signal,
10611077
phases,
10621078
(verifyCli) => {
1063-
runTimeoutMs = codeBudgetMs(req, signal)
1079+
runTimeoutMs = codeBudgetMs(req, signal, Boolean(lease.session))
10641080
return sandbox.runCode(code, {
10651081
timeoutMs: runTimeoutMs,
10661082
javascriptPreload: buildJavaScriptRuntimeBindingsSource(req.runtimeBindings ?? []),
@@ -1283,7 +1299,7 @@ async function executeShellInSandboxWithinBudget(
12831299
signal,
12841300
phases,
12851301
(verifyCli) => {
1286-
runTimeoutMs = codeBudgetMs(req, signal)
1302+
runTimeoutMs = codeBudgetMs(req, signal, Boolean(lease.session))
12871303
return sandbox.runCommand(code, {
12881304
envs: commandEnvironment,
12891305
timeoutMs: runTimeoutMs,

‎apps/sim/lib/execution/remote-sandbox/session-sandbox.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,6 +1130,43 @@ describe('session sandbox lease', () => {
11301130
}
11311131
})
11321132

1133+
it('reports the output of a short run whose own timeout answers after a round trip', async () => {
1134+
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
1135+
try {
1136+
const { handle } = fakeSandbox('timeout-short')
1137+
mockFindSessionSandbox.mockResolvedValue(handle)
1138+
// The provider answers its own timeout one round trip late, and stops on the call's abort.
1139+
handle.runCommand = (_command, options) =>
1140+
new Promise((resolve, reject) => {
1141+
const timer = setTimeout(
1142+
() => resolve({ stdout: 'step 1', stderr: '', exitCode: 124, timedOut: true }),
1143+
(options?.timeoutMs ?? 0) + 300
1144+
)
1145+
options?.signal?.addEventListener('abort', () => {
1146+
clearTimeout(timer)
1147+
reject(options.signal?.reason)
1148+
})
1149+
})
1150+
handle.listFiles = () => new Promise<never>(() => {})
1151+
const pending = executeShellInSandbox({
1152+
...CODE_REQUEST,
1153+
timeoutMs: 10_000,
1154+
envs: {},
1155+
session: { key: 'timeout-short' },
1156+
outputSandboxDir: OUTPUT_DIR,
1157+
}).then(
1158+
() => undefined,
1159+
(rejection: unknown) => rejection
1160+
)
1161+
await vi.runAllTimersAsync()
1162+
const error = await pending
1163+
if (!(error instanceof SandboxExecutionTimeoutError)) throw error
1164+
expect(error.progress.output).toBe('step 1')
1165+
} finally {
1166+
vi.useRealTimers()
1167+
}
1168+
})
1169+
11331170
it('keeps a cancellation that arrives while listing the outputs a cancellation', async () => {
11341171
const { handle } = fakeSandbox('timeout-cancelled')
11351172
mockFindSessionSandbox.mockResolvedValue(handle)

‎apps/sim/lib/mothership/tools/server/settings.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { OrganizationDelegatedPrincipal } from '@sim/auth/principal'
2+
import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock'
23
import {
34
knowledgeAvailabilityMock,
45
knowledgeAvailabilityMockFns,
@@ -11,6 +12,7 @@ vi.mock('@/lib/settings/application/organization-section-access', () => ({
1112
authorizeOrganizationSettingsSection: sectionAccess,
1213
}))
1314
vi.mock('@/lib/knowledge/access/availability', () => knowledgeAvailabilityMock)
15+
vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock)
1416
vi.mock('@/lib/mothership/application/settings-context', () => ({
1517
resolveSettingsContext: resolve,
1618
}))
@@ -98,6 +100,27 @@ describe('settings tool dispatch', () => {
98100
})
99101
})
100102

103+
it('offers Sim links only to an organization that has them enabled', async () => {
104+
const simLinks = expect.objectContaining({ id: 'sim-links' })
105+
featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false)
106+
expect(
107+
await settingsServerTool.execute({ scope: 'organization', action: 'list' })
108+
).not.toMatchObject({ sections: expect.arrayContaining([simLinks]) })
109+
for (const action of ['open', 'get'] as const) {
110+
await expect(
111+
settingsServerTool.execute({ scope: 'organization', action, section: 'sim-links' })
112+
).rejects.toThrow('unavailable')
113+
}
114+
115+
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(
116+
async (flag, context) =>
117+
flag === 'sim-links' && (context as { orgId?: string })?.orgId === 'org'
118+
)
119+
expect(
120+
await settingsServerTool.execute({ scope: 'organization', action: 'list' })
121+
).toMatchObject({ sections: expect.arrayContaining([simLinks]) })
122+
})
123+
101124
it('discovers names without eagerly reading every setting or dumping every schema', async () => {
102125
const read = vi.spyOn(readOrganizationSettings, 'execute')
103126
const result = await settingsServerTool.execute({ scope: 'organization', action: 'list' })

‎apps/sim/lib/mothership/tools/server/settings.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ interface SettingAdapter {
8787
get(context: SettingsContext): Promise<unknown>
8888
schema?: z.ZodType
8989
update?(context: SettingsContext, changes: Record<string, unknown>): Promise<unknown>
90+
/** Whether the section exists for this target at all; a section that does not is never listed. */
91+
available?(context: SettingsContext): Promise<boolean>
9092
}
9193

9294
function organizationInput(context: SettingsContext) {
@@ -255,7 +257,10 @@ const adapters: Record<string, SettingAdapter> = {
255257
},
256258
'organization/members': { get: readSettingsRoster },
257259
'organization/requests': { get: (context) => readSettingsAccessRequests(context) },
258-
'organization/sim-links': { get: (context) => readSettingsSimLinks(context) },
260+
'organization/sim-links': {
261+
available: (context) => isSimLinksEnabled(organizationInput(context).organizationId),
262+
get: (context) => readSettingsSimLinks(context),
263+
},
259264
'organization/whitelabeling': {
260265
schema: updateOrganizationWhitelabelBodySchema.strict(),
261266
get: async (context) =>
@@ -322,6 +327,7 @@ async function describeSection(context: SettingsContext, section: SettingsSectio
322327
)
323328
return null
324329
const adapter = adapters[`${context.scope}/${section.id}`]
330+
if (adapter?.available && !(await adapter.available(context))) return null
325331
const uiSection =
326332
context.scope === 'organization' &&
327333
section.id === 'connected-accounts' &&

‎apps/sim/stores/modals/chat-plan/store.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,23 @@ import { create } from 'zustand'
22
import { devtools } from 'zustand/middleware'
33

44
interface ChatPlanModalState {
5-
isOpen: boolean
6-
open: () => void
5+
/** The workspace whose refused send opened the modal; another workspace never shows it. */
6+
workspaceId: string | null
7+
open: (workspaceId: string | undefined) => void
78
close: () => void
89
}
910

1011
/**
11-
* Whether the Chat plan modal is showing. A send the payer's plan refuses opens it from wherever
12-
* the send started (the composer, a queued message, a handoff from another surface), and the
13-
* workspace shell renders the one modal.
12+
* Which workspace's Chat plan modal is showing. A send the payer's plan refuses opens it from
13+
* wherever the send started (the composer, a queued message, a handoff from another surface), and
14+
* the workspace shell renders the one modal, only for the workspace that opened it.
1415
*/
1516
export const useChatPlanModalStore = create<ChatPlanModalState>()(
1617
devtools(
1718
(set) => ({
18-
isOpen: false,
19-
open: () => set({ isOpen: true }),
20-
close: () => set({ isOpen: false }),
19+
workspaceId: null,
20+
open: (workspaceId) => set({ workspaceId: workspaceId ?? null }),
21+
close: () => set({ workspaceId: null }),
2122
}),
2223
{ name: 'chat-plan-modal-store' }
2324
)

0 commit comments

Comments
 (0)