mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
Merge remote-tracking branch 'origin/staging' into feat/unified-server-selector-execution
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 > '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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>[] = []
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
+11
-3
@@ -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' && (
|
||||
|
||||
+6
-3
@@ -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>
|
||||
)
|
||||
|
||||
+9
-1
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,3 +10,4 @@ export {
|
||||
type LineChartMultiSeries,
|
||||
type LineChartPoint,
|
||||
} from '@/components/charts/line-chart'
|
||||
export { RadarChart, type RadarChartAxis } from '@/components/charts/radar-chart'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: 2π/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)
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+9
-16
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user