Merge remote-tracking branch 'origin/staging' into feat/unified-server-selector-execution

This commit is contained in:
Bill Leoutsakos
2026-08-28 09:20:57 -07:00
237 changed files with 10939 additions and 2238 deletions
+4
View File
@@ -1056,6 +1056,10 @@ After creating the block, you MUST validate it against every tool it references:
4. **Verify conditions** — each subBlock should only show for the operations that actually use it
5. **Verify `{Service}BlockMeta` is exported** with at least 7 templates, each having `icon`, `title`, `prompt`, `modules`, `category`, and `tags`
6. **If any tool outputs are still unknown**, explicitly tell the user instead of guessing block outputs
7. **Verify the tool execution boundary** — blocks never create or call API routes. Every referenced
tool must already be either a registered `InternalToolConfig.operation` or an absolute external
HTTP(S) `ToolConfig.request`. If transport needs to change, use the `add-tools` skill; do not add a
same-origin `/api/...` hop from the block.
## Option Lists: `selectorKey` or `options`, never a per-block fetcher
+59 -138
View File
@@ -60,6 +60,18 @@ apps/sim/tools/{service}/
### Key Patterns
Choose the tool boundary before writing the declaration:
- Use `InternalToolConfig.operation` for same-process Sim/provider work. Put the handler under
`apps/sim/lib/internal/{service}/execute-tool.ts` and register every ID in
`apps/sim/lib/internal/tool-operations/registry.server.ts`.
- Use `ToolConfig.request` only for an absolute external HTTP(S) provider endpoint.
Never point a tool at `/api/...`, construct an absolute URL back to Sim, declare
`request.internal`, or add an API route merely to reuse code, normalize files, or authorize
resources. A real external/browser route and an in-process tool may share the same operation, but
neither calls the other. Follow the full transport and handler rules in the `add-tools` skill.
**types.ts:**
```typescript
import type { ToolResponse } from '@/tools/types'
@@ -82,7 +94,7 @@ export interface {Service}Response extends ToolResponse {
**Tool file pattern:**
```typescript
export const {service}{Action}Tool: ToolConfig<Params, Response> = {
export const {service}{Action}Tool: InternalToolConfig<Params, Response> = {
id: '{service}_{action}',
name: '{Service} {Action}',
description: '...',
@@ -95,16 +107,11 @@ export const {service}{Action}Tool: ToolConfig<Params, Response> = {
// ... other params
},
request: { url, method, headers, body },
transformResponse: async (response) => {
const data = await response.json()
return {
success: true,
output: {
field: data.field ?? null, // Always handle nullables
},
}
operation: {
input: (params) => ({
accessToken: params.accessToken,
// Map only the semantic operation input.
}),
},
outputs: { /* ... */ },
@@ -135,7 +142,8 @@ and leave the field unannotated.
sent with their normal request semantics. A URL, domain, resource ID, control field, or opaque
payload is not model-visible merely because the provider is AI-backed or may process the
referenced resource later.
- **Text or structured content consumed by an AI model:** declare `request.modelInput` with
- **Text or structured content consumed by an AI model:** declare `request.modelInput` for an
external provider request or `operation.modelInput` for an in-process operation, with
`mode: 'project'` and select only the exact model-visible fields. The shared executor replaces
activated Sim secrets with canonical `{{NAME}}` labels before request formatting. For nested or
JSON-string fields, use a small shared selector plus `applyProjected`; verify that selecting the
@@ -144,20 +152,19 @@ and leave the field unannotated.
top-level param in `request.modelInput`. Project the private copy before the existing request
formatter parses it; keep formatter behavior deterministic when a whole-value placeholder is not
valid in the serialized grammar. Do not introduce a second hard-rejection path.
- **Opaque model input owned by an authenticated internal route** such as inline audio, image,
video, or document bytes: add `privateProvenance` to a projected request, or use
- **Opaque model input owned by an in-process operation** such as inline audio, image, video, or
document bytes: add `privateProvenance` to the operation model-input declaration, or use
`mode: 'private-provenance'` when there is no textual projection. Do not select storage keys,
paths, signed URLs, or ordinary remote URLs as byte provenance; the owning route must authorize
stored bytes independently at model egress. The route must call
paths, signed URLs, or ordinary remote URLs as byte provenance; the owning operation must
authorize stored bytes independently at model egress. The operation must call
`validateOpaqueModelInputProvenance` before downloading or sending content to the model and must
apply the workspace-file provenance guard before reading a persisted workspace file.
- **Sim-owned durable storage or internal execution handoff** that can later enter a workflow/model
(table cells, Agent memory, knowledge documents/chunks, workspace-file contents, or child-workflow
input): transport encrypted field-scoped provenance with `request.secretProvenance`. The
authenticated receiver validates the exact selection and scope, strips the private envelope, and
persists, imports, or propagates it at the owning boundary. Preserve shared legacy behavior for
headerless internal calls and rows/files whose provenance marker is `NULL`; never invent a
tool-local migration rule.
input): transport encrypted field-scoped provenance with `operation.secretProvenance`. The
operation validates the exact selection and trusted scope, then persists, imports, or propagates
it at the owning boundary. Preserve shared legacy behavior for rows/files whose provenance marker
is `NULL`; never invent a tool-local migration rule.
Hard rules:
@@ -166,7 +173,8 @@ Hard rules:
transport and strips private metadata from functional results.
- Never attach private provenance to an external URL or to `directExecution`. Project proven
model-visible external fields with `request.modelInput`; otherwise preserve ordinary request
semantics. Use an authenticated internal route when encrypted provenance must cross the boundary.
semantics. Use a registered in-process operation when encrypted provenance must cross the
boundary.
- Never sanitize arbitrary third-party tool results. Projection applies only to secrets activated
by Sim's resolved-secret provenance for that execution/tool call.
- Do not add provenance merely because a value is persisted, returned by a tool, or appears in a
@@ -605,6 +613,10 @@ If creating V2 versions (API-aligned outputs):
- [ ] Created `tools/{service}/` directory
- [ ] Created `types.ts` with all interfaces
- [ ] Created tool file for each operation
- [ ] Chose exactly one boundary per tool: registered `InternalToolConfig.operation` or absolute
external HTTP(S) `ToolConfig.request`
- [ ] No tool points to `/api/...`, constructs a URL back to Sim, declares `request.internal`, or
has an HTTP fallback for an in-process operation
- [ ] All params have correct visibility
- [ ] All nullable fields use `?? null`
- [ ] All optional outputs have `optional: true`
@@ -616,6 +628,8 @@ If creating V2 versions (API-aligned outputs):
external resource locators and control inputs retain their request semantics
- [ ] Confirmed ordinary third-party tool results are not generically sanitized
- [ ] Added provenance compatibility and fail-closed boundary tests where applicable
- [ ] `bun run check:tool-request-boundary` passes
- [ ] Internal-operation registry completeness test passes for every operation-backed tool
### Block
- [ ] Created `blocks/blocks/{service}.ts`
@@ -734,7 +748,8 @@ interface UserFile {
### File Input Pattern (Uploads)
For tools that accept file uploads, **always route through an internal API endpoint** rather than calling external APIs directly. This ensures proper file content retrieval.
File authorization, normalization, storage reads, provider upload, and response mapping belong in a
registered in-process operation. Do not create an internal API route for file tools.
#### 1. Block SubBlocks for File Input
@@ -770,137 +785,36 @@ Use the basic/advanced mode pattern:
#### 2. Normalize File Input in Block Config
In `tools.config.tool`, use `normalizeFileInput` to handle all input variants:
`tools.config.tool` selects the tool before variable resolution and must not mutate or coerce input.
Use `tools.config.params`, which runs after variable resolution, to normalize all file variants:
```typescript
import { normalizeFileInput } from '@/blocks/utils'
tools: {
config: {
tool: (params) => {
// Normalize file from basic (uploadFile), advanced (fileRef), or legacy (fileContent)
const normalizedFile = normalizeFileInput(
params.uploadFile || params.fileRef || params.fileContent,
{ single: true }
)
if (normalizedFile) {
params.file = normalizedFile
}
return `{service}_${params.operation}`
tool: (params) => `{service}_${params.operation}`,
params: (params) => {
// Serialization collapses the basic/advanced pair into the canonical `file` key.
const normalizedFile = normalizeFileInput(params.file, { single: true })
return normalizedFile ? { file: normalizedFile } : {}
},
},
}
```
#### 3. Create Special Internal Tool Execution Route
Create `apps/sim/app/api/tools/{service}/{action}/route.ts`. This raw route pattern is only for an integration's provider-execution boundary when it needs special file normalization, large-body handling, or protocol behavior. It is not the pattern for CRUD or other operations on protected Sim resources. For those, use the `migrate-application-operation` skill and an authorized application use case with the ordinary internal/v2 route builders.
Internal tool routes are HTTP boundaries and follow the same contract policy as public routes — define the request/response shape in `apps/sim/lib/api/contracts/tools/{service}.ts` (or an existing aggregate) and validate with canonical helpers from `@/lib/api/server`. Never write a route-local Zod schema. Authenticate and perform cheap admission before parsing or downloading files.
#### 3. Define and register the in-process operation
```typescript
// apps/sim/lib/api/contracts/tools/{service}.ts
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts'
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
export const {service}UploadBodySchema = z.object({
accessToken: z.string(),
file: FileInputSchema.optional().nullable(),
fileContent: z.string().optional().nullable(),
// ... other params
})
export const {service}UploadResponseSchema = z.object({
success: z.boolean(),
output: z.object({ id: z.string(), url: z.string() }).optional(),
error: z.string().optional(),
})
export const {service}UploadContract = defineRouteContract({
method: 'POST',
path: '/api/tools/{service}/upload',
body: {service}UploadBodySchema,
response: { mode: 'json', schema: {service}UploadResponseSchema },
})
export type {Service}UploadBody = z.input<typeof {service}UploadBodySchema>
export type {Service}UploadResponse = z.output<typeof {service}UploadResponseSchema>
```
```typescript
// apps/sim/app/api/tools/{service}/upload/route.ts
import { createLogger } from '@sim/logger'
import { NextResponse, type NextRequest } from 'next/server'
import { {service}UploadContract } from '@/lib/api/contracts/tools/{service}'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { type RawFileInput } from '@/lib/uploads/utils/file-schemas'
import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
import { downloadFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
const logger = createLogger('{Service}UploadAPI')
export const POST = withRouteHandler(async (request: NextRequest) => {
const requestId = generateRequestId()
// Auth always runs BEFORE parseRequest — never validate untrusted input before authenticating.
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
if (!authResult.success) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 })
}
const parsed = await parseRequest({service}UploadContract, request, {})
if (!parsed.success) return parsed.response
const data = parsed.data.body
let fileBuffer: Buffer
let fileName: string
// Prefer UserFile input, fall back to legacy base64
if (data.file) {
const userFiles = processFilesToUserFiles([data.file as RawFileInput], requestId, logger)
if (userFiles.length === 0) {
return NextResponse.json({ success: false, error: 'Invalid file' }, { status: 400 })
}
const userFile = userFiles[0]
fileBuffer = await downloadFileFromStorage(userFile, requestId, logger)
fileName = userFile.name
} else if (data.fileContent) {
// Legacy: base64 string (backwards compatibility)
fileBuffer = Buffer.from(data.fileContent, 'base64')
fileName = 'file'
} else {
return NextResponse.json({ success: false, error: 'File required' }, { status: 400 })
}
// Now call external API with fileBuffer
const response = await fetch('https://api.{service}.com/upload', {
method: 'POST',
headers: { Authorization: `Bearer ${data.accessToken}` },
body: new Uint8Array(fileBuffer), // Convert Buffer for fetch
})
// ... handle response
})
```
#### 4. Update Tool to Use Internal Route
```typescript
export const {service}UploadTool: ToolConfig<Params, Response> = {
export const {service}UploadTool: InternalToolConfig<Params, Response> = {
id: '{service}_upload',
// ...
params: {
file: { type: 'file', required: false, visibility: 'user-or-llm' },
fileContent: { type: 'string', required: false, visibility: 'hidden' }, // Legacy
},
request: {
url: '/api/tools/{service}/upload', // Internal route
method: 'POST',
body: (params) => ({
operation: {
input: (params) => ({
accessToken: params.accessToken,
file: params.file,
fileContent: params.fileContent,
@@ -909,6 +823,13 @@ export const {service}UploadTool: ToolConfig<Params, Response> = {
}
```
Implement `apps/sim/lib/internal/{service}/execute-tool.ts` and keep the file/provider work in typed
operations beside it. The handler validates `request.input`, derives storage authority only from
trusted `request.context`, authorizes every stored file before reading bytes, forwards
`request.signal`, enforces declared and actual byte caps, and returns the canonical tool response.
Register `{service}_upload` in `apps/sim/lib/internal/tool-operations/registry.server.ts` and add a
registry/direct-handler test. There is no HTTP fallback.
### File Output Pattern (Downloads)
For tools that return files, use `FileToolProcessor` to store files and return `UserFile` objects.
@@ -936,11 +857,11 @@ transformResponse: async (response, context) => {
}
```
#### In API Route (for complex file handling)
#### In the operation handler (for complex file handling)
```typescript
// Return file data that FileToolProcessor can handle
return NextResponse.json({
// Return file data that FileToolProcessor can handle. No API route is involved.
return Response.json({
success: true,
output: {
file: {
+68 -8
View File
@@ -42,7 +42,29 @@ tools/{service}/
## Tool Configuration Structure
Every tool MUST follow this exact structure:
### Choose the execution boundary first
Every tool must use exactly one of these configurations:
- **In-process operation (preferred):** use `InternalToolConfig` when the executor and the
implementation run in the same Sim process/trust/runtime plane. Materialize typed
`operation.input`, implement the handler under `apps/sim/lib/internal/{service}/execute-tool.ts`,
and register every tool ID in `apps/sim/lib/internal/tool-operations/registry.server.ts`.
- **External provider request:** use `ToolConfig.request` only when the URL is an absolute external
HTTP(S) provider endpoint.
Never set a tool URL to `/api/...`, construct an absolute URL back to Sim, declare
`request.internal`, import a route module, or create an API route merely to normalize files,
authorize access, or reuse server code. A real browser/API route may remain as a thin adapter, but
the route and the tool must call the same operation directly. A true cross-process/capability
boundary uses an explicit server client and is not disguised as a tool self-hop.
For protected Sim resources, the internal handler calls the domain's authorized application use
case with trusted execution context; use the `migrate-application-operation` skill.
### External provider request
Use this structure only for an absolute external provider API:
```typescript
import type { {ServiceName}{Action}Params } from '@/tools/{service}/types'
@@ -126,6 +148,38 @@ export const {serviceName}{Action}Tool: ToolConfig<
}
```
### In-process operation
```typescript
import type { InternalToolConfig } from '@/tools/types'
export const {serviceName}{Action}Tool: InternalToolConfig<
{ServiceName}{Action}Params,
{ServiceName}{Action}Response
> = {
id: '{service}_{action}',
name: '{Service} {Action}',
description: 'Brief description',
version: '1.0.0',
params: {
// Same canonical metadata as an external tool.
},
operation: {
input: (params) => ({
// Map resolved tool params into the typed semantic operation input.
}),
},
outputs: {
// Define each output field.
},
}
```
The registered handler accepts `InternalToolOperationCall`, validates `request.input`, uses only
trusted `request.context` for authority, forwards `request.signal`, and returns the same bounded
`Response` contract expected by the tool executor. It has no URL, method, request headers, fetch
fallback, or caller-controlled `_context` authority.
## Critical Rules for Parameters
### Visibility Options
@@ -149,17 +203,17 @@ export const {serviceName}{Action}Tool: ToolConfig<
- Leave ordinary external API inputs and third-party results unchanged. Add provenance handling only
when an exact field is proven to cross a Sim model, durable-storage, or internal-execution boundary.
- Project AI-consumed text/structured fields with the smallest exact `request.modelInput` selector.
- Project AI-consumed text/structured fields with the smallest exact model-input selector:
`request.modelInput` for an external request or `operation.modelInput` for an in-process operation.
- Treat URLs, domains, resource IDs, and control fields as ordinary request values unless the exact
field is proven model-visible. For serialized external model content, project the serialized
top-level param through `request.modelInput` before the existing formatter parses it; do not add a
separate hard-rejection mechanism.
- For authenticated internal routes, use `privateProvenance` for actual inline/raw model bytes or
`request.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key,
- For in-process operations, use `operation.modelInput` for actual inline/raw model bytes or
`operation.secretProvenance` for durable writes and execution handoffs. Do not treat a storage key,
path, signed URL, or remote URL as provenance for fetched bytes; authorize tracked stored bytes at
the owning model-egress boundary. Authenticate first, validate the exact selection and scope,
strip the private envelope, then import or propagate provenance at the receiving boundary.
Preserve documented headerless legacy behavior.
the owning model-egress boundary. Validate the exact selection and trusted scope, then import or
propagate provenance at the receiving operation boundary.
- Never substitute secret plaintext into source, serialize plaintext provenance, hand-roll private
headers, or blanket-sanitize tool results.
- Add focused tests for named projection, identical unproven public text, malformed/incomplete
@@ -466,6 +520,10 @@ All tool IDs MUST use `snake_case`: `{service}_{action}` (e.g., `x_create_tweet`
## Checklist Before Finishing
- [ ] All tool IDs use snake_case
- [ ] Chose exactly one boundary: registered `InternalToolConfig.operation` or absolute external
HTTP(S) `ToolConfig.request`
- [ ] No tool request points to `/api/...`, constructs a URL back to Sim, or declares
`request.internal`
- [ ] All params have explicit `required: true` or `required: false`
- [ ] All params have appropriate `visibility`
- [ ] All nullable response fields use `?? null`
@@ -492,7 +550,9 @@ After creating all tools, you MUST validate every tool before finishing:
- All required params are marked `required: true`
- All optional params are marked `required: false`
- Param types match the API (string, number, boolean, json)
- Request URL, method, headers, and body match the API spec
- For external tools, request URL, method, headers, and body match the provider API spec
- For internal tools, `operation.input` matches the handler schema and the handler is registered
with no HTTP fallback
- `transformResponse` extracts the correct fields from the API response
- All output fields match what the API actually returns
- No fields are missing from outputs that the API provides
+5
View File
@@ -512,6 +512,11 @@ Two rules the checks enforce:
## Checklist
Webhook and polling routes are legitimate external ingress boundaries. They must not call this
Sim app's own API routes to reuse provider or business logic. Extract the shared provider operation
or authorized application use case and call it directly from the trigger handler and any other
server adapter. HTTP is reserved for an actual cross-process/capability boundary.
### Trigger Definition
- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT
+20
View File
@@ -19,6 +19,13 @@ inputs:
tags:
description: Comma-separated list of tags to push.
required: true
max-cache-size-mb:
description: >-
Layer cache to retain after the post-job prune, in MB. Must stay above one
build's working set (base + dependency layers + RUN --mount=type=cache
dirs) or every build evicts what the next one needs. Falls back to the
small-image default below when empty.
required: false
# Registry logins must precede this action. provenance/sbom stay off: attestation
# manifests break `imagetools create` retagging in promote-images.
@@ -42,11 +49,24 @@ runs:
PLATFORMS: ${{ inputs.platforms }}
run: echo "value=${GITHUB_REPOSITORY##*/}/${FILE#./}/${PLATFORMS//\//-}" >> "$GITHUB_OUTPUT"
# max-cache-size-mb is what bounds the disk: BuildKit's default GC is
# time-based only (layers unused for 8 days), and setup-docker-builder skips
# pruning altogether when the value is empty. On a repo that builds this
# often nothing ever ages out, so the disks grew without limit —
# app.Dockerfile/linux-amd64 reached 351 GB inside a day, and realtime, whose
# image is under 300 MB, sat at 249 GB. Sticky disks bill at ~$0.51/GB-month,
# so that was real money for layers no build would ever read again.
#
# The fallback is here rather than an input `default:` because callers pass
# this from a matrix field, and an unset matrix key arrives as the empty
# string — which counts as "provided", so a `default:` would never apply and
# a row that forgot the field would silently go back to unbounded growth.
- name: Set up Blacksmith builder
if: inputs.provider == '' || inputs.provider == 'blacksmith'
uses: useblacksmith/setup-docker-builder@a5256a73e30f09e37e3eceb8ca36043d17621d24 # v2
with:
cache-key: ${{ steps.cache-key.outputs.value }}
max-cache-size-mb: ${{ inputs.max-cache-size-mb || '25600' }}
- name: Build and push (Blacksmith)
if: inputs.provider == '' || inputs.provider == 'blacksmith'
+8
View File
@@ -31,3 +31,11 @@ paths-ignore:
- '**/dist/**'
- '**/.next/**'
- 'apps/docs/content/**'
# Do NOT add `queries:`, `packs:`, `query-filters:`, or `disable-default-queries`
# here to try to speed the scan up. Under the code-scanning feature flag the
# action's checkOverlayAnalysisFeatureEnabled treats any of those as
# OverlayDisabledReason.NonDefaultQueries and permanently turns off overlay
# (incremental) analysis. Extraction is ~53% of a run and is exactly what overlay
# skips, so scoping the queries trades a documented up-to-10x win for a few
# percent off the 27% query phase.
+22 -9
View File
@@ -76,7 +76,7 @@ jobs:
# (/api/desktop/update) starts offering automatically.
detect-desktop-changes:
name: Detect Desktop Changes
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
if: github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/staging')
outputs:
@@ -165,7 +165,15 @@ jobs:
# build` ~260s). The same `next build` runs on 16 vCPU in the separate
# Build App verification job, which does not gate anything; this one
# was doing comparable work on half the cores.
#
# cache_mb is the layer cache the post-job prune retains, and it is the
# only reason the sticky disks stay bounded — see docker-build's
# action.yml. Rows that omit it take the small-image default there. The
# app image overrides because it carries ~34 layers plus apt and bun
# cache mounts for the whole monorepo; 100 GB is several builds' worth
# of headroom over that working set.
- dockerfile: ./docker/app.Dockerfile
cache_mb: '102400'
ecr_repo_secret: ECR_APP
gh_runner: linux-x64-8-core
bs_runner: blacksmith-16vcpu-ubuntu-2404
@@ -176,11 +184,11 @@ jobs:
- dockerfile: ./docker/realtime.Dockerfile
ecr_repo_secret: ECR_REALTIME
gh_runner: ubuntu-latest
bs_runner: blacksmith-4vcpu-ubuntu-2404
bs_runner: blacksmith-2vcpu-ubuntu-2404
- dockerfile: ./docker/pii.Dockerfile
ecr_repo_secret: ECR_PII
gh_runner: ubuntu-latest
bs_runner: blacksmith-4vcpu-ubuntu-2404
bs_runner: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@@ -214,6 +222,7 @@ jobs:
file: ${{ matrix.dockerfile }}
platforms: linux/amd64
tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev
max-cache-size-mb: ${{ matrix.cache_mb }}
# Dev: deploy Trigger.dev background tasks to the preview "dev-sim" branch.
# Gated after migrate-dev for the same reason as build-dev — the new task
@@ -280,6 +289,7 @@ jobs:
matrix:
include:
- dockerfile: ./docker/app.Dockerfile
cache_mb: '102400'
ghcr_image: ghcr.io/simstudioai/simstudio
ecr_repo_secret: ECR_APP
gh_runner: linux-x64-8-core
@@ -293,12 +303,12 @@ jobs:
ghcr_image: ghcr.io/simstudioai/realtime
ecr_repo_secret: ECR_REALTIME
gh_runner: ubuntu-latest
bs_runner: blacksmith-4vcpu-ubuntu-2404
bs_runner: blacksmith-2vcpu-ubuntu-2404
- dockerfile: ./docker/pii.Dockerfile
ghcr_image: ghcr.io/simstudioai/pii
ecr_repo_secret: ECR_PII
gh_runner: ubuntu-latest
bs_runner: blacksmith-4vcpu-ubuntu-2404
bs_runner: blacksmith-2vcpu-ubuntu-2404
# No ECR repo is provisioned for cron, so it publishes to GHCR only.
# The tag step below omits the ECR tag when the repo name is empty.
- dockerfile: ./docker/cron.Dockerfile
@@ -382,6 +392,7 @@ jobs:
file: ${{ matrix.dockerfile }}
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
max-cache-size-mb: ${{ matrix.cache_mb }}
# Promote the sha-tagged ECR images to the deploy tags once tests and
# migrations pass. Pushing the ECR latest/staging tag is what triggers
@@ -484,6 +495,7 @@ jobs:
# hang a release in `queued` rather than fail a PR.
include:
- dockerfile: ./docker/app.Dockerfile
cache_mb: '102400'
image: ghcr.io/simstudioai/simstudio
gh_runner: linux-arm64-8-core
bs_runner: blacksmith-8vcpu-ubuntu-2404-arm
@@ -522,6 +534,7 @@ jobs:
file: ${{ matrix.dockerfile }}
platforms: linux/arm64
tags: ${{ matrix.image }}:${{ github.sha }}-arm64
max-cache-size-mb: ${{ matrix.cache_mb }}
# Publish all mutable GHCR tags (latest, latest-amd64/arm64, version tags)
# and the multi-arch manifests from the immutable sha tags — only on main,
@@ -675,7 +688,7 @@ jobs:
# Job-level `if:` cannot read the secrets context, hence the probe job.
check-desktop-signing:
name: Check Desktop Signing Secrets
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 2
needs: [detect-version, detect-desktop-changes]
# !cancelled(): detect-desktop-changes is skipped on main (and
@@ -724,7 +737,7 @@ jobs:
# remains testable end to end with a manual download.
create-desktop-prerelease:
name: Create Desktop Prerelease
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
needs: [detect-desktop-changes, check-desktop-signing]
# Requires the signing probe to have actually succeeded (not just "not
@@ -813,7 +826,7 @@ jobs:
# point of view.
publish-desktop-prerelease:
name: Publish Desktop Prerelease
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
needs: [create-desktop-prerelease, desktop-prerelease]
permissions:
@@ -837,7 +850,7 @@ jobs:
# are always garbage by this point — the current run's release is published.
prune-desktop-prereleases:
name: Prune Desktop Prereleases
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
timeout-minutes: 5
needs: [publish-desktop-prerelease]
permissions:
+29 -4
View File
@@ -20,8 +20,16 @@ on:
# created, prevents developers from introducing new vulnerabilities."
push:
branches: [main]
# main only, not staging. Feature PRs land on staging and are ~90% of PR scan
# volume, and every one of them is scanned again — against the exact tree being
# promoted — when the staging->main PR opens. Scanning at the promotion
# boundary defers the signal rather than dropping it.
#
# Deliberately a branch cut and not an activity-type cut: dropping
# `synchronize` would have scanned each PR's first commit and never its final
# state, which is backwards, since review fixups land in later pushes.
pull_request:
branches: [main, staging]
branches: [main]
# `ready_for_review` is not a default activity type, so it has to be listed
# alongside the defaults it replaces. Without it, a PR opened as a draft and
# then marked ready is skipped by the job-level draft guard and never
@@ -41,7 +49,15 @@ on:
# Safety net behind the push trigger, and the thing that keeps the
# default-branch alert view fresh when main is quiet. Only fires once this
# file is on the default branch — schedule events ignore other branches.
- cron: '17 8 * * 1'
#
# Daily rather than weekly. Pushes to main are rare, and with PR scans now
# limited to main the alert view leans on this more than it used to; a week
# is too long to leave it stale. It also reseeds the overlay-base database
# that PR runs restore from — that cache key embeds the CodeQL bundle
# version, so a bundle bump invalidates it, and an unused Actions cache is
# evicted after 7 days. One 8 vCPU default-branch scan a day is a few
# dollars a month against a PR scan that halves when the base is warm.
- cron: '17 8 * * *'
workflow_dispatch:
concurrency:
@@ -54,7 +70,12 @@ permissions:
jobs:
analyze:
name: Analyze ${{ matrix.language }}
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }}
# Sized per language, not per workflow. The two analyses are nothing alike:
# javascript-typescript peaks at 19.5 GB (p95 over 3090 runs), so it needs
# the 8 vCPU tier's 30.4 GB and would OOM on the 4 vCPU tier's 15.2 GB; the
# actions analysis peaks at 1.3 GB and averages 22% CPU over a 39s median
# run, so 8 vCPU was 4x more machine than it ever used.
runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.bs_runner || 'ubuntu-latest' }}
timeout-minutes: 60
if: github.event.pull_request.draft != true
permissions:
@@ -71,7 +92,11 @@ jobs:
# entries default setup listed were one analysis, not three.
# `javascript-typescript` is the documented spelling. Python dropped:
# 7 files in the tree.
language: [javascript-typescript, actions]
include:
- language: javascript-typescript
bs_runner: blacksmith-8vcpu-ubuntu-2404
- language: actions
bs_runner: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
+8
View File
@@ -40,6 +40,14 @@ You are a professional software engineer. All code must follow best practices: a
- Never substitute a billing owner, uploader, creator, or API-key owner for the acting principal. Fail fast when the identity model or operation policy cannot express the caller.
- Use the `migrate-application-operation` skill whenever creating or migrating a protected endpoint, tool command, or resource method.
### Tool Execution Boundary
- A tool has exactly one execution boundary. Use `InternalToolConfig.operation` when the executor can call the implementation in the same process and trust/runtime plane. Put the server handler under `apps/sim/lib/internal/<service>/execute-tool.ts` and register it in `apps/sim/lib/internal/tool-operations/registry.server.ts`.
- `ToolConfig.request` is only for absolute external HTTP(S) provider APIs. A tool definition must never point at `/api/...`, construct an absolute URL back to this Sim app, or declare an `internal` request policy. Do not add a same-origin route merely to reuse code, normalize files, or perform authorization.
- Real browser/API ingress and real cross-process capability boundaries may remain HTTP. Their route and any in-process tool adapter call the same application/provider operation; neither calls the other, and tool code never imports route modules.
- Protected Sim resources still enter through authorized application use cases. The internal tool handler is a trusted surface adapter, not an authorization or database bypass.
- `bun run check:tool-request-boundary` rejects detectable tool self-hops, the external request formatter rejects relative URLs at runtime, and the internal-operation registry test requires every operation-backed tool to have a loadable handler.
### Root Structure
```
@@ -43,7 +43,6 @@ Retrieve content from Confluence pages using the Confluence API.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | Confluence page ID to retrieve \(numeric ID from page URL or API\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -81,7 +80,6 @@ Update a Confluence page using the Confluence API.
| `pageId` | string | Yes | Confluence page ID to update \(numeric ID from page URL or API\) |
| `title` | string | No | New title for the page |
| `content` | string | No | New content for the page in Confluence storage format |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -124,7 +122,6 @@ Create a new page in a Confluence space.
| `title` | string | Yes | Title of the new page |
| `content` | string | Yes | Page content in Confluence storage format \(HTML\) |
| `parentId` | string | No | Parent page ID if creating a child page |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -165,7 +162,6 @@ Delete a Confluence page. By default moves to trash; use purge=true to permanent
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | Confluence page ID to delete |
| `purge` | boolean | No | If true, permanently deletes the page instead of moving to trash \(default: false\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -189,7 +185,6 @@ List all pages within a specific Confluence space. Supports pagination and filte
| `status` | string | No | Filter pages by status: current, archived, trashed, or draft |
| `bodyFormat` | string | No | Format for page body content: storage, atlas_doc_format, or view. If not specified, body is not included. |
| `cursor` | string | No | Pagination cursor from previous response to get the next page of results |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -235,7 +230,6 @@ Get all child pages of a specific Confluence page. Useful for navigating page hi
| `pageId` | string | Yes | The ID of the parent page to get children from |
| `limit` | number | No | Maximum number of child pages to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response to get the next page of results |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -263,7 +257,6 @@ Get the ancestor (parent) pages of a specific Confluence page. Returns the full
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | The ID of the page to get ancestors for |
| `limit` | number | No | Maximum number of ancestors to return \(default: 25, max: 250\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -290,7 +283,6 @@ List all versions (revision history) of a Confluence page.
| `pageId` | string | Yes | The ID of the page to get versions for |
| `limit` | number | No | Maximum number of versions to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -317,7 +309,6 @@ Get details about a specific version of a Confluence page.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | The ID of the page |
| `versionNumber` | number | Yes | The version number to retrieve \(e.g., 1, 2, 3\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -353,7 +344,6 @@ List all custom properties (metadata) attached to a Confluence page.
| `pageId` | string | Yes | The ID of the page to list properties from |
| `limit` | number | No | Maximum number of properties to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -385,7 +375,6 @@ Create a new custom property (metadata) on a Confluence page.
| `pageId` | string | Yes | The ID of the page to add the property to |
| `key` | string | Yes | The key/name for the property |
| `value` | json | Yes | The value for the property \(can be any JSON value\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -414,7 +403,6 @@ Delete a content property from a Confluence page by its property ID.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | The ID of the page containing the property |
| `propertyId` | string | Yes | The ID of the property to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -436,7 +424,6 @@ Search for content across Confluence pages, blog posts, and other content.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `query` | string | Yes | Search query string |
| `limit` | number | No | Maximum number of results to return \(default: 25\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -471,7 +458,6 @@ Search for content within a specific Confluence space. Optionally filter by text
| `query` | string | No | Text search query. If not provided, returns all content in the space. |
| `contentType` | string | No | Filter by content type: page, blogpost, attachment, or comment |
| `limit` | number | No | Maximum number of results to return \(default: 25, max: 250\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -508,7 +494,6 @@ List all blog posts across all accessible Confluence spaces.
| `status` | string | No | Filter by status: current, archived, trashed, or draft |
| `sort` | string | No | Sort order: created-date, -created-date, modified-date, -modified-date, title, -title |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -542,7 +527,6 @@ Get a specific Confluence blog post by ID, including its content.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `blogPostId` | string | Yes | The ID of the blog post to retrieve |
| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, or view |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -586,7 +570,6 @@ Create a new blog post in a Confluence space.
| `title` | string | Yes | Title of the blog post |
| `content` | string | Yes | Blog post content in Confluence storage format \(HTML\) |
| `status` | string | No | Blog post status: current \(default\) or draft |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -630,7 +613,6 @@ List all blog posts within a specific Confluence space.
| `status` | string | No | Filter by status: current, archived, trashed, or draft |
| `bodyFormat` | string | No | Format for blog post body: storage, atlas_doc_format, or view |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -674,7 +656,6 @@ Add a comment to a Confluence page.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | Confluence page ID to comment on |
| `comment` | string | Yes | Comment text in Confluence storage format |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -697,7 +678,6 @@ List all comments on a Confluence page.
| `limit` | number | No | Maximum number of comments to return \(default: 25\) |
| `bodyFormat` | string | No | Format for the comment body: storage, atlas_doc_format, view, or export_view \(default: storage\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -735,7 +715,6 @@ Update an existing comment on a Confluence page.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `commentId` | string | Yes | Confluence comment ID to update |
| `comment` | string | Yes | Updated comment text in Confluence storage format |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -755,7 +734,6 @@ Delete a comment from a Confluence page.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `commentId` | string | Yes | Confluence comment ID to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -778,7 +756,6 @@ Upload a file as an attachment to a Confluence page.
| `file` | file | Yes | The file to upload as an attachment |
| `fileName` | string | No | Optional custom file name for the attachment |
| `comment` | string | No | Optional comment to add to the attachment |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -804,7 +781,6 @@ List all attachments on a Confluence page.
| `pageId` | string | Yes | Confluence page ID to list attachments from |
| `limit` | number | No | Maximum number of attachments to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -840,7 +816,6 @@ Delete an attachment from a Confluence page (moves to trash).
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `attachmentId` | string | Yes | Confluence attachment ID to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -862,7 +837,6 @@ List all labels on a Confluence page.
| `pageId` | string | Yes | Confluence page ID to list labels from |
| `limit` | number | No | Maximum number of labels to return \(default: 25, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -887,7 +861,6 @@ Add a label to a Confluence page for organization and categorization.
| `pageId` | string | Yes | Confluence page ID to add the label to |
| `labelName` | string | Yes | Name of the label to add |
| `prefix` | string | No | Label prefix: global \(default\), my, team, or system |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -909,7 +882,6 @@ Remove a label from a Confluence page.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `pageId` | string | Yes | Confluence page ID to remove the label from |
| `labelName` | string | Yes | Name of the label to remove |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -932,7 +904,6 @@ Retrieve all pages that have a specific label applied.
| `labelId` | string | Yes | The ID of the label to get pages for |
| `limit` | number | No | Maximum number of pages to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -968,7 +939,6 @@ List all labels associated with a Confluence space.
| `spaceId` | string | Yes | The ID of the Confluence space to list labels from |
| `limit` | number | No | Maximum number of labels to return \(default: 25, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -992,7 +962,6 @@ Get details about a specific Confluence space.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Confluence space ID to retrieve |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1024,7 +993,6 @@ Create a new Confluence space.
| `name` | string | Yes | Name for the new space |
| `key` | string | Yes | Unique key for the space \(uppercase, no spaces\) |
| `description` | string | No | Description for the new space |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1054,7 +1022,6 @@ Update a Confluence space name or description.
| `spaceId` | string | Yes | ID of the space to update |
| `name` | string | No | New name for the space |
| `description` | string | No | New description for the space |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1081,7 +1048,6 @@ Delete a Confluence space.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | ID of the space to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1104,7 +1070,6 @@ List all Confluence spaces accessible to the user.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `limit` | number | No | Maximum number of spaces to return \(default: 25, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1137,7 +1102,6 @@ List properties on a Confluence space.
| `spaceId` | string | Yes | Space ID to list properties for |
| `limit` | number | No | Maximum number of properties to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1163,7 +1127,6 @@ Create a property on a Confluence space.
| `spaceId` | string | Yes | Space ID to create the property on |
| `key` | string | Yes | Property key/name |
| `value` | json | No | Property value \(JSON\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1186,7 +1149,6 @@ Delete a property from a Confluence space.
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `spaceId` | string | Yes | Space ID the property belongs to |
| `propertyId` | string | Yes | Property ID to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1209,7 +1171,6 @@ List permissions for a Confluence space.
| `spaceId` | string | Yes | Space ID to list permissions for |
| `limit` | number | No | Maximum number of permissions to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1239,7 +1200,6 @@ Get all descendants of a Confluence page recursively.
| `pageId` | string | Yes | Page ID to get descendants for |
| `limit` | number | No | Maximum number of descendants to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1273,7 +1233,6 @@ List inline tasks from Confluence. Optionally filter by page, space, assignee, o
| `status` | string | No | Filter tasks by status \(complete or incomplete\) |
| `limit` | number | No | Maximum number of tasks to return \(default: 50, max: 250\) |
| `cursor` | string | No | Pagination cursor from previous response |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1307,7 +1266,6 @@ Get a specific Confluence inline task by ID.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `taskId` | string | Yes | The ID of the task to retrieve |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1340,7 +1298,6 @@ Update the status of a Confluence inline task (complete or incomplete).
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `taskId` | string | Yes | The ID of the task to update |
| `status` | string | Yes | New status for the task \(complete or incomplete\) |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1374,7 +1331,6 @@ Update an existing Confluence blog post title and/or content.
| `blogPostId` | string | Yes | The ID of the blog post to update |
| `title` | string | No | New title for the blog post |
| `content` | string | No | New content for the blog post in storage format |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1398,7 +1354,6 @@ Delete a Confluence blog post.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `blogPostId` | string | Yes | The ID of the blog post to delete |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -1418,7 +1373,6 @@ Get display name and profile info for a Confluence user by account ID.
| --------- | ---- | -------- | ----------- |
| `domain` | string | Yes | Your Confluence domain \(e.g., yourcompany.atlassian.net\) |
| `accountId` | string | Yes | The Atlassian account ID of the user to look up |
| `cloudId` | string | No | Confluence Cloud ID for the instance. If not provided, it will be fetched using the domain. |
#### Output
@@ -46,6 +46,7 @@ List files and folders in Google Drive with complete metadata
| `folderId` | string | No | The ID of the folder to list files from \(internal use\) |
| `query` | string | No | Search term to filter files by name \(e.g. "budget" finds files with "budget" in the name\). Do NOT use Google Drive query syntax here - just provide a plain search term. |
| `pageSize` | number | No | The maximum number of files to return \(default: 100\) |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
@@ -484,6 +485,7 @@ Search for files in Google Drive using advanced query syntax (e.g., fullText con
| --------- | ---- | -------- | ----------- |
| `query` | string | Yes | Google Drive query string using advanced search syntax \(e.g., "fullText contains 'budget'", "mimeType = 'application/pdf'", "modifiedTime &gt; '2024-01-01'"\) |
| `pageSize` | number | No | Maximum number of files to return \(default: 100\) |
| `pageToken` | string | No | Token for fetching the next page of results |
#### Output
@@ -671,6 +673,7 @@ List all permissions (who has access) for a file in Google Drive
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to list permissions for |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
@@ -720,6 +723,7 @@ List the revision history of a file in Google Drive
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | The ID of the file to list revisions for |
| `pageSize` | number | No | Maximum number of revisions to return \(1-1000, default 200\) |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
@@ -779,6 +783,7 @@ List comments on a file in Google Drive
| `includeDeleted` | boolean | No | Whether to include deleted comments \(their content is stripped\) |
| `pageSize` | number | No | Maximum number of comments to return \(1-100, default 20\) |
| `startModifiedTime` | string | No | Only return comments modified after this RFC 3339 timestamp |
| `pageToken` | string | No | The page token to use for pagination |
#### Output
@@ -331,7 +331,7 @@ Upload an evidence file to a Vanta document. Requires credentials with the vanta
| `documentId` | string | Yes | Unique ID of the document to attach the file to |
| `file` | file | No | The evidence file to upload |
| `fileName` | string | No | Optional file name override |
| `mimeType` | string | No | MIME type of the file \(e.g., application/pdf\). Used only for base64 uploads; ignored for a file from the File input, whose content type is always resolved from storage. |
| `mimeType` | string | No | MIME type of the file \(e.g., application/pdf\). Applies only to the base64 upload path; a file from the File input always sends the content type resolved from storage. |
| `description` | string | No | Description of the uploaded evidence \(e.g., "Q3 access review evidence"\) |
| `effectiveAtDate` | string | No | ISO 8601 date indicating when the document is effective from |
@@ -17,7 +17,7 @@ All figures are in **credits** (1 credit = $0.005). See [cost calculation](/plat
Go to **Settings → Organization → Usage tracking** in your workspace.
<Image src="/static/enterprise/usage-tracking-overview.png" alt="Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources breakdown listing Sim Chat, Workflow, and Agent block" width={900} height={578} />
<Image src="/static/enterprise/usage-tracking-overview.png" alt="Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources section pairing a ranked list of sources with a radar chart of the same mix" width={900} height={781} />
The period selector applies to every tab:
@@ -40,9 +40,8 @@ The period selector applies to every tab:
| **Members** | Which people are driving usage |
| **Workspaces** | Which workspaces are driving usage — select one to drill in |
| **Models** | Which models we are paying for |
| **BYOK** | What ran on our own provider keys |
Selecting a workspace opens its detail view, which splits that workspace's usage into **Sources** (what kind of work) and **Workflows** (the individual workflow runs). **Open logs** jumps to that workspace's execution logs.
Selecting a workspace opens its detail view, which splits that workspace's usage into **Sources** (what kind of work) and **Workflows** (the individual workflow runs). **Open logs** jumps to [audit logs](/platform/enterprise/audit-logs) filtered to that workspace.
<Image src="/static/enterprise/usage-tracking-workspace-detail.png" alt="A workspace's detail view with a Sources section listing Sim Chat and Workflow, and a Workflows section ranking individual workflows by credits" width={900} height={598} />
@@ -90,12 +89,10 @@ These follow from how charges are recorded, and they explain most questions abou
## Bring your own keys (BYOK)
When a workspace or organization supplies its own provider key, Sim does not charge for that model usage. Those calls are still recorded so you can see the volume.
The **BYOK** tab groups this usage by provider and reports **tokens** rather than credits, because the credit cost is zero by definition. Tokens on your own keys are not included in the credit totals anywhere else in the panel.
When a workspace or organization supplies its own provider key, Sim does not charge for that model usage. Those calls are still recorded, measured in **tokens** rather than credits — the credit cost is zero by definition — and they are not included in the credit totals anywhere in the panel.
<Callout type="info">
Tool calls and Chat usage made on your own keys are not yet recorded. BYOK currently covers model usage in workflow runs.
This usage is not broken out in the panel today. Of what is recorded, only model usage in workflow runs is covered: tool calls and Chat usage made on your own keys are not recorded at all.
</Callout>
---
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 245 KiB

@@ -164,4 +164,29 @@ describe('GET /api/audit-logs/export', () => {
expect(response.status).toBe(400)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
/**
* The export has to filter by everything the on-screen feed does. It did not
* forward `workspaceId`, so an admin exporting from a workspace-scoped feed
* downloaded the whole organization silently, because every field of
* `AuditLogFilterParams` is optional and dropping one still type-checks.
*/
it('forwards the workspace filter the on-screen feed applies', async () => {
mockGetOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
await GET(makeRequest('?workspaceId=workspace-1'))
expect(mockBuildFilterConditions).toHaveBeenCalledWith(
expect.objectContaining({ workspaceId: 'workspace-1' })
)
})
it('rejects a workspaceId outside the organization, as the list route does', async () => {
mockGetOrgWorkspaceIds.mockResolvedValue(['workspace-1'])
const response = await GET(makeRequest('?workspaceId=workspace-elsewhere'))
expect(response.status).toBe(400)
expect(mockQueryAuditLogs).not.toHaveBeenCalled()
})
})
+23 -10
View File
@@ -66,8 +66,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
const { organizationId, orgMemberIds } = authResult.context
const { search, action, resourceType, actorId, startDate, endDate, includeDeparted } =
parsed.data.query
const { actorId, workspaceId, includeDeparted } = parsed.data.query
if (actorId && !orgMemberIds.includes(actorId)) {
return NextResponse.json(
@@ -77,20 +76,34 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
const orgWorkspaceIds = await getOrgWorkspaceIds(organizationId)
/**
* The same refusal `listAuditLogs` gives. The scope predicate already makes an
* out-of-organization id return nothing, but an empty CSV and a 400 that names the
* problem are very different answers to the same bad request, and the two paths
* disagreeing about which one you get is what an audit trail cannot afford.
*/
if (workspaceId && !orgWorkspaceIds.includes(workspaceId)) {
return NextResponse.json(
{ error: 'workspaceId does not belong to your organization' },
{ status: 400 }
)
}
const scopeCondition = buildOrgScopeCondition({
organizationId,
orgWorkspaceIds,
orgMemberIds,
includeDeparted,
})
const filterConditions = buildFilterConditions({
action,
resourceType,
actorId,
search,
startDate,
endDate,
})
/**
* The whole parsed query, not a hand-listed subset.
*
* Every field of `AuditLogFilterParams` is optional, so dropping one type-checks
* silently which is how `workspaceId` came to be accepted by the contract,
* honoured by the list route, and ignored here: an admin looking at one
* workspace's feed downloaded the entire organization's, under a truncation
* warning that blamed the date range.
*/
const filterConditions = buildFilterConditions(parsed.data.query)
const conditions = [scopeCondition, ...filterConditions]
const rows: ReturnType<typeof formatAuditLogEntry>[] = []
+1
View File
@@ -27,6 +27,7 @@ export const GET = defineInternalJsonRoute({
action: query.action,
resourceType: query.resourceType,
actorId: query.actorId,
workspaceId: query.workspaceId,
startDate: query.startDate,
endDate: query.endDate,
},
@@ -95,12 +95,26 @@ describe('POST /api/copilot/tools/execute (in-band)', () => {
expect(body.error).toBe('File not found: files/a.md')
})
it('withholds results when no egress registry can be built', async () => {
mockPrepareEnvironmentContext.mockRejectedValue(new Error('env unavailable'))
/**
* Running the tool without a catalog used to produce the worst pair of outcomes available:
* the side effect happened and the caller got a bare `{success: true}` naming neither the
* cause nor whether anything had changed.
*/
it('refuses the call, without running the tool, when no egress registry can be built', async () => {
mockPrepareEnvironmentContext.mockRejectedValue(new Error('Workspace ws-gone does not exist'))
mockHandler.mockResolvedValue({ success: true, output: { content: 'sensitive' } })
const res = await POST(makeRequest({ ...BASE_BODY, messageId: 'msg-no-registry' }) as never)
const body = await res.json()
expect(body).toEqual({ success: true })
expect(mockHandler).not.toHaveBeenCalled()
expect(body.success).toBe(false)
expect(body.output).toEqual({ resultWithheld: true, effect: 'not_attempted' })
// The thrown reason is an unprojectable environment failure — the catalog that would
// vouch for it is the very thing missing — so it stays in the log.
expect(body.error).not.toContain('does not exist')
expect(body.error).toContain(BASE_BODY.workspaceId)
expect(body.error).toContain('could not be resolved')
})
it('reuses one turn registry across calls that share a messageId', async () => {
@@ -4,11 +4,13 @@ import { type NextRequest, NextResponse } from 'next/server'
import { copilotToolExecuteInternalBodySchema } from '@/lib/api/contracts/copilot'
import { validationErrorResponse } from '@/lib/api/server'
import { prepareCopilotEnvironmentContext } from '@/lib/copilot/environment-context'
import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
projectToolErrorMessageForCopilot,
} from '@/lib/copilot/request/tools/resolved-secret-result'
@@ -16,6 +18,7 @@ import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources
import type { ToolCallResult } from '@/lib/copilot/request/types'
import { ensureHandlersRegistered } from '@/lib/copilot/tool-executor'
import { executeTool } from '@/lib/copilot/tool-executor/executor'
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
@@ -115,17 +118,43 @@ export const POST = withRouteHandler((request: NextRequest) =>
[TraceAttr.UserId]: userId,
})
let toolRegistry: ResolvedSecretTraceRegistry | undefined
let turnRegistry: ResolvedSecretTraceRegistry | undefined
let toolRegistry: ResolvedSecretTraceRegistry
let turnRegistry: ResolvedSecretTraceRegistry
try {
turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId)
toolRegistry = turnRegistry.forkForInputPaths([])
} catch (err) {
logger.error('In-band egress registry unavailable; results will be withheld', {
/**
* Without a catalog the projection can vouch for nothing, so every result this call
* could produce would be withheld. Running the tool anyway was the worst of both
* outcomes: the side effect happened and the caller got an opaque sentinel that named
* neither the cause nor whether anything had changed. Refusing before dispatch is
* both truthful and the only answer that leaves nothing behind.
*
* The cause is almost always the workspace itself a deleted or inaccessible id
* reaching this lane which is actionable, so it is reported rather than swallowed.
*/
logger.error('In-band egress registry unavailable; refusing the call', {
toolName,
toolCallId,
userId,
workspaceId,
error: getErrorMessage(err),
})
rootSpan.setAttributes({ [TraceAttr.ToolOutcome]: MothershipStreamV1ToolOutcome.error })
/**
* The thrown reason stays in the log. It is an environment or database failure that
* nothing here can project the catalog it needed is the very thing that is missing
* so this is the one message on this route that must be fixed text. The workspace id
* is echoed because the caller supplied it, and it is what makes this actionable.
*/
return NextResponse.json({
success: false,
error: workspaceId
? `${toolName} was not run: its workspace (${workspaceId}) could not be resolved. Check that the workspace exists and is accessible before retrying.`
: `${toolName} was not run: its execution environment could not be resolved.`,
output: { resultWithheld: true, effect: TOOL_EFFECT_PHASE.notAttempted },
})
}
try {
@@ -148,9 +177,23 @@ export const POST = withRouteHandler((request: NextRequest) =>
})
const projection = inspectToolResultForCopilot(result, toolRegistry, toolName)
const projected = projection.result
if (projection.safe && toolRegistry?.isComplete() && turnRegistry) {
if (projection.safe && toolRegistry.isComplete()) {
turnRegistry.mergeToolCallRegistry(toolRegistry)
}
if (!projection.safe) {
/**
* Reported on its own rather than folded into the failure branch below: a withheld
* SUCCESS keeps `projected.success` true, so gating on failure meant the one case
* that leaves no other trace the model reads a bare success was also the one
* case whose cause was never written down.
*/
logger.warn('In-band tool result withheld by egress projection', {
toolName,
toolCallId,
runtimeSucceeded: result.success,
...describeWithholdingCause(projection.cause),
})
}
if (!projected.success) {
logger.warn('In-band tool execution failed', {
toolName,
@@ -38,7 +38,7 @@ function resolveContentProvenance(
headers: request.headers,
payload,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, workspaceId),
...(workspaceId ? { workspaceId } : {}),
selectionKeys: includeContent ? ['chunk-content'] : [],
})
@@ -65,7 +65,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
headers: request.headers,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
chunks: [
@@ -102,7 +102,7 @@ export const PUT = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
headers: request.headers,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
chunks: [
@@ -39,7 +39,7 @@ function resolveContentProvenance(
headers: request.headers,
payload,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, workspaceId),
...(workspaceId ? { workspaceId } : {}),
selectionKeys: includeContent ? ['chunk-content'] : [],
})
@@ -70,7 +70,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
headers: request.headers,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
chunks: result.chunks.map((chunk) => ({
@@ -45,7 +45,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
headers: request.headers,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
documents: [
@@ -60,7 +60,7 @@ export const GET = defineInternalJsonRoute({
finalizeKnowledgePersistedResponse({
headers: request.headers,
authType: internalKnowledgeAuthType(principal),
userId: internalKnowledgeProvenanceUserId(request, principal, result.workspaceId),
userId: internalKnowledgeProvenanceUserId(request.headers, principal, result.workspaceId),
workspaceId: result.workspaceId,
body,
documents: result.documents.map((document) => ({
@@ -587,14 +587,22 @@
}
/* Cmd/Ctrl+F match highlights. Every hit carries the same tint the rest of the app
* paints a search match with; the active one is ringed rather than recolored, so the
* two read as one family and neither needs a token that only exists here. */
* paints a search match with. */
.rich-markdown-nodes .rich-find-match {
background-color: var(--highlight-match-bg);
color: var(--highlight-match-text);
border-radius: 2px;
}
/* The active hit is a stronger fill of the same hue, never a ring: an outline drawn
* around a run of text traces the line box, so on a heading it reads as a stray
* rectangle rather than as emphasis. Filling instead keeps the two states one family.
*
* The ink is fixed rather than tokenized because the fill is: `--brand-secondary` is
* the same blue in both themes, so a theme-flipping text token would go white on light
* blue in dark mode. Same reasoning as the note card's active search mark, which pairs
* its solid fill with a fixed dark ink for exactly this reason. */
.rich-markdown-nodes .rich-find-match-active {
box-shadow: 0 0 0 1.5px var(--highlight-match-text);
background-color: var(--brand-secondary);
color: #000;
}
@@ -176,7 +176,11 @@ export function SettingsPage({ section }: SettingsPageProps) {
<AuditLogs organizationId={organizationId} />
)}
{effectiveSection === 'usage' && organizationId && (
<UsageMonitoring organizationId={organizationId} workspaceId={hostContext.workspace.id} />
<UsageMonitoring
organizationId={organizationId}
eventsHref={`/workspace/${hostContext.workspace.id}/settings/usage/events`}
auditLogsHref={`/workspace/${hostContext.workspace.id}/settings/audit-logs`}
/>
)}
{effectiveSection === 'apikeys' && <ApiKeys scope='combined' />}
{isBillingEnabled && effectiveSection === 'billing' && (
@@ -121,10 +121,14 @@ export function UsageLimitField({
return (
<SettingsSection label='Usage limit' headerAccessory={USAGE_LIMIT_INFO}>
{/*
Text with a numeric input mode rather than `type='number'`: the native stepper
is all that type buys and it does not fit the chip chrome. The minimum is
enforced on commit below, where it can explain itself, rather than by a `min`
attribute the browser enforces silently.
*/}
<ChipInput
type='number'
inputMode='numeric'
min={dollarsToCredits(minimumLimit)}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder={
@@ -135,7 +139,6 @@ export function UsageLimitField({
: String(dollarsToCredits(currentLimit))
}
disabled={!canEdit}
inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
/>
</SettingsSection>
)
@@ -109,9 +109,17 @@ export function ManageCreditsModal({
value={isLoading ? 'Loading…' : creditsUsed}
copyLabel='Copy credits used'
/>
{/*
Text with a numeric input mode, not `inputType='number'` the same choice
the retry settings field documents. The native stepper is all the number
type buys, and it paints browser chrome inside a flat chip surface. It also
reports `''` for anything the browser considers invalid, so a typo arrived
here indistinguishable from a cleared field and saved as "no limit"; as text
it reaches the `Number.isInteger` check below and is refused.
*/}
<ChipModalField
type='input'
inputType='number'
inputMode='numeric'
title={
<span className='inline-flex items-center gap-1.5'>
Credit limit
@@ -16,7 +16,7 @@ export default function UsageEventsLoading() {
onSelect: () => router.push(`/workspace/${workspaceId}/settings/usage`),
}}
title='Usage events'
description='Every credit-consuming event behind your usage.'
description="Every credit-consuming event across your organization's workspaces."
/>
)
}
@@ -227,6 +227,74 @@ describe('async preprocessing correlation threading', () => {
)
})
it.each([
{
name: 'workspace API key',
serializedPrincipal: {
version: 1 as const,
principal: {
kind: 'workspace_api_key' as const,
workspaceId: 'workspace-1',
keyId: 'workspace-key-1',
},
},
isPublicApiAccess: false,
},
{
name: 'public API system',
serializedPrincipal: {
version: 1 as const,
principal: {
kind: 'system' as const,
serviceId: 'public_api' as const,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
},
},
isPublicApiAccess: true,
},
])(
'restores the exact serialized $name principal before Trigger worker execution',
async ({ serializedPrincipal, isPublicApiAccess }) => {
mockPreprocessExecution.mockResolvedValueOnce({
success: true,
actorUserId: 'actor-1',
workflowRecord: {
id: 'workflow-1',
userId: 'owner-1',
workspaceId: 'workspace-1',
variables: {},
},
billingAttribution,
executionTimeout: {},
})
mockExecuteWorkflowCore.mockResolvedValueOnce({
success: true,
status: 'success',
output: { ok: true },
metadata: { duration: 10, userId: 'actor-1' },
})
await executeWorkflowJob({
principal: serializedPrincipal,
workflowId: 'workflow-1',
userId: 'actor-1',
workspaceId: 'workspace-1',
billingAttribution,
triggerType: 'api',
executionId: `execution-${serializedPrincipal.principal.kind}`,
requestId: `request-${serializedPrincipal.principal.kind}`,
isPublicApiAccess,
})
const executionMetadata = mockExecutionSnapshot.mock.calls[0]?.[0]
expect(executionMetadata.userId).toBe('actor-1')
expect(executionMetadata.principal).toEqual(serializedPrincipal.principal)
expect(executionMetadata.isPublicApiAccess).toBe(isPublicApiAccess)
expect(executionMetadata.principal).not.toHaveProperty('userId')
}
)
it('restores a legacy authenticated workflow job as its recorded user actor', async () => {
mockPreprocessExecution.mockResolvedValueOnce({
success: true,
@@ -545,6 +613,14 @@ describe('async preprocessing correlation threading', () => {
loggingSession,
})
)
const executionMetadata = mockExecutionSnapshot.mock.calls[0]?.[0]
expect(executionMetadata.userId).toBe('actor-2')
expect(executionMetadata.principal).toEqual({
kind: 'system',
serviceId: 'schedule',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
})
})
it('passes workflow correlation into preprocessing', async () => {
@@ -98,7 +98,14 @@ export async function executeConnectorSyncJob(payload: unknown) {
export const knowledgeConnectorSync = task({
id: 'knowledge-connector-sync',
maxDuration: CONNECTOR_SYNC_MAX_DURATION_SECONDS,
machine: 'large-2x',
/**
* Sized from production telemetry: peak sampled RSS 2.6 GB and peak 1.4 vCPU,
* so `large-1x` holds ~3x memory and ~2.8x CPU headroom. No `outOfMemory`
* escalation: an OOM is a SIGKILL, so the run never reaches the terminal
* write that clears `syncLockToken`, and the escalated attempt would find the
* row still `syncing` and skip. The stale-lock reaper owns that recovery.
*/
machine: 'large-1x',
retry: {
maxAttempts: 3,
factor: 2,
+7 -1
View File
@@ -135,7 +135,13 @@ export async function runDocumentProcessing(
export const processDocument = task({
id: 'knowledge-process-document',
maxDuration: envNumber(env.KB_CONFIG_MAX_DURATION, 600),
machine: 'large-1x', // 4 vCPU, 8GB RAM - needed for large PDF processing
/**
* Sized from production telemetry: peak sampled RSS 902 MB and peak 1.2 vCPU
* across a corpus where no document exceeded 2 GB, so `medium-2x` holds ~4x
* memory and ~1.7x CPU headroom over the observed worst case. The prior
* `large-1x` reserved 8 GB against a worst case using an eighth of it.
*/
machine: 'medium-2x',
retry: {
maxAttempts: envNumber(env.KB_CONFIG_MAX_ATTEMPTS, 3),
factor: envNumber(env.KB_CONFIG_RETRY_FACTOR, 2),
@@ -346,6 +346,51 @@ describe('executeWebhookJob fault vs error handling', () => {
)
})
it('restores the exact serialized webhook principal without substituting the billing actor', async () => {
const serializedPrincipal = {
version: 1 as const,
principal: {
kind: 'system' as const,
serviceId: 'webhook' as const,
webhookId: 'webhook-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
provider: 'slack',
subject: {
kind: 'external_user' as const,
provider: 'slack',
tenantId: 'team-1',
subjectId: 'slack-user-1',
},
},
}
mockExecuteWorkflowCore.mockResolvedValueOnce({
success: true,
status: 'completed',
output: {},
logs: [],
executionState: {
blockStates: {},
executedBlocks: [],
blockLogs: [],
decisions: {},
completedLoops: [],
activeExecutionPath: [],
},
})
await executeWebhookJob({
...payload,
provider: 'slack',
principal: serializedPrincipal,
})
const executionMetadata = mockExecutionSnapshot.mock.calls[0]?.[0]
expect(executionMetadata.userId).toBe('user-1')
expect(executionMetadata.principal).toEqual(serializedPrincipal.principal)
expect(executionMetadata.principal).not.toHaveProperty('userId')
})
it('persists the reconstructed legacy principal on setup retries', async () => {
executionPreprocessingMockFns.mockPreprocessExecution.mockResolvedValueOnce({
success: false,
@@ -0,0 +1,97 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/triggers', () => ({
getTrigger: () => ({ subBlocks: [] }),
}))
import { GoogleDriveBlock } from '@/blocks/blocks/google_drive'
import { listTool } from '@/tools/google_drive/list'
import { listCommentsTool } from '@/tools/google_drive/list_comments'
import { listPermissionsTool } from '@/tools/google_drive/list_permissions'
import { listRevisionsTool } from '@/tools/google_drive/list_revisions'
import { searchTool } from '@/tools/google_drive/search'
const paginationCases = [
{ operation: 'list', subBlockId: 'pageToken', tool: listTool },
{ operation: 'search', subBlockId: 'searchPageToken', tool: searchTool },
{ operation: 'list_permissions', subBlockId: 'permissionsPageToken', tool: listPermissionsTool },
{ operation: 'list_revisions', subBlockId: 'revisionsPageToken', tool: listRevisionsTool },
{ operation: 'list_comments', subBlockId: 'commentsPageToken', tool: listCommentsTool },
] as const
describe('GoogleDriveBlock pagination', () => {
const buildParams = GoogleDriveBlock.tools.config.params!
describe.each(paginationCases)('$operation', ({ operation, subBlockId, tool }) => {
it('exposes a page token field scoped to the operation', () => {
expect(GoogleDriveBlock.subBlocks.find(({ id }) => id === subBlockId)).toMatchObject({
type: 'short-input',
mode: 'advanced',
condition: { field: 'operation', value: operation },
})
})
/**
* `pageToken` is the canonical tool param, so the `list` case would forward
* through `...rest` even without the mapper. The per-operation ids are the
* ones the mapper has to translate, and none of them may survive as-is.
*/
it('forwards the page token to the tool under its own id', () => {
const params = buildParams({ operation, [subBlockId]: 'token-abc' }, undefined as never)
expect(params).toMatchObject({ pageToken: 'token-abc' })
if (subBlockId !== 'pageToken') expect(params[subBlockId]).toBeUndefined()
})
it('lets an agent feed a nextPageToken back in', () => {
expect(tool.params.pageToken?.visibility).toBe('user-or-llm')
})
})
it('does not leak a page token into operations that do not paginate', () => {
expect(
buildParams({ operation: 'get_file', pageToken: 'token-abc' }, undefined as never).pageToken
).toBeUndefined()
})
/**
* `shouldSerializeSubBlock` short-circuits for `advanced` fields in basic display
* mode without evaluating `condition`, so a page token typed under one operation
* genuinely reaches `inputs` after the user switches to another. The mapper must
* pick the token belonging to the operation being run and drop the rest.
*/
describe.each(paginationCases.filter(({ subBlockId }) => subBlockId !== 'pageToken'))(
'$subBlockId left over from a previous operation',
({ subBlockId }) => {
it.each(['upload', 'get_file', 'list'])('is dropped under %s', (operation) => {
const params = buildParams({ operation, [subBlockId]: 'stale' }, undefined as never)
expect(params.pageToken).toBeUndefined()
expect(params[subBlockId]).toBeUndefined()
})
}
)
it('prefers the operation-owned token when a stale sibling is also present', () => {
const params = buildParams(
{
operation: 'search',
searchPageToken: 'search-token',
commentsPageToken: 'stale',
pageToken: 'stale-canonical',
},
undefined as never
)
expect(params.pageToken).toBe('search-token')
expect(params.commentsPageToken).toBeUndefined()
expect(params.searchPageToken).toBeUndefined()
})
it('declares pageToken as a block input', () => {
expect(GoogleDriveBlock.inputs.pageToken).toBeDefined()
})
})
+54
View File
@@ -463,6 +463,14 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
placeholder: 'Number of results (default: 100, max: 100)',
condition: { field: 'operation', value: 'list' },
},
{
id: 'pageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token from a previous nextPageToken',
mode: 'advanced',
condition: { field: 'operation', value: 'list' },
},
// Download File Fields - File Selector (basic mode)
{
id: 'downloadFileSelector',
@@ -905,6 +913,14 @@ Return ONLY the message text - no subject line, no greetings/signatures, no extr
condition: { field: 'operation', value: 'list_permissions' },
required: true,
},
{
id: 'permissionsPageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token from a previous nextPageToken',
mode: 'advanced',
condition: { field: 'operation', value: 'list_permissions' },
},
// Get File Content Fields
{
id: 'getContentFileSelector',
@@ -1073,6 +1089,14 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
mode: 'advanced',
condition: { field: 'operation', value: 'search' },
},
{
id: 'searchPageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token from a previous nextPageToken',
mode: 'advanced',
condition: { field: 'operation', value: 'search' },
},
// Untrash File Fields
{
id: 'untrashFileSelector',
@@ -1191,6 +1215,14 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
mode: 'advanced',
condition: { field: 'operation', value: 'list_revisions' },
},
{
id: 'revisionsPageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token from a previous nextPageToken',
mode: 'advanced',
condition: { field: 'operation', value: 'list_revisions' },
},
{
id: 'getRevisionFileSelector',
title: 'Select File',
@@ -1255,6 +1287,14 @@ Return ONLY the query string - no explanations, no quotes around the whole thing
mode: 'advanced',
condition: { field: 'operation', value: 'list_comments' },
},
{
id: 'commentsPageToken',
title: 'Page Token',
type: 'short-input',
placeholder: 'Token from a previous nextPageToken',
mode: 'advanced',
condition: { field: 'operation', value: 'list_comments' },
},
{
id: 'includeDeleted',
title: 'Include Deleted Comments',
@@ -1473,6 +1513,11 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
searchPageSize,
revisionsPageSize,
commentsPageSize,
pageToken,
searchPageToken,
permissionsPageToken,
revisionsPageToken,
commentsPageToken,
getContentExportMimeType,
exportMimeType,
...rest
@@ -1586,6 +1631,13 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
else if (params.operation === 'list_revisions') effectivePageSize = revisionsPageSize
else if (params.operation === 'list_comments') effectivePageSize = commentsPageSize
let effectivePageToken: string | undefined = pageToken
if (params.operation === 'search') effectivePageToken = searchPageToken
else if (params.operation === 'list_permissions') effectivePageToken = permissionsPageToken
else if (params.operation === 'list_revisions') effectivePageToken = revisionsPageToken
else if (params.operation === 'list_comments') effectivePageToken = commentsPageToken
else if (params.operation !== 'list') effectivePageToken = undefined
const effectiveQuery = params.operation === 'search' ? searchQuery : query
const effectiveMimeType =
params.operation === 'get_content'
@@ -1603,6 +1655,7 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
pageSize: effectivePageSize
? Number.parseInt(effectivePageSize as string, 10)
: undefined,
pageToken: effectivePageToken?.trim() || undefined,
query: effectiveQuery,
mimeType: effectiveMimeType === 'auto' ? undefined : effectiveMimeType,
type: shareType, // Map shareType to type for share tool
@@ -1660,6 +1713,7 @@ Return ONLY the comment text - no explanations, no quotes, no extra formatting.`
// List operation inputs
query: { type: 'string', description: 'Search query' },
pageSize: { type: 'number', description: 'Results per page' },
pageToken: { type: 'string', description: 'Pagination token from a previous nextPageToken' },
// Copy operation inputs
newName: { type: 'string', description: 'New name for copied file' },
// Update operation inputs
-13
View File
@@ -286,14 +286,6 @@ export const VantaBlock: BlockConfig<ToolResponse> = {
condition: { field: 'operation', value: 'upload_document_file' },
mode: 'advanced',
},
{
id: 'uploadMimeType',
title: 'MIME Type',
type: 'short-input',
placeholder: 'e.g., application/pdf (used when the file has no type of its own)',
condition: { field: 'operation', value: 'upload_document_file' },
mode: 'advanced',
},
{
id: 'uploadDescription',
title: 'Description',
@@ -930,7 +922,6 @@ export const VantaBlock: BlockConfig<ToolResponse> = {
const normalizedFile = normalizeFileInput(rest.file, { single: true })
if (normalizedFile) result.file = normalizedFile
result.fileName = optionalString(rest.uploadFileName)
result.mimeType = optionalString(rest.uploadMimeType)
result.description = optionalString(rest.uploadDescription)
result.effectiveAtDate = optionalString(rest.effectiveAtDate)
break
@@ -993,10 +984,6 @@ export const VantaBlock: BlockConfig<ToolResponse> = {
uploadedFileId: { type: 'string', description: 'Uploaded file ID' },
file: { type: 'json', description: 'Evidence file to upload' },
uploadFileName: { type: 'string', description: 'Optional file name override' },
uploadMimeType: {
type: 'string',
description: 'MIME type override used when the uploaded content has no type of its own',
},
uploadDescription: { type: 'string', description: 'Description of the uploaded evidence' },
effectiveAtDate: { type: 'string', description: 'Effective date of the document (ISO 8601)' },
frameworkMatchesAny: { type: 'string', description: 'Comma-separated framework ID filters' },
+49 -46
View File
@@ -8,19 +8,21 @@ import {
formatChartTimestamp,
} from '@/components/charts/chart-format'
import {
CHART_AXIS_LABEL_GAP,
CHART_DEFAULT_HEIGHT,
CHART_GRID_FRACTIONS,
CHART_PADDING,
CHART_TICK_FILL,
CHART_TICK_FONT_SIZE,
chartPlotBand,
formatTimeTick,
resolveChartPadding,
resolveSpanMs,
resolveTimeTickIndices,
} from '@/components/charts/chart-geometry'
import {
ChartTooltip,
ChartTooltipRow,
estimateTooltipHeight,
estimateTooltipWidth,
positionChartTooltip,
} from '@/components/charts/chart-tooltip'
@@ -47,6 +49,17 @@ interface BarChartProps {
highlightIndex?: number
}
/** Tick and tooltip text for a bucket's value, in the caller's unit. */
function formatBarValue(value: number | undefined, unit: string | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
const suffix = (unit ?? '').toLowerCase()
if (suffix.includes('%')) return `${value.toFixed(1)}%`
if (suffix === 'latency') return formatChartLatency(value)
if (suffix.includes('ms')) return `${Math.round(value)}ms`
if (suffix === 'credits') return formatChartCompactNumber(value)
return `${Math.round(value)}${unit ?? ''}`
}
/**
* Discrete time buckets as bars.
*
@@ -71,16 +84,11 @@ function BarChartComponent({
const uniqueId = useId().replace(/:/g, '')
const [containerRef, containerWidth] = useChartWidth()
const width = containerWidth ?? 0
const padding = CHART_PADDING
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const { yMin, yMax } = chartPlotBand(height)
const isDark = useIsDarkTheme()
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const colorTokens = useMemo(() => ({ base: color }), [color])
const resolvedColors = useResolvedChartColors(colorTokens)
const resolvedColors = useResolvedChartColors({ base: color })
const resolvedColor = resolvedColors.base || color
const hasExternalWrapper = !label
@@ -100,10 +108,24 @@ function BarChartComponent({
return peak <= 0 ? 1 : peak * 1.1
}, [data])
const padding = resolveChartPadding([formatBarValue(maxValue, unit), '0'])
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
/** Slot geometry: every bucket owns an equal slice, with the bar centred in it. */
const slot = data.length > 0 ? Math.max(1, chartWidth) / data.length : 0
const barWidth = Math.max(1, Math.min(24, slot * 0.7))
/**
* Bars own a slot, so the hovered bucket is which slot the cursor is in not the
* nearest sample, which is how a line chart resolves it. Derived, so a resize
* mid-hover cannot leave an index disagreeing with the slot geometry.
*/
const hoverIndex =
hoverPos === null || data.length === 0 || slot <= 0
? null
: Math.max(0, Math.min(data.length - 1, Math.floor((hoverPos.x - padding.left) / slot)))
const bars = useMemo(
() =>
data.map((point, index) => {
@@ -127,21 +149,14 @@ function BarChartComponent({
[data, slot, barWidth, maxValue, chartHeight, height, padding.left, padding.top, yMin, yMax]
)
const formatValue = (value?: number) => {
if (typeof value !== 'number' || !Number.isFinite(value)) return '—'
const suffix = (unit ?? '').toLowerCase()
if (suffix.includes('%')) return `${value.toFixed(1)}%`
if (suffix === 'latency') return formatChartLatency(value)
if (suffix.includes('ms')) return `${Math.round(value)}ms`
if (suffix === 'credits') return formatChartCompactNumber(value)
return `${Math.round(value)}${unit ?? ''}`
}
if (containerWidth === null) {
return (
<div
ref={containerRef}
className={cn('w-full', !hasExternalWrapper && 'rounded-lg border bg-card p-4')}
className={cn(
'w-full',
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
)}
style={{ height }}
/>
)
@@ -156,7 +171,7 @@ function BarChartComponent({
ref={containerRef}
className={cn(
'flex w-full items-center justify-center',
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
)}
/*
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
@@ -185,8 +200,8 @@ function BarChartComponent({
contradicted the constant's own note that the chart "scrolls rather than
compresses". At or above the floor there is no overflow and nothing changes.
*/
'w-full overflow-x-auto',
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
'w-full overflow-x-auto overflow-y-hidden',
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4 shadow-card'
)}
>
{!hasExternalWrapper && (
@@ -202,20 +217,9 @@ function BarChartComponent({
onMouseMove={(e) => {
if (bars.length === 0 || slot <= 0) return
const rect = (e.currentTarget as SVGSVGElement).getBoundingClientRect()
const x = e.clientX - rect.left
// Bars own a slot, so the hovered bucket is which slot the cursor is in —
// not the nearest sample, which is how a line chart resolves it.
const index = Math.max(
0,
Math.min(data.length - 1, Math.floor((x - padding.left) / slot))
)
setHoverIndex(index)
setHoverPos({ x, y: e.clientY - rect.top })
}}
onMouseLeave={() => {
setHoverIndex(null)
setHoverPos(null)
setHoverPos({ x: e.clientX - rect.left, y: e.clientY - rect.top })
}}
onMouseLeave={() => setHoverPos(null)}
>
<defs>
<linearGradient id={`bar-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
@@ -229,7 +233,7 @@ function BarChartComponent({
y1={padding.top}
x2={padding.left}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
stroke='var(--border)'
strokeWidth='1'
/>
@@ -240,7 +244,7 @@ function BarChartComponent({
y1={padding.top + chartHeight * fraction}
x2={width - padding.right}
y2={padding.top + chartHeight * fraction}
stroke='hsl(var(--muted))'
stroke='var(--border)'
strokeOpacity='0.35'
strokeWidth='1'
/>
@@ -313,7 +317,7 @@ function BarChartComponent({
})}
<text
x={padding.left - 8}
x={padding.left - CHART_AXIS_LABEL_GAP}
y={padding.top}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
@@ -321,10 +325,10 @@ function BarChartComponent({
>
{/* Same formatter the tooltip uses, or the axis and the hover disagree
about what the numbers mean on any non-`credits` unit. */}
{formatValue(maxValue)}
{formatBarValue(maxValue, unit)}
</text>
<text
x={padding.left - 8}
x={padding.left - CHART_AXIS_LABEL_GAP}
y={height - padding.bottom}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
@@ -338,7 +342,7 @@ function BarChartComponent({
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
stroke='var(--border)'
strokeWidth='1'
/>
</svg>
@@ -347,20 +351,19 @@ function BarChartComponent({
bars[hoverIndex] &&
(() => {
const bar = bars[hoverIndex]
const value = formatValue(bar.point.value)
const value = formatBarValue(bar.point.value, unit)
const date = formatChartTimestamp(bar.point.timestamp)
const { left, top } = positionChartTooltip({
anchorX: hoverPos?.x ?? bar.x,
anchorY: hoverPos?.y ?? bar.y,
width,
height,
tooltipMaxWidth: estimateTooltipWidth(value.length),
tooltipHeight: estimateTooltipHeight(1, Boolean(date)),
padding,
})
return (
<ChartTooltip
left={left}
top={top}
date={formatChartTimestamp(bar.point.timestamp) || undefined}
>
<ChartTooltip left={left} top={top} date={date || undefined}>
<ChartTooltipRow color={resolvedColor} value={value} />
</ChartTooltip>
)
@@ -3,9 +3,12 @@
*/
import { describe, expect, it } from 'vitest'
import {
CHART_AXIS_LABEL_GAP,
CHART_PADDING,
chartPlotBand,
estimateAxisLabelWidth,
formatTimeTick,
resolveChartPadding,
resolveSpanMs,
resolveTimeTickIndices,
} from '@/components/charts/chart-geometry'
@@ -78,3 +81,40 @@ describe('chartPlotBand', () => {
expect(chartPlotBand(240).yMax).toBeGreaterThan(chartPlotBand(166).yMax)
})
})
describe('resolveChartPadding', () => {
it('widens the gutter until the longest label fits beside the axis', () => {
const { left } = resolveChartPadding(['7.3k', '0'])
expect(left).toBeGreaterThanOrEqual(estimateAxisLabelWidth('7.3k') + CHART_AXIS_LABEL_GAP)
})
it('never narrows below the shared padding', () => {
expect(resolveChartPadding(['0', '0']).left).toBeGreaterThanOrEqual(CHART_PADDING.left)
expect(resolveChartPadding([]).left).toBeGreaterThanOrEqual(CHART_PADDING.left)
})
/**
* Three charts sit side by side on the logs dashboard. A gutter derived exactly from
* each one's own labels put their plot origins at 26, 27 and 32 visibly ragged
* across a row that used to share one origin.
*/
it('resolves labels of similar width to the same gutter', () => {
const gutters = [['5'], ['1.2s'], ['12.3k'], ['0'], ['7.3k']].map(
(labels) => resolveChartPadding(labels).left
)
expect(new Set(gutters).size).toBe(1)
})
it('still grows for a genuinely wider label', () => {
expect(resolveChartPadding(['123456.7m']).left).toBeGreaterThan(
resolveChartPadding(['7.3k']).left
)
})
it('leaves the other three sides on the shared constant', () => {
const padding = resolveChartPadding(['123.4m'])
expect(padding.top).toBe(CHART_PADDING.top)
expect(padding.right).toBe(CHART_PADDING.right)
expect(padding.bottom).toBe(CHART_PADDING.bottom)
})
})
+65 -2
View File
@@ -10,16 +10,79 @@
export const CHART_PADDING = { top: 16, right: 28, bottom: 26, left: 26 } as const
export type ChartPadding = { top: number; right: number; bottom: number; left: number }
/** Matches the loader placeholders callers size themselves against. */
export const CHART_DEFAULT_HEIGHT = 166
/** Below this the axis labels collide, so the chart scrolls rather than compresses. */
/**
* Below this the axis labels collide, so the chart scrolls rather than compresses.
*
* Consumers pair `overflow-x-auto` with `overflow-y-hidden`: a computed `overflow-x`
* other than `visible` promotes `overflow-y: visible` to `auto`, so the tooltip's
* shadow reaching the foot of the box raised a vertical scrollbar over the chart
* whenever the cursor neared the axis.
*/
export const CHART_MIN_WIDTH = 280
export const CHART_TICK_FILL = 'var(--text-tertiary)'
export const CHART_TICK_FONT_SIZE = '9'
export const CHART_TICK_FONT_SIZE = 9
export const CHART_GRID_FRACTIONS = [0.25, 0.5, 0.75] as const
/** Punctuation and whitespace, which sit near half the width of a digit or letter. */
const NARROW_GLYPH = /[.,:\s]/
/** Gap between a y-axis tick label's right edge and the axis rule. */
export const CHART_AXIS_LABEL_GAP = 8
/**
* The gutter is rounded up to a multiple of this.
*
* Charts are read side by side the logs dashboard puts three in one row and a
* gutter derived exactly from each chart's own labels made `5`, `1.2s` and `12.3k`
* resolve to 26, 27 and 32, so three plots that used to share an origin no longer
* did. Quantizing collapses differences this small to one value while still growing
* for a genuinely wider label, and it turns the sub-pixel slack that `Math.ceil`
* alone left into several pixels.
*/
const CHART_AXIS_GUTTER_STEP = 8
/**
* Rendered width of a right-anchored y-axis tick label.
*
* SVG `<text>` cannot be measured before layout, so the gutter that has to hold it
* is estimated from the glyphs instead. The ratios are for the UI sans at
* {@link CHART_TICK_FONT_SIZE}: digits and letters sit near 0.58em, punctuation and
* spaces near 0.3em. Deliberately generous an over-wide gutter costs a couple of
* plot pixels, an under-wide one clips the label against the container's edge.
*/
export function estimateAxisLabelWidth(text: string): number {
let width = 0
for (const character of text) {
width += NARROW_GLYPH.test(character) ? 0.3 : 0.58
}
return width * CHART_TICK_FONT_SIZE
}
/**
* {@link CHART_PADDING} with a left gutter wide enough for the chart's own y-axis
* labels.
*
* The fixed 26px gutter left 18px of drawable width once the label gap is taken out,
* which fits four narrow glyphs so any tick past `7.3k` was cut off at the left edge
* of the container. Both charts resolve their gutter through this one function from
* the labels they are about to draw, so a bar and a line chart showing comparable
* magnitudes still line up when stacked in one card, and neither can clip.
*/
export function resolveChartPadding(yAxisLabels: readonly string[]): ChartPadding {
const widest = yAxisLabels.reduce((max, label) => Math.max(max, estimateAxisLabelWidth(label)), 0)
const required = Math.max(CHART_PADDING.left, widest + CHART_AXIS_LABEL_GAP)
return {
...CHART_PADDING,
left: Math.ceil(required / CHART_AXIS_GUTTER_STEP) * CHART_AXIS_GUTTER_STEP,
}
}
/** Vertical clamp for plotted geometry, keeping strokes off the axis rules. */
export function chartPlotBand(height: number): { yMin: number; yMax: number } {
const chartHeight = height - CHART_PADDING.top - CHART_PADDING.bottom
@@ -0,0 +1,239 @@
/**
* @vitest-environment jsdom
*/
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BarChart } from '@/components/charts/bar-chart'
import { CHART_PADDING } from '@/components/charts/chart-geometry'
import { RadarChart } from '@/components/charts/radar-chart'
/**
* Rendered-geometry guards for the chart family.
*
* These assert against the real SVG the components emit rather than against the
* geometry helpers in isolation: the two clipping bugs this file exists for a
* y-axis label cut off at the container's left edge, and a radar caption painting
* over the section beside it were both invisible to a unit test of the maths,
* because each came from a *callsite* combining correct helpers wrongly.
*/
let container: HTMLDivElement
let root: Root
/** jsdom lays nothing out, so the width the chart measures has to be supplied. */
function mountAtWidth(width: number, element: React.ReactElement): SVGSVGElement {
container = document.createElement('div')
document.body.appendChild(container)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width,
height: 0,
top: 0,
left: 0,
right: width,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
root = createRoot(container)
act(() => root.render(element))
const svg = container.querySelector('svg')
if (!svg) throw new Error('chart did not render an svg')
return svg
}
/** Right-anchored SVG text at 9px, measured the way the chart's own estimator does. */
function textExtent(text: string): number {
let width = 0
for (const character of text) width += /[.,:\s]/.test(character) ? 0.3 : 0.58
return width * 9
}
beforeEach(() => {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver
})
afterEach(() => {
act(() => root?.unmount())
container?.remove()
vi.restoreAllMocks()
})
function dailySeries(count: number, peak: number) {
return Array.from({ length: count }, (_, index) => ({
timestamp: new Date(Date.UTC(2026, 0, 1 + index)).toISOString(),
value: index === 0 ? peak : peak / 10,
}))
}
describe('BarChart rendered geometry', () => {
const widths = [280, 420, 680, 1024]
const peaks = [7300, 173_000, 1_234_567]
it.each(widths.flatMap((width) => peaks.map((peak) => [width, peak] as const)))(
'keeps the y-axis labels inside the box at width %i, peak %i',
(width, peak) => {
const svg = mountAtWidth(
width,
<BarChart
data={dailySeries(90, peak)}
label=''
color='#5b8def'
unit='credits'
height={160}
/>
)
const labels = [...svg.querySelectorAll('text')].filter(
(node) => node.getAttribute('text-anchor') === 'end'
)
expect(labels.length).toBe(2)
for (const label of labels) {
const anchorX = Number(label.getAttribute('x'))
// Right-anchored: the glyphs run leftward from the anchor.
expect(anchorX - textExtent(label.textContent ?? '')).toBeGreaterThanOrEqual(0)
}
}
)
it.each(widths)('keeps every bar inside the plot area at width %i', (width) => {
const svg = mountAtWidth(
width,
<BarChart
data={dailySeries(90, 173_000)}
label=''
color='#5b8def'
unit='credits'
height={160}
/>
)
const bars = [...svg.querySelectorAll('rect')]
expect(bars.length).toBeGreaterThan(0)
const svgWidth = Number(svg.getAttribute('width'))
for (const bar of bars) {
const x = Number(bar.getAttribute('x'))
const right = x + Number(bar.getAttribute('width'))
expect(x).toBeGreaterThanOrEqual(CHART_PADDING.left)
expect(right).toBeLessThanOrEqual(svgWidth - CHART_PADDING.right + 0.01)
}
})
it('keeps the first and last x-axis tick label inside the box', () => {
const width = 680
const svg = mountAtWidth(
width,
<BarChart
data={dailySeries(90, 173_000)}
label=''
color='#5b8def'
unit='credits'
height={160}
/>
)
const ticks = [...svg.querySelectorAll('text')].filter(
(node) => node.getAttribute('text-anchor') === 'middle'
)
expect(ticks.length).toBeGreaterThan(1)
for (const tick of ticks) {
const centre = Number(tick.getAttribute('x'))
const half = textExtent(tick.textContent ?? '') / 2
expect(centre - half).toBeGreaterThanOrEqual(0)
expect(centre + half).toBeLessThanOrEqual(width)
}
})
})
describe('RadarChart rendered geometry', () => {
const LONG = 'Knowledge Base Sync'
/**
* Every caption long, not just the first.
*
* The first axis sits at twelve o'clock, where a caption is centred and has the
* whole half-width to spend the one position that cannot overflow horizontally.
* A fixture that only made that one long proved nothing about the axes that
* actually run out of room.
*/
function axesOf(count: number) {
return Array.from({ length: count }, (_, index) => ({
label: `${LONG} ${index}`,
value: 100 * (index + 1),
display: String(100 * (index + 1)),
}))
}
it.each([
[280, 3],
[280, 6],
[320, 4],
[420, 5],
[420, 6],
[520, 7],
[680, 6],
])('keeps every axis caption inside the box at width %i with %i axes', (width, axisCount) => {
const svg = mountAtWidth(width, <RadarChart axes={axesOf(axisCount)} color='#5b8def' />)
const height = Number(svg.getAttribute('height'))
const captions = [...svg.querySelectorAll('text')]
expect(captions.length).toBe(axisCount)
for (const caption of captions) {
const x = Number(caption.getAttribute('x'))
const y = Number(caption.getAttribute('y'))
const anchor = caption.getAttribute('text-anchor')
const extent = textExtent(caption.textContent ?? '')
const left = anchor === 'start' ? x : anchor === 'end' ? x - extent : x - extent / 2
const right = left + extent
expect(left).toBeGreaterThanOrEqual(0)
expect(right).toBeLessThanOrEqual(width)
// An 'auto' baseline sits the glyphs above y; 'middle' centres them on it.
const capHeight = 9
const top =
caption.getAttribute('dominant-baseline') === 'middle' ? y - capHeight / 2 : y - capHeight
const bottom = top + capHeight
expect(top).toBeGreaterThanOrEqual(0)
expect(bottom).toBeLessThanOrEqual(height)
}
})
it('draws a positive-radius web rather than collapsing at the narrow floor', () => {
const svg = mountAtWidth(280, <RadarChart axes={axesOf(6)} color='#5b8def' />)
const rings = [...svg.querySelectorAll('polygon')].filter(
(node) => node.getAttribute('fill') === 'none'
)
expect(rings.length).toBeGreaterThan(0)
const outer = rings[rings.length - 1]
const points = (outer.getAttribute('points') ?? '')
.split(' ')
.map((pair) => pair.split(',').map(Number))
const xs = points.map(([x]) => x)
const ys = points.map(([, y]) => y)
expect(Math.max(...xs) - Math.min(...xs)).toBeGreaterThan(40)
expect(Math.max(...ys) - Math.min(...ys)).toBeGreaterThan(40)
})
it('renders the empty state rather than a degenerate polygon below three axes', () => {
container = document.createElement('div')
document.body.appendChild(container)
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
width: 420,
height: 0,
top: 0,
left: 0,
right: 420,
bottom: 0,
x: 0,
y: 0,
toJSON: () => ({}),
} as DOMRect)
root = createRoot(container)
act(() => root.render(<RadarChart axes={axesOf(2)} color='#5b8def' />))
expect(container.querySelector('svg')).toBeNull()
expect(container.textContent).toContain('No data')
})
})
@@ -0,0 +1,114 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { CHART_PADDING, resolveChartPadding } from '@/components/charts/chart-geometry'
import {
estimateTooltipHeight,
estimateTooltipWidth,
positionChartTooltip,
} from '@/components/charts/chart-tooltip'
const WIDTH = 800
const HEIGHT = 166
function place(anchorY: number, rows = 1, hasDate = true) {
const tooltipHeight = estimateTooltipHeight(rows, hasDate)
const position = positionChartTooltip({
anchorX: 400,
anchorY,
width: WIDTH,
height: HEIGHT,
tooltipMaxWidth: estimateTooltipWidth(12),
tooltipHeight,
})
return { ...position, tooltipHeight }
}
describe('positionChartTooltip', () => {
/** Guards the height-aware vertical clamp — see `positionChartTooltip`. */
it('keeps the whole box inside the chart when the cursor is at the very bottom', () => {
const { top, tooltipHeight } = place(HEIGHT)
expect(top + tooltipHeight).toBeLessThanOrEqual(HEIGHT)
})
it('holds for a taller multi-row tooltip, which overflows soonest', () => {
const { top, tooltipHeight } = place(HEIGHT, 5)
expect(top + tooltipHeight).toBeLessThanOrEqual(HEIGHT)
expect(top).toBeGreaterThanOrEqual(0)
})
it('never places the box above the chart when the cursor is at the top', () => {
expect(place(0).top).toBeGreaterThanOrEqual(0)
})
it('prefers the right of the cursor and flips left near the right edge', () => {
const boxWidth = estimateTooltipWidth(12)
const right = positionChartTooltip({
anchorX: 100,
anchorY: 80,
width: WIDTH,
height: HEIGHT,
tooltipMaxWidth: boxWidth,
tooltipHeight: estimateTooltipHeight(1, true),
})
expect(right.left).toBeGreaterThan(100)
const flipped = positionChartTooltip({
anchorX: WIDTH - CHART_PADDING.right,
anchorY: 80,
width: WIDTH,
height: HEIGHT,
tooltipMaxWidth: boxWidth,
tooltipHeight: estimateTooltipHeight(1, true),
})
expect(flipped.left + boxWidth).toBeLessThanOrEqual(WIDTH - CHART_PADDING.right)
})
/** A chart with wide axis labels has a wider gutter, and the clamp must follow it. */
it('clamps the left edge to the resolved gutter, not the shared constant', () => {
const padding = resolveChartPadding(['123456.7m'])
const { left } = positionChartTooltip({
anchorX: 0,
anchorY: 80,
width: WIDTH,
height: HEIGHT,
tooltipMaxWidth: estimateTooltipWidth(12),
tooltipHeight: estimateTooltipHeight(1, true),
padding,
})
expect(left).toBeGreaterThanOrEqual(padding.left)
expect(padding.left).toBeGreaterThan(CHART_PADDING.left)
})
})
describe('estimateTooltipHeight', () => {
it('grows with each row and with the date header', () => {
expect(estimateTooltipHeight(2, true)).toBeGreaterThan(estimateTooltipHeight(1, true))
expect(estimateTooltipHeight(1, true)).toBeGreaterThan(estimateTooltipHeight(1, false))
})
it('reserves a row even when told there are none', () => {
expect(estimateTooltipHeight(0, false)).toBe(estimateTooltipHeight(1, false))
})
/**
* The estimate is what the clamp measures against, and the chart clips its overflow,
* so it must never come in under the real box an underestimate cuts the bottom off
* rather than moving the box up. Measured here against the box model the tooltip's
* own class string implies: `border` + `py-1.5`, a `text-micro` date with `mb-1`,
* and one `text-xs` row per value, every line at the ambient 1.5 line-height.
*/
it('never comes in under the box the tooltip actually renders', () => {
const chrome = 2 + 6 + 6
const dateLine = 10 * 1.5 + 4
const rowLine = 11 * 1.5
for (const rows of [1, 2, 5]) {
expect(estimateTooltipHeight(rows, true)).toBeGreaterThanOrEqual(
chrome + dateLine + rows * rowLine
)
expect(estimateTooltipHeight(rows, false)).toBeGreaterThanOrEqual(chrome + rows * rowLine)
}
})
})
+49 -11
View File
@@ -1,7 +1,7 @@
'use client'
import type { ReactNode } from 'react'
import { CHART_PADDING } from '@/components/charts/chart-geometry'
import { CHART_PADDING, type ChartPadding } from '@/components/charts/chart-geometry'
/**
* The chart family's hover surface. Defined once so a sibling chart cannot ship a
@@ -9,7 +9,7 @@ import { CHART_PADDING } from '@/components/charts/chart-geometry'
* between the line chart and the status bar.
*/
export const CHART_TOOLTIP_CLASSES =
'pointer-events-none absolute rounded-lg border border-[var(--border-1)] bg-[var(--surface-1)] px-2 py-1.5 text-xs shadow-lg'
'pointer-events-none absolute rounded-lg border border-[var(--border)] bg-[var(--surface-1)] px-2 py-1.5 text-xs shadow-overlay'
interface PositionChartTooltipArgs {
anchorX: number
@@ -17,11 +17,19 @@ interface PositionChartTooltipArgs {
width: number
height: number
tooltipMaxWidth: number
tooltipHeight: number
/** The chart's resolved padding, whose left gutter varies with its axis labels. */
padding?: ChartPadding
}
/**
* Places the tooltip beside the cursor, preferring the right and flipping left when
* it would overflow, then clamping into the plot band so it never escapes the card.
* it would overflow, then clamping it wholly inside the chart box.
*
* The vertical clamp is against the tooltip's own height rather than a fixed inset.
* A fixed one let the box hang a pixel or two past the bottom near the foot of the
* plot, and because the scroll container's `overflow-x` forces `overflow-y` to `auto`,
* those pixels raised a vertical scrollbar the moment the cursor approached the axis.
*/
export function positionChartTooltip({
anchorX,
@@ -29,20 +37,19 @@ export function positionChartTooltip({
width,
height,
tooltipMaxWidth,
tooltipHeight,
padding = CHART_PADDING,
}: PositionChartTooltipArgs): { left: number; top: number } {
const margin = 10
const rightEdge = width - CHART_PADDING.right
const rightEdge = width - padding.right
const preferRight = anchorX + margin + tooltipMaxWidth <= rightEdge
const left = preferRight
? Math.max(CHART_PADDING.left, Math.min(anchorX + margin, rightEdge - tooltipMaxWidth))
? Math.max(padding.left, Math.min(anchorX + margin, rightEdge - tooltipMaxWidth))
: Math.max(
CHART_PADDING.left,
padding.left,
Math.min(anchorX - margin - tooltipMaxWidth, rightEdge - tooltipMaxWidth)
)
const top = Math.min(
Math.max(anchorY - 26, CHART_PADDING.top),
height - CHART_PADDING.bottom - 18
)
const top = Math.max(0, Math.min(anchorY - 26, height - tooltipHeight))
return { left, top }
}
@@ -51,6 +58,37 @@ export function estimateTooltipWidth(longestRowLength: number): number {
return Math.min(220, Math.max(80, 7 * longestRowLength + 24))
}
/** Border plus the `py-1.5` the tooltip's own class string sets. */
const TOOLTIP_CHROME_HEIGHT = 2 + 12
/**
* The `text-micro` date's line box plus its `mb-1`.
*
* The type scale pairs no line-height with a font size, so a line occupies the
* ambient 1.5 rather than the font size itself 15px for 10px `text-micro`, not 10.
*/
const TOOLTIP_DATE_HEIGHT = 15 + 4
/** One `text-xs` row's line box: 11px at the ambient 1.5, rounded up from 16.5. */
const TOOLTIP_ROW_HEIGHT = 17
/**
* Height of the box {@link ChartTooltip} renders, from its own box model.
*
* Estimated rather than measured because the position is computed in the same render
* that mounts the tooltip reading a real height would need a second paint, which
* shows up as the tooltip visibly jumping under the cursor. Every part rounds up:
* this is what {@link positionChartTooltip} clamps against and the chart clips its
* overflow, so an underestimate cuts the bottom off the box rather than moving it.
*/
export function estimateTooltipHeight(rowCount: number, hasDate: boolean): number {
return (
TOOLTIP_CHROME_HEIGHT +
(hasDate ? TOOLTIP_DATE_HEIGHT : 0) +
Math.max(1, rowCount) * TOOLTIP_ROW_HEIGHT
)
}
interface ChartTooltipProps {
left: number
top: number
@@ -83,7 +121,7 @@ export function ChartTooltipRow({ color, label, value }: ChartTooltipRowProps) {
style={{ backgroundColor: color }}
/>
{label && <span className='text-[var(--text-secondary)]'>{label}</span>}
<span className='text-[var(--text-primary)]'>{value}</span>
<span className='text-[var(--text-body)]'>{value}</span>
</div>
)
}
+1
View File
@@ -10,3 +10,4 @@ export {
type LineChartMultiSeries,
type LineChartPoint,
} from '@/components/charts/line-chart'
export { RadarChart, type RadarChartAxis } from '@/components/charts/radar-chart'
+121 -106
View File
@@ -8,19 +8,21 @@ import {
formatChartTimestamp,
} from '@/components/charts/chart-format'
import {
CHART_AXIS_LABEL_GAP,
CHART_DEFAULT_HEIGHT,
CHART_GRID_FRACTIONS,
CHART_PADDING,
CHART_TICK_FILL,
CHART_TICK_FONT_SIZE,
chartPlotBand,
formatTimeTick,
resolveChartPadding,
resolveSpanMs,
resolveTimeTickIndices,
} from '@/components/charts/chart-geometry'
import {
ChartTooltip,
ChartTooltipRow,
estimateTooltipHeight,
estimateTooltipWidth,
positionChartTooltip,
} from '@/components/charts/chart-tooltip'
@@ -53,6 +55,38 @@ interface LineChartProps {
height?: number
}
/**
* Smoothed path through `points`, with every control point clamped into the plot
* band so a curve between two near-axis samples cannot bow over an axis rule.
*
* At module scope because the base line and each extra series need the identical
* curve: the two copies had drifted apart before, and a clamp fixed in one drew a
* different shape from the other.
*/
function buildSmoothPath(
points: ReadonlyArray<{ x: number; y: number }>,
yMin: number,
yMax: number
): string {
if (points.length <= 1) return ''
const tension = 0.2
let d = `M ${points[0].x} ${points[0].y}`
for (let i = 0; i < points.length - 1; i++) {
const p0 = points[i - 1] || points[i]
const p1 = points[i]
const p2 = points[i + 1]
const p3 = points[i + 2] || points[i + 1]
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
}
return d
}
function LineChartComponent({
data,
label,
@@ -69,23 +103,16 @@ function LineChartComponent({
const uniqueId = useId().replace(/:/g, '')
const [containerRef, containerWidth] = useChartWidth()
const width = containerWidth ?? 0
const padding = CHART_PADDING
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const isDark = useIsDarkTheme()
const [hoverSeriesId, setHoverSeriesId] = useState<string | null>(null)
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
const colorTokens = useMemo(() => {
const tokens: Record<string, string> = { base: color }
for (const s of series ?? []) {
const id = s.id || s.label || ''
if (id) tokens[id] = s.color
}
return tokens
}, [color, series])
const colorTokens: Record<string, string> = { base: color }
for (const s of series ?? []) {
const id = s.id || s.label || ''
if (id) colorTokens[id] = s.color
}
const resolvedColors = useResolvedChartColors(colorTokens)
const hasExternalWrapper = !label || label === ''
@@ -129,6 +156,25 @@ function LineChartComponent({
}
}, [allSeries, unit])
/**
* The two y-axis tick labels, resolved once so the gutter that has to hold them is
* measured from the same strings the axis draws.
*/
const yAxisLabels = useMemo(() => {
const unitSuffix = (unit || '').trim()
const isLatency = unitSuffix.toLowerCase() === 'latency'
const suffix = unitSuffix === '%' && !isLatency ? unitSuffix : ''
const compact = (value: number) => {
if (isLatency) return value === 0 ? '0' : formatChartLatency(value)
return `${formatChartCompactNumber(value)}${suffix}`
}
return [compact(maxValue), compact(minValue)] as const
}, [maxValue, minValue, unit])
const padding = resolveChartPadding(yAxisLabels)
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
const { yMin, yMax } = chartPlotBand(height)
const scaledPoints = useMemo(
@@ -143,6 +189,28 @@ function LineChartComponent({
[data, chartWidth, chartHeight, minValue, valueRange, yMin, yMax, padding.left, padding.top]
)
/**
* The hovered sample, derived from the stored cursor rather than stored beside it.
*
* Clamped here rather than relying on the stored x having been clamped at mousemove
* time: `padding.left` follows the axis labels and `chartWidth` follows the
* container, so either can move with no pointer event at all a sidebar collapse
* mid-hover otherwise pushed the ratio past 1 and indexed off the end, and the dot,
* the rule and the tooltip all vanished until the cursor moved again.
*/
const hoverIndex =
hoverPos === null || scaledPoints.length === 0
? null
: Math.max(
0,
Math.min(
scaledPoints.length - 1,
Math.round(
((hoverPos.x - padding.left) / (chartWidth || 1)) * (scaledPoints.length - 1)
)
)
)
const scaledSeries = useMemo(
() =>
allSeries.map((s) => {
@@ -169,31 +237,11 @@ function LineChartComponent({
)
const getSeriesById = (id?: string | null) => scaledSeries.find((s) => s.id === id)
const visibleSeries = useMemo(
() => (activeSeriesId ? scaledSeries.filter((s) => s.id === activeSeriesId) : scaledSeries),
[activeSeriesId, scaledSeries]
)
const visibleSeries = activeSeriesId
? scaledSeries.filter((s) => s.id === activeSeriesId)
: scaledSeries
const pathD = useMemo(() => {
if (scaledPoints.length <= 1) return ''
const p = scaledPoints
const tension = 0.2
let d = `M ${p[0].x} ${p[0].y}`
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i]
const p1 = p[i]
const p2 = p[i + 1]
const p3 = p[i + 2] || p[i + 1]
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
}
return d
}, [scaledPoints, yMin, yMax])
const pathD = useMemo(() => buildSmoothPath(scaledPoints, yMin, yMax), [scaledPoints, yMin, yMax])
const currentHoverDate =
hoverIndex !== null && data[hoverIndex] ? formatChartTimestamp(data[hoverIndex].timestamp) : ''
@@ -202,7 +250,10 @@ function LineChartComponent({
return (
<div
ref={containerRef}
className={cn('w-full', !hasExternalWrapper && 'rounded-lg border bg-card p-4')}
className={cn(
'w-full',
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
)}
style={{ height }}
/>
)
@@ -213,7 +264,7 @@ function LineChartComponent({
<div
className={cn(
'flex w-full items-center justify-center',
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4'
)}
/*
Height only. `width` is floored at CHART_MIN_WIDTH for the plot geometry,
@@ -239,8 +290,8 @@ function LineChartComponent({
contradicted the constant's own note that the chart "scrolls rather than
compresses". At or above the floor there is no overflow and nothing changes.
*/
'w-full overflow-x-auto',
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
'w-full overflow-x-auto overflow-y-hidden',
!hasExternalWrapper && 'rounded-lg border bg-[var(--surface-1)] p-4 shadow-card'
)}
>
{!hasExternalWrapper && (
@@ -259,11 +310,11 @@ function LineChartComponent({
variant='ghost'
aria-pressed={activeSeriesId === s.id}
aria-label={`Toggle ${s.label}`}
className='inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-transparent px-1.5 py-0.5 text-micro'
style={{
color: resolvedColors[s.id || ''] || s.color,
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
}}
className={cn(
'inline-flex items-center gap-1 rounded-md border border-[var(--border)] bg-transparent px-1.5 py-0.5 text-micro',
dimmed ? 'opacity-40' : isHovered ? 'opacity-100' : 'opacity-90'
)}
style={{ color: resolvedColors[s.id || ''] || s.color }}
onMouseEnter={() => setHoverSeriesId(s.id || null)}
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
onKeyDown={(e) => {
@@ -301,7 +352,6 @@ function LineChartComponent({
const clamped = Math.max(padding.left, Math.min(width - padding.right, x))
const ratio = (clamped - padding.left) / (chartWidth || 1)
const i = Math.round(ratio * (scaledPoints.length - 1))
setHoverIndex(i)
setHoverPos({ x: clamped, y: e.clientY - rect.top })
const cursorY = e.clientY - rect.top
if (activeSeriesId) {
@@ -321,7 +371,6 @@ function LineChartComponent({
}
}}
onMouseLeave={() => {
setHoverIndex(null)
setHoverPos(null)
setHoverSeriesId(null)
}}
@@ -355,7 +404,7 @@ function LineChartComponent({
y1={padding.top}
x2={padding.left}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
stroke='var(--border)'
strokeWidth='1'
/>
@@ -366,7 +415,7 @@ function LineChartComponent({
y1={padding.top + chartHeight * p}
x2={width - padding.right}
y2={padding.top + chartHeight * p}
stroke='hsl(var(--muted))'
stroke='var(--border)'
strokeOpacity='0.35'
strokeWidth='1'
/>
@@ -433,25 +482,7 @@ function LineChartComponent({
/>
)
}
const p = (() => {
const p = s.pts
const tension = 0.2
let d = `M ${p[0].x} ${p[0].y}`
for (let i = 0; i < p.length - 1; i++) {
const p0 = p[i - 1] || p[i]
const p1 = p[i]
const p2 = p[i + 1]
const p3 = p[i + 2] || p[i + 1]
const cp1x = p1.x + ((p2.x - p0.x) / 6) * tension
let cp1y = p1.y + ((p2.y - p0.y) / 6) * tension
const cp2x = p2.x - ((p3.x - p1.x) / 6) * tension
let cp2y = p2.y - ((p3.y - p1.y) / 6) * tension
cp1y = Math.max(yMin, Math.min(yMax, cp1y))
cp2y = Math.max(yMin, Math.min(yMax, cp2y))
d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`
}
return d
})()
const p = buildSmoothPath(s.pts, yMin, yMax)
return (
<path
key={s.id}
@@ -533,46 +564,31 @@ function LineChartComponent({
})
})()}
{(() => {
const unitSuffix = (unit || '').trim()
const showInTicks = unitSuffix === '%'
const isLatency = unitSuffix.toLowerCase() === 'latency'
const fmtCompact = (v: number) => {
if (isLatency) return v === 0 ? '0' : formatChartLatency(v)
return formatChartCompactNumber(v)
}
return (
<>
<text
x={padding.left - 8}
y={padding.top}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
fill={CHART_TICK_FILL}
>
{fmtCompact(maxValue)}
{showInTicks && !isLatency ? unit : ''}
</text>
<text
x={padding.left - 8}
y={height - padding.bottom}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
fill={CHART_TICK_FILL}
>
{fmtCompact(minValue)}
{showInTicks && !isLatency ? unit : ''}
</text>
</>
)
})()}
<text
x={padding.left - CHART_AXIS_LABEL_GAP}
y={padding.top}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
fill={CHART_TICK_FILL}
>
{yAxisLabels[0]}
</text>
<text
x={padding.left - CHART_AXIS_LABEL_GAP}
y={height - padding.bottom}
textAnchor='end'
fontSize={CHART_TICK_FONT_SIZE}
fill={CHART_TICK_FILL}
>
{yAxisLabels[1]}
</text>
<line
x1={padding.left}
y1={height - padding.bottom}
x2={width - padding.right}
y2={height - padding.bottom}
stroke='hsl(var(--border))'
stroke='var(--border)'
strokeWidth='1'
/>
</svg>
@@ -613,6 +629,8 @@ function LineChartComponent({
width,
height,
tooltipMaxWidth: estimateTooltipWidth(longest),
tooltipHeight: estimateTooltipHeight(toDisplay.length, Boolean(currentHoverDate)),
padding,
})
return (
<ChartTooltip left={left} top={top} date={currentHoverDate || undefined}>
@@ -639,7 +657,4 @@ function LineChartComponent({
)
}
/**
* Memoized LineChart component to prevent re-renders when parent updates.
*/
export const LineChart = memo(LineChartComponent)
+327
View File
@@ -0,0 +1,327 @@
'use client'
import { memo, useId, useMemo, useState } from 'react'
import { truncate } from '@sim/utils/string'
import {
CHART_GRID_FRACTIONS,
CHART_TICK_FILL,
CHART_TICK_FONT_SIZE,
estimateAxisLabelWidth,
} from '@/components/charts/chart-geometry'
import {
ChartTooltip,
ChartTooltipRow,
estimateTooltipHeight,
estimateTooltipWidth,
positionChartTooltip,
} from '@/components/charts/chart-tooltip'
import {
useChartWidth,
useIsDarkTheme,
useResolvedChartColors,
} from '@/components/charts/use-chart-theme'
export interface RadarChartAxis {
label: string
value: number
/** Text shown for `value` in the hover row. Defaults to the raw number. */
display?: string
}
interface RadarChartProps {
axes: RadarChartAxis[]
color: string
height?: number
}
/** Room above and below the web for the captions on the vertical centreline. */
const LABEL_GUTTER = 52
/** Gap between the outer ring and a caption anchored beyond it. */
const LABEL_GAP = 12
/**
* The web's rings: the family's gridline fractions plus the outer ring, which is this
* chart's axis rule. Read from the constant rather than divided into `RING_COUNT`
* even steps the arithmetic agreed with the siblings only while the fractions
* happened to be uniform, which is exactly the drift `chart-geometry` exists to stop.
*/
const RING_FRACTIONS = [...CHART_GRID_FRACTIONS, 1] as const
/**
* Caption budget. A long source name would otherwise run past the container, and the
* svg paints outside its box so it would not even clip it would overlap the section
* beside it. The hover row carries the full name.
*/
const MAX_LABEL_LENGTH = 16
/**
* Polar coordinates for an axis. `-90°` puts the first axis at twelve o'clock, so a
* list read top-down and the web read clockwise start in the same place.
*/
function axisPoint(index: number, count: number, radius: number, cx: number, cy: number) {
const angle = (index / count) * Math.PI * 2 - Math.PI / 2
return { x: cx + Math.cos(angle) * radius, y: cy + Math.sin(angle) * radius }
}
function polygon(points: ReadonlyArray<{ x: number; y: number }>): string {
return points.map((p) => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(' ')
}
/**
* Shape of a distribution across a handful of named categories.
*
* The third member of the chart family, and built from the same tokens, tooltip, and
* theme hooks as {@link BarChart} and {@link LineChart}. It answers a question the
* other two cannot: a bar list ranks categories but says nothing about balance, and
* "one source dominates" versus "spend is spread evenly" is legible here at a glance
* and nowhere else on the panel.
*
* Every axis is scaled against the largest value rather than against its own range,
* so the polygon's area is proportional to the real distribution normalising each
* axis independently would draw a balanced pentagon for any input at all.
*/
function RadarChartComponent({ axes, color, height = 200 }: RadarChartProps) {
const uniqueId = useId().replace(/:/g, '')
const [containerRef, containerWidth] = useChartWidth()
const isDark = useIsDarkTheme()
const [hoverIndex, setHoverIndex] = useState<number | null>(null)
const resolvedColors = useResolvedChartColors({ base: color })
const resolvedColor = resolvedColors.base || color
const width = containerWidth ?? 0
const cx = width / 2
const cy = height / 2
/*
One memo over the whole web: hovering re-renders this component on every wedge
enter and leave, and none of this geometry can move under a hover. Guarding only
the point projection left the costlier half a per-glyph estimate of every
caption running on each of those renders.
The horizontal budget is the caption's own estimated width, the same
`estimateAxisLabelWidth` the sibling charts use to size a gutter around SVG text
they cannot measure. A 16-glyph caption runs to ~84px, so a fixed inset let every
side caption run past the plot; budgeting the radius against the real caption
width is what keeps them inside the box the svg clips to.
*/
const { maxValue, radius, points } = useMemo(() => {
const labelWidth = axes.reduce(
(max, axis) => Math.max(max, estimateAxisLabelWidth(truncate(axis.label, MAX_LABEL_LENGTH))),
0
)
const webRadius = Math.max(
0,
Math.min(width / 2 - labelWidth - LABEL_GAP, height / 2 - LABEL_GUTTER / 2)
)
const peak = Math.max(...axes.map((axis) => axis.value), 0)
return {
maxValue: peak,
radius: webRadius,
points: axes.map((axis, index) => {
const fraction = peak > 0 ? axis.value / peak : 0
return {
axis,
outer: axisPoint(index, axes.length, webRadius, cx, cy),
value: axisPoint(index, axes.length, webRadius * fraction, cx, cy),
label: axisPoint(index, axes.length, webRadius + LABEL_GAP, cx, cy),
}
}),
}
}, [axes, width, height, cx, cy])
if (containerWidth === null) {
return <div ref={containerRef} className='w-full' style={{ height }} />
}
/*
Three axes are the fewest that enclose an area; below that the "polygon" is a
line or a point and reads as a rendering fault rather than as a distribution.
*/
if (axes.length < 3 || maxValue <= 0) {
return (
<div
ref={containerRef}
className='flex w-full items-center justify-center'
style={{ height }}
>
<p className='text-[var(--text-muted)] text-sm'>No data</p>
</div>
)
}
const hovered = hoverIndex !== null ? points[hoverIndex] : null
return (
/*
Two boxes, like the siblings: the outer one scrolls, the inner one is the
positioning context. `relative` on the scroll container itself left the
absolutely-positioned tooltip anchored to the viewport of the scroll rather than
to the plot below CHART_MIN_WIDTH it stayed nailed while the web slid under it.
Captions are inside the plot by construction, since `radius` is budgeted against
`labelWidth`, so the horizontal scroll never cuts one off.
*/
<div ref={containerRef} className='w-full overflow-x-auto overflow-y-hidden'>
<div className='relative' style={{ width, height }}>
<svg width={width} height={height} className='overflow-hidden'>
<defs>
{/*
Radial rather than the siblings' vertical linear gradient a shape with
radial symmetry lit from the top reads as a rendering error. The stop
opacities stay in the family's range, and light is the more opaque theme
because dark composites through `screen` below.
*/}
<radialGradient id={`radar-${uniqueId}`}>
<stop offset='0%' stopColor={resolvedColor} stopOpacity={isDark ? 0.32 : 0.45} />
<stop offset='100%' stopColor={resolvedColor} stopOpacity={isDark ? 0.1 : 0.14} />
</radialGradient>
</defs>
{RING_FRACTIONS.map((fraction) => (
<polygon
key={`${uniqueId}-ring-${fraction}`}
points={polygon(
axes.map((_, index) => axisPoint(index, axes.length, radius * fraction, cx, cy))
)}
fill='none'
stroke='var(--border)'
strokeOpacity={fraction === 1 ? 1 : 0.35}
strokeWidth='1'
/>
))}
{points.map((point, index) => (
<line
key={`${uniqueId}-spoke-${point.axis.label}`}
x1={cx}
y1={cy}
x2={point.outer.x}
y2={point.outer.y}
stroke='var(--border)'
strokeOpacity={hoverIndex === index ? 1 : 0.35}
strokeWidth='1'
/>
))}
<g style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}>
<polygon
points={polygon(points.map((point) => point.value))}
fill={`url(#radar-${uniqueId})`}
stroke={resolvedColor}
strokeWidth={isDark ? 1.7 : 2}
strokeLinejoin='round'
/>
{points.map((point, index) => (
<circle
key={`${uniqueId}-vertex-${point.axis.label}`}
cx={point.value.x}
cy={point.value.y}
r={hoverIndex === index ? 3 : 2}
fill={resolvedColor}
/>
))}
</g>
{points.map((point, index) => (
<text
key={`${uniqueId}-label-${point.axis.label}`}
x={point.label.x}
y={point.label.y}
/*
Anchored away from the centre so a caption never crosses the web: the
left half ends at its x, the right half starts at it, and the two axes
on the vertical centreline are centred.
The baseline follows the same logic. `auto` is alphabetic, so glyphs sit
*above* their anchor right for the caption at twelve o'clock, but it
left the one at six o'clock riding ~3px off the ring instead of the
LABEL_GAP it was given, and it vertically misaligned every caption beside
the web from its own vertex.
*/
textAnchor={
Math.abs(point.label.x - cx) < 1 ? 'middle' : point.label.x > cx ? 'start' : 'end'
}
dominantBaseline={
Math.abs(point.label.x - cx) >= 1
? 'middle'
: point.label.y > cy
? 'hanging'
: 'auto'
}
fontSize={CHART_TICK_FONT_SIZE}
fill={CHART_TICK_FILL}
>
{truncate(point.axis.label, MAX_LABEL_LENGTH)}
</text>
))}
{/*
Hit targets last so they sit above the painted web, and wedge-sized a
vertex-sized target is far too small to hover on a 200px chart.
An arc sector, not a triangle. A triangle's far edge is the chord, which
along its own spoke reaches only `reach·cos(π/n)` at three axes that is
50px against a 74px radius, so the largest value's vertex, the one a reader
aims at, sat outside its own target and outside every other. Sectors tile
identically and reach `reach` in every direction. The sweep flag is 1
because SVG's y grows downward, and the arc is never a major one: /n
2π/3 < π for the three-or-more axes this chart requires.
*/}
{points.map((point, index) => {
const half = Math.PI / axes.length
const angle = (index / axes.length) * Math.PI * 2 - Math.PI / 2
const reach = radius + LABEL_GUTTER / 2
const a = {
x: cx + Math.cos(angle - half) * reach,
y: cy + Math.sin(angle - half) * reach,
}
const b = {
x: cx + Math.cos(angle + half) * reach,
y: cy + Math.sin(angle + half) * reach,
}
return (
<path
key={`${uniqueId}-hit-${point.axis.label}`}
d={`M ${cx} ${cy} L ${a.x} ${a.y} A ${reach} ${reach} 0 0 1 ${b.x} ${b.y} Z`}
fill='transparent'
onMouseEnter={() => setHoverIndex(index)}
onMouseLeave={() => setHoverIndex(null)}
/>
)
})}
</svg>
{hovered &&
(() => {
const value = hovered.axis.display ?? String(hovered.axis.value)
/*
Beside the hovered vertex, through the same placer the siblings use, so
the box flips and clamps identically. Centring it on the web instead put
a filled panel over the densest part of the gradient the concentration
this chart exists to show. The padding passed is the caption gap rather
than the axis-bearing charts' gutters: a radar has no axis rules to keep
clear of.
*/
const { left, top } = positionChartTooltip({
anchorX: hovered.value.x,
anchorY: hovered.value.y,
width,
height,
tooltipMaxWidth: estimateTooltipWidth(
Math.max(hovered.axis.label.length, value.length)
),
tooltipHeight: estimateTooltipHeight(1, true),
padding: { top: 0, right: LABEL_GAP, bottom: 0, left: LABEL_GAP },
})
return (
<ChartTooltip left={left} top={top} date={hovered.axis.label}>
<ChartTooltipRow color={resolvedColor} value={value} />
</ChartTooltip>
)
})()}
</div>
</div>
)
}
export const RadarChart = memo(RadarChartComponent)
+45 -27
View File
@@ -1,27 +1,49 @@
'use client'
import { type RefObject, useEffect, useRef, useState } from 'react'
import { type RefObject, useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { CHART_MIN_WIDTH } from '@/components/charts/chart-geometry'
function subscribeToDarkTheme(onStoreChange: () => void): () => void {
const observer = new MutationObserver(onStoreChange)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}
function getDarkThemeSnapshot(): boolean {
return document.documentElement.classList.contains('dark')
}
/** Dark is the assumed default before the class is readable, matching first paint. */
function getServerDarkThemeSnapshot(): boolean {
return true
}
/**
* Whether the document is in dark mode, tracked by observing the class the theme
* toggle writes. Charts need this as a *value* rather than a CSS class because SVG
* stroke opacity and blend mode are set per element, not by a selector.
* Whether the document is in dark mode, read from the class the theme toggle writes.
* Charts need this as a *value* rather than a CSS class because SVG stroke opacity
* and blend mode are set per element, not by a selector.
*
* The class is an external store, so it is read through `useSyncExternalStore`: the
* first client render already sees the real value instead of painting the default and
* correcting it in an effect.
*/
export function useIsDarkTheme(): boolean {
const [isDark, setIsDark] = useState(true)
return useSyncExternalStore(
subscribeToDarkTheme,
getDarkThemeSnapshot,
getServerDarkThemeSnapshot
)
}
useEffect(() => {
if (typeof window === 'undefined') return
const element = document.documentElement
const update = () => setIsDark(element.classList.contains('dark'))
update()
const observer = new MutationObserver(update)
observer.observe(element, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
return isDark
/** Materializes one `var(--token)` into a concrete `rgb()` via a throwaway probe node. */
function resolveColor(value: string): string {
if (!value.startsWith('var(')) return value
const probe = document.createElement('div')
probe.style.color = value
document.body.appendChild(probe)
const computed = window.getComputedStyle(probe).color
probe.remove()
return computed
}
/**
@@ -34,26 +56,22 @@ export function useIsDarkTheme(): boolean {
export function useResolvedChartColors(colors: Record<string, string>): Record<string, string> {
const [resolved, setResolved] = useState<Record<string, string>>({})
const serialized = JSON.stringify(colors)
/*
A token resolves to a different `rgb()` per theme, and the probe runs once per
token set so without this the colours resolved on the theme the chart mounted
under survived a toggle, and the series kept its dark-mode fill on a light page.
*/
const isDark = useIsDarkTheme()
useEffect(() => {
if (typeof window === 'undefined') return
const resolveColor = (value: string): string => {
if (!value.startsWith('var(')) return value
const probe = document.createElement('div')
probe.style.color = value
document.body.appendChild(probe)
const computed = window.getComputedStyle(probe).color
probe.remove()
return computed
}
const next: Record<string, string> = {}
for (const [key, value] of Object.entries(JSON.parse(serialized) as Record<string, string>)) {
next[key] = resolveColor(value)
}
setResolved(next)
}, [serialized])
}, [serialized, isDark])
return resolved
}
@@ -1,9 +1,9 @@
---
slug: openai-vs-n8n-vs-sim
title: 'OpenAI AgentKit vs n8n vs Sim: AI Agent Workflow Builder Comparison'
description: OpenAI just released AgentKit for building AI agents. How does it compare to workflow automation platforms like n8n and purpose-built AI agent builders like Sim?
title: 'Sim vs n8n vs OpenAI AgentKit: AI Agent Builder Comparison (2026)'
description: 'Compare Sim with n8n and OpenAI AgentKit on integrations and deployment. See how Sim''s open-source platform works with multiple model providers.'
date: 2025-10-06
updated: 2026-07-23
updated: 2026-08-28
authors:
- emir
readingTime: 9
@@ -0,0 +1,51 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import type { AuditLogPage } from '@/lib/api/contracts/audit-logs'
import { presentableAuditEntries } from '@/ee/audit-logs/components/audit-logs'
function page(...ids: string[]): AuditLogPage {
return {
success: true,
data: ids.map((id) => ({
id,
workspaceId: null,
actorId: null,
actorName: null,
actorEmail: null,
action: 'organization.updated',
resourceType: 'organization',
resourceId: null,
resourceName: null,
description: null,
metadata: null,
createdAt: '2026-01-01T00:00:00.000Z',
})),
}
}
describe('presentableAuditEntries', () => {
it('flattens every loaded page while the scope is answerable', () => {
expect(presentableAuditEntries([page('a', 'b'), page('c')], true).map((e) => e.id)).toEqual([
'a',
'b',
'c',
])
})
/**
* The case this exists for: an unresolved workspace scope drops the filter, so its
* query key equals the unscoped feed's. Disabling the query does not clear that
* cache entry, so an admin who had just been reading the organization-wide feed
* would have kept its rows on screen under a scoped URL and Export, which gates
* on this list being non-empty, stayed armed against them.
*/
it('presents nothing when the scope cannot be answered, even with pages cached', () => {
expect(presentableAuditEntries([page('a', 'b')], false)).toEqual([])
})
it('presents nothing before any page has loaded', () => {
expect(presentableAuditEntries(undefined, true)).toEqual([])
})
})
+156 -31
View File
@@ -1,27 +1,27 @@
'use client'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import {
Badge,
Button,
Calendar,
Chip,
ChipCombobox,
ChipInput,
ChipSelect,
type ComboboxOption,
Download,
OverflowText,
Popover,
PopoverAnchor,
PopoverContent,
RefreshCw,
Search,
toast,
} from '@sim/emcn'
import { Download, RefreshCw, Search, X } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { formatDateTime } from '@sim/utils/formatting'
import { isRecordLike } from '@sim/utils/object'
import { useQueryStates } from 'nuqs'
import type { AuditLogPage } from '@/lib/api/contracts/audit-logs'
import { formatDateShort } from '@/lib/core/utils/date-display'
import { getEndDateFromTimeRange, getStartDateFromTimeRange } from '@/lib/logs/filters'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
@@ -33,6 +33,7 @@ import {
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
import { useOrganizationWorkspaces } from '@/ee/access-control/hooks/permission-groups'
import { RESOURCE_TYPE_OPTIONS } from '@/ee/audit-logs/constants'
import { type AuditLogFilters, useAuditLogs } from '@/ee/audit-logs/hooks/audit-logs'
import {
@@ -150,12 +151,15 @@ function renderMetadataValue(value: unknown) {
)
}
/** Already rendered as their own labelled rows, so the metadata block would repeat them. */
const HIDDEN_METADATA_KEYS = new Set(['name', 'description'])
function getMetadataEntries(metadata: unknown) {
if (!isRecordLike(metadata)) return []
return Object.entries(metadata).filter(([key, value]) => {
if (value === undefined) return false
return !['name', 'description'].includes(key)
return !HIDDEN_METADATA_KEYS.has(key)
})
}
@@ -237,6 +241,24 @@ interface AuditLogsProps {
organizationId: string
}
/**
* Entries the feed is allowed to present.
*
* A disabled query still serves whatever is cached under its key, and an unresolved
* workspace scope resolves to the same key as the unscoped feed so an admin looking
* at the organization-wide feed who then followed a stale scoped link kept those rows
* on screen, with Export still armed against them. The scope a link asks for is a
* ceiling, so when it cannot be honoured the feed presents nothing rather than
* whatever it happens to be holding.
*/
export function presentableAuditEntries(
pages: AuditLogPage[] | undefined,
isScopeAnswerable: boolean
): EnterpriseAuditLogEntry[] {
if (!isScopeAnswerable || !pages) return []
return pages.flatMap((page) => page.data)
}
export function AuditLogs({ organizationId }: AuditLogsProps) {
const [urlFilters, setUrlFilters] = useQueryStates(auditLogFilterParsers, auditLogFilterUrlKeys)
const { types: selectedTypes } = urlFilters
@@ -251,30 +273,83 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
urlFilters.timeRange === 'Custom range' && (!customStartDate || !customEndDate)
? DEFAULT_AUDIT_TIME_RANGE
: urlFilters.timeRange
/**
* Resolved, not merely present. Only the id lives in the URL, and the filter is
* applied once it matches a workspace the organization actually owns a stale id
* from an old link would otherwise be shown under a chip labelled with a bare uuid.
*/
const workspaceScope = urlFilters.workspace
const orgWorkspaces = useOrganizationWorkspaces(organizationId, Boolean(workspaceScope))
const scopedWorkspace = workspaceScope
? orgWorkspaces.data?.find((entry) => entry.id === workspaceScope)
: undefined
const [datePickerOpen, setDatePickerOpen] = useState(false)
const dateRangeAppliedRef = useRef(false)
const [searchTerm, setSearchTerm] = useSettingsSearch()
const debouncedSearch = useDebounce(searchTerm, SEARCH_DEBOUNCE_MS).trim()
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
const refreshTimersRef = useRef(new Set<number>())
const refreshTimersRef = useRef<Set<number> | null>(null)
refreshTimersRef.current ??= new Set<number>()
const refreshTimers = refreshTimersRef.current
const [isExporting, setIsExporting] = useState(false)
useEffect(() => {
const timers = refreshTimersRef.current
return () => {
for (const timerId of timers) window.clearTimeout(timerId)
for (const timerId of refreshTimers) window.clearTimeout(timerId)
}
}, [])
}, [refreshTimers])
const filters = useMemo<AuditLogFilters>(() => {
return {
search: debouncedSearch || undefined,
resourceType: selectedTypes.length > 0 ? selectedTypes.join(',') : undefined,
startDate: getStartDateFromTimeRange(timeRange, customStartDate)?.toISOString(),
endDate: getEndDateFromTimeRange(timeRange, customEndDate)?.toISOString(),
}
}, [debouncedSearch, selectedTypes, timeRange, customStartDate, customEndDate])
/*
Not memoized: this object is only ever hashed, never compared by identity React
Query hashes a query key structurally, and the export handler reads its fields
directly. The same rule `useUsageWindow` applies to its window object.
*/
const filters: AuditLogFilters = {
search: debouncedSearch || undefined,
resourceType: selectedTypes.length > 0 ? selectedTypes.join(',') : undefined,
workspaceId: scopedWorkspace?.id,
startDate: getStartDateFromTimeRange(timeRange, customStartDate)?.toISOString(),
endDate: getEndDateFromTimeRange(timeRange, customEndDate)?.toISOString(),
}
/**
* A deep-linked workspace scope is only resolvable once the organization's workspace
* list has loaded. Querying before then fetches the whole organization's feed and
* immediately refetches it narrowed two requests, with a flash of rows the link
* did not ask for in between.
*/
const isWorkspaceScopePending = Boolean(workspaceScope) && orgWorkspaces.isPending
/**
* The lookup itself failed, so whether the workspace exists is simply unknown.
*
* Kept apart from {@link isWorkspaceScopeUnresolved}: telling an admin their
* workspace is not part of the organization because a request timed out is a wrong
* answer, not a cautious one, and it offers nothing to do about it. Refresh retries
* this lookup alongside the feed.
*/
const isWorkspaceScopeUnavailable = Boolean(workspaceScope) && orgWorkspaces.isError
/**
* The link named a workspace this organization does not have deleted since, or
* never one of ours.
*
* The feed stays closed rather than falling back to the organization. Every other
* deep-linked id in the app degrades to the unfiltered view, but an audit feed is
* the one place where widening is the dangerous direction: dropping the filter
* would answer a request for one workspace's history with everybody's, under a URL
* that still claims to be scoped, and the CSV export would follow.
*/
const isWorkspaceScopeUnresolved =
Boolean(workspaceScope) &&
!isWorkspaceScopePending &&
!isWorkspaceScopeUnavailable &&
!scopedWorkspace
/** The feed can answer the scope the URL asks for — the gate on reading or exporting. */
const isScopeAnswerable =
!isWorkspaceScopePending && !isWorkspaceScopeUnresolved && !isWorkspaceScopeUnavailable
const {
data,
isLoading,
@@ -283,12 +358,12 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
hasNextPage,
fetchNextPage,
refetch,
} = useAuditLogs(organizationId, filters)
} = useAuditLogs(organizationId, filters, !isWorkspaceScopePending && !isWorkspaceScopeUnresolved)
const allEntries = useMemo(() => {
if (!data?.pages) return []
return data.pages.flatMap((page) => page.data)
}, [data])
const allEntries = useMemo(
() => presentableAuditEntries(data?.pages, isScopeAnswerable),
[data, isScopeAnswerable]
)
const typeDisplayLabel =
selectedTypes.length === 0
@@ -324,25 +399,38 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
setDatePickerOpen(false)
}
const handleRefresh = useCallback(() => {
const handleRefresh = () => {
setIsVisuallyRefreshing(true)
const timerId = window.setTimeout(() => {
setIsVisuallyRefreshing(false)
refreshTimersRef.current.delete(timerId)
refreshTimers.delete(timerId)
}, REFRESH_SPINNER_DURATION_MS)
refreshTimersRef.current.add(timerId)
refetch().catch((error: unknown) => {
refreshTimers.add(timerId)
const pending: Promise<unknown>[] = []
/*
`refetch` ignores `enabled`, so this has to repeat the gate. While the scope is
unanswerable the feed's filter carries no workspace, and refreshing it would
issue exactly the organization-wide read the gate exists to prevent.
*/
if (isScopeAnswerable) pending.push(refetch())
/*
The lookup is what has to succeed for a closed feed to reopen, so it is retried
whenever a scope asked for it and skipped entirely when none did, where it is
a disabled query with nothing to say.
*/
if (workspaceScope) pending.push(orgWorkspaces.refetch())
Promise.all(pending).catch((error: unknown) => {
logger.error('Failed to refresh audit logs', { error })
})
}, [refetch])
}
const handleLoadMore = useCallback(() => {
const handleLoadMore = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage().catch((error: unknown) => {
logger.error('Failed to load more audit logs', { error })
})
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage])
}
const handleExportCsv = async () => {
setIsExporting(true)
@@ -351,6 +439,7 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
params.set('organizationId', organizationId)
if (filters.search) params.set('search', filters.search)
if (filters.resourceType) params.set('resourceType', filters.resourceType)
if (filters.workspaceId) params.set('workspaceId', filters.workspaceId)
if (filters.startDate) params.set('startDate', filters.startDate)
if (filters.endDate) params.set('endDate', filters.endDate)
@@ -385,7 +474,13 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
text: 'Export',
icon: Download,
onSelect: () => void handleExportCsv(),
disabled: allEntries.length === 0 || isExporting || isPlaceholderData,
/*
`isScopeAnswerable` explicitly, not just via the empty `allEntries` it
implies: the export is the action that leaves the building, so the
condition that makes it safe belongs where it is read.
*/
disabled:
!isScopeAnswerable || allEntries.length === 0 || isExporting || isPlaceholderData,
},
]}
>
@@ -410,6 +505,28 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
allOptionLabel='All types'
align='start'
/>
{workspaceScope && (
/*
A deep-linked scope, not a picker: the organization can hold hundreds of
workspaces, so this narrows the feed only when a link asks it to and
offers exactly one action take it back off. Trailing `X` and a bounded
width, matching the app's other removable filter chips; the label names
the dimension because a bare workspace name gives no clue what it scopes.
*/
<Chip
rightIcon={X}
onClick={() => void setUrlFilters({ workspace: null })}
aria-label='Clear the workspace filter'
className='max-w-[280px] shrink-0'
>
{/* Rendered for an unresolved scope too, or a bad link would leave the
feed closed with no control to reopen it. */}
<OverflowText
label={`Workspace: ${scopedWorkspace?.name ?? (isWorkspaceScopeUnavailable ? 'unavailable' : 'not found')}`}
className='block min-w-0'
/>
</Chip>
)}
<div className='relative'>
{/* ChipCombobox (Radix Popover, non-modal), not ChipSelect (Radix
DropdownMenu, modal by default) a modal trigger closing in the
@@ -469,7 +586,15 @@ export function AuditLogs({ organizationId }: AuditLogsProps) {
<ActivityLog
entries={allEntries.map(toActivityEntry)}
emptyState={
isLoading ? undefined : debouncedSearch ? (
isLoading || isWorkspaceScopePending ? undefined : isWorkspaceScopeUnavailable ? (
<SettingsEmptyState tone='error'>
Couldn't check that workspace. Refresh to try again.
</SettingsEmptyState>
) : isWorkspaceScopeUnresolved ? (
<SettingsEmptyState>
That workspace is not part of this organization.
</SettingsEmptyState>
) : debouncedSearch ? (
<SettingsEmptyState variant='inline'>
No results for "{debouncedSearch}"
</SettingsEmptyState>
@@ -50,8 +50,16 @@ let container: HTMLDivElement
let root: Root
let queryClient: QueryClient
function AuditProbe({ organizationId }: { organizationId: string }) {
const auditLogs = useAuditLogs(organizationId, {})
function AuditProbe({
organizationId,
workspaceId,
search,
}: {
organizationId: string
workspaceId?: string
search?: string
}) {
const auditLogs = useAuditLogs(organizationId, { workspaceId, search })
const entries = auditLogs.data?.pages.flatMap((page) => page.data) ?? []
return (
@@ -62,11 +70,20 @@ function AuditProbe({ organizationId }: { organizationId: string }) {
)
}
function renderAuditLogs(organizationId: string) {
interface RenderOptions {
workspaceId?: string
search?: string
}
function renderAuditLogs(organizationId: string, options: RenderOptions = {}) {
act(() => {
root.render(
<QueryClientProvider client={queryClient}>
<AuditProbe organizationId={organizationId} />
<AuditProbe
organizationId={organizationId}
workspaceId={options.workspaceId}
search={options.search}
/>
</QueryClientProvider>
)
})
@@ -130,4 +147,50 @@ describe('useAuditLogs identity transitions', () => {
})
)
})
/** Blanking the feed on each keystroke is what the placeholder exists to stop. */
it('holds the current entries while a filter change loads, within one scope', async () => {
const filteredPage = createDeferred<AuditLogPage>()
mockRequestJson.mockImplementation(
(contract: unknown, input: { query?: { search?: string } }) => {
if (contract !== listAuditLogsContract) throw new Error('Unexpected contract')
return input.query?.search ? filteredPage.promise : Promise.resolve(AUDIT_PAGE_A)
}
)
renderAuditLogs('org-a')
await flushQueries()
expect(container).toHaveTextContent('Updated Organization A')
renderAuditLogs('org-a', { search: 'canary' })
await flushQueries()
expect(container).toHaveTextContent('Updated Organization A')
})
/**
* The other side of that rule. A workspace is a scope, not a filter: holding the
* organization-wide rows while the scoped page loads would show, under a
* workspace-scoped URL, entries that scope does not cover with Export armed
* against them, since it gates on this list being non-empty.
*/
it('clears the entries when the workspace scope changes, within one organization', async () => {
const scopedPage = createDeferred<AuditLogPage>()
mockRequestJson.mockImplementation(
(contract: unknown, input: { query?: { workspaceId?: string } }) => {
if (contract !== listAuditLogsContract) throw new Error('Unexpected contract')
return input.query?.workspaceId ? scopedPage.promise : Promise.resolve(AUDIT_PAGE_A)
}
)
renderAuditLogs('org-a')
await flushQueries()
expect(container).toHaveTextContent('Updated Organization A')
renderAuditLogs('org-a', { workspaceId: 'workspace-a' })
await flushQueries()
expect(container).not.toHaveTextContent('Updated Organization A')
expect(container.querySelector('button')).toBeNull()
})
})
+37 -3
View File
@@ -1,4 +1,4 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { hashKey, useInfiniteQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { type AuditLogPage, listAuditLogsContract } from '@/lib/api/contracts/audit-logs'
@@ -7,8 +7,22 @@ export const AUDIT_LOG_LIST_STALE_TIME = 30 * 1000
export const auditLogKeys = {
all: ['audit-logs'] as const,
lists: () => [...auditLogKeys.all, 'list'] as const,
/**
* What a key is allowed to see: the organization, and the workspace within it.
*
* It leads the key, ahead of the filters, because previous data may be held across
* a filter change but never across a scope change and a leading scope makes that
* a prefix comparison rather than a reach inside the filter object.
*/
scope: (organizationId: string, workspaceId?: string) =>
[...auditLogKeys.lists(), organizationId, workspaceId ?? ''] as const,
list: (organizationId: string, filters: AuditLogFilters) =>
[...auditLogKeys.lists(), organizationId, filters] as const,
[...auditLogKeys.scope(organizationId, filters.workspaceId), filters] as const,
}
/** The scope a key reads from, which is everything but its trailing filter object. */
function auditListScopeIdentity(key: readonly unknown[]): string {
return hashKey(key.slice(0, -1))
}
export interface AuditLogFilters {
@@ -16,6 +30,8 @@ export interface AuditLogFilters {
action?: string
resourceType?: string
actorId?: string
/** Narrows the feed to one workspace in the organization. */
workspaceId?: string
startDate?: string
endDate?: string
}
@@ -34,6 +50,7 @@ async function fetchAuditLogs(
action: filters.action,
resourceType: filters.resourceType,
actorId: filters.actorId,
workspaceId: filters.workspaceId,
startDate: filters.startDate,
endDate: filters.endDate,
cursor,
@@ -43,12 +60,29 @@ async function fetchAuditLogs(
}
export function useAuditLogs(organizationId: string, filters: AuditLogFilters, enabled = true) {
const queryKey = auditLogKeys.list(organizationId, filters)
return useInfiniteQuery({
queryKey: auditLogKeys.list(organizationId, filters),
queryKey,
queryFn: ({ pageParam, signal }) => fetchAuditLogs(organizationId, filters, pageParam, signal),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
enabled: Boolean(organizationId) && enabled,
staleTime: AUDIT_LOG_LIST_STALE_TIME,
/**
* Held across a filter change, never across a scope change.
*
* Search, types and the window are all part of the key, so without a placeholder
* the feed blanks to its empty state on each keystroke and the Export action's
* `isPlaceholderData` guard is dead. But the organization and the workspace are in
* the key too, and holding across either shows rows the current scope does not
* cover one tenant's entries under another's heading, or the organization's
* under a workspace-scoped URL with Export armed against them.
*/
placeholderData: (previous, previousQuery) =>
previous &&
previousQuery &&
auditListScopeIdentity(previousQuery.queryKey) === auditListScopeIdentity(queryKey)
? previous
: undefined,
})
}
+18 -1
View File
@@ -1,4 +1,4 @@
import { parseAsArrayOf, parseAsString } from 'nuqs/server'
import { createSerializer, parseAsArrayOf, parseAsString } from 'nuqs/server'
import {
parseAsDateString,
parseAsTimeRange,
@@ -20,6 +20,13 @@ export const DEFAULT_AUDIT_TIME_RANGE: TimeRange = 'Past 30 days'
*/
export const auditLogFilterParsers = {
types: parseAsArrayOf(parseAsString).withDefault([]),
/**
* Nullable by design: the feed is organization-wide unless a link narrows it, and
* the usage panel's workspace drill-down is what does. Only the id is stored the
* name is resolved from the loaded workspace list, so a stale id from an old link
* clears the filter rather than labelling it with nothing.
*/
workspace: parseAsString,
timeRange: parseAsTimeRange.withDefault(DEFAULT_AUDIT_TIME_RANGE),
startDate: parseAsDateString,
endDate: parseAsDateString,
@@ -36,3 +43,13 @@ export const auditLogFilterUrlKeys = {
endDate: 'end-date',
},
} as const
/**
* Outbound links into the audit feed the usage panel's workspace drill-down builds
* one serialized from the map the feed itself parses rather than by concatenation,
* which emitted a bare `?workspace=` for a null id and left the value unencoded.
*/
export const serializeAuditLogFilters = createSerializer(auditLogFilterParsers, {
clearOnDefault: true,
urlKeys: auditLogFilterUrlKeys.urlKeys,
})
@@ -0,0 +1,15 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { USAGE_PROVIDER_ICON_IDS } from '@/ee/organization-usage/components/usage-consumers'
import { PROVIDER_DEFINITIONS } from '@/providers/models'
describe('PROVIDER_ICONS', () => {
/** A gap is silent: the row simply renders with no mark. */
it('covers every provider the model registry defines', () => {
const covered = new Set(USAGE_PROVIDER_ICON_IDS)
const missing = Object.keys(PROVIDER_DEFINITIONS).filter((id) => !covered.has(id))
expect(missing).toEqual([])
})
})
@@ -1,21 +1,33 @@
'use client'
import type { ComponentType } from 'react'
import { cn } from '@sim/emcn'
import { ChevronRight } from '@sim/emcn/icons'
import { cn, disclosureChevronClass } from '@sim/emcn'
import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
import { formatChartCompactNumber } from '@/components/charts'
import {
AnthropicIcon,
AzureIcon,
BasetenIcon,
BedrockIcon,
CerebrasIcon,
DeepseekIcon,
GoogleIcon,
FireworksIcon,
GeminiIcon,
GroqIcon,
KimiIcon,
LitellmIcon,
MetaIcon,
MistralIcon,
NvidiaIcon,
OllamaIcon,
OpenAIIcon,
OpenRouterIcon,
SakanaIcon,
TogetherIcon,
VertexIcon,
VllmIcon,
xAIIcon,
ZaiIcon,
} from '@/components/icons'
import type {
OrganizationUsageBreakdown,
@@ -27,34 +39,65 @@ import {
RowActionsMenu,
} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import { RESOURCE_ROW_ARROW_CLASSES } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { USAGE_TAB_EMPTY_COPY } from '@/ee/organization-usage/constants'
/**
* Provider brand marks, keyed by the `providerId` the server resolves.
*
* Kept here rather than read from `providers/models.ts`: that module carries the
* Kept here rather than read from `PROVIDER_DEFINITIONS`: that module carries the
* whole model registry and would land in this settings chunk for two dozen glyphs.
* The icons themselves come from the same `@/components/icons` module the registry
* imports, so this is a re-keying, never a second set of artwork.
*
* It must list every provider the registry defines, or a model resolving to a
* missing one renders an unexplained blank where every neighbouring row has a mark
* which is how `zai` (GLM) shipped iconless. `usage-consumers.test.ts` fails when
* the two drift, so the coverage is checked rather than remembered.
*/
const PROVIDER_ICONS: Readonly<Record<string, ComponentType<{ className?: string }>>> = {
openai: OpenAIIcon,
anthropic: AnthropicIcon,
google: GoogleIcon,
'azure-openai': AzureIcon,
deepseek: DeepseekIcon,
xai: xAIIcon,
groq: GroqIcon,
baseten: BasetenIcon,
bedrock: BedrockIcon,
cerebras: CerebrasIcon,
ollama: OllamaIcon,
openrouter: OpenRouterIcon,
deepseek: DeepseekIcon,
fireworks: FireworksIcon,
google: GeminiIcon,
groq: GroqIcon,
kimi: KimiIcon,
litellm: LitellmIcon,
meta: MetaIcon,
mistral: MistralIcon,
nvidia: NvidiaIcon,
ollama: OllamaIcon,
'ollama-cloud': OllamaIcon,
openai: OpenAIIcon,
openrouter: OpenRouterIcon,
sakana: SakanaIcon,
together: TogetherIcon,
vertex: VertexIcon,
vllm: VllmIcon,
xai: xAIIcon,
zai: ZaiIcon,
'azure-anthropic': AzureIcon,
/** Not a registry provider — a BYOK credential kind the breakdown can also emit. */
'azure-openai': AzureIcon,
}
export const USAGE_PROVIDER_ICON_IDS = Object.keys(PROVIDER_ICONS)
interface UsageConsumerRowProps {
row: OrganizationUsageBreakdownRow
/** BYOK rows carry no cost, so tokens are the only usage they can show. */
showTokensOnly: boolean
onSelect?: (row: OrganizationUsageBreakdownRow) => void
actions?: RowAction[]
/**
* Width of the affordance some other row in this list carries, reserved here so
* every figure stays in one column including when the only row that carries one
* is `Other`.
*/
reservedTrailing?: string
}
/**
@@ -62,17 +105,31 @@ interface UsageConsumerRowProps {
* same slot on its `Other` row and keep every figure in one column.
*/
const TRAILING_SLOT_CLASSES = {
/** `ChevronRight` at the platform icon size. */
chevron: 'size-[14px]',
arrow: 'size-4',
/** `RowActionsMenu`'s trigger: a 14px glyph in a `chipVariants()` pill. */
menu: 'size-[30px]',
/** The disclosure chevron on an expandable `Other` row, at the default icon size. */
disclosure: 'size-[14px]',
} as const
/**
* Geometry of the bespoke tabular usage row the sanctioned exception to
* `SettingsResourceRow` in `sim-settings-pages.md`. One definition, so the breakdown
* rows, the `Other` row, and the events ledger cannot drift apart.
*/
export const USAGE_ROW_CLASSES = 'flex w-full items-center gap-2.5 rounded-lg p-2 text-left'
/**
* A tabular row, not `SettingsResourceRow` tabular columns are the sanctioned
* exception in `sim-settings-pages.md`, alongside billing invoices and credit usage.
*/
function UsageConsumerRow({ row, showTokensOnly, onSelect, actions }: UsageConsumerRowProps) {
function UsageConsumerRow({
row,
showTokensOnly,
onSelect,
actions,
reservedTrailing,
}: UsageConsumerRowProps) {
const ProviderIcon = row.providerId ? PROVIDER_ICONS[row.providerId] : undefined
const Row = onSelect ? 'button' : 'div'
@@ -86,8 +143,8 @@ function UsageConsumerRow({ row, showTokensOnly, onSelect, actions }: UsageConsu
}
: {})}
className={cn(
'flex w-full items-center gap-2.5 rounded-lg p-2 text-left',
onSelect && 'transition-colors hover:bg-[var(--surface-active)]'
USAGE_ROW_CLASSES,
onSelect && 'transition-colors hover-hover:bg-[var(--surface-active)]'
)}
>
{ProviderIcon && (
@@ -106,13 +163,13 @@ function UsageConsumerRow({ row, showTokensOnly, onSelect, actions }: UsageConsu
<span className='w-[72px] flex-shrink-0 text-right text-[var(--text-muted)] text-caption tabular-nums'>
{showTokensOnly ? formatChartCompactNumber(row.tokens ?? 0) : row.credits.toLocaleString()}
</span>
{/* A chevron or a menu, never both — `sim-settings-pages.md`. */}
{/* An arrow or a menu, never both — `sim-settings-pages.md`. */}
{onSelect ? (
<ChevronRight
className={cn(TRAILING_SLOT_CLASSES.chevron, 'flex-shrink-0 text-[var(--text-icon)]')}
/>
<ArrowRight className={RESOURCE_ROW_ARROW_CLASSES} />
) : actions?.length ? (
<RowActionsMenu label={`${row.label} actions`} actions={actions} />
) : reservedTrailing ? (
<span className={cn(reservedTrailing, 'flex-shrink-0')} aria-hidden='true' />
) : null}
</Row>
)
@@ -123,10 +180,17 @@ interface UsageConsumersProps {
breakdown?: OrganizationUsageBreakdown
isLoading: boolean
isError: boolean
/** Dims the list while a re-keyed fetch resolves, rather than blanking it. */
isPlaceholderData?: boolean
/** Set on Workspaces, where a row drills into that workspace's workflows. */
onSelectRow?: (row: OrganizationUsageBreakdownRow) => void
/** Set on Members, where a row can open the shared manage-credits modal. */
rowActions?: (row: OrganizationUsageBreakdownRow) => RowAction[]
/**
* Opens the truncated tail. Omitted when the list is already showing everything the
* API will return, which is the one case where the `Other` row has nothing to open.
*/
onExpandOther?: () => void
}
export function UsageConsumers({
@@ -134,8 +198,10 @@ export function UsageConsumers({
breakdown,
isLoading,
isError,
isPlaceholderData,
onSelectRow,
rowActions,
onExpandOther,
}: UsageConsumersProps) {
if (isError) {
return (
@@ -155,18 +221,26 @@ export function UsageConsumers({
const showTokensOnly = dimension === 'byok'
const trailingSlot = onSelectRow
? TRAILING_SLOT_CLASSES.chevron
? TRAILING_SLOT_CLASSES.arrow
: rowActions
? TRAILING_SLOT_CLASSES.menu
: null
: onExpandOther
? TRAILING_SLOT_CLASSES.disclosure
: null
return (
<div className='-mx-2 flex flex-col gap-y-0.5'>
<div
className={cn(
'-mx-2 flex flex-col gap-y-0.5',
isPlaceholderData && 'opacity-50 transition-opacity'
)}
>
{breakdown.rows.map((row) => (
<UsageConsumerRow
key={`${dimension}-${row.id}`}
row={row}
showTokensOnly={showTokensOnly}
{...(onExpandOther && trailingSlot ? { reservedTrailing: trailingSlot } : {})}
{...(onSelectRow && row.id ? { onSelect: onSelectRow } : {})}
{...(rowActions && row.id ? { actions: rowActions(row) } : {})}
/>
@@ -174,22 +248,53 @@ export function UsageConsumers({
{/*
The truncated tail, named rather than dropped: a ranked list that does not add
up to the headline figure is how "the numbers are wrong" reports start.
A button when there is more the API can return, so the tail opens in place.
Past the API's ceiling it stays a plain row a control that cannot change
what you see is worse than no control.
*/}
{breakdown.other.rowCount > 0 && (
<div className='flex items-center gap-2.5 rounded-lg p-2 text-left'>
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-sm'>
{`Other (${breakdown.other.rowCount} more)`}
</span>
<span className='w-[72px] flex-shrink-0 text-right text-[var(--text-muted)] text-caption tabular-nums'>
{showTokensOnly
? formatChartCompactNumber(breakdown.other.tokens)
: breakdown.other.credits.toLocaleString()}
</span>
{trailingSlot && (
<span className={cn(trailingSlot, 'flex-shrink-0')} aria-hidden='true' />
)}
</div>
)}
{breakdown.other.rowCount > 0 &&
(() => {
const OtherRow = onExpandOther ? 'button' : 'div'
return (
<OtherRow
{...(onExpandOther
? {
type: 'button' as const,
onClick: onExpandOther,
'aria-label': `Show the remaining ${breakdown.other.rowCount}`,
}
: {})}
className={cn(
USAGE_ROW_CLASSES,
onExpandOther && 'transition-colors hover-hover:bg-[var(--surface-active)]'
)}
>
<span className='min-w-0 flex-1 truncate text-[var(--text-muted)] text-sm'>
{`Other (${breakdown.other.rowCount} more)`}
</span>
<span className='w-[72px] flex-shrink-0 text-right text-[var(--text-muted)] text-caption tabular-nums'>
{showTokensOnly
? formatChartCompactNumber(breakdown.other.tokens)
: breakdown.other.credits.toLocaleString()}
</span>
{/*
Centred in the slot the rows above reserve rather than sized to the
glyph: with a navigable or action-bearing list the reserved slot is
wider than the chevron, and drawing it bare pulled this row's figure
out of the column.
*/}
{trailingSlot && (
<span
className={cn(trailingSlot, 'flex flex-shrink-0 items-center justify-center')}
aria-hidden='true'
>
{onExpandOther && <ChevronDown className={disclosureChevronClass} />}
</span>
)}
</OtherRow>
)
})()}
</div>
)
}
@@ -1,6 +1,6 @@
'use client'
import { chipVariants, cn } from '@sim/emcn'
import { Chip, cn } from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { formatDateTime } from '@sim/utils/formatting'
import { useRouter } from 'next/navigation'
@@ -74,7 +74,7 @@ export function UsageEventsView({ organizationId, backHref }: UsageEventsViewPro
<SettingsPanel
back={{ text: 'Usage', icon: ArrowLeft, onSelect: () => router.push(backHref) }}
title='Usage events'
description='Every credit-consuming event behind your usage.'
description="Every credit-consuming event across your organization's workspaces."
>
<div
className={cn(
@@ -96,18 +96,14 @@ export function UsageEventsView({ organizationId, backHref }: UsageEventsViewPro
<UsageEventRow key={event.id} event={event} />
))}
{hasNextPage && (
<button
type='button'
<Chip
fullWidth
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
aria-label='Load more usage events'
className={cn(
chipVariants({ fullWidth: true }),
'text-[var(--text-muted)] text-small'
)}
>
{isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
</Chip>
)}
</>
)}
@@ -6,6 +6,7 @@ import {
Calendar,
ChipCombobox,
ChipModalTabs,
OverflowText,
Popover,
PopoverAnchor,
PopoverContent,
@@ -18,16 +19,20 @@ import {
type UsageBreakdownDimension,
} from '@/lib/api/contracts/organization-usage'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { isHosted } from '@/lib/core/config/env-flags'
import { isAuditLogsEnabled, isHosted } from '@/lib/core/config/env-flags'
import {
ManageCreditsModal,
type ManageCreditsTarget,
} from '@/app/workspace/[workspaceId]/settings/components/manage-credits-modal'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
import { serializeAuditLogFilters } from '@/ee/audit-logs/search-params'
import { UsageConsumers } from '@/ee/organization-usage/components/usage-consumers'
import { UsageSourceMix } from '@/ee/organization-usage/components/usage-source-mix'
import { UsageSummary } from '@/ee/organization-usage/components/usage-summary'
import {
COLLAPSED_ROW_COUNT,
EXPANDED_ROW_COUNT,
PERIOD_OPTIONS,
USAGE_OVERVIEW_TAB,
USAGE_SECTION_LABELS,
@@ -36,6 +41,7 @@ import {
type UsageTab,
} from '@/ee/organization-usage/constants'
import { useUsageWindow } from '@/ee/organization-usage/hooks/use-usage-window'
import { serializeOrganizationUsageParams } from '@/ee/organization-usage/search-params'
import { useOrganizationBilling } from '@/hooks/queries/organization'
import {
useOrganizationUsageBreakdown,
@@ -63,7 +69,7 @@ function UsageSection({
return (
<SettingsSection
label={USAGE_SECTION_LABELS[dimension]}
action={<span className='text-[var(--text-muted)] text-caption'>{unit}</span>}
action={<span className='text-[var(--text-muted)] text-small'>{unit}</span>}
>
{children}
</SettingsSection>
@@ -72,8 +78,13 @@ function UsageSection({
interface UsageMonitoringProps {
organizationId: string
/** Set by the settings section switch; the sub-route lives under this workspace. */
workspaceId: string
/**
* Base path of the events drill-down, built by the settings switch the same way it
* builds `creditUsageHref` and `billingHref`. The panel appends its own window.
*/
eventsHref: string
/** Base path of the audit-logs section, which the workspace drill-down scopes. */
auditLogsHref: string
}
/**
@@ -84,11 +95,16 @@ interface UsageMonitoringProps {
* Only the visible tab's dimension is fetched, which is also the performance story
* half the dimensions heap-scan the ledger, and a tab nobody opens never pays for one.
*/
export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoringProps) {
export function UsageMonitoring({
organizationId,
eventsHref: eventsBaseHref,
auditLogsHref: auditLogsBaseHref,
}: UsageMonitoringProps) {
const router = useRouter()
const { window, tab, workspace, preset, startDate, endDate, periodLabel, setState } =
const { window, tab, workspace, expanded, preset, startDate, endDate, periodLabel, setState } =
useUsageWindow()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const [isExporting, setIsExporting] = useState(false)
/** The member whose credit limit is being edited, or null when the modal is closed. */
const [creditsTarget, setCreditsTarget] = useState<ManageCreditsTarget | null>(null)
@@ -115,11 +131,17 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
const summary = useOrganizationUsageSummary(organizationId, window)
/**
* Kept alive in the drill-down purely to name it. The rule is to store the id and
* derive the entity from the loaded list; arriving by click serves this from cache,
* and arriving by deep link fetches it once.
* derive the entity from the loaded list.
*
* Pinned to the full page rather than to the panel's current row limit: the id can
* come from an expanded list or from a bookmark, and a lookup that only held the
* top ten resolved nothing for either which reads as the drill-down refusing to
* open, since `isWorkspaceDetail` gates on the name. Requesting the ceiling means a
* click from an expanded list is served from that list's own cache entry.
*/
const workspaceList = useOrganizationUsageBreakdown(organizationId, window, 'workspace', {
enabled: isWorkspaceSelected,
limit: EXPANDED_ROW_COUNT,
})
const workspaceName = workspaceList.data?.rows.find((row) => row.id === workspace)?.label
/**
@@ -135,17 +157,63 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
? 'workflow'
: (tab as UsageBreakdownDimension)
/**
* Per breakdown, not per page: the drill-down shows two lists at once, so opening
* one tail has to leave its neighbour at the count it was rendered with.
*/
const rowLimitFor = (target: UsageBreakdownDimension) =>
expanded.includes(target) ? EXPANDED_ROW_COUNT : COLLAPSED_ROW_COUNT
/**
* Opens one list's tail, unless it is already at the API's ceiling past that the
* `Other` row is a true remainder and the control would do nothing. `undefined`
* rather than a no-op handler, so the row renders as text instead of as a button.
*/
const expandOtherFor = (target: UsageBreakdownDimension) =>
rowLimitFor(target) < EXPANDED_ROW_COUNT
? () => void setState({ expanded: [...expanded, target] })
: undefined
const breakdown = useOrganizationUsageBreakdown(organizationId, window, dimension, {
limit: rowLimitFor(dimension),
...(isWorkspaceDetail && workspace ? { workspaceId: workspace } : {}),
})
const workspaceSources = useOrganizationUsageBreakdown(organizationId, window, 'source', {
enabled: isWorkspaceDetail,
limit: rowLimitFor('source'),
...(workspace ? { workspaceId: workspace } : {}),
})
// Already cached by Members and Billing, so the meter costs nothing extra and
// cannot report a different allowance than they do.
const billing = useOrganizationBilling(organizationId)
/**
* The organization audit feed, narrowed to the workspace being drilled into.
*
* Only offered where that section exists. Usage and Audit logs carry the same
* hosted and enterprise gates, so reaching this panel already proves both but
* their self-hosted overrides are separate flags, and an install with usage
* monitoring on and audit logs off would have been handed an action pointing at a
* section it had switched off. The window is deliberately not carried across: the
* audit feed speaks in rolling ranges (`Past 30 days`) and this panel in billing
* periods, so there is no honest mapping for `current-period`.
*/
const auditLogsHref =
isHosted || isAuditLogsEnabled
? serializeAuditLogFilters(auditLogsBaseHref, { workspace })
: null
/**
* The drill-down is the same window, in more detail. Without the params it read its
* own defaults and silently showed the current period while the panel behind it
* showed a custom range two pages disagreeing about what "this" means.
*/
const eventsHref = serializeOrganizationUsageParams(eventsBaseHref, {
preset: window.preset,
startDate: window.startDate ?? null,
endDate: window.endDate ?? null,
})
const handlePeriodChange = (value: string) => {
if (value === 'custom') {
setDatePickerOpen(true)
@@ -173,6 +241,8 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
}
const handleExport = async () => {
if (isExporting) return
setIsExporting(true)
// The organization is the path segment below; the query no longer carries a
// second copy of it.
const params = new URLSearchParams({
@@ -211,6 +281,8 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
URL.revokeObjectURL(url)
} catch {
toast.error('Failed to export usage')
} finally {
setIsExporting(false)
}
}
@@ -221,19 +293,33 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
if (isWorkspaceDetail && workspace) {
return (
<SettingsPanel
// Opening pushed nothing (filters replace), so closing replaces too.
// Opening pushes (the drill-down is a destination, not a filter), so closing
// replaces — the rule for a selected entity in `sim-url-state.md`.
back={{
text: 'Workspaces',
icon: ArrowLeft,
onSelect: () => void setState({ workspace: null }),
onSelect: () => void setState({ workspace: null, expanded: null }),
}}
title={workspaceName ?? 'Workspace usage'}
actions={[
{
text: 'Open logs',
onSelect: () => router.push(`/workspace/${workspace}/logs`),
},
]}
actions={
auditLogsHref
? [
{
/*
The organization's audit feed, scoped to this workspace not
`/workspace/<id>/logs`. Organization admin is not workspace
membership, and `WorkspaceLayout` answers a non-member with
`WorkspaceAccessDenied`, so the run-logs route was a one-way trip
to a dead end for any workspace the admin had not joined. Audit
logs live in the settings section the admin is already inside.
*/
text: 'Open logs',
onSelect: () => router.push(auditLogsHref),
onPrefetch: () => router.prefetch(auditLogsHref),
},
]
: []
}
>
{/*
Sources first, because in most workspaces the majority of usage is Chat
@@ -247,6 +333,8 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
breakdown={workspaceSources.data}
isLoading={workspaceSources.isLoading}
isError={workspaceSources.isError}
isPlaceholderData={workspaceSources.isPlaceholderData}
onExpandOther={expandOtherFor('source')}
/>
</UsageSection>
<UsageSection dimension='workflow' unit='credits'>
@@ -255,6 +343,8 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
breakdown={breakdown.data}
isLoading={breakdown.isLoading}
isError={breakdown.isError}
isPlaceholderData={breakdown.isPlaceholderData}
onExpandOther={expandOtherFor('workflow')}
/>
</UsageSection>
</SettingsPanel>
@@ -262,54 +352,67 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
}
return (
<SettingsPanel
actions={[
{
text: 'All events',
onSelect: () => router.push(`/workspace/${workspaceId}/settings/usage/events`),
},
{
text: 'Export',
icon: Download,
onSelect: () => void handleExport(),
},
]}
>
<div className='flex items-center justify-between gap-2'>
<ChipModalTabs
tabs={TABS}
value={tab}
onChange={(value) => void setState({ tab: value as UsageTab, workspace: null })}
/>
<div className='relative flex-shrink-0'>
{/* ChipCombobox (Radix Popover, non-modal), not ChipSelect (Radix
<>
<SettingsPanel
actions={[
{
text: 'All events',
onSelect: () => router.push(eventsHref),
onPrefetch: () => router.prefetch(eventsHref),
},
{
text: 'Export',
icon: Download,
onSelect: () => void handleExport(),
disabled: summary.isLoading || isExporting,
},
]}
>
<div className='flex items-center justify-between gap-2'>
<ChipModalTabs
tabs={TABS}
value={tab}
/*
`expanded` describes one list, so it is cleared with the list. Carrying it
across meant landing on a different tab already opened to fifty rows.
*/
onChange={(value) =>
void setState({ tab: value as UsageTab, workspace: null, expanded: null })
}
/>
<div className='relative flex-shrink-0'>
{/* ChipCombobox (Radix Popover, non-modal), not ChipSelect (Radix
DropdownMenu, modal by default) a modal trigger closing in the
same tick that opens the Calendar popover below traps it behind
the modal's focus lock, so "Custom range" silently does nothing. */}
<ChipCombobox
options={PERIOD_OPTIONS}
value={preset}
onChange={handlePeriodChange}
/*
<ChipCombobox
options={PERIOD_OPTIONS}
value={preset}
onChange={handlePeriodChange}
/*
The visible layer is masked, so the interactive layer owns the one
reachable tooltip a custom range's label truncates, and without
`overlayLabel` its full value was unreadable.
*/
overlayLabel={periodLabel}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{periodLabel}</span>
}
align='end'
/>
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) setDatePickerOpen(false)
}}
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='end' sideOffset={4} className='w-auto p-0'>
{/*
overlayLabel={periodLabel}
overlayContent={
<OverflowText
label={periodLabel}
className='block w-full text-[var(--text-primary)]'
tooltipEnabled={false}
/>
}
align='end'
/>
<Popover
open={datePickerOpen}
onOpenChange={(isOpen) => {
if (!isOpen) setDatePickerOpen(false)
}}
>
<PopoverAnchor className='pointer-events-none absolute inset-0' />
<PopoverContent align='end' sideOffset={4} className='w-auto p-0'>
{/*
No `showTime`: the panel buckets by calendar day, so a time of day is
precision it cannot render. It also emitted the end bound as an
inclusive `…T23:59:59` local wall time, which the window resolver then
@@ -318,87 +421,130 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
rejected. Bare `YYYY-MM-DD` bounds parse as UTC midnight, matching the
rest of the window logic.
*/}
<Calendar
mode='range'
startDate={startDate ?? undefined}
endDate={endDate ?? undefined}
onRangeChange={handleDateRangeApply}
onCancel={() => setDatePickerOpen(false)}
/>
</PopoverContent>
</Popover>
<Calendar
mode='range'
startDate={startDate ?? undefined}
endDate={endDate ?? undefined}
onRangeChange={handleDateRangeApply}
onCancel={() => setDatePickerOpen(false)}
/>
</PopoverContent>
</Popover>
</div>
</div>
</div>
{isOverview ? (
<>
{/*
{isOverview ? (
<>
{/*
The allowance is a per-billing-period figure, so it is only comparable
to the current period's total. Against a rolling window or a custom
range it measures a different span than the limit covers a 30-day
window spanning two periods could read "Over limit" while neither
period was so those windows show the figure without an allowance.
*/}
<UsageSummary
summary={summary.data}
limitCredits={
preset === 'current-period' && billing.data?.data?.totalUsageLimit != null
? dollarsToCredits(billing.data.data.totalUsageLimit)
: null
}
isLoading={summary.isLoading}
isError={summary.isError}
/>
{/*
<SettingsSection label={periodLabel}>
<UsageSummary
summary={summary.data}
limitCredits={
preset === 'current-period' && billing.data?.data?.totalUsageLimit != null
? dollarsToCredits(billing.data.data.totalUsageLimit)
: null
}
isLoading={summary.isLoading}
isError={summary.isError}
/>
</SettingsSection>
{/*
"What kind of work was this?" belongs beside the total it explains, not
behind a tab it is the second half of the same sentence.
One section, two readings of it: the list ranks the sources, the web shows
whether spend is concentrated or spread. Two `SettingsSection`s side by
side would have drawn two half-width hairlines on one line every other
rule in this panel spans the column and left one header carrying the
`credits` unit while its neighbour, showing the same data, carried none.
`auto-fit` on a track minimum rather than a `lg:` breakpoint: the settings
content column is a fixed `max-w-[48rem]`, so viewport width says nothing
about how wide this actually is. Same rule as `RESOURCE_LIST_GRID`.
*/}
<UsageSection dimension='source' unit='credits'>
<UsageSection dimension='source' unit='credits'>
{/*
`min(320px, 100%)` rather than a bare `320px`: a track minimum is a
hard floor, so on a column narrower than the minimum the grid would
be wider than its container and overflow. Capping the floor at the
available width collapses it to one column instead.
*/}
<div className='grid grid-cols-[repeat(auto-fit,minmax(min(320px,100%),1fr))] gap-x-6 gap-y-7'>
<UsageConsumers
dimension='source'
breakdown={breakdown.data}
isLoading={breakdown.isLoading}
isError={breakdown.isError}
isPlaceholderData={breakdown.isPlaceholderData}
onExpandOther={expandOtherFor('source')}
/>
<UsageSourceMix
breakdown={breakdown.data}
isLoading={breakdown.isLoading}
isError={breakdown.isError}
/>
</div>
</UsageSection>
</>
) : (
<UsageSection dimension={dimension} unit={dimension === 'byok' ? 'tokens' : 'credits'}>
<UsageConsumers
dimension='source'
dimension={dimension}
breakdown={breakdown.data}
isLoading={breakdown.isLoading}
isError={breakdown.isError}
isPlaceholderData={breakdown.isPlaceholderData}
onExpandOther={expandOtherFor(dimension)}
{...(tab === 'workspace'
? {
/*
`push`, not the group's default `replace`: this opens a
destination with its own back chip, and replacing meant browser
Back skipped the Workspaces list and left settings entirely.
*/
onSelectRow: (row) =>
void setState({ workspace: row.id, expanded: null }, { history: 'push' }),
}
: {})}
{...(canManageCredits
? {
rowActions: (row) => [
{
label: 'Manage credits',
onSelect: () => setCreditsTarget({ userId: row.id, name: row.label }),
},
],
}
: {})}
/>
</UsageSection>
</>
) : (
<UsageSection dimension={dimension} unit={dimension === 'byok' ? 'tokens' : 'credits'}>
<UsageConsumers
dimension={dimension}
breakdown={breakdown.data}
isLoading={breakdown.isLoading}
isError={breakdown.isError}
{...(tab === 'workspace'
? { onSelectRow: (row) => void setState({ workspace: row.id }) }
: {})}
{...(canManageCredits
? {
rowActions: (row) => [
{
label: 'Manage credits',
onSelect: () => setCreditsTarget({ userId: row.id, name: row.label }),
},
],
}
: {})}
/>
</UsageSection>
)}
)}
</SettingsPanel>
{/*
A sibling of the panel, not a child. `SettingsPanel` renders its children
straight into the shell's gap-7 content column, so a modal mounted inside it
is a body slot that contributes to that spacing.
The same modal the Members settings page opens, driven by the same hooks
setting a cap here and there is one implementation, not two.
*/}
<ManageCreditsModal
key={creditsTarget?.userId ?? 'none'}
open={creditsTarget !== null}
onOpenChange={(open) => {
if (!open) setCreditsTarget(null)
}}
organizationId={organizationId}
member={creditsTarget}
/>
</SettingsPanel>
{canManageCredits && (
<ManageCreditsModal
key={creditsTarget?.userId ?? 'none'}
open={creditsTarget !== null}
onOpenChange={(open) => {
if (!open) setCreditsTarget(null)
}}
organizationId={organizationId}
member={creditsTarget}
/>
)}
</>
)
}
@@ -0,0 +1,100 @@
'use client'
import { useMemo } from 'react'
import { RadarChart, type RadarChartAxis } from '@/components/charts'
import type { OrganizationUsageBreakdown } from '@/lib/api/contracts/organization-usage'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
/** The same series colour the credit bars use, so one period reads as one dataset. */
const MIX_SERIES_COLOR = 'var(--indicator-seat-filled)'
/**
* Beyond this the web's captions overlap and the shape stops being readable, so the
* tail folds into one axis the same treatment the list gives its `Other` row.
*/
const MAX_AXES = 6
/**
* Tall enough that the web is bound by its caption gutter rather than by height
* past roughly this the extra pixels become dead space above and below a web that
* cannot grow any wider. Deliberately not matched to the ten-row list beside it: a
* summary shape does not have to be as tall as the ranking it summarises.
*/
const SOURCE_MIX_HEIGHT = 260
interface UsageSourceMixProps {
breakdown?: OrganizationUsageBreakdown
isLoading: boolean
isError: boolean
}
/**
* The source list's shape, beside the list itself.
*
* The rows answer "how much did each source cost"; they cannot answer "is this
* organization's spend concentrated or spread", which is the question an admin
* actually opens this tab with. Reading the same rows as a polygon makes a single
* dominant source and an even split visibly different at a glance.
*/
export function UsageSourceMix({ breakdown, isLoading, isError }: UsageSourceMixProps) {
/*
Stabilized so `RadarChart`'s `memo()` can pass built inline it was a new array
on every render of the panel.
*/
const axes = useMemo<RadarChartAxis[]>(() => {
const rows = breakdown?.rows ?? []
const head = rows.slice(0, MAX_AXES)
const tail = rows.slice(MAX_AXES)
/*
The folded axis carries the API's own remainder as well as the rows this chart
dropped, so the web reconciles to the same total as the list beside it.
It is deliberately *not* labelled `Other (N more)`: the chart folds at MAX_AXES
and the list folds at COLLAPSED_ROW_COUNT, so the two counts genuinely differ,
and printing both a few pixels apart under identical wording reads as a bug. The
count moves into the hover row, where it is attributed.
*/
const otherRowCount = tail.length + (breakdown?.other.rowCount ?? 0)
const otherCredits =
tail.reduce((total, row) => total + row.credits, 0) + (breakdown?.other.credits ?? 0)
return [
...head.map((row) => ({
label: row.label,
value: row.credits,
display: row.credits.toLocaleString(),
})),
...(otherRowCount > 0
? [
{
label: 'Other',
value: otherCredits,
display: `${otherCredits.toLocaleString()} · ${otherRowCount} sources`,
},
]
: []),
]
}, [breakdown])
if (isError) {
return (
<SettingsEmptyState variant='inline' tone='error'>
Couldn't load this view.
</SettingsEmptyState>
)
}
if (isLoading || !breakdown) {
return <SettingsEmptyState variant='inline'>Loading</SettingsEmptyState>
}
/*
The chart refuses fewer than three axes a two-gon is a line, not a distribution
but its own fallback is a `height`-tall "No data" box, which beside a list holding
two populated rows says the wrong thing at the wrong size. The wrapper answers
instead, in the same inline empty state its neighbour uses.
*/
if (axes.length < 3) {
return <SettingsEmptyState variant='inline'>Not enough sources to compare.</SettingsEmptyState>
}
return <RadarChart axes={axes} color={MIX_SERIES_COLOR} height={SOURCE_MIX_HEIGHT} />
}
@@ -1,5 +1,6 @@
'use client'
import { useMemo } from 'react'
import { Badge } from '@sim/emcn'
import { BarChart } from '@/components/charts'
import type { OrganizationUsageSummary } from '@/lib/api/contracts/organization-usage'
@@ -23,6 +24,17 @@ function percentDelta(current: number, previous: number): number | null {
}
export function UsageSummary({ summary, limitCredits, isLoading, isError }: UsageSummaryProps) {
/*
Stabilized so `BarChart`'s `memo()` can actually pass. Built inline it was a new
array on every render of the panel a date-picker toggle or an export click
re-rendered ninety bars for nothing.
*/
const series = useMemo(
() =>
summary?.series.map((point) => ({ timestamp: point.timestamp, value: point.credits })) ?? [],
[summary]
)
if (isError) {
return (
<SettingsEmptyState variant='inline' tone='error'>
@@ -47,7 +59,11 @@ export function UsageSummary({ summary, limitCredits, isLoading, isError }: Usag
number twice and read as a rendering bug.
*/}
<div className='flex flex-wrap items-baseline gap-x-2 gap-y-1'>
<span className='text-[var(--text-body)] text-lg tabular-nums'>
{/*
`text-base`, not `text-lg`: the shell's page title is `text-lg`, and a
metric drawn at the same size competed with the header for the first read.
*/}
<span className='text-[var(--text-body)] text-base tabular-nums'>
{formatCreditsLabel(used)}
</span>
{hasLimit && (
@@ -63,22 +79,15 @@ export function UsageSummary({ summary, limitCredits, isLoading, isError }: Usag
</Badge>
)}
{isOverLimit && (
<Badge variant='amber' size='sm'>
// `red`, not `amber`: past the pooled allowance is a violation, and the
// trend badge sitting immediately beside it is already amber.
<Badge variant='red' size='sm'>
Over limit
</Badge>
)}
</div>
<BarChart
data={summary.series.map((point) => ({
timestamp: point.timestamp,
value: point.credits,
}))}
label=''
color={USAGE_SERIES_COLOR}
unit='credits'
height={160}
/>
<BarChart data={series} label='' color={USAGE_SERIES_COLOR} unit='credits' height={160} />
</div>
)
}
+17 -1
View File
@@ -39,7 +39,12 @@ export const USAGE_TAB_ORDER: readonly UsageTab[] = [
'member',
'workspace',
'model',
'byok',
/*
'byok' is withheld, not removed: no usage has been recorded against a
bring-your-own-key provider yet, so the tab could only show its empty state.
Re-add it here once the ledger carries BYOK rows the dimension, its labels,
its token unit, and the breakdown query all still work.
*/
]
export const USAGE_TAB_LABELS: Record<UsageTab, string> = {
@@ -70,5 +75,16 @@ export const USAGE_TAB_EMPTY_COPY: Record<UsageBreakdownDimension, string> = {
source: 'Nothing consumed credits in this period.',
}
/**
* Rows per breakdown before and after the `Other` row is expanded.
*
* The collapsed count keeps a tab to one screen; the expanded one is the contract's
* own ceiling (`usageLimitSchema(50, 10)`), so asking for more would be refused. A
* dimension with more than {@link EXPANDED_ROW_COUNT} distinct rows still shows an
* `Other` row after expanding, which is the honest result rather than a bug.
*/
export const COLLAPSED_ROW_COUNT = 10
export const EXPANDED_ROW_COUNT = 50
export const DEFAULT_USAGE_PRESET = 'current-period' as const
export const DEFAULT_USAGE_TAB = USAGE_OVERVIEW_TAB
@@ -0,0 +1,45 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { MAX_CUSTOM_RANGE_DAYS } from '@/lib/api/contracts/organization-usage'
import { isUsableCustomRange } from '@/ee/organization-usage/hooks/use-usage-window'
/**
* Every condition here is one the server answers with a 400. A deep link carrying
* one would otherwise be marked resolved and fail all four of the panel's queries,
* so the guard degrades the link to the default window instead and that only
* works while these three rules match the window resolver's.
*/
describe('isUsableCustomRange', () => {
it('accepts a well-formed range inside the cap', () => {
expect(isUsableCustomRange('2026-01-01', '2026-01-31')).toBe(true)
})
it('accepts a single-day range', () => {
expect(isUsableCustomRange('2026-01-01', '2026-01-01')).toBe(true)
})
it('rejects a missing bound', () => {
expect(isUsableCustomRange('2026-01-01', null)).toBe(false)
expect(isUsableCustomRange(null, '2026-01-31')).toBe(false)
})
it('rejects a malformed or unreal date', () => {
expect(isUsableCustomRange('2026-1-1', '2026-01-31')).toBe(false)
expect(isUsableCustomRange('2026-02-30', '2026-03-01')).toBe(false)
expect(isUsableCustomRange('not-a-date', '2026-03-01')).toBe(false)
})
it('rejects an inverted pair', () => {
expect(isUsableCustomRange('2026-03-01', '2026-02-01')).toBe(false)
})
it('accepts a span of exactly the cap and rejects one day past it', () => {
const start = new Date('2026-01-01T00:00:00.000Z')
const at = new Date(start.getTime() + (MAX_CUSTOM_RANGE_DAYS - 1) * 86_400_000)
const past = new Date(start.getTime() + MAX_CUSTOM_RANGE_DAYS * 86_400_000)
expect(isUsableCustomRange('2026-01-01', at.toISOString().slice(0, 10))).toBe(true)
expect(isUsableCustomRange('2026-01-01', past.toISOString().slice(0, 10))).toBe(false)
})
})
@@ -1,6 +1,5 @@
'use client'
import { useMemo } from 'react'
import { useQueryStates } from 'nuqs'
import {
MAX_CUSTOM_RANGE_DAYS,
@@ -32,7 +31,7 @@ function isCalendarDate(value: string | null): value is string {
* guard exists to provide. Duplicated deliberately, and narrowly: these are the three
* conditions that turn a link into an error rather than into different data.
*/
function isUsableCustomRange(start: string | null, end: string | null): boolean {
export function isUsableCustomRange(start: string | null, end: string | null): boolean {
if (!isCalendarDate(start) || !isCalendarDate(end)) return false
const from = new Date(`${start}T00:00:00.000Z`).getTime()
const to = new Date(`${end}T00:00:00.000Z`).getTime()
@@ -63,16 +62,18 @@ export function useUsageWindow() {
const preset: UsageWindowPreset =
state.preset === 'custom' && !isResolvedCustom ? DEFAULT_USAGE_PRESET : state.preset
const window = useMemo<OrganizationUsageWindowKey>(
() => ({
preset,
...(isResolvedCustom
? { startDate: state.startDate ?? undefined, endDate: state.endDate ?? undefined }
: {}),
timezone,
}),
[preset, isResolvedCustom, state.startDate, state.endDate, timezone]
)
/*
Not memoized: this object is only ever hashed, never compared by identity
React Query hashes a query key structurally, and the panel reads the primitive
fields off it directly.
*/
const window: OrganizationUsageWindowKey = {
preset,
...(isResolvedCustom
? { startDate: state.startDate ?? undefined, endDate: state.endDate ?? undefined }
: {}),
timezone,
}
const periodLabel = isResolvedCustom
? `${formatDateShort(state.startDate as string)} - ${formatDateShort(state.endDate as string)}`
@@ -82,6 +83,7 @@ export function useUsageWindow() {
window,
tab: state.tab,
workspace: state.workspace,
expanded: state.expanded,
/**
* The *resolved* preset, not the raw URL value. A partial custom deep link
* queries the current period, so surfacing `state.preset` left the picker
@@ -1,5 +1,8 @@
import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
import { USAGE_WINDOW_PRESETS } from '@/lib/api/contracts/organization-usage'
import { createSerializer, parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'
import {
USAGE_BREAKDOWN_DIMENSIONS,
USAGE_WINDOW_PRESETS,
} from '@/lib/api/contracts/organization-usage'
import { parseAsDateString } from '@/app/workspace/[workspaceId]/logs/search-params'
import {
DEFAULT_USAGE_PRESET,
@@ -26,6 +29,17 @@ export const organizationUsageParsers = {
* list rather than rendering an empty drill-down.
*/
workspace: parseAsString,
/**
* Which breakdowns have had their `Other` row opened, named by dimension. In the URL
* because it is shareable view-state like every other filter here and because it
* changes which rows the page fetched, so a shared link that omitted it would not
* show the list the sender was looking at.
*
* A list, not a flag: the workspace drill-down renders two breakdowns at once, and
* one boolean meant opening either tail silently opened the other's refetching a
* list nobody asked to expand, and leaving its `Other` row as inert text.
*/
expanded: parseAsArrayOf(parseAsStringLiteral(USAGE_BREAKDOWN_DIMENSIONS)).withDefault([]),
} as const
/** Filter view-state: clean URLs, no back-stack churn, kebab-case URL keys. */
@@ -38,3 +52,14 @@ export const organizationUsageUrlKeys = {
endDate: 'end-date',
},
} as const
/**
* Outbound links into the usage drill-downs, serialized from the same parser map the
* destination reads. Hand-writing the wire keys duplicated the `urlKeys` remap, so
* renaming `start-date` would have silently dropped the window from every such link
* exactly the panel/drill-down disagreement the events href exists to prevent.
*/
export const serializeOrganizationUsageParams = createSerializer(organizationUsageParsers, {
clearOnDefault: true,
urlKeys: organizationUsageUrlKeys.urlKeys,
})
@@ -40,13 +40,11 @@ function HourField({ id, title, hint, value, onChange }: HourFieldProps) {
</Label>
<ChipInput
id={id}
type='number'
inputMode='numeric'
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder='No limit'
className='w-[220px]'
inputClassName='[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none'
/>
<p className='text-[var(--text-muted)] text-caption'>{hint}</p>
</div>
@@ -3688,9 +3688,11 @@ describe('AgentBlockHandler', () => {
expect(mockDiscoverMcpServerToolsAsExecutor).toHaveBeenCalledWith(
expect.objectContaining({
userId: contextWithWorkspace.userId,
workspaceId: 'test-workspace-123',
workflowId: 'test-workflow-456',
context: expect.objectContaining({
userId: contextWithWorkspace.userId,
workflowId: 'test-workflow-456',
}),
serverId: 'mcp-legacy-server',
})
)
@@ -1283,10 +1283,14 @@ export class AgentBlockHandler implements BlockHandler {
}
return discoverMcpServerToolsAsExecutor({
userId: ctx.userId,
workspaceId: ctx.workspaceId,
workflowId: ctx.workflowId,
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
context: {
workflowId: ctx.workflowId,
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
executorDelegationOrigin: ctx.executorDelegationOrigin,
},
serverId,
signal: ctx.abortSignal,
})
@@ -1349,6 +1353,7 @@ export class AgentBlockHandler implements BlockHandler {
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
executorDelegationOrigin: ctx.executorDelegationOrigin,
},
toolIndex,
resolveCustomBlockBinding: (blockType: string) =>
@@ -220,6 +220,7 @@ export async function buildSimToolSpecs(
workspaceId: ctx.workspaceId,
executionId: ctx.executionId,
userId: ctx.userId,
executorDelegationOrigin: ctx.executorDelegationOrigin,
},
resolveCustomBlockBinding: (blockType: string) =>
resolveCustomBlockToolBinding(blockType, ctx.workspaceId),
@@ -229,8 +229,16 @@ describe('WorkflowBlockHandler', () => {
mockContext = {
workflowId: 'parent-workflow-id',
executionId: 'parent-execution-id',
userId: 'user-1',
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
executorDelegationOrigin: {
subjectUserId: 'user-1',
workflowId: 'parent-workflow-id',
executionId: 'parent-execution-id',
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' },
},
blockStates: new Map(),
blockLogs: [],
metadata: {
@@ -438,6 +446,7 @@ describe('WorkflowBlockHandler', () => {
workflowId: 'parent-workflow-id',
executionId: 'parent-execution-id',
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' },
},
})
)
@@ -2096,6 +2105,7 @@ describe('WorkflowBlockHandler', () => {
workflowId: 'parent-workflow-id',
executionId: 'parent-execution-id',
principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' },
currentWorkflow: { workflowId: 'parent-workflow-id', mode: 'draft' },
},
})
)
@@ -1,4 +1,3 @@
import { resolvePrincipalSubject } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { findCause, getErrorMessage, toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
@@ -330,20 +329,18 @@ export class WorkflowBlockHandler implements BlockHandler {
if (!ctx.principal) {
throw new Error('Workflow child loading requires an execution principal')
}
const principalSubject = resolvePrincipalSubject(ctx.principal)
const workflowReadDelegationOrigin: ExecutorDelegationOrigin = isCustomBlock
? {
...(loadUserId ? { subjectUserId: loadUserId } : {}),
workflowId,
}
: (ctx.executorDelegationOrigin ?? {
...(principalSubject?.kind === 'sim_user'
? { subjectUserId: principalSubject.userId }
: {}),
workflowId: ctx.workflowId,
...(ctx.executionId ? { executionId: ctx.executionId } : {}),
principal: ctx.principal,
})
let workflowReadDelegationOrigin: ExecutorDelegationOrigin
if (isCustomBlock) {
workflowReadDelegationOrigin = {
...(loadUserId ? { subjectUserId: loadUserId } : {}),
workflowId,
}
} else {
if (!ctx.executorDelegationOrigin) {
throw new Error('Child workflow loading requires executor delegation authority')
}
workflowReadDelegationOrigin = ctx.executorDelegationOrigin
}
if (!isCustomBlock) childExecutorDelegationOrigin = workflowReadDelegationOrigin
// A custom block runs the source's latest deployment; if the source has been
// undeployed there's nothing to run. `BoundarySafeError` marks the message as
+49
View File
@@ -36,6 +36,55 @@ export function attachExecutionResult(error: Error, executionResult: ExecutionRe
Object.assign(error, { executionResult })
}
/**
* Dispatched-run ids, keyed by the thrown value itself.
*
* A side table rather than a property on the error, for the same reason
* {@link markExecutionFinalizedByCore} keeps one: a thrown value is not reliably writable.
* `Object.assign` throws on a frozen or sealed failure, and guarding that throw would drop
* the marker instead silently converting "this run exists" into "nothing started", which
* is the one direction that duplicates work. Identity keying also means no id can arrive
* through a prototype chain, and nothing is added to the error's own surface, so a
* serialized error carries no stray field.
*/
const attemptedExecutionIds = new WeakMap<object, string>()
/**
* Names the run a failure belongs to once dispatch has been attempted.
*
* A caller that only sees the thrown error cannot tell an authorization refusal which
* created nothing from a crash after the run was already dispatched, and those need
* opposite retry decisions. Recording the id at the point of no return makes its absence
* mean "nothing was started" rather than "we do not know", and its presence a key that
* resolves to zero or one executions.
*
* Distinct from {@link attachExecutionResult}: that says the workflow ran and produced a
* result, this says only that it was dispatched.
*/
export function attachAttemptedExecutionId(error: unknown, executionId: string): void {
if (!isRecordedThrown(error) || !executionId) return
if (attemptedExecutionIds.has(error)) return
attemptedExecutionIds.set(error, executionId)
}
/** Reads the dispatched-run id a thrown value carries, if dispatch was reached at all. */
export function readAttemptedExecutionId(error: unknown): string | undefined {
return isRecordedThrown(error) ? attemptedExecutionIds.get(error) : undefined
}
/**
* Any non-null object, not only an `Error`.
*
* Restricting this to `Error` would silently invert the invariant for a thrown plain object:
* no id would be recorded, its absence would read as "nothing was started", and the caller
* would retry a run that already exists. A thrown primitive cannot be keyed at all, which
* costs nothing today because every throw site past the dispatch boundary raises an `Error`.
*/
function isRecordedThrown(value: unknown): value is object {
/** Functions key a WeakMap as well as objects do, so excluding them would drop the record. */
return (typeof value === 'object' || typeof value === 'function') && value !== null
}
export interface BlockExecutionErrorDetails {
block: SerializedBlock
error: Error | string
+30 -10
View File
@@ -1,6 +1,6 @@
'use client'
import { keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { hashKey, keepPreviousData, useInfiniteQuery, useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import {
getOrganizationUsageBreakdownContract,
@@ -53,6 +53,14 @@ interface UseBreakdownOptions {
workspaceId?: string
}
/**
* A breakdown key with its trailing row limit removed the identity of the list,
* which is what "the same list, more rows" has to compare on.
*/
function breakdownListIdentity(key: readonly unknown[]): string {
return hashKey(key.slice(0, -1))
}
export function useOrganizationUsageBreakdown(
organizationId: string | undefined,
window: OrganizationUsageWindowKey,
@@ -61,14 +69,15 @@ export function useOrganizationUsageBreakdown(
) {
const limit = options.limit ?? 10
const { workspaceId } = options
const queryKey = organizationUsageKeys.breakdown(
organizationId ?? '',
window,
dimension,
limit,
workspaceId
)
return useQuery({
queryKey: organizationUsageKeys.breakdown(
organizationId ?? '',
window,
dimension,
limit,
workspaceId
),
queryKey,
queryFn: ({ signal }): Promise<OrganizationUsageBreakdown> =>
requestJson(getOrganizationUsageBreakdownContract, {
params: { id: organizationId as string },
@@ -82,8 +91,19 @@ export function useOrganizationUsageBreakdown(
}),
enabled: Boolean(organizationId) && (options.enabled ?? true),
staleTime: ORGANIZATION_USAGE_BREAKDOWN_STALE_TIME,
// Deliberately no keepPreviousData: a stale ranking under a new group label reads
// as wrong data, which is worse than a brief skeleton.
/**
* Kept only across a row-limit change opening the `Other` row asks the same
* question of the same list, and the visible rows are a prefix of the answer, so
* dimming beats blanking. Any other key change (dimension, window, workspace)
* would put a stale ranking under a new label, which reads as wrong data and is
* worse than a brief skeleton.
*/
placeholderData: (previous, previousQuery) =>
previous &&
previousQuery &&
breakdownListIdentity(previousQuery.queryKey) === breakdownListIdentity(queryKey)
? previous
: undefined,
})
}
@@ -37,8 +37,15 @@ export const organizationUsageKeys = {
[
...organizationUsageKeys.breakdowns(organizationId, window),
dimension,
limit,
workspaceId ?? '',
/*
Last deliberately: it is the one segment the breakdown's `placeholderData`
ignores, so the list's identity is a plain prefix rather than an index-based
filter that would silently drop `workspaceId` if a segment were ever appended.
It also lets an invalidation target one dimension and workspace across every
row limit.
*/
limit,
] as const,
events: (organizationId: string, window: OrganizationUsageWindowKey, sources: string[]) =>
[...organizationUsageKeys.all(organizationId), 'events', window, sources] as const,
+8 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod'
import { organizationIdSchema } from '@/lib/api/contracts/primitives'
import { organizationIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const auditLogsQuerySchema = z.object({
@@ -11,6 +11,13 @@ export const auditLogsQuerySchema = z.object({
action: z.string().optional(),
resourceType: z.string().optional(),
actorId: z.string().optional(),
/**
* Narrows the org-scoped feed to one workspace. The use case already refuses an
* id outside the caller's organization; this only opens the door the internal
* surface had left shut while `buildFilterConditions` and the v1 contract both
* supported it.
*/
workspaceId: workspaceIdSchema.optional(),
startDate: z
.string()
.optional()
@@ -319,16 +319,23 @@ export const listKnowledgeDocumentsContract = defineRouteContract({
},
})
export const createKnowledgeDocumentsContract = defineRouteContract({
method: 'POST',
path: '/api/knowledge/[id]/documents',
/**
* Document creation from inline content has no HTTP route: `POST
* /api/knowledge/[id]/documents` was retired when tool operations moved
* in-process, and the surviving `GET`/`PATCH` on that path would answer a `POST`
* with 405. So these stay plain schemas rather than a `defineRouteContract`
* `lib/internal/knowledge/execute-tool.ts` validates `knowledge_create_document`
* against them directly. Callers wanting an HTTP upload use v1 or v2, both of
* which take multipart file bodies rather than inline content.
*/
export const createKnowledgeDocumentsSchemas = {
params: knowledgeBaseParamsSchema,
body: createKnowledgeDocumentsBodySchema,
response: {
mode: 'json',
schema: successResponseSchema(z.union([bulkCreateDocumentsResponseSchema, documentDataSchema])),
},
})
} as const
export const createKnowledgeDocumentsResponseSchema = successResponseSchema(
z.union([bulkCreateDocumentsResponseSchema, documentDataSchema])
)
export const updateKnowledgeDocumentContract = defineRouteContract({
method: 'PUT',
@@ -348,14 +348,16 @@ export const confluencePageSelectorContract = definePostSelector(
z.object({ id: z.string(), title: z.string() }).passthrough()
)
export const confluenceUpdatePageContract = defineConfluencePutContract(
'/api/tools/confluence/page',
confluenceUpdatePageBodySchema
)
export const confluenceDeletePageContract = defineConfluenceDeleteContract(
'/api/tools/confluence/page',
confluenceDeletePageBodySchema
)
/**
* Page update and delete have no contract because they have no route: the
* `PUT`/`DELETE` handlers on `/api/tools/confluence/page` were retired when the
* tool moved in process, and the surviving selector `POST` on that path would
* answer either verb with 405. `lib/internal/confluence/execute-tool.ts`
* validates both against `confluenceUpdatePageBodySchema` /
* `confluenceDeletePageBodySchema` directly.
*/
export type ConfluenceUpdatePageBody = z.output<typeof confluenceUpdatePageBodySchema>
export type ConfluenceDeletePageBody = z.output<typeof confluenceDeletePageBodySchema>
export const confluenceDeleteAttachmentContract = defineConfluenceDeleteContract(
'/api/tools/confluence/attachment',
confluenceDeleteAttachmentBodySchema
@@ -510,8 +512,6 @@ export const confluenceUserContract = defineConfluencePostContract(
)
export type ConfluencePageBody = ContractBody<typeof confluencePageSelectorContract>
export type ConfluenceUpdatePageBody = ContractBody<typeof confluenceUpdatePageContract>
export type ConfluenceDeletePageBody = ContractBody<typeof confluenceDeletePageContract>
export type ConfluenceDeleteAttachmentBody = ContractBody<typeof confluenceDeleteAttachmentContract>
export type ConfluenceListAttachmentsQuery = ContractQuery<typeof confluenceListAttachmentsContract>
export type ConfluenceListBlogPostsQuery = ContractQuery<typeof confluenceListBlogPostsContract>
@@ -1,20 +0,0 @@
import { z } from 'zod'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const docusignToolBodySchema = z
.object({
accessToken: z.string().min(1, 'Access token is required'),
operation: z.string().min(1, 'Operation is required'),
})
.passthrough()
export const docusignToolContract = defineRouteContract({
method: 'POST',
path: '/api/tools/docusign',
body: docusignToolBodySchema,
response: {
mode: 'json',
// untyped-response: forwards DocuSign API response unchanged; shape varies by operation (envelope, listing, base64 download, etc.)
schema: z.unknown(),
},
})
@@ -4,7 +4,6 @@ export * from './communication'
export * from './crowdstrike'
export * from './custom'
export * from './databases'
export * from './docusign'
export * from './file'
export * from './google'
export * from './media'
@@ -3,7 +3,7 @@ import { resolvedSecretTraceProvenanceSchema } from '@/lib/api/contracts/primiti
import { AWS_REGION_PATTERN, toolJsonResponseSchema } from '@/lib/api/contracts/tools/media/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
import { RESOLVED_SECRET_PROVENANCE_FIELD } from '@/lib/execution/private-tool-metadata'
import { FileInputSchema, RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
import { RawFileInputSchema } from '@/lib/uploads/utils/file-schemas'
const textractQuerySchema = z.object({
Text: z.string().min(1),
@@ -110,19 +110,6 @@ export const textractAnalyzeIdBodySchema = z
}
})
export const mistralParseBodySchema = z.object({
apiKey: z.string().min(1, 'API key is required'),
filePath: z.string().min(1, 'File path is required').optional(),
fileData: FileInputSchema.optional(),
file: FileInputSchema.optional(),
resultType: z.string().optional(),
pages: z.array(z.number()).optional(),
includeImageBase64: z.boolean().optional(),
imageLimit: z.number().optional(),
imageMinSize: z.number().optional(),
[RESOLVED_SECRET_PROVENANCE_FIELD]: resolvedSecretTraceProvenanceSchema.optional(),
})
export const textractParseContract = defineRouteContract({
method: 'POST',
path: '/api/tools/textract/parse',
@@ -143,10 +130,3 @@ export const textractAnalyzeIdContract = defineRouteContract({
body: textractAnalyzeIdBodySchema,
response: { mode: 'json', schema: toolJsonResponseSchema },
})
export const mistralParseContract = defineRouteContract({
method: 'POST',
path: '/api/tools/mistral/parse',
body: mistralParseBodySchema,
response: { mode: 'json', schema: toolJsonResponseSchema },
})
@@ -1,5 +1,4 @@
export * from '@/lib/api/contracts/tools/media/document-parse'
export * from '@/lib/api/contracts/tools/media/image'
export * from '@/lib/api/contracts/tools/media/shared'
export * from '@/lib/api/contracts/tools/media/tts'
export * from '@/lib/api/contracts/tools/media/video'
@@ -1,92 +0,0 @@
import { z } from 'zod'
import { toolJsonResponseSchema } from '@/lib/api/contracts/tools/media/shared'
import { defineRouteContract } from '@/lib/api/contracts/types'
export const ttsToolBodySchema = z.object({
text: z.string({ error: 'Missing required parameters' }).min(1, 'Missing required parameters'),
voiceId: z.string({ error: 'Missing required parameters' }).min(1, 'Missing required parameters'),
apiKey: z.string({ error: 'Missing required parameters' }).min(1, 'Missing required parameters'),
modelId: z.string().optional().default('eleven_monolingual_v1'),
stability: z.coerce.number().min(0).max(1).optional(),
similarityBoost: z.coerce.number().min(0).max(1).optional(),
workspaceId: z.string().optional(),
workflowId: z.string().optional(),
executionId: z.string().optional(),
})
export const ttsOutputFormatSchema = z.union([z.record(z.string(), z.unknown()), z.string()])
export const playHtOutputFormatSchema = z.enum(['mp3', 'wav', 'ogg', 'flac', 'mulaw'])
export const ttsUnifiedToolBodySchema = z
.object({
provider: z.enum(
['openai', 'deepgram', 'elevenlabs', 'cartesia', 'google', 'azure', 'playht'],
{
error: 'Missing required fields: provider, text, and apiKey',
}
),
text: z
.string({ error: 'Missing required fields: provider, text, and apiKey' })
.min(1, 'Missing required fields: provider, text, and apiKey'),
apiKey: z
.string({ error: 'Missing required fields: provider, text, and apiKey' })
.min(1, 'Missing required fields: provider, text, and apiKey'),
model: z.enum(['tts-1', 'tts-1-hd', 'gpt-4o-mini-tts']).optional(),
voice: z.string().optional(),
responseFormat: z.enum(['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']).optional(),
speed: z.coerce.number().optional(),
encoding: z.enum(['linear16', 'mp3', 'opus', 'aac', 'flac', 'mulaw', 'alaw']).optional(),
sampleRate: z.coerce.number().optional(),
bitRate: z.coerce.number().optional(),
container: z.enum(['none', 'wav', 'ogg']).optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
stability: z.coerce.number().optional(),
similarityBoost: z.coerce.number().optional(),
style: z.union([z.coerce.number(), z.string()]).optional(),
useSpeakerBoost: z.boolean().optional(),
language: z.string().optional(),
outputFormat: ttsOutputFormatSchema.optional().nullable(),
emotion: z.array(z.string()).optional(),
languageCode: z.string().optional(),
gender: z.enum(['MALE', 'FEMALE', 'NEUTRAL']).optional(),
audioEncoding: z.enum(['LINEAR16', 'MP3', 'OGG_OPUS', 'MULAW', 'ALAW']).optional(),
speakingRate: z.coerce.number().optional(),
pitch: z.union([z.number(), z.string()]).optional(),
volumeGainDb: z.coerce.number().optional(),
sampleRateHertz: z.coerce.number().optional(),
effectsProfileId: z.array(z.string()).optional(),
region: z
.string()
.regex(
/^[a-z][a-z0-9-]{1,30}[a-z0-9]$/,
'region must be a valid Azure region identifier (e.g. eastus, westeurope)'
)
.optional(),
rate: z.string().optional(),
styleDegree: z.coerce.number().optional(),
role: z.string().optional(),
userId: z.string().optional(),
quality: z.enum(['draft', 'standard', 'premium']).optional(),
temperature: z.coerce.number().optional(),
voiceGuidance: z.coerce.number().optional(),
textGuidance: z.coerce.number().optional(),
workspaceId: z.string().optional(),
workflowId: z.string().optional(),
executionId: z.string().optional(),
})
.passthrough()
export const ttsToolContract = defineRouteContract({
method: 'POST',
path: '/api/tools/tts',
body: ttsToolBodySchema,
response: { mode: 'json', schema: toolJsonResponseSchema },
})
export const ttsUnifiedToolContract = defineRouteContract({
method: 'POST',
path: '/api/tools/tts/unified',
body: ttsUnifiedToolBodySchema,
response: { mode: 'json', schema: toolJsonResponseSchema },
})
+27
View File
@@ -52,6 +52,33 @@ export type ResponseMode<S extends ApiSchema = ApiSchema> =
| StreamResponseMode
| RedirectResponseMode
/**
* A contract is consumed in one of two modes, and `method`/`path` only describe
* the first.
*
* **Boundary mode** the common one. The contract bridges the client/server
* gap: a route builder under `app/api/**` serves `method` at `path`, and
* `requestJson(contract, …)` on the client parses the request out and validates
* the response back. Both sides read the same declaration, so `method` and
* `path` are load-bearing.
*
* **In-process mode.** Tool operations that once self-hopped over HTTP now
* execute in the same process (`lib/internal/<domain>/execute-tool.ts`), and
* they kept their contract as the input/response schema bundle
* `parseInternalContractInput` reads only `params`, `query`, and `body`, and
* never looks at `method` or `path`. For these there is no route and no client
* fetch; `method` and `path` are vestigial, describing the HTTP endpoint the
* operation *used* to expose. Do not read them as evidence that an endpoint
* exists, and do not point a client at one.
*
* The distinction is not expressed in the type, so which mode a contract is in
* is derived, never annotated per file `bun run check:api-contract-routes
* --list-in-process` enumerates the in-process set from the tree rather than
* from a hand-maintained list that would drift. That same audit enforces the
* part which actually matters: an in-process contract may not claim a `path`
* whose live route serves other methods, because a caller trusting the
* declaration gets a 405 rather than an honest 404.
*/
export interface ApiRouteContract<
TParams extends ApiSchema | undefined = undefined,
TQuery extends ApiSchema | undefined = undefined,
+25 -1
View File
@@ -205,6 +205,30 @@ describe('resolveAtlassianCloudId', () => {
it('rejects when the token can see no sites', async () => {
fetchMock.mockResolvedValue(sites([]))
await expect(resolveAtlassianCloudId(options())).rejects.toThrow('No Jira resources found')
await expect(resolveAtlassianCloudId(options())).rejects.toThrow(
'No Jira sites are accessible to this credential. Reconnect the credential and grant access to the configured Atlassian site.'
)
})
it('distinguishes a malformed discovery payload from an empty site grant', async () => {
fetchMock.mockResolvedValue(createMockResponse({ json: { id: CLOUD_ID, url: SITE } }))
await expect(resolveAtlassianCloudId(options())).rejects.toThrow(
'Invalid Jira accessible-resources response'
)
})
it.each([
[{ url: SITE }],
[{ id: CLOUD_ID }],
[{ id: '', url: SITE }],
[{ id: CLOUD_ID, url: '' }],
[null],
])('rejects malformed resource entries in an otherwise valid array', async (resources) => {
fetchMock.mockResolvedValue(createMockResponse({ json: resources }))
await expect(resolveAtlassianCloudId(options())).rejects.toThrow(
'Invalid Jira accessible-resources response'
)
})
})
+23 -7
View File
@@ -102,6 +102,17 @@ interface AccessibleResource {
url: string
}
function isAccessibleResource(value: unknown): value is AccessibleResource {
if (typeof value !== 'object' || value === null) return false
const resource = value as Record<string, unknown>
return (
typeof resource.id === 'string' &&
resource.id.trim().length > 0 &&
typeof resource.url === 'string' &&
resource.url.trim().length > 0
)
}
interface ResolveAtlassianCloudIdOptions {
domain: string
accessToken: string
@@ -203,21 +214,26 @@ export function selectAtlassianCloudId(
domain: string,
product: string
): string {
if (!Array.isArray(resources) || resources.length === 0) {
throw new Error(`No ${product} resources found`)
if (!Array.isArray(resources) || !resources.every(isAccessibleResource)) {
throw new Error(`Invalid ${product} accessible-resources response`)
}
if (resources.length === 0) {
throw new Error(
`No ${product} sites are accessible to this credential. ` +
'Reconnect the credential and grant access to the configured Atlassian site.'
)
}
const siteUrl = normalizeAtlassianSiteUrl(domain)
const match = (resources as AccessibleResource[]).find(
(r) => normalizeAtlassianSiteUrl(r.url) === siteUrl
)
const match = resources.find((r) => normalizeAtlassianSiteUrl(r.url) === siteUrl)
if (match) return match.id
if (resources.length === 1) return (resources as AccessibleResource[])[0].id
if (resources.length === 1) return resources[0].id
throw new Error(
`Could not match ${product} domain "${domain}" to any accessible resource. ` +
`Available sites: ${(resources as AccessibleResource[]).map((r) => r.url).join(', ')}`
`Available sites: ${resources.map((r) => r.url).join(', ')}`
)
}
@@ -20,7 +20,7 @@ import {
type InternalUsageLogSource,
toBillingUsageLogSource,
} from '@/lib/billing/usage-sources'
import { getProviderFromModel } from '@/providers/models'
import { getProviderFromModel, PROVIDER_DEFINITIONS } from '@/providers/models'
export interface OrganizationUsageBreakdownInput {
organizationId: string
@@ -56,22 +56,15 @@ export interface OrganizationUsageBreakdownResult {
const NAMED_DIMENSIONS = new Set<UsageBreakdownDimension>(['member', 'workspace', 'workflow'])
/** Provider ids that read better with their conventional casing. */
const PROVIDER_LABELS: Readonly<Record<string, string>> = {
openai: 'OpenAI',
anthropic: 'Anthropic',
google: 'Google',
'azure-openai': 'Azure OpenAI',
deepseek: 'DeepSeek',
xai: 'xAI',
groq: 'Groq',
cerebras: 'Cerebras',
ollama: 'Ollama',
openrouter: 'OpenRouter',
mistral: 'Mistral',
}
/**
* The registry's own display name, not a second hand-written table.
*
* A local map had eleven of the registry's twenty-two providers, so anything newer
* `zai`, `kimi`, `vertex` surfaced as a raw lowercase id. This is a server
* module, so reading the registry costs nothing a client bundle would pay for.
*/
function providerLabel(providerId: string): string {
return PROVIDER_LABELS[providerId] ?? providerId
return PROVIDER_DEFINITIONS[providerId]?.name ?? providerId
}
export const getOrganizationUsageBreakdown = defineAuthorizedOrganizationUsageUseCase({
+10 -2
View File
@@ -61,7 +61,10 @@ import {
setTerminalToolCallState,
} from '@/lib/copilot/request/tool-call-state'
import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files'
import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
} from '@/lib/copilot/request/tools/resolved-secret-result'
import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources'
import {
maybeWriteOutputToTable,
@@ -737,15 +740,20 @@ async function executeToolAndReportInner(
toolSpan.attributes = {
...toolSpan.attributes,
...summarizeToolResultForSpan(copilotResult),
...(projection.safe ? {} : { resultWithheld: true }),
...(projection.safe
? {}
: { resultWithheld: true, ...describeWithholdingCause(projection.cause) }),
}
if (!projection.safe) {
// A withheld SUCCESS otherwise leaves no trace anywhere: the span reads
// ok and the model just sees a bare `{success: true}` with no output.
// The cause is what says whether a guard latched, no catalog was built,
// or the payload itself was unprojectable — three different fixes.
logger.warn('Tool result withheld by egress projection', {
toolCallId: toolCall.id,
toolName: toolCall.name,
runtimeSucceeded: result.success,
...describeWithholdingCause(projection.cause),
})
}
@@ -4,6 +4,8 @@
import { describe, expect, it } from 'vitest'
import { RunCode, RunFunction } from '@/lib/copilot/generated/tool-catalog-v1'
import {
describeWithholdingCause,
inspectToolResultForCopilot,
projectToolResultForCopilot,
READ_TOOL_RESULT_UNAVAILABLE_ERROR,
TOOL_RESULT_UNAVAILABLE_ERROR,
@@ -457,3 +459,105 @@ describe('projectToolResultForCopilot', () => {
expect(toolResultUnavailableError(undefined)).toBe(TOOL_RESULT_UNAVAILABLE_ERROR)
})
})
describe('effect disclosure on a withheld result', () => {
const EXECUTION_ID = '0f4d5a4c-6a1e-4c2f-9b7d-2c8f1a3e5d90'
it('carries nothing extra for a tool that declared no effect', () => {
expect(projectToolResultForCopilot({ success: true, output: { a: 1 } }, undefined)).toEqual({
success: true,
})
expect(projectToolResultForCopilot({ success: false, error: 'why' }, undefined)).toEqual({
success: false,
error: TOOL_RESULT_UNAVAILABLE_ERROR,
})
})
/**
* The exemption is what makes the disclosure trustworthy, so it has to be all or
* nothing: a disclosure that silently dropped the id it could not vouch for would
* read exactly like one that never had a run to name.
*/
it('voids the whole disclosure when an id is not a shape this system mints', () => {
expect(
projectToolResultForCopilot(
{
success: false,
error: 'why',
effect: { phase: 'performed', ids: { executionId: 'not-a-server-minted-id' } },
},
undefined,
'run_workflow'
)
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
})
it.each(['effect', 'resultWithheld'])(
'voids the disclosure when an id would take the reserved key %s',
(reserved) => {
expect(
projectToolResultForCopilot(
{
success: false,
error: 'why',
effect: { phase: 'performed', ids: { [reserved]: EXECUTION_ID } },
},
undefined,
'run_workflow'
)
).toEqual({ success: false, error: TOOL_RESULT_UNAVAILABLE_ERROR })
}
)
it('reports the phase and ids when every id is vouchable', () => {
expect(
projectToolResultForCopilot(
{
success: false,
error: 'why',
effect: { phase: 'attempted', ids: { executionId: EXECUTION_ID } },
},
undefined,
'run_workflow'
)
).toEqual({
success: false,
output: { resultWithheld: true, effect: 'attempted', executionId: EXECUTION_ID },
error: expect.stringContaining('At most one run exists'),
})
})
it('never leaks the disclosure into a result that projected cleanly', () => {
const registry = new ResolvedSecretTraceRegistry()
expect(
projectToolResultForCopilot(
{
success: true,
output: { executionId: EXECUTION_ID },
effect: { phase: 'performed', ids: { executionId: EXECUTION_ID } },
},
registry,
'run_workflow'
)
).toEqual({ success: true, output: { executionId: EXECUTION_ID } })
})
it('names why the content was withheld, for the surface about to log it', () => {
const latched = createRegistry()
latched.markIncomplete('source-provenance-incomplete', { origin: 'test.origin' })
const projection = inspectToolResultForCopilot({ success: false }, latched, 'run_workflow')
expect(projection.safe).toBe(false)
// The per-call fork adds its own propagation reason; the guard that originally
// tripped has to survive alongside it, or a refusal names only the messenger.
expect(projection.safe === false && describeWithholdingCause(projection.cause)).toEqual({
withheldCause: 'registry-incomplete',
withheldReasons: expect.arrayContaining(['source-provenance-incomplete']),
withheldOrigins: ['test.origin'],
})
const absent = inspectToolResultForCopilot({ success: false }, undefined)
expect(absent.safe === false && absent.cause).toEqual({ kind: 'registry-absent' })
})
})
@@ -1,6 +1,10 @@
import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
import type { ToolCallEffect, ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
import { TOOL_EFFECT_PHASE } from '@/lib/copilot/tool-executor/types'
import { projectResolvedSecretModelJsonContent } from '@/executor/utils/resolved-secret-content-projection'
import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
import type {
ResolvedSecretIncompletenessReason,
ResolvedSecretTraceRegistry,
} from '@/executor/utils/resolved-secret-trace-registry'
export const TOOL_RESULT_UNAVAILABLE_ERROR =
'Tool execution settled, but its result could not be returned safely. Do not retry a mutation automatically.'
@@ -13,8 +17,38 @@ export const TOOL_RESULT_UNAVAILABLE_ERROR =
export const READ_TOOL_RESULT_UNAVAILABLE_ERROR =
'Tool executed, but its result could not be returned safely. The call was read-only, so you may retry it or continue without the result.'
/**
* Withheld-result wording for a call that disclosed how far its side effect got.
*
* The generic message above has to cover both "nothing happened" and "it happened,
* you just cannot see it", which is why a caller could not build a retry policy from
* it: a rejected call and a completed mutation read identically. A tool that declares
* its {@link ToolCallEffect} gets the phrasing its phase actually warrants.
*/
const WITHHELD_ERROR_BY_EFFECT_PHASE: Record<ToolCallEffect['phase'], string> = {
[TOOL_EFFECT_PHASE.notAttempted]:
'Tool call was rejected before it ran, so nothing was created or changed. The reason could not be returned safely — correct the call and try again.',
[TOOL_EFFECT_PHASE.attempted]:
'Tool execution was dispatched but its outcome could not be returned safely. At most one run exists for the ids in this result — resolve it before retrying a mutation.',
[TOOL_EFFECT_PHASE.performed]:
'Tool execution completed but its result could not be returned safely. Do not retry — read the outcome using the ids in this result.',
}
const READ_ONLY_RESULT_TOOLS = new Set(['read', 'glob', 'grep'])
/**
* The shape of an identifier this system mints `generateId`'s UUID, and the
* database ids that share it. Effect ids bypass secret projection, so the set of
* values that may occupy one is pinned to a syntax no credential we issue or store
* takes. A caller with a differently shaped id has to widen this deliberately,
* where the exemption is reviewed, rather than by passing it.
*/
const SERVER_MINTED_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
/** Field names the disclosure record owns; an id may not take one. */
const RESERVED_DISCLOSURE_KEYS = new Set(['resultWithheld', 'effect'])
/** Chooses the withheld-result message a tool's caller should surface. */
export function toolResultUnavailableError(toolId?: string): string {
return toolId && READ_ONLY_RESULT_TOOLS.has(toolId)
@@ -22,24 +56,101 @@ export function toolResultUnavailableError(toolId?: string): string {
: TOOL_RESULT_UNAVAILABLE_ERROR
}
/**
* Why complete content could not cross, for the caller that is about to log a refusal.
*
* The three causes need different fixes a latched registry names the guard that tripped,
* an absent one means the surface never built a catalog, and a content refusal means the
* registry was fine and the payload itself was unprojectable so they are not collapsed.
*/
export type ToolResultWithholdingCause =
| {
kind: 'registry-incomplete'
reasons: readonly ResolvedSecretIncompletenessReason[]
origins: readonly string[]
}
| { kind: 'registry-absent' }
| { kind: 'content-refused' }
export type CopilotToolResultProjection =
| { safe: true; result: ToolExecutionResult }
| { safe: false; result: ToolExecutionResult; cause: ToolResultWithholdingCause }
function structuralResult(result: ToolExecutionResult): ToolExecutionResult {
return { success: result.success === true }
}
/**
* Reduces a withheld result to the facts the tool asserted about the call itself.
*
* Content is dropped because nothing here can prove it secret-free. The effect
* disclosure survives because it is not derived from content: the phase is a
* code-defined literal and every id is checked against {@link SERVER_MINTED_ID_PATTERN}.
* An id that fails that check voids the whole disclosure rather than being dropped
* on its own a partially honoured exemption is the one shape a reader would
* misread as complete.
*/
function omittedResult(result: ToolExecutionResult, toolId?: string): ToolExecutionResult {
if (result.success) return { success: true }
return { success: false, error: toolResultUnavailableError(toolId) }
const effect = vouchableEffect(result.effect)
if (!effect) {
return result.success
? { success: true }
: { success: false, error: toolResultUnavailableError(toolId) }
}
return {
success: result.success === true,
output: { resultWithheld: true, effect: effect.phase, ...effect.ids },
...(result.success ? {} : { error: WITHHELD_ERROR_BY_EFFECT_PHASE[effect.phase] }),
}
}
export type CopilotToolResultProjection =
| { safe: true; result: ToolExecutionResult }
| { safe: false; result: ToolExecutionResult }
/**
* Returns the disclosure only when every id it carries is a shape this system mints and none
* of them would displace the record's own fields. An id named `effect` overwriting the phase
* would corrupt exactly the field the retry decision reads, so a collision voids the
* disclosure on the same all-or-nothing terms as an unvouchable id.
*/
function vouchableEffect(effect: ToolCallEffect | undefined): ToolCallEffect | undefined {
if (!effect) return undefined
for (const [key, value] of Object.entries(effect.ids ?? {})) {
if (RESERVED_DISCLOSURE_KEYS.has(key)) return undefined
if (typeof value !== 'string' || !SERVER_MINTED_ID_PATTERN.test(value)) return undefined
}
return effect
}
function withholdingCause(
registry: ResolvedSecretTraceRegistry | undefined
): ToolResultWithholdingCause {
if (!registry) return { kind: 'registry-absent' }
const diagnostics = registry.getIncompletenessDiagnostics()
return diagnostics
? {
kind: 'registry-incomplete',
reasons: diagnostics.reasons,
origins: diagnostics.origins,
}
: { kind: 'content-refused' }
}
function withheld(
result: ToolExecutionResult,
registry: ResolvedSecretTraceRegistry | undefined,
toolId: string | undefined
): CopilotToolResultProjection {
return {
safe: false,
result: omittedResult(result, toolId),
cause: withholdingCause(registry),
}
}
/**
* Projects terminal tool content and reports whether the complete content was safe to cross.
* Callers that isolate provenance per tool call may merge that child registry only when `safe`
* is true and the child is complete. The returned result is always safe to expose: an unsafe
* projection is reduced to a structural success or failure.
* projection is reduced to a structural success or failure, plus any effect the tool disclosed.
*/
export function inspectToolResultForCopilot(
result: ToolExecutionResult,
@@ -54,7 +165,7 @@ export function inspectToolResultForCopilot(
if (Object.hasOwn(result, 'error')) content.error = result.error
const projection = projectResolvedSecretModelJsonContent(content, resultRegistry)
if (!projection.safe || !projection.value || typeof projection.value !== 'object') {
return { safe: false, result: omittedResult(result, toolId) }
return withheld(result, resultRegistry, toolId)
}
const projectedContent = projection.value as Record<string, unknown>
@@ -62,7 +173,7 @@ export function inspectToolResultForCopilot(
if (Object.hasOwn(projectedContent, 'output')) projected.output = projectedContent.output
if (Object.hasOwn(projectedContent, 'error')) {
if (typeof projectedContent.error !== 'string') {
return { safe: false, result: omittedResult(result, toolId) }
return withheld(result, resultRegistry, toolId)
}
projected.error = projectedContent.error
}
@@ -74,7 +185,7 @@ export function inspectToolResultForCopilot(
}
return { safe: true, result: projected }
} catch {
return { safe: false, result: omittedResult(result, toolId) }
return withheld(result, registry, toolId)
}
}
@@ -98,3 +209,16 @@ export function projectToolErrorMessageForCopilot(
): string {
return projectToolResultForCopilot({ success: false, error }, registry, toolId).error ?? ''
}
/** Flattens a withholding cause into log/span fields, so every surface reports it alike. */
export function describeWithholdingCause(
cause: ToolResultWithholdingCause
): Record<string, unknown> {
return cause.kind === 'registry-incomplete'
? {
withheldCause: cause.kind,
withheldReasons: [...cause.reasons],
...(cause.origins.length > 0 ? { withheldOrigins: [...cause.origins] } : {}),
}
: { withheldCause: cause.kind }
}
@@ -45,11 +45,52 @@ export interface ToolExecutionContext {
resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
}
/**
* How far a tool call got in performing its side effect.
*
* This is a property of the call, not of the content it produced, which is why it
* can still be reported when the content itself cannot cross the model boundary.
* It is the only thing that lets a caller decide about retry: a rejected call and a
* completed mutation are otherwise indistinguishable once their payloads are withheld.
*/
export const TOOL_EFFECT_PHASE = {
/** Rejected before anything could happen. Correcting the call and retrying is safe. */
notAttempted: 'not_attempted',
/**
* Dispatched; zero or one effects may exist. Resolve by id before retrying.
*
* Zero is a legitimate outcome here, not a defect: the id is a correlation key, not a
* promise that a row exists. Narrowing this to "a run definitely exists" would take
* per-block instrumentation across every execution in the product to spare one caller a
* lookup that answers the question definitively either way.
*/
attempted: 'attempted',
/** The effect ran to completion, whatever its outcome. Never retry blind. */
performed: 'performed',
} as const
export type ToolEffectPhase = (typeof TOOL_EFFECT_PHASE)[keyof typeof TOOL_EFFECT_PHASE]
export interface ToolCallEffect {
phase: ToolEffectPhase
/**
* Server-minted identifiers naming the effect, so an unreadable result stays
* resolvable. Values must be identifiers this system issues; the egress
* projection rejects the whole disclosure otherwise.
*/
ids?: Readonly<Record<string, string>>
}
export interface ToolExecutionResult {
success: boolean
output?: unknown
error?: string
resources?: MothershipResource[]
/**
* Declared by tools whose failure a caller cannot otherwise act on. Consumed by
* the egress projection and never returned to the model as-is on a withheld
* result it becomes the disclosure record that replaces the dropped content.
*/
effect?: ToolCallEffect
}
export type ToolHandler = (
@@ -9,6 +9,7 @@ const { mocks } = vi.hoisted(() => ({
apiKey: vi.fn(),
executeWorkflowUseCase: vi.fn(),
hasExecutionResult: vi.fn(),
readAttemptedExecutionId: vi.fn(),
},
}))
@@ -28,6 +29,7 @@ vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({
vi.mock('@/executor/utils/errors', () => ({
hasExecutionResult: mocks.hasExecutionResult,
readAttemptedExecutionId: mocks.readAttemptedExecutionId,
}))
vi.mock('@/lib/core/telemetry', () => ({
@@ -57,6 +59,7 @@ describe('workflow mutation Copilot adapters', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.hasExecutionResult.mockReturnValue(false)
mocks.readAttemptedExecutionId.mockReturnValue(undefined)
})
it('maps encoded folder aliases into one create application command', async () => {
@@ -259,6 +262,66 @@ describe('workflow mutation Copilot adapters', () => {
const result = await executeRunWorkflow({ workflowId: 'workflow-1' }, context)
expect(result).toEqual({ success: false, error: 'Workflow execution failed' })
expect(result).toEqual({
success: false,
error: 'Workflow execution failed',
effect: { phase: 'not_attempted' },
})
})
/**
* How far the run got is the only thing a caller can act on once the egress boundary
* withholds the payload, so each of these must reach the projection distinguishable.
*/
it.each([
{
label: 'refused on its own arguments',
arrange: () => {},
run: () => executeRunWorkflow({}, { ...context, workflowId: undefined }),
effect: { phase: 'not_attempted' },
},
{
label: 'failed before dispatch',
arrange: () => mocks.executeWorkflowUseCase.mockRejectedValueOnce(new Error('denied')),
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
effect: { phase: 'not_attempted' },
},
{
label: 'failed after dispatch',
arrange: () => {
mocks.executeWorkflowUseCase.mockRejectedValueOnce(new Error('crashed'))
mocks.readAttemptedExecutionId.mockReturnValue('execution-1')
},
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
effect: { phase: 'attempted', ids: { executionId: 'execution-1' } },
},
{
label: 'cancelled before it could finish',
arrange: () =>
mocks.executeWorkflowUseCase.mockResolvedValueOnce({
success: false,
output: {},
logs: [],
status: 'cancelled',
metadata: { executionId: 'execution-1' },
}),
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
effect: { phase: 'attempted', ids: { executionId: 'execution-1' } },
},
{
label: 'completed',
arrange: () =>
mocks.executeWorkflowUseCase.mockResolvedValueOnce({
success: true,
output: {},
logs: [],
metadata: { executionId: 'execution-1' },
}),
run: () => executeRunWorkflow({ workflowId: 'workflow-1' }, context),
effect: { phase: 'performed', ids: { executionId: 'execution-1' } },
},
])('states that a run $label', async ({ arrange, run, effect }) => {
arrange()
expect((await run()).effect).toEqual(effect)
})
})
@@ -7,6 +7,11 @@ import {
messageForCopilotWorkflowError,
} from '@/lib/copilot/application/execute-workflow-use-case'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import {
TOOL_EFFECT_PHASE,
type ToolCallEffect,
type ToolEffectPhase,
} from '@/lib/copilot/tool-executor/types'
import { requireCopilotWorkspace } from '@/lib/copilot/tools/server/workspace-scope'
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { PlatformEvents } from '@/lib/core/telemetry'
@@ -24,7 +29,7 @@ import {
setWorkflowBlockEnabled,
} from '@/lib/workflows/application/update-workflow-content'
import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer'
import { hasExecutionResult } from '@/executor/utils/errors'
import { hasExecutionResult, readAttemptedExecutionId } from '@/executor/utils/errors'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
function stripBinaryFields(value: unknown): unknown {
@@ -39,6 +44,41 @@ function stripBinaryFields(value: unknown): unknown {
return out
}
/**
* States how far a run got, so the answer survives a result the egress boundary withholds.
*
* Without it a withheld run reduces to a bare success or an opaque failure and takes the
* execution id with it, which is what left a caller unable to tell a rejected call from a
* completed run and with nothing to look either one up by.
*/
function executionEffect(phase: ToolEffectPhase, executionId?: string): ToolCallEffect {
return { phase, ...(executionId ? { ids: { executionId } } : {}) }
}
/** A run refused on its own arguments, before anything could be created. */
function runRejected(error: string): ToolCallResult {
return { success: false, error, effect: executionEffect(TOOL_EFFECT_PHASE.notAttempted) }
}
/**
* The phase of a run whose result came back, from how that run ended.
*
* A result in hand means the executor reached a terminal state and recorded it, so the
* caller can read the whole story by id `performed`. Cancelled and paused stopped partway
* and may have run every block, one, or none, which is exactly what `attempted` says.
*
* Deliberately does not separate "ran no blocks" from "ran some". Establishing that would
* take a callback on every block of every execution in the product, and buys the caller
* nothing it cannot get by resolving the id it was already handed.
*/
function settledPhase(status: ExecutionResultStatus): ToolEffectPhase {
return status === 'cancelled' || status === 'paused'
? TOOL_EFFECT_PHASE.attempted
: TOOL_EFFECT_PHASE.performed
}
type ExecutionResultStatus = 'completed' | 'paused' | 'cancelled' | undefined
function buildExecutionOutput(
result: {
success: boolean
@@ -46,7 +86,9 @@ function buildExecutionOutput(
output?: unknown
logs?: unknown[]
error?: string
status?: ExecutionResultStatus
},
phase: ToolEffectPhase,
extra?: Record<string, unknown>
): ToolCallResult {
return {
@@ -59,21 +101,34 @@ function buildExecutionOutput(
logs: stripBinaryFields(result.logs),
},
error: result.success ? undefined : result.error || 'Workflow execution failed',
effect: executionEffect(phase, result.metadata?.executionId),
}
}
function buildExecutionError(error: unknown): ToolCallResult {
if (hasExecutionResult(error)) {
return buildExecutionOutput({
...error.executionResult,
success: false,
error: error.executionResult.error || 'Workflow execution failed',
})
return buildExecutionOutput(
{
...error.executionResult,
success: false,
error: error.executionResult.error || 'Workflow execution failed',
},
settledPhase(error.executionResult.status)
)
}
logger.error('Copilot workflow execution command failed', { error })
/**
* Only failures raised after dispatch carry the id, so its absence is the positive
* statement that nothing was created rather than an admission of not knowing.
*/
const attemptedExecutionId = readAttemptedExecutionId(error)
return {
success: false,
error: messageForCopilotWorkflowError(error, 'Workflow execution failed'),
effect: executionEffect(
attemptedExecutionId ? TOOL_EFFECT_PHASE.attempted : TOOL_EFFECT_PHASE.notAttempted,
attemptedExecutionId
),
}
}
@@ -204,7 +259,7 @@ export async function executeRunWorkflow(
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
return runRejected('workflowId is required')
}
const useDraftState = !params.useDeployedState
@@ -221,7 +276,7 @@ export async function executeRunWorkflow(
lifecycle: copilotRunLifecycle(context),
})
return buildExecutionOutput(result)
return buildExecutionOutput(result, settledPhase(result.status))
} catch (error) {
return buildExecutionError(error)
}
@@ -322,10 +377,10 @@ export async function executeRunWorkflowUntilBlock(
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
return runRejected('workflowId is required')
}
if (!params.stopAfterBlockId) {
return { success: false, error: 'stopAfterBlockId is required' }
return runRejected('stopAfterBlockId is required')
}
const useDraftState = !params.useDeployedState
@@ -343,7 +398,9 @@ export async function executeRunWorkflowUntilBlock(
lifecycle: copilotRunLifecycle(context),
})
return buildExecutionOutput(result, { stoppedAfterBlockId: params.stopAfterBlockId })
return buildExecutionOutput(result, settledPhase(result.status), {
stoppedAfterBlockId: params.stopAfterBlockId,
})
} catch (error) {
return buildExecutionError(error)
}
@@ -401,10 +458,10 @@ export async function executeRunFromBlock(
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
return runRejected('workflowId is required')
}
if (!params.startBlockId) {
return { success: false, error: 'startBlockId is required' }
return runRejected('startBlockId is required')
}
const useDraftState = !params.useDeployedState
@@ -418,7 +475,9 @@ export async function executeRunFromBlock(
lifecycle: copilotRunLifecycle(context),
})
return buildExecutionOutput(result, { startBlockId: params.startBlockId })
return buildExecutionOutput(result, settledPhase(result.status), {
startBlockId: params.startBlockId,
})
} catch (error) {
return buildExecutionError(error)
}
@@ -487,10 +546,10 @@ export async function executeRunBlock(
try {
const workflowId = params.workflowId || context.workflowId
if (!workflowId) {
return { success: false, error: 'workflowId is required' }
return runRejected('workflowId is required')
}
if (!params.blockId) {
return { success: false, error: 'blockId is required' }
return runRejected('blockId is required')
}
const useDraftState = !params.useDeployedState
@@ -504,7 +563,7 @@ export async function executeRunBlock(
lifecycle: copilotRunLifecycle(context),
})
return buildExecutionOutput(result, { blockId: params.blockId })
return buildExecutionOutput(result, settledPhase(result.status), { blockId: params.blockId })
} catch (error) {
return buildExecutionError(error)
}
@@ -0,0 +1,229 @@
/**
* @vitest-environment node
*
* What a caller can learn about a workflow run whose result the secret-egress boundary
* withholds.
*
* The registry is latched the way production latches one a child run that returned no
* provenance envelope rather than by asserting an "unsafe" flag, so these fail for the
* same reason the incident did. Every outcome the copilot run path can produce is driven
* through the real handler and the real projection and asserted on two axes: the retry
* decision a caller can reach, which is the point of the disclosure, and that no run
* content crosses, which is the point of the boundary.
*
* The phases are deliberately coarse. `attempted` and `performed` both mean "an execution
* exists under this id". Separating "ran no blocks" from "ran some" would take a callback
* on every block of every execution in the product, and buys a caller nothing it cannot get
* by resolving the id it was handed.
*/
import { getErrorMessage } from '@sim/utils/errors'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { inspectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import type { ExecutionContext } from '@/lib/copilot/request/types'
import type { ToolExecutionResult } from '@/lib/copilot/tool-executor/types'
import { attachAttemptedExecutionId } from '@/executor/utils/errors'
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
const { mocks } = vi.hoisted(() => ({ mocks: { executeWorkflowUseCase: vi.fn() } }))
vi.mock('@/lib/copilot/application/execute-workflow-use-case', () => ({
executeCopilotWorkflowUseCase: mocks.executeWorkflowUseCase,
/** Passthrough, so a masked message reads as masking rather than as a fallback. */
messageForCopilotWorkflowError: (error: unknown, fallback = 'Workflow operation failed') =>
getErrorMessage(error, fallback),
}))
vi.mock('@/lib/workflows/sanitization/json-sanitizer', () => ({
sanitizeForCopilot: vi.fn((state) => state),
}))
vi.mock('@/lib/core/telemetry', () => ({ PlatformEvents: { apiKeyGenerated: vi.fn() } }))
import { executeRunWorkflow } from '@/lib/copilot/tools/handlers/workflow/mutations'
const EXECUTION_ID = '0f4d5a4c-6a1e-4c2f-9b7d-2c8f1a3e5d90'
/**
* Above the eight-character substitution floor, and deliberately not shaped like a real
* provider credential a realistic fixture makes secret scanners flag this file.
*/
const SECRET = 'fake-secret-for-test-only'
const context = {
userId: 'user-1',
workspaceId: 'workspace-1',
toolCallId: 'tool-call-1',
} as ExecutionContext
/** A registry latched exactly as `importCrossingProvenance` latches one in production. */
async function latchedRegistry(): Promise<ResolvedSecretTraceRegistry> {
const registry = new ResolvedSecretTraceRegistry([
{ name: 'API_KEY', plaintext: SECRET, encryptedValue: 'ciphertext' },
])
registry.recordResolved('API_KEY', SECRET, { propagated: true })
await registry.importCrossingProvenance(
undefined,
{ output: {} },
{ trusted: true, origin: 'copilotWorkflowMutation.runCrossing' }
)
expect(registry.isPermanentlyIncomplete()).toBe(true)
return registry
}
/** A run dense with the active secret, so a leak cannot pass unnoticed. */
function secretBearingResult(extra: Record<string, unknown> = {}) {
return {
success: true,
output: { report: `PASS ${SECRET}`, nested: { key: SECRET } },
logs: [{ blockName: 'report', output: SECRET }],
metadata: { executionId: EXECUTION_ID, duration: 2800 },
...extra,
}
}
function dispatchFailure(): Error {
const error = new Error(`crashed reading ${SECRET}`)
// What `executeCopilotRun` does once the run has been handed to the executor.
attachAttemptedExecutionId(error, EXECUTION_ID)
return error
}
interface Outcome {
label: string
arrange: () => void
effect: string
/** Whether the caller may re-issue the call without resolving anything first. */
safeToRetry: boolean
succeeded: boolean
}
const OUTCOMES: Outcome[] = [
{
label: 'refused before the executor was handed the run',
arrange: () => mocks.executeWorkflowUseCase.mockRejectedValue(new Error('Access denied')),
effect: 'not_attempted',
safeToRetry: true,
succeeded: false,
},
{
label: 'failed after the executor was handed the run',
arrange: () => mocks.executeWorkflowUseCase.mockRejectedValue(dispatchFailure()),
effect: 'attempted',
safeToRetry: false,
succeeded: false,
},
{
label: 'cancelled partway',
arrange: () =>
mocks.executeWorkflowUseCase.mockResolvedValue(
secretBearingResult({ success: false, status: 'cancelled' })
),
effect: 'attempted',
safeToRetry: false,
succeeded: false,
},
{
label: 'paused partway',
arrange: () =>
mocks.executeWorkflowUseCase.mockResolvedValue(
secretBearingResult({ success: false, status: 'paused' })
),
effect: 'attempted',
safeToRetry: false,
succeeded: false,
},
{
label: 'ran and failed',
arrange: () =>
mocks.executeWorkflowUseCase.mockResolvedValue(
secretBearingResult({ success: false, error: `Block failed with ${SECRET}` })
),
effect: 'performed',
safeToRetry: false,
succeeded: false,
},
{
label: 'ran and completed',
arrange: () => mocks.executeWorkflowUseCase.mockResolvedValue(secretBearingResult()),
effect: 'performed',
safeToRetry: false,
succeeded: true,
},
]
async function withhold(): Promise<ToolExecutionResult> {
const settled = await executeRunWorkflow({ workflowId: 'wf-1' }, context)
const projection = inspectToolResultForCopilot(settled, await latchedRegistry(), 'run_workflow')
expect(projection.safe).toBe(false)
return projection.result
}
describe('a withheld run_workflow result', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.executeWorkflowUseCase.mockReset()
})
it('says nothing was created when the call never reached the use case', async () => {
const rejected = await executeRunWorkflow({}, { ...context, workflowId: undefined })
expect(mocks.executeWorkflowUseCase).not.toHaveBeenCalled()
const { result } = inspectToolResultForCopilot(
rejected,
await latchedRegistry(),
'run_workflow'
)
expect(result.output).toEqual({ resultWithheld: true, effect: 'not_attempted' })
expect(result.error).toContain('nothing was created')
})
it.each(OUTCOMES)('discloses a run that was $label', async ({ arrange, effect, succeeded }) => {
arrange()
const result = await withhold()
expect(result.success).toBe(succeeded)
expect(result.output).toEqual({
resultWithheld: true,
effect,
// An id is present exactly when there is something to resolve.
...(effect === 'not_attempted' ? {} : { executionId: EXECUTION_ID }),
})
})
it.each(OUTCOMES)('never leaks run content for a run that was $label', async ({ arrange }) => {
arrange()
const serialized = JSON.stringify(await withhold())
expect(serialized).not.toContain(SECRET)
expect(serialized).not.toContain('PASS')
expect(serialized).not.toContain('Block failed')
expect(serialized).not.toContain('crashed')
})
/**
* The property the disclosure exists for: a caller can decide about retry from the
* response alone, and can never conclude "nothing happened" about a run that exists.
*/
it('lets a caller decide retry safety without resolving anything', async () => {
for (const outcome of OUTCOMES) {
mocks.executeWorkflowUseCase.mockReset()
outcome.arrange()
const output = (await withhold()).output as Record<string, unknown>
expect(output.effect === 'not_attempted', outcome.label).toBe(outcome.safeToRetry)
expect(Object.hasOwn(output, 'executionId'), outcome.label).toBe(!outcome.safeToRetry)
}
})
/** The defect this replaced: every one of these arrived as the same sentence. */
it('distinguishes outcomes that need different decisions', async () => {
const seen = new Set<string>()
for (const outcome of OUTCOMES) {
mocks.executeWorkflowUseCase.mockReset()
outcome.arrange()
seen.add(JSON.stringify(await withhold()))
}
// Retry, resolve-then-decide, and read-the-result are the three distinct answers.
expect(seen.size).toBeGreaterThanOrEqual(3)
})
})
@@ -375,6 +375,8 @@ export interface SecureFetchOptions {
stripAuthOnRedirect?: boolean
/** Omit for the historical behavior used by existing workflows. */
redirectPolicy?: HttpRedirectPolicy
/** Rejects a redirect target before DNS resolution or a follow-up request is attempted. */
assertRedirectTarget?: (url: string) => void
/**
* Pre-validated, IP-pinned `http://` proxy URL (see {@link validateAndPinProxyUrl}).
* When set, the connection routes through this proxy and target-IP pinning is
@@ -1062,6 +1064,12 @@ export async function secureFetchWithPinnedIP(
res.resume()
const redirectUrl = resolveRedirectUrl(url, location)
try {
options.assertRedirectTarget?.(redirectUrl)
} catch (error) {
settledReject(error)
return
}
validateUrlWithDNS(redirectUrl, 'redirectUrl', { allowHttp: options.allowHttp })
.then((validation) => {
if (!validation.isValid) {
@@ -56,6 +56,29 @@ async function startRecordingServer(hops: RecordedHop[]): Promise<string> {
}
describe('secureFetchWithPinnedIP redirect replay', () => {
it('rejects a redirect target before following it', async () => {
const hops: RecordedHop[] = []
const target = await startRecordingServer(hops)
const origin = await startServer((req, res) => {
req.resume()
res.writeHead(302, { location: `${target}/after` })
res.end()
})
const assertRedirectTarget = vi.fn((url: string) => {
if (url === `${target}/after`) throw new Error('redirect target rejected')
})
await expect(
secureFetchWithPinnedIP(origin, '127.0.0.1', {
allowHttp: true,
assertRedirectTarget,
})
).rejects.toThrow('redirect target rejected')
expect(assertRedirectTarget).toHaveBeenCalledWith(`${target}/after`)
expect(hops).toEqual([])
})
it('preserves historical replay when no redirect policy is present', async () => {
const hops: RecordedHop[] = []
const target = await startRecordingServer(hops)
@@ -131,6 +131,43 @@ describe('custom tool application use cases', () => {
expect(mocks.audit).not.toHaveBeenCalled()
})
it('authorizes an actorless deployment without enabling personal fallback', async () => {
const result = await readAvailableCustomToolByIdOrTitleUseCase.execute({
principal: executorPrincipal({
subjectUserId: undefined,
delegationContext: {
kind: 'workflow_execution',
workflowId: 'workflow-1',
executionId: 'execution-1',
principal: {
kind: 'system',
serviceId: 'schedule',
workspaceId: workspace.workspaceId,
workflowId: 'workflow-1',
},
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment',
deploymentVersionId: 'version-1',
},
},
}),
input: {
workspaceId: workspace.workspaceId,
identifier: tool.id,
lookup: 'id_or_title',
},
})
expect(result).toEqual({ tool })
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.getAvailableTool).toHaveBeenCalledWith({
identifier: tool.id,
workspaceId: workspace.workspaceId,
lookup: 'id_or_title',
})
})
it('conceals a workspace assertion outside the delegated workspace before lookup', async () => {
mocks.loadContext.mockResolvedValueOnce({ ...workspace, workspaceId: 'workspace-2' })
@@ -3,6 +3,7 @@ import {
type Principal,
requirePrincipalSubjectUserId,
resolvePrincipalAttribution,
resolvePrincipalSubject,
} from '@sim/auth/principal'
import type { customTools } from '@sim/db/schema'
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
@@ -162,9 +163,10 @@ export const readAvailableCustomToolByIdOrTitleUseCase = defineAuthorizedWorkspa
resolveWorkspaceContext(input.workspaceId),
authorizationOptions,
async execute({ principal, input, context }) {
const subject = resolvePrincipalSubject(principal)
const tool = await getAvailableCustomTool({
identifier: input.identifier,
userId: requirePrincipalSubjectUserId(principal),
...(subject?.kind === 'sim_user' ? { userId: subject.userId } : {}),
workspaceId: context.workspaceId,
lookup: input.lookup,
})
+9
View File
@@ -164,6 +164,15 @@ async function loadAccessibleEncryptedEnvironment(
let workspaceCanAdmin = false
if (workspaceId) {
const access = options?.workspaceAccess ?? (await checkWorkspaceAccess(workspaceId, userId))
/**
* A workspace that no longer exists and one the caller may not read are different facts
* and take different corrections stop using the id versus ask for access. Collapsing
* them sent every deleted-workspace call down the access-denied path, where it read as a
* permissions problem nobody could reproduce.
*/
if (!access.exists) {
throw new Error(`Workspace ${workspaceId} does not exist`)
}
if (!access.hasAccess) {
throw new Error(`Access denied to workspace ${workspaceId}`)
}
@@ -3,10 +3,12 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockDownloadServableFileFromStorage, mockVerifyFileAccess } = vi.hoisted(() => ({
mockDownloadServableFileFromStorage: vi.fn(),
mockVerifyFileAccess: vi.fn(),
}))
const { mockDownloadServableFileFromStorage, mockReadWorkspaceFileByKey, mockVerifyFileAccess } =
vi.hoisted(() => ({
mockDownloadServableFileFromStorage: vi.fn(),
mockReadWorkspaceFileByKey: vi.fn(),
mockVerifyFileAccess: vi.fn(),
}))
vi.mock('@/lib/uploads/utils/file-utils.server', () => ({
downloadServableFileFromStorage: mockDownloadServableFileFromStorage,
@@ -16,6 +18,10 @@ vi.mock('@/app/api/files/authorization', () => ({
verifyFileAccess: mockVerifyFileAccess,
}))
vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({
readWorkspaceFileRecordByKey: { execute: mockReadWorkspaceFileByKey },
}))
import { readUserFileContent } from '@/lib/execution/payloads/materialization.server'
import type { UserFile } from '@/executor/types'
@@ -36,6 +42,7 @@ describe('readUserFileContent', () => {
vi.clearAllMocks()
generatedPdf.size = PDF_SOURCE.length
mockVerifyFileAccess.mockResolvedValue(true)
mockReadWorkspaceFileByKey.mockResolvedValue({ file: { id: 'file-1' } })
mockDownloadServableFileFromStorage.mockResolvedValue({
buffer: PDF_BYTES,
contentType: 'application/pdf',
@@ -53,4 +60,144 @@ describe('readUserFileContent', () => {
expect(content).not.toBe(PDF_SOURCE.toString('base64'))
expect(generatedPdf.size).toBe(PDF_BYTES.length)
})
it('authorizes execution-scoped files without inventing a human subject', async () => {
const executionFile: UserFile = {
id: 'file-2',
name: 'result.txt',
url: '',
size: 6,
type: 'text/plain',
key: 'execution/workspace-1/workflow-1/execution-1/result.txt',
context: 'execution',
}
mockDownloadServableFileFromStorage.mockResolvedValueOnce({ buffer: Buffer.from('result') })
await expect(
readUserFileContent(executionFile, {
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
encoding: 'text',
})
).resolves.toBe('result')
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
})
it.each(['profile-pictures', 'og-images', 'workspace-logos'] as const)(
'authorizes actorless reads from the trusted public %s context',
async (context) => {
const publicFile: UserFile = {
id: 'public-file',
name: 'public.png',
url: '',
size: 6,
type: 'image/png',
key: `${context}/public.png`,
context,
}
mockDownloadServableFileFromStorage.mockResolvedValueOnce({ buffer: Buffer.from('public') })
await expect(readUserFileContent(publicFile, { encoding: 'text' })).resolves.toBe('public')
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockReadWorkspaceFileByKey).not.toHaveBeenCalled()
}
)
it('does not let an actorless caller relabel a private key as public', async () => {
const relabeledFile: UserFile = {
id: 'private-file',
name: 'private.txt',
url: '',
size: 7,
type: 'text/plain',
key: 'workspace/workspace-1/private.txt',
context: 'og-images',
}
await expect(readUserFileContent(relabeledFile, { encoding: 'text' })).rejects.toThrow(
'File context does not match its storage key.'
)
expect(mockDownloadServableFileFromStorage).not.toHaveBeenCalled()
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
})
it('authorizes workspace files with the preserved actorless deployment principal', async () => {
const principal = {
kind: 'delegated' as const,
serviceId: 'executor' as const,
workspaceId: 'workspace-1',
delegationId: 'function-1',
audience: 'sim:function-executions',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
delegationContext: {
kind: 'workflow_execution' as const,
workflowId: 'workflow-1',
executionId: 'execution-1',
principal: {
kind: 'system' as const,
serviceId: 'schedule' as const,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
},
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment' as const,
deploymentVersionId: 'deployment-1',
},
},
}
await readUserFileContent(generatedPdf, {
principal,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
requestId: 'request-1',
encoding: 'base64',
})
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockReadWorkspaceFileByKey).toHaveBeenCalledWith(
expect.objectContaining({
input: {
key: generatedPdf.key,
assertedWorkspaceId: 'workspace-1',
},
principal: expect.objectContaining({
audience: 'sim:workspace-files',
delegationContext: principal.delegationContext,
}),
})
)
})
it('authorizes an exact workspace storage key with the workspace-key principal', async () => {
const principal = {
kind: 'workspace_api_key' as const,
workspaceId: 'workspace-1',
keyId: 'key-1',
}
await readUserFileContent(generatedPdf, {
principal,
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
encoding: 'base64',
})
expect(mockVerifyFileAccess).not.toHaveBeenCalled()
expect(mockReadWorkspaceFileByKey).toHaveBeenCalledWith({
principal,
input: {
key: generatedPdf.key,
assertedWorkspaceId: 'workspace-1',
},
})
})
})
@@ -1,5 +1,7 @@
import type { Principal } from '@sim/auth/principal'
import { createLogger, type Logger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
import {
@@ -21,13 +23,17 @@ import {
bufferToBase64,
inferContextFromKey,
isGeneratedDocumentSourceType,
isPublicStorageContext,
} from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
import { readWorkspaceFileRecordByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key'
import type { UserFile } from '@/executor/types'
const logger = createLogger('ExecutionPayloadMaterialization')
export interface ExecutionMaterializationContext {
principal?: Principal
workflowId?: string
workspaceId?: string
executionId?: string
@@ -244,7 +250,7 @@ function assertExecutionFileScope(key: string, options: ExecutionMaterialization
}
}
function getVerifiedStorageContext(file: UserFile): StorageContext {
function getVerifiedStorageContext(file: Pick<UserFile, 'key' | 'context'>): StorageContext {
if (!file.key) {
throw new Error('File content requires a storage key.')
}
@@ -258,13 +264,48 @@ function getVerifiedStorageContext(file: UserFile): StorageContext {
}
export async function assertUserFileContentAccess(
file: UserFile,
file: Pick<UserFile, 'key' | 'context'>,
options: ExecutionMaterializationContext
): Promise<void> {
const context = getVerifiedStorageContext(file)
if (context === 'execution') {
assertExecutionFileScope(file.key, options)
return
}
if (isPublicStorageContext(context)) {
return
}
if (context === 'workspace' && options.principal && options.workspaceId) {
const principal =
options.principal.kind === 'delegated'
? rebindWorkspaceFileDelegatedPrincipal({
principal: options.principal,
workspaceId: options.workspaceId,
delegationId: `execution-file-read:${options.requestId ?? 'unknown'}`,
...(options.principal.resourceScope?.fileId
? { fileId: options.principal.resourceScope.fileId }
: {}),
...(options.principal.resourceScope?.chatId
? { chatId: options.principal.resourceScope.chatId }
: {}),
...(options.executionId ? { executionId: options.executionId } : {}),
})
: options.principal
try {
await readWorkspaceFileRecordByKey.execute({
principal,
input: {
key: file.key,
assertedWorkspaceId: options.workspaceId,
},
})
return
} catch (error) {
if (!(error instanceof OrchestrationError && error.code === 'not_found')) throw error
}
}
if (!options.userId) {
@@ -0,0 +1,157 @@
/**
* @vitest-environment node
*/
import type { WorkflowExecutionDelegatedPrincipal } from '@sim/auth/principal'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
executeRequest: vi.fn(),
loadWorkspace: vi.fn(),
resolvePermission: vi.fn(),
}))
vi.mock('@/lib/function-execution/execute-request', () => ({
executeFunctionRequest: mocks.executeRequest,
}))
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
resolveActiveWorkspaceApplicationContext: mocks.loadWorkspace,
}))
vi.mock('@sim/platform-authz/workspace', () => ({
permissionSatisfies: (actual: string | null, required: string) =>
actual === 'admin' || actual === required || (actual === 'write' && required === 'read'),
resolveEffectiveWorkspacePermission: mocks.resolvePermission,
}))
import { FUNCTION_EXECUTION_DELEGATION_AUDIENCE } from '@/lib/function-execution/application/authorization'
import { executeFunction } from '@/lib/function-execution/application/execute-function'
const principal: WorkflowExecutionDelegatedPrincipal = {
kind: 'delegated',
serviceId: 'executor',
workspaceId: 'workspace-1',
delegationId: 'delegation-1',
audience: FUNCTION_EXECUTION_DELEGATION_AUDIENCE,
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
resourceScope: { executionId: 'execution-1' },
delegationContext: {
kind: 'workflow_execution',
workflowId: 'workflow-1',
executionId: 'execution-1',
principal: {
kind: 'system',
serviceId: 'schedule',
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
},
currentWorkflow: {
workflowId: 'workflow-1',
mode: 'deployment',
deploymentVersionId: 'deployment-1',
},
},
}
describe('executeFunction', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.loadWorkspace.mockResolvedValue({
workspaceId: 'workspace-1',
workspaceOrganizationId: null,
billedAccountUserId: 'workspace-owner',
allowPersonalApiKeys: true,
})
mocks.executeRequest.mockResolvedValue(Response.json({ success: true }))
mocks.resolvePermission.mockResolvedValue('write')
})
it('uses only the real workflow subject for legacy file contexts', async () => {
const humanPrincipal: WorkflowExecutionDelegatedPrincipal = {
...principal,
subjectUserId: 'invoking-user',
delegationContext: {
...principal.delegationContext!,
principal: {
kind: 'session',
userId: 'invoking-user',
sessionId: 'session-1',
},
},
}
await executeFunction.execute({
principal: humanPrincipal,
input: {
workspaceId: 'workspace-1',
body: {
code: 'return 1',
workspaceId: 'workspace-1',
executionId: 'execution-1',
},
headers: new Headers(),
},
})
expect(mocks.executeRequest).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
attributedUserId: 'invoking-user',
fileAccessUserId: 'invoking-user',
principal: humanPrincipal,
})
)
})
it('keeps an actorless deployed principal authoritative and attributes legacy work afterward', async () => {
const headers = new Headers()
const signal = new AbortController().signal
const response = await executeFunction.execute({
principal,
input: {
workspaceId: 'workspace-1',
body: {
code: 'return 1',
workflowId: 'workflow-1',
executionId: 'execution-1',
workspaceId: 'workspace-1',
},
headers,
signal,
},
})
expect(response.status).toBe(200)
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.executeRequest).toHaveBeenCalledWith(
{ headers, signal },
expect.objectContaining({
code: 'return 1',
workflowId: 'workflow-1',
executionId: 'execution-1',
}),
{
attributedUserId: 'workspace-owner',
principal,
}
)
})
it('rejects a body workspace that differs from the trusted operation scope', async () => {
await expect(
executeFunction.execute({
principal,
input: {
workspaceId: 'workspace-1',
body: { code: 'return 1', workspaceId: 'workspace-victim' },
headers: new Headers(),
},
})
).rejects.toMatchObject({ code: 'not_found' })
expect(mocks.loadWorkspace).not.toHaveBeenCalled()
expect(mocks.executeRequest).not.toHaveBeenCalled()
})
})
@@ -1,4 +1,4 @@
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal'
import { type FunctionExecuteBody, functionExecuteBodySchema } from '@/lib/api/contracts'
import { defineAuthorizedWorkspaceUseCase } from '@/lib/core/application'
import { OrchestrationError } from '@/lib/core/orchestration/types'
@@ -35,7 +35,7 @@ export const executeFunction = defineAuthorizedWorkspaceUseCase({
authorizationOptions: {
delegation: functionExecutionDelegationPolicy,
},
execute: async ({ principal, input }): Promise<Response> => {
execute: async ({ principal, input, context }): Promise<Response> => {
const parsedBody = functionExecuteBodySchema.safeParse(input.body)
if (!parsedBody.success) {
throw new OrchestrationError(
@@ -44,6 +44,10 @@ export const executeFunction = defineAuthorizedWorkspaceUseCase({
)
}
const { executeFunctionRequest } = await import('@/lib/function-execution/execute-request')
const { attributedUserId } = resolvePrincipalAttribution(principal, {
workspaceBillingOwnerUserId: context.billedAccountUserId,
})
const subject = resolvePrincipalSubject(principal)
return executeFunctionRequest(
{
headers: input.headers,
@@ -51,7 +55,9 @@ export const executeFunction = defineAuthorizedWorkspaceUseCase({
},
parsedBody.data,
{
userId: requirePrincipalSubjectUserId(principal),
attributedUserId,
principal,
...(subject?.kind === 'sim_user' ? { fileAccessUserId: subject.userId } : {}),
...(input.sandboxProfile ? { sandboxProfile: input.sandboxProfile } : {}),
}
)
@@ -14,6 +14,7 @@ import {
import { NextRequest } from 'next/server'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { functionExecuteBodySchema } from '@/lib/api/contracts'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { INTERNAL_EXECUTION_DEADLINE_HEADER } from '@/lib/execution/execution-deadline-header'
import {
MOUNTED_WORKSPACE_FILES_PROVENANCE_KEY,
@@ -27,17 +28,6 @@ import {
SandboxOutputLimitError,
} from '@/lib/execution/remote-sandbox/output-limits'
function grantedAccess(workspaceId: string) {
return {
exists: true,
hasAccess: true,
canWrite: true,
canAdmin: false,
workspace: { id: workspaceId },
permission: 'admin',
}
}
const {
mockExecuteInSandbox,
mockExecuteInIsolatedVM,
@@ -51,8 +41,6 @@ const {
mockUploadFile,
mockValidateWorkspaceFileWriteTarget,
mockWriteWorkspaceFileByPath,
mockCheckWorkspaceAccess,
mockResolveWorkspaceAccess,
} = vi.hoisted(() => ({
mockExecuteInSandbox: vi.fn(),
mockExecuteInIsolatedVM: vi.fn(),
@@ -71,13 +59,6 @@ const {
mockUploadFile: vi.fn(),
mockValidateWorkspaceFileWriteTarget: vi.fn(),
mockWriteWorkspaceFileByPath: vi.fn(),
mockCheckWorkspaceAccess: vi.fn(),
mockResolveWorkspaceAccess: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: mockCheckWorkspaceAccess,
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
}))
vi.mock('@/lib/core/security/encryption', () => ({
@@ -189,7 +170,22 @@ async function POST(request: NextRequest): Promise<Response> {
}
return executeFunctionRequest({ headers: request.headers, signal: request.signal }, parsed.data, {
userId: auth.userId,
attributedUserId: auth.userId,
principal: {
kind: 'delegated',
serviceId: 'executor',
subjectUserId: auth.userId,
workspaceId: parsed.data.workspaceId ?? 'workspace-test',
delegationId: 'function-test',
audience: 'sim:function-executions',
issuedAt: new Date(Date.now() - 1_000),
expiresAt: new Date(Date.now() + 60_000),
delegationContext: {
kind: 'workflow_execution',
workflowId: parsed.data.workflowId ?? 'workflow-test',
...(parsed.data.executionId ? { executionId: parsed.data.executionId } : {}),
},
},
...(auth.sandboxProfile === 'mothership' ? { sandboxProfile: 'mothership' } : {}),
})
}
@@ -208,9 +204,6 @@ describe('Function execution request', () => {
authType: 'internal_jwt',
})
mockCheckWorkspaceAccess.mockImplementation(async (id: string) => grantedAccess(id))
mockResolveWorkspaceAccess.mockImplementation(async (id: string) => grantedAccess(id))
mockExecuteInIsolatedVM.mockResolvedValue({ result: 'test', stdout: '' })
mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey }))
clearLargeValueCacheForTests()
@@ -282,31 +275,7 @@ describe('Function execution request', () => {
expect(data).toHaveProperty('error', 'Unauthorized')
})
it('rejects a body-supplied workspaceId the acting user is not a member of', async () => {
mockCheckWorkspaceAccess.mockResolvedValue({
exists: true,
hasAccess: false,
canWrite: false,
canAdmin: false,
workspace: { id: 'workspace-victim' },
permission: null,
})
const req = createMockRequest('POST', {
code: 'return "test"',
workspaceId: 'workspace-victim',
})
const response = await POST(req)
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Workspace access denied')
expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('workspace-victim', 'user-123')
expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled()
})
it('rejects a sandbox output export into a workspace the acting user cannot write to', async () => {
it('rejects a sandbox output export through the workspace-file application policy', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
@@ -314,16 +283,9 @@ describe('Function execution request', () => {
sandboxId: 'sandbox-123',
exportedFiles: { '/tmp/out.txt': 'owned by attacker' },
})
const readOnly = {
exists: true,
hasAccess: true,
canWrite: false,
canAdmin: false,
workspace: { id: 'workspace-victim' },
permission: 'read',
}
mockCheckWorkspaceAccess.mockResolvedValue(readOnly)
mockResolveWorkspaceAccess.mockResolvedValue(readOnly)
mockWriteWorkspaceFileByPath.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'Insufficient workspace permissions')
)
const req = createMockRequest('POST', {
code: 'print("done")',
@@ -338,45 +300,8 @@ describe('Function execution request', () => {
const data = await response.json()
expect(response.status).toBe(403)
expect(data).toHaveProperty('error', 'Workspace access denied')
expect(mockValidateWorkspaceFileWriteTarget).not.toHaveBeenCalled()
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})
it('rejects an export whose workspace is derived from a body-supplied workflowId', async () => {
envFlagsMock.isRemoteSandboxEnabled = true
mockExecuteInSandbox.mockResolvedValueOnce({
result: 'done',
stdout: 'ok',
sandboxId: 'sandbox-123',
exportedFiles: { '/tmp/out.txt': 'owned by attacker' },
})
workflowsUtilsMock.getWorkflowById.mockResolvedValueOnce({
id: 'workflow-victim',
workspaceId: 'workspace-victim',
})
mockResolveWorkspaceAccess.mockResolvedValue({
exists: true,
hasAccess: false,
canWrite: false,
canAdmin: false,
workspace: { id: 'workspace-victim' },
permission: null,
})
const req = createMockRequest('POST', {
code: 'print("done")',
language: 'python',
workflowId: 'workflow-victim',
outputs: {
files: [{ path: 'files/README.md', mode: 'overwrite', sandboxPath: '/tmp/out.txt' }],
},
})
const response = await POST(req)
expect(response.status).toBe(403)
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
expect(data).toHaveProperty('error', 'Insufficient workspace permissions')
expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
})
it('runs import-free JavaScript in isolated-vm without a remote provider', async () => {
@@ -1,4 +1,4 @@
import type { Principal } from '@sim/auth/principal'
import type { DelegatedPrincipal, Principal } from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
@@ -22,6 +22,7 @@ import {
isTimeoutAbortReason,
type TimeoutAbortController,
} from '@/lib/core/execution-limits'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { encryptSecret } from '@/lib/core/security/encryption'
import { setRecordValue } from '@/lib/core/utils/records'
import { generateRequestId } from '@/lib/core/utils/request'
@@ -84,15 +85,10 @@ import {
type WorkspaceFileSecretProvenance,
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
import { getWorkflowById } from '@/lib/workflows/utils'
import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
import { fileOperations } from '@/lib/workspace-files/application/operations'
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference'
import {
checkWorkspaceAccess,
resolveWorkspaceAccess,
type WorkspaceAccess,
} from '@/lib/workspaces/permissions/utils'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
import {
@@ -970,6 +966,7 @@ function serializeForShellEnv(value: unknown, nullValue = ''): string {
}
interface FunctionRouteExecutionContext {
principal: DelegatedPrincipal
workflowId?: string
workspaceId?: string
executionId?: string
@@ -977,7 +974,8 @@ interface FunctionRouteExecutionContext {
largeValueKeys?: string[]
fileKeys?: string[]
allowLargeValueWorkflowScope?: boolean
userId?: string
attributedUserId: string
fileAccessUserId?: string
requestId: string
resolvedSecretNames: Set<string>
includePrivateResolvedSecretNames: boolean
@@ -1078,6 +1076,7 @@ function createFunctionRuntimeBrokers(
const largeValueKeys = context.largeValueKeys
const fileKeys = context.fileKeys
const base = {
principal: context.principal,
requestId: context.requestId,
workflowId: context.workflowId,
workspaceId: context.workspaceId,
@@ -1086,7 +1085,7 @@ function createFunctionRuntimeBrokers(
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope: context.allowLargeValueWorkflowScope,
userId: context.userId,
userId: context.fileAccessUserId,
logger,
}
@@ -1158,7 +1157,7 @@ async function compactFunctionRouteBody<T>(
workflowId: context.workflowId,
workspaceId: context.workspaceId,
executionId: context.executionId,
userId: context.userId,
userId: context.attributedUserId,
preserveRoot: true,
requireDurable: Boolean(context.workspaceId && context.workflowId && context.executionId),
})
@@ -1411,21 +1410,8 @@ function exportFailure(
)
}
/**
* Both `workspaceId` and `workflowId` arrive in the request body, so the workspace an export
* resolves to is caller-controlled either way. Returns null when the acting user cannot write to
* it, gating the secret-provenance scan and overwrite probe that run before the write itself.
*/
async function authorizeExportWorkspace(
workspaceId: string,
authUserId: string,
provided?: WorkspaceAccess
): Promise<WorkspaceAccess | null> {
const access = await resolveWorkspaceAccess(workspaceId, authUserId, provided)
if (access.exists && access.canWrite) return access
logger.warn('Sandbox file export denied for workspace', { workspaceId, userId: authUserId })
return null
function workspaceFileExportErrorStatus(error: unknown): number {
return asOrchestrationError(error)?.code === 'forbidden' ? 403 : 400
}
async function maybeExportSandboxFileToWorkspace(args: {
@@ -1433,7 +1419,6 @@ async function maybeExportSandboxFileToWorkspace(args: {
authUserId: string
workflowId?: string
workspaceId?: string
workspaceAccess?: WorkspaceAccess
outputPath?: string
outputFormat?: string
outputMimeType?: string
@@ -1449,7 +1434,6 @@ async function maybeExportSandboxFileToWorkspace(args: {
authUserId,
workflowId,
workspaceId,
workspaceAccess,
outputPath,
outputFormat,
outputMimeType,
@@ -1484,9 +1468,6 @@ async function maybeExportSandboxFileToWorkspace(args: {
)
}
const access = await authorizeExportWorkspace(resolvedWorkspaceId, authUserId, workspaceAccess)
if (!access) return exportFailure('Workspace access denied', 403, stdout, executionTime)
if (exportedFileContent === undefined) {
return exportFailure(
`Sandbox file "${outputSandboxPath}" was not found or could not be read`,
@@ -1523,9 +1504,8 @@ async function maybeExportSandboxFileToWorkspace(args: {
const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create')
const targetPath = mode === 'create' ? outputPath : overwriteFileId || outputPath
const principal = createWorkspaceFileDelegatedPrincipal({
serviceId: 'executor',
subjectUserId: authUserId,
const principal = rebindWorkspaceFileDelegatedPrincipal({
principal: routeContext.principal,
workspaceId: resolvedWorkspaceId,
delegationId: `function-execute:${routeContext.requestId}`,
executionId: routeContext.executionId,
@@ -1591,7 +1571,7 @@ async function maybeExportSandboxFileToWorkspace(args: {
} catch (error) {
return exportFailure(
getErrorMessage(error, 'Failed to export sandbox file'),
400,
workspaceFileExportErrorStatus(error),
stdout,
executionTime
)
@@ -1603,7 +1583,6 @@ async function maybeExportSandboxFilesToWorkspace(args: {
authUserId: string
workflowId?: string
workspaceId?: string
workspaceAccess?: WorkspaceAccess
outputFiles: OutputFileDeclaration[]
exportedFiles?: Record<string, string>
exportedFileContent?: string
@@ -1628,7 +1607,6 @@ async function maybeExportSandboxFilesToWorkspace(args: {
authUserId: args.authUserId,
workflowId: args.workflowId,
workspaceId: args.workspaceId,
workspaceAccess: args.workspaceAccess,
outputPath: file.formatPath ?? file.path,
outputFormat: file.format,
outputMimeType: file.mimeType,
@@ -1654,15 +1632,6 @@ async function maybeExportSandboxFilesToWorkspace(args: {
)
}
const access = await authorizeExportWorkspace(
resolvedWorkspaceId,
args.authUserId,
args.workspaceAccess
)
if (!access) {
return exportFailure('Workspace access denied', 403, args.stdout, args.executionTime)
}
const preparedFiles = []
let totalOutputBytes = 0
for (const file of sandboxFiles) {
@@ -1716,9 +1685,8 @@ async function maybeExportSandboxFilesToWorkspace(args: {
})
}
const principal = createWorkspaceFileDelegatedPrincipal({
serviceId: 'executor',
subjectUserId: args.authUserId,
const principal = rebindWorkspaceFileDelegatedPrincipal({
principal: args.routeContext.principal,
workspaceId: resolvedWorkspaceId,
delegationId: `function-execute:${args.routeContext.requestId}`,
executionId: args.routeContext.executionId,
@@ -1738,7 +1706,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
} catch (error) {
return exportFailure(
getErrorMessage(error, 'Invalid sandbox output destination'),
400,
workspaceFileExportErrorStatus(error),
args.stdout,
args.executionTime
)
@@ -1805,7 +1773,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
} catch (error) {
return exportFailure(
getErrorMessage(error, 'Failed to export sandbox files'),
400,
workspaceFileExportErrorStatus(error),
args.stdout,
args.executionTime
)
@@ -1857,17 +1825,13 @@ async function maybeExportSandboxFilesToWorkspace(args: {
}
export interface TrustedFunctionExecutionAuth {
userId: string
attributedUserId: string
fileAccessUserId?: string
principal: DelegatedPrincipal
sandboxProfile?: 'mothership'
}
/**
* Executes the Function protocol after the caller has authenticated the human subject.
*
* The public route uses the legacy internal-token adapter below. Trusted in-process callers use
* the authorized Function application operation, which supplies this subject without creating a
* second Sim-to-Sim HTTP request.
*/
/** Executes the Function protocol after the application operation authorizes its principal. */
export async function executeFunctionRequest(
req: FunctionExecutionRequestContext,
body: ParsedFunctionExecuteBody,
@@ -1963,24 +1927,6 @@ export async function executeFunctionRequest(
_sandboxFiles,
} = body
// The internal JWT carries no workspace scope, so a body-supplied workspaceId would
// otherwise be the sole authorization input for sandbox selection and file exports.
// Denial is returned rather than thrown: this handler's catch-all would turn a thrown
// WorkspaceAccessDeniedError into a 500 before withRouteHandler could map it.
const workspaceAccess = workspaceId
? await checkWorkspaceAccess(workspaceId, auth.userId)
: undefined
if (workspaceAccess && (!workspaceAccess.exists || !workspaceAccess.hasAccess)) {
logger.warn(`[${requestId}] Function execution denied for workspace`, {
workspaceId,
userId: auth.userId,
})
return NextResponse.json(
{ success: false, error: 'Workspace access denied' },
{ status: 403 }
)
}
if (selectedSandboxId && !isRemoteSandboxEnabled) {
return NextResponse.json(
{ success: false, error: 'The Function code sandbox is not configured' },
@@ -2053,6 +1999,7 @@ export async function executeFunctionRequest(
})
routeContext = {
principal: auth.principal,
workflowId,
workspaceId,
executionId,
@@ -2060,7 +2007,8 @@ export async function executeFunctionRequest(
largeValueKeys,
fileKeys,
allowLargeValueWorkflowScope,
userId: auth.userId,
attributedUserId: auth.attributedUserId,
fileAccessUserId: auth.fileAccessUserId,
requestId,
resolvedSecretNames: new Set<string>(),
includePrivateResolvedSecretNames,
@@ -2223,10 +2171,9 @@ export async function executeFunctionRequest(
if (outputSandboxPaths.length > 0 || outputSandboxPath) {
const fileExportResponse = await maybeExportSandboxFilesToWorkspace({
routeContext,
authUserId: auth.userId,
authUserId: auth.attributedUserId,
workflowId,
workspaceId,
workspaceAccess,
outputFiles,
exportedFiles,
exportedFileContent,
@@ -2403,10 +2350,9 @@ export async function executeFunctionRequest(
if (outputSandboxPaths.length > 0 || outputSandboxPath) {
const fileExportResponse = await maybeExportSandboxFilesToWorkspace({
routeContext,
authUserId: auth.userId,
authUserId: auth.attributedUserId,
workflowId,
workspaceId,
workspaceAccess,
outputFiles,
exportedFiles,
exportedFileContent,
@@ -2494,10 +2440,9 @@ export async function executeFunctionRequest(
if (outputSandboxPaths.length > 0 || outputSandboxPath) {
const fileExportResponse = await maybeExportSandboxFilesToWorkspace({
routeContext,
authUserId: auth.userId,
authUserId: auth.attributedUserId,
workflowId,
workspaceId,
workspaceAccess,
outputFiles,
exportedFiles,
exportedFileContent,
@@ -2550,7 +2495,7 @@ export async function executeFunctionRequest(
runtimeBindings: compilerRuntimeBindings,
timeoutMs: timeout,
requestId,
ownerKey: `user:${auth.userId}`,
ownerKey: `user:${auth.attributedUserId}`,
ownerWeight: 1,
},
{ brokers: createFunctionRuntimeBrokers(routeContext), signal: executionSignal }

Some files were not shown because too many files have changed in this diff Show More