Skip to content

Commit 4e115aa

Browse files
committed
Merge remote-tracking branch 'origin/staging' into fix/stream-continuity
2 parents 0f2dd1c + c30d58d commit 4e115aa

35 files changed

Lines changed: 3446 additions & 523 deletions

File tree

‎apps/sim/app/api/cron/cleanup-stale-executions/route.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
STALE_SWEEPABLE_EXECUTION_STATUSES,
3333
type StaleSweepableExecutionStatus,
3434
} from '@/lib/logs/types'
35+
import { sweepOrphanedRuns } from '@/lib/mothership/async-runs/orphaned-runs'
3536
import { cancelStaleDispatches } from '@/lib/table/dispatcher'
3637
import { deleteFile } from '@/lib/uploads/core/storage-service'
3738
import {
@@ -738,6 +739,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
738739
})
739740
}
740741

742+
/**
743+
* Settle Chat runs no controller will finish: their process died, their
744+
* controller was superseded without a successor, or Stop found none. Without
745+
* this they stay unfinished forever and keep their chat marked as busy.
746+
*/
747+
let orphanedRunsSettled = 0
748+
try {
749+
orphanedRunsSettled = (await sweepOrphanedRuns()).settledRunIds.length
750+
} catch (error) {
751+
logger.error('Failed to settle orphaned Chat runs:', {
752+
error: toError(error).message,
753+
})
754+
}
755+
741756
return NextResponse.json({
742757
success: true,
743758
executions: {
@@ -768,6 +783,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
768783
pruned: deploymentOperationsPruned,
769784
retentionDays: DEPLOYMENT_OPERATION_RETENTION_DAYS,
770785
},
786+
chatRuns: {
787+
orphanedSettled: orphanedRunsSettled,
788+
},
771789
})
772790
} catch (error) {
773791
logger.error('Error in stale execution cleanup job:', error)

‎apps/sim/app/api/workflows/[id]/execute/route.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { flushMacrotask } from '@sim/testing/helpers/async'
2+
import { createDeferred } from '@sim/testing/helpers/deferred'
13
import { createRouteContext } from '@sim/testing/helpers/http'
24
import { asyncJobsMock, asyncJobsMockFns } from '@sim/testing/mocks/async-jobs.mock'
35
import {
@@ -551,6 +553,29 @@ describe('workflow execute async route', () => {
551553
expect(executionOptions.snapshot.input).not.toHaveProperty(PRIVATE_SECRET_PROVENANCE_FIELD)
552554
})
553555

556+
it('holds a synchronous response until the run log and its cost are finalized', async () => {
557+
configureExecutionCaller(EXECUTION_CALLERS[4])
558+
const finalizer = createDeferred<void>()
559+
loggingSessionMockFns.mockWaitForPostExecution.mockReturnValue(finalizer.promise)
560+
561+
let responded = false
562+
const pending = POST(
563+
createInternalProvenanceRequest(),
564+
createRouteContext({ id: 'workflow-1' })
565+
)
566+
void pending.then(() => {
567+
responded = true
568+
})
569+
await vi.waitFor(() => {
570+
expect(loggingSessionMockFns.mockWaitForPostExecution).toHaveBeenCalled()
571+
})
572+
await flushMacrotask()
573+
expect(responded).toBe(false)
574+
575+
finalizer.resolve()
576+
expect((await pending).status).toBe(200)
577+
})
578+
554579
it('queues authenticated workflow input provenance without exposing the private sidecar as input', async () => {
555580
configureExecutionCaller(EXECUTION_CALLERS[4])
556581

‎apps/sim/app/api/workflows/[id]/execute/route.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1636,6 +1636,12 @@ async function handleExecutePost(
16361636
reqLogger.error('Failed to cleanup base64 cache', { error })
16371637
})
16381638
}
1639+
/**
1640+
* The sync response is the run's receipt: callers read its log and cost as soon
1641+
* as it lands. The core finalizes both in the background, so hold the response
1642+
* until they are durable.
1643+
*/
1644+
await loggingSession.waitForPostExecution()
16391645
}
16401646
}
16411647

‎apps/sim/content/library/best-ai-agent-builders-slack-crm-automation-2026/index.mdx‎

