Skip to content

Commit 9b0f81c

Browse files
committed
chore(release): merge staging into the v0.9.41 release
2 parents 2640869 + 78cd332 commit 9b0f81c

40 files changed

Lines changed: 1042 additions & 97 deletions

‎apps/docs/content/docs/cli/files.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -643,7 +643,7 @@ sim files batch-delete [file-ids...] [options]
643643

644644
| Argument | Required | Description |
645645
| --- | --- | --- |
646-
| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |
646+
| `file-ids...` | No | Files to delete, each by identifier or by the VFS path of a live file: `files/<folder>/<name>`. Pass the id when two files in a folder have names that display alike. At most 100 entries may be paths. Same as --file-ids. |
647647

648648
</CommandTable>
649649

@@ -653,7 +653,7 @@ sim files batch-delete [file-ids...] [options]
653653

654654
| Option | Required | Description |
655655
| --- | --- | --- |
656-
| `--file-ids <value...>` | No | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
656+
| `--file-ids <value...>` | No | Files to delete, each by identifier or by the VFS path of a live file: `files/<folder>/<name>`. Pass the id when two files in a folder have names that display alike. At most 100 entries may be paths. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
657657
| `-y, --yes` | Yes | Confirm this operation. |
658658

659659
</CommandTable>

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,7 @@ sim files batch-delete [file-ids...] [options]
981981

982982
| Argument | Required | Description |
983983
| --- | --- | --- |
984-
| `file-ids...` | No | File identifiers to delete. Same as --file-ids. |
984+
| `file-ids...` | No | Files to delete, each by identifier or by the VFS path of a live file: `files/<folder>/<name>`. Pass the id when two files in a folder have names that display alike. At most 100 entries may be paths. Same as --file-ids. |
985985

986986
</CommandTable>
987987

@@ -991,7 +991,7 @@ sim files batch-delete [file-ids...] [options]
991991

992992
| Option | Required | Description |
993993
| --- | --- | --- |
994-
| `--file-ids <value...>` | No | File identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
994+
| `--file-ids <value...>` | No | Files to delete, each by identifier or by the VFS path of a live file: `files/<folder>/<name>`. Pass the id when two files in a folder have names that display alike. At most 100 entries may be paths. (space-separated, or @path / @- with one value per line; @@value for a literal leading @) (required, or pass the values as arguments). |
995995
| `-y, --yes` | Yes | Confirm this operation. |
996996

997997
</CommandTable>

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7369,7 +7369,7 @@
73697369
"type": "string",
73707370
"minLength": 1
73717371
},
7372-
"description": "File identifiers to delete."
7372+
"description": "Files to delete, each by identifier or by the VFS path of a live file: `files/<folder>/<name>`. Pass the id when two files in a folder have names that display alike. At most 100 entries may be paths."
73737373
}
73747374
},
73757375
"required": ["workspaceId", "fileIds"],

‎apps/sim/app/api/v2/files/bulk-delete/route.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ export const POST = defineV2JsonRoute({
1313
operation: fileOperations.delete,
1414
rateLimit: v2RateLimits.publicApi,
1515
errorPolicy: v2FileErrorPolicies.default,
16-
mapInput: ({ body }) => ({ workspaceId: body.workspaceId, fileIds: body.fileIds }),
16+
mapInput: ({ body }) => ({
17+
workspaceId: body.workspaceId,
18+
fileIds: body.fileIds,
19+
acceptVfsPath: true,
20+
}),
1721
useCase: archiveWorkspaceFileItemsOperation,
1822
present: ({ deletedItems }) => ({ data: { deletedItems: { files: deletedItems.files } } }),
1923
})

