Compare commits

...

5 Commits

Author SHA1 Message Date
Zecheng Zhang 8a699d16d7 fix(jaeger,du): codex review, service-scoped reads and a real request deadline
read() served a trace fetched by id through any service directory, so
/services/<other>/traces/<id>.json returned content that stat and ls both
report absent. It now asserts the service exists and that the trace's own
process table names it. Membership comes from the trace document, not the
service listing, because that listing is windowed and limited and would hide a
trace that really does belong.

The typescript jaeger transport ignored requestTimeout: the config field, the
snake-case mapping and the transport option all existed, but nothing read them
and fetch ran with no deadline, so a stalled endpoint hung the command forever.
Threaded through as seconds, matching python's httpx timeout and dify's
existing requestTimeout convention.

du's operand stat caught every exception and reported it as
"No such file or directory", so an auth failure or a backend bug printed a
wrong reason and a partial total. Narrowed to isMissingPath, matching python's
(FileNotFoundError, ValueError). The test mock rejected with a bare
Error('ENOENT') rather than a stamped FsError, which is what let this pass.

Moved the jaeger resource's command and op imports to module scope, per the
repo's import rule. Verified no cycle: the registry and workspace still import
cleanly. This also matches the typescript resource, which already imports
JAEGER_COMMANDS at module scope.

Integ gains a cat and a stat through a foreign service, the pair that proves
the two agree.
2026-07-26 15:51:30 -07:00
Zecheng Zhang 2971715a2f fix(ci): build mirage-browser in the observability and facet integ jobs
The typescript runner imports mirage-browser statically for the opfs target,
so its dist must exist even in a job that runs no browser target. Both new
jobs hand-roll their setup and built only core and node, so every typescript
host step died with ERR_MODULE_NOT_FOUND. The pre-existing jobs get the build
from the integ-battery-setup action, which is why only these two were hit.

This was masked in the first run: the typescript step is skipped when the
python step fails, so the stale golden hid it.
2026-07-26 13:53:42 -07:00
Zecheng Zhang 22dbfaecb3 fix(integ): shell-attributed redirect golden, regenerate specs for jaeger
The ro_write_refused contract case expected "echo: <path>: Operation not
supported", but #635 (which landed in main after this branch was cut)
attributes a failed redirect target to the shell rather than the command, so
the correct stderr has no command prefix. It is the only contract case using a
redirect; mkdir, rm and tee take file operands and keep their prefixes.

Regenerate spec/ for the new jaeger resource (154 one-line additions across
both generators). CI runs gen_specs.py and gen-specs.ts as a separate drift
step, not through pre-commit, so a local pre-commit run does not catch this.
2026-07-26 13:42:13 -07:00
Zecheng Zhang f53e4794e3 fix(jaeger,langfuse): CodeQL ReDoS, eslint findings, formatter pass
- stat used a /\/+$/ trailing-slash regex in jaeger and langfuse, which
  CodeQL flags as js/polynomial-redos on a path from library input. Both now
  use the existing loop-based rstripSlash. Python already used str.rstrip.
- errorMessage stringified an unknown errors[0].msg, which could render as
  [object Object]; now requires a non-empty string, mirroring python's
  truthiness check.
- readdir tested pattern against undefined, which its type excludes.
- drop an unused fixture constant in read.test.ts.
2026-07-26 13:29:12 -07:00
Zecheng Zhang a4b7c085ff feat(integ): jaeger backend, real-server observability integ, cross-backend contract
Add a jaeger backend and put both observability backends under a real server
in integ, then extend the shared read-only contract across nine backends.

Jaeger backend (python + typescript):
- service-scoped tree: /services/<name>/{operations.json,traces/<id>.json}.
  Jaeger's search API requires a service, so there is no listable /traces.
- always sends an explicit microsecond window: `lookback` is ignored by the
  query API, so without start/end the search silently returns nothing.
- an unknown service answers 200 with an empty list, so existence is checked
  against the service list rather than inferred from an empty listing.

Langfuse fixes found by running against a real self-hosted instance:
- prompt versions were unlistable: the list endpoint returns a `versions`
  array, and reading a scalar `version` collapsed each prompt to one 0.json.
- dataset runs rendered as an indented document under a .jsonl name.
- a 404 leaked the raw SDK error instead of ENOENT; a 500 still propagates.
- stat accepted any plausible path without checking it exists.
- typescript requested dataset runs under /v2/, a hard 404 on a real server.
- typescript applied a hidden 7-day window that hid traces cat could serve.
- an unrecognized path resolved to scope "root", so the grep/rg push-down
  answered a missing file with every trace in the mount and exit 0.