Lines changed: 363 additions & 0 deletions
Large diffs are not rendered by default.

‎apps/sim/content/library/best-ai-automation-tools-2026/index.mdx‎

Lines changed: 161 additions & 134 deletions
Large diffs are not rendered by default.

‎apps/sim/content/library/best-gumloop-alternatives-in-2026/index.mdx‎

Lines changed: 125 additions & 164 deletions
Large diffs are not rendered by default.

‎apps/sim/content/library/marketing-automation-platform-vs-ai-agent-builder/index.mdx‎

Lines changed: 305 additions & 0 deletions
Large diffs are not rendered by default.

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -891,7 +891,9 @@ async function executeInSandboxWithinBudget(
891891
await recordSessionFileInput(
892892
req.session.key,
893893
{ providerId: created.providerId, sandboxId },
894-
sandboxSessionInputsSafe() && !Object.keys(selected?.envs ?? {}).length
894+
sandboxSessionInputsSafe() &&
895+
!req.session.unprovenancedInputs &&
896+
!Object.keys(selected?.envs ?? {}).length
895897
)
896898
await provisionWithinBudget(sandbox, selected, signal)
897899
await writeSandboxInputs(sandbox, req.sandboxFiles, {
@@ -1078,7 +1080,9 @@ async function executeShellInSandboxWithinBudget(
10781080
await recordSessionFileInput(
10791081
req.session.key,
10801082
{ providerId: created.providerId, sandboxId },
1081-
sandboxSessionInputsSafe() && !Object.keys(selected?.envs ?? {}).length
1083+
sandboxSessionInputsSafe() &&
1084+
!req.session.unprovenancedInputs &&
1085+
!Object.keys(selected?.envs ?? {}).length
10821086
)
10831087
await provisionWithinBudget(sandbox, selected, signal)
10841088
await writeSandboxInputs(sandbox, req.sandboxFiles, {
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* The persistent workbench's input history is what lets a scratch file reach the model. This runs
3+
* the real code boundary against the real history script in a disposable Redis, so a mount the
4+
* caller could not classify has to leave the machine uncertified. Only the sandbox provider is a
5+
* stand-in: it hands back an existing machine that executes nothing.
6+
*
7+
* Set TEST_REDIS_URL to an isolated local Redis service.
8+
*/
9+
import { createHash } from 'node:crypto'
10+
import {
11+
remoteSandboxProviderMock,
12+
remoteSandboxProviderMockFns,
13+
} from '@sim/testing/mocks/remote-sandbox-provider.mock'
14+
import { generateShortId } from '@sim/utils/id'
15+
import { afterAll, describe, expect, it, vi } from 'vitest'
16+
17+
const { redisUrl, inheritedRedisUrl, mockFindSessionSandbox } = await vi.hoisted(async () => {
18+
const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure')
19+
const url = readTestRedisUrl()
20+
const inheritedRedisUrl = process.env.REDIS_URL
21+
/** The real Redis module reads this at import. */
22+
if (url) process.env.REDIS_URL = url
23+
return { redisUrl: url, inheritedRedisUrl, mockFindSessionSandbox: vi.fn() }
24+
})
25+
26+
vi.mock('@/lib/execution/remote-sandbox/provider', () => remoteSandboxProviderMock)
27+
vi.mock('@/lib/execution/remote-sandbox/resolve', () => ({
28+
resolveWorkspaceSandbox: async () => null,
29+
provisionRuntimeDependencies: async () => {},
30+
repairMissingSandboxImage: async () => null,
31+
RUNTIME_INSTALL_TIMEOUT_MS: 60_000,
32+
}))
33+
34+
import { closeRedisConnection, getRedisClient } from '@/lib/core/config/redis'
35+
import { CodeLanguage } from '@/lib/execution/languages'
36+
import {
37+
executeInSandbox,
38+
executeShellInSandbox,
39+
SIM_RESULT_PREFIX,
40+
} from '@/lib/execution/remote-sandbox'
41+
import { observeSandboxSessionInputs } from '@/lib/execution/remote-sandbox/execution-observer'
42+
import {
43+
initializeSessionFileProvenance,
44+
isSessionFileProvenanceClean,
45+
} from '@/lib/execution/remote-sandbox/session-file-provenance'
46+
import type { SandboxHandle, SandboxProvider } from '@/lib/execution/remote-sandbox/types'
47+
48+
remoteSandboxProviderMockFns.mockResolveProvider.mockImplementation(
49+
() =>
50+
({
51+
id: 'e2b',
52+
dependencyStrategy: 'prebuilt',
53+
resolveLifetimeMs: (ms: number) => ms,
54+
create: async () => {
55+
throw new Error('The certification fixture only reuses an existing machine')
56+
},
57+
findSessionSandbox: mockFindSessionSandbox,
58+
}) satisfies SandboxProvider
59+
)
60+
61+
function machine(sandboxId: string): SandboxHandle {
62+
return {
63+
sandboxId,
64+
runCode: async () => ({ text: `${SIM_RESULT_PREFIX}{"ok":true}`, stdout: '', stderr: '' }),
65+
runCommand: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
66+
extendLifetime: async () => {},
67+
getFileSize: async () => 0,
68+
readFile: async () => '',
69+
readFileWithLimit: async () => ({ content: '', byteLength: 0 }),
70+
writeFile: async () => {},
71+
removeFile: async () => {},
72+
listFiles: async () => [],
73+
kill: async () => {},
74+
}
75+
}
76+
77+
/** Machine-history keys this suite created, so cleanup never touches another suite's state. */
78+
const createdKeys: string[] = []
79+
80+
afterAll(async () => {
81+
if (redisUrl && createdKeys.length) {
82+
const redis = getRedisClient()
83+
if (redis) await redis.del(...createdKeys)
84+
}
85+
if (redisUrl) await closeRedisConnection()
86+
// Only restore what the hoisted setup changed; assigning undefined would store the string "undefined".
87+
if (!redisUrl) return
88+
if (inheritedRedisUrl === undefined) Reflect.deleteProperty(process.env, 'REDIS_URL')
89+
else process.env.REDIS_URL = inheritedRedisUrl
90+
})
91+
92+
describe.skipIf(!redisUrl)('workbench certification at the code boundary', () => {
93+
it.each([
94+
['code', false],
95+
['code', true],
96+
['shell', false],
97+
['shell', true],
98+
] as const)('%s with unprovenanced mounts %s', async (kind, unprovenanced) => {
99+
const sandboxId = `machine-${generateShortId(12)}`
100+
const key = `certification-${generateShortId(12)}`
101+
const identity = { providerId: 'e2b', sandboxId } as const
102+
createdKeys.push(
103+
`mothership:workbench-provenance:v1:${createHash('sha256')
104+
.update(JSON.stringify([key, identity.providerId, sandboxId]))
105+
.digest('hex')}`
106+
)
107+
mockFindSessionSandbox.mockResolvedValue(machine(sandboxId))
108+
await initializeSessionFileProvenance(key, identity)
109+
expect(await isSessionFileProvenanceClean(key, identity)).toBe(true)
110+
111+
const request = {
112+
code: 'print(1)',
113+
language: CodeLanguage.Python,
114+
timeoutMs: 30_000,
115+
session: { key, ...(unprovenanced ? { unprovenancedInputs: true } : {}) },
116+
}
117+
await observeSandboxSessionInputs(
118+
() => true,
119+
() =>
120+
kind === 'code'
121+
? executeInSandbox(request)
122+
: executeShellInSandbox({ ...request, envs: {} })
123+
)
124+
expect(await isSessionFileProvenanceClean(key, identity)).toBe(!unprovenanced)
125+
})
126+
})

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ export interface SandboxSessionRequest {
9191
cli?: { path: string; content: string; runtime?: { path: string; content: string } }
9292
/** Extra environment variables present on every execution in the session. */
9393
envs?: Record<string, string>
94+
/**
95+
* This execution mounts bytes whose secret provenance is unknown, so the machine's input
96+
* history must not stay certified clean even when the caller's own inputs are.
97+
*/
98+
unprovenancedInputs?: boolean
9499
}
95100

96101
export interface SandboxShellExecutionRequest {

0 commit comments

Comments
 (0)