Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a699d16d7 | |||
| 2971715a2f | |||
| 22dbfaecb3 | |||
| f53e4794e3 | |||
| a4b7c085ff |
@@ -189,6 +189,20 @@ runs:
|
||||
cat /tmp/difysrv.log
|
||||
echo "DIFY_ENDPOINT=http://127.0.0.1:5093" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start jaeger all-in-one and seed traces over OTLP
|
||||
shell: bash
|
||||
run: |
|
||||
for i in $(seq 1 5); do
|
||||
docker pull jaegertracing/jaeger:latest && break
|
||||
echo "docker pull failed (attempt $i), retrying"; sleep 10
|
||||
done
|
||||
docker run -d --name mirage-jaeger \
|
||||
-p 16686:16686 -p 4317:4317 -p 4318:4318 \
|
||||
jaegertracing/jaeger:latest
|
||||
./python/.venv/bin/python integ/server/jaeger_seed.py \
|
||||
--host http://localhost:16686 --otlp http://localhost:4318
|
||||
echo "JAEGER_URL=http://localhost:16686" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start fake Databricks API
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
database: ${{ steps.filter.outputs.database }}
|
||||
data: ${{ steps.filter.outputs.data }}
|
||||
fuse: ${{ steps.filter.outputs.fuse }}
|
||||
observability: ${{ steps.filter.outputs.observability }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@v4
|
||||
id: filter
|
||||
@@ -87,6 +88,28 @@ jobs:
|
||||
- 'python/**'
|
||||
- '.github/workflows/test_integ.yml'
|
||||
- '.github/actions/integ-battery-setup/action.yml'
|
||||
observability:
|
||||
- 'integ/server/langfuse_compose.yml'
|
||||
- 'integ/server/langfuse_seed.py'
|
||||
- 'integ/server/jaeger_seed.py'
|
||||
- 'integ/resources/observability/**'
|
||||
- 'integ/targets.json'
|
||||
- 'integ/runners/**'
|
||||
- 'python/mirage/accessor/langfuse.py'
|
||||
- 'python/mirage/accessor/jaeger.py'
|
||||
- 'python/mirage/core/langfuse/**'
|
||||
- 'python/mirage/core/jaeger/**'
|
||||
- 'python/mirage/commands/builtin/langfuse/**'
|
||||
- 'python/mirage/commands/builtin/jaeger/**'
|
||||
- 'python/mirage/resource/langfuse/**'
|
||||
- 'python/mirage/resource/jaeger/**'
|
||||
- 'typescript/packages/core/src/core/langfuse/**'
|
||||
- 'typescript/packages/core/src/core/jaeger/**'
|
||||
- 'typescript/packages/core/src/accessor/langfuse.ts'
|
||||
- 'typescript/packages/core/src/accessor/jaeger.ts'
|
||||
- 'typescript/packages/node/src/resource/langfuse/**'
|
||||
- 'typescript/packages/node/src/resource/jaeger/**'
|
||||
- '.github/workflows/test_integ.yml'
|
||||
fuse:
|
||||
- 'integ/fuse.py'
|
||||
- 'integ/fuse.ts'
|
||||
@@ -144,11 +167,6 @@ jobs:
|
||||
./python/.venv/bin/python integ/safeguard.py > /tmp/safeguard.out
|
||||
diff integ/truth_safeguard.txt /tmp/safeguard.out
|
||||
|
||||
- name: Run session mount-mode scenarios and diff against truth
|
||||
run: |
|
||||
./python/.venv/bin/python integ/session_modes.py > /tmp/session_modes.out
|
||||
diff integ/truth_session_modes.txt /tmp/session_modes.out
|
||||
|
||||
- name: Run History scenarios and diff against truth
|
||||
run: |
|
||||
./python/.venv/bin/python integ/history.py > /tmp/history.out
|
||||
@@ -293,12 +311,6 @@ jobs:
|
||||
pnpm exec tsx safeguard.ts > /tmp/ts-safeguard.out
|
||||
diff truth_safeguard.txt /tmp/ts-safeguard.out
|
||||
|
||||
- name: Run session mount-mode scenarios and diff against truth
|
||||
working-directory: integ
|
||||
run: |
|
||||
pnpm exec tsx session_modes.ts > /tmp/ts-session-modes.out
|
||||
diff truth_session_modes.txt /tmp/ts-session-modes.out
|
||||
|
||||
- name: Run cross-mount commands (ram -> ram/s3 via MinIO)
|
||||
working-directory: integ
|
||||
run: pnpm exec tsx cross_commands.ts
|
||||
@@ -366,7 +378,7 @@ jobs:
|
||||
with:
|
||||
build-packages: "false"
|
||||
- name: Run declarative battery (python hosts)
|
||||
run: ./python/.venv/bin/python integ/runners/python/main.py
|
||||
run: ./python/.venv/bin/python integ/runners/python/main.py --facet core
|
||||
|
||||
integ-shared-ts:
|
||||
needs: changes
|
||||
@@ -412,7 +424,7 @@ jobs:
|
||||
- uses: ./.github/actions/integ-battery-setup
|
||||
- name: Run declarative battery (typescript hosts)
|
||||
working-directory: integ
|
||||
run: pnpm exec tsx runners/typescript/main.ts
|
||||
run: pnpm exec tsx runners/typescript/main.ts --facet core
|
||||
|
||||
# The two jobs above prove each language passes its own battery. This one
|
||||
# runs both emitters and diffs them case by case, so a change that breaks
|
||||
@@ -952,10 +964,225 @@ jobs:
|
||||
pnpm exec tsx runtime.ts 2>&1 \
|
||||
| bash check_lines.sh truth_runtime.txt
|
||||
|
||||
integ-observability:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.observability == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: python
|
||||
run: uv sync --all-extras --no-extra camel
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.32.1
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: typescript
|
||||
run: pnpm install --frozen-lockfile=false
|
||||
|
||||
- name: Build mirage-core
|
||||
working-directory: typescript
|
||||
run: pnpm --filter @struktoai/mirage-core build
|
||||
|
||||
- name: Build mirage-node
|
||||
working-directory: typescript
|
||||
run: pnpm --filter @struktoai/mirage-node build
|
||||
|
||||
# The typescript runner imports mirage-browser statically for the opfs
|
||||
# target, so its dist must exist even when no browser target runs.
|
||||
- name: Build mirage-browser
|
||||
working-directory: typescript
|
||||
run: pnpm --filter @struktoai/mirage-browser build
|
||||
|
||||
# Self-hosted Langfuse needs six containers with real startup ordering
|
||||
# (web runs migrations against postgres and clickhouse), so this uses
|
||||
# compose health gates rather than a flat `services:` block.
|
||||
- name: Start Langfuse stack
|
||||
working-directory: integ/server
|
||||
run: docker compose -f langfuse_compose.yml up -d --wait --wait-timeout 600
|
||||
|
||||
- name: Seed Langfuse
|
||||
run: ./python/.venv/bin/python integ/server/langfuse_seed.py --host http://localhost:3000
|
||||
|
||||
# Jaeger is a single container with a built-in OTLP receiver, so it is
|
||||
# seeded by pushing spans rather than through a fake server.
|
||||
- name: Start jaeger and seed traces over OTLP
|
||||
run: |
|
||||
docker run -d --name mirage-jaeger \
|
||||
-p 16686:16686 -p 4317:4317 -p 4318:4318 \
|
||||
jaegertracing/jaeger:latest
|
||||
./python/.venv/bin/python integ/server/jaeger_seed.py \
|
||||
--host http://localhost:16686 --otlp http://localhost:4318
|
||||
|
||||
- name: Run observability battery (python host)
|
||||
env:
|
||||
LANGFUSE_URL: http://localhost:3000
|
||||
JAEGER_URL: http://localhost:16686
|
||||
run: ./python/.venv/bin/python integ/runners/python/main.py --facet observability
|
||||
|
||||
- name: Run observability battery (typescript host)
|
||||
working-directory: integ
|
||||
env:
|
||||
LANGFUSE_URL: http://localhost:3000
|
||||
JAEGER_URL: http://localhost:16686
|
||||
run: pnpm exec tsx runners/typescript/main.ts --facet observability
|
||||
|
||||
- name: Jaeger logs on failure
|
||||
if: failure()
|
||||
run: docker logs mirage-jaeger --tail 100 || true
|
||||
|
||||
- name: Langfuse container logs on failure
|
||||
if: failure()
|
||||
working-directory: integ/server
|
||||
run: docker compose -f langfuse_compose.yml logs --tail 200
|
||||
|
||||
- name: Stop jaeger
|
||||
if: always()
|
||||
run: docker rm -f mirage-jaeger || true
|
||||
|
||||
- name: Stop Langfuse stack
|
||||
if: always()
|
||||
working-directory: integ/server
|
||||
run: docker compose -f langfuse_compose.yml down -v
|
||||
|
||||
# One job per backend family. A failure names the family, and each variant
|
||||
# starts only the fake servers that family needs rather than the whole fleet.
|
||||
integ-facets:
|
||||
needs: changes
|
||||
if: ${{ !cancelled() && (github.event_name != 'pull_request' || needs.changes.outputs.core == 'true' || needs.changes.outputs.ts == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
facet: [project, email, chat, dify, mem0]
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install Python dependencies
|
||||
working-directory: python
|
||||
run: uv sync --all-extras --no-extra camel
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.32.1
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: typescript
|
||||
run: pnpm install --frozen-lockfile=false
|
||||
|
||||
- name: Build mirage-core
|
||||
working-directory: typescript
|
||||
run: pnpm --filter @struktoai/mirage-core build
|
||||
|
||||
- name: Build mirage-node
|
||||
working-directory: typescript
|
||||
run: pnpm --filter @struktoai/mirage-node build
|
||||
|
||||
# The typescript runner imports mirage-browser statically for the opfs
|
||||
# target, so its dist must exist even when no browser target runs.
|
||||
- name: Build mirage-browser
|
||||
working-directory: typescript
|
||||
run: pnpm --filter @struktoai/mirage-browser build
|
||||
|
||||
- name: Start fake Trello and Linear APIs
|
||||
if: matrix.facet == 'project'
|
||||
run: |
|
||||
nohup ./python/.venv/bin/python integ/server/trello_server.py --port 5095 > /tmp/trello.log 2>&1 &
|
||||
nohup ./python/.venv/bin/python integ/server/linear_server.py --port 5094 > /tmp/linear.log 2>&1 &
|
||||
for f in trello linear; do
|
||||
for i in $(seq 1 30); do grep -q "ENDPOINT=" /tmp/$f.log && break; sleep 1; done
|
||||
cat /tmp/$f.log
|
||||
done
|
||||
echo "TRELLO_ENDPOINT=http://127.0.0.1:5095" >> "$GITHUB_ENV"
|
||||
echo "LINEAR_ENDPOINT=http://127.0.0.1:5094/graphql" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start GreenMail and the fake Google Workspace server
|
||||
if: matrix.facet == 'email'
|
||||
run: |
|
||||
docker run -d --name mirage-greenmail \
|
||||
-p 3025:3025 -p 3143:3143 -p 8080:8080 \
|
||||
-e GREENMAIL_OPTS="-Dgreenmail.setup.test.all -Dgreenmail.users=integ:secret@example.com -Dgreenmail.users.login=email -Dgreenmail.hostname=0.0.0.0" \
|
||||
greenmail/standalone:2.1.3
|
||||
cd integ && nohup pnpm exec tsx server/gws_server.ts --port 19999 > /tmp/gws.log 2>&1 &
|
||||
for i in $(seq 1 30); do grep -q "GWS_URL=" /tmp/gws.log && break; sleep 1; done
|
||||
cat /tmp/gws.log
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf -X POST http://localhost:8080/api/service/reset && break
|
||||
sleep 2
|
||||
done
|
||||
echo "EMAIL_HOST=localhost" >> "$GITHUB_ENV"
|
||||
echo "GWS_URL=http://127.0.0.1:19999" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start fake Slack Web API
|
||||
if: matrix.facet == 'chat'
|
||||
working-directory: integ
|
||||
env:
|
||||
SLACK_DB_URL: file:/tmp/mirage-slack-ci.db
|
||||
run: |
|
||||
./node_modules/.bin/prisma generate --schema prisma/schema.prisma
|
||||
nohup ./node_modules/.bin/tsx server/slack.ts --port 5097 > /tmp/slack.log 2>&1 &
|
||||
for i in $(seq 1 30); do grep -q "SLACK_URL=" /tmp/slack.log && break; sleep 1; done
|
||||
cat /tmp/slack.log
|
||||
echo "SLACK_URL=http://127.0.0.1:5097" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Start fake Dify API
|
||||
if: matrix.facet == 'dify'
|
||||
run: |
|
||||
nohup ./python/.venv/bin/python integ/server/dify_server.py --port 5093 > /tmp/dify.log 2>&1 &
|
||||
for i in $(seq 1 30); do grep -q "DIFY_ENDPOINT=" /tmp/dify.log && break; sleep 1; done
|
||||
cat /tmp/dify.log
|
||||
echo "DIFY_ENDPOINT=http://127.0.0.1:5093" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run ${{ matrix.facet }} battery (python host)
|
||||
run: ./python/.venv/bin/python integ/runners/python/main.py --facet ${{ matrix.facet }}
|
||||
|
||||
- name: Run ${{ matrix.facet }} battery (typescript host)
|
||||
working-directory: integ
|
||||
run: pnpm exec tsx runners/typescript/main.ts --facet ${{ matrix.facet }}
|
||||
|
||||
- name: Stop GreenMail
|
||||
if: always() && matrix.facet == 'email'
|
||||
run: docker rm -f mirage-greenmail || true
|
||||
|
||||
gate:
|
||||
name: integ-gate
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, integ, integ-ts, integ-shared-py, integ-shared-ts, integ-shared-parity, integ-database, integ-database-ts, integ-data, integ-fuse, runtime-py, runtime-ts]
|
||||
needs: [changes, integ, integ-ts, integ-shared-py, integ-shared-ts, integ-shared-parity, integ-database, integ-database-ts, integ-data, integ-fuse, integ-observability, integ-facets, runtime-py, runtime-ts]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check required jobs
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from mirage import Workspace
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.resource.gdrive import GoogleDriveConfig, GoogleDriveResource
|
||||
from mirage.resource.gmail import GmailConfig, GmailResource
|
||||
from mirage.resource.notion import NotionConfig, NotionResource
|
||||
from mirage.resource.s3 import S3Config, S3Resource
|
||||
from mirage.resource.slack import SlackConfig, SlackResource
|
||||
|
||||
load_dotenv(".env.development")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||
DATA_DIR = REPO_ROOT / "data"
|
||||
|
||||
tmp = tempfile.mkdtemp()
|
||||
shutil.copytree(DATA_DIR, Path(tmp) / "files", dirs_exist_ok=True)
|
||||
|
||||
google_kwargs = dict(
|
||||
client_id=os.environ["GOOGLE_CLIENT_ID"],
|
||||
client_secret=os.environ["GOOGLE_CLIENT_SECRET"],
|
||||
refresh_token=os.environ["GOOGLE_REFRESH_TOKEN"],
|
||||
)
|
||||
|
||||
notion = NotionResource(config=NotionConfig(
|
||||
api_key=os.environ["NOTION_API_KEY"]))
|
||||
gdrive = GoogleDriveResource(config=GoogleDriveConfig(**google_kwargs))
|
||||
gmail = GmailResource(config=GmailConfig(**google_kwargs))
|
||||
local = DiskResource(root=tmp + "/files")
|
||||
s3 = S3Resource(config=S3Config(
|
||||
bucket=os.environ["AWS_S3_BUCKET"],
|
||||
region=os.environ.get("AWS_DEFAULT_REGION", "us-east-1"),
|
||||
aws_access_key_id=os.environ["AWS_ACCESS_KEY_ID"],
|
||||
aws_secret_access_key=os.environ["AWS_SECRET_ACCESS_KEY"],
|
||||
))
|
||||
slack = SlackResource(config=SlackConfig(
|
||||
token=os.environ["SLACK_BOT_TOKEN"],
|
||||
search_token=os.environ.get("SLACK_USER_TOKEN"),
|
||||
))
|
||||
|
||||
with Workspace({
|
||||
"/notion/": notion,
|
||||
"/gdrive/": gdrive,
|
||||
"/gmail/": gmail,
|
||||
"/local/": local,
|
||||
"/s3/": s3,
|
||||
"/slack/": slack,
|
||||
}) as ws:
|
||||
# One FUSE mount over the workspace root exposes every backend as a
|
||||
# subdirectory of a single real filesystem path.
|
||||
mp = ws.add_fuse_mount("/")
|
||||
|
||||
print(f"=== FUSE MODE: mounted at {mp} ===\n")
|
||||
|
||||
print("--- os.listdir() workspace root ---")
|
||||
for e in sorted(os.listdir(mp)):
|
||||
print(f" {e}/")
|
||||
|
||||
print("\n--- /notion: os.listdir() pages ---")
|
||||
pages = os.listdir(f"{mp}/notion/pages")
|
||||
for p in pages[:5]:
|
||||
print(f" {p}")
|
||||
if pages:
|
||||
with open(f"{mp}/notion/pages/{pages[0]}/page.json") as f:
|
||||
data = json.loads(f.read())
|
||||
print(f" first page title: {data.get('title')}")
|
||||
|
||||
print("\n--- /gdrive: os.listdir() root ---")
|
||||
drive_entries = os.listdir(f"{mp}/gdrive")
|
||||
for e in drive_entries[:5]:
|
||||
print(f" {e}")
|
||||
for e in drive_entries:
|
||||
path = f"{mp}/gdrive/{e}"
|
||||
if os.path.isfile(path):
|
||||
with open(path, "rb") as f:
|
||||
head = f.read(1024)
|
||||
print(f" read {e}: {len(head)} bytes (first 1024)")
|
||||
break
|
||||
|
||||
print("\n--- /gmail: os.listdir() labels ---")
|
||||
labels = os.listdir(f"{mp}/gmail")
|
||||
for lb in labels[:5]:
|
||||
print(f" {lb}")
|
||||
inbox = f"{mp}/gmail/INBOX"
|
||||
if os.path.isdir(inbox):
|
||||
dates = os.listdir(inbox)
|
||||
if dates:
|
||||
messages = os.listdir(f"{inbox}/{dates[0]}")
|
||||
json_msgs = [m for m in messages if m.endswith(".gmail.json")]
|
||||
if json_msgs:
|
||||
with open(f"{inbox}/{dates[0]}/{json_msgs[0]}") as f:
|
||||
parsed = json.loads(f.read())
|
||||
print(f" latest INBOX message ({dates[0]}):")
|
||||
print(f" subject: {parsed.get('subject', 'N/A')}")
|
||||
print(f" from: {parsed.get('from', 'N/A')}")
|
||||
|
||||
print("\n--- /local: os.listdir() + sizes ---")
|
||||
for e in os.listdir(f"{mp}/local"):
|
||||
size = os.path.getsize(f"{mp}/local/{e}")
|
||||
print(f" {e:30s} {size:>10,} bytes")
|
||||
|
||||
print("\n--- /s3: open() + read 3 lines ---")
|
||||
with open(f"{mp}/s3/data/example.jsonl") as f:
|
||||
for i, line in enumerate(f):
|
||||
if i >= 3:
|
||||
break
|
||||
print(f" [{i}] {line.strip()[:100]}...")
|
||||
|
||||
print("\n--- /slack: os.listdir() channels ---")
|
||||
channels = os.listdir(f"{mp}/slack/channels")
|
||||
for ch in channels[:5]:
|
||||
print(f" {ch}")
|
||||
if channels:
|
||||
ch = next((c for c in channels if "general" in c), channels[0])
|
||||
for d in reversed(os.listdir(f"{mp}/slack/channels/{ch}")):
|
||||
path = f"{mp}/slack/channels/{ch}/{d}/chat.jsonl"
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
lines = []
|
||||
with open(path) as f:
|
||||
lines = [ln for ln in f.read().splitlines() if ln.strip()]
|
||||
texted = [
|
||||
m for m in map(json.loads, lines) if m.get("text", "").strip()
|
||||
]
|
||||
if texted:
|
||||
msg = texted[-1]
|
||||
print(f" latest message in {ch} ({d}):")
|
||||
print(f" [{msg.get('user', '?')}] "
|
||||
f"{msg.get('text', '')[:80]}")
|
||||
break
|
||||
|
||||
print(f"\n>>> FUSE mounted at: {mp}")
|
||||
print(">>> Open another terminal and run:")
|
||||
print(f">>> ls {mp}/")
|
||||
print(f">>> ls {mp}/notion/pages/")
|
||||
print(f">>> ls {mp}/gmail/INBOX/")
|
||||
print(f">>> cat {mp}/s3/data/example.jsonl | head -n 3")
|
||||
print(f">>> cat {mp}/local/example.json | jq .")
|
||||
print(f">>> ls {mp}/slack/channels/")
|
||||
print(">>> Press Enter to unmount and exit...")
|
||||
try:
|
||||
input()
|
||||
except EOFError:
|
||||
pass
|
||||
|
||||
records = ws.ops.records
|
||||
total = sum(r.bytes for r in records)
|
||||
print(f"\nStats: {len(records)} ops, {total} bytes transferred")
|
||||
@@ -0,0 +1,31 @@
|
||||
mode: WRITE
|
||||
consistency: LAZY
|
||||
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
fuse: /tmp/demo
|
||||
|
||||
/notion:
|
||||
resource: notion
|
||||
config:
|
||||
api_key: ${NOTION_API_KEY}
|
||||
|
||||
/local:
|
||||
resource: disk
|
||||
config:
|
||||
root: /tmp/mirage-local
|
||||
|
||||
/s3:
|
||||
resource: s3
|
||||
config:
|
||||
bucket: ${AWS_S3_BUCKET}
|
||||
region: ${AWS_DEFAULT_REGION}
|
||||
aws_access_key_id: ${AWS_ACCESS_KEY_ID}
|
||||
aws_secret_access_key: ${AWS_SECRET_ACCESS_KEY}
|
||||
|
||||
/slack:
|
||||
resource: slack
|
||||
config:
|
||||
token: ${SLACK_BOT_TOKEN}
|
||||
search_token: ${SLACK_USER_TOKEN}
|
||||
@@ -0,0 +1,117 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
GmailResource,
|
||||
Mount,
|
||||
MountMode,
|
||||
Workspace,
|
||||
type GmailConfig,
|
||||
} from "@struktoai/mirage-node";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
const __HERE = fileURLToPath(new URL(".", import.meta.url));
|
||||
dotenv.config({
|
||||
path: resolve(__HERE, "../../../.env.development"),
|
||||
override: true,
|
||||
});
|
||||
|
||||
function buildConfig(): GmailConfig {
|
||||
const clientId = process.env.GOOGLE_CLIENT_ID ?? "";
|
||||
const clientSecret = process.env.GOOGLE_CLIENT_SECRET ?? "";
|
||||
const refreshToken = process.env.GOOGLE_REFRESH_TOKEN ?? "";
|
||||
if (clientId === "" || clientSecret === "" || refreshToken === "") {
|
||||
throw new Error(
|
||||
"GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REFRESH_TOKEN are required",
|
||||
);
|
||||
}
|
||||
return { clientId, clientSecret, refreshToken };
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const resource = new GmailResource(buildConfig());
|
||||
const ws = new Workspace({
|
||||
"/gmail": new Mount(resource, { mode: MountMode.READ, fuse: true }),
|
||||
});
|
||||
await ws.fuseReady();
|
||||
const mp = ws.fuseMountpoint as string;
|
||||
try {
|
||||
console.log(`=== FUSE MODE: mounted at ${mp} ===\n`);
|
||||
|
||||
console.log("--- readdir() labels ---");
|
||||
const labels = await readdir(mp);
|
||||
for (const label of labels) console.log(` ${label}`);
|
||||
|
||||
if (labels.includes("INBOX")) {
|
||||
const inboxPath = `${mp}/INBOX`;
|
||||
console.log("\n--- readdir() INBOX (first 5 dates) ---");
|
||||
const dates = await readdir(inboxPath);
|
||||
for (const date of dates.slice(0, 5)) console.log(` ${date}`);
|
||||
|
||||
const firstDate = dates[0];
|
||||
if (firstDate !== undefined) {
|
||||
const datePath = `${inboxPath}/${firstDate}`;
|
||||
console.log(`\n--- readdir() ${firstDate} (first 5 messages) ---`);
|
||||
const messages = await readdir(datePath);
|
||||
for (const message of messages.slice(0, 5)) console.log(` ${message}`);
|
||||
|
||||
const firstMessage = messages.find((message) =>
|
||||
message.endsWith(".gmail.json"),
|
||||
);
|
||||
if (firstMessage !== undefined) {
|
||||
const messagePath = `${datePath}/${firstMessage}`;
|
||||
console.log("\n--- stat before read ---");
|
||||
console.log(` size: ${(await stat(messagePath)).size}`);
|
||||
|
||||
console.log(`--- readFile() ${firstMessage.slice(0, 60)} ---`);
|
||||
const content = await readFile(messagePath, "utf8");
|
||||
const message = JSON.parse(content) as {
|
||||
subject?: string;
|
||||
from?: string;
|
||||
};
|
||||
console.log(` subject: ${message.subject ?? "N/A"}`);
|
||||
console.log(` from: ${message.from ?? "N/A"}`);
|
||||
console.log(` rendered bytes: ${Buffer.byteLength(content)}`);
|
||||
|
||||
console.log("--- stat after read ---");
|
||||
console.log(` size: ${(await stat(messagePath)).size}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n>>> FUSE mounted at: ${mp}`);
|
||||
console.log(">>> Try in another terminal:");
|
||||
console.log(`>>> ls ${mp}/`);
|
||||
console.log(`>>> ls ${mp}/INBOX/`);
|
||||
console.log(">>> Press Enter to unmount and exit...");
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
await rl.question("");
|
||||
rl.close();
|
||||
} finally {
|
||||
await ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,756 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "ro_cat_missing",
|
||||
"seq": 620000,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "cat {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_stat_missing",
|
||||
"seq": 620001,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "stat {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "stat: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_ls_missing",
|
||||
"seq": 620002,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "ls {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 2,
|
||||
"stdout": "",
|
||||
"stderr": "ls: cannot access '{mount}/__nf_missing__': No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_grep_missing",
|
||||
"seq": 620003,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack"
|
||||
],
|
||||
"command": "grep x {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "grep: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_cat_dotfile",
|
||||
"seq": 620004,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "cat {mount}/.hidden",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: {mount}/.hidden: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_test_e_missing",
|
||||
"seq": 620005,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "test -e {mount}/__nf_missing__; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_write_refused",
|
||||
"seq": 620006,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "echo hi > {mount}/__nf_new__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "{mount}/__nf_new__: Operation not supported\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_mkdir_refused",
|
||||
"seq": 620007,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "mkdir {mount}/__nf_dir__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "mkdir: {mount}/__nf_dir__: Operation not supported\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_root_is_dir",
|
||||
"seq": 620008,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "test -d {mount}; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_head_missing",
|
||||
"seq": 620020,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "head {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "head: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_head_n_missing",
|
||||
"seq": 620021,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "head -n 1 {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "head: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_tail_missing",
|
||||
"seq": 620022,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "tail {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "tail: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_wc_missing",
|
||||
"seq": 620023,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "wc -l {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "wc: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_wc_c_missing",
|
||||
"seq": 620024,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "wc -c {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "wc: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_nl_missing",
|
||||
"seq": 620025,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "nl {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "nl: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_cut_missing",
|
||||
"seq": 620026,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "cut -c1-3 {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cut: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_sort_missing",
|
||||
"seq": 620027,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "sort {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "sort: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_uniq_missing",
|
||||
"seq": 620028,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "uniq {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "uniq: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_rev_missing",
|
||||
"seq": 620029,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "rev {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "rev: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_tac_missing",
|
||||
"seq": 620030,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "tac {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "tac: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_sed_missing",
|
||||
"seq": 620031,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "sed -n 1p {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "sed: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_awk_missing",
|
||||
"seq": 620032,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "awk '{print}' {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "awk: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_jq_missing",
|
||||
"seq": 620033,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "jq . {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "jq: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_md5sum_missing",
|
||||
"seq": 620034,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "md5sum {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "md5sum: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_sha256sum_missing",
|
||||
"seq": 620035,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "sha256sum {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "sha256sum: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_xxd_missing",
|
||||
"seq": 620036,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "xxd {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "xxd: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_base64_missing",
|
||||
"seq": 620037,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "base64 {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "base64: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_strings_missing",
|
||||
"seq": 620038,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "strings {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "strings: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_file_missing",
|
||||
"seq": 620039,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "file {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "file: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_rg_missing",
|
||||
"seq": 620041,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack"
|
||||
],
|
||||
"command": "rg x {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "rg: {mount}/__nf_missing__: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_realpath_missing",
|
||||
"seq": 620043,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "realpath -e {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "realpath: '{mount}/__nf_missing__': No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_touch_refused_missing",
|
||||
"seq": 620044,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "touch {mount}/__nf_new__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "touch: cannot touch '{mount}/__nf_new__': Read-only file system\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_rm_refused_missing",
|
||||
"seq": 620045,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "rm {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "rm: {mount}/__nf_missing__: Operation not supported\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_tee_refused_missing",
|
||||
"seq": 620046,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "echo x | tee {mount}/__nf_new__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "tee: {mount}/__nf_new__: Operation not supported\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_du_missing",
|
||||
"seq": 620047,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "du {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "du: cannot access '{mount}/__nf_missing__': No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ro_tree_missing",
|
||||
"seq": 620048,
|
||||
"targets": [
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"dify",
|
||||
"mem0",
|
||||
"trello",
|
||||
"linear",
|
||||
"slack",
|
||||
"gmail",
|
||||
"email"
|
||||
],
|
||||
"command": "tree {mount}/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 2,
|
||||
"stdout": "{mount}/__nf_missing__ [error opening dir]\n\n0 directories, 0 files\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "jg_root_ls",
|
||||
"seq": 610000,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "services\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_services_ls",
|
||||
"seq": 610001,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "checkout-api\njaeger\norders-api\nsearch-api\nweb-frontend\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_service_children_ls",
|
||||
"seq": 610002,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services/checkout-api",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "operations.json\ntraces\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_traces_ls",
|
||||
"seq": 610003,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services/checkout-api/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_search_traces_ls",
|
||||
"seq": 610004,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services/search-api/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_id",
|
||||
"seq": 610005,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.traceID' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_span_names",
|
||||
"seq": 610006,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[].operationName' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "POST /checkout\ncharge-card\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_span_count",
|
||||
"seq": 610007,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans | length' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_parent_ref",
|
||||
"seq": 610008,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"charge-card\") | .references[0].refType' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "CHILD_OF\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_process_service",
|
||||
"seq": 610009,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.processes.p1.serviceName' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "checkout-api\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_client_start_time",
|
||||
"seq": 610010,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"POST /checkout\") | .startTime' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1767225600000000\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_trace_http_tag",
|
||||
"seq": 610011,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"POST /checkout\") | .tags[] | select(.key==\"http.method\") | .value' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "POST\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_operations",
|
||||
"seq": 610012,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.[].name' /j/services/checkout-api/operations.json | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "POST /checkout\ncharge-card\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_find_trace_files",
|
||||
"seq": 610013,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "find /j/services/search-api -type f | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/j/services/search-api/operations.json\n/j/services/search-api/traces/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_tree_service",
|
||||
"seq": 610014,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "tree /j/services/search-api",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/j/services/search-api\n|-- operations.json\n`-- traces\n `-- bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2.json\n\n2 directories, 2 files\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_grep_operation",
|
||||
"seq": 610015,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "grep -c charge-card /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_unknown_service_ls",
|
||||
"seq": 610016,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 2,
|
||||
"stdout": "",
|
||||
"stderr": "ls: cannot access '/j/services/__nf_missing__': No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_malformed_trace_id_cat",
|
||||
"seq": 610017,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "cat /j/services/checkout-api/traces/zzz.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: /j/services/checkout-api/traces/zzz.json: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_unlisted_trace_stat",
|
||||
"seq": 610018,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "stat /j/services/checkout-api/traces/00000000000000000000000000000000.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "stat: /j/services/checkout-api/traces/00000000000000000000000000000000.json: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_foreign_service_cat",
|
||||
"seq": 610019,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "cat /j/services/search-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: /j/services/search-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_foreign_service_stat",
|
||||
"seq": 610020,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "stat /j/services/search-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "stat: /j/services/search-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json: No such file or directory\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "jg_dist_00",
|
||||
"seq": 612000,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services/web-frontend/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "ccccccccccccccccccccccccccccccc3.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_01",
|
||||
"seq": 612001,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "ls /j/services/orders-api/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "ccccccccccccccccccccccccccccccc3.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_02",
|
||||
"seq": 612002,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "cmp /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json /j/services/orders-api/traces/ccccccccccccccccccccccccccccccc3.json; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_03",
|
||||
"seq": 612003,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans | length' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "3\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_04",
|
||||
"seq": 612004,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[].operationName' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "GET /cart\nPOST /orders\ndb.query\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_05",
|
||||
"seq": 612005,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '[.processes[].serviceName] | sort | join(\",\")' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "orders-api,web-frontend\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_06",
|
||||
"seq": 612006,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '[.spans[] | select(.references | length > 0)] | length' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_07",
|
||||
"seq": 612007,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"db.query\") | .references[0].refType' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "CHILD_OF\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_08",
|
||||
"seq": 612008,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"POST /orders\") | .references[0].spanID' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "4444444444444444\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_09",
|
||||
"seq": 612009,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"db.query\") | .tags[] | select(.key==\"error\") | .value' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "true\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_10",
|
||||
"seq": 612010,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"db.query\") | .tags[] | select(.key==\"db.system\") | .value' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "postgresql\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_11",
|
||||
"seq": 612011,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "jq -r '.spans[] | select(.operationName==\"db.query\") | .duration' /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2000\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_dist_12",
|
||||
"seq": 612012,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "grep -c db.query /j/services/web-frontend/traces/ccccccccccccccccccccccccccccccc3.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "jg_read_00",
|
||||
"seq": 611000,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "wc -l /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "68 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_01",
|
||||
"seq": 611001,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "wc -c /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1530 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_02",
|
||||
"seq": 611002,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "wc -w /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "115 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_03",
|
||||
"seq": 611003,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "md5sum /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "83a65c2549691b99373a7c4642902269 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_04",
|
||||
"seq": 611004,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "sha256sum /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "94b55137aaf3fa9668c2a950ef50c64284fdc551dc1133fde7b5d3f4a209b6d4 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_05",
|
||||
"seq": 611005,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "head -n 3 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"traceID\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\",\n \"spans\": [\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_06",
|
||||
"seq": 611006,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "tail -n 2 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"warnings\": null\n}",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_07",
|
||||
"seq": 611007,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "head -c 24 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"traceID\": \"aaaaaaaa",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_08",
|
||||
"seq": 611008,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "xxd /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "00000000: 7b0a 2020 2274 7261 6365 4944 223a 2022 {. \"traceID\": \"\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_09",
|
||||
"seq": 611009,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "base64 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | wc -c",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2067\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_10",
|
||||
"seq": 611010,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "file /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json: json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_11",
|
||||
"seq": 611011,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "strings /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"traceID\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_12",
|
||||
"seq": 611012,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "nl /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " 1\t{\n 2\t \"traceID\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_13",
|
||||
"seq": 611013,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "sed -n 2p /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"traceID\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_14",
|
||||
"seq": 611014,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "cut -c1-6 /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"tra\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_15",
|
||||
"seq": 611015,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "tac /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "} \"warnings\": null\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_16",
|
||||
"seq": 611016,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "rev /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_17",
|
||||
"seq": 611017,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "cmp /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_18",
|
||||
"seq": 611018,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "grep -c traceID /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "4\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_19",
|
||||
"seq": 611019,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "grep -n operationName /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "7: \"operationName\": \"POST /checkout\",\n35: \"operationName\": \"charge-card\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_20",
|
||||
"seq": 611020,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "grep -o 'charge-card' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "charge-card\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_21",
|
||||
"seq": 611021,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "rg -c operationName /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_22",
|
||||
"seq": 611022,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "sort /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"key\": \"http.method\",\n \"key\": \"otel.scope.name\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_23",
|
||||
"seq": 611023,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "uniq /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json | head -n 2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"traceID\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_24",
|
||||
"seq": 611024,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "stat -c %F /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "regular file\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_25",
|
||||
"seq": 611025,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "realpath /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_26",
|
||||
"seq": 611026,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "test -f /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_27",
|
||||
"seq": 611027,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "test -s /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_28",
|
||||
"seq": 611028,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "du -sh /j/services/checkout-api/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0B\t/j/services/checkout-api/traces\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_29",
|
||||
"seq": 611029,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "tree /j/services/checkout-api",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/j/services/checkout-api\n|-- operations.json\n`-- traces\n `-- aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json\n\n2 directories, 2 files\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "jg_read_30",
|
||||
"seq": 611030,
|
||||
"targets": [
|
||||
"jaeger"
|
||||
],
|
||||
"command": "awk 'NR==2' /j/services/checkout-api/traces/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"traceID\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_dataset_item_ids",
|
||||
"seq": 600060,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.id' /lf/datasets/eval-basic/items.jsonl | sort",
|
||||
"expect": { "exit": 0, "stdout": "item-one\nitem-two\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_item_inputs",
|
||||
"seq": 600061,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.input.question' /lf/datasets/eval-basic/items.jsonl | sort",
|
||||
"expect": { "exit": 0, "stdout": "capital of france\ncapital of japan\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_item_expected_outputs",
|
||||
"seq": 600062,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.expectedOutput.answer' /lf/datasets/eval-basic/items.jsonl | sort",
|
||||
"expect": { "exit": 0, "stdout": "paris\ntokyo\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_item_status",
|
||||
"seq": 600063,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.status' /lf/datasets/eval-basic/items.jsonl | sort -u",
|
||||
"expect": { "exit": 0, "stdout": "ACTIVE\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_items_line_count",
|
||||
"seq": 600064,
|
||||
"targets": ["langfuse"],
|
||||
"command": "cat /lf/datasets/eval-basic/items.jsonl | wc -l",
|
||||
"expect": { "exit": 0, "stdout": "2\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_empty_dataset_items_cat",
|
||||
"seq": 600065,
|
||||
"targets": ["langfuse"],
|
||||
"command": "cat /lf/datasets/eval-empty/items.jsonl",
|
||||
"expect": { "exit": 0, "stdout": "", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_run_name",
|
||||
"seq": 600066,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.name' /lf/datasets/eval-basic/runs/run-alpha.jsonl",
|
||||
"expect": { "exit": 0, "stdout": "run-alpha\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_run_dataset_name",
|
||||
"seq": 600067,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.datasetName' /lf/datasets/eval-basic/runs/run-alpha.jsonl",
|
||||
"expect": { "exit": 0, "stdout": "eval-basic\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_run_metadata",
|
||||
"seq": 600068,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -c '.metadata' /lf/datasets/eval-basic/runs/run-alpha.jsonl",
|
||||
"expect": { "exit": 0, "stdout": "{}\n", "stderr": "" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_find_top_dirs",
|
||||
"seq": 600080,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "find /lf -maxdepth 1 -type d | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf\n/lf/datasets\n/lf/prompts\n/lf/sessions\n/lf/traces\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_find_trace_files",
|
||||
"seq": 600081,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "find /lf/traces -type f | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/traces/trace-alpha.json\n/lf/traces/trace-beta.json\n/lf/traces/trace-gamma.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_find_prompt_files",
|
||||
"seq": 600082,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "find /lf/prompts -type f | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/prompts/greeting/1.json\n/lf/prompts/greeting/2.json\n/lf/prompts/qa-template/1.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_find_name_glob",
|
||||
"seq": 600083,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "find /lf/traces -name 'trace-b*' | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/traces/trace-beta.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_find_jsonl",
|
||||
"seq": 600084,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "find /lf/datasets -type f -name '*.jsonl' | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/datasets/eval-basic/items.jsonl\n/lf/datasets/eval-basic/runs/run-alpha.jsonl\n/lf/datasets/eval-empty/items.jsonl\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_find_maxdepth_sessions",
|
||||
"seq": 600085,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "find /lf/sessions -maxdepth 1 -type d | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/sessions\n/lf/sessions/session-one\n/lf/sessions/session-two\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_tree_prompts",
|
||||
"seq": 600086,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "tree /lf/prompts",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/prompts\n|-- greeting\n| |-- 1.json\n| `-- 2.json\n`-- qa-template\n `-- 1.json\n\n3 directories, 3 files\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_root_ls",
|
||||
"seq": 600000,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf",
|
||||
"expect": { "exit": 0, "stdout": "datasets\nprompts\nsessions\ntraces\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_traces_ls",
|
||||
"seq": 600001,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "trace-alpha.json\ntrace-beta.json\ntrace-gamma.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_sessions_ls",
|
||||
"seq": 600002,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/sessions",
|
||||
"expect": { "exit": 0, "stdout": "session-one\nsession-two\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_session_one_ls",
|
||||
"seq": 600003,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/sessions/session-one",
|
||||
"expect": { "exit": 0, "stdout": "trace-alpha.json\ntrace-beta.json\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_session_two_ls",
|
||||
"seq": 600004,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/sessions/session-two",
|
||||
"expect": { "exit": 0, "stdout": "trace-gamma.json\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompts_ls",
|
||||
"seq": 600005,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/prompts",
|
||||
"expect": { "exit": 0, "stdout": "greeting\nqa-template\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_versions_ls",
|
||||
"seq": 600006,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/prompts/greeting",
|
||||
"expect": { "exit": 0, "stdout": "1.json\n2.json\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_single_version_ls",
|
||||
"seq": 600007,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/prompts/qa-template",
|
||||
"expect": { "exit": 0, "stdout": "1.json\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_datasets_ls",
|
||||
"seq": 600008,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/datasets",
|
||||
"expect": { "exit": 0, "stdout": "eval-basic\neval-empty\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_children_ls",
|
||||
"seq": 600009,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/datasets/eval-basic",
|
||||
"expect": { "exit": 0, "stdout": "items.jsonl\nruns\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_dataset_runs_ls",
|
||||
"seq": 600010,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/datasets/eval-basic/runs",
|
||||
"expect": { "exit": 0, "stdout": "run-alpha.jsonl\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_empty_dataset_children_ls",
|
||||
"seq": 600011,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/datasets/eval-empty",
|
||||
"expect": { "exit": 0, "stdout": "items.jsonl\nruns\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_empty_dataset_runs_ls",
|
||||
"seq": 600012,
|
||||
"targets": ["langfuse"],
|
||||
"command": "ls /lf/datasets/eval-empty/runs",
|
||||
"expect": { "exit": 0, "stdout": "", "stderr": "" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_nf_cat_trace",
|
||||
"seq": 600100,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "cat /lf/traces/__nf_missing__.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: /lf/traces/__nf_missing__.json: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_nf_stat_trace",
|
||||
"seq": 600101,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "stat /lf/traces/__nf_missing__.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "stat: /lf/traces/__nf_missing__.json: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_nf_ls_top_level",
|
||||
"seq": 600102,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "ls /lf/__nf_missing__",
|
||||
"expect": {
|
||||
"exit": 2,
|
||||
"stdout": "",
|
||||
"stderr": "ls: cannot access '/lf/__nf_missing__': No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_nf_grep_trace",
|
||||
"seq": 600103,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep x /lf/traces/__nf_missing__.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "grep: /lf/traces/__nf_missing__.json: No such file or directory\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_nf_cat_dotfile",
|
||||
"seq": 600104,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "cat /lf/traces/.hidden.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: /lf/traces/.hidden.json: No such file or directory\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_obs_00",
|
||||
"seq": 602000,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations | length' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_01",
|
||||
"seq": 602001,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations[].name' /lf/traces/trace-alpha.json | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "describe-order\nvalidate-cart\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_02",
|
||||
"seq": 602002,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations[].type' /lf/traces/trace-alpha.json | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "GENERATION\nSPAN\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_03",
|
||||
"seq": 602003,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations[] | select(.type==\"GENERATION\") | .model' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "gpt-4o-mini\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_04",
|
||||
"seq": 602004,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations[] | select(.name==\"describe-order\") | .parentObservationId' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "obs-span-checkout\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_05",
|
||||
"seq": 602005,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations[] | select(.name==\"validate-cart\") | .input.items' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_06",
|
||||
"seq": 602006,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations[] | select(.type==\"GENERATION\") | .output.content' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "two items, confirmed\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_07",
|
||||
"seq": 602007,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.scores | length' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_08",
|
||||
"seq": 602008,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.scores[0].name' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "helpfulness\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_09",
|
||||
"seq": 602009,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.scores[0].value' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0.75\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_10",
|
||||
"seq": 602010,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.scores[0].comment' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "clear summary\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_11",
|
||||
"seq": 602011,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.scores[0].dataType' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "NUMERIC\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_12",
|
||||
"seq": 602012,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.latency' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_13",
|
||||
"seq": 602013,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.observations | length' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_14",
|
||||
"seq": 602014,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.scores | length' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_obs_15",
|
||||
"seq": 602015,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep -c obs-span-checkout /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_prompt_v1_text",
|
||||
"seq": 600040,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.prompt' /lf/prompts/greeting/1.json",
|
||||
"expect": { "exit": 0, "stdout": "Hello {{name}}, welcome aboard.\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_v2_text",
|
||||
"seq": 600041,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.prompt' /lf/prompts/greeting/2.json",
|
||||
"expect": { "exit": 0, "stdout": "Hi {{name}}, glad you are here.\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_version_field",
|
||||
"seq": 600042,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.version' /lf/prompts/greeting/2.json",
|
||||
"expect": { "exit": 0, "stdout": "2\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_type_text",
|
||||
"seq": 600043,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.type' /lf/prompts/greeting/1.json",
|
||||
"expect": { "exit": 0, "stdout": "text\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_type_chat",
|
||||
"seq": 600044,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.type' /lf/prompts/qa-template/1.json",
|
||||
"expect": { "exit": 0, "stdout": "chat\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_labels",
|
||||
"seq": 600045,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.labels | join(\",\")' /lf/prompts/greeting/1.json",
|
||||
"expect": { "exit": 0, "stdout": "production\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_chat_system_message",
|
||||
"seq": 600046,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.prompt[0].content' /lf/prompts/qa-template/1.json",
|
||||
"expect": { "exit": 0, "stdout": "Answer briefly.\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_chat_user_role",
|
||||
"seq": 600047,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.prompt[1].role' /lf/prompts/qa-template/1.json",
|
||||
"expect": { "exit": 0, "stdout": "user\n", "stderr": "" }
|
||||
},
|
||||
{
|
||||
"id": "lf_prompt_name_field",
|
||||
"seq": 600048,
|
||||
"targets": ["langfuse"],
|
||||
"command": "jq -r '.name' /lf/prompts/qa-template/1.json",
|
||||
"expect": { "exit": 0, "stdout": "qa-template\n", "stderr": "" }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_read_00",
|
||||
"seq": 601000,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "wc -l /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "32 /lf/traces/trace-beta.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_01",
|
||||
"seq": 601001,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "head -n 2 /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"id\": \"trace-beta\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_02",
|
||||
"seq": 601002,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "tail -n 1 /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "}",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_03",
|
||||
"seq": 601003,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "sed -n 2p /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"id\": \"trace-beta\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_04",
|
||||
"seq": 601004,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "nl /lf/traces/trace-beta.json | head -n 2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " 1\t{\n 2\t \"id\": \"trace-beta\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_05",
|
||||
"seq": 601005,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep -c '\"id\"' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_06",
|
||||
"seq": 601006,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep -n item-one /lf/datasets/eval-basic/items.jsonl | cut -d: -f1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_07",
|
||||
"seq": 601007,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep -o search-query /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "search-query\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_08",
|
||||
"seq": 601008,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "rg -c userId /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_09",
|
||||
"seq": 601009,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "cut -c1-6 /lf/traces/trace-beta.json | head -n 2",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n \"id\"\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_10",
|
||||
"seq": 601010,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "awk 'NR==1' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "{\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_11",
|
||||
"seq": 601011,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "file /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/traces/trace-beta.json: json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_12",
|
||||
"seq": 601012,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "strings /lf/traces/trace-beta.json | head -n 1",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": " \"id\": \"trace-beta\",\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_13",
|
||||
"seq": 601013,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "stat -c %F /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "regular file\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_14",
|
||||
"seq": 601014,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "realpath /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/traces/trace-beta.json\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_15",
|
||||
"seq": 601015,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "test -f /lf/traces/trace-beta.json; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_16",
|
||||
"seq": 601016,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "test -s /lf/traces/trace-beta.json; echo $?",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_17",
|
||||
"seq": 601017,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "wc -c /lf/datasets/eval-empty/items.jsonl",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0 /lf/datasets/eval-empty/items.jsonl\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_18",
|
||||
"seq": 601018,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "md5sum /lf/datasets/eval-empty/items.jsonl",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "d41d8cd98f00b204e9800998ecf8427e /lf/datasets/eval-empty/items.jsonl\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_19",
|
||||
"seq": 601019,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "base64 /lf/datasets/eval-empty/items.jsonl",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_20",
|
||||
"seq": 601020,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "cat /lf/datasets/eval-empty/items.jsonl | wc -l",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_21",
|
||||
"seq": 601021,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "du -sh /lf/traces",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "0B\t/lf/traces\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_22",
|
||||
"seq": 601022,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "tree /lf/datasets/eval-empty",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/lf/datasets/eval-empty\n|-- items.jsonl\n`-- runs\n\n2 directories, 1 file\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_23",
|
||||
"seq": 601023,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.tags | sort | join(\",\")' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "search\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_24",
|
||||
"seq": 601024,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.name' /lf/traces/trace-beta.json | rev",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "yreuq-hcraes\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_read_25",
|
||||
"seq": 601025,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.name' /lf/traces/trace-alpha.json /lf/traces/trace-gamma.json | sort",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "checkout-flow\nsummarize-doc\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "lf_trace_name",
|
||||
"seq": 600020,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.name' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "checkout-flow\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_user_id",
|
||||
"seq": 600021,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.userId' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "user-bo\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_session_id",
|
||||
"seq": 600022,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.sessionId' /lf/traces/trace-gamma.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "session-two\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_tags",
|
||||
"seq": 600023,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.tags | join(\",\")' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "checkout,prod\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_input",
|
||||
"seq": 600024,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.input.cart' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "two items\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_output",
|
||||
"seq": 600025,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.output.status' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "confirmed\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_metadata",
|
||||
"seq": 600026,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.metadata.region' /lf/traces/trace-gamma.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "eu-west\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_client_timestamp",
|
||||
"seq": 600027,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.timestamp' /lf/traces/trace-beta.json | cut -c1-19",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "2026-01-01T00:05:00\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_project_id",
|
||||
"seq": 600028,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.projectId' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "mirage-integ-project\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_html_path",
|
||||
"seq": 600029,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.htmlPath' /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "/project/mirage-integ-project/traces/trace-alpha\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_whole_doc",
|
||||
"seq": 600030,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -c 'del(.createdAt, .updatedAt) | keys' /lf/traces/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "[\"bookmarked\",\"environment\",\"externalId\",\"htmlPath\",\"id\",\"input\",\"latency\",\"metadata\",\"name\",\"observations\",\"output\",\"projectId\",\"public\",\"release\",\"scores\",\"sessionId\",\"tags\",\"timestamp\",\"totalCost\",\"userId\",\"version\"]\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_via_session_path",
|
||||
"seq": 600031,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "jq -r '.name' /lf/sessions/session-one/trace-beta.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "search-query\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_grep_name",
|
||||
"seq": 600032,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep -c checkout-flow /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "lf_trace_grep_nomatch",
|
||||
"seq": 600033,
|
||||
"targets": [
|
||||
"langfuse"
|
||||
],
|
||||
"command": "grep __lf_absent__ /lf/traces/trace-alpha.json",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -69,6 +69,8 @@ from mirage.resource.gsheets.gsheets import GSheetsResource
|
||||
from mirage.resource.gslides.config import GSlidesConfig
|
||||
from mirage.resource.gslides.gslides import GSlidesResource
|
||||
from mirage.resource.hf_buckets import HfBucketsConfig, HfBucketsResource
|
||||
from mirage.resource.jaeger import JaegerConfig, JaegerResource
|
||||
from mirage.resource.langfuse import LangfuseConfig, LangfuseResource
|
||||
from mirage.resource.linear import LinearConfig, LinearResource
|
||||
from mirage.resource.mem0 import Mem0Config, Mem0Resource
|
||||
from mirage.resource.minio import MinIOConfig, MinIOResource
|
||||
@@ -988,6 +990,58 @@ def _clear_sharepoint_caches() -> None:
|
||||
sharepoint_resolver._drive_cache.clear()
|
||||
|
||||
|
||||
class JaegerService:
|
||||
"""Points jaeger mounts at a real jaeger all-in-one container.
|
||||
|
||||
The container is external and seeded over OTLP by
|
||||
integ/server/jaeger_seed.py, so trace ids and timestamps are fixed.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str) -> None:
|
||||
self.host = host
|
||||
|
||||
@classmethod
|
||||
async def create(cls) -> "JaegerService":
|
||||
return cls(os.environ["JAEGER_URL"])
|
||||
|
||||
def resource(self, mount: dict) -> JaegerResource:
|
||||
return JaegerResource(JaegerConfig(host=self.host))
|
||||
|
||||
async def teardown(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class LangfuseService:
|
||||
"""Points langfuse mounts at a real self-hosted Langfuse instance.
|
||||
|
||||
The stack (web + worker + postgres + clickhouse + redis + blob store) is
|
||||
external, brought up from integ/server/langfuse_compose.yml and seeded by
|
||||
integ/server/langfuse_seed.py, so the project keys are fixed constants.
|
||||
"""
|
||||
|
||||
def __init__(self, host: str, public_key: str, secret_key: str) -> None:
|
||||
self.host = host
|
||||
self.public_key = public_key
|
||||
self.secret_key = secret_key
|
||||
|
||||
@classmethod
|
||||
async def create(cls) -> "LangfuseService":
|
||||
return cls(
|
||||
os.environ["LANGFUSE_URL"],
|
||||
os.environ.get("LANGFUSE_PUBLIC_KEY", "pk-lf-mirage-integ"),
|
||||
os.environ.get("LANGFUSE_SECRET_KEY", "sk-lf-mirage-integ"),
|
||||
)
|
||||
|
||||
def resource(self, mount: dict) -> LangfuseResource:
|
||||
return LangfuseResource(
|
||||
LangfuseConfig(public_key=self.public_key,
|
||||
secret_key=self.secret_key,
|
||||
host=self.host))
|
||||
|
||||
async def teardown(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class SharePointService:
|
||||
|
||||
def __init__(self, server, runner) -> None:
|
||||
@@ -1023,7 +1077,8 @@ Service = (S3Service | OneDriveService | SharePointService | Mem0Service
|
||||
| SSHService
|
||||
| NextcloudService | GwsService | HfService | BoxService
|
||||
| DropboxService | GridFSService | SlackService | TrelloService
|
||||
| LinearService | DifyService | DatabricksVolumeService)
|
||||
| LinearService | DifyService | DatabricksVolumeService
|
||||
| LangfuseService | JaegerService)
|
||||
|
||||
|
||||
def build_ram(
|
||||
@@ -1135,6 +1190,20 @@ def build_linear(
|
||||
return service.resource(mount), _noop
|
||||
|
||||
|
||||
def build_jaeger(
|
||||
mount: dict, run_id: str, service: Service | None
|
||||
) -> tuple[object, Callable[[], Awaitable[None]]]:
|
||||
assert isinstance(service, JaegerService)
|
||||
return service.resource(mount), _noop
|
||||
|
||||
|
||||
def build_langfuse(
|
||||
mount: dict, run_id: str, service: Service | None
|
||||
) -> tuple[object, Callable[[], Awaitable[None]]]:
|
||||
assert isinstance(service, LangfuseService)
|
||||
return service.resource(mount), _noop
|
||||
|
||||
|
||||
def build_ssh(
|
||||
mount: dict, run_id: str, service: Service | None
|
||||
) -> tuple[object, Callable[[], Awaitable[None]]]:
|
||||
@@ -1244,6 +1313,8 @@ BUILDERS = {
|
||||
"slack": build_slack,
|
||||
"trello": build_trello,
|
||||
"linear": build_linear,
|
||||
"langfuse": build_langfuse,
|
||||
"jaeger": build_jaeger,
|
||||
"dify": build_dify,
|
||||
}
|
||||
|
||||
@@ -1285,6 +1356,10 @@ async def make_service(target: dict, run_id: str) -> "Service | None":
|
||||
return await LinearService.create()
|
||||
if target.get("service") == "dify":
|
||||
return await DifyService.create(target)
|
||||
if target.get("service") == "langfuse":
|
||||
return await LangfuseService.create()
|
||||
if target.get("service") == "jaeger":
|
||||
return await JaegerService.create()
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ from pathlib import Path
|
||||
|
||||
from mirage.types import FileStat, PathSpec
|
||||
|
||||
CASE_DIRS = ("unix", "bash", "crossmount", "runtime", "resources", "cli")
|
||||
CASE_DIRS = ("unix", "bash", "crossmount", "runtime", "resources", "cli",
|
||||
"session")
|
||||
|
||||
|
||||
def integ_root() -> Path:
|
||||
@@ -94,6 +95,34 @@ def provision_line(result) -> str:
|
||||
f"hits={result.cache_hits} precision={result.precision.value}")
|
||||
|
||||
|
||||
def bind_mount(case: dict, mount_path: str) -> dict:
|
||||
"""Substitute {mount} in a case with a target's primary mount path.
|
||||
|
||||
Lets one case assert a behavior that every backend shares while each target
|
||||
keeps its own mount path. Cases without the token are returned untouched,
|
||||
so this is inert for the existing suite.
|
||||
|
||||
Args:
|
||||
case (dict): case as loaded from disk.
|
||||
mount_path (str): the target's primary mount path.
|
||||
|
||||
Returns:
|
||||
dict: the case with {mount} replaced in command and expectations.
|
||||
"""
|
||||
if "{mount}" not in json.dumps(case):
|
||||
return case
|
||||
bound = dict(case)
|
||||
prefix = mount_path.rstrip("/")
|
||||
if "command" in bound:
|
||||
bound["command"] = bound["command"].replace("{mount}", prefix)
|
||||
expect = dict(bound["expect"])
|
||||
for name in ("stdout", "stderr"):
|
||||
if isinstance(expect.get(name), str):
|
||||
expect[name] = expect[name].replace("{mount}", prefix)
|
||||
bound["expect"] = expect
|
||||
return bound
|
||||
|
||||
|
||||
async def run_case(ws, case: dict) -> tuple[int, str, str, float]:
|
||||
if case.get("clear_cache"):
|
||||
# A full clear means the file cache AND every mount's index cache:
|
||||
@@ -109,7 +138,7 @@ async def run_case(ws, case: dict) -> tuple[int, str, str, float]:
|
||||
if case.get("provision"):
|
||||
plan = await ws.execute(case["command"], provision=True)
|
||||
return 0, provision_line(plan) + "\n", "", time.monotonic() - start
|
||||
result = await ws.execute(case["command"])
|
||||
result = await ws.execute(case["command"], session_id=case.get("session"))
|
||||
elapsed = time.monotonic() - start
|
||||
out = await result.stdout_str()
|
||||
err = await result.stderr_str()
|
||||
|
||||
@@ -68,11 +68,18 @@ async def run_target(target: dict, cases: list[dict], root: Path,
|
||||
for mount in target["mounts"]:
|
||||
await harness.seed_fixture(ws, mount.get("fixture"), mount["path"],
|
||||
root)
|
||||
# Sessions a case can name via its "session" field. Mount grants take
|
||||
# either the mapping form ({"/data": "read"}) or the list form
|
||||
# (["/data"], which inherits the mount's own mode).
|
||||
for session_id, mounts in (target.get("sessions") or {}).items():
|
||||
ws.create_session(session_id, mounts=mounts)
|
||||
primary = target["mounts"][0]["path"]
|
||||
for case in selected:
|
||||
if "consistency" in case:
|
||||
continue
|
||||
exit_code, out, err, elapsed = await harness.run_case(ws, case)
|
||||
_emit_or_record(emit, report, target["id"], case, exit_code, out,
|
||||
bound = harness.bind_mount(case, primary)
|
||||
exit_code, out, err, elapsed = await harness.run_case(ws, bound)
|
||||
_emit_or_record(emit, report, target["id"], bound, exit_code, out,
|
||||
err, elapsed)
|
||||
finally:
|
||||
await cleanup()
|
||||
@@ -84,6 +91,7 @@ async def run_target(target: dict, cases: list[dict], root: Path,
|
||||
async def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--target", action="append", dest="targets")
|
||||
parser.add_argument("--facet", dest="facet")
|
||||
parser.add_argument("--emit", dest="emit")
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -91,7 +99,18 @@ async def main() -> None:
|
||||
manifest = harness.load_targets(root)
|
||||
cases = harness.load_cases(root)
|
||||
|
||||
selected = args.targets or list(manifest)
|
||||
# Targets are grouped into facets so CI can run one backend family per job;
|
||||
# a target with no facet belongs to "core", which the shared battery runs.
|
||||
if args.facet:
|
||||
selected = [
|
||||
tid for tid, t in manifest.items()
|
||||
if (t.get("facet") or "core") == args.facet
|
||||
]
|
||||
if not selected:
|
||||
print(f"no targets in facet {args.facet!r}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
else:
|
||||
selected = args.targets or list(manifest)
|
||||
report = None if args.emit else harness.Report()
|
||||
emit: list[dict] | None = [] if args.emit else None
|
||||
for target_id in selected:
|
||||
@@ -122,6 +141,14 @@ async def main() -> None:
|
||||
and not os.environ.get("SLACK_URL")):
|
||||
print(f"skip [{target_id}]: SLACK_URL not set", file=sys.stderr)
|
||||
continue
|
||||
if (target.get("service") == "jaeger"
|
||||
and not os.environ.get("JAEGER_URL")):
|
||||
print(f"skip [{target_id}]: JAEGER_URL not set", file=sys.stderr)
|
||||
continue
|
||||
if (target.get("service") == "langfuse"
|
||||
and not os.environ.get("LANGFUSE_URL")):
|
||||
print(f"skip [{target_id}]: LANGFUSE_URL not set", file=sys.stderr)
|
||||
continue
|
||||
await run_target(target, cases, root, report, emit)
|
||||
|
||||
if args.emit:
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
GSheetsResource,
|
||||
GSlidesResource,
|
||||
HfBucketsResource,
|
||||
JaegerResource,
|
||||
LangfuseResource,
|
||||
LinearResource,
|
||||
MinIOResource,
|
||||
Mem0Resource,
|
||||
@@ -1011,6 +1013,45 @@ async function openLinear(target: Target): Promise<Open> {
|
||||
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
|
||||
}
|
||||
|
||||
// The jaeger stack is a real jaeger all-in-one container, seeded over OTLP by
|
||||
// integ/server/jaeger_seed.py so trace ids and timestamps are fixed.
|
||||
async function openJaeger(target: Target): Promise<Open> {
|
||||
const host = process.env.JAEGER_URL
|
||||
if (!host) throw new Error('jaeger target requires JAEGER_URL')
|
||||
const mounts: Record<string, JaegerResource | RAMResource> = {}
|
||||
for (const m of target.mounts) {
|
||||
if (m.resource === 'ram') {
|
||||
mounts[m.path] = new RAMResource()
|
||||
continue
|
||||
}
|
||||
mounts[m.path] = new JaegerResource({ host })
|
||||
}
|
||||
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
|
||||
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
|
||||
}
|
||||
|
||||
// The langfuse stack is a real self-hosted Langfuse (integ/server/
|
||||
// langfuse_compose.yml), seeded by integ/server/langfuse_seed.py. The project
|
||||
// keys come from LANGFUSE_INIT_* headless initialization, so they are fixed.
|
||||
async function openLangfuse(target: Target): Promise<Open> {
|
||||
const host = process.env.LANGFUSE_URL
|
||||
if (!host) throw new Error('langfuse target requires LANGFUSE_URL')
|
||||
const mounts: Record<string, LangfuseResource | RAMResource> = {}
|
||||
for (const m of target.mounts) {
|
||||
if (m.resource === 'ram') {
|
||||
mounts[m.path] = new RAMResource()
|
||||
continue
|
||||
}
|
||||
mounts[m.path] = new LangfuseResource({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY ?? 'pk-lf-mirage-integ',
|
||||
secretKey: process.env.LANGFUSE_SECRET_KEY ?? 'sk-lf-mirage-integ',
|
||||
host,
|
||||
})
|
||||
}
|
||||
const ws = new Workspace(mounts, { mode: MountMode.WRITE })
|
||||
return { ws: ws as unknown as ExecWorkspace, cleanup: () => ws.close() }
|
||||
}
|
||||
|
||||
export const ADAPTERS: Record<string, (target: Target) => Promise<Open>> = {
|
||||
ram: openRam,
|
||||
disk: openDisk,
|
||||
@@ -1037,6 +1078,8 @@ export const ADAPTERS: Record<string, (target: Target) => Promise<Open>> = {
|
||||
slack: openSlack,
|
||||
trello: openTrello,
|
||||
linear: openLinear,
|
||||
langfuse: openLangfuse,
|
||||
jaeger: openJaeger,
|
||||
dify: openDify,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const CASE_DIRS = ['unix', 'bash', 'crossmount', 'runtime', 'resources', 'cli']
|
||||
const CASE_DIRS = ['unix', 'bash', 'crossmount', 'runtime', 'resources', 'cli', 'session']
|
||||
const ENC = new TextEncoder()
|
||||
const DEC = new TextDecoder()
|
||||
|
||||
@@ -46,7 +46,12 @@ export interface Target {
|
||||
mail?: string
|
||||
dataset?: string
|
||||
agentId?: string
|
||||
facet?: string
|
||||
mounts: Mount[]
|
||||
// Sessions a case can name via its `session` field. Grants take either the
|
||||
// mapping form ({ '/data': 'read' }) or the list form (['/data'], which
|
||||
// inherits the mount's own mode).
|
||||
sessions?: Record<string, Record<string, string> | string[]>
|
||||
}
|
||||
|
||||
export interface Expect {
|
||||
@@ -71,6 +76,7 @@ export interface Case {
|
||||
provision?: boolean
|
||||
clear_cache?: boolean
|
||||
consistency?: 'always' | 'lazy'
|
||||
session?: string
|
||||
scenario?: ScenarioStep[]
|
||||
expect: Expect
|
||||
_source?: string
|
||||
@@ -107,10 +113,11 @@ export interface HarnessStat {
|
||||
}
|
||||
|
||||
export interface ExecWorkspace {
|
||||
execute(cmd: string, opts?: { stdin?: Uint8Array }): Promise<ExecResult>
|
||||
execute(cmd: string, opts?: { stdin?: Uint8Array; sessionId?: string }): Promise<ExecResult>
|
||||
dispatch(opName: string, path: string): Promise<unknown>
|
||||
cache: { clear(): Promise<void> }
|
||||
mounts(): readonly { resource: { index?: { clear(): Promise<void> } } }[]
|
||||
createSession(sessionId: string, options: { mounts: Record<string, string> | string[] }): unknown
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -229,6 +236,31 @@ function provisionLine(r: ProvisionInfo): string {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitute {mount} in a case with a target's primary mount path.
|
||||
*
|
||||
* Lets one case assert a behavior that every backend shares while each target
|
||||
* keeps its own mount path. Cases without the token are returned untouched, so
|
||||
* this is inert for the existing suite.
|
||||
*/
|
||||
export function bindMount(c: Case, mountPath: string): Case {
|
||||
const prefix = mountPath.replace(/\/+$/, '')
|
||||
const hasToken =
|
||||
c.command?.includes('{mount}') === true ||
|
||||
c.expect.stdout.includes('{mount}') ||
|
||||
c.expect.stderr.includes('{mount}')
|
||||
if (!hasToken) return c
|
||||
return {
|
||||
...c,
|
||||
...(c.command !== undefined ? { command: c.command.split('{mount}').join(prefix) } : {}),
|
||||
expect: {
|
||||
...c.expect,
|
||||
stdout: c.expect.stdout.split('{mount}').join(prefix),
|
||||
stderr: c.expect.stderr.split('{mount}').join(prefix),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function runCase(
|
||||
ws: ExecWorkspace,
|
||||
c: Case,
|
||||
@@ -251,7 +283,7 @@ export async function runCase(
|
||||
elapsed: (performance.now() - start) / 1000,
|
||||
}
|
||||
}
|
||||
const result = await ws.execute(c.command)
|
||||
const result = await ws.execute(c.command, { sessionId: c.session })
|
||||
const elapsed = (performance.now() - start) / 1000
|
||||
let out = DEC.decode(result.stdout)
|
||||
if (c.check !== undefined) out = await statCheck(ws, c.check)
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
integRoot,
|
||||
loadCases,
|
||||
loadTargets,
|
||||
bindMount,
|
||||
runCase,
|
||||
runScenario,
|
||||
seedFixture,
|
||||
@@ -37,15 +38,17 @@ interface EmitRow {
|
||||
stderr: string
|
||||
}
|
||||
|
||||
function parseArgs(): { targets: string[]; emit: string | undefined } {
|
||||
function parseArgs(): { targets: string[]; emit: string | undefined; facet: string | undefined } {
|
||||
const targets: string[] = []
|
||||
let emit: string | undefined
|
||||
let facet: string | undefined
|
||||
const argv = process.argv.slice(2)
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
if (argv[i] === '--target' && i + 1 < argv.length) targets.push(argv[++i])
|
||||
else if (argv[i] === '--facet' && i + 1 < argv.length) facet = argv[++i]
|
||||
else if (argv[i] === '--emit' && i + 1 < argv.length) emit = argv[++i]
|
||||
}
|
||||
return { targets, emit }
|
||||
return { targets, emit, facet }
|
||||
}
|
||||
|
||||
async function runTarget(
|
||||
@@ -58,14 +61,21 @@ async function runTarget(
|
||||
const { ws, cleanup } = await ADAPTERS[target.mounts[0].resource](target)
|
||||
try {
|
||||
for (const mount of target.mounts) await seedFixture(ws, mount.fixture, mount.path, root)
|
||||
// Sessions a case can name via its `session` field. Mount grants take
|
||||
// either the mapping form ({ '/data': 'read' }) or the list form
|
||||
// (['/data'], which inherits the mount's own mode).
|
||||
for (const [sessionId, mounts] of Object.entries(target.sessions ?? {})) {
|
||||
ws.createSession(sessionId, { mounts })
|
||||
}
|
||||
for (const c of cases) {
|
||||
if (!c.targets.includes(target.id)) continue
|
||||
if (c.consistency !== undefined) continue
|
||||
const { exitCode, out, err, elapsed } = await runCase(ws, c)
|
||||
const bound = bindMount(c, target.mounts[0].path)
|
||||
const { exitCode, out, err, elapsed } = await runCase(ws, bound)
|
||||
if (emit !== null) {
|
||||
emit.push({ target: target.id, id: c.id, exit: exitCode, stdout: out, stderr: err })
|
||||
emit.push({ target: target.id, id: bound.id, exit: exitCode, stdout: out, stderr: err })
|
||||
} else if (report !== null) {
|
||||
report.record(target.id, c.id, compare(c, exitCode, out, err, elapsed))
|
||||
report.record(target.id, bound.id, compare(bound, exitCode, out, err, elapsed))
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -98,8 +108,21 @@ async function main(): Promise<void> {
|
||||
const manifest = loadTargets(root)
|
||||
const cases = loadCases(root)
|
||||
|
||||
const { targets, emit: emitPath } = parseArgs()
|
||||
const ids = targets.length ? targets : [...manifest.keys()]
|
||||
const { targets, emit: emitPath, facet } = parseArgs()
|
||||
// Targets are grouped into facets so CI can run one backend family per job; a
|
||||
// target with no facet belongs to "core", which the shared battery runs.
|
||||
let ids: string[]
|
||||
if (facet !== undefined) {
|
||||
ids = [...manifest.entries()]
|
||||
.filter(([, t]) => (t.facet ?? 'core') === facet)
|
||||
.map(([id]) => id)
|
||||
if (ids.length === 0) {
|
||||
process.stderr.write(`no targets in facet '${facet}'\n`)
|
||||
process.exit(2)
|
||||
}
|
||||
} else {
|
||||
ids = targets.length ? targets : [...manifest.keys()]
|
||||
}
|
||||
const report = emitPath ? null : new Report()
|
||||
const emit: EmitRow[] | null = emitPath ? [] : null
|
||||
for (const id of ids) {
|
||||
@@ -161,6 +184,14 @@ async function main(): Promise<void> {
|
||||
process.stderr.write(`skip [${id}]: LINEAR_ENDPOINT not set\n`)
|
||||
continue
|
||||
}
|
||||
if (target.service === 'jaeger' && !process.env.JAEGER_URL) {
|
||||
process.stderr.write(`skip [${id}]: JAEGER_URL not set\n`)
|
||||
continue
|
||||
}
|
||||
if (target.service === 'langfuse' && !process.env.LANGFUSE_URL) {
|
||||
process.stderr.write(`skip [${id}]: LANGFUSE_URL not set\n`)
|
||||
continue
|
||||
}
|
||||
await runTarget(target, cases, root, report, emit)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
# 2026-01-01T00:00:00Z in unix nanoseconds. Spans carry client-chosen ids and
|
||||
# timestamps, so every name and field the battery asserts on is fixed.
|
||||
T0 = 1767225600000000000
|
||||
|
||||
TRACE_CHECKOUT = "a" * 31 + "1"
|
||||
TRACE_SEARCH = "b" * 31 + "2"
|
||||
# A distributed trace: three levels across two services, with an error leaf.
|
||||
# The same trace id is therefore reachable under either service's directory.
|
||||
TRACE_ORDER = "c" * 31 + "3"
|
||||
|
||||
POLL_ATTEMPTS = 60
|
||||
POLL_DELAY = 1.0
|
||||
|
||||
|
||||
def attr(key: str, value: str) -> dict:
|
||||
"""Build an OTLP string attribute.
|
||||
|
||||
Args:
|
||||
key (str): attribute key.
|
||||
value (str): attribute value.
|
||||
|
||||
Returns:
|
||||
dict: OTLP KeyValue.
|
||||
"""
|
||||
return {"key": key, "value": {"stringValue": value}}
|
||||
|
||||
|
||||
def span(trace: str,
|
||||
span_id: str,
|
||||
name: str,
|
||||
start: int,
|
||||
duration: int,
|
||||
parent: str | None = None,
|
||||
attrs: list[dict] | None = None,
|
||||
status_code: int = 0) -> dict:
|
||||
"""Build one OTLP span.
|
||||
|
||||
Args:
|
||||
trace (str): 32 hex digit trace id.
|
||||
span_id (str): 16 hex digit span id.
|
||||
name (str): operation name.
|
||||
start (int): start time in unix nanoseconds.
|
||||
duration (int): span duration in nanoseconds.
|
||||
parent (str | None): parent span id for a child span.
|
||||
attrs (list[dict] | None): OTLP attributes.
|
||||
|
||||
Returns:
|
||||
dict: OTLP span.
|
||||
"""
|
||||
out = {
|
||||
"traceId": trace,
|
||||
"spanId": span_id,
|
||||
"name": name,
|
||||
"kind": "SPAN_KIND_SERVER",
|
||||
"startTimeUnixNano": str(start),
|
||||
"endTimeUnixNano": str(start + duration),
|
||||
"attributes": attrs or [],
|
||||
"status": {
|
||||
"code": status_code
|
||||
},
|
||||
}
|
||||
if parent is not None:
|
||||
out["parentSpanId"] = parent
|
||||
return out
|
||||
|
||||
|
||||
PAYLOAD = {
|
||||
"resourceSpans": [
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [attr("service.name", "checkout-api")]
|
||||
},
|
||||
"scopeSpans": [{
|
||||
"scope": {
|
||||
"name": "mirage-integ"
|
||||
},
|
||||
"spans": [
|
||||
span(TRACE_CHECKOUT,
|
||||
"1" * 16,
|
||||
"POST /checkout",
|
||||
T0,
|
||||
5_000_000,
|
||||
attrs=[attr("http.method", "POST")]),
|
||||
span(TRACE_CHECKOUT,
|
||||
"2" * 16,
|
||||
"charge-card",
|
||||
T0 + 1_000_000,
|
||||
2_000_000,
|
||||
parent="1" * 16),
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [attr("service.name", "web-frontend")]
|
||||
},
|
||||
"scopeSpans": [{
|
||||
"scope": {
|
||||
"name": "mirage-integ"
|
||||
},
|
||||
"spans": [
|
||||
span(TRACE_ORDER, "4" * 16, "GET /cart",
|
||||
T0 + 120_000_000_000, 9_000_000),
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [attr("service.name", "orders-api")]
|
||||
},
|
||||
"scopeSpans": [{
|
||||
"scope": {
|
||||
"name": "mirage-integ"
|
||||
},
|
||||
"spans": [
|
||||
span(TRACE_ORDER,
|
||||
"5" * 16,
|
||||
"POST /orders",
|
||||
T0 + 120_001_000_000,
|
||||
6_000_000,
|
||||
parent="4" * 16),
|
||||
span(TRACE_ORDER,
|
||||
"6" * 16,
|
||||
"db.query",
|
||||
T0 + 120_002_000_000,
|
||||
2_000_000,
|
||||
parent="5" * 16,
|
||||
attrs=[attr("db.system", "postgresql")],
|
||||
status_code=2),
|
||||
],
|
||||
}],
|
||||
},
|
||||
{
|
||||
"resource": {
|
||||
"attributes": [attr("service.name", "search-api")]
|
||||
},
|
||||
"scopeSpans": [{
|
||||
"scope": {
|
||||
"name": "mirage-integ"
|
||||
},
|
||||
"spans": [
|
||||
span(TRACE_SEARCH, "3" * 16, "GET /search",
|
||||
T0 + 60_000_000_000, 3_000_000),
|
||||
],
|
||||
}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def post_spans(host: str) -> None:
|
||||
"""Push the fixture spans over OTLP/HTTP.
|
||||
|
||||
Args:
|
||||
host (str): OTLP HTTP endpoint, e.g. http://localhost:4318.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the collector rejected the batch.
|
||||
"""
|
||||
request = urllib.request.Request(
|
||||
f"{host.rstrip('/')}/v1/traces",
|
||||
data=json.dumps(PAYLOAD).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
with urllib.request.urlopen(request) as response:
|
||||
if response.status != 200:
|
||||
raise RuntimeError(f"OTLP push failed: HTTP {response.status}")
|
||||
|
||||
|
||||
def query(query_host: str, path: str) -> dict:
|
||||
"""Call the Jaeger query API.
|
||||
|
||||
Args:
|
||||
query_host (str): query API base URL.
|
||||
path (str): API path beginning with a slash.
|
||||
|
||||
Returns:
|
||||
dict: decoded JSON body, or an empty dict when unreachable.
|
||||
"""
|
||||
try:
|
||||
with urllib.request.urlopen(f"{query_host.rstrip('/')}{path}") as resp:
|
||||
body = json.loads(resp.read().decode())
|
||||
return body if isinstance(body, dict) else {}
|
||||
except (urllib.error.URLError, ValueError) as exc:
|
||||
print(f"query not ready: {exc}", file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
async def wait_for_services(query_host: str) -> None:
|
||||
"""Poll until both seeded services are queryable.
|
||||
|
||||
Jaeger's in-memory store indexes asynchronously, so the services are not
|
||||
listed the moment the OTLP push returns.
|
||||
|
||||
Args:
|
||||
query_host (str): query API base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the services never appeared.
|
||||
"""
|
||||
wanted = {"checkout-api", "search-api", "web-frontend", "orders-api"}
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
data = query(query_host, "/api/services").get("data")
|
||||
if isinstance(data, list) and wanted <= {str(s) for s in data}:
|
||||
return
|
||||
await asyncio.sleep(POLL_DELAY)
|
||||
raise RuntimeError("seeded services never became queryable")
|
||||
|
||||
|
||||
async def seed(query_host: str, otlp_host: str) -> None:
|
||||
"""Bring a fresh jaeger container to the state the battery expects.
|
||||
|
||||
Args:
|
||||
query_host (str): query API base URL.
|
||||
otlp_host (str): OTLP HTTP base URL.
|
||||
"""
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
if query(query_host, "/api/services"):
|
||||
break
|
||||
await asyncio.sleep(POLL_DELAY)
|
||||
post_spans(otlp_host)
|
||||
await wait_for_services(query_host)
|
||||
print(f"JAEGER_URL={query_host}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse arguments and seed the configured jaeger instance."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="http://localhost:16686")
|
||||
parser.add_argument("--otlp", default="http://localhost:4318")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(seed(args.host, args.otlp))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
# Real Langfuse v3 stack for the integ battery. Six containers because that
|
||||
# is what Langfuse self-hosting requires: web + async worker in front of
|
||||
# Postgres (OLTP), ClickHouse (OLAP, where traces land), Redis (ingestion
|
||||
# queue) and an S3-compatible blob store (raw event upload).
|
||||
#
|
||||
# Keys and ids are pinned through LANGFUSE_INIT_* headless initialization so
|
||||
# the integ adapters can authenticate with constants and so `htmlPath` in API
|
||||
# responses carries a stable project id.
|
||||
services:
|
||||
langfuse-postgres:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_USER: langfuse
|
||||
POSTGRES_PASSWORD: langfuse
|
||||
POSTGRES_DB: langfuse
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U langfuse"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
langfuse-clickhouse:
|
||||
image: clickhouse/clickhouse-server:24.12
|
||||
environment:
|
||||
CLICKHOUSE_DB: default
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8123/ping || exit 1"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 40
|
||||
|
||||
langfuse-redis:
|
||||
image: redis:7
|
||||
command: --requirepass langfuse
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "langfuse", "ping"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
langfuse-minio:
|
||||
image: minio/minio:latest
|
||||
command: server /data
|
||||
environment:
|
||||
MINIO_ROOT_USER: minio
|
||||
MINIO_ROOT_PASSWORD: minio123
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mc ready local || exit 1"]
|
||||
interval: 3s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
|
||||
langfuse-minio-init:
|
||||
image: minio/mc:latest
|
||||
depends_on:
|
||||
langfuse-minio:
|
||||
condition: service_healthy
|
||||
entrypoint: >-
|
||||
/bin/sh -c "
|
||||
mc alias set lf http://langfuse-minio:9000 minio minio123 &&
|
||||
mc mb --ignore-existing lf/langfuse &&
|
||||
echo minio-init-done"
|
||||
|
||||
langfuse-worker:
|
||||
image: langfuse/langfuse-worker:3
|
||||
depends_on:
|
||||
langfuse-postgres:
|
||||
condition: service_healthy
|
||||
langfuse-clickhouse:
|
||||
condition: service_healthy
|
||||
langfuse-redis:
|
||||
condition: service_healthy
|
||||
langfuse-minio-init:
|
||||
condition: service_completed_successfully
|
||||
environment: &langfuse-env
|
||||
DATABASE_URL: postgresql://langfuse:langfuse@langfuse-postgres:5432/langfuse
|
||||
SALT: mirage-integ-salt
|
||||
ENCRYPTION_KEY: "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
CLICKHOUSE_URL: http://langfuse-clickhouse:8123
|
||||
CLICKHOUSE_MIGRATION_URL: clickhouse://langfuse-clickhouse:9000
|
||||
CLICKHOUSE_USER: clickhouse
|
||||
CLICKHOUSE_PASSWORD: clickhouse
|
||||
CLICKHOUSE_CLUSTER_ENABLED: "false"
|
||||
REDIS_HOST: langfuse-redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_AUTH: langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_BUCKET: langfuse
|
||||
LANGFUSE_S3_EVENT_UPLOAD_REGION: us-east-1
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://langfuse-minio:9000
|
||||
LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: minio
|
||||
LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: minio123
|
||||
LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true"
|
||||
TELEMETRY_ENABLED: "false"
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES: "false"
|
||||
|
||||
langfuse-web:
|
||||
image: langfuse/langfuse:3
|
||||
depends_on:
|
||||
langfuse-worker:
|
||||
condition: service_started
|
||||
langfuse-postgres:
|
||||
condition: service_healthy
|
||||
langfuse-clickhouse:
|
||||
condition: service_healthy
|
||||
langfuse-redis:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${LANGFUSE_PORT:-3000}:3000"
|
||||
environment:
|
||||
<<: *langfuse-env
|
||||
NEXTAUTH_URL: http://localhost:${LANGFUSE_PORT:-3000}
|
||||
NEXTAUTH_SECRET: mirage-integ-nextauth-secret
|
||||
LANGFUSE_INIT_ORG_ID: mirage-integ-org
|
||||
LANGFUSE_INIT_ORG_NAME: Mirage Integ
|
||||
LANGFUSE_INIT_PROJECT_ID: mirage-integ-project
|
||||
LANGFUSE_INIT_PROJECT_NAME: Mirage Integ
|
||||
LANGFUSE_INIT_PROJECT_PUBLIC_KEY: pk-lf-mirage-integ
|
||||
LANGFUSE_INIT_PROJECT_SECRET_KEY: sk-lf-mirage-integ
|
||||
LANGFUSE_INIT_USER_EMAIL: integ@example.com
|
||||
LANGFUSE_INIT_USER_NAME: Mirage Integ
|
||||
LANGFUSE_INIT_USER_PASSWORD: mirage-integ-password
|
||||
@@ -0,0 +1,554 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
PUBLIC_KEY = "pk-lf-mirage-integ"
|
||||
SECRET_KEY = "sk-lf-mirage-integ"
|
||||
|
||||
# Traces are ingested with client-chosen ids and timestamps, so every VFS name
|
||||
# the battery asserts on is fixed. Server-generated fields (createdAt, latency,
|
||||
# htmlPath) still vary, which is why the cases project through jq instead of
|
||||
# diffing whole documents.
|
||||
TRACES = [
|
||||
{
|
||||
"event_id": "11111111-1111-4111-8111-111111111111",
|
||||
"id": "trace-alpha",
|
||||
"name": "checkout-flow",
|
||||
"userId": "user-ana",
|
||||
"sessionId": "session-one",
|
||||
"timestamp": "2026-01-01T00:00:00.000Z",
|
||||
"input": {
|
||||
"cart": "two items"
|
||||
},
|
||||
"output": {
|
||||
"status": "confirmed"
|
||||
},
|
||||
"tags": ["checkout", "prod"],
|
||||
"metadata": {
|
||||
"region": "eu-west"
|
||||
},
|
||||
},
|
||||
{
|
||||
"event_id": "22222222-2222-4222-8222-222222222222",
|
||||
"id": "trace-beta",
|
||||
"name": "search-query",
|
||||
"userId": "user-bo",
|
||||
"sessionId": "session-one",
|
||||
"timestamp": "2026-01-01T00:05:00.000Z",
|
||||
"input": {
|
||||
"query": "running shoes"
|
||||
},
|
||||
"output": {
|
||||
"hits": 12
|
||||
},
|
||||
"tags": ["search"],
|
||||
"metadata": {
|
||||
"region": "us-east"
|
||||
},
|
||||
},
|
||||
{
|
||||
"event_id": "33333333-3333-4333-8333-333333333333",
|
||||
"id": "trace-gamma",
|
||||
"name": "summarize-doc",
|
||||
"userId": "user-ana",
|
||||
"sessionId": "session-two",
|
||||
"timestamp": "2026-01-01T00:10:00.000Z",
|
||||
"input": {
|
||||
"doc": "quarterly report"
|
||||
},
|
||||
"output": {
|
||||
"summary": "revenue grew"
|
||||
},
|
||||
"tags": ["summarize", "prod"],
|
||||
"metadata": {
|
||||
"region": "eu-west"
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# trace-alpha carries a span with a nested generation plus a score, so the
|
||||
# `observations` and `scores` arrays in a rendered trace document are populated
|
||||
# rather than empty.
|
||||
OBSERVATIONS = [
|
||||
{
|
||||
"event_id": "44444444-4444-4444-8444-444444444444",
|
||||
"type": "span-create",
|
||||
"body": {
|
||||
"id": "obs-span-checkout",
|
||||
"traceId": "trace-alpha",
|
||||
"type": "SPAN",
|
||||
"name": "validate-cart",
|
||||
"startTime": "2026-01-01T00:00:01.000Z",
|
||||
"endTime": "2026-01-01T00:00:02.000Z",
|
||||
"input": {
|
||||
"items": 2
|
||||
},
|
||||
"output": {
|
||||
"valid": True
|
||||
},
|
||||
"level": "DEFAULT",
|
||||
},
|
||||
},
|
||||
{
|
||||
"event_id": "55555555-5555-4555-8555-555555555555",
|
||||
"type": "generation-create",
|
||||
"body": {
|
||||
"id": "obs-gen-summary",
|
||||
"traceId": "trace-alpha",
|
||||
"parentObservationId": "obs-span-checkout",
|
||||
"type": "GENERATION",
|
||||
"name": "describe-order",
|
||||
"startTime": "2026-01-01T00:00:01.200Z",
|
||||
"endTime": "2026-01-01T00:00:01.800Z",
|
||||
"model": "gpt-4o-mini",
|
||||
"input": [{
|
||||
"role": "user",
|
||||
"content": "describe the order"
|
||||
}],
|
||||
"output": {
|
||||
"content": "two items, confirmed"
|
||||
},
|
||||
"level": "DEFAULT",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
SCORES = [
|
||||
{
|
||||
"event_id": "66666666-6666-4666-8666-666666666666",
|
||||
"type": "score-create",
|
||||
"body": {
|
||||
"id": "score-helpfulness",
|
||||
"traceId": "trace-alpha",
|
||||
"name": "helpfulness",
|
||||
"value": 0.75,
|
||||
"dataType": "NUMERIC",
|
||||
"comment": "clear summary",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
PROMPTS = [
|
||||
{
|
||||
"name": "greeting",
|
||||
"type": "text",
|
||||
"prompt": "Hello {{name}}, welcome aboard.",
|
||||
"labels": ["production"],
|
||||
"version": 1,
|
||||
},
|
||||
{
|
||||
"name": "greeting",
|
||||
"type": "text",
|
||||
"prompt": "Hi {{name}}, glad you are here.",
|
||||
"labels": ["latest"],
|
||||
"version": 2,
|
||||
},
|
||||
{
|
||||
"name":
|
||||
"qa-template",
|
||||
"type":
|
||||
"chat",
|
||||
"prompt": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Answer briefly."
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "{{question}}"
|
||||
},
|
||||
],
|
||||
"labels": ["production"],
|
||||
"version":
|
||||
1,
|
||||
},
|
||||
]
|
||||
|
||||
DATASETS = ["eval-basic", "eval-empty"]
|
||||
|
||||
DATASET_ITEMS = [
|
||||
{
|
||||
"id": "item-one",
|
||||
"datasetName": "eval-basic",
|
||||
"input": {
|
||||
"question": "capital of france"
|
||||
},
|
||||
"expectedOutput": {
|
||||
"answer": "paris"
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "item-two",
|
||||
"datasetName": "eval-basic",
|
||||
"input": {
|
||||
"question": "capital of japan"
|
||||
},
|
||||
"expectedOutput": {
|
||||
"answer": "tokyo"
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
RUN_NAME = "run-alpha"
|
||||
RUN_DATASET = "eval-basic"
|
||||
RUN_ITEM_ID = "item-one"
|
||||
RUN_TRACE_ID = "trace-alpha"
|
||||
|
||||
POLL_ATTEMPTS = 120
|
||||
POLL_DELAY = 2.0
|
||||
|
||||
|
||||
def auth_header() -> dict[str, str]:
|
||||
"""Build the HTTP Basic header Langfuse's public API expects.
|
||||
|
||||
Returns:
|
||||
dict[str, str]: Authorization header for the seeded project keys.
|
||||
"""
|
||||
raw = f"{PUBLIC_KEY}:{SECRET_KEY}".encode()
|
||||
return {"Authorization": f"Basic {base64.b64encode(raw).decode()}"}
|
||||
|
||||
|
||||
async def request(
|
||||
session: aiohttp.ClientSession,
|
||||
host: str,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
params: dict[str, str] | None = None,
|
||||
) -> tuple[int, Any]:
|
||||
"""Call the Langfuse public API and return status plus decoded body.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
method (str): HTTP verb.
|
||||
path (str): API path beginning with a slash.
|
||||
payload (dict[str, Any] | None): JSON request body.
|
||||
params (dict[str, str] | None): query string arguments.
|
||||
|
||||
Returns:
|
||||
tuple[int, Any]: response status and parsed JSON, or raw text when the
|
||||
body is not JSON.
|
||||
"""
|
||||
url = f"{host.rstrip('/')}{path}"
|
||||
async with session.request(
|
||||
method,
|
||||
url,
|
||||
json=payload,
|
||||
params=params,
|
||||
headers=auth_header(),
|
||||
) as response:
|
||||
text = await response.text()
|
||||
try:
|
||||
body = await response.json(content_type=None)
|
||||
except (aiohttp.ContentTypeError, ValueError):
|
||||
body = text
|
||||
return response.status, body
|
||||
|
||||
|
||||
async def wait_healthy(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Block until the Langfuse web container answers its health probe.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the server never became healthy.
|
||||
"""
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
try:
|
||||
status, _body = await request(session, host, "GET",
|
||||
"/api/public/health")
|
||||
if status == 200:
|
||||
return
|
||||
except aiohttp.ClientError as exc:
|
||||
print(f"health probe not ready: {exc}", file=sys.stderr)
|
||||
await asyncio.sleep(POLL_DELAY)
|
||||
raise RuntimeError("langfuse did not become healthy in time")
|
||||
|
||||
|
||||
async def existing_prompt_versions(session: aiohttp.ClientSession, host: str,
|
||||
name: str) -> set[int]:
|
||||
"""List prompt versions already stored for a prompt name.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
name (str): prompt name.
|
||||
|
||||
Returns:
|
||||
set[int]: versions present on the server.
|
||||
"""
|
||||
status, body = await request(session, host, "GET",
|
||||
"/api/public/v2/prompts", None, {
|
||||
"name": name,
|
||||
"limit": "100"
|
||||
})
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
return set()
|
||||
# The list endpoint returns PromptMeta rows, which carry every version in a
|
||||
# `versions` array; there is no scalar `version` to read here.
|
||||
found: set[int] = set()
|
||||
for row in body.get("data", []):
|
||||
for version in row.get("versions", []):
|
||||
found.add(int(version))
|
||||
return found
|
||||
|
||||
|
||||
async def seed_prompts(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Create the fixture prompt versions that are not already present.
|
||||
|
||||
Creating a prompt with an existing name appends a new version, so this
|
||||
skips versions the server already has and keeps re-runs deterministic.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the server rejected a prompt creation.
|
||||
"""
|
||||
seen: dict[str, set[int]] = {}
|
||||
for spec in PROMPTS:
|
||||
name = str(spec["name"])
|
||||
if name not in seen:
|
||||
seen[name] = await existing_prompt_versions(session, host, name)
|
||||
if int(spec["version"]) in seen[name]:
|
||||
print(f"prompt {name} v{spec['version']} already seeded")
|
||||
continue
|
||||
payload = {
|
||||
"name": name,
|
||||
"type": spec["type"],
|
||||
"prompt": spec["prompt"],
|
||||
"labels": spec["labels"],
|
||||
}
|
||||
status, body = await request(session, host, "POST",
|
||||
"/api/public/v2/prompts", payload)
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"prompt {name} create failed: {status} {body}")
|
||||
seen[name].add(int(spec["version"]))
|
||||
|
||||
|
||||
async def seed_datasets(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Create the fixture datasets, tolerating ones that already exist.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the server rejected a dataset creation.
|
||||
"""
|
||||
for name in DATASETS:
|
||||
status, body = await request(session, host, "POST",
|
||||
"/api/public/v2/datasets", {"name": name})
|
||||
if status not in (200, 201, 409):
|
||||
raise RuntimeError(
|
||||
f"dataset {name} create failed: {status} {body}")
|
||||
|
||||
|
||||
async def seed_dataset_items(session: aiohttp.ClientSession,
|
||||
host: str) -> None:
|
||||
"""Upsert the fixture dataset items by their client-chosen ids.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the server rejected a dataset item.
|
||||
"""
|
||||
for item in DATASET_ITEMS:
|
||||
status, body = await request(session, host, "POST",
|
||||
"/api/public/dataset-items", item)
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"dataset item {item['id']} failed: {status} {body}")
|
||||
|
||||
|
||||
async def ingest_traces(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Push the fixture traces through the async ingestion endpoint.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the ingestion batch was rejected.
|
||||
"""
|
||||
batch = []
|
||||
for spec in TRACES:
|
||||
body = {k: v for k, v in spec.items() if k != "event_id"}
|
||||
batch.append({
|
||||
"id": spec["event_id"],
|
||||
"type": "trace-create",
|
||||
"timestamp": spec["timestamp"],
|
||||
"body": body,
|
||||
})
|
||||
for extra in (*OBSERVATIONS, *SCORES):
|
||||
batch.append({
|
||||
"id": extra["event_id"],
|
||||
"type": extra["type"],
|
||||
"timestamp": "2026-01-01T00:00:00.000Z",
|
||||
"body": extra["body"],
|
||||
})
|
||||
status, body = await request(session, host, "POST",
|
||||
"/api/public/ingestion", {"batch": batch})
|
||||
if status not in (200, 201, 207):
|
||||
raise RuntimeError(f"ingestion failed: {status} {body}")
|
||||
if isinstance(body, dict) and body.get("errors"):
|
||||
raise RuntimeError(f"ingestion reported errors: {body['errors']}")
|
||||
|
||||
|
||||
async def wait_for_traces(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Poll until every ingested trace is queryable.
|
||||
|
||||
Ingestion is queued through Redis and written to ClickHouse by the worker
|
||||
container, so the traces are not readable the moment the POST returns.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the traces never landed.
|
||||
"""
|
||||
wanted = {str(spec["id"]) for spec in TRACES}
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
status, body = await request(session, host, "GET",
|
||||
"/api/public/traces", None,
|
||||
{"limit": "100"})
|
||||
if status == 200 and isinstance(body, dict):
|
||||
have = {row.get("id") for row in body.get("data", [])}
|
||||
missing = wanted - have
|
||||
if not missing:
|
||||
return
|
||||
print(f"waiting for traces: {sorted(missing)}", file=sys.stderr)
|
||||
await asyncio.sleep(POLL_DELAY)
|
||||
raise RuntimeError("ingested traces never became queryable")
|
||||
|
||||
|
||||
async def wait_for_observations(session: aiohttp.ClientSession,
|
||||
host: str) -> None:
|
||||
"""Poll until the ingested observations and score reach the trace.
|
||||
|
||||
Observations travel the same async queue as traces but are joined onto the
|
||||
trace document later, so a trace can be readable while still reporting an
|
||||
empty `observations` array.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the observations never appeared on the trace.
|
||||
"""
|
||||
wanted = {str(o["body"]["id"]) for o in OBSERVATIONS}
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
status, body = await request(session, host, "GET",
|
||||
"/api/public/traces/trace-alpha")
|
||||
if status == 200 and isinstance(body, dict):
|
||||
have = {
|
||||
row.get("id")
|
||||
for row in body.get("observations", [])
|
||||
if isinstance(row, dict)
|
||||
}
|
||||
if wanted <= have and body.get("scores"):
|
||||
return
|
||||
print(f"waiting for observations: {sorted(wanted - have)}",
|
||||
file=sys.stderr)
|
||||
await asyncio.sleep(POLL_DELAY)
|
||||
raise RuntimeError("ingested observations never reached the trace")
|
||||
|
||||
|
||||
async def seed_dataset_run(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Link a dataset item to an ingested trace, creating the fixture run.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the server rejected the dataset run item.
|
||||
"""
|
||||
status, body = await request(
|
||||
session, host, "POST", "/api/public/dataset-run-items", {
|
||||
"runName": RUN_NAME,
|
||||
"datasetItemId": RUN_ITEM_ID,
|
||||
"traceId": RUN_TRACE_ID,
|
||||
})
|
||||
if status not in (200, 201):
|
||||
raise RuntimeError(f"dataset run item failed: {status} {body}")
|
||||
|
||||
|
||||
async def wait_for_run(session: aiohttp.ClientSession, host: str) -> None:
|
||||
"""Poll until the fixture dataset run is listed for its dataset.
|
||||
|
||||
Args:
|
||||
session (aiohttp.ClientSession): shared HTTP session.
|
||||
host (str): Langfuse base URL.
|
||||
|
||||
Raises:
|
||||
RuntimeError: the run never appeared.
|
||||
"""
|
||||
path = f"/api/public/datasets/{RUN_DATASET}/runs"
|
||||
for _ in range(POLL_ATTEMPTS):
|
||||
status, body = await request(session, host, "GET", path, None,
|
||||
{"limit": "100"})
|
||||
if status == 200 and isinstance(body, dict):
|
||||
names = {row.get("name") for row in body.get("data", [])}
|
||||
if RUN_NAME in names:
|
||||
return
|
||||
print(f"waiting for dataset run {RUN_NAME}", file=sys.stderr)
|
||||
await asyncio.sleep(POLL_DELAY)
|
||||
raise RuntimeError("dataset run never became queryable")
|
||||
|
||||
|
||||
async def seed(host: str) -> None:
|
||||
"""Bring a fresh Langfuse instance to the state the battery expects.
|
||||
|
||||
Args:
|
||||
host (str): Langfuse base URL.
|
||||
"""
|
||||
async with aiohttp.ClientSession() as session:
|
||||
await wait_healthy(session, host)
|
||||
await seed_prompts(session, host)
|
||||
await seed_datasets(session, host)
|
||||
await seed_dataset_items(session, host)
|
||||
await ingest_traces(session, host)
|
||||
await wait_for_traces(session, host)
|
||||
await wait_for_observations(session, host)
|
||||
await seed_dataset_run(session, host)
|
||||
await wait_for_run(session, host)
|
||||
print(f"LANGFUSE_URL={host}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse arguments and seed the configured Langfuse instance."""
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--host", default="http://localhost:3000")
|
||||
args = parser.parse_args()
|
||||
asyncio.run(seed(args.host))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,234 @@
|
||||
{
|
||||
"cases": [
|
||||
{
|
||||
"id": "sess_prep",
|
||||
"seq": 920001,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "mkdir -p /data/sess /data2/sess && echo hello > /data/sess/a.txt && echo aside > /data2/sess/s.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_read_grant_reads",
|
||||
"seq": 920002,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "reader",
|
||||
"command": "cat /data/sess/a.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "hello\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_read_grant_blocks_rm",
|
||||
"seq": 920003,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "reader",
|
||||
"command": "rm /data/sess/a.txt",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "rm: read-only mount at /data/\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_read_grant_blocks_redirect",
|
||||
"seq": 920004,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "reader",
|
||||
"command": "echo leak > /data/sess/new.txt",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "/data/sess/new.txt: Permission denied\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_denied_redirect_wrote_nothing",
|
||||
"seq": 920005,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "test -e /data/sess/new.txt || echo absent",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "absent\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_ungranted_mount_denied",
|
||||
"seq": 920006,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "reader",
|
||||
"command": "cat /data2/sess/s.txt",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: session 'reader' not allowed to access mount '/data2'\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_write_grant_writes",
|
||||
"seq": 920007,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "writer",
|
||||
"command": "echo w > /data/sess/w.txt && cat /data/sess/w.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "w\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_list_form_inherits_mount_mode",
|
||||
"seq": 920008,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "lister",
|
||||
"command": "echo l > /data/sess/l.txt && cat /data/sess/l.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "l\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_grant_cannot_widen_read_mount",
|
||||
"seq": 920009,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "capped",
|
||||
"command": "echo up > /ro/sess_y.txt",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "/ro/sess_y.txt: Permission denied\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_pathless_pipeline_still_runs",
|
||||
"seq": 920010,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"session": "reader",
|
||||
"command": "echo hi | wc -l",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_cleanup",
|
||||
"seq": 920011,
|
||||
"targets": [
|
||||
"ram",
|
||||
"disk"
|
||||
],
|
||||
"command": "rm -r /data/sess /data2/sess",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_root_seed",
|
||||
"seq": 920020,
|
||||
"targets": [
|
||||
"ram-root"
|
||||
],
|
||||
"command": "echo top > /root.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_root_unlisted_denied",
|
||||
"seq": 920021,
|
||||
"targets": [
|
||||
"ram-root"
|
||||
],
|
||||
"session": "no_root",
|
||||
"command": "cat /root.txt",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "cat: session 'no_root' not allowed to access mount '/'\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_root_read_grant",
|
||||
"seq": 920022,
|
||||
"targets": [
|
||||
"ram-root"
|
||||
],
|
||||
"session": "root_ro",
|
||||
"command": "cat /root.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "top\n",
|
||||
"stderr": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_root_write_denied",
|
||||
"seq": 920023,
|
||||
"targets": [
|
||||
"ram-root"
|
||||
],
|
||||
"session": "root_ro",
|
||||
"command": "echo x > /root.txt",
|
||||
"expect": {
|
||||
"exit": 1,
|
||||
"stdout": "",
|
||||
"stderr": "/root.txt: Permission denied\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "sess_root_write_left_content",
|
||||
"seq": 920024,
|
||||
"targets": [
|
||||
"ram-root"
|
||||
],
|
||||
"command": "cat /root.txt",
|
||||
"expect": {
|
||||
"exit": 0,
|
||||
"stdout": "top\n",
|
||||
"stderr": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import asyncio
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.resource.ram import RAMResource
|
||||
|
||||
# (name, session, command, show) where show selects what the truth file
|
||||
# records: "out" prints stdout, "err" prints stderr (byte-identical in
|
||||
# both languages), "exit" prints only whether the command failed (for
|
||||
# messages that legitimately differ between implementations).
|
||||
CASES: list[tuple[str, str, str, str]] = [
|
||||
("seed_data", "default", "echo hello > /data/a.txt", "exit"),
|
||||
("seed_side", "default", "echo aside > /side/s.txt", "exit"),
|
||||
# ----- read mode: reads pass, writes refuse like a READ mount -----
|
||||
("reader_cat", "reader", "cat /data/a.txt", "out"),
|
||||
("reader_rm_denied", "reader", "rm /data/a.txt", "err"),
|
||||
("reader_redirect_denied", "reader", "echo leak > /data/new.txt", "exit"),
|
||||
("reader_no_partial_write", "reader", "ls /data", "out"),
|
||||
# ----- unlisted mount: invisible -----
|
||||
("reader_side_denied", "reader", "cat /side/s.txt", "err"),
|
||||
# ----- write mode and list-form inherit -----
|
||||
("writer_write", "writer", "echo w > /data/w.txt && cat /data/w.txt", "out"
|
||||
),
|
||||
("lister_inherits_write", "lister",
|
||||
"echo l > /data/l.txt && cat /data/l.txt", "out"),
|
||||
# ----- a session mode cannot widen a READ mount -----
|
||||
("widen_attempt_denied", "capped", "echo up > /ro/y.txt", "exit"),
|
||||
# ----- restricted sessions keep pure text pipelines -----
|
||||
("reader_pathless_wc", "reader", "echo hi | wc -l", "out"),
|
||||
]
|
||||
|
||||
ROOT_CASES: list[tuple[str, str, str, str]] = [
|
||||
("root_seed", "default", "echo top > /root.txt", "exit"),
|
||||
("root_unlisted_denied", "no_root", "cat /root.txt", "err"),
|
||||
("root_read_mode", "root_ro", "cat /root.txt", "out"),
|
||||
("root_write_denied", "root_ro", "echo x > /root.txt", "exit"),
|
||||
]
|
||||
|
||||
|
||||
async def run(ws: Workspace, label: str, cases: list[tuple[str, str, str,
|
||||
str]]) -> None:
|
||||
for name, session, cmd, show in cases:
|
||||
result = await ws.execute(cmd, session_id=session)
|
||||
print(f"=== {label}:{name} ===")
|
||||
if show == "out":
|
||||
out = await result.stdout_str()
|
||||
print(out, end="" if out.endswith("\n") else "\n")
|
||||
elif show == "err":
|
||||
err = await result.stderr_str()
|
||||
print(err, end="" if err.endswith("\n") else "\n")
|
||||
print(f"failed={result.exit_code != 0}")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
ws = Workspace(
|
||||
{
|
||||
"/data": (RAMResource(), MountMode.WRITE),
|
||||
"/side": (RAMResource(), MountMode.WRITE),
|
||||
"/ro": (RAMResource(), MountMode.READ),
|
||||
},
|
||||
mode=MountMode.WRITE,
|
||||
session_id="default",
|
||||
)
|
||||
ws.create_session("reader", mounts={"/data": "read"})
|
||||
ws.create_session("writer", mounts={"/data": "write"})
|
||||
ws.create_session("lister", mounts=["/data"])
|
||||
ws.create_session("capped", mounts={"/ro": "write"})
|
||||
await run(ws, "modes", CASES)
|
||||
|
||||
ws_root = Workspace(
|
||||
{
|
||||
"/": (RAMResource(), MountMode.WRITE),
|
||||
"/data": (RAMResource(), MountMode.WRITE),
|
||||
},
|
||||
mode=MountMode.WRITE,
|
||||
session_id="default",
|
||||
)
|
||||
ws_root.create_session("no_root", mounts={"/data": "write"})
|
||||
ws_root.create_session("root_ro", mounts={"/data": "write", "/": "read"})
|
||||
await run(ws_root, "root", ROOT_CASES)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1,99 +0,0 @@
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import { MountMode, RAMResource, Workspace } from '@struktoai/mirage-node'
|
||||
|
||||
// (name, session, command, show) where show selects what the truth file
|
||||
// records: "out" prints stdout, "err" prints stderr (byte-identical in
|
||||
// both languages), "exit" prints only whether the command failed (for
|
||||
// messages that legitimately differ between implementations).
|
||||
type Case = [name: string, session: string, cmd: string, show: 'out' | 'err' | 'exit']
|
||||
|
||||
const CASES: Case[] = [
|
||||
['seed_data', 'default', 'echo hello > /data/a.txt', 'exit'],
|
||||
['seed_side', 'default', 'echo aside > /side/s.txt', 'exit'],
|
||||
// ----- read mode: reads pass, writes refuse like a READ mount -----
|
||||
['reader_cat', 'reader', 'cat /data/a.txt', 'out'],
|
||||
['reader_rm_denied', 'reader', 'rm /data/a.txt', 'err'],
|
||||
['reader_redirect_denied', 'reader', 'echo leak > /data/new.txt', 'exit'],
|
||||
['reader_no_partial_write', 'reader', 'ls /data', 'out'],
|
||||
// ----- unlisted mount: invisible -----
|
||||
['reader_side_denied', 'reader', 'cat /side/s.txt', 'err'],
|
||||
// ----- write mode and list-form inherit -----
|
||||
['writer_write', 'writer', 'echo w > /data/w.txt && cat /data/w.txt', 'out'],
|
||||
['lister_inherits_write', 'lister', 'echo l > /data/l.txt && cat /data/l.txt', 'out'],
|
||||
// ----- a session mode cannot widen a READ mount -----
|
||||
['widen_attempt_denied', 'capped', 'echo up > /ro/y.txt', 'exit'],
|
||||
// ----- restricted sessions keep pure text pipelines -----
|
||||
['reader_pathless_wc', 'reader', 'echo hi | wc -l', 'out'],
|
||||
]
|
||||
|
||||
const ROOT_CASES: Case[] = [
|
||||
['root_seed', 'default', 'echo top > /root.txt', 'exit'],
|
||||
['root_unlisted_denied', 'no_root', 'cat /root.txt', 'err'],
|
||||
['root_read_mode', 'root_ro', 'cat /root.txt', 'out'],
|
||||
['root_write_denied', 'root_ro', 'echo x > /root.txt', 'exit'],
|
||||
]
|
||||
|
||||
async function run(ws: Workspace, label: string, cases: Case[]): Promise<void> {
|
||||
for (const [name, session, cmd, show] of cases) {
|
||||
const result = await ws.execute(cmd, { sessionId: session })
|
||||
process.stdout.write(`=== ${label}:${name} ===\n`)
|
||||
if (show === 'out') {
|
||||
const out = result.stdoutText
|
||||
process.stdout.write(out.endsWith('\n') || out === '' ? out : out + '\n')
|
||||
} else if (show === 'err') {
|
||||
const err = result.stderrText
|
||||
process.stdout.write(err.endsWith('\n') || err === '' ? err : err + '\n')
|
||||
}
|
||||
process.stdout.write(`failed=${result.exitCode !== 0 ? 'True' : 'False'}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const ws = new Workspace(
|
||||
{
|
||||
'/data': new RAMResource(),
|
||||
'/side': new RAMResource(),
|
||||
'/ro': [new RAMResource(), MountMode.READ] as const,
|
||||
},
|
||||
{ mode: MountMode.WRITE, sessionId: 'default' },
|
||||
)
|
||||
try {
|
||||
ws.createSession('reader', { mounts: { '/data': MountMode.READ } })
|
||||
ws.createSession('writer', { mounts: { '/data': MountMode.WRITE } })
|
||||
ws.createSession('lister', { mounts: ['/data'] })
|
||||
ws.createSession('capped', { mounts: { '/ro': MountMode.WRITE } })
|
||||
await run(ws, 'modes', CASES)
|
||||
} finally {
|
||||
await ws.close()
|
||||
}
|
||||
|
||||
const wsRoot = new Workspace(
|
||||
{ '/': new RAMResource(), '/data': new RAMResource() },
|
||||
{ mode: MountMode.WRITE, sessionId: 'default' },
|
||||
)
|
||||
try {
|
||||
wsRoot.createSession('no_root', { mounts: { '/data': MountMode.WRITE } })
|
||||
wsRoot.createSession('root_ro', { mounts: { '/data': MountMode.WRITE, '/': MountMode.READ } })
|
||||
await run(wsRoot, 'root', ROOT_CASES)
|
||||
} finally {
|
||||
await wsRoot.close()
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err: unknown) => {
|
||||
process.stderr.write(String(err) + '\n')
|
||||
process.exit(1)
|
||||
})
|
||||
+97
-2
@@ -30,7 +30,49 @@
|
||||
"backend": "memory",
|
||||
"mode": "read"
|
||||
}
|
||||
]
|
||||
],
|
||||
"sessions": {
|
||||
"reader": {
|
||||
"/data": "read"
|
||||
},
|
||||
"writer": {
|
||||
"/data": "write"
|
||||
},
|
||||
"lister": [
|
||||
"/data"
|
||||
],
|
||||
"capped": {
|
||||
"/ro": "write"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "ram-root",
|
||||
"hosts": [
|
||||
"python",
|
||||
"typescript-node"
|
||||
],
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/",
|
||||
"resource": "ram",
|
||||
"backend": "memory"
|
||||
},
|
||||
{
|
||||
"path": "/data",
|
||||
"resource": "ram",
|
||||
"backend": "memory"
|
||||
}
|
||||
],
|
||||
"sessions": {
|
||||
"no_root": {
|
||||
"/data": "write"
|
||||
},
|
||||
"root_ro": {
|
||||
"/data": "write",
|
||||
"/": "read"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "disk",
|
||||
@@ -62,7 +104,21 @@
|
||||
"backend": "tmpdir",
|
||||
"mode": "read"
|
||||
}
|
||||
]
|
||||
],
|
||||
"sessions": {
|
||||
"reader": {
|
||||
"/data": "read"
|
||||
},
|
||||
"writer": {
|
||||
"/data": "write"
|
||||
},
|
||||
"lister": [
|
||||
"/data"
|
||||
],
|
||||
"capped": {
|
||||
"/ro": "write"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "redis",
|
||||
@@ -656,6 +712,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "mem0",
|
||||
"facet": "mem0",
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/memories",
|
||||
@@ -905,6 +962,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "gws",
|
||||
"facet": "email",
|
||||
"epoch": "2026-02-01T00:00:00Z",
|
||||
"mail": "gmail/v1",
|
||||
"mounts": [
|
||||
@@ -927,6 +985,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "email",
|
||||
"facet": "email",
|
||||
"mail": "email/v1",
|
||||
"mounts": [
|
||||
{
|
||||
@@ -948,6 +1007,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "slack",
|
||||
"facet": "chat",
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/slack",
|
||||
@@ -963,6 +1023,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "trello",
|
||||
"facet": "project",
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/board",
|
||||
@@ -983,6 +1044,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "linear",
|
||||
"facet": "project",
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/issues",
|
||||
@@ -1003,6 +1065,7 @@
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "dify",
|
||||
"facet": "dify",
|
||||
"dataset": "kb-7f3a",
|
||||
"mounts": [
|
||||
{
|
||||
@@ -1012,6 +1075,38 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jaeger",
|
||||
"hosts": [
|
||||
"python",
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "jaeger",
|
||||
"facet": "observability",
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/j",
|
||||
"resource": "jaeger",
|
||||
"backend": "jaeger"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "langfuse",
|
||||
"hosts": [
|
||||
"python",
|
||||
"typescript-node"
|
||||
],
|
||||
"service": "langfuse",
|
||||
"facet": "observability",
|
||||
"mounts": [
|
||||
{
|
||||
"path": "/lf",
|
||||
"resource": "langfuse",
|
||||
"backend": "langfuse"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "statvfs",
|
||||
"hosts": [
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
=== modes:seed_data ===
|
||||
failed=False
|
||||
=== modes:seed_side ===
|
||||
failed=False
|
||||
=== modes:reader_cat ===
|
||||
hello
|
||||
failed=False
|
||||
=== modes:reader_rm_denied ===
|
||||
rm: read-only mount at /data/
|
||||
failed=True
|
||||
=== modes:reader_redirect_denied ===
|
||||
failed=True
|
||||
=== modes:reader_no_partial_write ===
|
||||
a.txt
|
||||
failed=False
|
||||
=== modes:reader_side_denied ===
|
||||
cat: session 'reader' not allowed to access mount '/side'
|
||||
failed=True
|
||||
=== modes:writer_write ===
|
||||
w
|
||||
failed=False
|
||||
=== modes:lister_inherits_write ===
|
||||
l
|
||||
failed=False
|
||||
=== modes:widen_attempt_denied ===
|
||||
failed=True
|
||||
=== modes:reader_pathless_wc ===
|
||||
1
|
||||
failed=False
|
||||
=== root:root_seed ===
|
||||
failed=False
|
||||
=== root:root_unlisted_denied ===
|
||||
cat: session 'no_root' not allowed to access mount '/'
|
||||
failed=True
|
||||
=== root:root_read_mode ===
|
||||
top
|
||||
failed=False
|
||||
=== root:root_write_denied ===
|
||||
failed=True
|
||||
@@ -0,0 +1,45 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from mirage.accessor.base import Accessor
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
|
||||
|
||||
class JaegerAccessor(Accessor):
|
||||
|
||||
def __init__(self, config: JaegerConfig) -> None:
|
||||
self.config = config
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
|
||||
def get_client(self) -> httpx.AsyncClient:
|
||||
if self._client is None:
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self.config.host.rstrip("/"),
|
||||
timeout=self.config.request_timeout,
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def request(self,
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None) -> httpx.Response:
|
||||
return await self.get_client().get(endpoint, params=params)
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._client is not None:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
@@ -1,8 +1,7 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.commands.builtin.utils.output import (format_optional_records,
|
||||
format_records)
|
||||
from mirage.commands.builtin.utils.output import format_records
|
||||
from mirage.io.types import IOResult
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
from mirage.utils.errors import WALK_ERRORS
|
||||
@@ -129,7 +128,9 @@ async def tree(
|
||||
warnings=warnings,
|
||||
index=index)
|
||||
root_label = path.raw_path or path.virtual
|
||||
stderr = format_optional_records(warnings)
|
||||
# GNU signals an unopenable path with the inline "[error opening dir]"
|
||||
# marker and exit 2, and writes nothing to stderr. `warnings` therefore
|
||||
# only decides the marker; emitting it would diverge.
|
||||
if warnings and not lines:
|
||||
# The root could not be opened (GNU prints the error marker inline
|
||||
# and exits 2).
|
||||
@@ -137,13 +138,13 @@ async def tree(
|
||||
f"{root_label} [error opening dir]", "",
|
||||
_summary(0, 0, dirs_only)
|
||||
]
|
||||
return format_records(body), IOResult(stderr=stderr, exit_code=2)
|
||||
return format_records(body), IOResult(exit_code=2)
|
||||
# GNU counts the root as a directory once it has any listed entry (an
|
||||
# empty root reports 0), then a blank line and the summary (the file
|
||||
# count is omitted under -d).
|
||||
root_dirs = dirs + 1 if lines else 0
|
||||
body = [root_label] + lines + ["", _summary(root_dirs, files, dirs_only)]
|
||||
return format_records(body), IOResult(stderr=stderr)
|
||||
return format_records(body), IOResult()
|
||||
|
||||
|
||||
__all__ = ["tree"]
|
||||
|
||||
@@ -66,6 +66,22 @@ async def du(
|
||||
paths = await ops.resolve_glob(accessor, paths, index)
|
||||
if not paths:
|
||||
raise ValueError("du: missing operand")
|
||||
# GNU reports an operand it cannot stat and carries on with the rest,
|
||||
# exiting 1. Walking a missing operand would otherwise report it as size 0.
|
||||
present: list[PathSpec] = []
|
||||
errors: list[str] = []
|
||||
for p in paths:
|
||||
try:
|
||||
await ops.stat(accessor, p, index)
|
||||
except (FileNotFoundError, ValueError):
|
||||
errors.append(
|
||||
f"du: cannot access '{p.raw_path}': No such file or directory")
|
||||
else:
|
||||
present.append(p)
|
||||
err = ("\n".join(errors) + "\n").encode() if errors else None
|
||||
if not present:
|
||||
return None, IOResult(exit_code=1, stderr=err)
|
||||
paths = present
|
||||
depth = int(max_depth) if max_depth is not None else None
|
||||
if ops.du_total is None or ops.du_all is None:
|
||||
out = await du_multi(paths,
|
||||
@@ -76,7 +92,7 @@ async def du(
|
||||
a=a,
|
||||
max_depth=depth,
|
||||
c=c)
|
||||
return out, IOResult()
|
||||
return out, IOResult(exit_code=1 if err else 0, stderr=err)
|
||||
text = await generic_du(
|
||||
paths,
|
||||
compute_total=partial(ops.du_total, accessor),
|
||||
@@ -87,7 +103,7 @@ async def du(
|
||||
max_depth=depth,
|
||||
c=c,
|
||||
)
|
||||
return text.encode(), IOResult()
|
||||
return text.encode(), IOResult(exit_code=1 if err else 0, stderr=err)
|
||||
|
||||
|
||||
BUILDER = Builder('du', du, None, False, None)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.generic_bind import make_generic_commands
|
||||
from mirage.commands.builtin.jaeger.io import IO as _IO
|
||||
|
||||
COMMANDS = [*make_generic_commands("jaeger", _IO)]
|
||||
@@ -0,0 +1,33 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from functools import partial
|
||||
|
||||
from mirage.commands.builtin.generic_bind import CommandIO
|
||||
from mirage.commands.builtin.utils.wrap import stream_from_bytes
|
||||
from mirage.core.jaeger.read import read as _read
|
||||
from mirage.core.jaeger.readdir import is_dir_name as _is_dir_name
|
||||
from mirage.core.jaeger.readdir import readdir as _readdir
|
||||
from mirage.core.jaeger.stat import stat as _stat
|
||||
|
||||
IO = CommandIO(
|
||||
readdir=_readdir,
|
||||
read_bytes=_read,
|
||||
read_stream=partial(stream_from_bytes, _read),
|
||||
stat=_stat,
|
||||
is_mounted=lambda a: True,
|
||||
is_dir_name=lambda a, name: _is_dir_name(name),
|
||||
local=False,
|
||||
)
|
||||
resolve_glob = IO.resolve_glob
|
||||
@@ -86,6 +86,10 @@ async def readdir(
|
||||
cached = await index.list_dir(virtual_key)
|
||||
if cached.entries is not None:
|
||||
return cached.entries
|
||||
# An unknown folder must be ENOENT: selecting it over IMAP fails with
|
||||
# "command SEARCH illegal in state AUTH", which leaked to the caller.
|
||||
if folder_name not in await list_folders(accessor):
|
||||
raise enoent(virtual)
|
||||
max_msgs = accessor.config.max_messages
|
||||
uids = await list_message_uids(accessor,
|
||||
folder_name,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
@@ -0,0 +1,193 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
|
||||
TRACE_ID_RE = re.compile(r"^[0-9a-f]{16}$|^[0-9a-f]{32}$", re.IGNORECASE)
|
||||
|
||||
# Jaeger's own query service self-instruments, so a `jaeger` service shows up
|
||||
# in listings alongside the ones a user actually sent.
|
||||
|
||||
|
||||
class JaegerApiError(Exception):
|
||||
|
||||
def __init__(self, message: str, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def is_trace_id(value: str) -> bool:
|
||||
"""Report whether a name is a syntactically valid Jaeger trace id.
|
||||
|
||||
Checked before calling the API so a malformed id becomes ENOENT instead of
|
||||
the API's 400 "invalid length for TraceID".
|
||||
|
||||
Args:
|
||||
value (str): candidate trace id.
|
||||
|
||||
Returns:
|
||||
bool: True when the value is 16 or 32 hex digits.
|
||||
"""
|
||||
return bool(TRACE_ID_RE.match(value))
|
||||
|
||||
|
||||
def _micros(iso: str | None, default: int) -> int:
|
||||
"""Convert an ISO-8601 timestamp to unix microseconds.
|
||||
|
||||
Args:
|
||||
iso (str | None): timestamp, or None to use the default.
|
||||
default (int): value used when iso is None.
|
||||
|
||||
Returns:
|
||||
int: unix epoch microseconds.
|
||||
"""
|
||||
if iso is None:
|
||||
return default
|
||||
parsed = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return int(parsed.timestamp() * 1_000_000)
|
||||
|
||||
|
||||
def _now_micros() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp() * 1_000_000)
|
||||
|
||||
|
||||
async def _get(accessor: JaegerAccessor,
|
||||
endpoint: str,
|
||||
params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Call the Jaeger query API and return the decoded body.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
endpoint (str): API path beginning with a slash.
|
||||
params (dict[str, Any] | None): query string arguments.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: decoded JSON body.
|
||||
|
||||
Raises:
|
||||
JaegerApiError: the API reported an error status.
|
||||
"""
|
||||
response = await accessor.request(endpoint, params)
|
||||
if response.status_code >= 400:
|
||||
message = f"Jaeger API error: HTTP {response.status_code}"
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
if isinstance(body, dict):
|
||||
errors = body.get("errors")
|
||||
if isinstance(errors, list) and errors:
|
||||
first = errors[0]
|
||||
if isinstance(first, dict) and first.get("msg"):
|
||||
message = str(first["msg"])
|
||||
raise JaegerApiError(message, response.status_code)
|
||||
payload = response.json()
|
||||
if not isinstance(payload, dict):
|
||||
raise JaegerApiError("Jaeger response must be a JSON object")
|
||||
return payload
|
||||
|
||||
|
||||
def _data_list(payload: dict[str, Any]) -> list[Any]:
|
||||
data = payload.get("data")
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
|
||||
async def fetch_services(accessor: JaegerAccessor) -> list[str]:
|
||||
"""List service names known to Jaeger.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
|
||||
Returns:
|
||||
list[str]: service names, self-instrumentation included.
|
||||
"""
|
||||
payload = await _get(accessor, "/api/services")
|
||||
return [str(name) for name in _data_list(payload)]
|
||||
|
||||
|
||||
async def fetch_operations(accessor: JaegerAccessor,
|
||||
service: str) -> list[dict[str, Any]]:
|
||||
"""List operations recorded for a service.
|
||||
|
||||
An unknown service yields an empty list rather than an error, so callers
|
||||
that need existence semantics must check the service list first.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
service (str): service name.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: operation records.
|
||||
"""
|
||||
payload = await _get(accessor, "/api/operations", {"service": service})
|
||||
return [row for row in _data_list(payload) if isinstance(row, dict)]
|
||||
|
||||
|
||||
async def fetch_traces(
|
||||
accessor: JaegerAccessor,
|
||||
service: str,
|
||||
limit: int = 100,
|
||||
from_timestamp: str | None = None,
|
||||
to_timestamp: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Search traces for a service within an explicit time window.
|
||||
|
||||
`service` is required by the API and `lookback` is ignored, so the window
|
||||
is always sent as explicit microsecond bounds.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
service (str): service name to search.
|
||||
limit (int): maximum traces to return.
|
||||
from_timestamp (str | None): lower bound, or None for the beginning.
|
||||
to_timestamp (str | None): upper bound, or None for now.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: trace documents.
|
||||
"""
|
||||
params = {
|
||||
"service": service,
|
||||
"limit": limit,
|
||||
"start": _micros(from_timestamp, 0),
|
||||
"end": _micros(to_timestamp, _now_micros()),
|
||||
}
|
||||
payload = await _get(accessor, "/api/traces", params)
|
||||
return [row for row in _data_list(payload) if isinstance(row, dict)]
|
||||
|
||||
|
||||
async def fetch_trace(accessor: JaegerAccessor,
|
||||
trace_id: str) -> dict[str, Any]:
|
||||
"""Fetch one trace by id.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
trace_id (str): 16 or 32 hex digit trace id.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: the trace document.
|
||||
|
||||
Raises:
|
||||
JaegerApiError: the trace is missing or the API failed.
|
||||
"""
|
||||
payload = await _get(accessor, f"/api/traces/{trace_id}")
|
||||
traces = [row for row in _data_list(payload) if isinstance(row, dict)]
|
||||
if not traces:
|
||||
raise JaegerApiError("trace not found", 404)
|
||||
return traces[0]
|
||||
@@ -0,0 +1,107 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.core.jaeger._client import (JaegerApiError, fetch_operations,
|
||||
fetch_trace, is_trace_id)
|
||||
from mirage.core.jaeger.readdir import assert_service
|
||||
from mirage.core.jaeger.scope import detect_scope
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
|
||||
|
||||
def _json_bytes(data: Any) -> bytes:
|
||||
return json.dumps(data, ensure_ascii=False, indent=2).encode()
|
||||
|
||||
|
||||
def _has_service(trace: dict[str, Any], service: str) -> bool:
|
||||
"""Report whether any span in the trace was emitted by the service.
|
||||
|
||||
A trace is fetched by id from the global endpoint, so the id alone does not
|
||||
place it under the service directory it was addressed through. Membership
|
||||
is read from the trace's own process table rather than the service listing,
|
||||
which is windowed and limited and would hide a trace that really belongs.
|
||||
|
||||
Args:
|
||||
trace (dict[str, Any]): trace document from the API.
|
||||
service (str): service name the path addressed.
|
||||
|
||||
Returns:
|
||||
bool: True when the service emitted at least one span.
|
||||
"""
|
||||
processes = trace.get("processes")
|
||||
if not isinstance(processes, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(p, dict) and p.get("serviceName") == service
|
||||
for p in processes.values())
|
||||
|
||||
|
||||
async def read(
|
||||
accessor: JaegerAccessor,
|
||||
path: PathSpec,
|
||||
index: IndexCacheStore = NULL_INDEX,
|
||||
) -> bytes:
|
||||
"""Read a file as bytes.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
path (PathSpec): resource-relative path.
|
||||
index (IndexCacheStore): index cache.
|
||||
|
||||
Returns:
|
||||
bytes: rendered file content.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: the path is not a jaeger file.
|
||||
"""
|
||||
virtual = path.virtual
|
||||
key = path.resource_path
|
||||
|
||||
if any(p.startswith(".") for p in key.split("/")):
|
||||
raise enoent(virtual)
|
||||
|
||||
scope = detect_scope(path)
|
||||
|
||||
if scope.level == "operations":
|
||||
assert scope.service is not None
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
operations = await fetch_operations(accessor, scope.service)
|
||||
return _json_bytes(operations)
|
||||
|
||||
if scope.level == "trace":
|
||||
assert scope.service is not None
|
||||
assert scope.trace_id is not None
|
||||
# A malformed id cannot name an existing trace, so it is ENOENT rather
|
||||
# than the API's 400 "invalid length for TraceID".
|
||||
if not is_trace_id(scope.trace_id):
|
||||
raise enoent(virtual)
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
try:
|
||||
trace = await fetch_trace(accessor, scope.trace_id)
|
||||
except JaegerApiError as exc:
|
||||
if exc.status_code == 404:
|
||||
raise enoent(virtual) from exc
|
||||
raise
|
||||
# Reading by id would otherwise serve any trace through any service
|
||||
# directory, contradicting stat and ls for the same path.
|
||||
if not _has_service(trace, scope.service):
|
||||
raise enoent(virtual)
|
||||
return _json_bytes(trace)
|
||||
|
||||
raise enoent(virtual)
|
||||
@@ -0,0 +1,164 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore, IndexEntry
|
||||
from mirage.core.jaeger._client import (fetch_services, fetch_traces,
|
||||
is_trace_id)
|
||||
from mirage.core.jaeger.scope import (OPERATIONS_FILE, TOP_LEVEL_DIRS,
|
||||
detect_scope)
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
from mirage.utils.key_prefix import mount_prefix_of
|
||||
|
||||
|
||||
async def readdir(
|
||||
accessor: JaegerAccessor,
|
||||
path_spec: PathSpec,
|
||||
index: IndexCacheStore = NULL_INDEX,
|
||||
) -> list[str]:
|
||||
"""List directory contents.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
path_spec (PathSpec): resource-relative path.
|
||||
index (IndexCacheStore): index cache.
|
||||
|
||||
Returns:
|
||||
list[str]: virtual child paths.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: the path is not a jaeger directory.
|
||||
"""
|
||||
virtual = path_spec.virtual
|
||||
prefix = mount_prefix_of(path_spec.virtual, path_spec.resource_path)
|
||||
path = (path_spec.dir if path_spec.pattern else path_spec).mount_path
|
||||
key = path.strip("/")
|
||||
|
||||
if key and any(p.startswith(".") for p in key.split("/")):
|
||||
raise enoent(virtual)
|
||||
|
||||
virtual_key = prefix + "/" + key if key else prefix or "/"
|
||||
scope = detect_scope(path)
|
||||
|
||||
if scope.level == "root":
|
||||
return [f"{prefix}/{d}" for d in TOP_LEVEL_DIRS]
|
||||
|
||||
if scope.level == "services":
|
||||
return await _readdir_services(accessor, virtual_key, index, prefix)
|
||||
|
||||
if scope.level == "service":
|
||||
assert scope.service is not None
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
return [
|
||||
f"{prefix}/services/{scope.service}/{OPERATIONS_FILE}",
|
||||
f"{prefix}/services/{scope.service}/traces",
|
||||
]
|
||||
|
||||
if scope.level == "traces":
|
||||
assert scope.service is not None
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
return await _readdir_traces(accessor, scope.service, virtual_key,
|
||||
index, prefix)
|
||||
|
||||
raise enoent(virtual)
|
||||
|
||||
|
||||
async def assert_service(accessor: JaegerAccessor, service: str,
|
||||
virtual: str) -> None:
|
||||
"""Raise ENOENT unless the service is known to Jaeger.
|
||||
|
||||
The operations endpoint answers 200 with an empty list for a service that
|
||||
was never seen, so an unknown service would otherwise look like an empty
|
||||
directory instead of a missing one.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
service (str): service name to check.
|
||||
virtual (str): virtual path named in the ENOENT message.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: the service is unknown.
|
||||
"""
|
||||
services = await fetch_services(accessor)
|
||||
if service not in services:
|
||||
raise enoent(virtual)
|
||||
|
||||
|
||||
async def _readdir_services(
|
||||
accessor: JaegerAccessor,
|
||||
virtual_key: str,
|
||||
index: IndexCacheStore,
|
||||
prefix: str,
|
||||
) -> list[str]:
|
||||
listing = await index.list_dir(virtual_key)
|
||||
if listing.entries is not None:
|
||||
return listing.entries
|
||||
services = await fetch_services(accessor)
|
||||
entries = []
|
||||
names = []
|
||||
for service in services:
|
||||
entry = IndexEntry(
|
||||
id=service,
|
||||
name=service,
|
||||
resource_type="jaeger/service",
|
||||
vfs_name=service,
|
||||
)
|
||||
entries.append((service, entry))
|
||||
names.append(f"{prefix}/services/{service}")
|
||||
await index.set_dir(virtual_key, entries)
|
||||
return names
|
||||
|
||||
|
||||
async def _readdir_traces(
|
||||
accessor: JaegerAccessor,
|
||||
service: str,
|
||||
virtual_key: str,
|
||||
index: IndexCacheStore,
|
||||
prefix: str,
|
||||
) -> list[str]:
|
||||
listing = await index.list_dir(virtual_key)
|
||||
if listing.entries is not None:
|
||||
return listing.entries
|
||||
traces = await fetch_traces(
|
||||
accessor,
|
||||
service,
|
||||
limit=accessor.config.default_trace_limit,
|
||||
from_timestamp=accessor.config.default_from_timestamp,
|
||||
to_timestamp=accessor.config.default_to_timestamp,
|
||||
)
|
||||
entries = []
|
||||
names = []
|
||||
for trace in traces:
|
||||
trace_id = str(trace.get("traceID", ""))
|
||||
if not is_trace_id(trace_id):
|
||||
continue
|
||||
filename = f"{trace_id}.json"
|
||||
entry = IndexEntry(
|
||||
id=trace_id,
|
||||
name=trace_id,
|
||||
resource_type="jaeger/trace",
|
||||
vfs_name=filename,
|
||||
)
|
||||
entries.append((filename, entry))
|
||||
names.append(f"{prefix}/services/{service}/traces/{filename}")
|
||||
await index.set_dir(virtual_key, entries)
|
||||
return names
|
||||
|
||||
|
||||
def is_dir_name(child: str) -> bool:
|
||||
# Entries are recognized by extension, so classification never needs the
|
||||
# stat fallback.
|
||||
name = child.rsplit("/", 1)[-1]
|
||||
return not name.endswith(".json")
|
||||
@@ -0,0 +1,79 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from mirage.types import PathSpec
|
||||
|
||||
OPERATIONS_FILE = "operations.json"
|
||||
TOP_LEVEL_DIRS = ["services"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class JaegerScope:
|
||||
level: str
|
||||
service: str | None = None
|
||||
trace_id: str | None = None
|
||||
resource_path: str = "/"
|
||||
|
||||
|
||||
def detect_scope(path: PathSpec | str) -> JaegerScope:
|
||||
"""Classify a resource-relative path into a jaeger tree position.
|
||||
|
||||
The tree is service-scoped because Jaeger's search API requires a service:
|
||||
there is no endpoint that lists every trace.
|
||||
|
||||
Args:
|
||||
path (PathSpec | str): resource-relative path.
|
||||
|
||||
Returns:
|
||||
JaegerScope: the detected position, level "unknown" when unrecognized.
|
||||
"""
|
||||
raw = path.mount_path if isinstance(path, PathSpec) else path
|
||||
key = raw.strip("/")
|
||||
|
||||
if not key:
|
||||
return JaegerScope(level="root", resource_path=raw)
|
||||
|
||||
parts = key.split("/")
|
||||
|
||||
if parts[0] != "services":
|
||||
return JaegerScope(level="unknown", resource_path=raw)
|
||||
|
||||
if len(parts) == 1:
|
||||
return JaegerScope(level="services", resource_path=raw)
|
||||
|
||||
service = parts[1]
|
||||
|
||||
if len(parts) == 2:
|
||||
return JaegerScope(level="service", service=service, resource_path=raw)
|
||||
|
||||
if len(parts) == 3 and parts[2] == OPERATIONS_FILE:
|
||||
return JaegerScope(level="operations",
|
||||
service=service,
|
||||
resource_path=raw)
|
||||
|
||||
if len(parts) == 3 and parts[2] == "traces":
|
||||
return JaegerScope(level="traces", service=service, resource_path=raw)
|
||||
|
||||
if (len(parts) == 4 and parts[2] == "traces"
|
||||
and parts[3].endswith(".json")):
|
||||
return JaegerScope(
|
||||
level="trace",
|
||||
service=service,
|
||||
trace_id=parts[3].removesuffix(".json"),
|
||||
resource_path=raw,
|
||||
)
|
||||
|
||||
return JaegerScope(level="unknown", resource_path=raw)
|
||||
@@ -0,0 +1,125 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.core.jaeger._client import is_trace_id
|
||||
from mirage.core.jaeger.readdir import assert_service, readdir
|
||||
from mirage.core.jaeger.scope import OPERATIONS_FILE, detect_scope
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
from mirage.utils.key_prefix import mount_prefix_of
|
||||
|
||||
|
||||
async def _assert_listed(
|
||||
accessor: JaegerAccessor,
|
||||
path: PathSpec,
|
||||
index: IndexCacheStore,
|
||||
) -> None:
|
||||
"""Raise ENOENT unless the path appears in its parent's listing.
|
||||
|
||||
Every path shape jaeger serves is recognizable from the text alone, but a
|
||||
recognizable shape is not evidence the trace exists. The parent listing is
|
||||
index-cached, so this costs one listing per directory rather than one API
|
||||
call per stat.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
path (PathSpec): path being stat'd.
|
||||
index (IndexCacheStore): index cache.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: the entry is absent from its parent listing.
|
||||
"""
|
||||
virtual = path.virtual.rstrip("/")
|
||||
prefix = mount_prefix_of(path.virtual, path.resource_path)
|
||||
parent_virtual = virtual.rsplit("/", 1)[0] or "/"
|
||||
parent_resource = parent_virtual
|
||||
if prefix and parent_virtual.startswith(prefix):
|
||||
parent_resource = parent_virtual[len(prefix):]
|
||||
entries = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=parent_resource.strip("/"),
|
||||
virtual=parent_virtual,
|
||||
directory=parent_virtual),
|
||||
index,
|
||||
)
|
||||
names = {entry.rstrip("/").rsplit("/", 1)[-1] for entry in entries}
|
||||
if path.resource_path.rstrip("/").rsplit("/", 1)[-1] not in names:
|
||||
raise enoent(virtual)
|
||||
|
||||
|
||||
async def stat(
|
||||
accessor: JaegerAccessor,
|
||||
path: PathSpec,
|
||||
index: IndexCacheStore = NULL_INDEX,
|
||||
) -> FileStat:
|
||||
"""Get file stat for a path.
|
||||
|
||||
Args:
|
||||
accessor (JaegerAccessor): jaeger accessor.
|
||||
path (PathSpec): resource-relative path.
|
||||
index (IndexCacheStore): index cache.
|
||||
|
||||
Returns:
|
||||
FileStat: stat for the path.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: the path is not a jaeger entry.
|
||||
"""
|
||||
virtual = path.virtual
|
||||
key = path.resource_path
|
||||
|
||||
if not key:
|
||||
return FileStat(name="/", type=FileType.DIRECTORY)
|
||||
|
||||
if any(p.startswith(".") for p in key.split("/")):
|
||||
raise enoent(virtual)
|
||||
|
||||
scope = detect_scope(path)
|
||||
|
||||
if scope.level == "services":
|
||||
return FileStat(name="services", type=FileType.DIRECTORY)
|
||||
|
||||
if scope.level == "service":
|
||||
assert scope.service is not None
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
return FileStat(
|
||||
name=scope.service,
|
||||
type=FileType.DIRECTORY,
|
||||
extra={"service": scope.service},
|
||||
)
|
||||
|
||||
if scope.level == "traces":
|
||||
assert scope.service is not None
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
return FileStat(name="traces", type=FileType.DIRECTORY)
|
||||
|
||||
if scope.level == "operations":
|
||||
assert scope.service is not None
|
||||
await assert_service(accessor, scope.service, virtual)
|
||||
return FileStat(name=OPERATIONS_FILE, type=FileType.JSON)
|
||||
|
||||
if scope.level == "trace":
|
||||
assert scope.trace_id is not None
|
||||
if not is_trace_id(scope.trace_id):
|
||||
raise enoent(virtual)
|
||||
await _assert_listed(accessor, path, index)
|
||||
return FileStat(
|
||||
name=f"{scope.trace_id}.json",
|
||||
type=FileType.JSON,
|
||||
extra={"trace_id": scope.trace_id},
|
||||
)
|
||||
|
||||
raise enoent(virtual)
|
||||
@@ -12,11 +12,49 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
from collections.abc import Awaitable
|
||||
from datetime import datetime
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from langfuse.api.client import AsyncLangfuseAPI
|
||||
from langfuse.api.core.api_error import ApiError
|
||||
|
||||
from mirage.utils.errors import enoent
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
async def fetch_or_enoent(pending: Awaitable[T], virtual: str) -> T:
|
||||
"""Await a Langfuse fetch, translating a 404 into ENOENT.
|
||||
|
||||
Every other status stays an ApiError: only "this resource does not exist"
|
||||
is a filesystem-level missing file.
|
||||
|
||||
Args:
|
||||
pending (Awaitable[T]): pending Langfuse API call.
|
||||
virtual (str): virtual path named in the ENOENT message.
|
||||
|
||||
Returns:
|
||||
T: the API result.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: Langfuse reported 404 for this resource.
|
||||
"""
|
||||
try:
|
||||
return await pending
|
||||
except ApiError as exc:
|
||||
if exc.status_code == 404:
|
||||
raise enoent(virtual) from exc
|
||||
raise
|
||||
|
||||
|
||||
# mode="json" is load-bearing: the SDK's models only apply their field
|
||||
# aliases (createdAt, userId, expectedOutput, ...) and datetime serialization
|
||||
# inside a json-mode serializer. A python-mode dump returns snake_case keys,
|
||||
# and by_alias does not restore them. The cost is that timestamps carry
|
||||
# microsecond precision where the API sent milliseconds, a rendering
|
||||
# divergence from TypeScript that is documented rather than papered over with
|
||||
# string rewriting that could alter trace content.
|
||||
def _to_dict(obj) -> dict[str, Any]:
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump(mode="json")
|
||||
@@ -32,6 +70,7 @@ async def fetch_traces(
|
||||
user_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
order_by: str | None = None,
|
||||
from_timestamp: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
kwargs: dict[str, Any] = {"limit": limit}
|
||||
if name:
|
||||
@@ -42,6 +81,9 @@ async def fetch_traces(
|
||||
kwargs["session_id"] = session_id
|
||||
if order_by:
|
||||
kwargs["order_by"] = order_by
|
||||
if from_timestamp:
|
||||
kwargs["from_timestamp"] = datetime.fromisoformat(
|
||||
from_timestamp.replace("Z", "+00:00"))
|
||||
result = await api.trace.list(**kwargs)
|
||||
return [_to_dict(t) for t in result.data]
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ from typing import Any
|
||||
from mirage.accessor.langfuse import LangfuseAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.core.langfuse._client import (fetch_dataset_items,
|
||||
fetch_dataset_runs, fetch_prompt,
|
||||
fetch_trace)
|
||||
fetch_dataset_runs, fetch_or_enoent,
|
||||
fetch_prompt, fetch_trace)
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
from mirage.utils.key_prefix import mount_prefix_of
|
||||
@@ -63,36 +63,44 @@ async def read(
|
||||
|
||||
if parts[0] == "traces" and len(parts) == 2 and parts[1].endswith(".json"):
|
||||
trace_id = parts[1].removesuffix(".json")
|
||||
data = await fetch_trace(accessor.api, trace_id)
|
||||
data = await fetch_or_enoent(fetch_trace(accessor.api, trace_id),
|
||||
virtual)
|
||||
return _json_bytes(data)
|
||||
|
||||
if (parts[0] == "sessions" and len(parts) == 3
|
||||
and parts[2].endswith(".json")):
|
||||
trace_id = parts[2].removesuffix(".json")
|
||||
data = await fetch_trace(accessor.api, trace_id)
|
||||
data = await fetch_or_enoent(fetch_trace(accessor.api, trace_id),
|
||||
virtual)
|
||||
return _json_bytes(data)
|
||||
|
||||
if (parts[0] == "prompts" and len(parts) == 3
|
||||
and parts[2].endswith(".json")):
|
||||
prompt_name = parts[1]
|
||||
version = int(parts[2].removesuffix(".json"))
|
||||
data = await fetch_prompt(accessor.api, prompt_name, version)
|
||||
data = await fetch_or_enoent(
|
||||
fetch_prompt(accessor.api, prompt_name, version), virtual)
|
||||
return _json_bytes(data)
|
||||
|
||||
if (parts[0] == "datasets" and len(parts) == 3
|
||||
and parts[2] == "items.jsonl"):
|
||||
dataset_name = parts[1]
|
||||
items = await fetch_dataset_items(accessor.api, dataset_name)
|
||||
items = await fetch_or_enoent(
|
||||
fetch_dataset_items(accessor.api, dataset_name), virtual)
|
||||
return _jsonl_bytes(items)
|
||||
|
||||
if (parts[0] == "datasets" and len(parts) == 4 and parts[2] == "runs"
|
||||
and parts[3].endswith(".jsonl")):
|
||||
dataset_name = parts[1]
|
||||
run_name = parts[3].removesuffix(".jsonl")
|
||||
runs = await fetch_dataset_runs(accessor.api, dataset_name)
|
||||
runs = await fetch_or_enoent(
|
||||
fetch_dataset_runs(accessor.api, dataset_name), virtual)
|
||||
matched = [r for r in runs if r.get("name") == run_name]
|
||||
if not matched:
|
||||
raise enoent(virtual)
|
||||
return _json_bytes(matched[0])
|
||||
# A .jsonl path must render as line-delimited JSON, not an indented
|
||||
# document: readers that split on newlines (jq) otherwise choke on the
|
||||
# first bare brace.
|
||||
return _jsonl_bytes(matched[:1])
|
||||
|
||||
raise enoent(virtual)
|
||||
|
||||
@@ -110,7 +110,11 @@ async def _readdir_traces(
|
||||
if listing.entries is not None:
|
||||
return listing.entries
|
||||
limit = accessor.config.default_trace_limit
|
||||
traces = await fetch_traces(accessor.api, limit=limit)
|
||||
traces = await fetch_traces(
|
||||
accessor.api,
|
||||
limit=limit,
|
||||
from_timestamp=accessor.config.default_from_timestamp,
|
||||
)
|
||||
entries = []
|
||||
names = []
|
||||
for t in traces:
|
||||
@@ -169,6 +173,7 @@ async def _readdir_session_traces(
|
||||
accessor.api,
|
||||
session_id=session_id,
|
||||
limit=limit,
|
||||
from_timestamp=accessor.config.default_from_timestamp,
|
||||
)
|
||||
entries = []
|
||||
names = []
|
||||
@@ -233,16 +238,18 @@ async def _readdir_prompt_versions(
|
||||
for p in prompts:
|
||||
if p.get("name") != prompt_name:
|
||||
continue
|
||||
version = p.get("version", 0)
|
||||
filename = f"{version}.json"
|
||||
entry = IndexEntry(
|
||||
id=f"{prompt_name}/{version}",
|
||||
name=str(version),
|
||||
resource_type="langfuse/prompt_version",
|
||||
vfs_name=filename,
|
||||
)
|
||||
entries.append((filename, entry))
|
||||
names.append(f"{prefix}/prompts/{prompt_name}/{filename}")
|
||||
# The list endpoint returns PromptMeta, which carries every version
|
||||
# of a prompt in a `versions` array; there is no scalar `version`.
|
||||
for version in sorted(p.get("versions", [])):
|
||||
filename = f"{version}.json"
|
||||
entry = IndexEntry(
|
||||
id=f"{prompt_name}/{version}",
|
||||
name=str(version),
|
||||
resource_type="langfuse/prompt_version",
|
||||
vfs_name=filename,
|
||||
)
|
||||
entries.append((filename, entry))
|
||||
names.append(f"{prefix}/prompts/{prompt_name}/{filename}")
|
||||
await index.set_dir(virtual_key, entries)
|
||||
return names
|
||||
|
||||
|
||||
@@ -74,4 +74,7 @@ def detect_scope(path: PathSpec) -> LangfuseScope:
|
||||
resource_path=raw,
|
||||
)
|
||||
|
||||
return LangfuseScope(level="root", resource_path=raw)
|
||||
# An unrecognized path is not the mount root: falling back to "root" made
|
||||
# the grep/rg search push-down treat any bogus path as "search every
|
||||
# trace", answering a missing file with the whole mount and exit 0.
|
||||
return LangfuseScope(level="unknown", resource_path=raw)
|
||||
|
||||
@@ -14,13 +14,55 @@
|
||||
|
||||
from mirage.accessor.langfuse import LangfuseAccessor
|
||||
from mirage.cache.index import NULL_INDEX, IndexCacheStore
|
||||
from mirage.core.langfuse.readdir import readdir
|
||||
from mirage.types import FileStat, FileType, PathSpec
|
||||
from mirage.utils.errors import enoent
|
||||
from mirage.utils.key_prefix import mount_prefix_of
|
||||
from mirage.utils.key_prefix import mount_key, mount_prefix_of
|
||||
|
||||
TOP_LEVEL_DIRS = {"traces", "sessions", "prompts", "datasets"}
|
||||
|
||||
|
||||
def basename_of(entry: str) -> str:
|
||||
return entry.rstrip("/").rsplit("/", 1)[-1]
|
||||
|
||||
|
||||
async def assert_listed(
|
||||
accessor: LangfuseAccessor,
|
||||
path: PathSpec,
|
||||
prefix: str,
|
||||
index: IndexCacheStore,
|
||||
) -> None:
|
||||
"""Raise ENOENT unless the path appears in its parent's listing.
|
||||
|
||||
Every path shape langfuse serves is recognizable from the path text alone,
|
||||
but a recognizable shape is not evidence that the trace, prompt, dataset or
|
||||
run behind it exists. The parent listing is index-cached, so validating
|
||||
costs one listing per directory rather than one API call per stat.
|
||||
|
||||
Args:
|
||||
accessor (LangfuseAccessor): langfuse accessor.
|
||||
path (PathSpec): resource-relative path being stat'd.
|
||||
prefix (str): mount prefix for virtual index keys.
|
||||
index (IndexCacheStore): index cache.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: the entry is absent from its parent listing.
|
||||
"""
|
||||
parent_virtual = path.virtual.rstrip("/").rsplit("/", 1)[0] or "/"
|
||||
entries = await readdir(
|
||||
accessor,
|
||||
PathSpec(virtual=parent_virtual,
|
||||
directory=parent_virtual,
|
||||
resource_path=mount_key(parent_virtual, prefix)),
|
||||
index,
|
||||
)
|
||||
if basename_of(path.resource_path) not in {
|
||||
basename_of(entry)
|
||||
for entry in entries
|
||||
}:
|
||||
raise enoent(path.virtual)
|
||||
|
||||
|
||||
async def stat(
|
||||
accessor: LangfuseAccessor,
|
||||
path: PathSpec,
|
||||
@@ -35,7 +77,7 @@ async def stat(
|
||||
prefix (str): mount prefix for virtual index keys.
|
||||
"""
|
||||
virtual = path.virtual
|
||||
mount_prefix_of(path.virtual, path.resource_path)
|
||||
prefix = mount_prefix_of(path.virtual, path.resource_path)
|
||||
key = path.resource_path
|
||||
|
||||
if not key:
|
||||
@@ -50,9 +92,11 @@ async def stat(
|
||||
return FileStat(name=parts[0], type=FileType.DIRECTORY)
|
||||
|
||||
if parts[0] == "traces" and len(parts) == 2 and parts[1].endswith(".json"):
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(name=parts[1], type=FileType.JSON)
|
||||
|
||||
if parts[0] == "sessions" and len(parts) == 2:
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(
|
||||
name=parts[1],
|
||||
type=FileType.DIRECTORY,
|
||||
@@ -61,9 +105,11 @@ async def stat(
|
||||
|
||||
if (parts[0] == "sessions" and len(parts) == 3
|
||||
and parts[2].endswith(".json")):
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(name=parts[2], type=FileType.JSON)
|
||||
|
||||
if parts[0] == "prompts" and len(parts) == 2:
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(
|
||||
name=parts[1],
|
||||
type=FileType.DIRECTORY,
|
||||
@@ -72,9 +118,11 @@ async def stat(
|
||||
|
||||
if (parts[0] == "prompts" and len(parts) == 3
|
||||
and parts[2].endswith(".json")):
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(name=parts[2], type=FileType.JSON)
|
||||
|
||||
if parts[0] == "datasets" and len(parts) == 2:
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(
|
||||
name=parts[1],
|
||||
type=FileType.DIRECTORY,
|
||||
@@ -83,13 +131,16 @@ async def stat(
|
||||
|
||||
if (parts[0] == "datasets" and len(parts) == 3
|
||||
and parts[2] == "items.jsonl"):
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(name="items.jsonl", type=FileType.TEXT)
|
||||
|
||||
if parts[0] == "datasets" and len(parts) == 3 and parts[2] == "runs":
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(name="runs", type=FileType.DIRECTORY)
|
||||
|
||||
if (parts[0] == "datasets" and len(parts) == 4 and parts[2] == "runs"
|
||||
and parts[3].endswith(".jsonl")):
|
||||
await assert_listed(accessor, path, prefix, index)
|
||||
return FileStat(name=parts[3], type=FileType.TEXT)
|
||||
|
||||
raise enoent(virtual)
|
||||
|
||||
@@ -280,4 +280,7 @@ async def readdir(
|
||||
await index.set_dir(idx_key, entries)
|
||||
return [f"{prefix}/{key}/{name}" for name, _ in entries]
|
||||
|
||||
return []
|
||||
# An unrecognized path is not an empty directory: returning [] made `ls`
|
||||
# and `tree` report a bogus path as a real-but-empty one, and left `rg`
|
||||
# without a message.
|
||||
raise enoent(virtual)
|
||||
|
||||
@@ -310,7 +310,9 @@ async def readdir(
|
||||
return await _readdir_files_dir(accessor, path, prefix, key,
|
||||
virtual_key, container, parts[1],
|
||||
parts[2], index)
|
||||
return []
|
||||
# An unrecognized path is not an empty directory: returning [] made
|
||||
# `ls` and `tree` report a bogus path as a real-but-empty one.
|
||||
raise enoent(path.virtual)
|
||||
|
||||
|
||||
async def _fetch_day(
|
||||
|
||||
@@ -323,4 +323,7 @@ async def readdir(
|
||||
raise enoent(virtual)
|
||||
return [f"{prefix}/{key}/card.json", f"{prefix}/{key}/comments.jsonl"]
|
||||
|
||||
return []
|
||||
# An unrecognized path is not an empty directory: returning [] made `ls`
|
||||
# and `tree` report a bogus path as a real-but-empty one, and left `rg`
|
||||
# without a message.
|
||||
raise enoent(virtual)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.commands.builtin.jaeger.io import IO
|
||||
from mirage.ops.generic import make_generic_ops
|
||||
|
||||
OPS = make_generic_ops("jaeger", IO)
|
||||
@@ -0,0 +1,24 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
|
||||
__all__ = ["JaegerConfig", "JaegerResource"]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "JaegerResource":
|
||||
from mirage.resource.jaeger.jaeger import JaegerResource
|
||||
return JaegerResource
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,27 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class JaegerConfig(BaseModel):
|
||||
host: str = "http://localhost:16686"
|
||||
default_trace_limit: int = 100
|
||||
# Jaeger's search endpoint takes an explicit microsecond window and its
|
||||
# `lookback` parameter is ignored, so a window is always sent. Unset means
|
||||
# "from the beginning": an implicit recent-only window would hide traces
|
||||
# that read() happily serves.
|
||||
default_from_timestamp: str | None = None
|
||||
default_to_timestamp: str | None = None
|
||||
request_timeout: int = 30
|
||||
@@ -0,0 +1,57 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from typing import Any
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.commands.builtin.jaeger import COMMANDS
|
||||
from mirage.core.jaeger.readdir import readdir
|
||||
from mirage.ops.jaeger import OPS as JAEGER_VFS_OPS
|
||||
from mirage.resource.base import BaseResource
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
from mirage.resource.jaeger.prompt import PROMPT
|
||||
from mirage.types import ResourceName
|
||||
from mirage.utils.glob_walk import make_resolve_glob
|
||||
|
||||
_resolve_glob = make_resolve_glob(readdir)
|
||||
|
||||
|
||||
class JaegerResource(BaseResource):
|
||||
|
||||
accessor: JaegerAccessor
|
||||
name: str = ResourceName.JAEGER
|
||||
caches_reads: bool = True
|
||||
PROMPT: str = PROMPT
|
||||
|
||||
def __init__(self, config: JaegerConfig) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.accessor = JaegerAccessor(self.config)
|
||||
for command in COMMANDS:
|
||||
self.register(command)
|
||||
for op in JAEGER_VFS_OPS:
|
||||
self.register_op(op)
|
||||
|
||||
async def resolve_glob(self, paths, prefix: str = ""):
|
||||
return await _resolve_glob(
|
||||
self.accessor,
|
||||
paths,
|
||||
index=self._index,
|
||||
)
|
||||
|
||||
def get_state(self) -> dict[str, Any]:
|
||||
return self.config_state(self.config)
|
||||
|
||||
def load_state(self, state: dict[str, Any]) -> None:
|
||||
pass
|
||||
@@ -0,0 +1,21 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
PROMPT = """\
|
||||
{prefix}
|
||||
services/
|
||||
<service-name>/
|
||||
operations.json
|
||||
traces/
|
||||
<trace-id>.json"""
|
||||
@@ -21,3 +21,7 @@ class LangfuseConfig(BaseModel):
|
||||
host: str = "https://cloud.langfuse.com"
|
||||
default_trace_limit: int = 100
|
||||
default_search_limit: int = 50
|
||||
# Opt-in lower bound for trace listings. Unset lists whatever the project
|
||||
# holds, up to default_trace_limit: an implicit rolling window would hide
|
||||
# traces that read() happily serves.
|
||||
default_from_timestamp: str | None = None
|
||||
|
||||
@@ -152,6 +152,9 @@ REGISTRY: dict[str, ResourceEntry] = {
|
||||
"langfuse":
|
||||
ResourceEntry("mirage.resource.langfuse:LangfuseResource",
|
||||
"mirage.resource.langfuse:LangfuseConfig"),
|
||||
"jaeger":
|
||||
ResourceEntry("mirage.resource.jaeger:JaegerResource",
|
||||
"mirage.resource.jaeger:JaegerConfig"),
|
||||
"ssh":
|
||||
ResourceEntry("mirage.resource.ssh:SSHResource",
|
||||
"mirage.resource.ssh:SSHConfig"),
|
||||
|
||||
@@ -312,6 +312,7 @@ class ResourceName(str, Enum):
|
||||
POSTGRES = "postgres"
|
||||
NOTION = "notion"
|
||||
LANGFUSE = "langfuse"
|
||||
JAEGER = "jaeger"
|
||||
SSH = "ssh"
|
||||
REDIS = "redis"
|
||||
GITHUB_CI = "github_ci"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from mirage import MountMode, Workspace
|
||||
from mirage.commands.builtin.generic.du import _depth, du, du_multi
|
||||
from mirage.resource.disk import DiskResource
|
||||
from mirage.types import PathSpec
|
||||
|
||||
|
||||
@@ -271,3 +273,29 @@ def test_depth_helper_direct_child_is_one():
|
||||
|
||||
def test_depth_helper_nested():
|
||||
assert _depth("/dir/sub/b.txt", "/dir") == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_missing_operand_reports_and_exits_1(tmp_path):
|
||||
# GNU: "du: cannot access 'X': No such file or directory", exit 1. Walking
|
||||
# a missing operand used to report it as size 0 with exit 0.
|
||||
res = DiskResource(root=str(tmp_path))
|
||||
ws = Workspace({"/d": res}, mode=MountMode.WRITE)
|
||||
result = await ws.execute("du /d/__nf_missing__")
|
||||
assert result.exit_code == 1
|
||||
assert await result.stdout_str() == ""
|
||||
assert (await result.stderr_str()) == (
|
||||
"du: cannot access '/d/__nf_missing__': No such file or directory\n")
|
||||
await ws.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_du_partial_operands_keeps_present_output(tmp_path):
|
||||
res = DiskResource(root=str(tmp_path))
|
||||
ws = Workspace({"/d": res}, mode=MountMode.WRITE)
|
||||
await ws.execute("mkdir -p /d/sub")
|
||||
result = await ws.execute("du /d/sub /d/__nf_missing__")
|
||||
assert result.exit_code == 1
|
||||
assert "/d/sub" in await result.stdout_str()
|
||||
assert "__nf_missing__" in await result.stderr_str()
|
||||
await ws.close()
|
||||
|
||||
@@ -208,7 +208,9 @@ async def test_tree_missing_path_marks_error_and_exits_2():
|
||||
"/nowhere [error opening dir]", "", "0 directories, 0 files"
|
||||
]
|
||||
assert io.exit_code == 2
|
||||
assert b"nowhere" in (io.stderr or b"")
|
||||
# GNU signals this with the inline marker and exit 2 and writes nothing to
|
||||
# stderr; TypeScript already behaved this way.
|
||||
assert io.stderr is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.core.jaeger._client import (JaegerApiError, fetch_traces,
|
||||
is_trace_id)
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
|
||||
def __init__(self, payload, status_code: int = 200) -> None:
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class RecordingAccessor(JaegerAccessor):
|
||||
|
||||
def __init__(self, config: JaegerConfig, payload, status_code=200) -> None:
|
||||
super().__init__(config)
|
||||
self.calls: list[tuple[str, dict | None]] = []
|
||||
self._payload = payload
|
||||
self._status = status_code
|
||||
|
||||
async def request(self, endpoint, params=None):
|
||||
self.calls.append((endpoint, params))
|
||||
return FakeResponse(self._payload, self._status)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value,valid", [
|
||||
("a" * 32, True),
|
||||
("a" * 16, True),
|
||||
("A" * 32, True),
|
||||
("zzz", False),
|
||||
("a" * 31, False),
|
||||
("a" * 33, False),
|
||||
("", False),
|
||||
])
|
||||
def test_is_trace_id(value, valid):
|
||||
assert is_trace_id(value) is valid
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_traces_sends_explicit_microsecond_window():
|
||||
# Jaeger ignores `lookback`, so an explicit start/end must always be sent
|
||||
# or the search silently returns nothing.
|
||||
accessor = RecordingAccessor(JaegerConfig(), {"data": []})
|
||||
await fetch_traces(accessor, "checkout", limit=7)
|
||||
endpoint, params = accessor.calls[0]
|
||||
assert endpoint == "/api/traces"
|
||||
assert params["service"] == "checkout"
|
||||
assert params["limit"] == 7
|
||||
assert params["start"] == 0
|
||||
assert params["end"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_traces_converts_iso_window_to_micros():
|
||||
accessor = RecordingAccessor(JaegerConfig(), {"data": []})
|
||||
await fetch_traces(
|
||||
accessor,
|
||||
"checkout",
|
||||
from_timestamp="2026-01-01T00:00:00Z",
|
||||
to_timestamp="2026-01-02T00:00:00Z",
|
||||
)
|
||||
_endpoint, params = accessor.calls[0]
|
||||
assert params["start"] == 1767225600000000
|
||||
assert params["end"] == 1767312000000000
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_traces_surfaces_api_error_message():
|
||||
accessor = RecordingAccessor(
|
||||
JaegerConfig(),
|
||||
{"errors": [{
|
||||
"code": 400,
|
||||
"msg": "parameter 'service' is required"
|
||||
}]},
|
||||
status_code=400,
|
||||
)
|
||||
with pytest.raises(JaegerApiError) as excinfo:
|
||||
await fetch_traces(accessor, "checkout")
|
||||
assert excinfo.value.status_code == 400
|
||||
assert "service" in str(excinfo.value)
|
||||
@@ -0,0 +1,175 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.jaeger._client import JaegerApiError
|
||||
from mirage.core.jaeger.read import read
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
TRACE_A = "a" * 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return JaegerAccessor(config=JaegerConfig())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
def spec(path: str) -> PathSpec:
|
||||
virtual = f"/{path}"
|
||||
return PathSpec(resource_path=path, virtual=virtual, directory=virtual)
|
||||
|
||||
|
||||
def known_service():
|
||||
return patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_trace(accessor, index):
|
||||
doc = {
|
||||
"traceID": TRACE_A,
|
||||
"spans": [{
|
||||
"operationName": "POST /checkout",
|
||||
"processID": "p1"
|
||||
}],
|
||||
"processes": {
|
||||
"p1": {
|
||||
"serviceName": "checkout"
|
||||
}
|
||||
},
|
||||
}
|
||||
with known_service():
|
||||
with patch("mirage.core.jaeger.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
return_value=doc):
|
||||
raw = await read(accessor,
|
||||
spec(f"services/checkout/traces/{TRACE_A}.json"),
|
||||
index)
|
||||
assert json.loads(raw) == doc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_trace_rejects_foreign_service(accessor, index):
|
||||
# stat and ls report this path absent, so cat must agree; reading by id
|
||||
# would otherwise serve any trace through any service directory.
|
||||
doc = {
|
||||
"traceID": TRACE_A,
|
||||
"spans": [{
|
||||
"operationName": "POST /checkout",
|
||||
"processID": "p1"
|
||||
}],
|
||||
"processes": {
|
||||
"p1": {
|
||||
"serviceName": "checkout"
|
||||
}
|
||||
},
|
||||
}
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout", "search"]):
|
||||
with patch("mirage.core.jaeger.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
return_value=doc):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor,
|
||||
spec(f"services/search/traces/{TRACE_A}.json"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_trace_rejects_unknown_service(accessor, index):
|
||||
with known_service():
|
||||
with patch("mirage.core.jaeger.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"traceID": TRACE_A}) as fetch:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor,
|
||||
spec(f"services/nope/traces/{TRACE_A}.json"), index)
|
||||
fetch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_operations(accessor, index):
|
||||
ops = [{"name": "POST /checkout", "spanKind": "server"}]
|
||||
with known_service():
|
||||
with patch("mirage.core.jaeger.read.fetch_operations",
|
||||
new_callable=AsyncMock,
|
||||
return_value=ops):
|
||||
raw = await read(accessor,
|
||||
spec("services/checkout/operations.json"), index)
|
||||
assert json.loads(raw) == ops
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_malformed_trace_id_is_enoent(accessor, index):
|
||||
# A malformed id cannot name an existing trace, so it must not reach the
|
||||
# API and come back as a 400.
|
||||
fake = AsyncMock()
|
||||
with known_service():
|
||||
with patch("mirage.core.jaeger.read.fetch_trace", fake):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor, spec("services/checkout/traces/zzz.json"),
|
||||
index)
|
||||
fake.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_missing_trace_is_enoent(accessor, index):
|
||||
with known_service():
|
||||
with patch("mirage.core.jaeger.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=JaegerApiError("trace not found", 404)):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor,
|
||||
spec(f"services/checkout/traces/{TRACE_A}.json"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_server_error_propagates(accessor, index):
|
||||
# A server fault must not read as "this trace does not exist".
|
||||
with known_service():
|
||||
with patch("mirage.core.jaeger.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=JaegerApiError("boom", 500)):
|
||||
with pytest.raises(JaegerApiError):
|
||||
await read(accessor,
|
||||
spec(f"services/checkout/traces/{TRACE_A}.json"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_unknown_service_is_enoent(accessor, index):
|
||||
with known_service():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor, spec("services/nope/operations.json"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_directory_is_enoent(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(accessor, spec("services"), index)
|
||||
@@ -0,0 +1,143 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.jaeger.readdir import readdir
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
from mirage.types import PathSpec
|
||||
|
||||
TRACE_A = "a" * 32
|
||||
TRACE_B = "b" * 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return JaegerAccessor(config=JaegerConfig())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
def spec(path: str) -> PathSpec:
|
||||
virtual = f"/{path}" if path else "/"
|
||||
return PathSpec(resource_path=path, virtual=virtual, directory=virtual)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(accessor, index):
|
||||
assert await readdir(accessor, spec(""), index) == ["/services"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_services(accessor, index):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout", "search"]):
|
||||
result = await readdir(accessor, spec("services"), index)
|
||||
assert result == ["/services/checkout", "/services/search"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_service_children(accessor, index):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"]):
|
||||
result = await readdir(accessor, spec("services/checkout"), index)
|
||||
assert result == [
|
||||
"/services/checkout/operations.json",
|
||||
"/services/checkout/traces",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_unknown_service_raises(accessor, index):
|
||||
# The operations endpoint answers 200 with an empty list for a service
|
||||
# that was never seen, so existence has to come from the service list.
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"]):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(accessor, spec("services/nope"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_traces(accessor, index):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"]):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_traces",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[{
|
||||
"traceID": TRACE_A
|
||||
}, {
|
||||
"traceID": TRACE_B
|
||||
}]):
|
||||
result = await readdir(accessor, spec("services/checkout/traces"),
|
||||
index)
|
||||
assert result == [
|
||||
f"/services/checkout/traces/{TRACE_A}.json",
|
||||
f"/services/checkout/traces/{TRACE_B}.json",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_traces_skips_malformed_ids(accessor, index):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"]):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_traces",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[{
|
||||
"traceID": TRACE_A
|
||||
}, {
|
||||
"traceID": "not-a-trace-id"
|
||||
}, {}]):
|
||||
result = await readdir(accessor, spec("services/checkout/traces"),
|
||||
index)
|
||||
assert result == [f"/services/checkout/traces/{TRACE_A}.json"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_traces_threads_configured_window(index):
|
||||
configured = JaegerAccessor(config=JaegerConfig(
|
||||
default_trace_limit=5,
|
||||
default_from_timestamp="2026-01-01T00:00:00Z",
|
||||
))
|
||||
fake = AsyncMock(return_value=[])
|
||||
with patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"]):
|
||||
with patch("mirage.core.jaeger.readdir.fetch_traces", fake):
|
||||
await readdir(configured, spec("services/checkout/traces"), index)
|
||||
assert fake.await_args.kwargs["limit"] == 5
|
||||
assert fake.await_args.kwargs["from_timestamp"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_dotfile_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(accessor, spec("services/.hidden"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_unknown_path_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(accessor, spec("traces"), index)
|
||||
@@ -0,0 +1,50 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.core.jaeger.scope import detect_scope
|
||||
|
||||
TRACE = "a" * 32
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path,level",
|
||||
[
|
||||
("", "root"),
|
||||
("/", "root"),
|
||||
("services", "services"),
|
||||
("services/checkout", "service"),
|
||||
("services/checkout/operations.json", "operations"),
|
||||
("services/checkout/traces", "traces"),
|
||||
(f"services/checkout/traces/{TRACE}.json", "trace"),
|
||||
("traces", "unknown"),
|
||||
("services/checkout/traces/deep/nested.json", "unknown"),
|
||||
("services/checkout/unknown.json", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_detect_scope_levels(path, level):
|
||||
assert detect_scope(path).level == level
|
||||
|
||||
|
||||
def test_detect_scope_carries_service_and_trace_id():
|
||||
scope = detect_scope(f"services/checkout/traces/{TRACE}.json")
|
||||
assert scope.service == "checkout"
|
||||
assert scope.trace_id == TRACE
|
||||
|
||||
|
||||
def test_detect_scope_service_without_trace():
|
||||
scope = detect_scope("services/checkout/traces")
|
||||
assert scope.service == "checkout"
|
||||
assert scope.trace_id is None
|
||||
@@ -0,0 +1,127 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.jaeger import JaegerAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.jaeger.stat import stat
|
||||
from mirage.resource.jaeger.config import JaegerConfig
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
TRACE_A = "a" * 32
|
||||
TRACE_B = "b" * 32
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return JaegerAccessor(config=JaegerConfig())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def index():
|
||||
return RAMIndexCacheStore()
|
||||
|
||||
|
||||
def spec(path: str) -> PathSpec:
|
||||
virtual = f"/{path}" if path else "/"
|
||||
return PathSpec(resource_path=path, virtual=virtual, directory=virtual)
|
||||
|
||||
|
||||
def known_service():
|
||||
return patch("mirage.core.jaeger.readdir.fetch_services",
|
||||
new_callable=AsyncMock,
|
||||
return_value=["checkout"])
|
||||
|
||||
|
||||
def listed_traces(ids):
|
||||
return patch("mirage.core.jaeger.readdir.fetch_traces",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[{
|
||||
"traceID": tid
|
||||
} for tid in ids])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_root(accessor, index):
|
||||
result = await stat(accessor, spec(""), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_services_dir(accessor, index):
|
||||
result = await stat(accessor, spec("services"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "services"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_service_dir(accessor, index):
|
||||
with known_service():
|
||||
result = await stat(accessor, spec("services/checkout"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.extra["service"] == "checkout"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_unknown_service_raises(accessor, index):
|
||||
with known_service():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor, spec("services/nope"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_operations_file(accessor, index):
|
||||
with known_service():
|
||||
result = await stat(accessor,
|
||||
spec("services/checkout/operations.json"), index)
|
||||
assert result.type == FileType.JSON
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_listed_trace(accessor, index):
|
||||
with known_service():
|
||||
with listed_traces([TRACE_A]):
|
||||
result = await stat(
|
||||
accessor, spec(f"services/checkout/traces/{TRACE_A}.json"),
|
||||
index)
|
||||
assert result.type == FileType.JSON
|
||||
assert result.extra["trace_id"] == TRACE_A
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_unlisted_trace_raises(accessor, index):
|
||||
# A well-formed id is not evidence the trace exists.
|
||||
with known_service():
|
||||
with listed_traces([TRACE_A]):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor,
|
||||
spec(f"services/checkout/traces/{TRACE_B}.json"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_malformed_trace_id_raises(accessor, index):
|
||||
with known_service():
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor, spec("services/checkout/traces/zzz.json"),
|
||||
index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_dotfile_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(accessor, spec("services/.hidden"), index)
|
||||
@@ -16,6 +16,7 @@ import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from langfuse.api.core.api_error import ApiError
|
||||
|
||||
from mirage.accessor.langfuse import LangfuseAccessor
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
@@ -134,3 +135,57 @@ async def test_read_session_trace(accessor, index):
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert parsed["id"] == "tid1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_dataset_run_renders_jsonl(accessor, index):
|
||||
# The path ends in .jsonl, so it must be one compact JSON object per line
|
||||
# with a trailing newline, not an indented document.
|
||||
runs = [{"name": "run-a", "metadata": {}}, {"name": "run-b"}]
|
||||
with patch(
|
||||
"mirage.core.langfuse.read.fetch_dataset_runs",
|
||||
new_callable=AsyncMock,
|
||||
return_value=runs,
|
||||
):
|
||||
result = await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="datasets/qa-eval/runs/run-a.jsonl",
|
||||
virtual="/datasets/qa-eval/runs/run-a.jsonl",
|
||||
directory="/datasets/qa-eval/runs/run-a.jsonl"), index)
|
||||
|
||||
text = result.decode()
|
||||
assert text.endswith("\n")
|
||||
assert text.count("\n") == 1
|
||||
assert json.loads(text)["name"] == "run-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_trace_not_found_is_enoent(accessor, index):
|
||||
# A 404 from the API is a missing file, not a leaked SDK error string.
|
||||
with patch(
|
||||
"mirage.core.langfuse.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ApiError(status_code=404, body={"message": "nope"}),
|
||||
):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="traces/gone.json",
|
||||
virtual="/traces/gone.json",
|
||||
directory="/traces/gone.json"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_trace_server_error_propagates(accessor, index):
|
||||
# Only 404 maps to ENOENT; a 500 must not be disguised as a missing file.
|
||||
with patch(
|
||||
"mirage.core.langfuse.read.fetch_trace",
|
||||
new_callable=AsyncMock,
|
||||
side_effect=ApiError(status_code=500, body={"message": "boom"}),
|
||||
):
|
||||
with pytest.raises(ApiError):
|
||||
await read(
|
||||
accessor,
|
||||
PathSpec(resource_path="traces/tid1.json",
|
||||
virtual="/traces/tid1.json",
|
||||
directory="/traces/tid1.json"), index)
|
||||
|
||||
@@ -101,11 +101,11 @@ async def test_readdir_prompts(accessor, index):
|
||||
return_value=[
|
||||
{
|
||||
"name": "summarize",
|
||||
"version": 1
|
||||
"versions": [1]
|
||||
},
|
||||
{
|
||||
"name": "translate",
|
||||
"version": 1
|
||||
"versions": [1]
|
||||
},
|
||||
],
|
||||
):
|
||||
@@ -169,3 +169,70 @@ async def test_readdir_dotfile_nested_raises(accessor, index):
|
||||
PathSpec(resource_path="traces/.DS_Store",
|
||||
virtual="/traces/.DS_Store",
|
||||
directory="/traces/.DS_Store"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_prompt_versions(accessor, index):
|
||||
# PromptMeta carries every version in a `versions` array; a scalar
|
||||
# `version` read would collapse the directory to a single 0.json.
|
||||
with patch(
|
||||
"mirage.core.langfuse.readdir.fetch_prompts",
|
||||
new_callable=AsyncMock,
|
||||
return_value=[
|
||||
{
|
||||
"name": "summarize",
|
||||
"versions": [2, 1, 10]
|
||||
},
|
||||
{
|
||||
"name": "translate",
|
||||
"versions": [1]
|
||||
},
|
||||
],
|
||||
):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="prompts/summarize",
|
||||
virtual="/prompts/summarize",
|
||||
directory="/prompts/summarize"), index)
|
||||
|
||||
assert result == [
|
||||
"/prompts/summarize/1.json",
|
||||
"/prompts/summarize/2.json",
|
||||
"/prompts/summarize/10.json",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_traces_applies_no_window_by_default(accessor, index):
|
||||
# An implicit rolling window would hide traces that read() serves, so an
|
||||
# unset default_from_timestamp must not narrow the listing.
|
||||
fake = AsyncMock(return_value=[{"id": "old-trace"}])
|
||||
with patch("mirage.core.langfuse.readdir.fetch_traces", fake):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="traces",
|
||||
virtual="/traces",
|
||||
directory="/traces"), index)
|
||||
|
||||
assert result == ["/traces/old-trace.json"]
|
||||
assert fake.await_args.kwargs["from_timestamp"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_traces_passes_explicit_window(index):
|
||||
config = LangfuseConfig(
|
||||
public_key="pk-test",
|
||||
secret_key="sk-test",
|
||||
default_from_timestamp="2026-01-01T00:00:00Z",
|
||||
)
|
||||
with patch("mirage.accessor.langfuse.Langfuse"):
|
||||
windowed = LangfuseAccessor(config=config)
|
||||
fake = AsyncMock(return_value=[])
|
||||
with patch("mirage.core.langfuse.readdir.fetch_traces", fake):
|
||||
await readdir(
|
||||
windowed,
|
||||
PathSpec(resource_path="traces",
|
||||
virtual="/traces",
|
||||
directory="/traces"), index)
|
||||
|
||||
assert fake.await_args.kwargs["from_timestamp"] == "2026-01-01T00:00:00Z"
|
||||
|
||||
@@ -125,3 +125,10 @@ def test_glob_scope_file():
|
||||
assert scope.level == "file"
|
||||
assert scope.resource_type == "traces"
|
||||
assert scope.resource_id == "abc"
|
||||
|
||||
|
||||
def test_unrecognized_path_is_not_root():
|
||||
# Falling back to "root" made the grep/rg push-down treat any bogus path
|
||||
# as "search every trace", answering a missing file with the whole mount.
|
||||
assert detect_scope("__nf_missing__").level == "unknown"
|
||||
assert detect_scope("traces/a/b/c/d").level == "unknown"
|
||||
|
||||
@@ -17,12 +17,22 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.langfuse import LangfuseAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.langfuse.stat import stat
|
||||
from mirage.resource.langfuse.config import LangfuseConfig
|
||||
from mirage.types import FileType, PathSpec
|
||||
|
||||
|
||||
async def seed_dir(index, virtual_key: str, names: list[str]) -> None:
|
||||
await index.set_dir(
|
||||
virtual_key,
|
||||
[(name,
|
||||
IndexEntry(
|
||||
id=name, name=name, resource_type="langfuse/x", vfs_name=name))
|
||||
for name in names])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
config = LangfuseConfig(
|
||||
@@ -60,6 +70,7 @@ async def test_stat_traces_dir(accessor, index):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_trace_file(accessor, index):
|
||||
await seed_dir(index, "/traces", ["abc.json"])
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="traces/abc.json",
|
||||
@@ -71,6 +82,7 @@ async def test_stat_trace_file(accessor, index):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_session_dir(accessor, index):
|
||||
await seed_dir(index, "/sessions", ["sid1"])
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="sessions/sid1",
|
||||
@@ -82,6 +94,7 @@ async def test_stat_session_dir(accessor, index):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_prompt_version_file(accessor, index):
|
||||
await seed_dir(index, "/prompts/summarize", ["1.json"])
|
||||
result = await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="prompts/summarize/1.json",
|
||||
@@ -121,3 +134,27 @@ async def test_stat_dataset_runs_dir(accessor, index):
|
||||
directory="/datasets/qa-eval/runs"), index)
|
||||
assert result.type == FileType.DIRECTORY
|
||||
assert result.name == "runs"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_unlisted_trace_raises(accessor, index):
|
||||
# A recognizable path shape is not evidence the trace exists: an id absent
|
||||
# from the parent listing must be ENOENT, not a confident stat.
|
||||
await seed_dir(index, "/traces", ["present.json"])
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="traces/absent.json",
|
||||
virtual="/traces/absent.json",
|
||||
directory="/traces/absent.json"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stat_unlisted_prompt_version_raises(accessor, index):
|
||||
await seed_dir(index, "/prompts/summarize", ["1.json"])
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await stat(
|
||||
accessor,
|
||||
PathSpec(resource_path="prompts/summarize/9.json",
|
||||
virtual="/prompts/summarize/9.json",
|
||||
directory="/prompts/summarize/9.json"), index)
|
||||
|
||||
@@ -148,3 +148,25 @@ async def test_readdir_issue_folder(accessor, index):
|
||||
"/teams/ENG__Engineering__TEAM1/issues/ENG-123__ISSUE1/issue.json",
|
||||
"/teams/ENG__Engineering__TEAM1/issues/ENG-123__ISSUE1/comments.jsonl",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_unrecognized_path_raises(accessor, index):
|
||||
# Returning [] for an unknown path made `ls` and `tree` report a bogus path
|
||||
# as real-but-empty, and left `rg` without a message.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="__nf_missing__",
|
||||
virtual="/__nf_missing__",
|
||||
directory="/__nf_missing__"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_unrecognized_nested_path_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="teams/x/nope/deeper",
|
||||
virtual="/teams/x/nope/deeper",
|
||||
directory="/teams/x/nope/deeper"), index)
|
||||
|
||||
@@ -12,22 +12,18 @@
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from mirage.accessor.trello import TrelloAccessor
|
||||
from mirage.cache.index import IndexEntry
|
||||
from mirage.cache.index.ram import RAMIndexCacheStore
|
||||
from mirage.core.trello.readdir import readdir
|
||||
from mirage.resource.trello.config import TrelloConfig
|
||||
from mirage.types import PathSpec
|
||||
from mirage.utils.key_prefix import mount_key
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def accessor():
|
||||
return TrelloAccessor(TrelloConfig(api_key="key", api_token="token"))
|
||||
return TrelloAccessor(TrelloConfig(api_key="k", api_token="t"))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -37,142 +33,29 @@ def index():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root(accessor, index):
|
||||
result = await readdir(accessor, PathSpec.from_str_path("/"), index)
|
||||
result = await readdir(
|
||||
accessor, PathSpec(resource_path="", virtual="/", directory="/"),
|
||||
index)
|
||||
assert result == ["/workspaces"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_root_with_prefix(accessor, index):
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path=mount_key("trello/", "trello"),
|
||||
virtual="trello/",
|
||||
directory="trello/"), index)
|
||||
assert result == ["trello/workspaces"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_workspaces(accessor, index):
|
||||
workspaces = [{"id": "ws1", "displayName": "Engineering", "name": "eng"}]
|
||||
with patch("mirage.core.trello.readdir.list_workspaces",
|
||||
new_callable=AsyncMock,
|
||||
return_value=workspaces):
|
||||
result = await readdir(accessor, PathSpec.from_str_path("/workspaces"),
|
||||
index)
|
||||
assert result == ["/workspaces/Engineering__ws1"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_workspaces_keeps_prefix_on_warm_cache_hit(
|
||||
accessor, index):
|
||||
workspaces = [{"id": "ws1", "displayName": "Engineering", "name": "eng"}]
|
||||
spec = PathSpec(resource_path=mount_key("trello/workspaces", "trello"),
|
||||
virtual="trello/workspaces",
|
||||
directory="trello/workspaces")
|
||||
with patch("mirage.core.trello.readdir.list_workspaces",
|
||||
new_callable=AsyncMock,
|
||||
return_value=workspaces):
|
||||
cold = await readdir(accessor, spec, index)
|
||||
warm = await readdir(accessor, spec, index)
|
||||
assert cold == ["trello/workspaces/Engineering__ws1"]
|
||||
assert warm == cold
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_workspace_entry(accessor, index):
|
||||
await index.put(
|
||||
"/workspaces/Engineering__ws1",
|
||||
IndexEntry(
|
||||
id="ws1",
|
||||
name="Engineering",
|
||||
resource_type="trello/workspace",
|
||||
remote_time="",
|
||||
vfs_name="Engineering__ws1",
|
||||
),
|
||||
)
|
||||
result = await readdir(
|
||||
accessor, PathSpec.from_str_path("/workspaces/Engineering__ws1"),
|
||||
index)
|
||||
assert result == [
|
||||
"/workspaces/Engineering__ws1/workspace.json",
|
||||
"/workspaces/Engineering__ws1/boards",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_boards(accessor, index):
|
||||
await index.put(
|
||||
"/workspaces/Engineering__ws1",
|
||||
IndexEntry(
|
||||
id="ws1",
|
||||
name="Engineering",
|
||||
resource_type="trello/workspace",
|
||||
remote_time="",
|
||||
vfs_name="Engineering__ws1",
|
||||
),
|
||||
)
|
||||
boards = [{"id": "b1", "name": "Product Roadmap", "dateLastActivity": ""}]
|
||||
with patch("mirage.core.trello.readdir.list_workspace_boards",
|
||||
new_callable=AsyncMock,
|
||||
return_value=boards):
|
||||
result = await readdir(
|
||||
async def test_readdir_unrecognized_path_raises(accessor, index):
|
||||
# Returning [] for an unknown path made `ls` and `tree` report a bogus path
|
||||
# as real-but-empty, and left `rg` without a message.
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path("/workspaces/Engineering__ws1/boards"),
|
||||
index)
|
||||
assert result == [
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1"
|
||||
]
|
||||
PathSpec(resource_path="__nf_missing__",
|
||||
virtual="/__nf_missing__",
|
||||
directory="/__nf_missing__"), index)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_board_entry(accessor, index):
|
||||
await index.put(
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1",
|
||||
IndexEntry(
|
||||
id="b1",
|
||||
name="Product Roadmap",
|
||||
resource_type="trello/board",
|
||||
remote_time="",
|
||||
vfs_name="Product_Roadmap__b1",
|
||||
),
|
||||
)
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path(
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1"),
|
||||
index,
|
||||
)
|
||||
assert result == [
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1/board.json",
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1/members",
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1/labels",
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1/lists",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readdir_card_folder(accessor, index):
|
||||
await index.put(
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1"
|
||||
"/lists/Backlog__l1/cards/Fix_login__c1",
|
||||
IndexEntry(
|
||||
id="c1",
|
||||
name="Fix login",
|
||||
resource_type="trello/card",
|
||||
remote_time="",
|
||||
vfs_name="Fix_login__c1",
|
||||
),
|
||||
)
|
||||
result = await readdir(
|
||||
accessor,
|
||||
PathSpec.from_str_path(
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1"
|
||||
"/lists/Backlog__l1/cards/Fix_login__c1"),
|
||||
index,
|
||||
)
|
||||
assert result == [
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1"
|
||||
"/lists/Backlog__l1/cards/Fix_login__c1/card.json",
|
||||
"/workspaces/Engineering__ws1/boards/Product_Roadmap__b1"
|
||||
"/lists/Backlog__l1/cards/Fix_login__c1/comments.jsonl",
|
||||
]
|
||||
async def test_readdir_unrecognized_nested_path_raises(accessor, index):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
await readdir(
|
||||
accessor,
|
||||
PathSpec(resource_path="workspaces/w/nope/deeper",
|
||||
virtual="/workspaces/w/nope/deeper",
|
||||
directory="/workspaces/w/nope/deeper"), index)
|
||||
|
||||
@@ -56,6 +56,7 @@ EXPECTED_RESOURCES = {
|
||||
"qdrant",
|
||||
"notion",
|
||||
"langfuse",
|
||||
"jaeger",
|
||||
"ssh",
|
||||
"email",
|
||||
"databricks_volume",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"history",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"hf_datasets",
|
||||
"hf_models",
|
||||
"hf_spaces",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"history",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"history",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"history",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"history",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"gsheets",
|
||||
"gslides",
|
||||
"hf_buckets",
|
||||
"jaeger",
|
||||
"lancedb",
|
||||
"langfuse",
|
||||
"linear",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user