GNU alignment:
- du reported a missing operand as size 0 with exit 0; now reports it and
  exits 1 while keeping output for the operands that exist.
- tree wrote a malformed line to stderr where GNU writes nothing.

ENOENT alignment across backends:
- trello, linear, slack and email readdir returned [] for an unrecognized
  path, so ls and tree reported a bogus path as real but empty.
- email selected an unvalidated IMAP folder, leaking "command SEARCH illegal
  in state AUTH" to the caller.
- email and slack hand-rolled an ENOENT whose message carried an "ENOENT: "
  prefix, and slack's dropped the mount prefix.
- trello typescript readBytes lacked the virtual path python passes, so the
  message reported the mount-relative path.

Integ:
- resources/observability/ holds langfuse (135 cases), jaeger (99) and the
  36-case shared contract, which runs on nine read-only backends via a new
  {mount} token so one case can cover backends with different mount paths.
- targets gain a facet, and the runners gain --facet, so CI runs one backend
  family per job: observability, project, email, chat, dify, mem0, core.
- langfuse runs against a six-container compose stack, jaeger against a
  single container seeded over OTLP.

Two contract cases stay scoped away from email and gmail: their grep/rg push
down to a server-side search that reports "no matches" for a path that does
not exist. Six typescript-only ENOENT prefix sites remain in box, discord,
gdocs, gdrive, gsheets and gslides.

Also includes the pre-existing session-mode integ migration that was already
in the tree: session_modes scripts replaced by session/modes.json.
2026-07-26 13:22:02 -07:00
273 changed files with 9288 additions and 585 deletions
@@ -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: |
+241 -14
View File
@@ -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
+168
View File
@@ -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")
+31
View File
@@ -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}
+117
View File
@@ -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);
});
+756
View File
@@ -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": ""
}
}
]
}
+76 -1
View File
@@ -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
+31 -2
View File
@@ -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()
+30 -3
View File
@@ -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
View File
@@ -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,
}
+35 -3
View File
@@ -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)
+38 -7
View File
@@ -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)
}
+257
View File
@@ -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()
+127
View File
@@ -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
+554
View File
@@ -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()
+234
View File
@@ -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": ""
}
}
]
}
-97
View File
@@ -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())
-99
View File
@@ -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
View File
@@ -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": [
-39
View File
@@ -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
+45
View File
@@ -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
+4
View File
@@ -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,
+13
View File
@@ -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. =========
+193
View File
@@ -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]
+107
View File
@@ -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)
+164
View File
@@ -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")
+79
View File
@@ -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)
+125
View File
@@ -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)
+43 -1
View File
@@ -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]
+16 -8
View File
@@ -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)
+18 -11
View File
@@ -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
+4 -1
View File
@@ -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)
+53 -2
View File
@@ -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)
+4 -1
View File
@@ -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)
+3 -1
View File
@@ -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(
+4 -1
View File
@@ -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)
+18
View File
@@ -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)
+24
View File
@@ -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}")
+27
View File
@@ -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
+57
View File
@@ -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
+21
View File
@@ -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
+3
View File
@@ -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"),
+1
View File
@@ -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
+100
View File
@@ -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)
+175
View File
@@ -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)
+143
View File
@@ -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)
+50
View File
@@ -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
+127
View File
@@ -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)
+55
View File
@@ -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)
+69 -2
View File
@@ -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"
+7
View File
@@ -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"
+37
View File
@@ -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)
+22
View File
@@ -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)
+19 -136
View File
@@ -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)
+1
View File
@@ -56,6 +56,7 @@ EXPECTED_RESOURCES = {
"qdrant",
"notion",
"langfuse",
"jaeger",
"ssh",
"email",
"databricks_volume",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -31,6 +31,7 @@
"gslides",
"hf_buckets",
"history",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -30,6 +30,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -25,6 +25,7 @@
"hf_datasets",
"hf_models",
"hf_spaces",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -30,6 +30,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -23,6 +23,7 @@
"gslides",
"hf_buckets",
"history",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -31,6 +31,7 @@
"gslides",
"hf_buckets",
"history",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -31,6 +31,7 @@
"gslides",
"hf_buckets",
"history",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -23,6 +23,7 @@
"gslides",
"hf_buckets",
"history",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -22,6 +22,7 @@
"gsheets",
"gslides",
"hf_buckets",
"jaeger",
"lancedb",
"langfuse",
"linear",
+1
View File
@@ -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