‎apps/sim/lib/api/contracts/v2/files.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ import { MAX_FOLDER_PATH_BYTES, MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/p
4646
import { MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types'
4747
import { MAX_TEXT_EXTRACTION_BYTES } from '@/lib/uploads/utils/file'
4848
import { isWorkspaceFileVfsPath } from '@/lib/vfs/path'
49-
import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits'
49+
import {
50+
MAX_WORKSPACE_FILE_BULK_REQUEST_PATHS,
51+
MAX_ZIP_DOWNLOAD_FILES,
52+
} from '@/lib/workspace-files/limits'
5053
import {
5154
FILE_SEARCH_DEFAULT_MAX_RESULTS,
5255
FILE_SEARCH_MAX_QUERY_LENGTH,
@@ -527,7 +530,9 @@ export type V2MoveFileItemsResult = z.output<typeof v2MoveFileItemsResultSchema>
527530
export const v2BulkDeleteFilesBodySchema = z
528531
.object({
529532
workspaceId: workspaceIdSchema.describe('Workspace containing the files.'),
530-
fileIds: fileIdsSchema.describe('File identifiers to delete.'),
533+
fileIds: fileIdsSchema.describe(
534+
`Files to delete, each by identifier or by the VFS path of a live file: \`files/<folder>/<name>\`. Pass the id when two files in a folder have names that display alike. At most ${MAX_WORKSPACE_FILE_BULK_REQUEST_PATHS} entries may be paths.`
535+
),
531536
})
532537
.strict()
533538

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

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import {
4343
SANDBOX_OUTPUT_DIR_MAX_DEPTH,
4444
SANDBOX_OUTPUT_DIR_SENTINEL,
4545
} from '@/lib/execution/remote-sandbox/paths'
46+
import {
47+
createSandboxPhaseTimer,
48+
type SandboxPhaseTimer,
49+
} from '@/lib/execution/remote-sandbox/phase-timing'
4650
import { resolvePiSandboxLifetimeMs } from '@/lib/execution/remote-sandbox/pi-lifetime'
4751
import { resolveProvider } from '@/lib/execution/remote-sandbox/provider'
4852
import {
@@ -116,13 +120,16 @@ async function leaseSandbox(
116120
selected: ResolvedSandbox | null,
117121
signal: AbortSignal,
118122
meterUsage: boolean | undefined,
119-
session: SandboxSessionRequest | undefined
123+
session: SandboxSessionRequest | undefined,
124+
phases: SandboxPhaseTimer
120125
): Promise<SandboxLease> {
121126
const provider = resolveProvider()
122127
const sessionCapable = Boolean(session && !meterUsage && provider.findSessionSandbox)
123128

124129
if (session && sessionCapable) {
130+
const endAccess = phases.start('access')
125131
await prepareSandboxSessionAccess(session.key, signal)
132+
endAccess()
126133
return withSandboxSessionLock(session.key, signal, async (leaseSignal) => {
127134
const { created, status } = await ensureSessionSandbox({
128135
provider,
@@ -132,6 +139,7 @@ async function leaseSandbox(
132139
session,
133140
signal: leaseSignal,
134141
bootstrapTimeoutMs: remainingSandboxBudgetMs(signal),
142+
phases,
135143
})
136144
return {
137145
created,
@@ -214,6 +222,16 @@ function raceSandboxAbort<T>(operation: Promise<T>, signal: AbortSignal): Promis
214222
})
215223
}
216224

225+
/** Attaches the phase breakdown to whichever result the execution returns, after its release. */
226+
async function withPhaseTimings(
227+
execute: (phases: SandboxPhaseTimer) => Promise<SandboxExecutionResult>
228+
): Promise<SandboxExecutionResult> {
229+
const phases = createSandboxPhaseTimer()
230+
const result = await execute(phases)
231+
result.phaseMs = phases.timings
232+
return result
233+
}
234+
217235
async function withSandboxExecutionBudget<T>(
218236
timeoutMs: number,
219237
parentSignal: AbortSignal | undefined,
@@ -864,22 +882,26 @@ async function acceptSessionOutputHistory(
864882
async function executeInSandboxWithinBudget(
865883
// The budget wrapper always injects the signal; the required-signal type states that
866884
// invariant instead of a cast hiding it.
867-
req: SandboxExecutionRequest & { signal: AbortSignal }
885+
req: SandboxExecutionRequest & { signal: AbortSignal },
886+
phases: SandboxPhaseTimer
868887
): Promise<SandboxExecutionResult> {
869888
const { code, language, signal } = req
870889
const kind = req.sandboxKind ?? 'code'
871890
throwIfAborted(signal)
872891

873892
// Resolved before the sandbox is created so a selection that cannot be honored
874893
// fails without spending a provider create.
894+
const endResolve = phases.start('resolve')
875895
const selected = await resolveWorkspaceSandbox({
876896
kind,
877897
language,
878898
workspaceId: req.workspaceId,
879899
sandboxId: req.sandboxId,
880900
})
901+
endResolve()
881902
throwIfAborted(signal)
882903

904+
const endLease = phases.start('lease')
883905
const lease = await leaseSandbox(
884906
kind,
885907
{
@@ -890,8 +912,10 @@ async function executeInSandboxWithinBudget(
890912
selected,
891913
signal,
892914
req.meterUsage,
893-
req.session
915+
req.session,
916+
phases
894917
)
918+
endLease()
895919
const created = lease.created
896920
const sandbox = created.sandbox
897921
const sandboxId = sandbox.sandboxId
@@ -906,6 +930,7 @@ async function executeInSandboxWithinBudget(
906930
// the finally below. Dependencies land before the inputs so user code and its
907931
// mounts always see a complete environment.
908932
//
933+
const endPrepare = phases.start('prepare')
909934
if (lease.session && req.session)
910935
await recordSessionFileInput(
911936
req.session.key,
@@ -926,6 +951,7 @@ async function executeInSandboxWithinBudget(
926951
signal,
927952
Boolean(lease.session)
928953
)
954+
endPrepare()
929955
const executionEnvironment = {
930956
...selected?.envs,
931957
...req.session?.envs,
@@ -944,13 +970,15 @@ async function executeInSandboxWithinBudget(
944970

945971
let execution: SandboxCodeResult
946972
try {
973+
const endRun = phases.start('run')
947974
execution = await sandbox.runCode(code, {
948975
timeoutMs: codeBudgetMs(req, signal),
949976
javascriptPreload: buildJavaScriptRuntimeBindingsSource(req.runtimeBindings ?? []),
950977
maxOutputBytes: MAX_SANDBOX_PROCESS_OUTPUT_BYTES,
951978
signal,
952979
...(hasExecutionEnvironment ? { envs: executionEnvironment } : {}),
953980
})
981+
endRun()
954982
} catch (error) {
955983
throwIfAborted(signal)
956984
throw error
@@ -1009,11 +1037,13 @@ async function executeInSandboxWithinBudget(
10091037
...sessionField,
10101038
}
10111039
try {
1040+
const endCollect = phases.start('collect')
10121041
const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles(
10131042
sandbox,
10141043
req,
10151044
{ signal }
10161045
)
1046+
endCollect()
10171047
throwIfAborted(signal)
10181048
billableResult.exportedFileContent = exportedFileContent
10191049
billableResult.exportedFiles = exportedFiles
@@ -1044,6 +1074,7 @@ async function executeInSandboxWithinBudget(
10441074
if (cost && billableOutputError) {
10451075
attachTrustedSandboxOutputCost(billableOutputError, cost)
10461076
}
1077+
const endRelease = phases.start('release')
10471078
try {
10481079
await privateInputFiles?.cleanup()
10491080
await lease.release()
@@ -1052,6 +1083,7 @@ async function executeInSandboxWithinBudget(
10521083
providerId: created.providerId,
10531084
sandboxId,
10541085
})
1086+
endRelease()
10551087
}
10561088
}
10571089
}
@@ -1061,34 +1093,41 @@ export function executeInSandbox(req: SandboxExecutionRequest): Promise<SandboxE
10611093
req.timeoutMs,
10621094
req.signal,
10631095
Boolean(req.session && !req.meterUsage),
1064-
(signal) => executeInSandboxWithinBudget({ ...req, signal })
1096+
(signal) =>
1097+
withPhaseTimings((phases) => executeInSandboxWithinBudget({ ...req, signal }, phases))
10651098
)
10661099
}
10671100

10681101
async function executeShellInSandboxWithinBudget(
1069-
req: SandboxShellExecutionRequest & { signal: AbortSignal }
1102+
req: SandboxShellExecutionRequest & { signal: AbortSignal },
1103+
phases: SandboxPhaseTimer
10701104
): Promise<SandboxExecutionResult> {
10711105
const { code, envs, signal } = req
10721106
const kind = req.sandboxKind ?? 'shell'
10731107
throwIfAborted(signal)
10741108

10751109
// No language is passed: a shell execution runs commands rather than a language
10761110
// runtime, so whichever language the sandbox carries is the one it installs.
1111+
const endResolve = phases.start('resolve')
10771112
const selected = await resolveWorkspaceSandbox({
10781113
kind,
10791114
workspaceId: req.workspaceId,
10801115
sandboxId: req.sandboxId,
10811116
})
1117+
endResolve()
10821118
throwIfAborted(signal)
10831119

1120+
const endLease = phases.start('lease')
10841121
const lease = await leaseSandbox(
10851122
kind,
10861123
{ imageRef: selected?.imageRef, lifetimeMs: remainingSandboxBudgetMs(signal) },
10871124
selected,
10881125
signal,
10891126
req.meterUsage,
1090-
req.session
1127+
req.session,
1128+
phases
10911129
)
1130+
endLease()
10921131
const created = lease.created
10931132
const sandbox = created.sandbox
10941133
const sandboxId = sandbox.sandboxId
@@ -1102,6 +1141,7 @@ async function executeShellInSandboxWithinBudget(
11021141
// Inside the try so a failed install or mount still releases the sandbox via
11031142
// the finally below. The install shares the caller's budget rather than adding
11041143
// to it — see the note in `executeInSandbox`.
1144+
const endPrepare = phases.start('prepare')
11051145
if (lease.session && req.session)
11061146
await recordSessionFileInput(
11071147
req.session.key,
@@ -1123,9 +1163,11 @@ async function executeShellInSandboxWithinBudget(
11231163
signal,
11241164
Boolean(lease.session)
11251165
)
1166+
endPrepare()
11261167

11271168
let result: SandboxCommandResult
11281169
try {
1170+
const endRun = phases.start('run')
11291171
result = await sandbox.runCommand(code, {
11301172
envs: {
11311173
...selected?.envs,
@@ -1141,6 +1183,7 @@ async function executeShellInSandboxWithinBudget(
11411183
rootUser: !lease.session,
11421184
atMostOnce: true,
11431185
})
1186+
endRun()
11441187
} catch (error) {
11451188
throwIfAborted(signal)
11461189
throw error
@@ -1185,11 +1228,13 @@ async function executeShellInSandboxWithinBudget(
11851228
...sessionField,
11861229
}
11871230
try {
1231+
const endCollect = phases.start('collect')
11881232
const { exportedFiles, exportedFileContent, collectedFiles } = await collectExportedFiles(
11891233
sandbox,
11901234
req,
11911235
{ signal }
11921236
)
1237+
endCollect()
11931238
throwIfAborted(signal)
11941239
billableResult.exportedFileContent = exportedFileContent
11951240
billableResult.exportedFiles = exportedFiles
@@ -1220,6 +1265,7 @@ async function executeShellInSandboxWithinBudget(
12201265
if (cost && billableOutputError) {
12211266
attachTrustedSandboxOutputCost(billableOutputError, cost)
12221267
}
1268+
const endRelease = phases.start('release')
12231269
try {
12241270
await privateInputFiles?.cleanup()
12251271
await lease.release()
@@ -1228,6 +1274,7 @@ async function executeShellInSandboxWithinBudget(
12281274
providerId: created.providerId,
12291275
sandboxId,
12301276
})
1277+
endRelease()
12311278
}
12321279
}
12331280
}
@@ -1239,7 +1286,8 @@ export function executeShellInSandbox(
12391286
req.timeoutMs,
12401287
req.signal,
12411288
Boolean(req.session && !req.meterUsage),
1242-
(signal) => executeShellInSandboxWithinBudget({ ...req, signal })
1289+
(signal) =>
1290+
withPhaseTimings((phases) => executeShellInSandboxWithinBudget({ ...req, signal }, phases))
12431291
)
12441292
}
12451293

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* Phases of one sandbox execution, in the order they run. `lease` spans the whole acquisition
3+
* (session lock included); for a session, `lookup` and `cli` are the parts of it spent finding or
4+
* creating the machine and verifying its deployment tooling.
5+
*/
6+
type SandboxPhase =
7+
| 'resolve'
8+
| 'access'
9+
| 'lease'
10+
| 'lookup'
11+
| 'cli'
12+
| 'prepare'
13+
| 'run'
14+
| 'collect'
15+
| 'release'
16+
17+
/** Wall-clock milliseconds per phase; a phase that did not finish is absent. */
18+
export type SandboxPhaseTimings = Partial<Record<SandboxPhase, number>>
19+
20+
/** Accumulates per-phase wall time for one execution's log line. */
21+
export interface SandboxPhaseTimer {
22+
readonly timings: SandboxPhaseTimings
23+
/** Starts timing a phase; the returned function records its duration. */
24+
start(phase: SandboxPhase): () => void
25+
}
26+
27+
/** Creates a timer whose callbacks record rounded milliseconds; unfinished phases stay absent. */
28+
export function createSandboxPhaseTimer(): SandboxPhaseTimer {
29+
const timings: SandboxPhaseTimings = {}
30+
return {
31+
timings,
32+
start(phase) {
33+
const startedAt = performance.now()
34+
return () => {
35+
timings[phase] = Math.round(performance.now() - startedAt)
36+
}
37+
},
38+
}
39+
}

0 commit comments

Comments
 (0)