diff --git a/.agents/skills/data-agent-tutor b/.agents/skills/data-agent-tutor new file mode 120000 index 0000000..3f645fe --- /dev/null +++ b/.agents/skills/data-agent-tutor @@ -0,0 +1 @@ +../../skills/data-agent-tutor \ No newline at end of file diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..d2c50a4 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,21 @@ +{ + "name": "dataplusagent-hackathon-tutorial", + "owner": { + "name": "StreamNative" + }, + "metadata": { + "description": "The tutor for the Data + Agent Hackathon hello-world labs", + "version": "0.1.0" + }, + "plugins": [ + { + "name": "data-agent-tutor", + "description": "A tutor that walks you through the Data + Agent Hackathon labs one step at a time, checks your work with you, and quizzes you", + "source": "./", + "strict": false, + "skills": [ + "./skills/data-agent-tutor" + ] + } + ] +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ab6b111 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "deny": [ + "Read(/.env)", + "Read(/.env.cloud)", + "Read(/.env.local)", + "Read(/.lab/**)" + ] + } +} diff --git a/.claude/skills/data-agent-tutor b/.claude/skills/data-agent-tutor new file mode 120000 index 0000000..3f645fe --- /dev/null +++ b/.claude/skills/data-agent-tutor @@ -0,0 +1 @@ +../../skills/data-agent-tutor \ No newline at end of file diff --git a/.env.example b/.env.cloud.example similarity index 68% rename from .env.example rename to .env.cloud.example index f7aca61..d2d8ee8 100644 --- a/.env.example +++ b/.env.cloud.example @@ -1,5 +1,9 @@ -# Copy this file to .env (in the repo root) and paste the values from your -# team card. .env is git-ignored: never commit it. +# The Cloud course: copy this file to .env (in the repo root) and paste the +# values from your team card. .env is git-ignored: never commit it. +# +# cp .env.cloud.example .env +# +# (The Local course writes its own .env: see labs/local/00-set-up.md.) # ---------------------------------------------------------------- team card -- @@ -11,11 +15,6 @@ SN_API_KEY= # Looks like: @.auth.streamnative.cloud SN_SERVICE_ACCOUNT= -# Optional separate Registry workspace key for ork local (sent as x-api-key). -# Leave empty for a hosted team card; SN_API_KEY then authenticates Agent Engine. -# This key does not authenticate Kafka, Schema Registry, or StreamNative MCP. -ORCA_API_KEY= - # Agent Engine registry endpoint (the External one). Host root only, no /v1. # Looks like: https:// ORCA_BASE_URL= @@ -28,9 +27,8 @@ SCHEMA_REGISTRY_URL= SN_MCP_URL= # MCP authentication: oauth (default) or static_bearer for API-key MCP servers. -# All three tutorial paths call ork for the first OAuth login, then reuse the -# credential stored in the vault. Install ork with the OAuth discovery support -# from orca-cli PR #8 (or current main). +# All three paths call ork for the first OAuth login, then reuse the +# credential stored in the vault. Needs ork v0.6.0 or newer. SN_MCP_AUTH=oauth # Optional authorization server selection. Leave empty for automatic discovery. @@ -42,8 +40,9 @@ SN_MCP_OAUTH_SCOPE="openid profile email offline_access" # ------------------------------------------------------------- your choices -- # The Kafka topic name. Injectors and doctor read this value. -# L2 SQL files use the default below: edit their quoted "avro." -# source name to match this value before running them in SQL Workspace. +# The SQL files in sql/cloud/ use the default below: edit their quoted +# "avro." source name to match this value before running them in +# SQL Workspace. LOGIN_TOPIC=security.login_events # The model your agent runs on (served by the event's AI gateway). diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..8c8cf61 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,29 @@ +# The Local course: you do not fill this in by hand. local/write-env.sh writes +# .env for you, with the key your Agent Engine generated (labs/local/00-set-up.md). +# This file shows what it writes. .env is git-ignored: never commit it. + +# Tells every script that the stack is the one on your laptop. +TUTORIAL_STACK=local + +# The Agent Engine that `ork local` runs, and the workspace key it generated. +ORCA_BASE_URL=http://127.0.0.1:8080 +ORCA_API_KEY= + +# Ursa for Kafka and its schema registry, as your terminal reaches them. +KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:29092 +SCHEMA_REGISTRY_URL=http://127.0.0.1:18081 + +# RisingWave's MCP server. The first address is the one your agent's definition +# carries: the AI Gateway reaches the server by that name. The second is the same +# server from your terminal, for the doctor. +RW_MCP_URL=http://risingwave-mcp:8000/mcp +RW_MCP_LOCAL_URL=http://127.0.0.1:8000/mcp + +# The Kafka topic the injector writes to and RisingWave reads. +LOGIN_TOPIC=security.login_events + +# The model your agent runs on. Your provider key must be able to use it. +ORCA_MODEL=claude-sonnet-4-6 + +# Names your agent and environment. Defaults to your OS user name. +PARTICIPANT= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e015b8e..8c70ce3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,27 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: shellcheck cli/*.sh cli/tests/*.sh cli/tests/fake-ork + - run: shellcheck cli/*.sh cli/tests/*.sh cli/tests/fake-ork lab-ork - run: cli/tests/run.sh + local: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: shellcheck local/*.sh local/tests/*.sh + - run: local/tests/run.sh + - name: The Compose file is valid + run: docker compose -f local/compose.yaml config -q + + labs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: shellcheck scripts/*.sh scripts/tests/*.sh + - run: scripts/tests/run.sh + - name: Every lab has its steps, checks, quiz and solution, and every link resolves + run: scripts/check-labs.sh + no-credentials: runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 054c77d..53168d8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,17 @@ -# Secrets: your team card lives here. Never commit it. +# Secrets: your team card (or your local stack's key) lives here. Never commit it. .env .env.* -!.env.example +!.env.*.example # Local state (agent/environment/vault ids per participant) .orca-state/ +# The Local course's Agent Engine data: `ork local` keeps its keys here. +.lab/ + +# Claude Code worktrees +.claude/worktrees/ + # Python .venv/ __pycache__/ diff --git a/README.md b/README.md index f573602..3a6a605 100644 --- a/README.md +++ b/README.md @@ -1,443 +1,91 @@ # Data + Agent Hackathon: hello world -**Data Streaming Summit 2026 · guided tutorial · about 30 minutes** +**Data Streaming Summit 2026 · a hands-on course in five short labs** -In the next half hour you'll build an agent whose context is a live Kafka stream, -kept fresh by streaming SQL, and that asks a human before it acts. Four short -layers, each adding one idea. The agent steps work three ways; pick one: - -- **CLI**: the [`ork`](https://github.com/orca-ae/orca-cli) command line -- **Python**: the [`runorca`](https://pypi.org/project/runorca/) SDK -- **TypeScript**: the [`@runorca/orca-sdk`](https://www.npmjs.com/package/@runorca/orca-sdk) SDK +You build an agent whose context is a live Kafka stream, kept fresh by streaming +SQL, and that asks a human before it acts. Five labs, each adding one idea. ## The story Aegis Financial, a fictional bank, streams every login attempt into Kafka. -Somewhere in that stream, an attacker is guessing passwords. Your agent will spot -them from live data, and flag the account once you say so. +Somewhere in that stream, an attacker is guessing passwords. Your agent spots +them from live data, and flags the account once you say so. ```mermaid flowchart LR - K["Kafka topic
security.login_events"] --> S["SQL Workspace
materialized view
login_failures"] - J["inject
(you, in L3)"] -- "new login burst" --> K - S -- "StreamNative MCP
sql_workspace_query" --> A["Orca agent
hello-agent-<you>"] - A -- "sql_workspace_insert_rows
(only if you approve)" --> F["SQL table
flagged_accounts"] -``` - -| Step | Time | Where | You | The idea | -|---|---|---|---|---| -| [0. Connect](#step-0-connect-3-min) | 3 min | terminal | Fill in `.env`, run the doctor | Check service access; authorize MCP with OAuth | -| [L1. Hello, agent](#l1-hello-agent-5-min) | 5 min | CLI / Python / TS | Create an agent and chat | Agent, environment, session, events | -| [L2. Hello, streaming SQL](#l2-hello-streaming-sql-8-min) | 8 min | SQL Workspace | Build a materialized view over the topic | Context that keeps itself fresh | -| [L3. Agent + live context](#l3-agent--live-context-9-min) | 9 min | CLI / Python / TS | Give the agent SQL tools, inject new data | The answer changes with the data | -| [L4. Agent acts, human approves](#l4-agent-acts-human-approves-5-min) | 5 min | CLI / Python / TS | Let the agent write, with your OK | Governed actions | - -## Before you start - -- Your **team card** from the organizers. Your team's Kafka cluster already - holds the login stream. -- One path installed: see [Before you arrive](docs/before-you-arrive.md). - -## Step 0: Connect (3 min) - -Copy the template, then paste the values from your team card into `.env`: - -```bash -cp .env.example .env -``` - -Go to your path's folder and run the doctor: - -| Path | Run | -|---|---| -| Python | `cd python && source .venv/bin/activate && python doctor.py` | -| TypeScript | `cd typescript && npm run doctor` | -| CLI | `cd cli`, and run the doctor from your helper language: `(cd ../python && .venv/bin/python doctor.py)` or `(cd ../typescript && npm run doctor)` | - -The service checks should say `PASS`. Before the first OAuth login, the MCP -check asks you to run L3; that script opens your browser and stores the credential -in a vault. After completing L2 and running L3, rerun the doctor to validate the -stored OAuth credential. This check verifies MCP initialization; L3/L4 exercise -the actual SQL tools. A failed check prints its fix. Still stuck after two tries? -Raise your hand. - -For StreamNative SQL Workspace MCP, keep `SN_MCP_AUTH=oauth`, leave -`SN_MCP_OAUTH_ISSUER` empty for automatic discovery, and use the scope from -`.env.example`. Use an `ork` build containing [PR #8](https://github.com/orca-ae/orca-cli/pull/8) -or current main. Its discovery accepts HTTPS issuer aliases within the same -registrable domain and port. Only set `SN_MCP_OAUTH_ISSUER` when selecting one -of multiple advertised `authorization_servers`; copy that advertised value -exactly rather than the final issuer in authorization-server metadata. -`SN_API_KEY` authenticates the hosted Agent Engine, -Kafka and Schema Registry; it is not the OAuth MCP access token. All three paths -use `ork` for the first MCP login, then reuse the live credential for the same URL -and auth type from `.orca-state/.json`. Tokens stay in the server-side -vault, where they can be refreshed; they are never written to `.env` or local state. -Set `SN_MCP_AUTH=static_bearer` only when your MCP server accepts `SN_API_KEY`. -Changing the auth mode archives the previous live credential for that same URL -before creating its replacement (the Registry permits one active credential per -URL in a vault). If authorization fails, rerun L3/L4 to finish setup; other URLs' -credentials are preserved. A local Agent Engine with OAuth MCP needs only -`ORCA_API_KEY` for Registry authentication; `SN_API_KEY` is still needed for Kafka -and Schema Registry. - -### Use a local Agent Engine - -Start the CLI's stack with a provider key in your shell: - -```bash -export ANTHROPIC_API_KEY='' -ork local start --with-gateway + K["Kafka topic
security.login_events"] --> S["Streaming SQL
materialized view
login_failures"] + J["inject
(you, in Lab 3)"] -- "new login burst" --> K + S -- "SQL tools, over MCP" --> A["Orca agent
hello-agent-<you>"] + A -- "insert
(only if you approve)" --> F["table
flagged_accounts"] ``` -Set `ORCA_BASE_URL=http://127.0.0.1:8080` in the tutorial's `.env`, and copy the -workspace key from the file printed by `ork local start` into `ORCA_API_KEY`. -The tutorial sends this key as `x-api-key`. A hosted team card continues to use -`SN_API_KEY` as a Bearer token when `ORCA_API_KEY` is empty. +## Pick your course -For L1, run `python doctor.py --agent-only` or `npm run doctor -- --agent-only`. -This checks the Agent Engine without requiring Kafka, Schema Registry, or MCP. -The local stack provides the Agent Engine and AI Gateway; L2–L4 still need the -streaming data services from your team card. For L3/L4, keep `SN_API_KEY` set to -the MCP service key, separately from the local Registry's `ORCA_API_KEY`. +The same five labs, on two stacks. -## L1: Hello, agent (5 min) - -| CLI | Python | TypeScript | +| | [Cloud course](labs/cloud/README.md) | [Local course](labs/local/README.md) | |---|---|---| -| `./l1_hello.sh` | `python l1_hello.py` | `npm run l1` | - -You'll see something like this (the agent's wording varies): - -``` -hello-agent-ana v1: no tools: just a conversation -[you] Hi! What is the Data + Agent Hackathon, and what can you see right now? -[agent] It's a one-day build where teams combine live streaming data with AI agents. I can't see any live data yet: the next step connects me to a Kafka stream. -``` - -**What just happened: four API calls.** +| Runs on | StreamNative Cloud: your team's Kafka cluster, SQL Workspace, and a hosted Agent Engine | Your laptop: [Ursa for Kafka](https://openlakestream.org/docs/ursa-for-kafka), [RisingWave](https://risingwave.com), and the Orca Agent Engine (`ork local`) | +| You need | A team card, handed out at the hackathon | Docker and an Anthropic API key | +| Time | About 30 minutes | About 45 minutes, plus image downloads | +| Start | [Lab 0: Set up](labs/cloud/00-set-up.md) | [Lab 0: Set up](labs/local/00-set-up.md) | -1. **Environment**: where your agent's sessions run. -2. **Agent**: a model plus a system prompt, defined in - [`agent/l1-hello.json`](agent/l1-hello.json). All three paths read that file. -3. **Session**: one conversation, pinned to a specific agent version. -4. **Events**: you send a `user.message`; the agent streams back `agent.message` - events until the session goes idle. +At the hackathon, take the Cloud course: see +[Before you arrive](docs/before-you-arrive.md). Without a team card, or to see +every part run on your own machine, take the Local course. -
-The code (Python) - -```python -environment_id = ensure_environment(client, state, f"hello-env-{config.participant}") - -layer = load_layer("l1-hello") -agent = ensure_agent(client, state, agent_params(layer, config)) - -session = client.sessions.create( - environment_id=environment_id, - agent={"type": "agent", "id": agent.id, "version": agent.version}, - title="L1: hello", -) -run_turn(client, session.id, question) -``` - -`run_turn` ([`python/common.py`](python/common.py)) opens the event stream -*before* sending the message, so no event is missed, then prints events until -the agent's turn ends. -
- -
-The code (TypeScript) - -```ts -const environmentId = await ensureEnvironment(client, state, `hello-env-${config.participant}`); - -const layer = loadLayer('l1-hello'); -const agent = await ensureAgent(client, state, agentParams(layer, config)); - -const session = await client.sessions.create({ - environment_id: environmentId, - agent: { type: 'agent', id: agent.id, version: agent.version }, - title: 'L1: hello', -}); -await runTurn(client, session.id, question); -``` - -`runTurn` ([`typescript/src/common.ts`](typescript/src/common.ts)) works the same -way as the Python version. -
- -
-The commands (CLI) - -```bash -ork agent environments create --name hello-env-ana -o json - -ork agent create --name hello-agent-ana --model "$ORCA_MODEL" \ - --system "$(jq -r .system ../agent/l1-hello.json)" -o json - -ork agent sessions create --agent "$AGENT_ID" --agent-version 1 \ - --environment-id "$ENVIRONMENT_ID" --title "L1: hello" -o json - -ork agent sessions events send message --session "$SESSION_ID" --text "Hi! ..." -ork agent sessions events stream --session "$SESSION_ID" --timeout 15s -``` - -[`cli/lib.sh`](cli/lib.sh) wraps these commands, remembers the ids, and prints -the stream the same way as the other paths. -
- -Re-running is safe: the scripts remember your agent in `.orca-state/` and only -create a new version when its definition changes. - -## L2: Hello, streaming SQL (8 min) - -In the StreamNative Cloud console, open **SQL Workspace**, select the hackathon -workspace, and pick your team's database. Use a new query tab for each step. -The default Kafka topic is `security.login_events`; SQL Workspace exposes its -Avro source as `"avro.security.login_events"`. - -**Align the SQL with your `.env` before running it.** The injectors and doctor use -`LOGIN_TOPIC`, but the SQL files and examples below contain a fixed source name: -SQL Workspace does not read your local `.env`. Check `LOGIN_TOPIC`, then replace -`"avro.security.login_events"` with `"avro."` in both -[`sql/01_explore.sql`](sql/01_explore.sql) and -[`sql/02_login_failures.sql`](sql/02_login_failures.sql), and in any query copied -from this page. For example, `LOGIN_TOPIC=security.team07_logins` requires -`FROM "avro.security.team07_logins"`. Keep the double quotes around the entire -source name and confirm that SQL Workspace imported that topic as an Avro source. -Keep the `login_failures` view name: L3/L4 query that view. - -**1. Peek at the stream** ([`sql/01_explore.sql`](sql/01_explore.sql)). Each row -is one login attempt. The topic name contains dots, so it's double-quoted. - -```sql -SELECT event_time, account_id, ip_address, result, failure_reason -FROM "avro.security.login_events" -ORDER BY event_time DESC -LIMIT 20; -``` +## Pick your path -**2. Turn the stream into context** ([`sql/02_login_failures.sql`](sql/02_login_failures.sql)). +The agent steps work three ways. Pick one; a teammate can pick another. -```sql -CREATE MATERIALIZED VIEW login_failures AS -SELECT - account_id, - COUNT(*) FILTER (WHERE result = 'FAILURE') AS failed_logins, - COUNT(*) FILTER (WHERE result = 'SUCCESS') AS successful_logins, - COUNT(DISTINCT ip_address) AS distinct_ips, - MAX(event_time) AS last_seen -FROM "avro.security.login_events" -GROUP BY account_id; -``` - -A materialized view is maintained incrementally: every new login updates the -counts within seconds. There is no batch job to schedule and nothing to refresh. -That makes it perfect agent context: always current, and cheap to read. - -Check it: `SELECT * FROM login_failures ORDER BY failed_logins DESC LIMIT 10;` -You should see `acct_0042` near the top: failed logins, then a success. That's -the attacker. - -**3. Make room for the agent's decisions** ([`sql/03_flagged_accounts.sql`](sql/03_flagged_accounts.sql)). -The agent will write here in L4. - -```sql -CREATE TABLE flagged_accounts ( - account_id VARCHAR PRIMARY KEY, - reason VARCHAR, - flagged_at TIMESTAMPTZ DEFAULT now() -); -``` - -## L3: Agent + live context (9 min) - -| CLI | Python | TypeScript | -|---|---|---| -| `./l3_live_context.sh` | `python l3_live_context.py` | `npm run l3` | - -Your agent is now at version 2, and answers *"Which accounts look like an account -takeover right now?"* by querying `login_failures` itself. The `[tool]` lines -show the SQL it runs: - -``` -hello-agent-ana v2: + StreamNative MCP (read-only SQL tools) -[you] Which accounts look like an account takeover right now? -[tool] sql_workspace_list_databases {} -[tool] sql_workspace_query {"database": "...", "sql": "SELECT account_id, failed_logins, ..."} -[agent] acct_0042: 5 failed logins followed by a success, from 2 IP addresses ... -``` - -**Now the real-time moment.** Leave the conversation open. In a **second -terminal**, inject a fresh attack: - -| Python (and CLI path) | TypeScript (and CLI path) | -|---|---| -| `cd python && source .venv/bin/activate && python inject.py` | `cd typescript && npm run inject` | - -It prints the account it attacked, `acct_9…`. Back in the first terminal, ask the -same question again. The new account shows up. Nobody refreshed anything: the -event landed in Kafka, the view updated itself, and the agent read the view. - -**What changed** ([`agent/l3-live-context.json`](agent/l3-live-context.json)): - -- `mcp_servers`: the StreamNative MCP server for your SQL Workspace. -- `tools`: an allow-list. Two read-only tools run without asking - (`always_allow`); every other tool on that server is disabled. -- A **vault**: the MCP server's OAuth credential is created through `ork` and - stored server-side. Approve the browser login on the first run. The session - references the vault by id, so tokens never enter the prompt. Later runs reuse - the credential without another browser login. - -
-The code (Python) - -```python -layer = load_layer("l3-live-context") -agent = ensure_agent(client, state, agent_params(layer, config)) - -vault_id = ensure_vault(client, state, f"hello-vault-{config.participant}", config) -session = client.sessions.create( - environment_id=environment_id, - agent={"type": "agent", "id": agent.id, "version": agent.version}, - vault_ids=[vault_id], - title="L3: live context", -) -chat(client, session.id, QUESTION) -``` -
- -
-The code (TypeScript) - -```ts -const layer = loadLayer('l3-live-context'); -const agent = await ensureAgent(client, state, agentParams(layer, config)); - -const vaultId = await ensureVault(client, state, `hello-vault-${config.participant}`, config); -const session = await client.sessions.create({ - environment_id: environmentId, - agent: { type: 'agent', id: agent.id, version: agent.version }, - vault_ids: [vaultId], - title: 'L3: live context', -}); -await chat(client, session.id, QUESTION); -``` -
- -
-The commands (CLI) - -```bash -ork agent update "$AGENT_ID" --version 1 --model "$ORCA_MODEL" \ - --system "$(jq -r .system ../agent/l3-live-context.json)" \ - --mcp-server "name=streamnative,type=url,url=$SN_MCP_URL" \ - --tool-json "$(jq -c '.tools[0]' ../agent/l3-live-context.json)" -o json - -ork agent vaults create --display-name hello-vault-ana -o json -ork agent vaults credentials create --vault "$VAULT_ID" --display-name streamnative-mcp \ - --mcp-server-url "$SN_MCP_URL" \ - --oauth-scope "$SN_MCP_OAUTH_SCOPE" -o json - -ork agent sessions create --agent "$AGENT_ID" --agent-version 2 \ - --environment-id "$ENVIRONMENT_ID" --vault-id "$VAULT_ID" --title "L3: live context" -o json -``` -
+- **CLI**: the [`ork`](https://github.com/orca-ae/orca-cli) command line +- **Python**: the [`runorca`](https://pypi.org/project/runorca/) SDK +- **TypeScript**: the [`@runorca/orca-sdk`](https://www.npmjs.com/package/@runorca/orca-sdk) SDK -## L4: Agent acts, human approves (5 min) +## The labs -| CLI | Python | TypeScript | +| Lab | You | The idea | |---|---|---| -| `./l4_act.sh` | `python l4_act.py` | `npm run l4` | +| 0. Set up | Get your stack ready and run the doctor | Know that every part answers before you build on it | +| 1. Hello, agent | Create an agent and chat | Agent, environment, session, events | +| 2. Hello, streaming SQL | Build a materialized view over the topic | Context that keeps itself fresh | +| 3. Agent + live context | Give the agent SQL tools, inject new data | The answer changes with the data | +| 4. Agent acts, human approves | Let the agent write, with your OK | Governed actions | -The agent (version 3) gets one write tool, and it can only use it with your -approval. It queries the view, describes the flag table, and reads the database -time before proposing an insert. The MCP insert tool requires every writable -column, including nullable columns; it does not apply table defaults. The -session pauses before the proposed row is written: +Every lab is steps you can check, a short quiz, and a task to try on your own. +[The labs](labs/README.md) explains how a lab and its checks work. -``` -[approve?] The agent wants to run sql_workspace_insert_rows with: -{ - "database": "", - "schema": "public", - "table": "flagged_accounts", - "rows": [{ - "account_id": "acct_9…", - "reason": "6 failed logins then a success from one new IP", - "flagged_at": "2026-09-30T12:00:00Z" - }] -} -Allow it? [y/N] -``` +## Learn with a tutor -Type `y`, then check in SQL Workspace: +A coding agent such as Claude Code can walk you through either course one step +at a time, check your work with you, and quiz you. The tutor skill ships in this +repository: see [Learn with the tutor](docs/tutor.md). -```sql -SELECT * FROM flagged_accounts; -``` - -Ask the agent to flag a different account, and answer `n` this time. The agent is told a human denied the insert, -and it does not retry. - -**What changed** ([`agent/l4-act.json`](agent/l4-act.json)): the read-only -`sql_workspace_describe_table` checks the required columns, and -`sql_workspace_insert_rows` uses `permission_policy: always_ask`. When the agent -calls it, the session emits `agent.mcp_tool_use` and goes idle with -`stop_reason: requires_action`. Your script answers with a -`user.tool_confirmation`: `allow`, or `deny` with a reason. On the CLI that is: - -```bash -ork agent sessions events send tool-confirmation --session "$SESSION_ID" \ - --tool-use-id "$TOOL_USE_EVENT_ID" --decision allow -``` - -## What you just built +## What you build - **A stream** (Kafka) that holds the facts as they happen. -- **A materialized view** that keeps a running summary: the agent's always-fresh context. +- **A materialized view** that keeps a running summary: the agent's always-fresh + context. - **An agent** that reads that context itself, through an allow-list of tools. - **A human approval gate** on the one action that changes something. -That's the shape of most data + agent apps. Swap the topic, the view, and the -action, and you have your hackathon project. Ideas and next steps: -[Go further](docs/go-further.md). - -## Troubleshooting - -| Symptom | Fix | -|---|---| -| Doctor: `Agent Engine HTTP 401/403` | The key was rejected. A key created before its permissions must be re-created: ask a facilitator. | -| Doctor: `Kafka ... authentication` | `SN_SERVICE_ACCOUNT` must be the full principal, `@.auth.streamnative.cloud`; `SN_API_KEY` is the raw key. | -| The login topic isn't listed in SQL Workspace | Only topics with a registered Avro schema appear. Ask a facilitator. | -| `relation "avro.security.login_events" does not exist` | Select your team's database and update the quoted Avro source in both L2 SQL files to match `LOGIN_TOPIC` in `.env`. | -| The agent can't find `login_failures` | Create the view in your team's database (L2, step 2); the agent looks it up there. | -| `[error]` lines from MCP tools in L3 | Check `SN_MCP_URL` and `SN_MCP_AUTH`, finish the OAuth login, then rerun the doctor. | -| OAuth issuer mismatch / unsupported client authentication | Use current `ork` main or PR #8 and leave `SN_MCP_OAUTH_ISSUER` empty for StreamNative discovery. An explicit issuer must match an advertised authorization server. `--oauth-allow-issuer-mismatch` is only for trusted servers whose metadata issuer crosses registrable domains; StreamNative does not need it. | -| `Cannot reach the Agent Engine` | `ORCA_BASE_URL` must be the host root from your card, with no `/v1`. | -| The agent answers from memory instead of querying | Ask again, "check the view first". The system prompt tells it to always query. | - -## Clean up - -| CLI | Python | TypeScript | -|---|---|---| -| `./cleanup.sh` | `python cleanup.py` | `npm run cleanup` | - -This archives your agent and environment, and deletes your vault. An environment -with session history cannot be deleted; archiving keeps that history available. -To start L2 over, run [`sql/99_reset.sql`](sql/99_reset.sql). +That is the shape of most data + agent applications. Swap the topic, the view, +and the action, and you have your own project: [Go further](docs/go-further.md). ## What's in this repository | Path | What | |---|---| -| [`agent/`](agent) | The agent definition for each layer, shared by all three paths | -| [`sql/`](sql) | The SQL for L2, plus a reset script | +| [`labs/`](labs) | The two courses: [Cloud](labs/cloud/README.md) and [Local](labs/local/README.md) | +| [`agent/`](agent) | The agent definition for each lab, per stack, shared by all three paths | +| [`sql/`](sql) | The SQL for Lab 2, per stack, plus a reset script | | [`cli/`](cli) | The CLI path (`ork` + `jq`) | -| [`python/`](python) | The Python path, the doctor, and the data injector | -| [`typescript/`](typescript) | The TypeScript path, the doctor, and the data injector | -| [`schemas/`](schemas) | The Avro schema of the login topic | -| [`docs/`](docs) | [Before you arrive](docs/before-you-arrive.md) · [Go further](docs/go-further.md) | +| [`python/`](python) | The Python path, the doctor, the seeder, and the data injector | +| [`typescript/`](typescript) | The TypeScript path, the doctor, the seeder, and the data injector | +| [`local/`](local) | The Local course's stack: a Compose file and four helper scripts | +| [`lab-ork`](lab-ork) | `ork` with your endpoint, key, and ids filled in: what the lab checks use | +| [`data/`](data), [`schemas/`](schemas) | The synthetic login events the Local course loads, and their Avro schema | +| [`skills/`](skills) | The tutor skill | +| [`docs/`](docs) | [Before you arrive](docs/before-you-arrive.md) · [Learn with the tutor](docs/tutor.md) · [Go further](docs/go-further.md) | Licensed under [Apache 2.0](LICENSE). diff --git a/agent/l1-hello.json b/agent/cloud/l1-hello.json similarity index 100% rename from agent/l1-hello.json rename to agent/cloud/l1-hello.json diff --git a/agent/l3-live-context.json b/agent/cloud/l3-live-context.json similarity index 100% rename from agent/l3-live-context.json rename to agent/cloud/l3-live-context.json diff --git a/agent/l4-act.json b/agent/cloud/l4-act.json similarity index 100% rename from agent/l4-act.json rename to agent/cloud/l4-act.json diff --git a/agent/local/l1-hello.json b/agent/local/l1-hello.json new file mode 100644 index 0000000..a1e9c3f --- /dev/null +++ b/agent/local/l1-hello.json @@ -0,0 +1,7 @@ +{ + "layer": "L1", + "summary": "no tools: just a conversation", + "system": "You are the friendly guide for the Data + Agent Hackathon at Data Streaming Summit 2026. Answer in two or three sentences. You cannot see any live data yet; if asked, say that the next step connects you to a live Kafka stream through streaming SQL.", + "mcp_servers": [], + "tools": [] +} diff --git a/agent/local/l3-live-context.json b/agent/local/l3-live-context.json new file mode 100644 index 0000000..4fe4cb3 --- /dev/null +++ b/agent/local/l3-live-context.json @@ -0,0 +1,18 @@ +{ + "layer": "L3", + "summary": "+ RisingWave MCP (one read-only SQL tool)", + "system": "You are a security analyst for Aegis Financial, a fictional bank. Your context is live: logins stream into Kafka, and a RisingWave materialized view keeps a running summary, which you query with the risingwave tools.\n\nRules:\n1. Always query before you answer. Never guess or reuse numbers from earlier answers: the data changes while you talk.\n2. Query the materialized view login_failures with run_select_query. It has one row per account: account_id, failed_logins, successful_logins, distinct_ips, last_seen.\n3. Several failed logins (5 or more) plus at least one success is a likely account takeover.\n4. Cite the numbers you used. Keep answers under 120 words.", + "mcp_servers": [ + { "name": "risingwave", "type": "url", "url": "${RW_MCP_URL}" } + ], + "tools": [ + { + "type": "mcp_toolset", + "mcp_server_name": "risingwave", + "default_config": { "enabled": false }, + "configs": [ + { "name": "run_select_query", "enabled": true, "permission_policy": { "type": "always_allow" } } + ] + } + ] +} diff --git a/agent/local/l4-act.json b/agent/local/l4-act.json new file mode 100644 index 0000000..a8ec737 --- /dev/null +++ b/agent/local/l4-act.json @@ -0,0 +1,20 @@ +{ + "layer": "L4", + "summary": "+ insert into flagged_accounts, only with human approval", + "system": "You are a security analyst for Aegis Financial, a fictional bank. Your context is live: logins stream into Kafka, and a RisingWave materialized view keeps a running summary, which you query with the risingwave tools.\n\nRules:\n1. Always query before you answer. Never guess or reuse numbers from earlier answers: the data changes while you talk.\n2. Query the materialized view login_failures with run_select_query. It has one row per account: account_id, failed_logins, successful_logins, distinct_ips, last_seen.\n3. Several failed logins (5 or more) plus at least one success is a likely account takeover.\n4. Cite the numbers you used. Keep answers under 120 words.\n\nActing:\n5. When asked to flag an account, first query login_failures for fresh numbers, then call describe_table for flagged_accounts.\n6. Submit exactly one insert_multiple_rows tool call with table_name=flagged_accounts, columns=\"account_id, reason\", and values_list holding one row in SQL format, for example ('acct_0001', '5 failed logins then a success from 2 IP addresses'). The reason is one sentence citing the fresh login counts; double any single quote inside it. Leave flagged_at out: the table fills it in. Calling the tool proposes the action: Orca pauses it for human approval. Do not ask for chat approval before submitting the tool call.\n7. If approval is denied, say so and do not retry. A tool error is different from denied human approval. After an approved insert, verify flagged_accounts with a read-only query. Never automatically resubmit an insert after an error or an unknown outcome.", + "mcp_servers": [ + { "name": "risingwave", "type": "url", "url": "${RW_MCP_URL}" } + ], + "tools": [ + { + "type": "mcp_toolset", + "mcp_server_name": "risingwave", + "default_config": { "enabled": false }, + "configs": [ + { "name": "run_select_query", "enabled": true, "permission_policy": { "type": "always_allow" } }, + { "name": "describe_table", "enabled": true, "permission_policy": { "type": "always_allow" } }, + { "name": "insert_multiple_rows", "enabled": true, "permission_policy": { "type": "always_ask" } } + ] + } + ] +} diff --git a/cli/cleanup.sh b/cli/cleanup.sh index 064d443..4d40f36 100755 --- a/cli/cleanup.sh +++ b/cli/cleanup.sh @@ -4,7 +4,7 @@ # # ./cleanup.sh # -# Your SQL objects stay; drop them with sql/99_reset.sql. +# Your SQL objects stay; drop them with sql/cloud/99_reset.sql or sql/local/99_reset.sql. set -euo pipefail # shellcheck source=lib.sh . "$(dirname "$0")/lib.sh" diff --git a/cli/env.sh b/cli/env.sh index b8ae592..846a640 100644 --- a/cli/env.sh +++ b/cli/env.sh @@ -1,8 +1,8 @@ # shellcheck shell=bash -# Loads your team card for the CLI scripts. Sourced by them, never run directly. +# Loads your .env for the CLI scripts. Sourced by them, never run directly. # # hello_setup VAR... read ../.env, check the named variables, point ork at -# your Agent Engine, and pick your participant name. +# your Agent Engine, and pick your stack and participant name. # # Variables you export in your shell win over .env, as in the Python and # TypeScript paths. @@ -45,6 +45,15 @@ hello_die() { exit 1 } +# How to get a complete .env, for the stack this one is for. +hello_setup_hint() { + if [ "$(hello_trim "${TUTORIAL_STACK:-}")" = local ]; then + printf '%s' "Run local/write-env.sh in the repo root to write .env again (Local course, Lab 0)." + else + printf '%s' "Copy .env.cloud.example to .env in the repo root and fill it in from your team card, or run local/write-env.sh for the Local course." + fi +} + hello_require() { local name value missing="" for name in "$@"; do @@ -56,7 +65,7 @@ hello_require() { fi done if [ -n "$missing" ]; then - hello_die "Missing $missing. Copy .env.example to .env in the repo root and fill it in from your team card." + hello_die "Missing $missing. $(hello_setup_hint)" fi } @@ -70,11 +79,17 @@ hello_slug() { hello_setup() { command -v ork >/dev/null || - hello_die "ork (the Orca CLI) is not installed. See docs/before-you-arrive.md, or take the Python or TypeScript path." + hello_die "ork (the Orca CLI) is not installed. Every path uses it for the checks, and the CLI path for the lab scripts. See docs/before-you-arrive.md." command -v jq >/dev/null || hello_die "jq is not installed. Install it (brew install jq, apt install jq, or winget install jqlang.jq) and try again." hello_load_dotenv + # `cloud`: your team card on StreamNative Cloud. `local`: the stack on your laptop. + HELLO_STACK=$(hello_trim "${TUTORIAL_STACK:-cloud}") + case "$HELLO_STACK" in + cloud | local) ;; + *) hello_die "TUTORIAL_STACK must be cloud or local." ;; + esac # Registry workspace keys use x-api-key; team-card keys use Bearer. # Keep the two CLI credentials mutually exclusive. if [ -n "$(hello_trim "${ORCA_API_KEY:-}")" ]; then @@ -87,10 +102,12 @@ hello_setup() { fi export ORCA_REGISTRY_URL="$ORCA_BASE_URL" - local who + local who suffix="" who=$(printenv PARTICIPANT || true) [ -n "$(hello_trim "$who")" ] || who=${LOGNAME:-${USER:-${LNAME:-${USERNAME:-$(id -un 2>/dev/null || true)}}}} HELLO_PARTICIPANT=$(hello_slug "$who") - HELLO_STATE_FILE="$HELLO_REPO_ROOT/.orca-state/$HELLO_PARTICIPANT.json" - export HELLO_PARTICIPANT HELLO_STATE_FILE + # Each stack has its own Agent Engine, so each keeps its ids in its own file. + [ "$HELLO_STACK" = cloud ] || suffix=.local + HELLO_STATE_FILE="$HELLO_REPO_ROOT/.orca-state/$HELLO_PARTICIPANT$suffix.json" + export HELLO_STACK HELLO_PARTICIPANT HELLO_STATE_FILE } diff --git a/cli/l1_hello.sh b/cli/l1_hello.sh index d823b66..596a6a0 100755 --- a/cli/l1_hello.sh +++ b/cli/l1_hello.sh @@ -20,7 +20,7 @@ QUESTION="Hi! What is the Data + Agent Hackathon, and what can you see right now # ork agent environments create --name hello-env- ensure_environment "hello-env-$HELLO_PARTICIPANT" -# 2. An agent: a model plus a system prompt, from agent/l1-hello.json. +# 2. An agent: a model plus a system prompt, from agent//l1-hello.json. # ork agent create --name hello-agent- --model --system ensure_agent l1-hello echo "$AGENT_NAME v$AGENT_VERSION: $(jq -r .summary "$(layer_file l1-hello)")" diff --git a/cli/l3_live_context.sh b/cli/l3_live_context.sh index 2ab3c77..5e4eb42 100755 --- a/cli/l3_live_context.sh +++ b/cli/l3_live_context.sh @@ -2,9 +2,9 @@ # shellcheck source-path=SCRIPTDIR # L3 - Agent + live context. # -# Upgrades your agent with read-only StreamNative MCP tools, gives the session a -# vault holding the MCP credential, and opens a conversation. Ask, run the -# injector in a second terminal, then ask again: the answer changes. +# Upgrades your agent with read-only SQL tools from an MCP server and opens a +# conversation. Ask, run the injector in a second terminal, then ask again: the +# answer changes. # # ./l3_live_context.sh # @@ -12,23 +12,24 @@ set -euo pipefail # shellcheck source=lib.sh . "$(dirname "$0")/lib.sh" -hello_setup ORCA_BASE_URL ORCA_MODEL SN_MCP_URL +hello_setup ORCA_BASE_URL ORCA_MODEL QUESTION="Which accounts look like an account takeover right now?" ensure_environment "hello-env-$HELLO_PARTICIPANT" -# The same agent, next version: agent/l3-live-context.json adds the MCP server. -# ork agent update --version --mcp-server name=streamnative,type=url,url= --tool-json +# The same agent, next version: agent//l3-live-context.json adds the MCP server. +# ork agent update --version --mcp-server name=,type=url,url= --tool-json ensure_agent l3-live-context echo "$AGENT_NAME v$AGENT_VERSION: $(jq -r .summary "$(layer_file l3-live-context)")" -# The MCP server needs a credential. It goes in a vault, never in the prompt. +# StreamNative Cloud's MCP server needs a credential. It goes in a vault, never +# in the prompt. The MCP server on your laptop takes none, so there is no vault. # ork agent vaults create --display-name hello-vault- # ork agent vaults credentials create --vault --mcp-server-url -ensure_vault "hello-vault-$HELLO_PARTICIPANT" +mcp_vault -# ork agent sessions create ... --vault-id +# ork agent sessions create ... [--vault-id ] create_session "L3: live context" "$VAULT_ID" # shellcheck disable=SC2016 # the backticks are for the reader printf 'Tip: after the first answer, run `python inject.py` (or `npm run inject`) in another terminal and ask again.\n\n' diff --git a/cli/l4_act.sh b/cli/l4_act.sh index 5811b87..4a03a32 100755 --- a/cli/l4_act.sh +++ b/cli/l4_act.sh @@ -11,17 +11,17 @@ set -euo pipefail # shellcheck source=lib.sh . "$(dirname "$0")/lib.sh" -hello_setup ORCA_BASE_URL ORCA_MODEL SN_MCP_URL +hello_setup ORCA_BASE_URL ORCA_MODEL REQUEST="Flag the account most likely to be under attack right now." ensure_environment "hello-env-$HELLO_PARTICIPANT" -# Next version again: agent/l4-act.json enables one write tool, always_ask. +# Next version again: agent//l4-act.json enables one write tool, always_ask. ensure_agent l4-act echo "$AGENT_NAME v$AGENT_VERSION: $(jq -r .summary "$(layer_file l4-act)")" -ensure_vault "hello-vault-$HELLO_PARTICIPANT" +mcp_vault create_session "L4: act with approval" "$VAULT_ID" # When the session pauses for approval, you decide, and the script answers with diff --git a/cli/lib.sh b/cli/lib.sh index c9e893e..f6e054f 100644 --- a/cli/lib.sh +++ b/cli/lib.sh @@ -15,7 +15,11 @@ HELLO_TURN_TIMEOUT=${HELLO_TURN_TIMEOUT:-300} # give up on a turn after this : >"$HELLO_TMP/tools" hello_cleanup_tmp() { - if [ -n "$HELLO_STREAM_PID" ]; then kill "$HELLO_STREAM_PID" 2>/dev/null || true; fi + if [ -n "$HELLO_STREAM_PID" ]; then + kill "$HELLO_STREAM_PID" 2>/dev/null || true + # Collect it here, quietly, or the shell reports the stream it just stopped. + wait "$HELLO_STREAM_PID" 2>/dev/null || true + fi rm -rf "$HELLO_TMP" } trap hello_cleanup_tmp EXIT @@ -53,7 +57,7 @@ ork_get_live() { # ork_get_live } # ------------------------------------------------------------ remembered ids -- -# Shared with the Python and TypeScript paths: .orca-state/.json +# Shared with the Python and TypeScript paths: one file per stack in .orca-state/ state_get() { [ -f "$HELLO_STATE_FILE" ] || return 0 @@ -70,16 +74,17 @@ state_set() { # ------------------------------------------------------- agent definitions -- -layer_file() { printf '%s/agent/%s.json' "$HELLO_REPO_ROOT" "$1"; } +# agent//.json: the same file the Python and TypeScript paths use. +layer_file() { printf '%s/agent/%s/%s.json' "$HELLO_REPO_ROOT" "$HELLO_STACK" "$1"; } # The five fields the fingerprint covers, with ${NAME} placeholders filled -# from your team card (only mcp_servers and tools carry placeholders). +# from your .env (only mcp_servers and tools carry placeholders). agent_definition() { # agent_definition local file name file=$(layer_file "$1") for name in $(jq -r '[.mcp_servers, .tools] | .. | strings | [match("\\$\\{([A-Z0-9_]+)\\}"; "g").captures[0].string] | .[]' "$file" | sort -u); do [ -n "$(printenv "$name" || true)" ] || - hello_die "Missing $name: the agent definition needs it. Add it to .env from your team card." + hello_die "Missing $name: the agent definition needs it. $(hello_setup_hint)" done jq -c --arg name "hello-agent-$HELLO_PARTICIPANT" --arg model "$ORCA_MODEL" ' def fill: if type == "string" then gsub("\\$\\{(?[A-Z0-9_]+)\\}"; $ENV[.var]) @@ -216,7 +221,7 @@ ensure_vault() { # ensure_vault [ -z "${SN_MCP_OAUTH_SCOPE:-}" ] || oauth_args+=(--oauth-scope "$SN_MCP_OAUTH_SCOPE") # Keep the browser URL and callback progress visible; tokens go directly to the vault. ork agent vaults credentials create "${oauth_args[@]}" -o json || - hello_die "MCP OAuth authorization failed. Check the ork error above; normally leave SN_MCP_OAUTH_ISSUER empty for discovery, then rerun L3/L4." + hello_die "MCP OAuth authorization failed. Check the ork error above; normally leave SN_MCP_OAUTH_ISSUER empty for discovery, then run the Lab 3 script again." else auth=$(jq -cn --arg url "$SN_MCP_URL" --arg token "$SN_API_KEY" '{type: "static_bearer", mcp_server_url: $url, token: $token}') ork_json agent vaults credentials create --vault "$VAULT_ID" --display-name streamnative-mcp --auth-json "$auth" \ @@ -225,15 +230,24 @@ ensure_vault() { # ensure_vault fi } +# Sets VAULT_ID to the vault a session needs to call the MCP server, or to nothing. +# StreamNative Cloud's MCP server wants a credential, kept in a vault. The MCP +# server on your laptop takes none, so the local stack has no vault. +mcp_vault() { + VAULT_ID="" + [ "$HELLO_STACK" = local ] || ensure_vault "hello-vault-$HELLO_PARTICIPANT" +} + # Sets SESSION_ID: one conversation, pinned to this exact agent version. +# The id is remembered for your checks. create_session() { # create_session [vault id] local json local -a vault=() [ -z "${2:-}" ] || vault=(--vault-id "$2") json=$(ork_json agent sessions create --agent "$AGENT_ID" --agent-version "$AGENT_VERSION" \ --environment-id "$ENVIRONMENT_ID" --title "$1" ${vault[@]+"${vault[@]}"}) || ork_fail - # shellcheck disable=SC2034 # read by the layer scripts SESSION_ID=$(jq -r .id <<<"$json") + state_set session_id "$SESSION_ID" } # -------------------------------------------------------------- one turn -- @@ -326,6 +340,7 @@ run_turn() { # run_turn <session id> <text> [approve] --decision deny --deny-message "$HELLO_DENY_MESSAGE" >/dev/null || ork_fail fi done + deadline=$((SECONDS + HELLO_TURN_TIMEOUT)) # the clock is for the agent, not for you at the prompt ;; *) hello_die "The agent stopped ($HELLO_STOP): ${HELLO_ERROR:-no details}" ;; esac diff --git a/cli/pretty.jq b/cli/pretty.jq index 14bff9d..7d3de04 100644 --- a/cli/pretty.jq +++ b/cli/pretty.jq @@ -20,6 +20,9 @@ def pyjson: def clean: tostring | gsub("[\n\r\u001f]"; " "); +# Servers report a retry in one of two places: beside the error, or inside it. +def will_retry: (.retry_status.will_retry == true) or (.error.retry_status.type == "retrying"); + (if wrapped then (.id // "" | tostring) else "" end) as $frame | (if wrapped then .data else . end) as $e | ($e.processed_at // "") as $at @@ -43,6 +46,6 @@ def clean: tostring | gsub("[\n\r\u001f]"; " "); ((if $e.is_error then "[error] " else "[result] " end) + ([($e.content // [])[] | select(type == "object") | .text // ""] | join(" "))) | shorten elif $e.type == "session.error" then - "[error] " + $error + (if $e.retry_status.will_retry then " (retrying)" else "" end) + "[error] " + $error + (if ($e | will_retry) then " (retrying)" else "" end) else "" end ) diff --git a/cli/tests/fake-ork b/cli/tests/fake-ork index ffd4258..70e7699 100755 --- a/cli/tests/fake-ork +++ b/cli/tests/fake-ork @@ -12,7 +12,9 @@ # reactions/<n>.ndjson events the agent emits after the n-th send (message or tool confirmation) # history.ndjson events already in every new session's transcript (an earlier turn) # Switches: FAKE_ORK_BARE=1 (stream lines without the SSE wrapper), FAKE_ORK_NO_ECHO=1 -# (sent events are not echoed), FAKE_ORK_TAKEN_ENVS="name ..." (409 on create), +# (sent events are not echoed), FAKE_ORK_UNSTAMPED=1 (sent events have no processed_at +# yet, as on ork local), FAKE_ORK_HANG=1 (a stream stays open after its frames), +# FAKE_ORK_TAKEN_ENVS="name ..." (409 on create), # FAKE_ORK_FAIL="<command words>:<status>" (fail that command). set -euo pipefail @@ -116,7 +118,8 @@ append_event() { # append_event <session> <event json> record_send() { # record_send <session> <user event json> -> prints the persisted event list local event reaction line - event=$(jq -c --arg id "evt_user_$(counter user)" --arg now "$(now)" '. + {id: $id, processed_at: $now}' <<<"$2") + event=$(jq -c --arg id "evt_user_$(counter user)" --arg now "$(now)" --arg unstamped "${FAKE_ORK_UNSTAMPED:-}" \ + '. + {id: $id, processed_at: (if $unstamped == "" then $now else null end)}' <<<"$2") [ -n "${FAKE_ORK_NO_ECHO:-}" ] || append_event "$1" "$event" reaction="$dir/reactions/$(counter sends).ndjson" if [ -f "$reaction" ]; then @@ -254,6 +257,8 @@ case "$command" in else jq -c --argjson cursor "${cursor:-0}" 'select((.id | tonumber) >= $cursor)' "$dir/sessions/$session.ndjson" fi + # exec, so that stopping this process leaves no sleeper behind. + [ -z "${FAKE_ORK_HANG:-}" ] || exec sleep 30 sleep 0.1 ;; *) diff --git a/cli/tests/run.sh b/cli/tests/run.sh index 935ada0..5d4f3e3 100755 --- a/cli/tests/run.sh +++ b/cli/tests/run.sh @@ -20,13 +20,17 @@ export PATH="$WORK/bin:$PATH" # Nothing from the developer's own shell may leak into the tests. unset SN_API_KEY SN_SERVICE_ACCOUNT ORCA_BASE_URL KAFKA_BOOTSTRAP_SERVERS SCHEMA_REGISTRY_URL SN_MCP_URL \ LOGIN_TOPIC ORCA_MODEL PARTICIPANT ORCA_API_KEY ORCA_ACCESS_TOKEN ORCA_REGISTRY_URL \ -+ SN_MCP_AUTH SN_MCP_OAUTH_ISSUER SN_MCP_OAUTH_SCOPE + SN_MCP_AUTH SN_MCP_OAUTH_ISSUER SN_MCP_OAUTH_SCOPE TUTORIAL_STACK RW_MCP_URL RW_MCP_LOCAL_URL export HELLO_ROUND_SECONDS=1 HELLO_TURN_TIMEOUT=5 MCP_URL=https://mcp.example.com/mcp/x/o-test/sqlworkspace/ws-1 +LOCAL_MCP_URL=http://risingwave-mcp:8000/mcp +# The same fingerprints the Python and TypeScript paths compute. SHA_L1=457f86a77738415f SHA_L3=ac4d0b08aca3f336 SHA_L4=8d424c704af22671 +SHA_L3_LOCAL=bc4fde88405b950e +SHA_L4_LOCAL=db2876f6ae6d41f8 PASSED=0 FAILED=0 @@ -39,7 +43,8 @@ fresh_repo() { # fresh_repo <name> R="$WORK/repo-$1" mkdir -p "$R/cli" "$R/agent" cp "$CLI"/*.sh "$CLI/pretty.jq" "$R/cli/" - cp "$REPO"/agent/*.json "$R/agent/" + cp -R "$REPO/agent/cloud" "$REPO/agent/local" "$R/agent/" + cp "$REPO/lab-ork" "$R/" export FAKE_ORK_DIR="$R/fake" mkdir -p "$FAKE_ORK_DIR/reactions" unset FAKE_ORK_BARE FAKE_ORK_NO_ECHO FAKE_ORK_TAKEN_ENVS FAKE_ORK_FAIL @@ -58,6 +63,17 @@ PARTICIPANT=jane EOF } +local_env() { # what local/write-env.sh writes for the stack on your laptop + cat >"$R/.env" <<EOF +TUTORIAL_STACK=local +ORCA_BASE_URL=http://127.0.0.1:8080 +ORCA_API_KEY=local-test-key +RW_MCP_URL=$LOCAL_MCP_URL +ORCA_MODEL=claude-sonnet-4-6 +PARTICIPANT=jane +EOF +} + reaction() { # reaction <n> <event json>... (what the agent emits after the n-th send) printf '%s\n' "${@:2}" >"$FAKE_ORK_DIR/reactions/$1.ndjson" } @@ -73,6 +89,14 @@ run() { # run <stdin text> <script> [args...] if (cd "$R/cli" && printf '%s' "$input" | ./"$script" "$@") >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi } +lab_ork() { # lab_ork <args...>: the check wrapper, from the repo root + if (cd "$R" && ./lab-ork "$@") >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi +} + +json_is() { # json_is <jq filter> <file>: the filter is true, and prints nothing + jq -e "$1" "$2" >/dev/null +} + called() { # called <exact invocation as a JSON array> jq -e -s --argjson want "$1" 'any(. == $want)' "$FAKE_ORK_DIR/calls.log" >/dev/null } @@ -88,6 +112,7 @@ calls_of() { jq -s --arg cmd "$1" '[.[] | select(join(" ") | startswith($cmd))] out_has() { grep -qF -- "$1" "$R/out"; } err_has() { grep -qF -- "$1" "$R/err"; } state_is() { [ "$(jq -r --arg k "$1" '.[$k] // empty' "$R/.orca-state/jane.json")" = "$2" ]; } +local_state_is() { [ "$(jq -r --arg k "$1" '.[$k] // empty' "$R/.orca-state/jane.local.json")" = "$2" ]; } check() { # check <description> <command...> if "${@:2}"; then @@ -107,8 +132,8 @@ test_missing_team_card() { fresh_repo missing run "" l1_hello.sh check "exits 1 without a team card" [ "$STATUS" -eq 1 ] - check "names every missing variable" \ - err_has "Missing ORCA_BASE_URL, ORCA_MODEL, SN_API_KEY. Copy .env.example to .env in the repo root and fill it in from your team card." + check "names every missing variable, and both ways to get an .env" \ + err_has "Missing ORCA_BASE_URL, ORCA_MODEL, SN_API_KEY. Copy .env.cloud.example to .env in the repo root and fill it in from your team card, or run local/write-env.sh for the Local course." check "runs no ork command" [ ! -s "$FAKE_ORK_DIR/calls.log" ] } @@ -118,7 +143,7 @@ test_l1_creates_everything() { reaction 1 "$(message evt_a 'Hello!')" "$(end_turn 1)" run "" l1_hello.sh check "L1 succeeds" [ "$STATUS" -eq 0 ] - check "hosted team cards use only Bearer" jq -e '.access_token_set and (.api_key_set | not)' "$FAKE_ORK_DIR/auth.json" + check "hosted team cards use only Bearer" json_is '.access_token_set and (.api_key_set | not)' "$FAKE_ORK_DIR/auth.json" check "creates the environment" called '["agent","environments","create","--name","hello-env-jane","-o","json"]' check "creates the agent with the L1 fingerprint" called_with "agent create" "definition_sha=$SHA_L1" check "names the agent after the participant" called_with "agent create" "hello-agent-jane" @@ -130,6 +155,7 @@ test_l1_creates_everything() { check "prints the reply" out_has "[agent] Hello!" check "remembers the agent" state_is agent_id agent_1 check "remembers the environment" state_is environment_id env_1 + check "remembers the session, for your checks" state_is session_id sess_1 # Run it again: same definition, so nothing is created or updated. reaction 2 "$(message evt_b 'Hello again!')" "$(end_turn 2)" @@ -140,6 +166,7 @@ test_l1_creates_everything() { check "rerun reuses the environment" [ "$(calls_of "agent environments create")" -eq 1 ] check "takes the question from the arguments" out_has "[you] Is anyone there?" check "rerun stays on version 1" out_has "hello-agent-jane v1:" + check "remembers the newest session" state_is session_id sess_2 # L3: the same agent moves to its next version, with a vault for the MCP credential. reaction 3 \ @@ -197,6 +224,41 @@ EOF check "prints a repeated event once" [ "$(grep -cF 'new answer' "$R/out")" -eq 1 ] } +test_turns_on_an_engine_that_neither_stamps_nor_echoes_sent_events() { + # The frame cursor is what keeps earlier turns out. A turn must not also need + # the engine to put a time on our message, or to show it on the stream. + fresh_repo unstamped + card + export FAKE_ORK_UNSTAMPED=1 FAKE_ORK_NO_ECHO=1 + reaction 1 "$(message evt_new 'the answer')" "$(end_turn 8)" + run "" l1_hello.sh + unset FAKE_ORK_UNSTAMPED FAKE_ORK_NO_ECHO + check "a turn ends without a time on our message or an echo of it" [ "$STATUS" -eq 0 ] + check "and prints the answer" out_has "the answer" +} + +test_a_script_stopped_mid_turn_says_nothing_about_its_stream() { + # The labs tell you to press Ctrl-C when the model does not answer. Stopping + # the script stops the stream it was following; the shell must not report that. + local pid tries=0 + fresh_repo interrupt + card + export FAKE_ORK_HANG=1 # the stream stays open, as it does while the agent is thinking + (cd "$R/cli" && exec ./l1_hello.sh >"$R/out" 2>"$R/err") & + pid=$! + until grep -q '"stream"' "$FAKE_ORK_DIR/calls.log" 2>/dev/null || [ "$tries" -ge 100 ]; do + tries=$((tries + 1)) + sleep 0.1 + done + sleep 0.2 + kill -TERM "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null && STATUS=0 || STATUS=$? + unset FAKE_ORK_HANG + check "the script was following the stream when it was stopped" grep -q '"stream"' "$FAKE_ORK_DIR/calls.log" + check "the stopped script exits with the signal's status" [ "$STATUS" -eq 143 ] + check "and prints nothing about the stream it stopped" [ ! -s "$R/err" ] +} + test_bare_stream_lines() { fresh_repo bare card @@ -240,6 +302,16 @@ test_l4_approval() { run "" l3_live_context.sh check "an approval request without an approver fails" [ "$STATUS" -eq 1 ] check "says why" err_has "The agent is waiting for human approval, but this script has no approver." + + # The turn's deadline is for the agent. The time a person spends reading the + # approval prompt must not count against it. + fresh_repo slow-human + card + reaction 1 "$tool_use" "$blocked" + reaction 2 "$(message evt_done 'Flagged acct_9123.')" "$(end_turn 8)" + if (cd "$R/cli" && (sleep 3; printf 'y\n') | HELLO_TURN_TIMEOUT=2 ./l4_act.sh) >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi + check "an approval answered after the turn's deadline still goes through" [ "$STATUS" -eq 0 ] + check "and the turn continues after it" out_has "[agent] Flagged acct_9123." } test_errors() { @@ -252,6 +324,25 @@ test_errors() { check "a retrying error is not fatal" [ "$STATUS" -eq 0 ] check "but it is shown" out_has "[error] model busy (retrying)" + # `ork local` reports the retry inside the error. + fresh_repo retrying-nested + card + reaction 1 \ + '{"id":"evt_err","type":"session.error","error":{"type":"unknown_error","message":"server_error (status 502)","retry_status":{"type":"retrying"}}}' \ + "$(message evt_ok ok)" "$(end_turn nested)" + run "" l1_hello.sh + check "a retry reported inside the error is not fatal" [ "$STATUS" -eq 0 ] + check "and is marked as retrying too" out_has "[error] server_error (status 502) (retrying)" + + fresh_repo exhausted-nested + card + reaction 1 \ + '{"id":"evt_err","type":"session.error","error":{"type":"unknown_error","message":"API key is invalid.","retry_status":{"type":"exhausted"}}}' \ + '{"id":"evt_idle_n","type":"session.status_idle","stop_reason":{"type":"retries_exhausted"}}' + run "" l1_hello.sh + check "an exhausted retry exits 1" [ "$STATUS" -eq 1 ] + check "and is not marked as retrying" bash -c "! grep -qF '(retrying)' '$R/out'" + fresh_repo exhausted card reaction 1 \ @@ -339,7 +430,120 @@ EOF run "" l1_hello.sh unset ORCA_ACCESS_TOKEN check "local L1 needs no team-card key" [ "$STATUS" -eq 0 ] - check "local key uses only x-api-key" jq -e '.api_key_set and (.access_token_set | not)' "$FAKE_ORK_DIR/auth.json" + check "local key uses only x-api-key" json_is '.api_key_set and (.access_token_set | not)' "$FAKE_ORK_DIR/auth.json" +} + +test_local_stack() { + local tool_use='{"id":"evt_tool_1","type":"agent.mcp_tool_use","name":"insert_multiple_rows","mcp_server_name":"risingwave","input":{"table_name":"flagged_accounts","columns":"account_id, reason","values_list":"('"'"'acct_0042'"'"', '"'"'5 failed logins then a success'"'"')"}}' + local blocked='{"id":"evt_idle_ask","type":"session.status_idle","stop_reason":{"type":"requires_action","event_ids":["evt_tool_1"]}}' + + fresh_repo local-stack + local_env + reaction 1 "$(message evt_a 'Hello from your laptop!')" "$(end_turn local1)" + run "" l1_hello.sh + check "local L1 succeeds" [ "$STATUS" -eq 0 ] + check "local L1 reads agent/local/" called_with "agent create" "definition_sha=$SHA_L1" + check "the local stack keeps its ids in its own file" local_state_is agent_id agent_1 + check "and not in the cloud one" [ ! -e "$R/.orca-state/jane.json" ] + check "remembers the local session" local_state_is session_id sess_1 + + reaction 2 \ + '{"id":"evt_q","type":"agent.mcp_tool_use","name":"run_select_query","mcp_server_name":"risingwave","input":{"query":"SELECT * FROM login_failures"}}' \ + "$(message evt_c 'acct_0042 looks taken over.')" "$(end_turn local2)" + run "" l3_live_context.sh + check "local L3 succeeds" [ "$STATUS" -eq 0 ] + check "local L3 carries the local fingerprint" called_with "agent update agent_1" "definition_sha=$SHA_L3_LOCAL" + check "local L3 attaches the RisingWave MCP server" called_with "agent update agent_1" "name=risingwave,type=url,url=$LOCAL_MCP_URL" + check "the local MCP server takes no credential, so no vault is created" [ "$(calls_of "agent vaults create")" -eq 0 ] + check "and none is looked up" [ "$(calls_of "agent vaults")" -eq 0 ] + check "the local session has no vault" \ + called '["agent","sessions","create","--agent","agent_1","--agent-version","2","--environment-id","env_1","--title","L3: live context","-o","json"]' + check "prints the local version line" out_has "hello-agent-jane v2: + RisingWave MCP (one read-only SQL tool)" + check "shows the local tool call" out_has '[tool] run_select_query {"query": "SELECT * FROM login_failures"}' + + reaction 3 "$tool_use" "$blocked" + reaction 4 "$(message evt_done 'Flagged acct_0042.')" "$(end_turn local4)" + run $'y\n' l4_act.sh + check "local L4 succeeds" [ "$STATUS" -eq 0 ] + check "local L4 carries the local fingerprint" called_with "agent update agent_1" "definition_sha=$SHA_L4_LOCAL" + check "asks before the local insert" out_has "[approve?] The agent wants to run insert_multiple_rows with:" + check "sends allow for the local insert" \ + called '["agent","sessions","events","send","tool-confirmation","--session","sess_3","--tool-use-id","evt_tool_1","--decision","allow","-o","json"]' + check "local L4 creates no vault either" [ "$(calls_of "agent vaults")" -eq 0 ] + + run "" cleanup.sh + check "local cleanup succeeds" [ "$STATUS" -eq 0 ] + check "local cleanup archives the agent" out_has "removed agent agent_1" + check "local cleanup forgets the local ids" [ ! -f "$R/.orca-state/jane.local.json" ] + + fresh_repo local-missing-url + local_env + sed -i.bak '/RW_MCP_URL=/d' "$R/.env" + run "" l3_live_context.sh + check "a local .env without the MCP address fails" [ "$STATUS" -eq 1 ] + check "and says to write .env again" err_has "Missing RW_MCP_URL: the agent definition needs it. Run local/write-env.sh in the repo root to write .env again (Local course, Lab 0)." + + fresh_repo local-missing-model + local_env + sed -i.bak '/ORCA_MODEL=/d' "$R/.env" + run "" l1_hello.sh + check "a local .env missing a value points at write-env, not the team card" \ + err_has "Missing ORCA_MODEL. Run local/write-env.sh in the repo root to write .env again (Local course, Lab 0)." + + fresh_repo bad-stack + card + echo "TUTORIAL_STACK=laptop" >>"$R/.env" + run "" l1_hello.sh + check "an unknown stack fails" [ "$STATUS" -eq 1 ] + check "and names the two that exist" err_has "TUTORIAL_STACK must be cloud or local." +} + +test_lab_ork() { + fresh_repo lab-ork + card + lab_ork agent get @agent_id -o json + check "a placeholder with no id yet fails" [ "$STATUS" -eq 1 ] + check "and says a lab script has to run first" err_has "No agent_id yet" + check "and calls no ork" [ ! -s "$FAKE_ORK_DIR/calls.log" ] + + reaction 1 "$(message evt_a 'Hello!')" "$(end_turn lab1)" + run "" l1_hello.sh + lab_ork agent get @agent_id -o json + check "lab-ork succeeds once the agent exists" [ "$STATUS" -eq 0 ] + check "fills in the agent id" called '["agent","get","agent_1","-o","json"]' + check "prints what ork prints" json_is '.id == "agent_1"' "$R/out" + check "uses the team card's Bearer key" json_is '.access_token_set and (.api_key_set | not)' "$FAKE_ORK_DIR/auth.json" + + lab_ork agent sessions events stream --session @session_id --timeout 1s + check "fills in the session id" called '["agent","sessions","events","stream","--session","sess_1","--timeout","1s"]' + + lab_ork agent environments get @environment_id -o json + check "fills in the environment id" called '["agent","environments","get","env_1","-o","json"]' + + lab_ork agent get @nonsense -o json + check "anything else goes to ork as typed" called '["agent","get","@nonsense","-o","json"]' + + fresh_repo lab-ork-local + local_env + reaction 1 "$(message evt_a 'Hello!')" "$(end_turn lab2)" + run "" l1_hello.sh + lab_ork agent get @agent_id -o json + check "lab-ork reads the local stack's ids" called '["agent","get","agent_1","-o","json"]' + check "and uses the local workspace key" json_is '.api_key_set and (.access_token_set | not)' "$FAKE_ORK_DIR/auth.json" + + fresh_repo lab-ork-no-env + lab_ork agent get @agent_id -o json + check "lab-ork without .env fails" [ "$STATUS" -eq 1 ] + check "and says how to get one" err_has "Copy .env.cloud.example to .env" + + # The checks of all three paths go through lab-ork, so a missing ork is not a + # reason to change path. + fresh_repo lab-ork-no-ork + card + if (cd "$R" && PATH=/usr/bin:/bin ./lab-ork agent list -o json) >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi + check "lab-ork without ork fails" [ "$STATUS" -eq 1 ] + check "and says every path needs ork" err_has "Every path uses it" + check "and does not send the learner to another path" bash -c "! grep -qF 'take the Python or TypeScript path' '$R/err'" } test_mcp_oauth() { @@ -359,8 +563,8 @@ EOF unset ORCA_ACCESS_TOKEN check "OAuth L3 succeeds" [ "$STATUS" -eq 0 ] check "OAuth invokes native discovery and browser flow" called "$(jq -cn --arg url "$MCP_URL" '["agent","vaults","credentials","create","--vault","vlt_1","--display-name","streamnative-mcp","--mcp-server-url",$url,"--oauth-issuer","https://auth.example.com/","--oauth-scope","openid profile email offline_access","-o","json"]')" - check "OAuth stores no static bearer credential" jq -e '.[0].auth.type == "mcp_oauth"' "$FAKE_ORK_DIR/creds/vlt_1.json" - check "OAuth child uses only Registry Bearer" jq -e '.access_token_set and (.api_key_set | not)' "$FAKE_ORK_DIR/auth.json" + check "OAuth stores no static bearer credential" json_is '.[0].auth.type == "mcp_oauth"' "$FAKE_ORK_DIR/creds/vlt_1.json" + check "OAuth child uses only Registry Bearer" json_is '.access_token_set and (.api_key_set | not)' "$FAKE_ORK_DIR/auth.json" # shellcheck disable=SC2016 # the child shell expands its own positional argument check "OAuth state contains no tokens" bash -c '! grep -q "test-key" "$1"' _ "$R/.orca-state/jane.json" @@ -383,14 +587,19 @@ EOF unset SN_MCP_AUTH check "OAuth failure stops L3" [ "$STATUS" -ne 0 ] check "OAuth failure has setup guidance" err_has "MCP OAuth authorization failed" + check "OAuth failure names the lab to run again" err_has "run the Lab 3 script again" check "no session after OAuth failure" [ "$(calls_of "agent sessions create")" -eq 0 ] } test_mcp_oauth test_local_registry_key +test_local_stack +test_lab_ork test_missing_team_card test_l1_creates_everything test_turns_ignore_history_and_duplicates +test_turns_on_an_engine_that_neither_stamps_nor_echoes_sent_events +test_a_script_stopped_mid_turn_says_nothing_about_its_stream test_bare_stream_lines test_l4_approval test_errors diff --git a/data/login_events.jsonl b/data/login_events.jsonl new file mode 100644 index 0000000..9847e31 --- /dev/null +++ b/data/login_events.jsonl @@ -0,0 +1,246 @@ +{"event_id":"ca52944d-e386-45ec-871b-04d9aa144761","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0035","session_id":"sess_ee0d5dc71b5d","device_id":"device_0035_primary","ip_address":"198.51.100.21","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c5ce43ce-becb-454a-9619-649aa8ccac8f","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0050","session_id":"sess_f88fc5ae3dd2","device_id":"device_0050_primary","ip_address":"198.51.100.68","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"0173b922-6e03-4014-be9c-c378cd06be2b","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0030","session_id":"sess_e14e8b2f41b2","device_id":"device_0030_primary","ip_address":"198.51.100.117","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6f2d1231-5e48-4292-88ce-0bd20274b8e1","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0083","session_id":"sess_72f743b849fb","device_id":"device_0083_primary","ip_address":"198.51.100.214","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"021a239a-9a5e-47ac-ba0d-15e6585733cb","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0086","session_id":"sess_798bd9e9e0d3","device_id":"device_0086_primary","ip_address":"198.51.100.217","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"eb2fb5bd-1621-4415-9d30-9c4061ea63db","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0021","session_id":"sess_7716f078083d","device_id":"device_0021_primary","ip_address":"198.51.100.203","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"f995ee9d-d471-4c63-bafc-1725a4d3cab3","event_type":"LOGIN_ATTEMPT","event_time":1788996759665,"ingested_at":1788996759665,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0069","session_id":"sess_561ffee06e95","device_id":"device_0069_primary","ip_address":"198.51.100.72","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"43c5094b-e524-4b64-b506-cb15b0d8b42f","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0022","session_id":"sess_bb0cb1db6819","device_id":"device_0022_primary","ip_address":"198.51.100.92","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"e021ae41-229f-4c78-b7c6-ce91bc500741","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0021","session_id":"sess_fc178a63b744","device_id":"device_0021_primary","ip_address":"198.51.100.106","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a8e24fbd-7339-4ce0-9e44-5bd1e56c8486","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0100","session_id":"sess_4acf1d267b22","device_id":"device_0100_primary","ip_address":"198.51.100.15","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"73fc71ff-e958-467c-b9ef-40f3eace6bca","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_0f6265bd338e","device_id":"device_0013_primary","ip_address":"198.51.100.25","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3955a41d-030f-4369-b932-14b700327f8b","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0088","session_id":"sess_ce34c871658f","device_id":"device_0088_primary","ip_address":"198.51.100.65","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"fbd16792-bea8-427b-a491-74debc7dd161","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0050","session_id":"sess_5182018f6973","device_id":"device_0050_primary","ip_address":"198.51.100.15","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3a263eea-4fd1-4789-a9ec-d7c4498271bd","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0099","session_id":"sess_6d3d47edc483","device_id":"device_0099_primary","ip_address":"198.51.100.6","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ba976035-6484-483f-b2c9-fe7523cd41e5","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0080","session_id":"sess_53e7f458e3ae","device_id":"device_0080_primary","ip_address":"198.51.100.134","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"821a730c-e092-453e-bc7f-73bf41ee7d3d","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0017","session_id":"sess_af69571f972d","device_id":"device_0017_primary","ip_address":"198.51.100.62","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"1c46941f-8698-4a43-93b0-253c92bad304","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0100","session_id":"sess_6e240cb3570b","device_id":"device_0100_primary","ip_address":"198.51.100.33","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"565dbce5-9c47-45d4-9221-e193bdf1dc2b","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0030","session_id":"sess_fa8963ace32a","device_id":"device_0030_primary","ip_address":"198.51.100.123","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"bdba95df-65cd-4c38-9e53-152621adc031","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0054","session_id":"sess_835ecbcd90e8","device_id":"device_0054_primary","ip_address":"198.51.100.161","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ae699ccc-eed4-4a95-946e-45d8b9c2bde4","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_26f160d682f3","device_id":"device_0076_primary","ip_address":"198.51.100.10","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"449cf397-9603-4c66-a0a3-c3025b496537","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0048","session_id":"sess_4203bd9d4aa6","device_id":"device_0048_primary","ip_address":"198.51.100.141","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"fddda71e-3fbd-4212-8cf3-fec7dcba65b8","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0011","session_id":"sess_ac9ebaae6fbf","device_id":"device_0011_primary","ip_address":"198.51.100.68","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"f8f8dc00-c4b5-4c02-892e-b2b25ba3755d","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0069","session_id":"sess_103917fc686d","device_id":"device_0069_primary","ip_address":"198.51.100.150","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"59bc6d29-a23c-451d-8dcb-1c0a98a02569","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0040","session_id":"sess_98fd4e39266d","device_id":"device_0040_primary","ip_address":"198.51.100.202","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"f524f981-84bb-495f-adab-523eee087267","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0025","session_id":"sess_d0b51c724219","device_id":"device_0025_primary","ip_address":"198.51.100.139","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"e1f841b6-d0d9-43df-9fea-863aeba47080","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0037","session_id":"sess_73509146dfd3","device_id":"device_0037_primary","ip_address":"198.51.100.49","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6f603720-9ea0-42d4-a041-3dd1af029cb7","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0023","session_id":"sess_5f78c73d632e","device_id":"device_0023_primary","ip_address":"198.51.100.8","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d911362d-be65-4d63-997f-1e34e502e7fa","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0048","session_id":"sess_17a593c0546b","device_id":"device_0048_primary","ip_address":"198.51.100.154","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ce66b341-c4d1-42b5-a953-7467971f9aad","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0079","session_id":"sess_35b0544fefd5","device_id":"device_0079_primary","ip_address":"198.51.100.84","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"59a00393-7c6d-4b81-a9dc-4f058a1d22e6","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0063","session_id":"sess_272c5d8cce82","device_id":"device_0063_primary","ip_address":"198.51.100.19","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"35ddf699-b7d2-4ec0-95f8-7db27bec7cc7","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0049","session_id":"sess_176fe452a9ad","device_id":"device_0049_primary","ip_address":"198.51.100.31","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"33692f0f-eb6a-4900-895a-9548ed8b6015","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0011","session_id":"sess_1292673cdff8","device_id":"device_0011_primary","ip_address":"198.51.100.207","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a12b6580-2102-4d92-bf4d-13aed8b288e9","event_type":"LOGIN_ATTEMPT","event_time":1788996759666,"ingested_at":1788996759666,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0041","session_id":"sess_72bcfcbfd0df","device_id":"device_0041_primary","ip_address":"198.51.100.57","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ce9ab366-1d28-4333-b2aa-06d783a80412","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0020","session_id":"sess_76d6f5e56a3d","device_id":"device_0020_primary","ip_address":"198.51.100.189","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"59f303c6-e297-4a97-b40d-f9fb0db2f6e5","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0017","session_id":"sess_97e7f4767a0b","device_id":"device_0017_primary","ip_address":"198.51.100.31","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ccdd6570-5348-4a1a-a943-e6d2e6580b54","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0041","session_id":"sess_d469a50d683d","device_id":"device_0041_primary","ip_address":"198.51.100.68","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c70fde8c-c827-44c2-8c3d-809db75063f9","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0055","session_id":"sess_1e70c511eeb9","device_id":"device_0055_primary","ip_address":"198.51.100.103","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ceed6e5d-0683-40b4-bc9d-a9bc1074c404","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0093","session_id":"sess_3b5688fabda5","device_id":"device_0093_primary","ip_address":"198.51.100.180","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"87d87e40-bbb3-4458-9345-68cb5b118222","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0033","session_id":"sess_5a5a0cf7c2c3","device_id":"device_0033_primary","ip_address":"198.51.100.157","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"81813865-f8c2-4b37-9642-09f7abc58f1b","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0044","session_id":"sess_d5dee12cf2cd","device_id":"device_0044_primary","ip_address":"198.51.100.163","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"e988a68b-54fb-4f89-93d8-efd1c8b47e07","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0066","session_id":"sess_4dfd9891b14a","device_id":"device_0066_primary","ip_address":"198.51.100.21","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"576b5978-109d-4659-9900-7014820cdfeb","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_cca3de5a314e","device_id":"device_0013_primary","ip_address":"198.51.100.119","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a617b757-2238-47a0-81c8-7d876f3276ed","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0073","session_id":"sess_857d9f2f8525","device_id":"device_0073_primary","ip_address":"198.51.100.151","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"caa70b87-861f-44dc-9467-ea0dd7e5b8c8","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0072","session_id":"sess_51f866b56081","device_id":"device_0072_primary","ip_address":"198.51.100.108","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"e8e9f8ab-d0d7-41d8-8065-591edc3cb4ac","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0006","session_id":"sess_232bce806919","device_id":"device_0006_primary","ip_address":"198.51.100.60","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d4f47893-87e3-48d8-a014-2d9bbd5b5619","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0039","session_id":"sess_1bb50266d2a0","device_id":"device_0039_primary","ip_address":"198.51.100.106","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"9ca2d63d-8849-4ea7-b890-51af455a397f","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0044","session_id":"sess_7295d27346b8","device_id":"device_0044_primary","ip_address":"198.51.100.144","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"902a543e-80e8-4494-8b5c-ee53c47f2967","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0081","session_id":"sess_a81ad079e5c1","device_id":"device_0081_primary","ip_address":"198.51.100.55","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9c2a4bbb-3bac-4a01-96f2-269d01e4fff0","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0068","session_id":"sess_1e2d3e06ffb2","device_id":"device_0068_primary","ip_address":"198.51.100.70","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"eeb8c496-d140-45ae-9908-1a4dfda7867a","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0023","session_id":"sess_08ca52f5687c","device_id":"device_0023_primary","ip_address":"198.51.100.178","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4c6a66f3-c6d8-4259-a195-c76d39fa80fc","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0046","session_id":"sess_a358d371696e","device_id":"device_0046_primary","ip_address":"198.51.100.178","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"786ba84d-20db-46ef-8c4c-5bd68a045281","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0042","session_id":"sess_cf739be8b4ed","device_id":"device_0042_primary","ip_address":"198.51.100.121","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"240b287f-1712-44e4-8926-6fec6f65cbd5","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0001","session_id":"sess_8ac8a7728b1c","device_id":"device_0001_primary","ip_address":"198.51.100.217","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"658455c7-e034-47d1-b6ea-47b08cbcb0f1","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0097","session_id":"sess_e34640a2ed9d","device_id":"device_0097_primary","ip_address":"198.51.100.210","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b59320af-ec9a-43fe-8a84-f087110497d9","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0012","session_id":"sess_dd42389280a1","device_id":"device_0012_primary","ip_address":"198.51.100.214","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"20020116-8f40-498f-aff6-274a6a84f7d7","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0022","session_id":"sess_96b86b9090be","device_id":"device_0022_primary","ip_address":"198.51.100.127","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9abf1a28-2d36-4474-ba15-293cf99ff1fe","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0028","session_id":"sess_48392a109d77","device_id":"device_0028_primary","ip_address":"198.51.100.109","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"665d5b5c-c9f0-4572-91ff-ec88366e0520","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0093","session_id":"sess_beaf2df75613","device_id":"device_0093_primary","ip_address":"198.51.100.181","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3471d2e4-22ae-4518-a171-1f65899c5ecf","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0083","session_id":"sess_a4326e01873b","device_id":"device_0083_primary","ip_address":"198.51.100.110","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"385a6d9c-8db4-4c47-91cb-26223e97c7b0","event_type":"LOGIN_ATTEMPT","event_time":1788996759667,"ingested_at":1788996759667,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0051","session_id":"sess_4e483b99a2d6","device_id":"device_0051_primary","ip_address":"198.51.100.70","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a458073c-6905-4f5a-b2bc-9962f49b8d3c","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0072","session_id":"sess_dabc46d9dc99","device_id":"device_0072_primary","ip_address":"198.51.100.97","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"61d6e391-d814-49d3-8c99-1093135097b5","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0036","session_id":"sess_17fd62d19f1d","device_id":"device_0036_primary","ip_address":"198.51.100.11","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"752c130c-dcec-406f-8008-78f7fd12e92b","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0014","session_id":"sess_83f0f9640861","device_id":"device_0014_primary","ip_address":"198.51.100.66","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5a5d1622-7c55-4ca5-b378-5c31d7b0436c","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0054","session_id":"sess_a61d9c6044d9","device_id":"device_0054_primary","ip_address":"198.51.100.48","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"20e308b1-37fd-4f68-bcfd-e91d1745f503","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0057","session_id":"sess_b835c0428bfe","device_id":"device_0057_primary","ip_address":"198.51.100.122","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4a307cb7-7a40-4521-ab12-57f284ed0ac1","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_19ffc55aa234","device_id":"device_0076_primary","ip_address":"198.51.100.128","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9e5e7c3d-a3b9-45e9-9c98-6b5e166a50fc","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0028","session_id":"sess_c8cedbf4eca4","device_id":"device_0028_primary","ip_address":"198.51.100.120","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"30b529e7-8d82-4349-9c5f-3ebb17a905c2","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0018","session_id":"sess_e9c4cb185da7","device_id":"device_0018_primary","ip_address":"198.51.100.81","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"8ea33392-22a2-461b-99ee-8829bb211611","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_4e6dc4f303d3","device_id":"device_0076_primary","ip_address":"198.51.100.104","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3cc1ae60-14f1-4894-a5a5-75b019cdf33c","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0001","session_id":"sess_29149a657cc4","device_id":"device_0001_primary","ip_address":"198.51.100.193","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"d58042d4-f613-4ade-9942-25c74dbb19d6","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0005","session_id":"sess_cebed55777d8","device_id":"device_0005_primary","ip_address":"198.51.100.206","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6e58ce87-1f46-4c8c-9ba2-3fd3ec319495","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0038","session_id":"sess_c3bacdd93918","device_id":"device_0038_primary","ip_address":"198.51.100.72","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"cabd0608-3857-4dee-9842-545f338aef0c","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0027","session_id":"sess_b1ad7eae99e1","device_id":"device_0027_primary","ip_address":"198.51.100.12","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"db009d83-759f-4d1a-bfbd-7b292bb57623","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0070","session_id":"sess_380fb9aa6b27","device_id":"device_0070_primary","ip_address":"198.51.100.119","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"43b74062-5d6f-4a6f-9dbd-310a4fcdd41d","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0012","session_id":"sess_f7e57ae9542e","device_id":"device_0012_primary","ip_address":"198.51.100.99","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"74700dcb-1a23-4199-8089-bba405873889","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0072","session_id":"sess_c40471fa8ace","device_id":"device_0072_primary","ip_address":"198.51.100.46","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7fde800f-8668-4894-9266-028389094fa0","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0022","session_id":"sess_d0a482dce8f3","device_id":"device_0022_primary","ip_address":"198.51.100.130","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4ca87e9a-08be-4d43-b812-af0e1e47c03a","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0097","session_id":"sess_50f8b70de9da","device_id":"device_0097_primary","ip_address":"198.51.100.166","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7449bc52-399d-4429-981a-bbc25c405ae3","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0084","session_id":"sess_6f90dbc492ad","device_id":"device_0084_primary","ip_address":"198.51.100.89","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"975faebd-cadc-4d9a-a0ad-88c1f683d6a6","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0071","session_id":"sess_31e8cf6bf33b","device_id":"device_0071_primary","ip_address":"198.51.100.98","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"c1e74379-ae45-46aa-8d03-d0d7a868d23e","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_3daebd4607f3","device_id":"device_0076_primary","ip_address":"198.51.100.30","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3e68bc3d-083c-48a6-bfda-42c0db77b901","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0068","session_id":"sess_8017c053f887","device_id":"device_0068_primary","ip_address":"198.51.100.65","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b52597b7-6c52-4f1f-b8fe-0ce43356cd37","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0003","session_id":"sess_7024954a6db1","device_id":"device_0003_primary","ip_address":"198.51.100.39","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6333124e-f671-43b9-bde0-d68f115c9348","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_3acabb0125fa","device_id":"device_0013_primary","ip_address":"198.51.100.208","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"e7eabe00-cf74-4dc3-9234-45aba8422b6f","event_type":"LOGIN_ATTEMPT","event_time":1788996759668,"ingested_at":1788996759668,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0033","session_id":"sess_04e98d495a27","device_id":"device_0033_primary","ip_address":"198.51.100.121","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"22a6da7d-dac3-4c0d-8035-dc1275e7ac5a","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_975716459c44","device_id":"device_0013_primary","ip_address":"198.51.100.86","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"224306a4-f638-463d-aba3-7116f09cd0fc","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0058","session_id":"sess_15b555cabdcd","device_id":"device_0058_primary","ip_address":"198.51.100.10","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"70fd2257-b65c-4306-8f46-8871f75968ce","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0029","session_id":"sess_5345652640d8","device_id":"device_0029_primary","ip_address":"198.51.100.100","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c6c3f13c-4971-472d-8046-270e8b55adcd","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0004","session_id":"sess_89a0807a6331","device_id":"device_0004_primary","ip_address":"198.51.100.126","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a5c9d7b8-6aab-4305-ac44-1c90581bf872","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0080","session_id":"sess_718eed420965","device_id":"device_0080_primary","ip_address":"198.51.100.41","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a13a70a2-9b63-4c14-bd68-30ac2a36c29a","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_277a2d518587","device_id":"device_0013_primary","ip_address":"198.51.100.118","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b153d62d-15ad-4946-829d-026958703d7e","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0077","session_id":"sess_a46c779e3308","device_id":"device_0077_primary","ip_address":"198.51.100.161","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ae6cda2e-10ac-4cd6-a132-03870a4401b7","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_7c985d04cad4","device_id":"device_0013_primary","ip_address":"198.51.100.1","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5fa9a832-e32c-4a32-8652-3224738dc786","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0063","session_id":"sess_c1cca3ecd03e","device_id":"device_0063_primary","ip_address":"198.51.100.23","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"fd23f014-6327-4cea-9531-662d142fb214","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0045","session_id":"sess_58ef74428a39","device_id":"device_0045_primary","ip_address":"198.51.100.219","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"2d1fe462-5242-4343-8ff2-094ca9c689d2","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0012","session_id":"sess_7f1d044eba5c","device_id":"device_0012_primary","ip_address":"198.51.100.17","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b7b8e307-c324-41c9-b0ac-f9b1b367d6a2","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0024","session_id":"sess_739929f643b3","device_id":"device_0024_primary","ip_address":"198.51.100.183","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7766232a-2099-4f69-b5bb-4dfa3cd49909","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0022","session_id":"sess_50be3bf6b478","device_id":"device_0022_primary","ip_address":"198.51.100.156","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"02cfc0c5-91c7-42e8-ad54-6453b559eb9b","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0040","session_id":"sess_73435b3ba31d","device_id":"device_0040_primary","ip_address":"198.51.100.202","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3e8726b6-e168-4764-9cf0-8cc7c8e79ab0","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0068","session_id":"sess_5f6df2a8ce27","device_id":"device_0068_primary","ip_address":"198.51.100.157","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"8a7c44c7-3907-41c9-8a3a-e912f9e77e9f","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0034","session_id":"sess_24689f1e0b53","device_id":"device_0034_primary","ip_address":"198.51.100.187","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5233f175-9739-4213-bd33-414565d30b3a","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_bb22b75379fb","device_id":"device_0076_primary","ip_address":"198.51.100.133","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"85a7ecfa-2dfc-40db-98c0-4cb2ca05197f","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0078","session_id":"sess_101d8114ee83","device_id":"device_0078_primary","ip_address":"198.51.100.154","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"497859b4-7bfd-4fff-a113-7867152991be","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0077","session_id":"sess_30e0da466c68","device_id":"device_0077_primary","ip_address":"198.51.100.164","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"19c070d8-c947-44cf-a48a-10233bf292bf","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0087","session_id":"sess_d276412e0517","device_id":"device_0087_primary","ip_address":"198.51.100.211","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"78d9c2c2-a806-4b02-8165-e5d0ad4c4db3","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0073","session_id":"sess_0265727b1953","device_id":"device_0073_primary","ip_address":"198.51.100.92","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"189b12f3-fae1-461b-8a43-1c3c245ca373","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0056","session_id":"sess_f8bb4c78f6e7","device_id":"device_0056_primary","ip_address":"198.51.100.182","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b95736df-84c5-48cb-88e1-a7fb787d05dc","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0033","session_id":"sess_80dd7a7f09e8","device_id":"device_0033_primary","ip_address":"198.51.100.20","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b31e8e52-7824-4d5c-940d-f9875bc9a183","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0030","session_id":"sess_7aa197336b61","device_id":"device_0030_primary","ip_address":"198.51.100.159","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"54b58d33-d007-40d3-a660-babb92e379d8","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0054","session_id":"sess_8051b6546c20","device_id":"device_0054_primary","ip_address":"198.51.100.18","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"235d22ec-ce88-481c-96b2-dd5ed21876f6","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759669,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0018","session_id":"sess_ada087a97251","device_id":"device_0018_primary","ip_address":"198.51.100.189","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"736a468c-aa38-449d-b3ee-c19e3b0303c9","event_type":"LOGIN_ATTEMPT","event_time":1788996759669,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0054","session_id":"sess_59f908bce3c1","device_id":"device_0054_primary","ip_address":"198.51.100.138","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"1e43c194-b1f6-4ba5-acaf-afcd607267ec","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0074","session_id":"sess_939c257e9954","device_id":"device_0074_primary","ip_address":"198.51.100.163","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7a749a95-ccb6-4345-b898-5e07a33816c0","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0082","session_id":"sess_6b8e51a9d319","device_id":"device_0082_primary","ip_address":"198.51.100.200","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"4538e90e-6a26-4f94-81a1-a3077913ceaa","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0100","session_id":"sess_4213c4cb16f4","device_id":"device_0100_primary","ip_address":"198.51.100.50","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"1d89d4f6-130a-4022-bad9-008e96cc258f","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0014","session_id":"sess_4d79d31a95ff","device_id":"device_0014_primary","ip_address":"198.51.100.93","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"594cdea2-8fc5-4f51-8cc3-469cbb527a8b","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0071","session_id":"sess_2348252059a8","device_id":"device_0071_primary","ip_address":"198.51.100.24","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"16c2111d-467c-41e7-b1fb-ad92b0a85c9d","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0089","session_id":"sess_ca8e8a6d3d13","device_id":"device_0089_primary","ip_address":"198.51.100.202","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"dd02adb9-310d-4d15-bec3-a73fdb5be043","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0018","session_id":"sess_c7f6e60e0b15","device_id":"device_0018_primary","ip_address":"198.51.100.65","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c6c7616d-bca3-49d5-b4ea-fb02f659a0f2","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0088","session_id":"sess_6b6e743db55d","device_id":"device_0088_primary","ip_address":"198.51.100.166","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7df1293a-db0d-41e2-a984-580e6e34ecbe","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0061","session_id":"sess_6b9f418b4357","device_id":"device_0061_primary","ip_address":"198.51.100.46","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"0b8f2855-e3aa-4dcf-b122-7b12970f660f","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0069","session_id":"sess_e0b218b53436","device_id":"device_0069_primary","ip_address":"198.51.100.217","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9035648e-bb6d-4f12-aa04-a8b83541ab7e","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0040","session_id":"sess_63ec1d0ed58e","device_id":"device_0040_primary","ip_address":"198.51.100.203","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"19ef3c91-1907-4e2b-8875-35d803390a84","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0092","session_id":"sess_cc114f037dd9","device_id":"device_0092_primary","ip_address":"198.51.100.67","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4bcdf815-fe11-43ab-b23d-bb9a931ff4ec","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0006","session_id":"sess_43d0494988d0","device_id":"device_0006_primary","ip_address":"198.51.100.184","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"946151c6-0c9e-45f2-a0ca-7dd39b1571e6","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0088","session_id":"sess_eae8e8dd675e","device_id":"device_0088_primary","ip_address":"198.51.100.121","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"78c30b9c-499d-4709-b6a4-a475d2fff5ff","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0034","session_id":"sess_7b8fef8fb495","device_id":"device_0034_primary","ip_address":"198.51.100.166","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"21b73edf-4217-42e3-a4c6-0f7420cd32b4","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0027","session_id":"sess_f52554b71660","device_id":"device_0027_primary","ip_address":"198.51.100.176","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"fbe6d7dd-8249-4a76-a9df-e066ce491c2f","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0002","session_id":"sess_f1e74a67c749","device_id":"device_0002_primary","ip_address":"198.51.100.136","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"400c4d19-2fcf-4cd9-a490-4c706d5e8ba7","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0061","session_id":"sess_6e4cf7e4ce5e","device_id":"device_0061_primary","ip_address":"198.51.100.206","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3f2978fa-0db9-4108-b439-567a9e67c573","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0083","session_id":"sess_13a1cc69cd60","device_id":"device_0083_primary","ip_address":"198.51.100.155","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"2e2bb919-2d8c-46d2-821b-566b50a12ebf","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0039","session_id":"sess_d2639f6960a0","device_id":"device_0039_primary","ip_address":"198.51.100.74","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"0f365b6d-ab5b-4935-959d-139d9c5bda13","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0012","session_id":"sess_301e2264eb52","device_id":"device_0012_primary","ip_address":"198.51.100.56","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4b7bf9c8-9229-4dc0-85e4-f78208276bca","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0092","session_id":"sess_094b90fcefb0","device_id":"device_0092_primary","ip_address":"198.51.100.190","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"08b1b7a6-68c6-421c-ade4-4ccdbd1db02d","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0096","session_id":"sess_9815baaa2cc9","device_id":"device_0096_primary","ip_address":"198.51.100.179","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ac64da7c-ad97-4009-840d-817b7ad69ce4","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0088","session_id":"sess_048c157a83dd","device_id":"device_0088_primary","ip_address":"198.51.100.134","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"836575bc-10b5-4a05-9db9-83f53812c2aa","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0017","session_id":"sess_0b918a2b9dd5","device_id":"device_0017_primary","ip_address":"198.51.100.148","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"197c5d1b-27ab-4aea-bc5d-d5aa702e2f66","event_type":"LOGIN_ATTEMPT","event_time":1788996759670,"ingested_at":1788996759670,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0023","session_id":"sess_c4e14982057c","device_id":"device_0023_primary","ip_address":"198.51.100.95","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b6b75bcc-7d6c-4ac6-9f31-4515b521d613","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0082","session_id":"sess_493903407572","device_id":"device_0082_primary","ip_address":"198.51.100.14","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"48636083-67cc-4ce4-9c5a-96211306c04b","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0099","session_id":"sess_d0134bb61381","device_id":"device_0099_primary","ip_address":"198.51.100.114","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"74887aa8-e248-40e0-9bd6-26df121c4f10","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0099","session_id":"sess_1654a91730db","device_id":"device_0099_primary","ip_address":"198.51.100.153","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"fd9c16d7-87d9-418f-aaeb-34c0e98c29e7","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0005","session_id":"sess_22fe627f896f","device_id":"device_0005_primary","ip_address":"198.51.100.129","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c0adaa47-c23b-4380-98db-1e5b07ff39e0","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0062","session_id":"sess_d5d94ae2e9d0","device_id":"device_0062_primary","ip_address":"198.51.100.197","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"57a9c495-5eb9-4f99-a2e3-ef39201412e8","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0040","session_id":"sess_8cf8cd813c8a","device_id":"device_0040_primary","ip_address":"198.51.100.21","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"78a708ac-97f4-43ea-838b-5ed6619e7def","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0001","session_id":"sess_4d52dc83a44c","device_id":"device_0001_primary","ip_address":"198.51.100.62","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"84bb483f-401b-4184-beb4-9b88904cacc9","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0005","session_id":"sess_df1bbb8275f9","device_id":"device_0005_primary","ip_address":"198.51.100.162","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"baca3173-6176-418f-9927-ea599850f56a","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0027","session_id":"sess_34a1829c4c72","device_id":"device_0027_primary","ip_address":"198.51.100.43","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"4d450779-9de5-4989-be12-a0674f455eb7","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0050","session_id":"sess_bdae7d50067a","device_id":"device_0050_primary","ip_address":"198.51.100.23","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"1a42d3f6-d9ad-4872-ba03-c7e15e033541","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_d87157e3139c","device_id":"device_0076_primary","ip_address":"198.51.100.36","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"33d070cb-38cf-4ee9-a4c8-b5fbfeb4238e","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0054","session_id":"sess_6bb01cd03619","device_id":"device_0054_primary","ip_address":"198.51.100.24","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a2f1aa97-ca01-48b3-91a8-69191e8a1e20","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0025","session_id":"sess_a5c6935a7c98","device_id":"device_0025_primary","ip_address":"198.51.100.33","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6e36148c-9193-47b8-b1eb-9c9eba007cb8","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0016","session_id":"sess_c00e61c75cc0","device_id":"device_0016_primary","ip_address":"198.51.100.35","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c9bf8553-da3d-4894-aba1-365afccbf982","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0041","session_id":"sess_1515cd1b2ba5","device_id":"device_0041_primary","ip_address":"198.51.100.15","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"fd7a9b58-a6e6-4a48-b73e-527a0570c744","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0055","session_id":"sess_c45340b5fac3","device_id":"device_0055_primary","ip_address":"198.51.100.209","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"36e793c1-2c92-492d-97c1-170474958678","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0043","session_id":"sess_3987863f5342","device_id":"device_0043_primary","ip_address":"198.51.100.202","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9750aa6b-961a-42e3-9101-868e82875dfa","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0067","session_id":"sess_04f9e3aad020","device_id":"device_0067_primary","ip_address":"198.51.100.82","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5aaaed01-e891-4fcf-929a-77cfc01bb9d5","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0018","session_id":"sess_963426b27e1f","device_id":"device_0018_primary","ip_address":"198.51.100.176","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9da15ebb-921a-4900-9cdb-85fc2f6a8fb9","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0081","session_id":"sess_0551b806848c","device_id":"device_0081_primary","ip_address":"198.51.100.35","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4c976754-a872-4b11-ba2c-1482fb311255","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0052","session_id":"sess_29e5358ded82","device_id":"device_0052_primary","ip_address":"198.51.100.120","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"86044cf0-e62b-4e44-b937-a699bfb0a032","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0049","session_id":"sess_170830356b19","device_id":"device_0049_primary","ip_address":"198.51.100.31","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"03d932ab-f98e-44a0-927c-eb4e05886c03","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0096","session_id":"sess_adc914d9e9ba","device_id":"device_0096_primary","ip_address":"198.51.100.166","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"04dcf696-70fa-4486-ba0c-b912ea945b32","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0076","session_id":"sess_fbc2f2b02aec","device_id":"device_0076_primary","ip_address":"198.51.100.25","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"82ef8730-c992-4cd5-becf-c59823f8fc88","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0006","session_id":"sess_434be9e1c41b","device_id":"device_0006_primary","ip_address":"198.51.100.140","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a8b467a0-a7b5-405a-bda0-0b55c3fda7eb","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0075","session_id":"sess_411a767c10da","device_id":"device_0075_primary","ip_address":"198.51.100.53","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"de4a944b-274e-44f2-ae6e-2b6a98f4ae15","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0034","session_id":"sess_9aaef998c21a","device_id":"device_0034_primary","ip_address":"198.51.100.199","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"af38dfcc-2730-4e2b-b96e-b1dd7ceb140a","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0009","session_id":"sess_a2f722ec75f3","device_id":"device_0009_primary","ip_address":"198.51.100.93","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"e260e26f-18e9-440c-b53a-830097918f2e","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0016","session_id":"sess_dd14547ccfe3","device_id":"device_0016_primary","ip_address":"198.51.100.206","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c5f730da-133d-4b1e-b4d3-12aa752b97dc","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0007","session_id":"sess_b62d2b9bb3a9","device_id":"device_0007_primary","ip_address":"198.51.100.185","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7789c961-f81e-4548-9921-0789cb11f4b0","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0083","session_id":"sess_e5bfb5a5c85a","device_id":"device_0083_primary","ip_address":"198.51.100.70","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4ea9606f-4539-4216-9e69-061cb433387e","event_type":"LOGIN_ATTEMPT","event_time":1788996759671,"ingested_at":1788996759671,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0029","session_id":"sess_d2c111e3e44a","device_id":"device_0029_primary","ip_address":"198.51.100.107","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"0198ae88-7efb-44cf-9515-0d4c142dce36","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0084","session_id":"sess_9710b76a1573","device_id":"device_0084_primary","ip_address":"198.51.100.198","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"e3a61f02-aa3b-495c-bc45-eb75b00592c9","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0014","session_id":"sess_639023b55b11","device_id":"device_0014_primary","ip_address":"198.51.100.100","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"bae9e7e4-0c1e-47fc-bc54-4e55765452aa","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0010","session_id":"sess_417ebed37d39","device_id":"device_0010_primary","ip_address":"198.51.100.7","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"03a36d4d-4050-4c8b-85f7-614d5f7bc204","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0019","session_id":"sess_39014b1b9189","device_id":"device_0019_primary","ip_address":"198.51.100.28","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"81590e7c-a62e-497b-9924-a9b8d970636c","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0092","session_id":"sess_7f5126c13597","device_id":"device_0092_primary","ip_address":"198.51.100.36","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"cde6d287-a8bc-474f-9800-06e7762c3114","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0066","session_id":"sess_f03f7d0be8da","device_id":"device_0066_primary","ip_address":"198.51.100.212","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c25c2c05-0308-4d0e-b321-1aa44dc5b896","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0030","session_id":"sess_9837cb518c6a","device_id":"device_0030_primary","ip_address":"198.51.100.30","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d8105590-0073-4460-89f9-ea25b74e366b","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0093","session_id":"sess_b55238733cec","device_id":"device_0093_primary","ip_address":"198.51.100.56","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a9c06e31-dd73-493c-b8c7-2ec11136a5b1","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0033","session_id":"sess_5ad260073652","device_id":"device_0033_primary","ip_address":"198.51.100.69","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"1026f64f-4bf3-4c76-b315-97fcaf815824","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0052","session_id":"sess_7f6a5ec5867d","device_id":"device_0052_primary","ip_address":"198.51.100.192","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a316d4c8-0cda-4e94-ada1-16631cf1325f","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0060","session_id":"sess_5f84bb51a789","device_id":"device_0060_primary","ip_address":"198.51.100.101","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d94874a7-1899-492c-a673-69a9b0e06cae","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0093","session_id":"sess_3e4f0e5c6d8c","device_id":"device_0093_primary","ip_address":"198.51.100.6","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"f4c22987-bd6a-4244-aa9f-1e68f7af74ff","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0053","session_id":"sess_d5640ff0a8c5","device_id":"device_0053_primary","ip_address":"198.51.100.179","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a73287cd-a4c9-46f8-b97f-70d8ef7cc72e","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0029","session_id":"sess_cbe08964448a","device_id":"device_0029_primary","ip_address":"198.51.100.202","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"697f832c-bf9b-4fb0-9e8b-ddf137d986ac","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0060","session_id":"sess_aff2f2015e82","device_id":"device_0060_primary","ip_address":"198.51.100.154","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"371dad3d-f18a-48a4-91b4-6bb3c9c5f95c","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0052","session_id":"sess_b38ef8b98a18","device_id":"device_0052_primary","ip_address":"198.51.100.213","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4610a33b-8df5-47bd-8054-3e9e007e597e","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0077","session_id":"sess_3f8a260e3a63","device_id":"device_0077_primary","ip_address":"198.51.100.63","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ea0a56a5-f151-4c31-9ffb-085aec537d33","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0059","session_id":"sess_be60a005ffb2","device_id":"device_0059_primary","ip_address":"198.51.100.139","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5c848282-8f67-4751-9759-bf80eafadbb7","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0062","session_id":"sess_23fd911bbe6f","device_id":"device_0062_primary","ip_address":"198.51.100.98","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9e6c4b6f-dac2-41ad-8dd6-5ec32b416370","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0046","session_id":"sess_5edf4aaf8263","device_id":"device_0046_primary","ip_address":"198.51.100.140","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d0dedc59-87dc-45b9-b010-a8cf41c3e15b","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0001","session_id":"sess_a6cbea8d649f","device_id":"device_0001_primary","ip_address":"198.51.100.154","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"820f0782-99d1-4e80-ac16-4536e92342db","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0064","session_id":"sess_c75d17a9bd9b","device_id":"device_0064_primary","ip_address":"198.51.100.46","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"561bfe31-9ecd-47fb-8624-179f4cf62d75","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0064","session_id":"sess_d8d720987a92","device_id":"device_0064_primary","ip_address":"198.51.100.206","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"1374d4b9-780a-4f88-abc8-870ce81a8200","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0040","session_id":"sess_b5535c28eab7","device_id":"device_0040_primary","ip_address":"198.51.100.37","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7ad054de-ed28-431a-9443-13fe6da2839c","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0028","session_id":"sess_c3f941e3582f","device_id":"device_0028_primary","ip_address":"198.51.100.113","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d9f142e5-9ea2-4a59-b2ce-27984a3257af","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0035","session_id":"sess_293253e4f6f0","device_id":"device_0035_primary","ip_address":"198.51.100.70","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b6ed09fd-4d0c-48e7-aa9f-06522fc3e347","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0027","session_id":"sess_f565e4b224b1","device_id":"device_0027_primary","ip_address":"198.51.100.111","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"dbf245ae-9262-4af3-9edc-83aeaf0c4a02","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0011","session_id":"sess_fe50645d2923","device_id":"device_0011_primary","ip_address":"198.51.100.214","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6948d5c2-e803-4cc0-9e8d-af3ff4e4d6c5","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0028","session_id":"sess_d4b7d29fd6d8","device_id":"device_0028_primary","ip_address":"198.51.100.111","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"18a9ff58-c3bb-41dd-83d3-72fc2288a9ab","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0091","session_id":"sess_0053474c2966","device_id":"device_0091_primary","ip_address":"198.51.100.152","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"9eec06a3-7a60-47d7-be53-17defac77f6f","event_type":"LOGIN_ATTEMPT","event_time":1788996759672,"ingested_at":1788996759672,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0099","session_id":"sess_518ad68bf44e","device_id":"device_0099_primary","ip_address":"198.51.100.70","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3d33d675-f646-4187-958f-de95f68c16c7","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0044","session_id":"sess_2c184d212648","device_id":"device_0044_primary","ip_address":"198.51.100.146","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"68b9fe10-99fd-4b0b-9542-1dfe118bff73","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0065","session_id":"sess_00d0c7edb607","device_id":"device_0065_primary","ip_address":"198.51.100.179","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6907ffb5-80fb-470e-ae2e-6b59d9c75c4d","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0052","session_id":"sess_cf17ce3d6b89","device_id":"device_0052_primary","ip_address":"198.51.100.111","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"438e8a95-20d4-44f7-acd9-4b6214ffd0c5","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0010","session_id":"sess_eec464a6b3f7","device_id":"device_0010_primary","ip_address":"198.51.100.194","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"cd5f45e7-e037-45c6-b178-d395227fb967","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0037","session_id":"sess_bb871c7216d7","device_id":"device_0037_primary","ip_address":"198.51.100.147","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7970bcd2-66aa-410f-8eea-9dfdde7d8a4b","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0001","session_id":"sess_30d209b21b08","device_id":"device_0001_primary","ip_address":"198.51.100.17","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7b6ec1b3-39b8-4a90-a725-01ffd3744c57","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0048","session_id":"sess_e2c9c8456ed1","device_id":"device_0048_primary","ip_address":"198.51.100.175","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c24f95c5-6422-418f-8120-c72ac2088632","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0002","session_id":"sess_c40ec8dc6f35","device_id":"device_0002_primary","ip_address":"198.51.100.182","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b34f46f1-9677-4aea-b0f1-75fe71e0c73b","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0009","session_id":"sess_4f247ea7cae1","device_id":"device_0009_primary","ip_address":"198.51.100.125","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"75d91dce-b667-49e7-928e-0b68687fc2fe","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0097","session_id":"sess_aa0b6374eb6e","device_id":"device_0097_primary","ip_address":"198.51.100.90","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4f56ef0b-68f3-42a2-b450-ab109dc2ebb8","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0039","session_id":"sess_a737c757b6ec","device_id":"device_0039_primary","ip_address":"198.51.100.59","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7a20bdb4-02cf-4a0b-ad41-395dc24537d9","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0013","session_id":"sess_30d71a2fa336","device_id":"device_0013_primary","ip_address":"198.51.100.74","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"6b04c13f-f0e0-473d-9421-15fd01eb4bac","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0019","session_id":"sess_709225d522c8","device_id":"device_0019_primary","ip_address":"198.51.100.185","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d1e400e8-1015-4a4f-9268-cb14566dcc69","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0066","session_id":"sess_97010c04cb46","device_id":"device_0066_primary","ip_address":"198.51.100.156","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"cb03de3c-f0d2-441b-92f5-79b4c8f19081","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0065","session_id":"sess_d764d768b09a","device_id":"device_0065_primary","ip_address":"198.51.100.94","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"ae141282-7d73-472b-91b5-244985810ef3","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0097","session_id":"sess_1037c1f4cc11","device_id":"device_0097_primary","ip_address":"198.51.100.83","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"56e76221-cde1-468b-85e8-60312890b9cf","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0099","session_id":"sess_cc36076de930","device_id":"device_0099_primary","ip_address":"198.51.100.206","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7a92c2f5-9a7f-461e-afd1-40fe0004d67b","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0028","session_id":"sess_b5bd525767a7","device_id":"device_0028_primary","ip_address":"198.51.100.6","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"dfeadb47-50ca-4872-abb1-776babc304fc","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0027","session_id":"sess_e558eaf87d1c","device_id":"device_0027_primary","ip_address":"198.51.100.21","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b380d470-dae2-4d97-b036-9ff80010178b","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0035","session_id":"sess_75212a376f78","device_id":"device_0035_primary","ip_address":"198.51.100.71","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"FAILURE","auth_method":"PASSWORD_MFA","failure_reason":"INVALID_PASSWORD","user_agent":"AegisWallet/6.2"} +{"event_id":"ee93ff6e-ab7b-4dcd-941f-b5c0a6268d0e","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0069","session_id":"sess_cee7bc32ff48","device_id":"device_0069_primary","ip_address":"198.51.100.220","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"d3844a9e-f564-43eb-b1a6-540a4cd7d0ea","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0090","session_id":"sess_d5a56b6957f0","device_id":"device_0090_primary","ip_address":"198.51.100.212","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"363637e2-8d62-449c-b831-e54583c12c3a","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0066","session_id":"sess_1b432b1d9e42","device_id":"device_0066_primary","ip_address":"198.51.100.196","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7ba91313-e069-48de-8584-f1a7ce041c3e","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0045","session_id":"sess_f13cce8014fe","device_id":"device_0045_primary","ip_address":"198.51.100.127","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5d530564-8e4b-4496-b3f9-6ce6b2f9f05d","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0052","session_id":"sess_f5bb8dc1ad27","device_id":"device_0052_primary","ip_address":"198.51.100.38","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"970c899b-a589-45d0-b818-f05dcce057d2","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759673,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0002","session_id":"sess_db5a15cb77a3","device_id":"device_0002_primary","ip_address":"198.51.100.141","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"5a68c061-185f-4d50-8c18-c33cb62ab288","event_type":"LOGIN_ATTEMPT","event_time":1788996759673,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0044","session_id":"sess_2753f1f3d5f9","device_id":"device_0044_primary","ip_address":"198.51.100.211","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"7dfb139f-357b-4b6f-96ae-4bb7adc17ad7","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0071","session_id":"sess_fe14407e9823","device_id":"device_0071_primary","ip_address":"198.51.100.95","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"59aec01b-c3e1-4919-a614-f9b2e2f03eff","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0063","session_id":"sess_347c8694e946","device_id":"device_0063_primary","ip_address":"198.51.100.131","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"b2130599-39eb-4f0b-bfe5-a3aa4b132c55","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0053","session_id":"sess_3fe3a1225775","device_id":"device_0053_primary","ip_address":"198.51.100.93","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"510a9e51-18a6-49e2-9a0b-1bbf6021e608","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0098","session_id":"sess_98c9a5e30446","device_id":"device_0098_primary","ip_address":"198.51.100.217","country":"US","region":"Washington","city":"Seattle","latitude":47.6062,"longitude":-122.3321,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"3843b015-9a18-4e14-88b0-74c06338e5bf","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0009","session_id":"sess_62ab354303c7","device_id":"device_0009_primary","ip_address":"198.51.100.199","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"bc599d98-aea1-49be-afd2-9eb5b33e782a","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0099","session_id":"sess_b92c1c401eb2","device_id":"device_0099_primary","ip_address":"198.51.100.81","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"c8824da0-daa7-4135-bbd7-9bd1fed553f2","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0068","session_id":"sess_b88b9232c275","device_id":"device_0068_primary","ip_address":"198.51.100.120","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"f226cf15-dd66-458c-aede-69f830ed5ff9","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0018","session_id":"sess_5cbf801fe4c0","device_id":"device_0018_primary","ip_address":"198.51.100.72","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"db3606d6-40e5-4054-9bb5-3739b1955c7c","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0025","session_id":"sess_78f2c8f351f5","device_id":"device_0025_primary","ip_address":"198.51.100.206","country":"DE","region":"Berlin","city":"Berlin","latitude":52.52,"longitude":13.405,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"8655f9d9-4aa6-4fef-8da5-699016e8458e","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0010","session_id":"sess_5318de54018d","device_id":"device_0010_primary","ip_address":"198.51.100.36","country":"US","region":"California","city":"San Francisco","latitude":37.7749,"longitude":-122.4194,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"50b3dc04-b7fa-482a-bc15-480b0f5c9e5e","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0029","session_id":"sess_8c8486ef4fe5","device_id":"device_0029_primary","ip_address":"198.51.100.70","country":"GB","region":"England","city":"London","latitude":51.5072,"longitude":-0.1276,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"4bae0a8b-9267-47d1-a7f1-ae465d3f638d","event_type":"LOGIN_ATTEMPT","event_time":1788996759674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"baseline","account_id":"acct_0048","session_id":"sess_23857289c611","device_id":"device_0048_primary","ip_address":"198.51.100.197","country":"US","region":"New York","city":"New York","latitude":40.7128,"longitude":-74.006,"result":"SUCCESS","auth_method":"PASSWORD_MFA","failure_reason":null,"user_agent":"AegisWallet/6.2"} +{"event_id":"a75dcfa7-93b1-47ad-8f12-205e1d4db0bf","event_type":"LOGIN_ATTEMPT","event_time":1788996767674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"ato_campaign_2026_01","account_id":"acct_0042","session_id":"sess_attack_00","device_id":"device_atk_9001","ip_address":"203.0.113.42","country":"NL","region":"North Holland","city":"Amsterdam","latitude":52.3676,"longitude":4.9041,"result":"FAILURE","auth_method":"PASSWORD","failure_reason":"INVALID_PASSWORD","user_agent":"Chrome/136 Windows"} +{"event_id":"8610d2ff-2d08-4fa1-bec7-21a63c801ff1","event_type":"LOGIN_ATTEMPT","event_time":1788996773674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"ato_campaign_2026_01","account_id":"acct_0042","session_id":"sess_attack_01","device_id":"device_atk_9001","ip_address":"203.0.113.42","country":"NL","region":"North Holland","city":"Amsterdam","latitude":52.3676,"longitude":4.9041,"result":"FAILURE","auth_method":"PASSWORD","failure_reason":"INVALID_PASSWORD","user_agent":"Chrome/136 Windows"} +{"event_id":"0f5718e3-6f61-428f-8798-8d4277f576a4","event_type":"LOGIN_ATTEMPT","event_time":1788996779674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"ato_campaign_2026_01","account_id":"acct_0042","session_id":"sess_attack_02","device_id":"device_atk_9001","ip_address":"203.0.113.42","country":"NL","region":"North Holland","city":"Amsterdam","latitude":52.3676,"longitude":4.9041,"result":"FAILURE","auth_method":"PASSWORD","failure_reason":"INVALID_PASSWORD","user_agent":"Chrome/136 Windows"} +{"event_id":"63578a52-a4f7-47fa-b821-478202f47807","event_type":"LOGIN_ATTEMPT","event_time":1788996785674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"ato_campaign_2026_01","account_id":"acct_0042","session_id":"sess_attack_03","device_id":"device_atk_9001","ip_address":"203.0.113.42","country":"NL","region":"North Holland","city":"Amsterdam","latitude":52.3676,"longitude":4.9041,"result":"FAILURE","auth_method":"PASSWORD","failure_reason":"INVALID_PASSWORD","user_agent":"Chrome/136 Windows"} +{"event_id":"4bf05c71-cbcf-4b47-a174-b9175ac2c895","event_type":"LOGIN_ATTEMPT","event_time":1788996791674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"ato_campaign_2026_01","account_id":"acct_0042","session_id":"sess_attack_04","device_id":"device_atk_9001","ip_address":"203.0.113.42","country":"NL","region":"North Holland","city":"Amsterdam","latitude":52.3676,"longitude":4.9041,"result":"FAILURE","auth_method":"PASSWORD","failure_reason":"INVALID_PASSWORD","user_agent":"Chrome/136 Windows"} +{"event_id":"7f55d6eb-f5f9-42e7-a563-2096f7fea580","event_type":"LOGIN_ATTEMPT","event_time":1788996807674,"ingested_at":1788996759674,"schema_version":"1.0","tenant_id":"aegis_financial","scenario_id":"ato_campaign_2026_01","account_id":"acct_0042","session_id":"sess_attack_success","device_id":"device_atk_9001","ip_address":"203.0.113.42","country":"NL","region":"North Holland","city":"Amsterdam","latitude":52.3676,"longitude":4.9041,"result":"SUCCESS","auth_method":"PASSWORD","failure_reason":null,"user_agent":"Chrome/136 Windows"} diff --git a/docs/before-you-arrive.md b/docs/before-you-arrive.md index d08a0ff..31f5fae 100644 --- a/docs/before-you-arrive.md +++ b/docs/before-you-arrive.md @@ -1,24 +1,30 @@ # Before you arrive -Ten minutes at home saves thirty at the event. Pick **one** path; your teammate -can pick a different one. +Ten minutes at home saves thirty at the event. This page prepares your laptop +for the [Cloud course](../labs/cloud/README.md), the one you take at the +hackathon. Pick **one** path; your teammate can pick a different one. | Path | Install | |---|---| | **Python** | Python 3.11 or newer | | **TypeScript** | Node.js 20 or newer | -| **CLI** | [`ork`](https://github.com/orca-ae/orca-cli) (the Orca CLI) and [`jq`](https://jqlang.org/download/), plus Python 3.11+ *or* Node.js 20+ for two helper scripts | - -Everyone also needs `git`, a terminal, and [`ork`](https://github.com/orca-ae/orca-cli) -for the first OAuth MCP login in L3/L4, including the Python and TypeScript paths. -Use current `orca-cli` main or a build containing [PR #8](https://github.com/orca-ae/orca-cli/pull/8). -Check `ork agent vaults credentials create --help` for `--oauth-issuer` and -`--oauth-allow-issuer-mismatch`. Automatic discovery handles StreamNative's -same-domain proxy issuer aliases and dynamic client authentication. Leave -`SN_MCP_OAUTH_ISSUER` empty for this flow; `--oauth-issuer` only selects an -authorization server advertised by the MCP server when a choice is needed. -The browser flow stores tokens directly in the vault. -On Windows, use WSL or Git Bash for the CLI path. +| **CLI** | Python 3.11+ *or* Node.js 20+, for two helper scripts (the doctor and the data injector) | + +Everyone also needs: + +- `git` and a terminal that runs `bash`. On Windows that is WSL or Git Bash, on + every path: the checks in the labs are `bash` commands. +- [`ork`](https://github.com/orca-ae/orca-cli) (the Orca CLI), v0.6.0 or newer: + `brew install orca-ae/tap/ork`. All three paths use it for the first MCP login + in Lab 3, and for the checks in every lab. Check + `ork agent vaults credentials create --help` for `--oauth-issuer` and + `--oauth-allow-issuer-mismatch`: a build with those flags handles + StreamNative's same-domain proxy issuer aliases and dynamic client + authentication. Leave `SN_MCP_OAUTH_ISSUER` empty for this flow; + `--oauth-issuer` only selects an authorization server advertised by the MCP + server when a choice is needed. The browser flow stores tokens directly in the + vault. +- [`jq`](https://jqlang.org/download/), for the checks in every lab. ## 1. Get the code @@ -31,7 +37,7 @@ Clone the repository linked in your invitation email, and `cd` into it. ```bash cd python python3 -m venv .venv -source .venv/bin/activate # Windows: .venv\Scripts\activate +source .venv/bin/activate # Git Bash on Windows: source .venv/Scripts/activate pip install -r requirements.txt ``` @@ -43,7 +49,7 @@ npm install ``` **CLI**: install `ork` and `jq`, then set up Python or TypeScript as above for -the doctor and the data injector. +the doctor and the injector. ## 3. Check your laptop @@ -53,4 +59,11 @@ npm run doctor -- --offline # TypeScript path ``` Every line should say `PASS`. You'll get your **team card** (your credentials -and endpoints) at the event; the online checks run then. +and endpoints) at the event; [Lab 0](../labs/cloud/00-set-up.md) starts there. + +## Want to try it tonight? + +The [Local course](../labs/local/README.md) is the same five labs on your own +laptop, with no team card: Ursa for Kafka, RisingWave, and the Orca Agent +Engine in Docker. It needs Docker and an Anthropic API key, and downloads about +5 GB of images, so start it on a good connection. diff --git a/docs/go-further.md b/docs/go-further.md index 242a9fe..a5cd56d 100644 --- a/docs/go-further.md +++ b/docs/go-further.md @@ -5,6 +5,11 @@ into an agent that can act, with a human in the loop. Here is where to take it during the build session. Items marked **ask onsite** depend on features whose availability in your environment an onsite StreamNative engineer can confirm. +This page is written for the [Cloud course](../labs/cloud/README.md). On the +[Local course](../labs/local/README.md) the Agent Engine features below work +the same way; the preloaded topics and the StreamNative MCP tools are specific +to StreamNative Cloud. + ## Build ideas - **Richer context.** Join the other preloaded topics (`identity_changes`, @@ -60,9 +65,9 @@ forever. ### Permission policies `always_allow` runs a tool immediately; `always_ask` pauses the session for a -human, as in L4. Keep write tools on `always_ask` until you trust them, and keep -tools you don't need disabled (see `default_config.enabled: false` in -[`agent/l4-act.json`](../agent/l4-act.json)). +human, as in Lab 4. Keep write tools on `always_ask` until you trust them, and +keep tools you don't need disabled (see `default_config.enabled: false` in +[`agent/cloud/l4-act.json`](../agent/cloud/l4-act.json)). ### Skills and guardrails diff --git a/docs/tutor.md b/docs/tutor.md new file mode 100644 index 0000000..b61e3c5 --- /dev/null +++ b/docs/tutor.md @@ -0,0 +1,151 @@ +# Learn with the tutor + +The repository ships a tutor: a skill for coding agents such as Claude Code. It +walks you through a course one step at a time, looks at each check with you, and +quizzes you. It reads the lab pages in [`labs/`](../labs/README.md) from your +clone, so it teaches what the pages say. + +## What it does, and what it will not do + +- It gives you **one step**, with the command to run and the step's check. You + run both in your own terminal and paste what the check printed. That is how it + knows a step worked. +- It starts a lab with the page's "Before you start" list, and ends it with the + page's "Clean up". +- When a check prints nothing, or you paste an error, it looks the symptom up in + the course's troubleshooting page and gives you that fix. +- It **does not run the lab for you**. Your terminal has your folder, your + virtual environment, and your keys. The agent's shell has none of them, and a + step you did not run is a step you did not learn. +- It **does not read your keys**. It never opens `.env` or `.lab/`, and it does + not need them: the doctor says `PASS` or `FAIL` for each part of your setup + and does not print a key. Do not paste a key into the chat. In Claude Code + started at the root of the clone, a project setting + ([`.claude/settings.json`](../.claude/settings.json)) also denies the agent's + file-reading tool those files, whatever it was asked. That is a guard rail, + not a lock: it does not cover every program an agent can run, and it does not + apply when Claude Code is started in a subfolder. +- It **does not give a quiz answer before you pick one**, or a solution before + you have tried. It gives hints instead, up to three. You can always skip: the + questions and the "Try it yourself" task are optional, and the answers are + folded in the lab page. + +## Start it + +Open your coding agent **in your clone of this repository**, at its root. There +is nothing to install. + +| Agent | How to start the tutor | +|---|---| +| Claude Code | Type `/data-agent-tutor`, or ask: `Tutor me through the Local course.` | +| Codex CLI, Gemini CLI | Ask: `Tutor me through the Local course.` Both find the skill in `.agents/skills/`. | +| Any other agent | Say: `Read skills/data-agent-tutor/SKILL.md and follow it. Tutor me through the Local course.` | + +What was tried: lessons were run in Claude Code (2.1.287). For Codex CLI +(0.147.0) and Gemini CLI (0.34.0) the check was that each lists the skill; no +lesson was run in them. If yours does not pick the skill up, the sentence in the +last row works in any agent that can read a file. + +Started with no request, the tutor asks three things: + +```text +1. Course: Cloud (a team card, StreamNative Cloud) or Local (everything on your laptop)? +2. Path: CLI, Python, or TypeScript? +3. What now: start at Lab 0, resume at a lab, quiz me on a lab, or check my setup? +``` + +## What to say to it + +| You want to | Say | +|---|---| +| Take a course from the start | `Start the Cloud course on the Python path.` | +| Pick up where you stopped | `Resume the Local course at Lab 3. I'm on TypeScript.` | +| Be quizzed | `Quiz me on Lab 2.` | +| Find out why something fails | `Check my setup.` Or paste the error. | +| Get unstuck on a task | `Give me a hint.` | +| Move on | `Skip this.` | + +After each step, paste what its check printed. A check that prints the evidence +means the step worked. A check that prints nothing means it has not, and the +tutor takes it from there. + +## What it looks like + +This is from a real session, shortened. It was recorded before the tutor put a +lab's "Before you start" list above the first step it sends: + +```text +You: Local. I'm on the CLI path. Resume at Lab 2. + +Tutor: Lab 2, step 1 of 3: Connect RisingWave to the topic + + In the terminal at the repository root. + + Run: + local/sql.sh < sql/local/00_source.sql + local/sql.sh < sql/local/01_explore.sql + + Check: + local/sql.sh -tA -c 'SELECT count(*) FROM "security.login_events"' + This prints 246, the number of logins the seeder loaded. + + Run both, then paste what the check printed. + +You: 246 + +Tutor: 246 matches the page: the source reads the whole topic, and that is the + number of logins the seeder loaded. ... + + Lab 2, step 2 of 3: Turn the stream into context ... + +You: (an error) can you just run it on your side and see what's wrong? + +Tutor: I don't run the lab's commands, not even just to look: the steps are + yours, and your terminal is the one that counts. We also don't need it, + because the error you pasted already says what is wrong. ... +``` + +## If the tutor does not show up + +- **Check where the agent was started.** It has to be in your clone, in the + folder that holds `labs/`. Start it there again. +- **Update your clone.** `git pull`, then start the agent again: agents read + their skills when they start. +- **On Windows, clone inside WSL.** The skill is linked into `.claude/skills/` + and `.agents/skills/`. Git for Windows can check such links out as small text + files, and then the agent does not find the skill. This was not tried here; + the next sentence works either way. +- **Use the sentence that always works.** Tell the agent: + `Read skills/data-agent-tutor/SKILL.md and follow it.` Any agent that can read + a file can be the tutor. + +## Install it for your user account (optional) + +You do not need this inside the clone. It makes the skill available to an agent +that reads its skills only from your home directory. The tutor still has to be +started in the clone, because that is where the lab pages are. + +With the [`skills`](https://github.com/vercel-labs/skills) CLI, at the root of +your clone: + +```bash +npx skills add "$PWD" --skill data-agent-tutor --agent claude-code --global -y +``` + +This copies the skill to `~/.claude/skills/data-agent-tutor/`. For another +agent, change `--agent` (`npx skills --help` describes the options). Keep +`--global`: without it, the CLI installs into the current folder, and inside the +clone that replaces the skill links the repository ships. + +Two more ways take the repository from GitHub instead of your clone. They follow +the `skills` CLI's and Claude Code's documentation, and were not run when this +page was written: + +```bash +npx skills add streamnative/dataplusagent-hackathon-tutorial --skill data-agent-tutor --global +``` + +```text +/plugin marketplace add streamnative/dataplusagent-hackathon-tutorial +/plugin install data-agent-tutor@dataplusagent-hackathon-tutorial +``` diff --git a/lab-ork b/lab-ork new file mode 100755 index 0000000..8643f4e --- /dev/null +++ b/lab-ork @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# shellcheck source-path=SCRIPTDIR +# `ork`, pointed at your Agent Engine, with the ids your lab scripts saved filled in. +# Every check in the labs is one line of this plus jq: +# +# ./lab-ork agent get @agent_id -o json | jq -e '{name, version}' +# ./lab-ork agent sessions events list --session @session_id -o json | jq -r '.data[].type' +# +# It reads .env the way the CLI path does, so it works on either stack and on any +# of the three paths, and it replaces these four words with the ids in .orca-state/: +# +# @agent_id @environment_id @vault_id @session_id +# +# Everything else goes to ork exactly as you typed it. +set -euo pipefail +# shellcheck source=cli/env.sh +. "$(dirname "${BASH_SOURCE[0]}")/cli/env.sh" +hello_setup ORCA_BASE_URL + +args=() +for arg in "$@"; do + case "$arg" in + @agent_id | @environment_id | @vault_id | @session_id) + key=${arg#@} + id="" + [ ! -f "$HELLO_STATE_FILE" ] || id=$(jq -r --arg key "$key" '.[$key] // empty' "$HELLO_STATE_FILE") + [ -n "$id" ] || + hello_die "No $key yet: the lab step that creates it has not run (looked in ${HELLO_STATE_FILE#"$HELLO_REPO_ROOT"/})." + args+=("$id") + ;; + *) args+=("$arg") ;; + esac +done + +exec ork ${args[@]+"${args[@]}"} diff --git a/labs/README.md b/labs/README.md new file mode 100644 index 0000000..ef0670a --- /dev/null +++ b/labs/README.md @@ -0,0 +1,75 @@ +# The labs + +Two courses teach the same five labs on two stacks. Pick one. + +| | [Cloud course](cloud/README.md) | [Local course](local/README.md) | +|---|---|---| +| Runs on | StreamNative Cloud: your team's Kafka cluster, SQL Workspace, and a hosted Agent Engine | Your laptop: Ursa for Kafka, RisingWave, and the Orca Agent Engine | +| You need | A team card, handed out at the hackathon | Docker and an Anthropic API key | +| Time | About 30 minutes | About 45 minutes, plus the image downloads | +| Take it when | You are at the event | You have no team card, or you want to see every part run | + +Both courses use the same three paths for the agent steps. Pick one path and stay +on it; a teammate can pick another. + +- **CLI**: the [`ork`](https://github.com/orca-ae/orca-cli) command line +- **Python**: the [`runorca`](https://pypi.org/project/runorca/) SDK +- **TypeScript**: the [`@runorca/orca-sdk`](https://www.npmjs.com/package/@runorca/orca-sdk) SDK + +## The labs + +| Lab | You | The idea | +|---|---|---| +| 0. Set up | Get your stack ready and run the doctor | Know that every part answers before you build on it | +| 1. Hello, agent | Create an agent and talk to it | Agent, environment, session, events | +| 2. Hello, streaming SQL | Build a materialized view over the login topic | Context that keeps itself fresh | +| 3. Agent + live context | Give the agent SQL tools, then inject new data | The answer changes with the data | +| 4. Agent acts, human approves | Let the agent write, with your OK | Governed actions | + +The labs build on each other, so take them in order. + +## How a lab works + +Every lab has the same parts, in the same order. + +1. **Before you start** says what has to be true first. +2. **Three or four steps.** Each step has you do one thing, then explains what + happened. Each step ends in a **Check**. +3. **Check your understanding**: two or three questions. The answer is folded + under each one; decide first, then open it. +4. **Try it yourself**: a small task with no instructions, its own check, and a + folded solution. +5. **Recap** and **What's next**. + +In a guided session the steps are what you do together. The questions and the +task are yours to do afterwards. + +## How to check your work + +A check is a command that reads something and changes nothing. Before you do a +step, its check prints nothing, or says what is missing. After the step, it +prints the evidence. + +Most checks look like this: + +```bash +./lab-ork agent get @agent_id -o json | jq -e '{name, version}' +``` + +- [`./lab-ork`](../lab-ork) is `ork`, pointed at your Agent Engine with the key + from your `.env`. It replaces `@agent_id`, `@environment_id`, `@vault_id` and + `@session_id` with the ids your lab scripts saved in `.orca-state/`, so you + never copy an id by hand. +- `jq -e` prints what the filter selects, and exits non-zero when it selects + nothing. A check passes when it prints something. + +The other checks are SQL: a `SELECT` that returns a row once the step is done. + +Keep two terminals open. Run the steps in your path's folder (`cli/`, `python/` +or `typescript/`), and run the checks from the repository root. + +## Learn with a tutor + +If you use a coding agent such as Claude Code, it can walk you through a course +one step at a time, check your work with you, and quiz you. See +[Learn with the tutor](../docs/tutor.md). diff --git a/labs/cloud/00-set-up.md b/labs/cloud/00-set-up.md new file mode 100644 index 0000000..ccd193d --- /dev/null +++ b/labs/cloud/00-set-up.md @@ -0,0 +1,196 @@ +# Lab 0: Set up + +**Cloud course** · 3 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You put your team card in `.env` and run the doctor. When this lab is done, you +know that the Agent Engine, Kafka, and Schema Registry on your card all answer. + +## Before you start + +- You have your **team card** from the organizers. +- You cloned this repository and opened a terminal in it. The terminal runs + `bash`: on Windows that is WSL or Git Bash, on every path, because the checks + are `bash` commands. +- You have `git`, [`ork`](https://github.com/orca-ae/orca-cli) v0.6.0 or newer, + and [`jq`](https://jqlang.org/download/). `ork` does the first MCP login in + Lab 3 for all three paths, and `ork` with `jq` runs the checks in every lab. + +## Step 1: Install your path + +Pick **one** path. Your teammate can pick a different one. + +**Python** (3.11 or newer) + +```bash +cd python +python3 -m venv .venv +source .venv/bin/activate # Git Bash on Windows: source .venv/Scripts/activate +pip install -r requirements.txt +``` + +**TypeScript** (Node.js 20 or newer) + +```bash +cd typescript +npm install +``` + +**CLI**: `ork` and `jq` are all the labs need. Set up Python or TypeScript as +above too: the doctor and the data injector come from one of them. + +### Check + +Run the doctor without the network. Every line says `PASS`. + +| Python or CLI | TypeScript | +|---|---| +| `python doctor.py --offline` | `npm run doctor -- --offline` | + +```text +PASS Python 3.11+ 3.13 +PASS package runorca +PASS package confluent-kafka[avro] +PASS package python-dotenv +PASS ork found +PASS jq found + +All good: you're ready. +``` + +## Step 2: Paste your team card + +Open a second terminal at the repository root. Copy the template, then paste the +values from your team card into `.env`: + +```bash +cp .env.cloud.example .env +``` + +`.env` is git-ignored. It holds your team's key: do not commit it or paste it +anywhere. + +`SN_API_KEY` authenticates the hosted Agent Engine, Kafka, and Schema Registry. +The MCP server uses a separate browser login, in Lab 3: keep `SN_MCP_AUTH=oauth` +and leave `SN_MCP_OAUTH_ISSUER` empty. + +### Check + +One authenticated read of your Agent Engine. It prints `true` when the endpoint +and the key on your card are accepted. + +```bash +./lab-ork agent list -o json | jq -e 'has("data")' +``` + +Before you filled in `.env`, the same command says +`Missing ORCA_BASE_URL, SN_API_KEY` instead: the two values it needs. + +## Step 3: Run the doctor + +In your path's folder: + +| Python | TypeScript | CLI | +|---|---|---| +| `python doctor.py` | `npm run doctor` | `(cd ../python && .venv/bin/python doctor.py)` or `(cd ../typescript && npm run doctor)` | + +The doctor checks your laptop, then each service on your card. A failed check +prints its fix on the next line. After the lines from step 1, it prints: + +```text +PASS .env cloud stack, participant: ana +PASS ORCA_BASE_URL https://... +PASS Agent Engine API key accepted +PASS Kafka security.login_events has 1 partition(s) +PASS Schema Registry security.login_events-value v1 +PASS login topic schema has account_id, event_time, ip_address, result, failure_reason +WAIT MCP OAuth no tutorial vault yet + next: Nothing to do now: Lab 3 opens your browser to authorize the MCP server. Run doctor again after it. + +You're ready. 1 check(s) wait for a later lab. +``` + +`WAIT` is not a failure. The MCP server needs a login that only Lab 3 can do. + +### Check + +The last line of the doctor says you are ready, and no line says `FAIL`. + +```bash +(cd python && .venv/bin/python doctor.py) | tail -n 1 +``` + +On the TypeScript path, use `npm --prefix typescript run doctor | tail -n 1`. + +Still failing after two tries? Raise your hand, or see +[Troubleshooting](troubleshooting.md). + +## Check your understanding + +**1. The doctor prints `WAIT MCP OAuth`. What should you do?** + +- A. Fix it now: the doctor has to print only `PASS`. +- B. Nothing yet: Lab 3 does the browser login this check waits for. +- C. Ask for a new team card. + +<details> +<summary>Answer</summary> + +**B.** The MCP server wants an OAuth login, and the lab script does it the first +time the agent needs the server. `WAIT` means "not ready, and not your mistake". +A real problem prints `FAIL` and its fix. + +</details> + +**2. What does `./lab-ork` add to `ork`?** + +- A. It is a different CLI with its own commands. +- B. It points `ork` at your Agent Engine with the key from `.env`, and fills in the ids your scripts saved. +- C. It runs the lab's steps for you. + +<details> +<summary>Answer</summary> + +**B.** Everything after `./lab-ork` goes to `ork` as you typed it. The wrapper +only supplies the endpoint, the credential, and the four `@..._id` words. + +</details> + +## Try it yourself + +Make the doctor fail on purpose, so you know what a failure looks like before a +real one. Change one value in `.env` so that a check fails, read the fix the +doctor prints, then put the value back. + +### Check + +The doctor ends on the "ready" line again. + +```bash +(cd python && .venv/bin/python doctor.py) | tail -n 1 +``` + +<details> +<summary>Solution</summary> + +Add `/v1` to the end of `ORCA_BASE_URL` and run the doctor: + +```text +FAIL ORCA_BASE_URL https://<your-host>/v1 + fix: Use the host root only: ORCA_BASE_URL=https://<your-host> +``` + +The doctor does not call an endpoint it can see is wrong. It skips the Agent +Engine check, tells you the exact value to use, and ends with +`1 check(s) failed`. Remove the `/v1` and run it again. + +</details> + +## Recap + +- `.env` in the repository root is your team card. It is git-ignored. +- The doctor checks each service on the card and prints the fix for a failure. +- `./lab-ork` is how you look at your Agent Engine from the terminal. + +## What's next + +[Lab 1: Hello, agent](01-hello-agent.md) diff --git a/labs/cloud/01-hello-agent.md b/labs/cloud/01-hello-agent.md new file mode 100644 index 0000000..88e13d2 --- /dev/null +++ b/labs/cloud/01-hello-agent.md @@ -0,0 +1,278 @@ +# Lab 1: Hello, agent + +**Cloud course** · 5 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You create an agent with no tools, start a conversation, and read back what the +Agent Engine recorded. When this lab is done, you have an agent at version 1 and +you know the four things every later lab reuses: environment, agent, session, +events. + +## Before you start + +- You finished [Lab 0](00-set-up.md): the doctor says you are ready. +- One terminal is in your path's folder (`cli/`, `python/` with the virtual + environment active, or `typescript/`). A second one is at the repository root. + +## Step 1: Create your agent and say hello + +In your path's folder: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l1_hello.sh` | `python l1_hello.py` | `npm run l1` | + +You see something like this. The agent's wording varies. + +```text +hello-agent-ana v1: no tools: just a conversation +[you] Hi! What is the Data + Agent Hackathon, and what can you see right now? +[agent] It's a one-day build where teams combine live streaming data with AI agents. I can't see any live data yet: the next step connects me to a Kafka stream. +``` + +### Check + +Read your agent back. It prints the name, the version, and that it has no tools. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '{name, version, tools: (.tools | length)} | select(.tools == 0)' +``` + +```json +{ + "name": "hello-agent-ana", + "version": 1, + "tools": 0 +} +``` + +Before the step, the same command says `No agent_id yet`: nothing has been +created. + +**What just happened: four API calls.** + +1. **Environment**: where your agent's sessions run. +2. **Agent**: a model plus a system prompt, defined in + [`agent/cloud/l1-hello.json`](../../agent/cloud/l1-hello.json). All three + paths read that file. +3. **Session**: one conversation, pinned to a specific agent version. +4. **Events**: you send a `user.message`; the agent streams back `agent.message` + events until the session goes idle. + +<details> +<summary>The code (Python)</summary> + +```python +environment_id = ensure_environment(client, state, f"hello-env-{config.participant}") + +layer = load_layer("l1-hello", config.stack) +agent = ensure_agent(client, state, agent_params(layer, config)) + +session = open_session(client, state, environment_id, agent, "L1: hello") +run_turn(client, session.id, question, send_first=config.stack == "local") +``` + +`open_session` ([`python/common.py`](../../python/common.py)) is one call, +`client.sessions.create(environment_id=..., agent={"type": "agent", "id": agent.id, "version": agent.version}, title=...)`, +and it remembers the session id for your checks. `run_turn` opens the event +stream *before* sending the message, so no event is missed, then prints events +until the agent's turn ends. (`send_first` is off on this course. It is for the +Local course's engine.) +</details> + +<details> +<summary>The code (TypeScript)</summary> + +```ts +const environmentId = await ensureEnvironment(client, state, `hello-env-${config.participant}`); + +const layer = loadLayer('l1-hello', config.stack); +const agent = await ensureAgent(client, state, agentParams(layer, config)); + +const session = await openSession(client, state, environmentId, agent, 'L1: hello'); +await runTurn(client, session.id, question, { sendFirst: config.stack === 'local' }); +``` + +`openSession` and `runTurn` ([`typescript/src/common.ts`](../../typescript/src/common.ts)) +work the same way as the Python versions. +</details> + +<details> +<summary>The commands (CLI)</summary> + +```bash +ork agent environments create --name hello-env-ana -o json + +ork agent create --name hello-agent-ana --model "$ORCA_MODEL" \ + --system "$(jq -r .system ../agent/cloud/l1-hello.json)" -o json + +ork agent sessions create --agent <agent id> --agent-version 1 \ + --environment-id <environment id> --title "L1: hello" -o json + +ork agent sessions events send message --session <session id> --text "Hi! ..." +ork agent sessions events stream --session <session id> --timeout 15s +``` + +[`cli/lib.sh`](../../cli/lib.sh) wraps these commands, remembers the ids, and +prints the stream the same way as the other paths. +</details> + +## Step 2: Read the conversation back + +A session is a list of events. From the repository root, list the types of the +events in the conversation you just had, oldest first: + +```bash +./lab-ork agent sessions events list --session @session_id --order asc -o json | jq -r '.data[].type' +``` + +```text +user.message +session.status_running +agent.message +session.status_idle +``` + +Your message, the agent's reply, and the event that ends the turn. You may see +`span.` events too, which time the model request, and `agent.thinking`, the +model working out its answer before it gives it. + +### Check + +"The agent replied" is three facts together: the turn ended normally, and a +reply came after your message. This prints the reply when all three hold. + +```bash +./lab-ork agent sessions events list --session @session_id --order asc --limit 200 -o json | jq -e ' + .data + | select((map(select(.type == "session.status_idle")) | last | .stop_reason.type) == "end_turn") + | .[(map(.type) | rindex("user.message")):] + | map(select(.type == "agent.message")) | last + | select(. != null) + | {reply: .content[0].text}' +``` + +Reading it line by line: take the events; keep going only if the last idle event +stopped for `end_turn`; look at what came after your last message; take the last +`agent.message` there; print its text. A turn that ended for another reason, or +ended without a reply, prints nothing. + +## Step 3: Run it again + +Ask your own question this time: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l1_hello.sh "What will you be able to do in Lab 3?"` | `python l1_hello.py "What will you be able to do in Lab 3?"` | `npm run l1 -- "What will you be able to do in Lab 3?"` | + +The first line still says `v1`. Re-running is safe: the scripts remember your +agent in `.orca-state/`, and only create a new version when its definition +changes. The conversation is new; the agent is not. + +### Check + +Your agent now has two sessions, and both are pinned to version 1. It prints +them once there are two. + +```bash +./lab-ork agent sessions list --agent @agent_id -o json | jq -e '[.data[] | {title, version: .agent.version}] | select(length >= 2)' +``` + +```json +[ + { + "title": "L1: hello", + "version": 1 + }, + { + "title": "L1: hello", + "version": 1 + } +] +``` + +## Check your understanding + +**1. What ties a session to one definition of the agent?** + +- A. The agent's name +- B. The agent version the session was created with +- C. The environment + +<details> +<summary>Answer</summary> + +**B.** A session records the agent id *and* version. Changing the agent later +creates a new version and does not touch a conversation that is already running. + +</details> + +**2. You run the script twice without changing anything. How many agents and versions exist?** + +- A. Two agents +- B. One agent, at version 2 +- C. One agent, at version 1 + +<details> +<summary>Answer</summary> + +**C.** The script stores a fingerprint of the definition in the agent's +metadata. Same fingerprint, no update. Each run does start a new session. + +</details> + +**3. Which event tells you the agent's turn is over?** + +- A. `agent.message` +- B. `session.status_idle` +- C. `user.message` + +<details> +<summary>Answer</summary> + +**B.** It carries a `stop_reason`. `end_turn` means the turn finished normally; +in Lab 4 you will see `requires_action`, which means the session is waiting for +you. + +</details> + +## Try it yourself + +Change how your agent talks, and watch its version change. Make it answer in a +single sentence, run the lab script again, then confirm the agent is at a newer +version. + +### Check + +It prints the version once it is 2 or more. + +```bash +./lab-ork agent get @agent_id -o json | jq -e 'select(.version >= 2) | {name, version}' +``` + +<details> +<summary>Solution</summary> + +The agent is the JSON file. In +[`agent/cloud/l1-hello.json`](../../agent/cloud/l1-hello.json), change +"Answer in two or three sentences." to "Answer in one sentence.", then run the +Lab 1 script again. The first line now says `v2`. + +Put the file back before the next lab (`git checkout agent/cloud/l1-hello.json`). +Your version numbers will run one or two ahead of the ones the labs show. That is +fine: the checks never depend on an exact number after this lab. + +On the CLI path, do this before Lab 3. Once your agent has tools, `ork` cannot +take them away, so going back to Lab 1 starts a fresh agent at version 1, and the +script says so. + +</details> + +## Recap + +- An agent is configuration: a model, a system prompt, and (later) tools. +- Every change to it is a new version. A session is pinned to one version. +- A conversation is a list of events, and `session.status_idle` ends a turn. + +## What's next + +[Lab 2: Hello, streaming SQL](02-streaming-sql.md) diff --git a/labs/cloud/02-streaming-sql.md b/labs/cloud/02-streaming-sql.md new file mode 100644 index 0000000..a1d198b --- /dev/null +++ b/labs/cloud/02-streaming-sql.md @@ -0,0 +1,200 @@ +# Lab 2: Hello, streaming SQL + +**Cloud course** · 8 minutes, plus 5 on your own · SQL Workspace, in the StreamNative Cloud console + +You turn the login topic into a materialized view that keeps a running summary +per account. When this lab is done, there is a view your agent can read in +Lab 3, and a table it can write to in Lab 4. + +## Before you start + +- You finished [Lab 1](01-hello-agent.md). +- In the StreamNative Cloud console, open **SQL Workspace**, select the hackathon + workspace, and pick your team's database. Use a new query tab for each step. +- **Align the SQL with your `.env` first.** The default Kafka topic is + `security.login_events`, and SQL Workspace exposes its Avro source as + `"avro.security.login_events"`. The injector and the doctor read `LOGIN_TOPIC` + from `.env`, but SQL Workspace does not: the SQL files and the examples below + contain a fixed source name. Check `LOGIN_TOPIC`, then replace + `"avro.security.login_events"` with `"avro.<your LOGIN_TOPIC>"` in + [`sql/cloud/01_explore.sql`](../../sql/cloud/01_explore.sql) and + [`sql/cloud/02_login_failures.sql`](../../sql/cloud/02_login_failures.sql), and + in any query copied from this page. For example, + `LOGIN_TOPIC=security.team07_logins` requires `FROM "avro.security.team07_logins"`. + Keep the double quotes around the entire source name, and confirm that SQL + Workspace imported that topic as an Avro source. Keep the `login_failures` + view name: Labs 3 and 4 query that view. + +## Step 1: Peek at the stream + +Run [`sql/cloud/01_explore.sql`](../../sql/cloud/01_explore.sql). Each row is +one login attempt. The topic name contains dots, so it is double-quoted. + +```sql +SELECT event_time, account_id, ip_address, result, failure_reason +FROM "avro.security.login_events" +ORDER BY event_time DESC +LIMIT 20; +``` + +### Check + +The query returns 20 rows, newest first, and `result` is `SUCCESS` or `FAILURE`. +This counts what the source holds: it returns a number greater than zero. + +```sql +SELECT count(*) AS logins FROM "avro.security.login_events"; +``` + +If it says `relation "avro.security.login_events" does not exist`, you are in +the wrong database or the source name does not match your topic: see +[Troubleshooting](troubleshooting.md). + +## Step 2: Turn the stream into context + +Run the first statement of +[`sql/cloud/02_login_failures.sql`](../../sql/cloud/02_login_failures.sql). + +```sql +CREATE MATERIALIZED VIEW login_failures AS +SELECT + account_id, + COUNT(*) FILTER (WHERE result = 'FAILURE') AS failed_logins, + COUNT(*) FILTER (WHERE result = 'SUCCESS') AS successful_logins, + COUNT(DISTINCT ip_address) AS distinct_ips, + MAX(event_time) AS last_seen +FROM "avro.security.login_events" +GROUP BY account_id; +``` + +A materialized view is maintained incrementally: every new login updates the +counts within seconds. There is no batch job to schedule and nothing to refresh. +That makes it good agent context: always current, and cheap to read. + +Look at it: + +```sql +SELECT * FROM login_failures ORDER BY failed_logins DESC LIMIT 10; +``` + +`acct_0042` is near the top: failed logins, then a success. That is the +attacker. + +### Check + +The view exists and has found the account under attack. This returns one row. + +```sql +SELECT account_id, failed_logins, successful_logins, distinct_ips +FROM login_failures +WHERE account_id = 'acct_0042' AND failed_logins >= 5 AND successful_logins >= 1; +``` + +Before the step, the same query fails: `login_failures` does not exist. + +## Step 3: Make room for the agent's decisions + +Run [`sql/cloud/03_flagged_accounts.sql`](../../sql/cloud/03_flagged_accounts.sql). +The agent writes here in Lab 4. + +```sql +CREATE TABLE flagged_accounts ( + account_id VARCHAR PRIMARY KEY, + reason VARCHAR, + flagged_at TIMESTAMPTZ DEFAULT now() +); +``` + +### Check + +The table exists and is empty: this returns `0`. + +```sql +SELECT count(*) AS flagged FROM flagged_accounts; +``` + +## Check your understanding + +**1. A new login event arrives in Kafka. What has to happen for `login_failures` to include it?** + +- A. Someone reruns the `CREATE MATERIALIZED VIEW` statement. +- B. A scheduled job refreshes the view. +- C. Nothing: the view is updated as the event arrives. + +<details> +<summary>Answer</summary> + +**C.** A materialized view is a standing query. It is maintained incrementally +as events arrive, so reading it is a cheap lookup and the result is current. + +</details> + +**2. Why is the source written as `"avro.security.login_events"`, in double quotes?** + +- A. The name contains dots, and without quotes each dot would separate a schema from a name. +- B. Double quotes make the query case-insensitive. +- C. Quotes are required for every table in SQL Workspace. + +<details> +<summary>Answer</summary> + +**A.** The dots are part of the name. Quoting the whole name keeps it one +identifier. + +</details> + +**3. Why does an agent read a view like this instead of the raw topic?** + +- A. Agents cannot read Kafka. +- B. The view answers "what is true now" in one small query, instead of the agent re-reading and counting every event. +- C. The view hides the data from the agent. + +<details> +<summary>Answer</summary> + +**B.** The database does the counting once, continuously. The agent's context +stays small and current, with no pipeline to babysit. + +</details> + +## Try it yourself + +The agent's rule of thumb in Lab 3 is "five or more failed logins plus at least +one success". Write one query over `login_failures` that returns only the +accounts that match it, worst first. + +### Check + +Your query's result includes `acct_0042`, and no account with fewer than five +failed logins or without a success. + +```text + account_id | failed_logins | successful_logins | ... +------------+---------------+-------------------+----- + acct_0042 | 5 | 2 | ... +``` + +<details> +<summary>Solution</summary> + +```sql +SELECT account_id, failed_logins, successful_logins, distinct_ips, last_seen +FROM login_failures +WHERE failed_logins >= 5 AND successful_logins >= 1 +ORDER BY failed_logins DESC; +``` + +This is close to what the agent runs in Lab 3. It writes the SQL itself. + +</details> + +## Recap + +- The login topic is a source you can query with SQL. +- `login_failures` is a materialized view: a running summary per account, kept + current as events arrive. +- `flagged_accounts` is an ordinary table, waiting for the agent's decisions. + +## What's next + +[Lab 3: Agent + live context](03-live-context.md) diff --git a/labs/cloud/03-live-context.md b/labs/cloud/03-live-context.md new file mode 100644 index 0000000..c61354b --- /dev/null +++ b/labs/cloud/03-live-context.md @@ -0,0 +1,281 @@ +# Lab 3: Agent + live context + +**Cloud course** · 9 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You give your agent two read-only SQL tools, ask it who is under attack, then +change the data and ask again. When this lab is done, the agent answers from the +materialized view, and its answer changes when the stream does. + +## Before you start + +- You finished [Lab 2](02-streaming-sql.md): `login_failures` exists in your + team's database. +- One terminal is in your path's folder, a second one is at the repository root. +- `ork` v0.6.0 or newer is installed. All three paths use it for the first MCP + login. + +## Step 1: Give the agent SQL tools, and ask + +In your path's folder: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l3_live_context.sh` | `python l3_live_context.py` | `npm run l3` | + +**The first run opens your browser.** The StreamNative MCP server wants an OAuth +login. Approve it. The script waits, then continues; later runs reuse the +credential without another login. + +Your agent gets a new version, and answers *"Which accounts look like an account +takeover right now?"* by querying `login_failures` itself. The `[tool]` lines +show the SQL it runs: + +```text +hello-agent-ana v2: + StreamNative MCP (read-only SQL tools) +Tip: after the first answer, run `python inject.py` in another terminal and ask again. + +[you] Which accounts look like an account takeover right now? +[tool] sql_workspace_list_databases {} +[tool] sql_workspace_query {"database": "...", "sql": "SELECT account_id, failed_logins, ..."} +[agent] acct_0042: 5 failed logins followed by a success, from 2 IP addresses ... + +Ask again (Enter to quit): +``` + +**Leave this conversation open.** You come back to it in step 3. + +### Check + +From the repository root. The agent's definition now has an MCP toolset, and this +prints the tools that are enabled in it: exactly two. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '.tools[] | select(.type == "mcp_toolset") | [.configs[] | select(.enabled) | .name]' +``` + +```json +[ + "sql_workspace_list_databases", + "sql_workspace_query" +] +``` + +Before the step it prints nothing: the Lab 1 agent has no tools. + +**What changed** ([`agent/cloud/l3-live-context.json`](../../agent/cloud/l3-live-context.json)): + +- `mcp_servers`: the StreamNative MCP server for your SQL Workspace. +- `tools`: an allow-list. Two read-only tools run without asking + (`always_allow`); every other tool on that server is disabled. +- A **vault**: the MCP server's OAuth credential is created through `ork` and + stored server-side. The session references the vault by id, so tokens never + enter the prompt, `.env`, or the state file, and the server can refresh them. + +<details> +<summary>The code (Python)</summary> + +```python +layer = load_layer("l3-live-context", config.stack) +agent = ensure_agent(client, state, agent_params(layer, config)) + +vault_ids = mcp_vault_ids(client, state, config) + +session = open_session(client, state, environment_id, agent, "L3: live context", vault_ids=vault_ids) +chat(client, session.id, QUESTION, send_first=config.stack == "local") +``` +</details> + +<details> +<summary>The code (TypeScript)</summary> + +```ts +const layer = loadLayer('l3-live-context', config.stack); +const agent = await ensureAgent(client, state, agentParams(layer, config)); + +const vaultIds = await mcpVaultIds(client, state, config); + +const session = await openSession(client, state, environmentId, agent, 'L3: live context', { vaultIds }); +await chat(client, session.id, QUESTION, { sendFirst: config.stack === 'local' }); +``` +</details> + +<details> +<summary>The commands (CLI)</summary> + +```bash +ork agent update <agent id> --version 1 --model "$ORCA_MODEL" \ + --system "$(jq -r .system ../agent/cloud/l3-live-context.json)" \ + --mcp-server "name=streamnative,type=url,url=$SN_MCP_URL" \ + --tool-json "$(jq -c '.tools[0]' ../agent/cloud/l3-live-context.json)" -o json + +ork agent vaults create --display-name hello-vault-ana -o json +ork agent vaults credentials create --vault <vault id> --display-name streamnative-mcp \ + --mcp-server-url "$SN_MCP_URL" \ + --oauth-scope "$SN_MCP_OAUTH_SCOPE" -o json + +ork agent sessions create --agent <agent id> --agent-version 2 \ + --environment-id <environment id> --vault-id <vault id> --title "L3: live context" -o json +``` +</details> + +## Step 2: Inject a fresh attack + +In your **second terminal**, at the repository root, write a new brute-force +burst into the login topic: + +| Python (and CLI path) | TypeScript (and CLI path) | +|---|---| +| `(cd python && .venv/bin/python inject.py)` | `npm --prefix typescript run inject` | + +```text +Injected 6 failed logins + 1 success for acct_9123 from 203.0.113.77 into security.login_events. +Ask your agent again, or run this SQL: SELECT * FROM login_failures WHERE account_id = 'acct_9123'; +``` + +It attacks a new account, `acct_9…`, outside the range the topic was loaded +with. + +### Check + +In SQL Workspace, the view already has the new account. This returns a row; it +returned none before the step. + +```sql +SELECT account_id, failed_logins, successful_logins, last_seen +FROM login_failures +WHERE account_id LIKE 'acct_9%'; +``` + +Nobody refreshed anything: the events landed in Kafka, and the view updated +itself. + +## Step 3: Ask again + +Back in the first terminal, where the conversation is still open, ask the same +question again: + +```text +Ask again (Enter to quit): Which accounts look like an account takeover right now? +[tool] sql_workspace_query {"database": "...", "sql": "SELECT ..."} +[agent] Two accounts now: acct_0042 ... and acct_9123: 6 failed logins followed by a success ... +``` + +The new account shows up. The agent did not change. Its context did, because it +fetches its context when it needs it instead of being handed a snapshot. + +Press Enter to end the conversation. + +### Check + +The agent queried the view again for your second question. This prints how many +query calls it made after that question, once there is one. + +```bash +./lab-ork agent sessions events list --session @session_id --order asc --limit 200 -o json | jq -e ' + .data + | select(map(select(.type == "user.message")) | length >= 2) + | .[(map(.type) | rindex("user.message")):] + | map(select(.type == "agent.mcp_tool_use" and .name == "sql_workspace_query")) + | select(length >= 1) + | {queries_after_your_last_question: length}' +``` + +```json +{ + "queries_after_your_last_question": 1 +} +``` + +Reading it line by line: take the events, oldest first; keep going only if you +asked at least twice; look at what came after your last question; keep the query +calls; print how many there are, when there is at least one. After step 1 it +prints nothing, however many queries the first answer took. + +Now that the browser login is done, the doctor's `MCP OAuth` line says `PASS` +too. It checks that the stored credential still initializes the MCP server. + +## Check your understanding + +**1. Where is the MCP server's OAuth token after the browser login?** + +- A. In `.env` +- B. In the agent's system prompt +- C. In a vault on the Agent Engine; the session only carries the vault's id + +<details> +<summary>Answer</summary> + +**C.** `ork` sends the tokens straight to the vault. They never enter the +prompt, your `.env`, or `.orca-state/`, and the server can refresh them. + +</details> + +**2. The agent's second answer included a new account. What changed between the two answers?** + +- A. The agent was updated to a new version. +- B. The materialized view changed, and the agent queried it again. +- C. The agent remembered the injection from the first answer. + +<details> +<summary>Answer</summary> + +**B.** Same agent, same session. Its system prompt tells it to query before +every answer, so it read the view again and the view had moved. + +</details> + +**3. The MCP server offers many more tools than two. Why can the agent not call, say, a tool that deletes rows?** + +- A. `default_config.enabled` is `false`, so every tool not named in `configs` is off. +- B. The agent does not know those tools exist, but could call them if it guessed the name. +- C. The service account lacks the permission. + +<details> +<summary>Answer</summary> + +**A.** The toolset is an allow-list. A tool that is not enabled is not offered +to the model at all, and a call to it is refused. + +</details> + +## Try it yourself + +Without changing any file, get the agent to run SQL you did not see in this lab. +Start the Lab 3 script again and ask a question whose answer is in +`login_failures` but needs a different query, for example which account was seen +from the most IP addresses. + +### Check + +It prints the SQL of every query in the newest session, oldest first. The last +one is the agent's answer to your question. + +```bash +./lab-ork agent sessions events list --session @session_id --event-type agent.mcp_tool_use --order asc --limit 200 -o json | jq -e '.data[] | select(.name == "sql_workspace_query") | .input.sql' +``` + +<details> +<summary>Solution</summary> + +Run the script, let it answer the first question, then type at the prompt: + +```text +Ask again (Enter to quit): Which account has logged in from the most distinct IP addresses, and how many? +``` + +The agent writes something like +`SELECT account_id, distinct_ips FROM login_failures ORDER BY distinct_ips DESC LIMIT 1`. +You gave it a view and a tool, not a list of queries. + +</details> + +## Recap + +- The agent reads the view itself, through an allow-list of two tools. +- Its credential for the MCP server lives in a vault, not in a prompt. +- Fresh context came from the stream and the view. Nothing about the agent + changed between the two answers. + +## What's next + +[Lab 4: Agent acts, human approves](04-act-with-approval.md) diff --git a/labs/cloud/04-act-with-approval.md b/labs/cloud/04-act-with-approval.md new file mode 100644 index 0000000..871fc36 --- /dev/null +++ b/labs/cloud/04-act-with-approval.md @@ -0,0 +1,222 @@ +# Lab 4: Agent acts, human approves + +**Cloud course** · 5 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You give your agent one tool that writes, and a policy that makes it wait for +you. When this lab is done, the agent has flagged an account because you said +yes, and has not flagged another because you said no. + +## Before you start + +- You finished [Lab 3](03-live-context.md): the agent reads `login_failures`, + and the browser login for the MCP server is done. +- `flagged_accounts` exists in your team's database (Lab 2, step 3). +- One terminal is in your path's folder, a second one is at the repository root. + +## Step 1: Ask the agent to act, and approve + +In your path's folder: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l4_act.sh` | `python l4_act.py` | `npm run l4` | + +The agent gets a new version with one write tool. It queries the view, describes +the flag table, and reads the database time before it proposes an insert. Then +the session pauses, before anything is written: + +```text +[approve?] The agent wants to run sql_workspace_insert_rows with: +{ + "database": "<your database>", + "schema": "public", + "table": "flagged_accounts", + "rows": [{ + "account_id": "acct_9…", + "reason": "6 failed logins then a success from one new IP", + "flagged_at": "2026-10-07T17:30:00Z" + }] +} +Allow it? [y/N] +``` + +Read what it wants to write, then type `y`. + +The MCP insert tool requires every writable column, including nullable ones, and +does not apply table defaults. That is why the agent supplies `flagged_at` +itself, from the database's clock. + +**Leave the conversation open** for step 2. + +### Check + +From the repository root. The session holds your decision: this prints the +confirmation you sent. + +```bash +./lab-ork agent sessions events list --session @session_id --event-type user.tool_confirmation --order asc -o json | jq -e '.data[] | select(.result == "allow") | {tool_use_id, result}' +``` + +And the row is there. In SQL Workspace: + +```sql +SELECT * FROM flagged_accounts; +``` + +## Step 2: Ask again, and deny + +At the prompt, ask the agent to flag a different account, and answer `n` this +time: + +```text +Ask again (Enter to quit): Now flag acct_0042 as well. +[approve?] The agent wants to run sql_workspace_insert_rows with: +... +Allow it? [y/N] n +[agent] The insert was denied by a human reviewer, so acct_0042 has not been flagged. +``` + +The agent is told a human denied the insert, and it does not retry. Press Enter +to end the conversation. + +### Check + +The session holds the denial, with the reason the agent was given. + +```bash +./lab-ork agent sessions events list --session @session_id --event-type user.tool_confirmation --order asc -o json | jq -e '.data[] | select(.result == "deny") | {tool_use_id, result, deny_message}' +``` + +In SQL Workspace, `SELECT * FROM flagged_accounts;` still returns one row. + +## Step 3: Read the policy that made it wait + +Nothing in the prompt made the agent stop. Look at the agent's tools: + +```bash +./lab-ork agent get @agent_id -o json | jq -c '.tools[].configs[] | {name, policy: .permission_policy.type}' +``` + +```json +{"name":"sql_workspace_list_databases","policy":"always_allow"} +{"name":"sql_workspace_query","policy":"always_allow"} +{"name":"sql_workspace_describe_table","policy":"always_allow"} +{"name":"sql_workspace_insert_rows","policy":"always_ask"} +``` + +**What changed** ([`agent/cloud/l4-act.json`](../../agent/cloud/l4-act.json)): +the read-only `sql_workspace_describe_table` checks the required columns, and +`sql_workspace_insert_rows` uses `permission_policy: always_ask`. When the agent +calls it, the session emits `agent.mcp_tool_use` and goes idle with +`stop_reason: requires_action`. Your script answers with a +`user.tool_confirmation`: `allow`, or `deny` with a reason. On the CLI that is: + +```bash +ork agent sessions events send tool-confirmation --session <session id> \ + --tool-use-id <tool use event id> --decision allow +``` + +### Check + +Exactly one tool needs your approval. This prints its name. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '.tools[].configs[] | select(.permission_policy.type == "always_ask") | .name' +``` + +## Check your understanding + +**1. The agent calls a tool whose policy is `always_ask`. What happens to the session?** + +- A. The tool runs, and you are told afterwards. +- B. The session goes idle with `stop_reason: requires_action` until someone sends a `user.tool_confirmation`. +- C. The call fails and the agent tries another tool. + +<details> +<summary>Answer</summary> + +**B.** The pause is in the Agent Engine, not in the model's manners. Nothing is +written until a client answers. + +</details> + +**2. You answered `n`. What did the agent learn?** + +- A. Nothing: the tool call looked like a timeout. +- B. That a human denied the action, with the reason your script sent. +- C. That the table is read-only. + +<details> +<summary>Answer</summary> + +**B.** A denial comes back to the agent as the result of its tool call, with the +`deny_message`. Its instructions say to report that and not retry. + +</details> + +**3. Why is the approval a policy on the tool, and not a sentence in the system prompt?** + +- A. A prompt is a request the model can misread or be talked out of; a policy is enforced outside the model. +- B. Policies are shorter to write. +- C. The system prompt cannot mention tools. + +<details> +<summary>Answer</summary> + +**A.** Governance that depends on the model behaving is not governance. The +allow-list and the policy hold whatever the model decides to try. + +</details> + +## Try it yourself + +Put a human in front of the agent's reads as well. Change one policy so that +every SQL query pauses for approval, run the lab script, and approve a query. + +### Check + +Two tools need approval now. It prints both names. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '[.tools[].configs[] | select(.permission_policy.type == "always_ask") | .name] | select(length == 2)' +``` + +<details> +<summary>Solution</summary> + +In [`agent/cloud/l4-act.json`](../../agent/cloud/l4-act.json), change the policy +of `sql_workspace_query` from `always_allow` to `always_ask`, then run the Lab 4 +script again. The agent gets a new version, and the first thing you see is an +approval prompt for a `SELECT`. + +Put the file back afterwards (`git checkout agent/cloud/l4-act.json`). Asking +for every read is the right default for a tool you do not trust yet, and the +wrong one for a tool the agent calls ten times a minute. + +</details> + +## Clean up + +| CLI | Python | TypeScript | +|---|---|---| +| `./cleanup.sh` | `python cleanup.py` | `npm run cleanup` | + +This archives your agent and environment, and deletes your vault. An environment +with session history cannot be deleted; archiving keeps that history available. +To start Lab 2 over, run [`sql/cloud/99_reset.sql`](../../sql/cloud/99_reset.sql) +in SQL Workspace. + +## Recap + +You built the shape of most data + agent applications: + +- **A stream** (Kafka) that holds the facts as they happen. +- **A materialized view** that keeps a running summary: the agent's always-fresh + context. +- **An agent** that reads that context itself, through an allow-list of tools. +- **A human approval gate** on the one action that changes something. + +## What's next + +Swap the topic, the view, and the action, and you have your hackathon project. +Ideas and next steps: [Go further](../../docs/go-further.md). diff --git a/labs/cloud/README.md b/labs/cloud/README.md new file mode 100644 index 0000000..5a6cd92 --- /dev/null +++ b/labs/cloud/README.md @@ -0,0 +1,63 @@ +# Cloud course + +**Data + Agent Hackathon: hello world, on StreamNative Cloud · about 30 minutes** + +You build an agent whose context is a live Kafka stream, kept fresh by streaming +SQL, and that asks a human before it acts. Everything runs on the environment on +your team card. + +## The story + +Aegis Financial, a fictional bank, streams every login attempt into Kafka. +Somewhere in that stream, an attacker is guessing passwords. Your agent spots +them from live data, and flags the account once you say so. + +```mermaid +flowchart LR + K["Kafka topic<br/>security.login_events"] --> S["SQL Workspace<br/>materialized view<br/>login_failures"] + J["inject<br/>(you, in Lab 3)"] -- "new login burst" --> K + S -- "StreamNative MCP<br/>sql_workspace_query" --> A["Orca agent<br/>hello-agent-<you>"] + A -- "sql_workspace_insert_rows<br/>(only if you approve)" --> F["SQL table<br/>flagged_accounts"] +``` + +## The labs + +| Lab | Time | Where | You | The idea | +|---|---|---|---|---| +| [0. Set up](00-set-up.md) | 3 min | terminal | Fill in `.env`, run the doctor | Check service access before you build on it | +| [1. Hello, agent](01-hello-agent.md) | 5 min | CLI / Python / TS | Create an agent and chat | Agent, environment, session, events | +| [2. Hello, streaming SQL](02-streaming-sql.md) | 8 min | SQL Workspace | Build a materialized view over the topic | Context that keeps itself fresh | +| [3. Agent + live context](03-live-context.md) | 9 min | CLI / Python / TS | Give the agent SQL tools, inject new data | The answer changes with the data | +| [4. Agent acts, human approves](04-act-with-approval.md) | 5 min | CLI / Python / TS | Let the agent write, with your OK | Governed actions | + +The times are for the steps. Each lab also has a short quiz and a task to try on +your own. + +## What you need + +- Your **team card** from the organizers. Your team's Kafka cluster already + holds the login stream. +- One path installed, plus `ork` and `jq`: see + [Before you arrive](../../docs/before-you-arrive.md). + +Start with [Lab 0: Set up](00-set-up.md). If something goes wrong, see +[Troubleshooting](troubleshooting.md). How labs and checks work is in +[The labs](../README.md), and a coding agent can +[tutor you through the course](../../docs/tutor.md). + +No team card? Take the [Local course](../local/README.md): the same labs, on +your laptop. + +## What was run + +These pages were rewritten on 2 October 2026, and the checks were added then. + +- **Run, on the Agent Engine of the [Local course](../local/README.md)** (`ork` + 0.6.0, which serves the same API): the check commands of Labs 1, 3 and 4 that + read your agent, its versions, its tools and their policies, and its sessions. +- **Not run for this revision: anything that needs a team card.** That is the + doctor against StreamNative Cloud, SQL Workspace (Lab 2), the browser login + and the vault (Lab 3), the hosted SQL tools (Labs 3 and 4), and every step + where the model answers. For those steps the labs show what the scripts are + written to print and what the checks are written to select, not a recording of + a run. diff --git a/labs/cloud/troubleshooting.md b/labs/cloud/troubleshooting.md new file mode 100644 index 0000000..8fac950 --- /dev/null +++ b/labs/cloud/troubleshooting.md @@ -0,0 +1,57 @@ +# Troubleshooting: Cloud course + +Run the doctor first. It checks each service on your team card and prints the +fix for what fails. + +| Python or CLI | TypeScript | +|---|---| +| `python doctor.py` | `npm run doctor` | + +Still stuck after two tries? Raise your hand. + +## Symptoms + +| Symptom | Fix | +|---|---| +| Doctor: `Agent Engine HTTP 401/403` | The key was rejected. A key created before its permissions must be re-created: ask a facilitator. | +| Doctor: `Kafka ... authentication` | `SN_SERVICE_ACCOUNT` must be the full principal, `<name>@<org>.auth.streamnative.cloud`; `SN_API_KEY` is the raw key. | +| Doctor: `WAIT MCP OAuth` | Not a failure. Lab 3 does the browser login; run the doctor again after it. | +| The login topic isn't listed in SQL Workspace | Only topics with a registered Avro schema appear. Ask a facilitator. | +| `relation "avro.security.login_events" does not exist` | Select your team's database, and update the quoted Avro source in both Lab 2 SQL files to match `LOGIN_TOPIC` in `.env`. | +| The agent can't find `login_failures` | Create the view in your team's database (Lab 2, step 2); the agent looks it up there. | +| `[error]` lines from MCP tools in Lab 3 | Check `SN_MCP_URL` and `SN_MCP_AUTH`, finish the OAuth login, then run the doctor again. | +| OAuth issuer mismatch / unsupported client authentication | Use `ork` v0.6.0 or newer and leave `SN_MCP_OAUTH_ISSUER` empty for StreamNative discovery. An explicit issuer must match an advertised authorization server. `--oauth-allow-issuer-mismatch` is only for trusted servers whose metadata issuer crosses registrable domains; StreamNative does not need it. | +| `Cannot reach the Agent Engine` | `ORCA_BASE_URL` must be the host root from your card, with no `/v1`. | +| The agent answers from memory instead of querying | Ask again, "check the view first". The system prompt tells it to always query. | +| `./lab-ork` says `No session_id yet` | The lab step that creates it has not run on this stack. Run the lab's script first. | +| A script seems stuck at an approval | The session is waiting for you. Answer the `Allow it? [y/N]` prompt, or press Ctrl-C and run the lab script again: it starts a fresh session. | + +## The MCP login in Lab 3 + +For the StreamNative SQL Workspace MCP server, keep `SN_MCP_AUTH=oauth`, leave +`SN_MCP_OAUTH_ISSUER` empty for automatic discovery, and use the scope from +`.env.cloud.example`. + +- `ork`'s discovery accepts HTTPS issuer aliases within the same registrable + domain and port. Only set `SN_MCP_OAUTH_ISSUER` when you have to choose between + several advertised `authorization_servers`; copy that advertised value exactly, + not the final issuer in the authorization-server metadata. +- `SN_API_KEY` authenticates the hosted Agent Engine, Kafka, and Schema + Registry. It is not the OAuth MCP access token. +- All three paths use `ork` for the first MCP login, then reuse the live + credential for the same URL and auth type from the vault remembered in + `.orca-state/<participant>.json`. Tokens stay in the server-side vault, where + they can be refreshed; they are never written to `.env` or local state. +- Set `SN_MCP_AUTH=static_bearer` only when your MCP server accepts `SN_API_KEY`. +- Changing the auth mode archives the previous live credential for that same URL + before creating its replacement: the Registry permits one active credential + per URL in a vault. If authorization fails, run Lab 3 or Lab 4 again to finish + setup; credentials for other URLs are preserved. + +## Start over + +- Agent Engine: run the cleanup script of your path (`./cleanup.sh`, + `python cleanup.py`, or `npm run cleanup`). +- SQL: run [`sql/cloud/99_reset.sql`](../../sql/cloud/99_reset.sql) in your + team's database. +- Kafka: injected `acct_9…` events stay in the topic. They are harmless. diff --git a/labs/local/00-set-up.md b/labs/local/00-set-up.md new file mode 100644 index 0000000..de3fce5 --- /dev/null +++ b/labs/local/00-set-up.md @@ -0,0 +1,322 @@ +# Lab 0: Set up + +**Local course** · 15 minutes, plus the image downloads and 5 on your own · CLI, Python, or TypeScript + +You start the streaming stack and the Agent Engine on your laptop, load the login +stream into a diskless topic, and run the doctor. When this lab is done, every +part of the stack answers and the topic holds 246 logins. + +## Before you start + +- **Docker** is running, with Compose v2 (`docker compose version`). +- You have [`ork`](https://github.com/orca-ae/orca-cli) v0.6.0 or newer + (`brew install orca-ae/tap/ork`), and [`jq`](https://jqlang.org/download/). +- You have an **Anthropic API key**. +- You cloned this repository, and installed **one** path: + + | Python (3.11 or newer) | TypeScript (Node.js 20 or newer) | + |---|---| + | `cd python && python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt` | `cd typescript && npm install` | + + The CLI path needs only `ork` and `jq` for the labs, plus one of the two + above for the doctor, the seeder, and the injector. +- Keep two terminals open: one in your path's folder (`cli/`, `python/` or + `typescript/`), one at the repository root. The commands in this lab run at + the repository root unless they say otherwise. + +## Step 1: Start the streaming stack + +```bash +docker compose -f local/compose.yaml up -d --wait +``` + +The first run downloads about 4.4 GB of images. It starts six services +([`local/compose.yaml`](../../local/compose.yaml)): + +- `kafka`: [Ursa for Kafka](https://openlakestream.org/docs/ursa-for-kafka), one + broker. +- `oxia` and `object-store`: where a diskless topic keeps its metadata and its + records. +- `schema-registry`: the Avro schema of the login topic. +- `risingwave`: streaming SQL. +- `risingwave-mcp`: RisingWave's MCP server, the agent's SQL tools. + +### Check + +The six services are running. Before the step this prints nothing. + +```bash +docker compose -f local/compose.yaml ps --status running --services +``` + +```text +kafka +object-store +oxia +risingwave +risingwave-mcp +schema-registry +``` + +## Step 2: Start the Agent Engine + +The engine reads your provider key when it starts, so export it in this +terminal first: + +```bash +export ANTHROPIC_API_KEY=<your key> +local/engine.sh +``` + +[`local/engine.sh`](../../local/engine.sh) does two things, and says so in its +header: + +1. It runs `ork local start --with-gateway`, with `.lab/ork` in this checkout + as the data directory: the Agent Engine (a registry and a harness) plus the + AI Gateway. The gateway makes every MCP call on your agent's behalf, so MCP + tools need it. +2. It links the gateway to your MCP server. The gateway refuses private hosts + unless they are on its allowlist, and `ork local start` writes that allowlist + empty each time. The script adds one host, `risingwave-mcp`, restarts the + gateway, and attaches the MCP server's container to the engine's network + under that name. + +Run `local/engine.sh` again whenever the engine has been restarted. It is safe +to run at any time. + +### Check + +The engine is up, has a provider key, and can reach the MCP server. The script +ends with these lines, and `--check` prints them again without starting +anything. + +```bash +local/engine.sh --check +``` + +```text +PASS the AI Gateway is running +PASS the gateway has a provider key +PASS the gateway allows the MCP host risingwave-mcp +PASS the MCP server answers at http://risingwave-mcp:8000/mcp on the engine's network + +The Agent Engine is up and can reach your MCP server. +``` + +## Step 3: Write your `.env` + +Every script in this repository reads `.env` in the repository root. +[`local/write-env.sh`](../../local/write-env.sh) writes it for the local stack, +with the workspace key your Agent Engine just generated. + +```bash +local/write-env.sh +``` + +```text +Wrote .env for the local stack (Agent Engine at http://127.0.0.1:8080). +``` + +`.env` is git-ignored. [`.env.local.example`](../../.env.local.example) shows +what is in it. The line `TUTORIAL_STACK=local` is what tells every script to use +the stack on your laptop. + +### Check + +One authenticated read of your Agent Engine. It prints `true` when the endpoint +and the key in `.env` are accepted. + +```bash +./lab-ork agent list -o json | jq -e 'has("data")' +``` + +[`./lab-ork`](../../lab-ork) is `ork` with the endpoint and key from `.env`. You +use it for the checks in every lab. + +## Step 4: Create the diskless topic and load it + +Create the login topic. `ursa.storage.enable=true` is what makes it diskless: +its records go to the object store through Ursa, not to the broker's disk. + +```bash +docker compose -f local/compose.yaml exec kafka /opt/kafka/bin/kafka-topics.sh \ + --bootstrap-server kafka:19092 --create --topic security.login_events \ + --partitions 1 --replication-factor 1 --config ursa.storage.enable=true +``` + +```text +Created topic security.login_events. +``` + +Kafka also prints a warning about periods in topic names. It is harmless here. + +Now load the login stream. In your path's folder: + +| Python | TypeScript | CLI | +|---|---|---| +| `python seed.py` | `npm run seed` | `(cd ../python && .venv/bin/python seed.py)` or `(cd ../typescript && npm run seed)` | + +```text +Loaded 246 logins for 91 accounts into security.login_events. +``` + +The seeder replays [`data/login_events.jsonl`](../../data/login_events.jsonl): +synthetic logins at a fictional bank, with their timestamps moved to now. It +also registers the topic's Avro schema, which RisingWave needs in Lab 2. + +Look at where the records went. The topic's data is objects in the object +store: + +```bash +docker compose -f local/compose.yaml exec object-store curl -s \ + --aws-sigv4 aws:amz:us-east-1:s3 --user ursa-local:ursa-local-secret \ + "http://localhost:9000/kafka-ursa?list-type=2&prefix=ursa/wal/" | grep -o '<Key>[^<]*</Key>' +``` + +It prints one line per object. The names carry the time of the write, so yours +differ: + +```text +<Key>ursa/wal/2026/10/07/17/30/00/2be58851-f143-498c-bfaa-752c4e49720d</Key> +<Key>ursa/wal/2026/10/07/17/30/00/7a611976-386a-46d6-a50a-802db13593d0</Key> +``` + +### Check + +Run the doctor in your path's folder. It checks every part of the local stack, +and prints the fix for anything that fails. + +| Python | TypeScript | CLI | +|---|---|---| +| `python doctor.py` | `npm run doctor` | `(cd ../python && .venv/bin/python doctor.py)` or `(cd ../typescript && npm run doctor)` | + +The Python doctor prints this. The TypeScript one starts with Node and its own +packages instead. + +```text +PASS Python 3.11+ 3.13 +PASS package runorca +PASS package confluent-kafka[avro] +PASS package python-dotenv +PASS ork found +PASS jq found +PASS .env local stack, participant: ana +PASS docker found +PASS ORCA_BASE_URL http://127.0.0.1:8080 +PASS Agent Engine API key accepted +PASS Kafka security.login_events has 1 partition(s) +PASS Schema Registry security.login_events-value v1 +PASS login topic schema has account_id, event_time, ip_address, result, failure_reason +PASS MCP tools run_select_query, describe_table, insert_multiple_rows +PASS RisingWave through MCP SELECT 1 returned a row + +All good: you're ready. +``` + +Before this step, the `Kafka` and `Schema Registry` lines fail: the topic and +its schema are not there yet. + +## Check your understanding + +**1. Where are the records of `security.login_events` stored?** + +- A. On the Kafka broker's disk, like any topic +- B. In the object store, written through Ursa; the broker keeps only metadata +- C. In RisingWave + +<details> +<summary>Answer</summary> + +**B.** The topic was created with `ursa.storage.enable=true`, which makes it +diskless. You listed the object yourself in step 4. A topic created without that +setting is an ordinary Kafka topic on the same broker. + +</details> + +**2. Why does `local/engine.sh` start the engine with `--with-gateway`?** + +- A. The gateway makes every MCP call on the agent's behalf, so without it the agent has no tools. +- B. The gateway is where agents are stored. +- C. It makes the model faster. + +<details> +<summary>Answer</summary> + +**A.** The harness never calls an MCP server itself. It asks the AI Gateway, +which decides whether the destination is allowed and attaches credentials. That +is also why the gateway has to be told about `risingwave-mcp`. + +</details> + +**3. Which line of `.env` tells the scripts to use the stack on your laptop?** + +- A. `ORCA_BASE_URL=http://127.0.0.1:8080` +- B. `TUTORIAL_STACK=local` +- C. `PARTICIPANT=` + +<details> +<summary>Answer</summary> + +**B.** With it, the scripts talk to Kafka without credentials, read the agent +definitions in `agent/local/`, and use no vault. + +</details> + +## Try it yourself + +Stop everything, bring it back, and confirm that nothing was lost. Your topic, +its 246 events, and your engine's key should all survive a stop. + +### Check + +After the restart, the doctor passes again, and the seeder refuses to load a +second copy because the topic still holds the first one. The seeder says the +same before you stop anything: the point is that it still says so afterwards. + +```bash +(cd python && .venv/bin/python seed.py) +``` + +```text +security.login_events already holds 246 events, so it is seeded. To load another copy anyway: python seed.py --force +``` + +On the TypeScript path, use `npm --prefix typescript run seed`. + +<details> +<summary>Solution</summary> + +```bash +local/down.sh +docker compose -f local/compose.yaml up -d --wait +local/engine.sh +``` + +`local/down.sh` stops both stacks and keeps their volumes. Starting is the same +two commands as in steps 1 and 2. You do not run `local/write-env.sh` again: the +engine kept its key. `ANTHROPIC_API_KEY` must still be exported in the terminal +where you run `local/engine.sh`. + +</details> + +## Clean up + +Nothing to clean up yet: the next labs use what you started. For later, there +are two ways to stop: + +- `local/down.sh` stops both stacks. Your topic, view, and agents stay. +- `local/down.sh --reset` also deletes all of it, to start over from this lab. + It removes the stacks' volumes, the engine's keys in `.lab/ork`, your local + `.env`, and the ids in `.orca-state/`. The engine's volumes and its keys have + to go together: with only one of them deleted, the engine cannot start again. + +## Recap + +- Two stacks run on your laptop: the streaming stack (one Compose file) and the + Agent Engine (`ork local`, through `local/engine.sh`). +- The login topic is diskless: its records are objects, written through Ursa. +- `.env` says `TUTORIAL_STACK=local`, and the doctor checks every part. + +## What's next + +[Lab 1: Hello, agent](01-hello-agent.md) diff --git a/labs/local/01-hello-agent.md b/labs/local/01-hello-agent.md new file mode 100644 index 0000000..263d3e8 --- /dev/null +++ b/labs/local/01-hello-agent.md @@ -0,0 +1,292 @@ +# Lab 1: Hello, agent + +**Local course** · 5 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You create an agent with no tools, start a conversation, and read back what the +Agent Engine recorded. When this lab is done, you have an agent at version 1 and +you know the four things every later lab reuses: environment, agent, session, +events. + +## Before you start + +- You finished [Lab 0](00-set-up.md): the doctor says you are ready. +- One terminal is in your path's folder (`cli/`, `python/` with the virtual + environment active, or `typescript/`). A second one is at the repository root. + +## Step 1: Create your agent and say hello + +In your path's folder: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l1_hello.sh` | `python l1_hello.py` | `npm run l1` | + +You see something like this. The agent's wording varies, and it writes Markdown, +which your terminal shows as it is. + +```text +hello-agent-ana v1: no tools: just a conversation +[you] Hi! What is the Data + Agent Hackathon, and what can you see right now? +[agent] # Welcome to the Data + Agent Hackathon! 🎉 + +The **Data + Agent Hackathon** is a hands-on competition at the **Data Streaming Summit 2026** where participants build intelligent agents that combine real-time data streaming with AI ... + +As for what I can see right now — nothing live just yet! The next step connects me to a **live Kafka stream through streaming SQL**, which is where the real-time magic happens. Stay tuned! 🚀 +``` + +This is the first time your stack calls the model. If you see +`[error] ... (retrying)` lines and no reply, the model provider rejected the key +the engine started with: press Ctrl-C and see +[Troubleshooting](troubleshooting.md#the-model-does-not-answer). + +### Check + +Read your agent back. It prints the name, the version, and that it has no tools. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '{name, version, tools: (.tools | length)} | select(.tools == 0)' +``` + +```json +{ + "name": "hello-agent-ana", + "version": 1, + "tools": 0 +} +``` + +Before the step, the same command says `No agent_id yet`: nothing has been +created. + +**What just happened: four API calls.** + +1. **Environment**: where your agent's sessions run. +2. **Agent**: a model plus a system prompt, defined in + [`agent/local/l1-hello.json`](../../agent/local/l1-hello.json). All three + paths read that file. +3. **Session**: one conversation, pinned to a specific agent version. +4. **Events**: you send a `user.message`; the agent streams back `agent.message` + events until the session goes idle. + +<details> +<summary>The code (Python)</summary> + +```python +environment_id = ensure_environment(client, state, f"hello-env-{config.participant}") + +layer = load_layer("l1-hello", config.stack) +agent = ensure_agent(client, state, agent_params(layer, config)) + +session = open_session(client, state, environment_id, agent, "L1: hello") +run_turn(client, session.id, question, send_first=config.stack == "local") +``` + +`open_session` ([`python/common.py`](../../python/common.py)) is one call, +`client.sessions.create(environment_id=..., agent={"type": "agent", "id": agent.id, "version": agent.version}, title=...)`, +and it remembers the session id for your checks. `run_turn` prints events until +the agent's turn ends. With `send_first`, it sends the message and then follows +the session's event stream from its start, so nothing the agent says in between +is missed. The engine on your laptop needs that order: it answers a stream +opened on a quiet session only at its next keep-alive, 15 seconds later. +</details> + +<details> +<summary>The code (TypeScript)</summary> + +```ts +const environmentId = await ensureEnvironment(client, state, `hello-env-${config.participant}`); + +const layer = loadLayer('l1-hello', config.stack); +const agent = await ensureAgent(client, state, agentParams(layer, config)); + +const session = await openSession(client, state, environmentId, agent, 'L1: hello'); +await runTurn(client, session.id, question, { sendFirst: config.stack === 'local' }); +``` + +`openSession` and `runTurn` ([`typescript/src/common.ts`](../../typescript/src/common.ts)) +work the same way as the Python versions. +</details> + +<details> +<summary>The commands (CLI)</summary> + +```bash +ork agent environments create --name hello-env-ana -o json + +ork agent create --name hello-agent-ana --model "$ORCA_MODEL" \ + --system "$(jq -r .system ../agent/local/l1-hello.json)" -o json + +ork agent sessions create --agent <agent id> --agent-version 1 \ + --environment-id <environment id> --title "L1: hello" -o json + +ork agent sessions events send message --session <session id> --text "Hi! ..." +ork agent sessions events stream --session <session id> --timeout 15s +``` + +[`cli/lib.sh`](../../cli/lib.sh) wraps these commands, remembers the ids, and +prints the stream the same way as the other paths. +</details> + +## Step 2: Read the conversation back + +A session is a list of events. From the repository root, list the types of the +events in the conversation you just had, oldest first: + +```bash +./lab-ork agent sessions events list --session @session_id --order asc -o json | jq -r '.data[].type' +``` + +```text +user.message +session.status_running +span.model_request_start +agent.thinking +agent.message +span.model_request_end +session.status_idle +``` + +Your message, the agent's reply, and the event that ends the turn. The `span.` +events time the model request, and `agent.thinking` is the model working out its +answer before it gives it. + +### Check + +"The agent replied" is three facts together: the turn ended normally, and a +reply came after your message. This prints the reply when all three hold. + +```bash +./lab-ork agent sessions events list --session @session_id --order asc --limit 200 -o json | jq -e ' + .data + | select((map(select(.type == "session.status_idle")) | last | .stop_reason.type) == "end_turn") + | .[(map(.type) | rindex("user.message")):] + | map(select(.type == "agent.message")) | last + | select(. != null) + | {reply: .content[0].text}' +``` + +Reading it line by line: take the events; keep going only if the last idle event +stopped for `end_turn`; look at what came after your last message; take the last +`agent.message` there; print its text. A turn that ended for another reason, or +ended without a reply, prints nothing. + +## Step 3: Run it again + +Ask your own question this time: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l1_hello.sh "What will you be able to do in Lab 3?"` | `python l1_hello.py "What will you be able to do in Lab 3?"` | `npm run l1 -- "What will you be able to do in Lab 3?"` | + +The first line still says `v1`. Re-running is safe: the scripts remember your +agent in `.orca-state/`, and only create a new version when its definition +changes. The conversation is new; the agent is not. + +### Check + +Your agent now has two sessions, and both are pinned to version 1. It prints +them once there are two. + +```bash +./lab-ork agent sessions list --agent @agent_id -o json | jq -e '[.data[] | {title, version: .agent.version}] | select(length >= 2)' +``` + +```json +[ + { + "title": "L1: hello", + "version": 1 + }, + { + "title": "L1: hello", + "version": 1 + } +] +``` + +## Check your understanding + +**1. What ties a session to one definition of the agent?** + +- A. The agent's name +- B. The agent version the session was created with +- C. The environment + +<details> +<summary>Answer</summary> + +**B.** A session records the agent id *and* version. Changing the agent later +creates a new version and does not touch a conversation that is already running. + +</details> + +**2. You run the script twice without changing anything. How many agents and versions exist?** + +- A. Two agents +- B. One agent, at version 2 +- C. One agent, at version 1 + +<details> +<summary>Answer</summary> + +**C.** The script stores a fingerprint of the definition in the agent's +metadata. Same fingerprint, no update. Each run does start a new session. + +</details> + +**3. Which event tells you the agent's turn is over?** + +- A. `agent.message` +- B. `session.status_idle` +- C. `user.message` + +<details> +<summary>Answer</summary> + +**B.** It carries a `stop_reason`. `end_turn` means the turn finished normally; +in Lab 4 you will see `requires_action`, which means the session is waiting for +you. + +</details> + +## Try it yourself + +Change how your agent talks, and watch its version change. Make it answer in a +single sentence, run the lab script again, then confirm the agent is at a newer +version. + +### Check + +It prints the version once it is 2 or more. + +```bash +./lab-ork agent get @agent_id -o json | jq -e 'select(.version >= 2) | {name, version}' +``` + +<details> +<summary>Solution</summary> + +The agent is the JSON file. In +[`agent/local/l1-hello.json`](../../agent/local/l1-hello.json), change +"Answer in two or three sentences." to "Answer in one sentence.", then run the +Lab 1 script again. The first line now says `v2`. + +Put the file back before the next lab (`git checkout agent/local/l1-hello.json`). +Your version numbers will run one or two ahead of the ones the labs show. That is +fine: the checks never depend on an exact number after this lab. + +On the CLI path, do this before Lab 3. Once your agent has tools, `ork` cannot +take them away, so going back to Lab 1 starts a fresh agent at version 1, and the +script says so. + +</details> + +## Recap + +- An agent is configuration: a model, a system prompt, and (later) tools. +- Every change to it is a new version. A session is pinned to one version. +- A conversation is a list of events, and `session.status_idle` ends a turn. + +## What's next + +[Lab 2: Hello, streaming SQL](02-streaming-sql.md) diff --git a/labs/local/02-streaming-sql.md b/labs/local/02-streaming-sql.md new file mode 100644 index 0000000..eee1143 --- /dev/null +++ b/labs/local/02-streaming-sql.md @@ -0,0 +1,219 @@ +# Lab 2: Hello, streaming SQL + +**Local course** · 8 minutes, plus 5 on your own · `psql`, through `local/sql.sh` + +You connect RisingWave to the login topic and turn it into a materialized view +that keeps a running summary per account. When this lab is done, there is a view +your agent can read in Lab 3, and a table it can write to in Lab 4. + +## Before you start + +- You finished [Lab 1](01-hello-agent.md), and the streaming stack from + [Lab 0](00-set-up.md) is running. +- Work in the terminal at the repository root. + [`local/sql.sh`](../../local/sql.sh) is `psql` against the RisingWave on your + laptop, with nothing to install: + + ```bash + local/sql.sh # a prompt (\q to leave) + local/sql.sh -c "SELECT 1" # one statement + local/sql.sh < sql/local/01_explore.sql # a file + ``` + +## Step 1: Connect RisingWave to the topic + +RisingWave does not know about the topic yet. A **source** tells it where the +topic is and how to decode it. + +```bash +local/sql.sh < sql/local/00_source.sql +``` + +```sql +CREATE SOURCE IF NOT EXISTS "security.login_events" (*) +WITH ( + connector = 'kafka', + topic = 'security.login_events', + properties.bootstrap.server = 'kafka:19092', + scan.startup.mode = 'earliest' +) FORMAT PLAIN ENCODE AVRO ( + schema.registry = 'http://schema-registry:8081' +); +``` + +The columns come from the Avro schema the seeder registered: `(*)` takes all of +them. RisingWave runs in a container, so it reaches the broker and the registry +by their names on the stack's network (`kafka`, `schema-registry`), not by +`127.0.0.1`. The source is named after the topic; the name contains a dot, so it +is always double-quoted. + +Peek at the stream. Each row is one login attempt: + +```bash +local/sql.sh < sql/local/01_explore.sql +``` + +### Check + +The source reads the whole topic: this prints `246`, the number of logins the +seeder loaded. + +```bash +local/sql.sh -tA -c 'SELECT count(*) FROM "security.login_events"' +``` + +Before the step, the same command fails with +`table or source not found: security.login_events`. + +## Step 2: Turn the stream into context + +```bash +local/sql.sh < sql/local/02_login_failures.sql +``` + +```sql +CREATE MATERIALIZED VIEW login_failures AS +SELECT + account_id, + COUNT(*) FILTER (WHERE result = 'FAILURE') AS failed_logins, + COUNT(*) FILTER (WHERE result = 'SUCCESS') AS successful_logins, + COUNT(DISTINCT ip_address) AS distinct_ips, + MAX(event_time) AS last_seen +FROM "security.login_events" +GROUP BY account_id; +``` + +A materialized view is maintained incrementally: every new login updates the +counts within seconds. There is no batch job to schedule and nothing to refresh. +That makes it good agent context: always current, and cheap to read. + +RisingWave prints a `NOTICE` about snapshot backfill first. It is harmless: the +view still reads the topic from its first event. + +The file ends with a query, so you also see the ten accounts with the most +failed logins. `acct_0042` is at the top: five failed logins, then a success. +That is the attacker. + +### Check + +The view exists and has found the account under attack. This prints one line. + +```bash +local/sql.sh -tA -c "SELECT account_id, failed_logins, successful_logins, distinct_ips FROM login_failures WHERE account_id = 'acct_0042' AND failed_logins >= 5 AND successful_logins >= 1" +``` + +```text +acct_0042|5|2|2 +``` + +Before the step, the same command fails with +`table or source not found: login_failures`. + +## Step 3: Make room for the agent's decisions + +The agent writes here in Lab 4. + +```bash +local/sql.sh < sql/local/03_flagged_accounts.sql +``` + +```sql +CREATE TABLE flagged_accounts ( + account_id VARCHAR PRIMARY KEY, + reason VARCHAR, + flagged_at TIMESTAMPTZ DEFAULT now() +); +``` + +### Check + +The table exists and is empty: this prints `0`. + +```bash +local/sql.sh -tA -c "SELECT count(*) FROM flagged_accounts" +``` + +## Check your understanding + +**1. A new login event arrives in Kafka. What has to happen for `login_failures` to include it?** + +- A. Someone reruns the `CREATE MATERIALIZED VIEW` statement. +- B. A scheduled job refreshes the view. +- C. Nothing: the view is updated as the event arrives. + +<details> +<summary>Answer</summary> + +**C.** A materialized view is a standing query. It is maintained incrementally +as events arrive, so reading it is a cheap lookup and the result is current. + +</details> + +**2. The source's address for Kafka is `kafka:19092`, but your `.env` says `127.0.0.1:29092`. Why two addresses for one broker?** + +- A. They are two different brokers. +- B. RisingWave runs in a container and reaches the broker by its name on the stack's network; your terminal reaches the same broker through a port published on your laptop. +- C. One is for reading and one is for writing. + +<details> +<summary>Answer</summary> + +**B.** Inside a container, `127.0.0.1` is the container itself. Anything one +container says to another uses the service name. + +</details> + +**3. Why does an agent read a view like this instead of the raw topic?** + +- A. Agents cannot read Kafka. +- B. The view answers "what is true now" in one small query, instead of the agent re-reading and counting every event. +- C. The view hides the data from the agent. + +<details> +<summary>Answer</summary> + +**B.** The database does the counting once, continuously. The agent's context +stays small and current, with no pipeline to babysit. + +</details> + +## Try it yourself + +The agent's rule of thumb in Lab 3 is "five or more failed logins plus at least +one success". Write one query over `login_failures` that returns only the +accounts that match it, worst first, and run it with `local/sql.sh -c`. + +### Check + +Your query returns exactly one account. Its `last_seen` is close to the time you +seeded the topic, so yours differs from this one. + +```text + account_id | failed_logins | successful_logins | distinct_ips | last_seen +------------+---------------+-------------------+--------------+------------------------------- + acct_0042 | 5 | 2 | 2 | 2026-10-07 17:30:00.000+00:00 +(1 row) +``` + +<details> +<summary>Solution</summary> + +```bash +local/sql.sh -c "SELECT * FROM login_failures WHERE failed_logins >= 5 AND successful_logins >= 1 ORDER BY failed_logins DESC" +``` + +This is close to what the agent runs in Lab 3. It writes the SQL itself. + +</details> + +## Recap + +- A source connects RisingWave to a Kafka topic and decodes it with the schema + in the registry. +- `login_failures` is a materialized view: a running summary per account, kept + current as events arrive. +- `flagged_accounts` is an ordinary table, waiting for the agent's decisions. + +## What's next + +[Lab 3: Agent + live context](03-live-context.md) diff --git a/labs/local/03-live-context.md b/labs/local/03-live-context.md new file mode 100644 index 0000000..2896b29 --- /dev/null +++ b/labs/local/03-live-context.md @@ -0,0 +1,291 @@ +# Lab 3: Agent + live context + +**Local course** · 9 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You give your agent one read-only SQL tool, ask it who is under attack, then +change the data and ask again. When this lab is done, the agent answers from the +materialized view, and its answer changes when the stream does. + +## Before you start + +- You finished [Lab 2](02-streaming-sql.md): `login_failures` exists. +- `local/engine.sh --check` passes. If the engine was restarted since Lab 0, + run `local/engine.sh` first. +- One terminal is in your path's folder, a second one is at the repository root. + +## Step 1: Give the agent a SQL tool, and ask + +In your path's folder: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l3_live_context.sh` | `python l3_live_context.py` | `npm run l3` | + +Your agent gets a new version, and answers *"Which accounts look like an account +takeover right now?"* by querying `login_failures` itself. The `[tool]` line +shows the SQL it runs, and `[result]` the start of what came back. From one run, +shortened: + +```text +hello-agent-ana v2: + RisingWave MCP (one read-only SQL tool) +Tip: after the first answer, run `python inject.py` in another terminal and ask again. + +[you] Which accounts look like an account takeover right now? +[tool] run_select_query {"query": "SELECT account_id, failed_logins, successful_logins, distinct_ips, last_seen FROM login_failures WHERE failed_logins >= 5 … +[result] {"result":"[\n {\n \"account_id\": \"acct_0042\",\n \"failed_logins\": 5,\n \"successful_logins\": 2,\n \"distinct_ips\": 2,\n \"last_… +[agent] ⚠️ **Likely Account Takeover Detected** + +| Account | Failed | Successful | Distinct IPs | Last Seen (UTC) | +|---|---|---|---|---| +| **acct_0042** | 5 | 2 | 2 | 2026-10-03 00:55:46 | + +**acct_0042** matches the takeover pattern: **5 failed logins** followed by **2 successful logins** across **2 distinct IPs** ... + +Ask again (Enter to quit): +``` + +**Leave this conversation open.** You come back to it in step 3. + +### Check + +From the repository root. The agent's definition now has an MCP toolset, and this +prints the tools that are enabled in it: exactly one. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '.tools[] | select(.type == "mcp_toolset") | [.configs[] | select(.enabled) | .name]' +``` + +```json +[ + "run_select_query" +] +``` + +Before the step it prints nothing: the Lab 1 agent has no tools. + +**What changed** ([`agent/local/l3-live-context.json`](../../agent/local/l3-live-context.json)): + +- `mcp_servers`: RisingWave's MCP server, at the address in `RW_MCP_URL`. That + is `http://risingwave-mcp:8000/mcp`: the name the AI Gateway reaches it by. +- `tools`: an allow-list. The server offers more than a hundred tools, among + them ones that drop tables. `default_config.enabled` is `false`, and one tool, + `run_select_query`, is enabled with `always_allow`. That one tool is all the + model is offered. +- No vault. This MCP server takes no credential, so there is nothing to store. + On StreamNative Cloud the server wants an OAuth login, and the token lives in a + vault on the Agent Engine, never in a prompt. + +<details> +<summary>The code (Python)</summary> + +```python +layer = load_layer("l3-live-context", config.stack) +agent = ensure_agent(client, state, agent_params(layer, config)) + +vault_ids = mcp_vault_ids(client, state, config) # [] on the local stack + +session = open_session(client, state, environment_id, agent, "L3: live context", vault_ids=vault_ids) +chat(client, session.id, QUESTION, send_first=config.stack == "local") +``` +</details> + +<details> +<summary>The code (TypeScript)</summary> + +```ts +const layer = loadLayer('l3-live-context', config.stack); +const agent = await ensureAgent(client, state, agentParams(layer, config)); + +const vaultIds = await mcpVaultIds(client, state, config); // [] on the local stack + +const session = await openSession(client, state, environmentId, agent, 'L3: live context', { vaultIds }); +await chat(client, session.id, QUESTION, { sendFirst: config.stack === 'local' }); +``` +</details> + +<details> +<summary>The commands (CLI)</summary> + +```bash +ork agent update <agent id> --version 1 --model "$ORCA_MODEL" \ + --system "$(jq -r .system ../agent/local/l3-live-context.json)" \ + --mcp-server "name=risingwave,type=url,url=$RW_MCP_URL" \ + --tool-json "$(jq -c '.tools[0]' ../agent/local/l3-live-context.json)" -o json + +ork agent sessions create --agent <agent id> --agent-version 2 \ + --environment-id <environment id> --title "L3: live context" -o json +``` +</details> + +## Step 2: Inject a fresh attack + +In your **second terminal**, at the repository root, write a new brute-force +burst into the login topic: + +| Python (and CLI path) | TypeScript (and CLI path) | +|---|---| +| `(cd python && .venv/bin/python inject.py)` | `npm --prefix typescript run inject` | + +```text +Injected 6 failed logins + 1 success for acct_9640 from 203.0.113.167 into security.login_events. +Ask your agent again, or run this SQL: SELECT * FROM login_failures WHERE account_id = 'acct_9640'; +``` + +It attacks a new account, `acct_9…`, picked at random: yours has another number +and address. The seeder loaded no account in that range. + +### Check + +The view already has the new account. This prints a line; it printed nothing +before the step. The view takes a second or two to catch up, so if you were +quick and it prints nothing, run it again. + +```bash +local/sql.sh -tA -c "SELECT account_id, failed_logins, successful_logins FROM login_failures WHERE account_id LIKE 'acct_9%'" +``` + +```text +acct_9640|6|1 +``` + +Nobody refreshed anything: the events landed in Kafka, and the view updated +itself. + +## Step 3: Ask again + +Back in the first terminal, where the conversation is still open, ask the same +question again: + +```text +Ask again (Enter to quit): Which accounts look like an account takeover right now? +[you] Which accounts look like an account takeover right now? +[tool] run_select_query {"query": "SELECT account_id, failed_logins, successful_logins, distinct_ips, last_seen FROM login_failures WHERE failed_logins >= 5 … +[result] {"result":"[\n {\n \"account_id\": \"acct_9640\",\n \"failed_logins\": 6,\n \"successful_logins\": 1,\n \"distinct_ips\": 1,\n \"last_… +[agent] ⚠️ **2 Likely Account Takeovers Detected** (updated live) + +| Account | Failed | Successful | Distinct IPs | Last Seen (UTC) | +|---|---|---|---|---| +| **acct_9640** | 6 | 1 | 1 | 2026-10-03 00:57:35 | +| **acct_0042** | 5 | 2 | 2 | 2026-10-03 00:55:46 | + +**New since last check:** **acct_9640** just appeared ... +``` + +The new account shows up. The agent did not change. Its context did, because it +fetches its context when it needs it instead of being handed a snapshot. + +Press Enter to end the conversation. + +### Check + +The agent queried the view again for your second question. This prints how many +query calls it made after that question, once there is one. + +```bash +./lab-ork agent sessions events list --session @session_id --order asc --limit 200 -o json | jq -e ' + .data + | select(map(select(.type == "user.message")) | length >= 2) + | .[(map(.type) | rindex("user.message")):] + | map(select(.type == "agent.mcp_tool_use" and .name == "run_select_query")) + | select(length >= 1) + | {queries_after_your_last_question: length}' +``` + +```json +{ + "queries_after_your_last_question": 1 +} +``` + +Reading it line by line: take the events, oldest first; keep going only if you +asked at least twice; look at what came after your last question; keep the query +calls; print how many there are, when there is at least one. After step 1 it +prints nothing, however many queries the first answer took. + +## Check your understanding + +**1. RisingWave's MCP server offers a tool that drops tables. Can your agent call it?** + +- A. Yes, if the model decides to. +- B. No: `default_config.enabled` is `false`, so only the tools named in `configs` exist for the agent. +- C. Only with human approval. + +<details> +<summary>Answer</summary> + +**B.** The toolset is an allow-list. A tool that is not enabled is not offered +to the model at all, and a call to it is refused. + +</details> + +**2. The agent's second answer included a new account. What changed between the two answers?** + +- A. The agent was updated to a new version. +- B. The materialized view changed, and the agent queried it again. +- C. The agent remembered the injection from the first answer. + +<details> +<summary>Answer</summary> + +**B.** Same agent, same session. Its system prompt tells it to query before +every answer, so it read the view again and the view had moved. + +</details> + +**3. The agent's definition says the MCP server is at `http://risingwave-mcp:8000/mcp`. Who connects to that address?** + +- A. Your terminal +- B. The model provider +- C. The AI Gateway, on the agent's behalf + +<details> +<summary>Answer</summary> + +**C.** The harness asks the gateway, and the gateway makes the call. That is +why the name has to resolve on the engine's network, and why +`local/engine.sh` had to put the host on the gateway's allowlist. + +</details> + +## Try it yourself + +Without changing any file, get the agent to run SQL you did not see in this lab. +Start the Lab 3 script again and ask a question whose answer is in +`login_failures` but needs a different query, for example which account was seen +from the most IP addresses. + +### Check + +It prints the SQL of every query in the newest session, oldest first. The last +one is the agent's answer to your question. + +```bash +./lab-ork agent sessions events list --session @session_id --event-type agent.mcp_tool_use --order asc --limit 200 -o json | jq -e '.data[] | select(.name == "run_select_query") | .input.query' +``` + +<details> +<summary>Solution</summary> + +Run the script, let it answer the first question, then type at the prompt: + +```text +Ask again (Enter to quit): Which account has logged in from the most distinct IP addresses, and how many? +``` + +In one run, the agent wrote +`SELECT account_id, failed_logins, successful_logins, distinct_ips, last_seen FROM login_failures ORDER BY distinct_ips DESC LIMIT 1`. +You gave it a view and a tool, not a list of queries. + +</details> + +## Recap + +- The agent reads the view itself, through an allow-list of one tool out of more + than a hundred. +- Every MCP call goes through the AI Gateway. +- Fresh context came from the stream and the view. Nothing about the agent + changed between the two answers. + +## What's next + +[Lab 4: Agent acts, human approves](04-act-with-approval.md) diff --git a/labs/local/04-act-with-approval.md b/labs/local/04-act-with-approval.md new file mode 100644 index 0000000..85462c8 --- /dev/null +++ b/labs/local/04-act-with-approval.md @@ -0,0 +1,278 @@ +# Lab 4: Agent acts, human approves + +**Local course** · 5 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You give your agent one tool that writes, and a policy that makes it wait for +you. When this lab is done, the agent has flagged an account because you said +yes, and has not flagged another because you said no. + +## Before you start + +- You finished [Lab 3](03-live-context.md): the agent reads `login_failures`. +- `flagged_accounts` exists (Lab 2, step 3). +- One terminal is in your path's folder, a second one is at the repository root. + +## Step 1: Ask the agent to act, and approve + +In your path's folder: + +| CLI | Python | TypeScript | +|---|---|---| +| `./l4_act.sh` | `python l4_act.py` | `npm run l4` | + +The agent gets a new version with one write tool. It queries the view and +describes the flag table before it proposes an insert. Then the session pauses, +before anything is written. From one run, shortened: + +```text +hello-agent-ana v3: + insert into flagged_accounts, only with human approval +[you] Flag the account most likely to be under attack right now. +[agent] I'll query the live data and check the `flagged_accounts` table structure at the same time. +[tool] run_select_query {"query": "SELECT account_id, failed_logins, successful_logins, distinct_ips, last_seen FROM login_failures ORDER BY failed_logins DE… +[tool] describe_table {"table_name": "flagged_accounts"} +[result] {"result":"[\n {\n \"account_id\": \"acct_9640\",\n \"failed_logins\": 6,\n \"successful_logins\": 1,\n \"distinct_ips\": 1,\n \"last_… +[result] {"result":"{\"Name\":{\"0\":\"account_id\",\"1\":\"reason\",\"2\":\"flagged_at\",\"3\":\"_rw_timestamp\",\"4\":\"primary key\",\"5\":\"distribution ke… +[agent] Two accounts meet the takeover criteria (5+ fails + at least 1 success). **acct_9640** is the top threat ... Submitting the flag now for human approval. +[tool] insert_multiple_rows {"table_name": "flagged_accounts", "columns": "account_id, reason", "values_list": "('acct_9640', '6 failed logins followed by 1 … + +[approve?] The agent wants to run insert_multiple_rows with: +{ + "table_name": "flagged_accounts", + "columns": "account_id, reason", + "values_list": "('acct_9640', '6 failed logins followed by 1 successful login from 1 distinct IP, indicating likely account takeover as of 2026-10-03 00:57 UTC')" +} +Allow it? [y/N] +``` + +Read what it wants to write, then type `y`. The insert runs, and the agent +usually reads the row back to confirm it: + +```text +Allow it? [y/N] y +[result] {"result":"Multiple rows inserted successfully into public.flagged_accounts"} +[agent] Insert approved and written. Verifying now: +[tool] run_select_query {"query": "SELECT account_id, reason, flagged_at FROM flagged_accounts WHERE account_id = 'acct_9640'"} +... +``` + +The agent leaves `flagged_at` out. The table's default, `now()`, fills it in. + +**Leave the conversation open** for step 2. + +### Check + +From the repository root. The session holds your decision: this prints the +confirmation you sent. + +```bash +./lab-ork agent sessions events list --session @session_id --event-type user.tool_confirmation --order asc -o json | jq -e '.data[] | select(.result == "allow") | {tool_use_id, result}' +``` + +```json +{ + "tool_use_id": "evt_01a0ff45-0b03-739f-97cc-61c45b6cf3c3", + "result": "allow" +} +``` + +And the row is there: + +```bash +local/sql.sh -c "SELECT * FROM flagged_accounts" +``` + +```text + account_id | reason | flagged_at +------------+----------------------------------------------------------------------------------------------------------------------------------+------------------------------- + acct_9640 | 6 failed logins followed by 1 successful login from 1 distinct IP, indicating likely account takeover as of 2026-10-03 00:57 UTC | 2026-10-03 00:58:29.586+00:00 +(1 row) +``` + +## Step 2: Ask again, and deny + +At the prompt, ask the agent to flag a different account, and answer `n` this +time: + +```text +Ask again (Enter to quit): Now flag acct_0042 as well. +[you] Now flag acct_0042 as well. +[agent] Querying fresh numbers for acct_0042 first. +... +[approve?] The agent wants to run insert_multiple_rows with: +... +Allow it? [y/N] n +[error] The human reviewer denied this action. +[agent] The human reviewer **denied** this flag. `acct_0042` has not been added to `flagged_accounts` and I will not retry the insert. +``` + +The `[error]` line is the result the agent got back for its tool call: a human +denied it. The agent does not retry. Press Enter to end the conversation. + +### Check + +The session holds the denial, with the reason the agent was given. + +```bash +./lab-ork agent sessions events list --session @session_id --event-type user.tool_confirmation --order asc -o json | jq -e '.data[] | select(.result == "deny") | {tool_use_id, result, deny_message}' +``` + +```json +{ + "tool_use_id": "evt_01a0ff45-459b-776e-b0f9-9e2eb912650d", + "result": "deny", + "deny_message": "The human reviewer denied this action." +} +``` + +The table still has one row: this prints `1`. + +```bash +local/sql.sh -tA -c "SELECT count(*) FROM flagged_accounts" +``` + +## Step 3: Read the policy that made it wait + +Nothing in the prompt made the agent stop. Look at the agent's tools: + +```bash +./lab-ork agent get @agent_id -o json | jq -c '.tools[].configs[] | {name, policy: .permission_policy.type}' +``` + +```json +{"name":"run_select_query","policy":"always_allow"} +{"name":"describe_table","policy":"always_allow"} +{"name":"insert_multiple_rows","policy":"always_ask"} +``` + +**What changed** ([`agent/local/l4-act.json`](../../agent/local/l4-act.json)): +the read-only `describe_table` lets the agent see the table's columns, and +`insert_multiple_rows` uses `permission_policy: always_ask`. When the agent calls +it, the session emits `agent.mcp_tool_use` and goes idle with +`stop_reason: requires_action`. Your script answers with a +`user.tool_confirmation`: `allow`, or `deny` with a reason. On the CLI that is: + +```bash +ork agent sessions events send tool-confirmation --session <session id> \ + --tool-use-id <tool use event id> --decision allow +``` + +### Check + +Exactly one tool needs your approval. This prints its name. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '.tools[].configs[] | select(.permission_policy.type == "always_ask") | .name' +``` + +## Check your understanding + +**1. The agent calls a tool whose policy is `always_ask`. What happens to the session?** + +- A. The tool runs, and you are told afterwards. +- B. The session goes idle with `stop_reason: requires_action` until someone sends a `user.tool_confirmation`. +- C. The call fails and the agent tries another tool. + +<details> +<summary>Answer</summary> + +**B.** The pause is in the Agent Engine, not in the model's manners. Nothing is +written until a client answers. + +</details> + +**2. You answered `n`. What did the agent learn?** + +- A. Nothing: the tool call looked like a timeout. +- B. That a human denied the action, with the reason your script sent. +- C. That the table is read-only. + +<details> +<summary>Answer</summary> + +**B.** A denial comes back to the agent as the result of its tool call, with the +`deny_message`. Its instructions say to report that and not retry. + +</details> + +**3. Why is the approval a policy on the tool, and not a sentence in the system prompt?** + +- A. A prompt is a request the model can misread or be talked out of; a policy is enforced outside the model. +- B. Policies are shorter to write. +- C. The system prompt cannot mention tools. + +<details> +<summary>Answer</summary> + +**A.** Governance that depends on the model behaving is not governance. The +allow-list and the policy hold whatever the model decides to try. + +</details> + +## Try it yourself + +Put a human in front of the agent's reads as well. Change one policy so that +every SQL query pauses for approval, run the lab script, and approve a query. + +### Check + +Two tools need approval now. It prints both names. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '[.tools[].configs[] | select(.permission_policy.type == "always_ask") | .name] | select(length == 2)' +``` + +<details> +<summary>Solution</summary> + +In [`agent/local/l4-act.json`](../../agent/local/l4-act.json), change the policy +of `run_select_query` from `always_allow` to `always_ask`, then run the Lab 4 +script again. The agent gets a new version, and the first thing you see is an +approval prompt for a `SELECT`. + +Put the file back afterwards (`git checkout agent/local/l4-act.json`). Asking +for every read is the right default for a tool you do not trust yet, and the +wrong one for a tool the agent calls ten times a minute. + +</details> + +## Clean up + +In your path's folder, archive your agent and environment: + +| CLI | Python | TypeScript | +|---|---|---| +| `./cleanup.sh` | `python cleanup.py` | `npm run cleanup` | + +An environment with session history cannot be deleted; archiving keeps that +history available. + +Then stop the stacks, at the repository root. Your topic, view, and table stay +for next time: + +```bash +local/down.sh +``` + +Two other commands are for starting over. Run one only if you mean it: + +- `local/sql.sh < sql/local/99_reset.sql` drops the view, the table, and the + source, so that you can take Lab 2 again. The topic and its events stay. +- `local/down.sh --reset` deletes both stacks and their data. The next start is + Lab 0. + +## Recap + +You built the shape of most data + agent applications, and you ran all of it: + +- **A stream** (Ursa for Kafka) that holds the facts as they happen. +- **A materialized view** (RisingWave) that keeps a running summary: the agent's + always-fresh context. +- **An agent** (Orca Agent Engine) that reads that context itself, through an + allow-list of tools. +- **A human approval gate** on the one action that changes something. + +## What's next + +Swap the topic, the view, and the action, and you have your own project. Ideas +and next steps: [Go further](../../docs/go-further.md). The same labs on +StreamNative Cloud are the [Cloud course](../cloud/README.md). diff --git a/labs/local/README.md b/labs/local/README.md new file mode 100644 index 0000000..94ba650 --- /dev/null +++ b/labs/local/README.md @@ -0,0 +1,96 @@ +# Local course + +**Data + Agent Hackathon: hello world, on your laptop · about 45 minutes, plus image downloads** + +You build an agent whose context is a live Kafka stream, kept fresh by streaming +SQL, and that asks a human before it acts. Every part runs on your machine. No +account, no team card. + +## The story + +Aegis Financial, a fictional bank, streams every login attempt into Kafka. +Somewhere in that stream, an attacker is guessing passwords. Your agent spots +them from live data, and flags the account once you say so. + +```mermaid +flowchart LR + K["Ursa for Kafka<br/>diskless topic<br/>security.login_events"] --> S["RisingWave<br/>materialized view<br/>login_failures"] + J["inject<br/>(you, in Lab 3)"] -- "new login burst" --> K + S -- "RisingWave MCP<br/>run_select_query" --> G["AI Gateway"] + G --> A["Orca agent<br/>hello-agent-<you>"] + A -- "insert_multiple_rows<br/>(only if you approve)" --> F["table<br/>flagged_accounts"] +``` + +## What runs on your laptop + +| Part | What it is | Started by | +|---|---|---| +| [Ursa for Kafka](https://openlakestream.org/docs/ursa-for-kafka) | Kafka, with a diskless login topic: its records live in an object store, not on the broker | `docker compose -f local/compose.yaml up` | +| Oxia, object store, schema registry | What a diskless topic and Avro need | the same command | +| [RisingWave](https://risingwave.com) | Streaming SQL: the materialized view | the same command | +| RisingWave MCP server | The agent's SQL tools | the same command | +| Orca Agent Engine | Runs your agent: registry, harness, and AI Gateway | `local/engine.sh`, which runs `ork local` | + +## The labs + +| Lab | Time | Where | You | The idea | +|---|---|---|---|---| +| [0. Set up](00-set-up.md) | 15 min | terminal | Start both stacks, load the login stream, run the doctor | Know that every part answers before you build on it | +| [1. Hello, agent](01-hello-agent.md) | 5 min | CLI / Python / TS | Create an agent and chat | Agent, environment, session, events | +| [2. Hello, streaming SQL](02-streaming-sql.md) | 8 min | `psql` | Connect RisingWave to the topic and build a materialized view | Context that keeps itself fresh | +| [3. Agent + live context](03-live-context.md) | 9 min | CLI / Python / TS | Give the agent a SQL tool, inject new data | The answer changes with the data | +| [4. Agent acts, human approves](04-act-with-approval.md) | 5 min | CLI / Python / TS | Let the agent write, with your OK | Governed actions | + +The times are for the steps. Each lab also has a short quiz and a task to try on +your own. + +## What you need + +- **Docker** with Compose v2 (Docker Desktop, or Docker Engine on Linux). The + images are a 5 GB download and take about 20 GB of disk once unpacked; more + than half of that is RisingWave. Running, the two stacks use about 2.5 GB of + memory. +- **An Anthropic API key**, from the Anthropic Console, on an account with + credit. The agent answers six questions in the whole course, plus the ones you + ask on your own; see "What was run" below for what that cost. +- [`ork`](https://github.com/orca-ae/orca-cli) v0.6.0 or newer, and + [`jq`](https://jqlang.org/download/). +- One path: Python 3.11 or newer, or Node.js 20 or newer. The CLI path also + uses one of them for the doctor, the seeder, and the injector. +- macOS or Linux. On Windows, use WSL 2. + +Start with [Lab 0: Set up](00-set-up.md). If something goes wrong, see +[Troubleshooting](troubleshooting.md). How labs and checks work is in +[The labs](../README.md), and a coding agent can +[tutor you through the course](../../docs/tutor.md). + +Have a team card from the hackathon? Take the [Cloud course](../cloud/README.md) +instead: the same labs, on StreamNative Cloud. + +## What was run + +This course was run on 2 October 2026 on macOS 26 (Apple silicon) with Docker +29.2 and Compose 5.1, from a fresh `local/down.sh --reset` on each path: + +| Part | Version | +|---|---| +| `ork` | 0.6.0 (Agent Engine 0.5.1, AI Gateway 0.4.3) | +| Ursa for Kafka | `lakestream/kafka:4.3.1.3`, with Oxia 0.16.7 and RustFS 1.0.0 | +| RisingWave | v3.1.0, with `risingwave-mcp-server` 0.1.0 | +| Schema registry | Karapace 6.2.2 | +| SDKs | `runorca` 0.3.0 on Python 3.13; `@runorca/orca-sdk` 0.2.3 on Node.js 20 | + +- **Labs 0 and 2**: every command and every check, as written, with each check + run before its step and after it. Lab 0 on all three paths, each from a fresh + `local/down.sh --reset`. +- **Labs 1, 3 and 4, with the model answering** (`claude-sonnet-4-6`): every + step and every check on all three paths. The agent replied, queried the view, + saw the injected attack on the second question, proposed an insert, wrote the + row when allowed, and stopped when denied. The sample output in Labs 1, 3 and 4 + is from those runs, shortened. +- **"Try it yourself"**: all five tasks, on the Python path (Lab 1 also on the + CLI path). +- **What the model calls cost**: Labs 1, 3 and 4 taken once, on the CLI path, + came to about 310,000 input tokens (three quarters of them read from the + prompt cache) and 2,500 output tokens: about $0.35 at Sonnet's list prices. + Each turn sends the model about 30,000 tokens of context. diff --git a/labs/local/troubleshooting.md b/labs/local/troubleshooting.md new file mode 100644 index 0000000..899487c --- /dev/null +++ b/labs/local/troubleshooting.md @@ -0,0 +1,98 @@ +# Troubleshooting: Local course + +Two commands tell you what is wrong. Run both first. + +```bash +local/engine.sh --check # the Agent Engine, and its link to the MCP server +(cd python && .venv/bin/python doctor.py) # or: npm --prefix typescript run doctor +``` + +Each failed line prints its fix. + +## Symptoms + +| Symptom | Fix | +|---|---| +| `docker compose ... up` fails with a port already in use | Another program has one of the ports the stack publishes on `127.0.0.1`: 29092 (Kafka), 18081 (schema registry), 4566 and 5691 (RisingWave), 8000 (MCP). Stop that program. The ports are fixed: the broker tells its clients to come back to `127.0.0.1:29092`, and `local/write-env.sh` writes these ports into `.env`. | +| `local/engine.sh` fails because port 8080 is taken (`Bind for 0.0.0.0:8080 failed: port is already allocated`) | Pick another port for the registry: `export ORCA_LOCAL_REGISTRY_PORT=18080`, run `local/engine.sh` again, then `local/write-env.sh` so `.env` has the new address. Export it in every terminal you run `local/engine.sh` from: a run without it goes back to 8080. | +| `local/engine.sh`: `ANTHROPIC_API_KEY is not set in this shell` | `export ANTHROPIC_API_KEY=<your key>` in the terminal where you run the script. The engine reads the key only when it starts. | +| `local/engine.sh`: `bootstrap refused: an organization already exists` | The engine's volumes exist but its keys in `.lab/ork` are gone. Start over: `local/down.sh --reset`, then Lab 0. | +| Doctor: `Agent Engine HTTP 401` | The key in `.env` is not the running engine's key. Run `local/write-env.sh`. If it still fails, the engine's volumes and keys are out of step: `local/down.sh --reset`, then Lab 0. | +| Doctor: `Kafka ... not found` | The topic does not exist yet: Lab 0, step 4. | +| Doctor: `Schema Registry ... not found` | The schema is registered when you seed the topic: `python seed.py` or `npm run seed`. | +| Doctor: `Kafka`, `Schema Registry`, or `MCP server` cannot be reached, or `RisingWave through MCP` fails | The streaming stack is not up: `docker compose -f local/compose.yaml up -d --wait`. | +| Doctor: `Agent Engine Connection error` | The engine is not up: `local/engine.sh`. Start the streaming stack first. | +| `seed`: `already holds 246 events` | The topic is seeded. Nothing to do. | +| `table or source not found: security.login_events` | Create the source: Lab 2, step 1. | +| `table or source not found: login_failures` or `flagged_accounts` | Create the view or the table: Lab 2, steps 2 and 3. | +| Lab 3: the agent's tool call fails, or the script prints `[error]` lines about MCP or `egress denied` | The gateway lost its link to the MCP server, which happens whenever the engine restarts. Run `local/engine.sh`, then the lab script again. | +| The agent answers from memory instead of querying | Ask again, "check the view first". The system prompt tells it to always query. | +| `./lab-ork` says `No session_id yet` | The lab step that creates it has not run on this stack. Run the lab's script first. | +| A script seems stuck at an approval | The session is waiting for you. Answer the `Allow it? [y/N]` prompt, or press Ctrl-C and run the lab script again: it starts a fresh session. | + +## The model does not answer + +Lab 1 is the first time the stack calls the model. If the script prints lines +like this and no `[agent]` reply: + +```text +[error] server_error (status 502) (retrying) +``` + +the model provider rejected the request, and the engine retries for about three +minutes before it gives up. Its last line then names the provider's answer. For +a key the provider does not accept, that is: + +```text +The agent stopped (retries_exhausted): API Error: 502 bad gateway: ... upstream returned 401: ... "API key is invalid." ... +``` + +You do not have to wait for it: press Ctrl-C. That stops your script, not the +engine. The engine keeps retrying that turn for the rest of the three minutes, +and a turn you start meanwhile waits behind it. The usual cause is the key: + +1. Check that the key works at all, for example in the + [Anthropic console](https://console.anthropic.com/). +2. Export the working key and restart the engine, which reads the key only at + start: + + ```bash + export ANTHROPIC_API_KEY=<your key> + local/engine.sh + ``` + +3. Run the lab script again. + +If the key is fine, check that `ORCA_MODEL` in `.env` is a model your key can +use. + +## Start over + +```bash +local/down.sh --reset +``` + +This stops both stacks and deletes their volumes, the engine's keys in +`.lab/ork`, your local `.env`, and the ids in `.orca-state/`. Then start again at +[Lab 0](00-set-up.md). The image downloads are kept, so it is quick. + +To start over only part of the way: + +- **Lab 2 only**: `local/sql.sh < sql/local/99_reset.sql` drops the view, the + table, and the source. The topic and its events stay. +- **Your agent only**: run the cleanup script of your path (`./cleanup.sh`, + `python cleanup.py`, or `npm run cleanup`). The next lab script creates a new + agent. + +## What is running + +```bash +docker compose -f local/compose.yaml ps # the streaming stack +ork local --data-dir "$PWD/.lab/ork" status # the Agent Engine +docker compose -f local/compose.yaml logs risingwave-mcp --tail 20 +``` + +Run them at the repository root. `ork` v0.6.0 needs the data directory as a full +path, which is what `"$PWD/.lab/ork"` gives it there. + +RisingWave has a dashboard at <http://127.0.0.1:5691>. diff --git a/local/compose.yaml b/local/compose.yaml new file mode 100644 index 0000000..1055b41 --- /dev/null +++ b/local/compose.yaml @@ -0,0 +1,201 @@ +# The streaming stack for the Local course, on your laptop: +# +# kafka Ursa for Kafka, one broker. The login topic is diskless: +# its records live in the object store, not on the broker. +# oxia metadata for diskless topics +# object-store S3-compatible storage for diskless topics +# schema-registry the Avro schema of the login topic +# risingwave streaming SQL: the materialized view your agent reads +# risingwave-mcp RisingWave's MCP server: your agent's SQL tools +# +# docker compose -f local/compose.yaml up -d --wait +# +# Every image is pinned and every published port binds to 127.0.0.1. +# The Agent Engine is a separate stack: see local/engine.sh. + +name: hello-data-agent + +services: + oxia: + image: oxia/oxia:0.16.7 + entrypoint: ["/oxia/bin/oxia"] + command: ["standalone", "--data-dir=/data", "--shards=8"] + volumes: + - oxia-data:/data + healthcheck: + test: ["CMD-SHELL", "wget --spider -q http://localhost:8080/metrics || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + + # No port is published, so these static credentials only work inside the stack. + object-store: + image: rustfs/rustfs:1.0.0 + environment: + RUSTFS_ACCESS_KEY: ursa-local + RUSTFS_SECRET_KEY: ursa-local-secret + volumes: + - object-data:/data + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:9000/health"] + interval: 5s + timeout: 5s + retries: 30 + + # Creates the bucket, unless it exists. It reuses the object store's image, + # whose curl signs S3 requests itself, so nothing else has to be pulled. + object-store-init: + image: rustfs/rustfs:1.0.0 + entrypoint: ["/bin/sh", "-ec"] + command: + - | + bucket=http://object-store:9000/kafka-ursa + if curl -fs -o /dev/null --head --aws-sigv4 aws:amz:us-east-1:s3 \ + --user ursa-local:ursa-local-secret "$$bucket"; then + echo "Bucket kafka-ursa exists." + else + curl -fsS -o /dev/null -X PUT --aws-sigv4 aws:amz:us-east-1:s3 \ + --user ursa-local:ursa-local-secret "$$bucket" + echo "Created bucket kafka-ursa." + fi + depends_on: + object-store: + condition: service_healthy + restart: "no" + + kafka: + image: lakestream/kafka:4.3.1.3 + hostname: kafka + ports: + - "127.0.0.1:29092:9092" + environment: + KAFKA_NODE_ID: "1" + KAFKA_PROCESS_ROLES: "broker,controller" + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: "CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT" + KAFKA_CONTROLLER_QUORUM_VOTERS: "1@kafka:9093" + # Containers reach the broker at kafka:19092; your terminal at 127.0.0.1:29092 + # (the address, not `localhost`, which clients may try over IPv6 first). + KAFKA_LISTENERS: "PLAINTEXT://:19092,CONTROLLER://:9093,PLAINTEXT_HOST://:9092" + KAFKA_ADVERTISED_LISTENERS: "PLAINTEXT://kafka:19092,PLAINTEXT_HOST://127.0.0.1:29092" + KAFKA_INTER_BROKER_LISTENER_NAME: "PLAINTEXT" + KAFKA_CONTROLLER_LISTENER_NAMES: "CONTROLLER" + CLUSTER_ID: "4L6g3nShT-eMCtK--X86sw" + # One broker, so every internal topic has one replica. + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: "1" + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: "1" + # A topic you did not create is an error, not a silently created classic topic. + KAFKA_AUTO_CREATE_TOPICS_ENABLE: "false" + # KRaft metadata lives on the data volume, so a restart keeps your topics. + KAFKA_LOG_DIRS: "/var/lib/kafka/data/kraft-combined-logs" + + # Diskless storage through Ursa: metadata in Oxia, records in the object store. + KAFKA_URSA_STORAGE_ENABLE: "true" + KAFKA_URSA_CATALOG_OXIA_SERVICE_URL: "oxia://oxia:6648/default" + KAFKA_URSA_OXIA_SERVICE_URL: "oxia://oxia:6648/default" + KAFKA_URSA_STORAGE_BACKEND_TYPE: "S3" + KAFKA_URSA_STORAGE_PATH: "ursa/wal" + KAFKA_URSA_STORAGE_S3_ENDPOINT: "http://object-store:9000" + KAFKA_URSA_STORAGE_S3_ACCESS_KEY: "ursa-local" + KAFKA_URSA_STORAGE_S3_SECRET_KEY: "ursa-local-secret" + KAFKA_URSA_STORAGE_S3_BUCKET: "kafka-ursa" + KAFKA_URSA_STORAGE_S3_REGION: "us-east-1" + KAFKA_URSA_STORAGE_S3_PATH_STYLE_ACCESS: "true" + KAFKA_URSA_STORAGE_COMPACTION_BUCKET: "kafka-ursa" + KAFKA_URSA_STORAGE_COMPACTION_PREFIX: "ursa/compacted" + KAFKA_URSA_STORAGE_WRITE_BUFFER_FLUSH_INTERVAL_MS: "250" + KAFKA_URSA_STORAGE_WRITE_BUFFER_FLUSH_SIZE: "268435456" + KAFKA_URSA_STORAGE_WRITE_BUFFER_SIZE: "16777216" + volumes: + - kafka-data:/var/lib/kafka/data + depends_on: + oxia: + condition: service_healthy + object-store-init: + condition: service_completed_successfully + healthcheck: + test: + - CMD-SHELL + - /opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:19092 >/dev/null 2>&1 + interval: 5s + timeout: 30s + retries: 30 + start_period: 30s + + # Karapace implements the Confluent Schema Registry API. Schemas live in a + # classic `_schemas` topic on the broker. + schema-registry: + image: ghcr.io/aiven-open/karapace:6.2.2 + hostname: schema-registry + ports: + - "127.0.0.1:18081:8081" + # The image has no entrypoint; this module is the schema registry. + entrypoint: ["python3", "-m", "karapace"] + environment: + KARAPACE_KARAPACE_REGISTRY: "true" + KARAPACE_KARAPACE_REST: "false" + KARAPACE_BOOTSTRAP_URI: kafka:19092 + KARAPACE_HOST: 0.0.0.0 + KARAPACE_PORT: "8081" + KARAPACE_ADVERTISED_HOSTNAME: schema-registry + KARAPACE_CLIENT_ID: schema-registry + KARAPACE_GROUP_ID: schema-registry + KARAPACE_TOPIC_NAME: _schemas + KARAPACE_REPLICATION_FACTOR: "1" + KARAPACE_COMPATIBILITY: BACKWARD + KARAPACE_LOG_LEVEL: WARNING + depends_on: + kafka: + condition: service_healthy + healthcheck: + # Ready means `_schemas` is read to its end and this node is the primary. + test: + - CMD-SHELL + - >- + health="$$(curl -sf http://localhost:8081/_health)" && + echo "$$health" | grep -Eq '"schema_registry_ready": ?true' && + echo "$$health" | grep -Eq '"schema_registry_is_primary": ?true' + interval: 5s + timeout: 5s + retries: 60 + start_period: 20s + + risingwave: + image: risingwavelabs/risingwave:v3.1.0 + command: ["single_node"] + ports: + - "127.0.0.1:4566:4566" # SQL (Postgres wire protocol) + - "127.0.0.1:5691:5691" # dashboard + volumes: + - risingwave-data:/root/.risingwave + healthcheck: + test: ["CMD-SHELL", "bash -c 'printf \"\" > /dev/tcp/127.0.0.1/4566'"] + interval: 3s + timeout: 5s + retries: 40 + start_period: 10s + + # The server offers more than a hundred tools. Your agent's definition + # (agent/local/) enables the few it needs. + risingwave-mcp: + image: risingwavelabs/risingwave-mcp-server:0.1.0 + hostname: risingwave-mcp + ports: + - "127.0.0.1:8000:8000" + environment: + RISINGWAVE_CONNECTION_STR: "postgresql://root:root@risingwave:4566/dev" + depends_on: + risingwave: + condition: service_healthy + + # `psql` for local/sql.sh, so there is nothing to install. Not started by `up`. + psql: + image: postgres:16-alpine + profiles: ["tools"] + entrypoint: ["psql", "-h", "risingwave", "-p", "4566", "-d", "dev", "-U", "root"] + +volumes: + oxia-data: + object-data: + kafka-data: + risingwave-data: diff --git a/local/down.sh b/local/down.sh new file mode 100755 index 0000000..5e9fe6b --- /dev/null +++ b/local/down.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# shellcheck source-path=SCRIPTDIR +# Stop the Local course's stacks. +# +# local/down.sh # stop everything; your topic, view, and agents stay +# local/down.sh --reset # also delete all of it, to start over from Lab 0 +# +# `ork local stop` on its own leaves the AI Gateway container running (ork +# v0.6.0), and deleting only the engine's volumes, or only .lab/ork, leaves an +# engine that cannot start again. This script does the whole sequence. +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +reset=false +case "${1:-}" in + "") ;; + --reset) reset=true ;; + *) die "usage: local/down.sh [--reset]" ;; +esac + +command -v docker >/dev/null || die "docker is not installed." +project=$(engine_project) + +# Detach the MCP server from the engine's network, so that network can go. +mcp=$(mcp_container) +if [ -n "$mcp" ]; then + docker network disconnect "${project}_default" "$mcp" >/dev/null 2>&1 || true +fi + +# The engine. `down` by project name also removes the gateway, and needs no file. +if $reset; then + docker compose --progress quiet --project-name "$project" down -v --remove-orphans +else + docker compose --progress quiet --project-name "$project" down --remove-orphans +fi + +# The streaming stack. +if $reset; then + streaming down -v --remove-orphans +else + streaming down --remove-orphans +fi + +if $reset; then + # The engine's keys go with its volumes: one without the other cannot start again. + case "$ORK_DIR" in + "$REPO_ROOT"/.lab/ork) rm -rf "$ORK_DIR" ;; + *) die "Refusing to delete $ORK_DIR: it is not this checkout's .lab/ork." ;; + esac + # Ids and the key of an engine that no longer exists. + rm -f "$REPO_ROOT"/.orca-state/*.local.json + if [ -f "$REPO_ROOT/.env" ] && [ "$(env_value TUTORIAL_STACK "$REPO_ROOT/.env")" = local ]; then + rm -f "$REPO_ROOT/.env" + fi + echo "Stopped and deleted the local stacks, their data, and the local .env. Start over at labs/local/00-set-up.md." +else + echo "Stopped the local stacks. Your data is kept: start again with" + echo " docker compose -f local/compose.yaml up -d --wait && local/engine.sh" +fi diff --git a/local/engine.sh b/local/engine.sh new file mode 100755 index 0000000..0f512ef --- /dev/null +++ b/local/engine.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# shellcheck source-path=SCRIPTDIR +# Start the Agent Engine for the Local course, and let it reach your MCP server. +# +# export ANTHROPIC_API_KEY=... # the engine reads your provider key when it starts +# local/engine.sh # start (or restart) the engine, then link it +# local/engine.sh --check # only check the link +# +# What it runs, in order: +# +# 1. ork local --data-dir <this checkout>/.lab/ork start --with-gateway +# The Agent Engine, with the AI Gateway. The gateway makes every MCP call on +# your agent's behalf, so MCP tools need it. The data directory is given as a +# full path: ork v0.6.0 does not resolve a relative one. +# +# 2. The link. The gateway refuses private MCP hosts unless they are on its +# allowlist, and `ork local start` writes that allowlist empty every time. +# So this script adds one host (risingwave-mcp) to .lab/ork/gateway.yaml, +# restarts the gateway, and attaches the MCP server's container to the +# engine's Docker network under that name. +# +# Safe to run again at any time. Run it again after anything restarts the engine. +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +FAILED=0 +pass() { printf 'PASS %s\n' "$1"; } +fail() { # fail <what> <fix> + printf 'FAIL %s\n fix: %s\n' "$1" "$2" + FAILED=$((FAILED + 1)) +} + +# Run curl in a throwaway container on the engine's network: what the gateway sees. +curl_on() { # curl_on <network> <curl args...> + docker run --rm --network "$1" --entrypoint curl "$CURL_IMAGE" "${@:2}" +} + +start_engine() { + command -v ork >/dev/null || die "ork (the Orca CLI) is not installed. See labs/local/00-set-up.md." + [ -n "${ANTHROPIC_API_KEY:-}" ] || + die "ANTHROPIC_API_KEY is not set in this shell. The engine reads it when it starts: + export ANTHROPIC_API_KEY=<your key>" + [ -n "$(mcp_container)" ] || + die "The streaming stack is not running. Start it first: + docker compose -f local/compose.yaml up -d --wait" + ork local --data-dir "$ORK_DIR" start --with-gateway +} + +link() { + local gateway network mcp + gateway=$(engine_container ai-gateway) + mcp=$(mcp_container) + [ -n "$gateway" ] || die "The AI Gateway is not running: \`ork local start --with-gateway\` did not start it." + + allow_mcp_host "$ORK_DIR/gateway.yaml" + # The gateway reads its config when it starts. A restart keeps its environment, + # so your provider key stays in place. + docker restart "$gateway" >/dev/null + + network=$(docker inspect -f '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{end}}' "$gateway") + if ! docker network inspect "$network" -f '{{range .Containers}}{{.Name}} {{end}}' | grep -qw -- "$mcp"; then + docker network connect --alias "$MCP_HOST" "$network" "$mcp" + fi + + # Wait until the gateway answers again. + for _ in 1 2 3 4 5 6 7 8 9 10; do + if curl_on "$network" -s -o /dev/null --max-time 3 http://ai-gateway:8090/; then return 0; fi + sleep 1 + done + die "The AI Gateway did not come back after its restart. Look at: docker logs $gateway" +} + +check() { + local gateway network mcp + gateway=$(engine_container ai-gateway) + mcp=$(mcp_container) + + if [ -z "$gateway" ]; then + fail "the AI Gateway is running" "local/engine.sh" + return + fi + pass "the AI Gateway is running" + + if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$gateway" | grep -q '^ANTHROPIC_API_KEY=.'; then + pass "the gateway has a provider key" + else + fail "the gateway has a provider key" "export ANTHROPIC_API_KEY=<your key>, then local/engine.sh" + fi + + if grep -qF "allowed_private_hosts: ['$MCP_HOST']" "$ORK_DIR/gateway.yaml" 2>/dev/null; then + pass "the gateway allows the MCP host $MCP_HOST" + else + fail "the gateway allows the MCP host $MCP_HOST" "local/engine.sh" + fi + + if [ -z "$mcp" ]; then + fail "the MCP server is running" "docker compose -f local/compose.yaml up -d --wait, then local/engine.sh" + return + fi + network=$(docker inspect -f '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{end}}' "$gateway") + # The request the gateway will make: an MCP initialize, by the allowlisted name. + if curl_on "$network" -fsS -o /dev/null --max-time 10 -X POST "http://$MCP_HOST:8000/mcp" \ + -H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \ + -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"engine-check","version":"1"}}}' 2>/dev/null; then + pass "the MCP server answers at http://$MCP_HOST:8000/mcp on the engine's network" + else + fail "the MCP server answers at http://$MCP_HOST:8000/mcp on the engine's network" "local/engine.sh" + fi +} + +command -v docker >/dev/null || die "docker is not installed. The Local course runs in Docker." + +case "${1:-}" in + "") + start_engine + link + ;; + --check) ;; + *) die "usage: local/engine.sh [--check]" ;; +esac + +check +if [ "$FAILED" -eq 0 ]; then + printf '\nThe Agent Engine is up and can reach your MCP server.\n' +else + printf '\n%d check(s) failed.\n' "$FAILED" + exit 1 +fi diff --git a/local/lib.sh b/local/lib.sh new file mode 100644 index 0000000..f831054 --- /dev/null +++ b/local/lib.sh @@ -0,0 +1,78 @@ +# shellcheck shell=bash +# Shared by the Local course's helper scripts. Sourced by them, never run directly. + +LOCAL_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +REPO_ROOT=$(cd "$LOCAL_DIR/.." && pwd) +# Where `ork local` keeps the Agent Engine's config and keys. Inside the checkout, +# so each checkout has its own engine and nothing lands in your home directory. +ORK_DIR="$REPO_ROOT/.lab/ork" +# The name the AI Gateway reaches the MCP server by, on the engine's Docker network. +MCP_HOST=risingwave-mcp +# The object store's image ships a curl. Both stacks already use the image. +# shellcheck disable=SC2034 # read by engine.sh +CURL_IMAGE=rustfs/rustfs:1.0.0 + +die() { + printf '\n%s\n' "$1" >&2 + exit 1 +} + +# The value of NAME in an env file: the last NAME=value line, quotes removed. +env_value() { # env_value <name> <file> + local value + value=$(sed -n "s/^$1=//p" "$2" | tail -n 1) + value=${value%$'\r'} + case "$value" in + \"*\") value=${value#\"} value=${value%\"} ;; + \'*\') value=${value#\'} value=${value%\'} ;; + esac + printf '%s' "$value" +} + +# The streaming stack's Compose project. Named on every call: an exported +# COMPOSE_PROJECT_NAME would otherwise beat the name in the Compose file, and +# `down -v` would then act on some other project. +STREAMING_PROJECT=hello-data-agent + +streaming() { # docker compose, for the streaming stack + docker compose --progress quiet --project-name "$STREAMING_PROJECT" -f "$LOCAL_DIR/compose.yaml" "$@" +} + +# The name of one of the Agent Engine's containers, if it is running. +engine_container() { # engine_container <service> + docker ps --filter "label=com.docker.compose.project.working_dir=$ORK_DIR" \ + --filter "label=com.docker.compose.service=$1" --format '{{.Names}}' | head -n 1 +} + +mcp_container() { + docker ps --filter "label=com.docker.compose.project=$STREAMING_PROJECT" \ + --filter "label=com.docker.compose.service=risingwave-mcp" --format '{{.Names}}' | head -n 1 +} + +# `ork local` names its Compose project after a hash of the data directory. +engine_project() { + local sum + if command -v shasum >/dev/null; then + sum=$(printf '%s' "$ORK_DIR" | shasum -a 256) + else + sum=$(printf '%s' "$ORK_DIR" | sha256sum) + fi + printf 'ork-local-%s' "${sum:0:8}" +} + +# The AI Gateway makes every MCP call on the agent's behalf, and refuses private +# hosts unless they are allowlisted. `ork local start` writes the allowlist empty +# each time, so the one host the Local course needs is added here. +allow_mcp_host() { # allow_mcp_host <gateway.yaml> + local file=$1 + if grep -qF "allowed_private_hosts: ['$MCP_HOST']" "$file"; then + return 0 + fi + grep -qF 'allowed_private_hosts: []' "$file" || + die "Cannot find 'allowed_private_hosts: []' in $file. +This script was written for ork v0.6.0; your ork may write a different gateway config." + sed "s/allowed_private_hosts: \[\]/allowed_private_hosts: ['$MCP_HOST']/" "$file" >"$file.tmp" + # Keep the file itself, and its mode: the gateway container reads it. + cat "$file.tmp" >"$file" + rm -f "$file.tmp" +} diff --git a/local/sql.sh b/local/sql.sh new file mode 100755 index 0000000..07d67c2 --- /dev/null +++ b/local/sql.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# shellcheck source-path=SCRIPTDIR +# psql against the RisingWave on your laptop, with nothing to install. +# +# local/sql.sh # a prompt (\q to leave) +# local/sql.sh -c "SELECT * FROM login_failures" # one statement +# local/sql.sh < sql/local/02_login_failures.sql # a file +# +# It runs psql in a container on the streaming stack's network, so the stack +# has to be up: docker compose -f local/compose.yaml up -d --wait +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +# With a file or a pipe on stdin there is no terminal to attach. +tty=() +[ -t 0 ] || tty=(-T) +exec docker compose --progress quiet --project-name "$STREAMING_PROJECT" -f "$LOCAL_DIR/compose.yaml" \ + run --rm ${tty[@]+"${tty[@]}"} psql "$@" diff --git a/local/tests/run.sh b/local/tests/run.sh new file mode 100755 index 0000000..2b9f316 --- /dev/null +++ b/local/tests/run.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Tests for the Local course's helper scripts: the file logic, with no Docker. +# +# local/tests/run.sh +# +# What needs a running stack (starting the engine, linking the MCP server, +# stopping) is proven by walking the Local course itself. +set -euo pipefail + +TESTS=$(cd "$(dirname "$0")" && pwd) +LOCAL=$(dirname "$TESTS") +WORK=$(mktemp -d "${TMPDIR:-/tmp}/hello-local-tests.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +# A `docker` that knows which host port the registry is published on, and +# remembers what it was asked. +mkdir -p "$WORK/bin" +cat >"$WORK/bin/docker" <<'EOF' +#!/usr/bin/env bash +[ -n "${FAKE_DOCKER_DIR:-}" ] || exit 1 +printf '%s\n' "$*" >>"$FAKE_DOCKER_DIR/calls" +case "$1" in + ps) [ -f "$FAKE_DOCKER_DIR/registry-port" ] && echo registry-1 ;; + port) + [ ! -f "$FAKE_DOCKER_DIR/port-fails" ] || exit 1 + [ -f "$FAKE_DOCKER_DIR/registry-port" ] && echo "127.0.0.1:$(cat "$FAKE_DOCKER_DIR/registry-port")" + ;; + compose) ;; + *) exit 1 ;; +esac +EOF +chmod +x "$WORK/bin/docker" +export PATH="$WORK/bin:$PATH" +# Nothing from the developer's own shell may leak into the tests. +unset ORCA_LOCAL_REGISTRY_PORT TUTORIAL_STACK PARTICIPANT ORCA_MODEL ORCA_API_KEY + +PASSED=0 +FAILED=0 +R="" +STATUS=0 +KEY=orca_test_key_0123456789 + +# ------------------------------------------------------------------ helpers -- + +fresh_repo() { # fresh_repo <name>: a throwaway repo holding a copy of local/ + R="$WORK/repo-$1" + mkdir -p "$R/local" + cp "$LOCAL"/*.sh "$R/local/" + export FAKE_DOCKER_DIR="$R/fake-docker" + mkdir -p "$FAKE_DOCKER_DIR" +} + +engine_started() { # what `ork local start` leaves behind, on host port <port> + mkdir -p "$R/.lab/ork/secrets" + printf '%s' "$KEY" >"$R/.lab/ork/secrets/workspace-api-key" + printf '%s' "${1:-8080}" >"$FAKE_DOCKER_DIR/registry-port" +} + +run() { # run <script> [args...] + if (cd "$R" && "local/$1" "${@:2}") >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi +} + +env_is() { [ "$(sed -n "s/^$1=//p" "$R/.env")" = "$2" ]; } +out_has() { grep -qF -- "$1" "$R/out"; } +err_has() { grep -qF -- "$1" "$R/err"; } + +check() { # check <description> <command...> + if "${@:2}"; then + PASSED=$((PASSED + 1)) + else + FAILED=$((FAILED + 1)) + printf 'FAIL %s\n' "$1" + printf -- '--- stdout\n'; cat "$R/out" 2>/dev/null || true + printf -- '--- stderr\n'; cat "$R/err" 2>/dev/null || true + printf -- '--- .env\n'; sed 's/^ORCA_API_KEY=.*/ORCA_API_KEY=<hidden>/' "$R/.env" 2>/dev/null || true + fi +} + +# --------------------------------------------------------------- write-env -- + +test_write_env_before_the_engine_started() { + fresh_repo no-engine + run write-env.sh + check "exits 1 when the engine has no key yet" [ "$STATUS" -eq 1 ] + check "says to start the engine first" err_has "local/engine.sh" + check "writes no .env" [ ! -e "$R/.env" ] +} + +test_write_env_writes_the_local_contract() { + fresh_repo contract + engine_started 8080 + run write-env.sh + check "succeeds" [ "$STATUS" -eq 0 ] + check "marks the stack as local" env_is TUTORIAL_STACK local + check "copies the workspace key" env_is ORCA_API_KEY "$KEY" + check "points at the local registry" env_is ORCA_BASE_URL http://127.0.0.1:8080 + check "points at the local broker" env_is KAFKA_BOOTSTRAP_SERVERS 127.0.0.1:29092 + check "points at the local schema registry" env_is SCHEMA_REGISTRY_URL http://127.0.0.1:18081 + check "gives the agent the MCP address the gateway uses" env_is RW_MCP_URL http://risingwave-mcp:8000/mcp + check "gives the doctor the MCP address your terminal uses" env_is RW_MCP_LOCAL_URL http://127.0.0.1:8000/mcp + check "names the login topic" env_is LOGIN_TOPIC security.login_events + check "picks a default model" env_is ORCA_MODEL claude-sonnet-4-6 + check "leaves the participant to default to your user name" env_is PARTICIPANT "" + # GNU stat first: its -f means something else, and prints before it fails. + check "keeps .env private to you" [ "$(stat -c %a "$R/.env" 2>/dev/null || stat -f %Lp "$R/.env")" = 600 ] + check "never prints the key" [ "$(grep -c "$KEY" "$R/out" "$R/err" | awk -F: '{s += $2} END {print s}')" -eq 0 ] +} + +test_write_env_reads_the_port_the_registry_is_published_on() { + fresh_repo port + engine_started 18080 + run write-env.sh + check "uses the published port, whatever your shell says" env_is ORCA_BASE_URL http://127.0.0.1:18080 +} + +test_write_env_falls_back_to_the_port_in_your_shell() { + fresh_repo port-fallback + engine_started + rm "$FAKE_DOCKER_DIR/registry-port" # the registry is not running + ORCA_LOCAL_REGISTRY_PORT=19090 run write-env.sh + check "uses ORCA_LOCAL_REGISTRY_PORT when the registry is not running" env_is ORCA_BASE_URL http://127.0.0.1:19090 +} + +test_write_env_survives_a_registry_whose_port_docker_cannot_tell() { + fresh_repo port-unknown + engine_started 8080 + touch "$FAKE_DOCKER_DIR/port-fails" # the container is listed, but `docker port` fails + ORCA_LOCAL_REGISTRY_PORT=19090 run write-env.sh + check "still writes .env when docker cannot tell the port" [ "$STATUS" -eq 0 ] + check "and falls back to the port in your shell" env_is ORCA_BASE_URL http://127.0.0.1:19090 +} + +test_the_streaming_stack_is_always_named_by_this_repo() { + # An exported COMPOSE_PROJECT_NAME beats the name in the Compose file. Without + # the flag, `local/down.sh --reset` would delete another project's volumes. + fresh_repo project-name + cat >"$R/local/streaming-down.sh" <<'EOF' +#!/usr/bin/env bash +. "$(dirname "$0")/lib.sh" +streaming down -v --remove-orphans +EOF + chmod +x "$R/local/streaming-down.sh" + COMPOSE_PROJECT_NAME=someone-elses-app run streaming-down.sh + check "docker compose is told the project by name" \ + grep -q -- "^compose .*--project-name hello-data-agent .*down -v --remove-orphans$" "$FAKE_DOCKER_DIR/calls" +} + +test_write_env_never_overwrites_a_team_card() { + fresh_repo team-card + engine_started + printf 'SN_API_KEY=team-key\nORCA_BASE_URL=https://ws.example.com\n' >"$R/.env" + run write-env.sh + check "exits 1 on a cloud .env" [ "$STATUS" -eq 1 ] + check "leaves the team card as it was" env_is SN_API_KEY team-key + check "says how to keep both" err_has "mv .env .env.cloud" +} + +test_write_env_refreshes_a_local_env_and_keeps_your_choices() { + fresh_repo refresh + engine_started 8080 + printf 'TUTORIAL_STACK=local\nORCA_API_KEY=orca_old_key\nORCA_MODEL=claude-haiku-4-5\nPARTICIPANT=ana\n' >"$R/.env" + run write-env.sh + check "succeeds on a local .env" [ "$STATUS" -eq 0 ] + check "replaces the old key" env_is ORCA_API_KEY "$KEY" + check "keeps your participant name" env_is PARTICIPANT ana + check "keeps your model" env_is ORCA_MODEL claude-haiku-4-5 +} + +# ------------------------------------------------------- the gateway patch -- + +gateway_yaml() { # the two lines of `ork local`'s gateway.yaml that matter here + printf " egress_policy:\n allowed_private_hosts: %s\n dns_timeout_ms: 2000\n" "$1" >"$R/gateway.yaml" +} + +patch() { # run allow_mcp_host from local/lib.sh on the fixture + if (cd "$R" && . local/lib.sh && allow_mcp_host "$R/gateway.yaml") >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi +} + +test_the_gateway_is_told_to_allow_the_mcp_host() { + fresh_repo patch + gateway_yaml "[]" + patch + check "patching succeeds" [ "$STATUS" -eq 0 ] + check "the MCP host is allowlisted" grep -qF "allowed_private_hosts: ['risingwave-mcp']" "$R/gateway.yaml" + check "the rest of the file is untouched" grep -qF "dns_timeout_ms: 2000" "$R/gateway.yaml" +} + +test_patching_twice_changes_nothing() { + fresh_repo patch-twice + gateway_yaml "['risingwave-mcp']" + cp "$R/gateway.yaml" "$R/before" + patch + check "an already patched file is accepted" [ "$STATUS" -eq 0 ] + check "and left as it is" cmp -s "$R/gateway.yaml" "$R/before" +} + +test_an_unexpected_gateway_config_is_an_error() { + fresh_repo patch-unknown + gateway_yaml "['some-other-host']" + cp "$R/gateway.yaml" "$R/before" + patch + check "an unknown allowlist stops the script" [ "$STATUS" -eq 1 ] + check "the file is not modified" cmp -s "$R/gateway.yaml" "$R/before" + check "the error says ork may have changed" err_has "ork" +} + +# ---------------------------------------------------------------------- run -- + +for t in $(declare -F | awk '{print $3}' | grep '^test_'); do "$t"; done + +printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/local/write-env.sh b/local/write-env.sh new file mode 100755 index 0000000..867678c --- /dev/null +++ b/local/write-env.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# shellcheck source-path=SCRIPTDIR +# Write .env for the Local course: where the stack on your laptop is, and the +# key `ork local` generated for its Agent Engine. +# +# local/write-env.sh +# +# Run it again whenever local/engine.sh has started a fresh engine. It keeps your +# PARTICIPANT and ORCA_MODEL, and it never overwrites a team card. +set -euo pipefail +# shellcheck source=lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +KEY_FILE="$ORK_DIR/secrets/workspace-api-key" +ENV_FILE="$REPO_ROOT/.env" + +[ -s "$KEY_FILE" ] || + die "The Agent Engine has no key yet. Start it first: local/engine.sh" + +participant="" +model=claude-sonnet-4-6 +if [ -f "$ENV_FILE" ]; then + [ "$(env_value TUTORIAL_STACK "$ENV_FILE")" = local ] || + die ".env holds a team card (the Cloud course), and this would replace it. +To keep it, move it aside first: mv .env .env.cloud (both names are git-ignored)" + participant=$(env_value PARTICIPANT "$ENV_FILE") + kept=$(env_value ORCA_MODEL "$ENV_FILE") + [ -z "$kept" ] || model=$kept +fi + +# The registry's host port: what Docker says while it runs, else what your shell says. +port="" +registry=$(engine_container registry 2>/dev/null || true) +[ -z "$registry" ] || port=$(docker port "$registry" 8080/tcp 2>/dev/null | sed -n 's/.*://p' | head -n 1) || true +[ -n "$port" ] || port=${ORCA_LOCAL_REGISTRY_PORT:-8080} + +umask 077 +cat >"$ENV_FILE.tmp" <<EOF +# Written by local/write-env.sh for the Local course. .env is git-ignored: never commit it. +TUTORIAL_STACK=local + +# The Agent Engine that \`ork local\` runs, and the workspace key it generated. +ORCA_BASE_URL=http://127.0.0.1:$port +ORCA_API_KEY=$(cat "$KEY_FILE") + +# Ursa for Kafka and its schema registry, as your terminal reaches them. +KAFKA_BOOTSTRAP_SERVERS=127.0.0.1:29092 +SCHEMA_REGISTRY_URL=http://127.0.0.1:18081 + +# RisingWave's MCP server. The first address is the one your agent's definition +# carries: the AI Gateway reaches the server by that name. The second is the same +# server from your terminal, for the doctor. +RW_MCP_URL=http://$MCP_HOST:8000/mcp +RW_MCP_LOCAL_URL=http://127.0.0.1:8000/mcp + +# The Kafka topic the injector writes to and RisingWave reads. +LOGIN_TOPIC=security.login_events + +# The model your agent runs on. Your provider key must be able to use it. +ORCA_MODEL=$model + +# Names your agent and environment. Defaults to your OS user name. +PARTICIPANT=$participant +EOF +mv "$ENV_FILE.tmp" "$ENV_FILE" +echo "Wrote .env for the local stack (Agent Engine at http://127.0.0.1:$port)." diff --git a/python/cleanup.py b/python/cleanup.py index 3172cd2..b6f2272 100644 --- a/python/cleanup.py +++ b/python/cleanup.py @@ -2,7 +2,7 @@ python cleanup.py -Your SQL objects stay; drop them with sql/99_reset.sql. +Your SQL objects stay; drop them with sql/cloud/99_reset.sql or sql/local/99_reset.sql. """ from common import cleanup, load_config, orca_client, run_main, state_for diff --git a/python/common.py b/python/common.py index 5ab860f..161452a 100644 --- a/python/common.py +++ b/python/common.py @@ -9,6 +9,7 @@ import getpass import hashlib import json +import logging import os import re import secrets @@ -17,7 +18,7 @@ from dataclasses import dataclass, field from datetime import datetime from pathlib import Path -from typing import Any, Callable, Mapping +from typing import Any, Callable, Iterator, Mapping from dotenv import dotenv_values from orca import APIConnectionError, APIStatusError, ConflictError, NotFoundError, Orca @@ -25,11 +26,18 @@ REPO_ROOT = Path(__file__).resolve().parent.parent +# A quiet event stream is kept open with empty frames. The SDK skips each one and +# logs a warning; keep those out of the turn's output. +logging.getLogger("orca._streaming").setLevel(logging.ERROR) + # ------------------------------------------------------------------ config -- class ConfigError(RuntimeError): - """The team card (.env) is missing something.""" + """Your .env is missing something.""" + + +STACKS = ("cloud", "local") @dataclass(frozen=True) @@ -40,18 +48,38 @@ class Config: def __getitem__(self, name: str) -> str: return self.values[name] + @property + def stack(self) -> str: + """`cloud`: your team card on StreamNative Cloud. `local`: the stack on your laptop.""" + stack = self.values.get("TUTORIAL_STACK", "cloud") + if stack not in STACKS: + raise ConfigError("TUTORIAL_STACK must be cloud or local.") + return stack + + def require(self, *names: str) -> None: + missing = [name for name in names if name not in self.values] + if missing: + raise ConfigError(f"Missing {', '.join(missing)}. {setup_hint(self.values)}") + + +def setup_hint(values: Mapping[str, str]) -> str: + """How to get a complete .env, for the stack this one is for.""" + if values.get("TUTORIAL_STACK") == "local": + return "Run local/write-env.sh in the repo root to write .env again (Local course, Lab 0)." + return ( + "Copy .env.cloud.example to .env in the repo root and fill it in from your team card, " + "or run local/write-env.sh for the Local course." + ) + def load_config(required: list[str], env: Mapping[str, str] | None = None) -> Config: - """Read the team card: the repo's .env file, overridden by exported variables.""" + """Read .env in the repo root, overridden by exported variables.""" if env is None: env = {**dotenv_values(REPO_ROOT / ".env"), **os.environ} values = {name: value.strip() for name, value in env.items() if value and value.strip()} - missing = [name for name in required if name not in values] - if missing: - raise ConfigError( - f"Missing {', '.join(missing)}. Copy .env.example to .env in the repo root and fill it in from your team card." - ) - return Config(values=values, participant=_slug(values.get("PARTICIPANT") or getpass.getuser())) + config = Config(values=values, participant=_slug(values.get("PARTICIPANT") or getpass.getuser())) + config.require(*required) + return config def _slug(raw: str) -> str: @@ -63,7 +91,7 @@ def _slug(raw: str) -> str: class State: - """Remembers the ids your scripts created, in .orca-state/<participant>.json.""" + """Remembers the ids your scripts created, in .orca-state/.""" def __init__(self, path: Path) -> None: self.path = Path(path) @@ -79,7 +107,9 @@ def set(self, key: str, value: str) -> None: def state_for(config: Config) -> State: - return State(REPO_ROOT / ".orca-state" / f"{config.participant}.json") + """Each stack has its own Agent Engine, so each keeps its ids in its own file.""" + suffix = ".local" if config.stack == "local" else "" + return State(REPO_ROOT / ".orca-state" / f"{config.participant}{suffix}.json") # ------------------------------------------------------- agent definitions -- @@ -87,9 +117,9 @@ def state_for(config: Config) -> State: _PLACEHOLDER = re.compile(r"\$\{([A-Z0-9_]+)\}") -def load_layer(name: str) -> dict[str, Any]: - """Read agent/<name>.json: the same file the CLI and TypeScript paths use.""" - return json.loads((REPO_ROOT / "agent" / f"{name}.json").read_text()) +def load_layer(name: str, stack: str = "cloud") -> dict[str, Any]: + """Read agent/<stack>/<name>.json: the same file the CLI and TypeScript paths use.""" + return json.loads((REPO_ROOT / "agent" / stack / f"{name}.json").read_text()) def agent_params(layer: dict[str, Any], config: Config) -> dict[str, Any]: @@ -111,7 +141,7 @@ def agent_params(layer: dict[str, Any], config: Config) -> dict[str, Any]: def _fill(value: Any, config: Config) -> Any: - """Replace ${NAME} placeholders with values from the team card.""" + """Replace ${NAME} placeholders with values from .env.""" if isinstance(value, str): return _PLACEHOLDER.sub(lambda match: _lookup(match.group(1), config), value) if isinstance(value, list): @@ -123,10 +153,37 @@ def _fill(value: Any, config: Config) -> Any: def _lookup(name: str, config: Config) -> str: if name not in config.values: - raise ConfigError(f"Missing {name}: the agent definition needs it. Add it to .env from your team card.") + raise ConfigError(f"Missing {name}: the agent definition needs it. {setup_hint(config.values)}") return config.values[name] +# ------------------------------------------------ Kafka and Schema Registry -- + + +def kafka_client_config(config: Config) -> dict[str, str]: + """How to reach Kafka: your team's cluster over TLS, or the broker on your laptop.""" + config.require("KAFKA_BOOTSTRAP_SERVERS") + if config.stack == "local": + return {"bootstrap.servers": config["KAFKA_BOOTSTRAP_SERVERS"], "security.protocol": "PLAINTEXT"} + config.require("SN_SERVICE_ACCOUNT", "SN_API_KEY") + return { + "bootstrap.servers": config["KAFKA_BOOTSTRAP_SERVERS"], + "security.protocol": "SASL_SSL", + "sasl.mechanisms": "PLAIN", + # StreamNative Cloud: the service-account principal, and the raw API key. + "sasl.username": config["SN_SERVICE_ACCOUNT"], + "sasl.password": config["SN_API_KEY"], + } + + +def schema_registry_config(config: Config) -> dict[str, str]: + config.require("SCHEMA_REGISTRY_URL") + if config.stack == "local": + return {"url": config["SCHEMA_REGISTRY_URL"]} + config.require("SN_SERVICE_ACCOUNT", "SN_API_KEY") + return {"url": config["SCHEMA_REGISTRY_URL"], "basic.auth.user.info": f"{config['SN_SERVICE_ACCOUNT']}:{config['SN_API_KEY']}"} + + # ------------------------------------------------- Agent Engine resources -- @@ -192,7 +249,7 @@ def authorize_mcp(vault_id: str, config: Config) -> None: except OSError: raise ConfigError("Install ork with MCP OAuth proxy support and put it on PATH (see docs/before-you-arrive.md).") from None if result.returncode: - raise ConfigError("MCP OAuth authorization failed. Check the ork error above; normally leave SN_MCP_OAUTH_ISSUER empty for discovery, then rerun L3/L4.") + raise ConfigError("MCP OAuth authorization failed. Check the ork error above; normally leave SN_MCP_OAUTH_ISSUER empty for discovery, then run the Lab 3 script again.") def ensure_vault(client: Any, state: State, name: str, config: Config) -> str: @@ -227,6 +284,31 @@ def ensure_vault(client: Any, state: State, name: str, config: Config) -> str: return vault.id +def mcp_vault_ids(client: Any, state: State, config: Config) -> list[str]: + """The vaults a session needs to call the MCP server. + + StreamNative Cloud's MCP server wants a credential, kept in a vault. The MCP + server on your laptop takes none, so the local stack has no vault. + """ + if config.stack == "local": + return [] + return [ensure_vault(client, state, f"hello-vault-{config.participant}", config)] + + +def open_session(client: Any, state: State, environment_id: str, agent: Agent, title: str, *, vault_ids: list[str] = ()) -> Any: + """One conversation, pinned to this exact agent version. Its id is remembered for your checks.""" + params: dict[str, Any] = { + "environment_id": environment_id, + "agent": {"type": "agent", "id": agent.id, "version": agent.version}, + "title": title, + } + if vault_ids: + params["vault_ids"] = list(vault_ids) + session = client.sessions.create(**params) + state.set("session_id", session.id) + return session + + def _live(retrieve: Callable[[str], Any], resource_id: str | None) -> Any: """The remembered resource, or None if it was never created, deleted, or archived.""" if not resource_id: @@ -248,12 +330,13 @@ def chat( confirm: Callable[[dict[str, Any]], bool] | None = None, ask: Callable[[str], str] = input, out: Callable[[str], None] = print, + send_first: bool = False, ) -> None: """Ask `first`, then whatever the participant types next, until they type nothing.""" question = first while question: out(f"[you] {question}") - run_turn(client, session_id, question, confirm=confirm, out=out) + run_turn(client, session_id, question, confirm=confirm, out=out, send_first=send_first) question = ask("\nAsk again (Enter to quit): ").strip() @@ -320,54 +403,96 @@ def run_turn( *, confirm: Callable[[dict[str, Any]], bool] | None = None, out: Callable[[str], None] = print, + send_first: bool = False, ) -> TurnResult: """Send one user message and follow the session until the agent's turn ends. `confirm(tool_use)` is asked whenever a tool with an `always_ask` policy wants to run; return True to allow it, False to deny it. + + `send_first` is for the Agent Engine that `ork local` runs. It answers a stream + opened on a quiet session only at its next keep-alive, 15 seconds later. """ events = client.sessions.events + message = [{"type": "user.message", "content": [{"type": "text", "text": text}]}] + if send_first: + # Speak first, then follow the session from its start: that engine replays + # the log, and this turn begins right after our own message in it. + sent = events.send(session_id, events=message) + if not sent.data: + raise TurnError("The Agent Engine did not confirm the message it was sent.") + with events.stream(session_id, from_cursor="0") as stream: + return _follow(_after(stream, sent.data[0].id), events, session_id, confirm, out) + # Listen first, then speak: an event emitted between the two would be lost. with events.stream(session_id) as stream: - sent = events.send(session_id, events=[{"type": "user.message", "content": [{"type": "text", "text": text}]}]) + sent = events.send(session_id, events=message) # Some servers replay the whole transcript on connect. Everything # processed before our message belongs to an earlier turn. since = sent.data[0].processed_at if sent.data else None - seen: set[str] = set() - tool_uses: dict[str, dict[str, Any]] = {} - replies: list[str] = [] - last_error = "" - - for raw in stream: - event = raw.to_dict() - if event["id"] in seen or _before(event.get("processed_at"), since): + return _follow(_not_before(stream, since), events, session_id, confirm, out) + + +def _after(stream: Any, message_id: str) -> Iterator[dict[str, Any]]: + """The events that follow our own message in a replay of the session.""" + reached = False + for raw in stream: + event = raw.to_dict() + if reached: + yield event + elif event["id"] == message_id: + reached = True + + +def _not_before(stream: Any, since: str | None) -> Iterator[dict[str, Any]]: + """The events that were not processed before our message.""" + for raw in stream: + event = raw.to_dict() + if not _before(event.get("processed_at"), since): + yield event + + +def _follow( + turn: Iterator[dict[str, Any]], + events: Any, + session_id: str, + confirm: Callable[[dict[str, Any]], bool] | None, + out: Callable[[str], None], +) -> TurnResult: + """Print this turn's events, answer its requests for approval, and return at its end.""" + seen: set[str] = set() + tool_uses: dict[str, dict[str, Any]] = {} + replies: list[str] = [] + last_error = "" + + for event in turn: + if event["id"] in seen: + continue + seen.add(event["id"]) + kind = event["type"] + + if kind == "agent.message": + reply = "".join(b.get("text", "") for b in event.get("content", []) if b.get("type") == "text") + replies.append(reply) + out(f"[agent] {reply}") + elif kind == "agent.mcp_tool_use": + tool_uses[event["id"]] = event + out(_shorten(f"[tool] {event.get('name')} {json.dumps(event.get('input', {}))}")) + elif kind == "agent.mcp_tool_result": + label = "error" if event.get("is_error") else "result" + out(_shorten(f"[{label}] {_content_text(event.get('content'))}")) + elif kind == "session.error": + error = event.get("error") or {} + last_error = error.get("message") or error.get("type") or "unknown error" + out(f"[error] {last_error}" + (" (retrying)" if _will_retry(event) else "")) + elif kind == "session.status_idle": + stop = event.get("stop_reason") or {} + if stop.get("type") == "requires_action": + _answer_approvals(events, session_id, stop.get("event_ids", []), tool_uses, confirm) continue - seen.add(event["id"]) - kind = event["type"] - - if kind == "agent.message": - reply = "".join(b.get("text", "") for b in event.get("content", []) if b.get("type") == "text") - replies.append(reply) - out(f"[agent] {reply}") - elif kind == "agent.mcp_tool_use": - tool_uses[event["id"]] = event - out(_shorten(f"[tool] {event.get('name')} {json.dumps(event.get('input', {}))}")) - elif kind == "agent.mcp_tool_result": - label = "error" if event.get("is_error") else "result" - out(_shorten(f"[{label}] {_content_text(event.get('content'))}")) - elif kind == "session.error": - error = event.get("error") or {} - last_error = error.get("message") or error.get("type") or "unknown error" - retrying = (event.get("retry_status") or {}).get("will_retry") - out(f"[error] {last_error}" + (" (retrying)" if retrying else "")) - elif kind == "session.status_idle": - stop = event.get("stop_reason") or {} - if stop.get("type") == "requires_action": - _answer_approvals(events, session_id, stop.get("event_ids", []), tool_uses, confirm) - continue - if stop.get("type") == "end_turn": - return TurnResult(text="\n".join(replies)) - raise TurnError(f"The agent stopped ({stop.get('type')}): {last_error or 'no details'}") + if stop.get("type") == "end_turn": + return TurnResult(text="\n".join(replies)) + raise TurnError(f"The agent stopped ({stop.get('type')}): {last_error or 'no details'}") raise TurnError("The event stream ended before the agent finished its turn.") @@ -385,6 +510,13 @@ def _answer_approvals(events: Any, session_id: str, event_ids: list[str], tool_u events.send(session_id, events=decisions) +def _will_retry(event: dict[str, Any]) -> bool: + """Servers report a retry in one of two places: beside the error, or inside it.""" + if (event.get("retry_status") or {}).get("will_retry"): + return True + return ((event.get("error") or {}).get("retry_status") or {}).get("type") == "retrying" + + def _before(processed_at: str | None, since: str | None) -> bool: if not processed_at or not since: return False diff --git a/python/doctor.py b/python/doctor.py index da1f1a0..a30cbaa 100644 --- a/python/doctor.py +++ b/python/doctor.py @@ -1,10 +1,11 @@ -"""Check your laptop and your team card before the tutorial starts. +"""Check your laptop and your .env before a lab. python doctor.py # laptop + .env + Agent Engine + Kafka + Schema Registry + MCP python doctor.py --offline # laptop only (run this before the event) - python doctor.py --agent-only # laptop + Agent Engine (for ork local / L1) + python doctor.py --agent-only # laptop + Agent Engine (enough for Lab 1) -Every failed check prints the fix. +It checks the stack your .env is for: your team card on StreamNative Cloud, or +the stack on your laptop. Every failed check prints the fix. """ from __future__ import annotations @@ -14,13 +15,19 @@ import shutil import sys from dataclasses import dataclass -from typing import Any, Iterable +from typing import Any, Callable, Iterable from urllib.parse import urlsplit CARD = ("SN_API_KEY", "SN_SERVICE_ACCOUNT", "ORCA_BASE_URL", "KAFKA_BOOTSTRAP_SERVERS", "SCHEMA_REGISTRY_URL", "SN_MCP_URL", "LOGIN_TOPIC", "ORCA_MODEL") +LOCAL = ("ORCA_API_KEY", "ORCA_BASE_URL", "KAFKA_BOOTSTRAP_SERVERS", "SCHEMA_REGISTRY_URL", "RW_MCP_URL", "RW_MCP_LOCAL_URL", "LOGIN_TOPIC", "ORCA_MODEL") SQL_FIELDS = ("account_id", "event_time", "ip_address", "result", "failure_reason") -MCP_TOOLS = ("sql_workspace_list_databases", "sql_workspace_query", "sql_workspace_describe_table", "sql_workspace_insert_rows") +# The tools the agent definitions in agent/<stack>/ enable. +MCP_TOOLS = { + "cloud": ("sql_workspace_list_databases", "sql_workspace_query", "sql_workspace_describe_table", "sql_workspace_insert_rows"), + "local": ("run_select_query", "describe_table", "insert_multiple_rows"), +} PACKAGES = {"orca": "runorca", "confluent_kafka": "confluent-kafka[avro]", "dotenv": "python-dotenv"} +START_LOCAL_STACK = "Start the streaming stack: docker compose -f local/compose.yaml up -d --wait" @dataclass @@ -29,11 +36,20 @@ class Check: ok: bool detail: str = "" fix: str = "" + wait: bool = False # not ready yet, and not your mistake: a later lab does it + + @property + def label(self) -> str: + return "PASS" if self.ok else "WAIT" if self.wait else "FAIL" # ------------------------------------------------------------- decisions -- +def required_for(stack: str) -> tuple[str, ...]: + return LOCAL if stack == "local" else CARD + + def check_python(version: tuple) -> Check: ok = tuple(version[:2]) >= (3, 11) return Check("Python 3.11+", ok, "%d.%d" % tuple(version[:2]), "" if ok else "Install Python 3.11 or newer.") @@ -50,23 +66,42 @@ def check_orca_base_url(url: str) -> Check: return Check("ORCA_BASE_URL", True, root) -def check_login_schema(fields: Iterable[str]) -> Check: +def check_login_schema(fields: Iterable[str], stack: str = "cloud") -> Check: present = set(fields) missing = [name for name in SQL_FIELDS if name not in present] if not missing: return Check("login topic schema", True, "has " + ", ".join(SQL_FIELDS)) fix = "The SQL in sql/ expects these fields: ask a facilitator which schema your topic uses." if "result" in missing and "outcome" in present: - fix = "Your topic names the login result `outcome`, not `result`: use `outcome` in sql/01 and sql/02 (and check its values)." + fix = ( + "Your topic names the login result `outcome`, not `result`: use `outcome` in " + f"sql/{stack}/01_explore.sql and sql/{stack}/02_login_failures.sql (and check its values)." + ) return Check("login topic schema", False, "missing " + ", ".join(missing), fix) -def check_mcp_tools(names: Iterable[str]) -> Check: +def check_mcp_tools(names: Iterable[str], stack: str = "cloud") -> Check: + wanted = MCP_TOOLS[stack] offered = set(names) - missing = [tool for tool in MCP_TOOLS if tool not in offered] + missing = [tool for tool in wanted if tool not in offered] if not missing: - return Check("MCP tools", True, ", ".join(MCP_TOOLS)) - return Check("MCP tools", False, "missing " + ", ".join(missing), "The MCP server does not offer these tools to your key: ask a facilitator.") + return Check("MCP tools", True, ", ".join(wanted)) + if stack == "local": + fix = "The agent definitions in agent/local/ need these tools: check the risingwave-mcp image tag in local/compose.yaml." + else: + fix = "The MCP server does not offer these tools to your key: ask a facilitator." + return Check("MCP tools", False, "missing " + ", ".join(missing), fix) + + +def check_mcp_query(text: str) -> Check: + """`SELECT 1` through the MCP server: one row back means it reaches RisingWave.""" + try: + rows = json.loads(text) + except ValueError: + rows = None + if isinstance(rows, list) and len(rows) == 1: + return Check("RisingWave through MCP", True, "SELECT 1 returned a row") + return Check("RisingWave through MCP", False, text[:200], f"The MCP server cannot query RisingWave. {START_LOCAL_STACK}") def parse_mcp_response(content_type: str, body: str, request_id: int) -> dict[str, Any]: @@ -83,8 +118,12 @@ def parse_mcp_response(content_type: str, body: str, request_id: int) -> dict[st raise RuntimeError(f"no reply to MCP request {request_id}") -def kafka_hint(error: str) -> str: +def kafka_hint(error: str, stack: str = "cloud") -> str: text = error.lower() + if stack == "local": + if any(word in text for word in ("resolve", "transport", "timed out", "connect")): + return f"Cannot reach Kafka on your laptop. {START_LOCAL_STACK}" + return "The login topic is not there yet. Create it and seed it: Local course, Lab 0." if "authentication" in text or "sasl" in text: return ( "Kafka rejected the login. SN_SERVICE_ACCOUNT must be the full principal " @@ -97,6 +136,32 @@ def kafka_hint(error: str) -> str: return "See the error above, or ask a facilitator." +def schema_registry_hint(error: str, stack: str = "cloud") -> str: + if stack != "local": + return "Check SCHEMA_REGISTRY_URL; your key may lack Schema Registry read access." + if "not found" in error.lower(): + return "The schema is registered when you seed the topic: python seed.py (Local course, Lab 0)." + return f"Cannot reach Schema Registry on your laptop. {START_LOCAL_STACK}" + + +def mcp_headers(token: str | None) -> dict[str, str]: + headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def summarize(checks: list[Check]) -> tuple[int, str]: + """The exit code and the last line. A waiting check is not a failure.""" + failed = sum(check.label == "FAIL" for check in checks) + waiting = sum(check.label == "WAIT" for check in checks) + if failed: + return 1, f"{failed} check(s) failed. Fix them, then run doctor again." + if waiting: + return 0, f"You're ready. {waiting} check(s) wait for a later lab." + return 0, "All good: you're ready." + + # ---------------------------------------------------------------- probes -- @@ -111,11 +176,14 @@ def check_packages(agent_only: bool = False) -> list[Check]: def check_cli_tools() -> list[Check]: - """ork is needed for first OAuth login; jq is only needed by the CLI path.""" - return [ - Check(tool, True, "found" if shutil.which(tool) else ("not found: install before first OAuth MCP login" if tool == "ork" else "not found: CLI path only")) - for tool in ("ork", "jq") - ] + """ork and jq run the checks in each lab; ork also does the first OAuth login. Reported, never failed.""" + notes = {"ork": "not found: install it for the lab checks and the first OAuth MCP login", "jq": "not found: install it for the lab checks"} + return [Check(tool, True, "found" if shutil.which(tool) else notes[tool]) for tool in ("ork", "jq")] + + +def check_docker() -> Check: + found = shutil.which("docker") is not None + return Check("docker", found, "found" if found else "not found", "" if found else "The Local course runs in Docker: install Docker Desktop, or Docker Engine with Compose v2.") def probe_orca(config: Any) -> Check: @@ -123,10 +191,13 @@ def probe_orca(config: Any) -> Check: from common import orca_client + local = config.stack == "local" try: orca_client(config).agents.list(limit=1) except APIStatusError as err: - if err.status_code in (401, 403): + if err.status_code in (401, 403) and local: + fix = "The key in .env does not match the running stack. Run local/write-env.sh; if it still fails, start over with local/down.sh --reset." + elif err.status_code in (401, 403): fix = "The Agent Engine rejected the key. For ork local use its generated workspace key as ORCA_API_KEY; for a team card check SN_API_KEY and its rolebinding." elif err.status_code == 404: fix = "ORCA_BASE_URL is not an Agent Engine registry: copy the registry endpoint from your team card." @@ -134,7 +205,8 @@ def probe_orca(config: Any) -> Check: fix = "Ask a facilitator." return Check("Agent Engine", False, f"HTTP {err.status_code}", fix) except APIConnectionError as err: - return Check("Agent Engine", False, str(err), "Cannot reach ORCA_BASE_URL. Check the URL and your network.") + fix = "Start the Agent Engine: local/engine.sh" if local else "Cannot reach ORCA_BASE_URL. Check the URL and your network." + return Check("Agent Engine", False, str(err), fix) return Check("Agent Engine", True, "API key accepted") @@ -142,50 +214,44 @@ def probe_kafka(config: Any) -> Check: from confluent_kafka import KafkaException from confluent_kafka.admin import AdminClient + from common import kafka_client_config + errors: list[str] = [] - admin = AdminClient( - { - "bootstrap.servers": config["KAFKA_BOOTSTRAP_SERVERS"], - "security.protocol": "SASL_SSL", - "sasl.mechanisms": "PLAIN", - "sasl.username": config["SN_SERVICE_ACCOUNT"], - "sasl.password": config["SN_API_KEY"], - "error_cb": lambda err: errors.append(str(err)), - } - ) + admin = AdminClient({**kafka_client_config(config), "error_cb": lambda err: errors.append(str(err))}) topic_name = config["LOGIN_TOPIC"] try: # Listing all existing topics avoids metadata requests that can auto-create a missing topic. topic = admin.list_topics(timeout=15).topics.get(topic_name) except KafkaException as err: reason = errors[0] if errors else str(err) - return Check("Kafka", False, reason, kafka_hint(reason)) + return Check("Kafka", False, reason, kafka_hint(reason, config.stack)) if topic is None or topic.error is not None: reason = str(topic.error) if topic is not None else "not found" - return Check("Kafka", False, f"{topic_name}: {reason}", kafka_hint(reason)) + return Check("Kafka", False, f"{topic_name}: {reason}", kafka_hint(reason, config.stack)) return Check("Kafka", True, f"{topic_name} has {len(topic.partitions)} partition(s)") def probe_schema_registry(config: Any) -> list[Check]: from confluent_kafka.schema_registry import SchemaRegistryClient + from common import schema_registry_config + subject = f"{config['LOGIN_TOPIC']}-value" - registry = SchemaRegistryClient( - {"url": config["SCHEMA_REGISTRY_URL"], "basic.auth.user.info": f"{config['SN_SERVICE_ACCOUNT']}:{config['SN_API_KEY']}"} - ) + registry = SchemaRegistryClient(schema_registry_config(config)) try: latest = registry.get_latest_version(subject) except Exception as err: # SchemaRegistryError, or a connection error - return [Check("Schema Registry", False, str(err), "Check SCHEMA_REGISTRY_URL; your key may lack Schema Registry read access.")] + return [Check("Schema Registry", False, str(err), schema_registry_hint(str(err), config.stack))] fields = [field["name"] for field in json.loads(latest.schema.schema_str)["fields"]] - return [Check("Schema Registry", True, f"{subject} v{latest.version}"), check_login_schema(fields)] + return [Check("Schema Registry", True, f"{subject} v{latest.version}"), check_login_schema(fields, config.stack)] -def mcp_tool_names(url: str, token: str) -> list[str]: - """Ask the MCP server which tools it offers: initialize, then tools/list.""" +def mcp_connect(url: str, token: str | None = None) -> Callable[[str, dict[str, Any]], dict[str, Any]]: + """Open an MCP session over HTTP. Returns `request(method, params)`.""" import urllib.request - headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "Accept": "application/json, text/event-stream"} + headers = mcp_headers(token) + ids = iter(range(1, 1000)) def post(payload: dict[str, Any]) -> dict[str, Any] | None: request = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=headers, method="POST") @@ -196,16 +262,23 @@ def post(payload: dict[str, Any]) -> dict[str, Any] | None: content_type = response.headers.get("Content-Type", "application/json") return parse_mcp_response(content_type, body, payload["id"]) if "id" in payload else None + def request(method: str, params: dict[str, Any]) -> dict[str, Any]: + return post({"jsonrpc": "2.0", "id": next(ids), "method": method, "params": params}) or {} + hello = {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "hello-doctor", "version": "1"}} - init = post({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": hello}) or {} + init = request("initialize", hello) headers["MCP-Protocol-Version"] = init.get("protocolVersion", hello["protocolVersion"]) post({"jsonrpc": "2.0", "method": "notifications/initialized"}) + return request + +def mcp_tool_names(url: str, token: str | None = None, *, request: Callable[..., dict[str, Any]] | None = None) -> list[str]: + """Ask the MCP server which tools it offers: initialize, then tools/list.""" + request = request or mcp_connect(url, token) names: list[str] = [] cursor = None - for request_id in range(2, 12): - params = {"cursor": cursor} if cursor else {} - page = post({"jsonrpc": "2.0", "id": request_id, "method": "tools/list", "params": params}) or {} + for _ in range(10): + page = request("tools/list", {"cursor": cursor} if cursor else {}) names += [tool["name"] for tool in page.get("tools", [])] cursor = page.get("nextCursor") if not cursor: @@ -221,27 +294,40 @@ def probe_mcp(config: Any, *, client: Any = None, state: Any = None) -> Check: client = client or orca_client(config) state = state or state_for(config) vault_id = state.get("vault_id") - fix = "Run L3 to create the MCP OAuth credential with ork, then rerun doctor." + later = "Nothing to do now: Lab 3 opens your browser to authorize the MCP server. Run doctor again after it." if not vault_id: - return Check("MCP OAuth", False, "no tutorial vault yet", fix) + return Check("MCP OAuth", False, "no tutorial vault yet", later, wait=True) credentials = client.vaults.credentials.list(vault_id).data credential = next((c for c in credentials if c.auth.type == "mcp_oauth" and c.auth.mcp_server_url == config["SN_MCP_URL"] and not c.archived_at), None) if credential is None: - return Check("MCP OAuth", False, "no matching OAuth credential", fix) + return Check("MCP OAuth", False, "no matching OAuth credential", later, wait=True) result = client.vaults.credentials.validate(vault_id, credential.id) ok = result.status == "valid" + fix = "" if result.status == "unknown": fix = "The MCP probe was inconclusive. Check Registry/MCP connectivity and rerun doctor; keep the existing credential." elif result.status == "invalid": - fix = f"Reauthorize: ork agent vaults credentials archive {credential.id} --vault {vault_id}, then rerun L3/L4." - return Check("MCP OAuth", ok, f"{result.status}: MCP initialization; SQL tools checked in L3/L4", "" if ok else fix) + fix = f"Reauthorize: ork agent vaults credentials archive {credential.id} --vault {vault_id}, then run the Lab 3 script again." + return Check("MCP OAuth", ok, f"{result.status}: MCP initialization; the SQL tools are checked in Labs 3 and 4", fix) names = mcp_tool_names(config["SN_MCP_URL"], config["SN_API_KEY"]) except Exception as err: # HTTP errors, JSON-RPC errors, timeouts - return Check("MCP server", False, str(err), "Check SN_MCP_URL and SN_MCP_AUTH; for OAuth, finish the L3 browser login and verify the vault credential.") + return Check("MCP server", False, str(err), "Check SN_MCP_URL and SN_MCP_AUTH; for OAuth, finish the Lab 3 browser login and verify the vault credential.") return check_mcp_tools(names) +def probe_local_mcp(config: Any) -> list[Check]: + """The MCP server on your laptop, as your terminal reaches it. No credential.""" + try: + request = mcp_connect(config["RW_MCP_LOCAL_URL"]) + tools = check_mcp_tools(mcp_tool_names(config["RW_MCP_LOCAL_URL"], request=request), "local") + result = request("tools/call", {"name": "run_select_query", "arguments": {"query": "SELECT 1 AS ready"}}) + except Exception as err: # HTTP errors, JSON-RPC errors, timeouts + return [Check("MCP server", False, str(err), START_LOCAL_STACK)] + text = "".join(block.get("text", "") for block in result.get("content", []) if isinstance(block, dict)) + return [tools, check_mcp_query(text)] + + # ------------------------------------------------------------------ main -- @@ -250,18 +336,23 @@ def run_checks(offline: bool, agent_only: bool = False) -> list[Check]: if offline or not all(check.ok for check in checks): return checks - from common import load_config + from common import ConfigError, load_config, setup_hint config = load_config([]) - required = ("ORCA_BASE_URL", "ORCA_MODEL") if agent_only else CARD + try: + stack = config.stack + except ConfigError as err: + return [*checks, Check(".env", False, str(err), "Set TUTORIAL_STACK=cloud or TUTORIAL_STACK=local in .env, or remove the line.")] + required = ("ORCA_BASE_URL", "ORCA_MODEL") if agent_only else required_for(stack) missing = [name for name in required if name not in config.values] if agent_only and not any(name in config.values for name in ("ORCA_API_KEY", "SN_API_KEY")): missing.append("ORCA_API_KEY or SN_API_KEY") if missing: - fix = "Copy .env.example to .env in the repo root and paste your team card." - return [*checks, Check("team card (.env)", False, "missing " + ", ".join(missing), fix)] + return [*checks, Check(".env", False, "missing " + ", ".join(missing), setup_hint(config.values))] - checks.append(Check("team card (.env)", True, f"participant: {config.participant}")) + checks.append(Check(".env", True, f"{stack} stack, participant: {config.participant}")) + if stack == "local": + checks.append(check_docker()) base_url = check_orca_base_url(config["ORCA_BASE_URL"]) checks.append(base_url) if base_url.ok: @@ -270,19 +361,22 @@ def run_checks(offline: bool, agent_only: bool = False) -> list[Check]: return checks checks.append(probe_kafka(config)) checks += probe_schema_registry(config) - checks.append(probe_mcp(config)) + if stack == "local": + checks += probe_local_mcp(config) + else: + checks.append(probe_mcp(config)) return checks def main() -> int: checks = run_checks(offline="--offline" in sys.argv[1:], agent_only="--agent-only" in sys.argv[1:]) for check in checks: - print(f"{'PASS' if check.ok else 'FAIL'} {check.name:<32} {check.detail}") + print(f"{check.label} {check.name:<32} {check.detail}") if not check.ok and check.fix: - print(f" fix: {check.fix}") - failed = sum(not check.ok for check in checks) - print("\nAll good: you're ready." if not failed else f"\n{failed} check(s) failed. Fix them, then run doctor again.") - return 1 if failed else 0 + print(f" {'next' if check.wait else 'fix'}: {check.fix}") + code, verdict = summarize(checks) + print(f"\n{verdict}") + return code if __name__ == "__main__": diff --git a/python/inject.py b/python/inject.py index b03945c..19fab88 100644 --- a/python/inject.py +++ b/python/inject.py @@ -1,4 +1,4 @@ -"""Inject a brute-force login burst into your team's login topic. +"""Inject a brute-force login burst into the login topic. Six failed logins from a new IP address, then a success: the classic account-takeover pattern. The materialized view login_failures picks it up @@ -14,7 +14,7 @@ from datetime import datetime, timedelta, timezone from typing import Any -from common import load_config, run_main +from common import Config, kafka_client_config, load_config, run_main, schema_registry_config FAILURES = 6 @@ -62,8 +62,11 @@ def build_burst(account_id: str, ip_address: str, now: datetime) -> list[dict[st return records -def publish(producer: Any, topic: str, key: str, records: list[dict[str, Any]]) -> list[str]: - """Write the records and wait for Kafka to confirm them. Returns what went wrong, if anything.""" +def publish(producer: Any, topic: str, records: list[dict[str, Any]]) -> list[str]: + """Write the records, each keyed by its account, and wait for Kafka to confirm them. + + Returns what went wrong, if anything. + """ from confluent_kafka import KafkaException errors: list[str] = [] @@ -75,7 +78,7 @@ def delivered(err: Any, _msg: Any) -> None: try: for record in records: # Serializing looks the schema up in Schema Registry, so it can fail here too. - producer.produce(topic, key=key, value=record, on_delivery=delivered) + producer.produce(topic, key=record["account_id"], value=record, on_delivery=delivered) undelivered = producer.flush(30) except KafkaException as err: return [str(err)] @@ -84,39 +87,43 @@ def delivered(err: Any, _msg: Any) -> None: return errors -def main() -> None: +def login_producer(config: Config, *, schema: str | None = None) -> Any: + """A producer of Avro login events. + + By default it writes with the schema the topic already has and never registers + a new one. Pass `schema` to register it first: that is how seed.py fills a new topic. + """ from confluent_kafka import SerializingProducer from confluent_kafka.schema_registry import SchemaRegistryClient from confluent_kafka.schema_registry.avro import AvroSerializer from confluent_kafka.serialization import StringSerializer - config = load_config(["KAFKA_BOOTSTRAP_SERVERS", "SCHEMA_REGISTRY_URL", "SN_SERVICE_ACCOUNT", "SN_API_KEY", "LOGIN_TOPIC"]) - topic = config["LOGIN_TOPIC"] - - registry = SchemaRegistryClient( - {"url": config["SCHEMA_REGISTRY_URL"], "basic.auth.user.info": f"{config['SN_SERVICE_ACCOUNT']}:{config['SN_API_KEY']}"} - ) - producer = SerializingProducer( + registry = SchemaRegistryClient(schema_registry_config(config)) + if schema is None: + serializer = AvroSerializer(registry, conf={"auto.register.schemas": False, "use.latest.version": True}) + else: + serializer = AvroSerializer(registry, schema) + return SerializingProducer( { - "bootstrap.servers": config["KAFKA_BOOTSTRAP_SERVERS"], - "security.protocol": "SASL_SSL", - "sasl.mechanisms": "PLAIN", - # StreamNative Cloud: the service-account principal, and the raw API key. - "sasl.username": config["SN_SERVICE_ACCOUNT"], - "sasl.password": config["SN_API_KEY"], + **kafka_client_config(config), "enable.idempotence": False, "key.serializer": StringSerializer("utf_8"), - # Write with the schema the topic already has; never register a new one. - "value.serializer": AvroSerializer(registry, conf={"auto.register.schemas": False, "use.latest.version": True}), + "value.serializer": serializer, } ) + +def main() -> None: + config = load_config(["KAFKA_BOOTSTRAP_SERVERS", "SCHEMA_REGISTRY_URL", "LOGIN_TOPIC"]) + topic = config["LOGIN_TOPIC"] + producer = login_producer(config) + account_id, ip_address = new_account_id(), new_ip() - errors = publish(producer, topic, account_id, build_burst(account_id, ip_address, datetime.now(timezone.utc))) + errors = publish(producer, topic, build_burst(account_id, ip_address, datetime.now(timezone.utc))) if errors: raise SystemExit(f"Could not write to {topic}: {errors[0]}\nRun `python doctor.py` to check your Kafka access.") print(f"Injected {FAILURES} failed logins + 1 success for {account_id} from {ip_address} into {topic}.") - print(f"Ask your agent again, or check in SQL Studio: SELECT * FROM login_failures WHERE account_id = '{account_id}';") + print(f"Ask your agent again, or run this SQL: SELECT * FROM login_failures WHERE account_id = '{account_id}';") if __name__ == "__main__": diff --git a/python/l1_hello.py b/python/l1_hello.py index a156578..8618d03 100644 --- a/python/l1_hello.py +++ b/python/l1_hello.py @@ -8,7 +8,18 @@ import sys -from common import agent_params, ensure_agent, ensure_environment, load_config, load_layer, orca_client, run_main, run_turn, state_for +from common import ( + agent_params, + ensure_agent, + ensure_environment, + load_config, + load_layer, + open_session, + orca_client, + run_main, + run_turn, + state_for, +) QUESTION = "Hi! What is the Data + Agent Hackathon, and what can you see right now?" @@ -21,22 +32,19 @@ def main() -> None: # 1. An environment: where your agent's sessions run. environment_id = ensure_environment(client, state, f"hello-env-{config.participant}") - # 2. An agent: a model plus a system prompt, from agent/l1-hello.json. - layer = load_layer("l1-hello") + # 2. An agent: a model plus a system prompt, from agent/<stack>/l1-hello.json. + layer = load_layer("l1-hello", config.stack) agent = ensure_agent(client, state, agent_params(layer, config)) print(f"{agent.name} v{agent.version}: {layer['summary']}") # 3. A session: one conversation, pinned to this exact agent version. - session = client.sessions.create( - environment_id=environment_id, - agent={"type": "agent", "id": agent.id, "version": agent.version}, - title="L1: hello", - ) + session = open_session(client, state, environment_id, agent, "L1: hello") - # 4. Send a message and stream the agent's reply. + # 4. Send a message and stream the agent's reply. (The engine on your laptop is + # spoken to first and listened to second: see run_turn.) question = " ".join(sys.argv[1:]) or QUESTION print(f"[you] {question}") - run_turn(client, session.id, question) + run_turn(client, session.id, question, send_first=config.stack == "local") if __name__ == "__main__": diff --git a/python/l3_live_context.py b/python/l3_live_context.py index 33c6deb..8948819 100644 --- a/python/l3_live_context.py +++ b/python/l3_live_context.py @@ -1,8 +1,8 @@ """L3 - Agent + live context. -Upgrades your agent with read-only StreamNative MCP tools, gives the session a -vault holding the MCP credential, and opens a conversation. Ask, run -`python inject.py` in a second terminal, then ask again: the answer changes. +Upgrades your agent with read-only SQL tools from an MCP server and opens a +conversation. Ask, run `python inject.py` in a second terminal, then ask again: +the answer changes. python l3_live_context.py """ @@ -12,9 +12,10 @@ chat, ensure_agent, ensure_environment, - ensure_vault, load_config, load_layer, + mcp_vault_ids, + open_session, orca_client, run_main, state_for, @@ -24,27 +25,23 @@ def main() -> None: - config = load_config(["ORCA_BASE_URL", "ORCA_MODEL", "SN_MCP_URL"]) + config = load_config(["ORCA_BASE_URL", "ORCA_MODEL"]) client = orca_client(config) state = state_for(config) environment_id = ensure_environment(client, state, f"hello-env-{config.participant}") - # The same agent, next version: agent/l3-live-context.json adds the MCP server. - layer = load_layer("l3-live-context") + # The same agent, next version: agent/<stack>/l3-live-context.json adds the MCP server. + layer = load_layer("l3-live-context", config.stack) agent = ensure_agent(client, state, agent_params(layer, config)) print(f"{agent.name} v{agent.version}: {layer['summary']}") - # The MCP server needs a credential. It goes in a vault, never in the prompt. - vault_id = ensure_vault(client, state, f"hello-vault-{config.participant}", config) + # StreamNative Cloud's MCP server needs a credential. It goes in a vault, never + # in the prompt. The MCP server on your laptop takes none, so there is no vault. + vault_ids = mcp_vault_ids(client, state, config) - session = client.sessions.create( - environment_id=environment_id, - agent={"type": "agent", "id": agent.id, "version": agent.version}, - vault_ids=[vault_id], - title="L3: live context", - ) + session = open_session(client, state, environment_id, agent, "L3: live context", vault_ids=vault_ids) print("Tip: after the first answer, run `python inject.py` in another terminal and ask again.\n") - chat(client, session.id, QUESTION) + chat(client, session.id, QUESTION, send_first=config.stack == "local") if __name__ == "__main__": diff --git a/python/l4_act.py b/python/l4_act.py index 792d1b4..f67109d 100644 --- a/python/l4_act.py +++ b/python/l4_act.py @@ -12,9 +12,10 @@ chat, ensure_agent, ensure_environment, - ensure_vault, load_config, load_layer, + mcp_vault_ids, + open_session, orca_client, run_main, state_for, @@ -24,26 +25,21 @@ def main() -> None: - config = load_config(["ORCA_BASE_URL", "ORCA_MODEL", "SN_MCP_URL"]) + config = load_config(["ORCA_BASE_URL", "ORCA_MODEL"]) client = orca_client(config) state = state_for(config) environment_id = ensure_environment(client, state, f"hello-env-{config.participant}") - # Next version again: agent/l4-act.json enables one write tool, always_ask. - layer = load_layer("l4-act") + # Next version again: agent/<stack>/l4-act.json enables one write tool, always_ask. + layer = load_layer("l4-act", config.stack) agent = ensure_agent(client, state, agent_params(layer, config)) print(f"{agent.name} v{agent.version}: {layer['summary']}") - vault_id = ensure_vault(client, state, f"hello-vault-{config.participant}", config) - session = client.sessions.create( - environment_id=environment_id, - agent={"type": "agent", "id": agent.id, "version": agent.version}, - vault_ids=[vault_id], - title="L4: act with approval", - ) + vault_ids = mcp_vault_ids(client, state, config) + session = open_session(client, state, environment_id, agent, "L4: act with approval", vault_ids=vault_ids) # ask_human is called whenever the session pauses for approval. - chat(client, session.id, REQUEST, confirm=ask_human) + chat(client, session.id, REQUEST, confirm=ask_human, send_first=config.stack == "local") if __name__ == "__main__": diff --git a/python/seed.py b/python/seed.py new file mode 100644 index 0000000..3f6c3b8 --- /dev/null +++ b/python/seed.py @@ -0,0 +1,80 @@ +"""Load the login stream into the topic on your laptop (Local course, Lab 0). + +Replays data/login_events.jsonl: 246 synthetic logins at a fictional bank, with +their timestamps moved to now. One of the accounts in it is under attack. + + python seed.py + python seed.py --force # load another copy into a topic that already has events +""" + +from __future__ import annotations + +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from common import REPO_ROOT, kafka_client_config, load_config, run_main +from inject import login_producer, publish + +EVENTS_FILE = REPO_ROOT / "data" / "login_events.jsonl" +SCHEMA_FILE = REPO_ROOT / "schemas" / "login_events.avsc" + + +def load_events(path: Path = EVENTS_FILE) -> list[dict[str, Any]]: + """One login event per line.""" + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def rebase_events(events: list[dict[str, Any]], now: datetime) -> list[dict[str, Any]]: + """The same events, moved in time so the newest one happens now. Every gap is kept.""" + shift = int(now.timestamp() * 1000) - max(event["event_time"] for event in events) + return [{**event, "event_time": event["event_time"] + shift, "ingested_at": event["ingested_at"] + shift} for event in events] + + +def events_in_topic(watermarks: Iterable[tuple[int, int]]) -> int: + """How many events a topic holds, from each partition's (low, high) offsets.""" + return sum(high - low for low, high in watermarks) + + +def count_existing(consumer: Any, topic: str) -> int: + """How many events the topic holds already. Stops the script when it cannot tell.""" + from confluent_kafka import KafkaException, TopicPartition + + try: + # Listing every topic avoids a metadata request that could create a missing one. + metadata = consumer.list_topics(timeout=15).topics.get(topic) + if metadata is None or metadata.error is not None: + raise SystemExit(f"The topic {topic} does not exist yet. Create it first: Local course, Lab 0.") + return events_in_topic(consumer.get_watermark_offsets(TopicPartition(topic, p), timeout=15) for p in metadata.partitions) + except KafkaException as err: + reason = err.args[0].str() if err.args else str(err) + raise SystemExit(f"Could not read {topic}: {reason}\nRun `python doctor.py` to check your setup.") from None + finally: + consumer.close() + + +def main() -> None: + from confluent_kafka import Consumer + + config = load_config(["KAFKA_BOOTSTRAP_SERVERS", "SCHEMA_REGISTRY_URL", "LOGIN_TOPIC"]) + if config.stack != "local": + raise SystemExit("seed.py loads the topic on your laptop (Local course). Your team's cluster already holds the login stream.") + topic = config["LOGIN_TOPIC"] + + existing = count_existing(Consumer({**kafka_client_config(config), "group.id": "hello-seed"}), topic) + if existing and "--force" not in sys.argv[1:]: + raise SystemExit(f"{topic} already holds {existing} events, so it is seeded. To load another copy anyway: python seed.py --force") + + events = rebase_events(load_events(), datetime.now(timezone.utc)) + # Registering the schema is what lets RisingWave decode the topic. + errors = publish(login_producer(config, schema=SCHEMA_FILE.read_text()), topic, events) + if errors: + raise SystemExit(f"Could not write to {topic}: {errors[0]}\nRun `python doctor.py` to check your setup.") + accounts = len({event["account_id"] for event in events}) + print(f"Loaded {len(events)} logins for {accounts} accounts into {topic}.") + + +if __name__ == "__main__": + run_main(main) diff --git a/python/tests/fakes.py b/python/tests/fakes.py index 07a2a1f..c457127 100644 --- a/python/tests/fakes.py +++ b/python/tests/fakes.py @@ -86,7 +86,61 @@ def send(self, session_id: str, *, events: list[dict[str, Any]]) -> SimpleNamesp return SimpleNamespace(data=persisted) -def client_with_events(events: FakeSessionEvents) -> SimpleNamespace: +class LogStream: + """A server-sent-event stream over a session's log, starting at `position`.""" + + def __init__(self, log: list[SessionEvent], position: int) -> None: + self._log = log + self._position = position + + def __enter__(self) -> "LogStream": + return self + + def __exit__(self, *exc: object) -> None: + return None + + def __iter__(self): + # Events logged while the stream is open are delivered too. + while self._position < len(self._log): + self._position += 1 + yield self._log[self._position - 1] + + +class OrkLocalSessionEvents: + """A scripted session on the engine that `ork local` runs (0.5.1), as observed. + + The session is one log of events. What you send is logged, with no + `processed_at` yet. A stream opened with `from_cursor="0"` replays the log + from its start and then follows it; without a cursor it starts at the live + edge and shows only what is logged after it was opened. + + `reactions[i]` are the events the agent emits after the i-th `send()` call. + """ + + def __init__(self, reactions: list[list[dict[str, Any]]]) -> None: + self.calls: list[tuple] = [] + self._reactions = list(reactions) + self._log: list[SessionEvent] = [] + self._ticks = itertools.count(1) + self._ids = itertools.count(1) + + def _now(self) -> str: + return f"2026-10-07T10:00:{next(self._ticks):02d}.000Z" + + def stream(self, session_id: str, *, from_cursor: str | None = None, **_: Any) -> LogStream: + self.calls.append(("stream", session_id, from_cursor)) + return LogStream(self._log, 0 if from_cursor == "0" else len(self._log)) + + def send(self, session_id: str, *, events: list[dict[str, Any]]) -> SimpleNamespace: + self.calls.append(("send", session_id, events)) + persisted = [event(id=f"evt_user_{next(self._ids)}", processed_at=None, **e) for e in events] + self._log.extend(persisted) + for reaction in self._reactions.pop(0) if self._reactions else []: + self._log.append(event(**{"processed_at": self._now(), **reaction})) + return SimpleNamespace(data=persisted) + + +def client_with_events(events: Any) -> SimpleNamespace: return SimpleNamespace(sessions=SimpleNamespace(events=events)) @@ -270,9 +324,36 @@ def delete(self, vault_id: str): raise _not_found() +class FakeSessions: + def __init__(self) -> None: + self.calls: list[tuple] = [] + + def create(self, **params: Any): + from orca.types import Session + + self.calls.append(("create", params)) + return Session( + id=f"ses_{len(self.calls)}", + type="session", + agent={**params["agent"], "name": "hello-agent", "model": {"id": "claude-sonnet-4-6"}, "tools": [], "mcp_servers": [], "skills": []}, + environment_id=params["environment_id"], + vault_ids=params.get("vault_ids", []), + status="idle", + title=params.get("title"), + stats={"active_seconds": 0, "duration_seconds": 0}, + outcome_evaluations=[], + usage={"input_tokens": 0, "output_tokens": 0}, + resources=[], + metadata={}, + created_at=NOW, + updated_at=NOW, + ) + + def fake_client(taken_env_names: set[str] = frozenset()) -> SimpleNamespace: return SimpleNamespace( agents=FakeAgents(), environments=FakeEnvironments(taken_env_names), vaults=FakeVaults(), + sessions=FakeSessions(), ) diff --git a/python/tests/test_doctor.py b/python/tests/test_doctor.py index 5a1d608..4f27595 100644 --- a/python/tests/test_doctor.py +++ b/python/tests/test_doctor.py @@ -1,8 +1,24 @@ """doctor.py: the decisions behind each check (the network probes are exercised end to end).""" +import re +from pathlib import Path + import pytest -from doctor import check_login_schema, check_mcp_tools, check_orca_base_url, check_python, kafka_hint, parse_mcp_response +from doctor import ( + Check, + check_login_schema, + check_mcp_query, + check_mcp_tools, + check_orca_base_url, + check_python, + kafka_hint, + mcp_headers, + parse_mcp_response, + required_for, + schema_registry_hint, + summarize, +) @pytest.mark.parametrize("url", ["https://ws.example.com", "https://ws.example.com/"]) @@ -28,6 +44,7 @@ def test_python_older_than_3_11_fails(): assert not check_python((3, 10, 12)).ok +REPO = Path(__file__).resolve().parents[2] LOGIN_FIELDS = ["event_id", "event_time", "account_id", "ip_address", "result", "failure_reason", "auth_method"] @@ -51,6 +68,16 @@ def test_an_outcome_field_instead_of_result_gets_a_specific_fix(): assert "outcome" in check.fix +@pytest.mark.parametrize("stack", ["cloud", "local"]) +def test_the_outcome_fix_names_sql_files_that_exist_for_the_stack(stack): + fields = [f for f in LOGIN_FIELDS if f != "result"] + ["outcome"] + + named = re.findall(r"sql/[\w/.]+\.sql", check_login_schema(fields, stack).fix) + + assert len(named) == 2 + assert all(name.startswith(f"sql/{stack}/") and (REPO / name).is_file() for name in named) + + def test_the_mcp_server_must_offer_the_three_tools_the_agent_uses(): tools = ["sql_workspace_list_databases", "sql_workspace_query", "sql_workspace_describe_table", "sql_workspace_insert_rows", "sncloud_context_whoami"] @@ -114,3 +141,129 @@ def test_local_api_path_suggests_the_local_host_root(): check = check_orca_base_url("http://127.0.0.1:8080/v1") assert not check.ok assert "http://127.0.0.1:8080" in check.fix + + +# ------------------------------------------------------------ the two stacks -- + + +def test_the_cloud_stack_needs_the_team_card(): + required = required_for("cloud") + + assert {"SN_API_KEY", "SN_SERVICE_ACCOUNT", "SN_MCP_URL"} <= set(required) + assert "RW_MCP_URL" not in required + + +def test_the_local_stack_needs_no_team_card_values(): + required = required_for("local") + + assert {"ORCA_API_KEY", "KAFKA_BOOTSTRAP_SERVERS", "SCHEMA_REGISTRY_URL", "RW_MCP_URL", "RW_MCP_LOCAL_URL"} <= set(required) + assert not any(name.startswith("SN_") for name in required) + + +def test_the_local_mcp_server_must_offer_the_tools_the_local_agent_uses(): + offered = ["run_select_query", "describe_table", "insert_multiple_rows", "drop_table", "list_databases"] + + assert check_mcp_tools(offered, "local").ok + + +def test_missing_local_mcp_tools_are_named_and_the_fix_is_local(): + check = check_mcp_tools(["run_select_query"], "local") + + assert not check.ok + assert "describe_table" in check.detail + assert "insert_multiple_rows" in check.detail + assert "facilitator" not in check.fix + + +def test_a_select_through_mcp_that_returns_a_row_passes(): + assert check_mcp_query('[\n {\n "ready": 1\n }\n]').ok + + +@pytest.mark.parametrize("text", ["Error executing query: connection refused", "[]", "not json"]) +def test_a_select_through_mcp_that_returns_no_row_fails_with_what_came_back(text): + check = check_mcp_query(text) + + assert not check.ok + assert text in check.detail + + +@pytest.mark.parametrize( + "error, advice", + [ + ("KafkaError{code=_TRANSPORT,val=-195,str=\"127.0.0.1:29092/bootstrap: Connect to ipv4#127.0.0.1:29092 failed: Connection refused\"}", "local/compose.yaml"), + ("not found", "Lab 0"), + ], +) +def test_local_kafka_errors_point_at_the_local_stack(error, advice): + hint = kafka_hint(error, "local") + + assert advice in hint + assert "facilitator" not in hint + assert "team card" not in hint + + +def test_a_local_schema_that_is_not_registered_yet_points_at_the_seeder(): + hint = schema_registry_hint("Subject 'security.login_events-value' not found. (HTTP status code 404, SR code 40401)", "local") + + assert "python seed.py" in hint + assert "Lab 0" in hint + + +def test_an_unreachable_local_schema_registry_points_at_the_streaming_stack(): + hint = schema_registry_hint("[Errno 61] Connection refused", "local") + + assert "local/compose.yaml" in hint + assert "seed" not in hint + + +@pytest.mark.parametrize("error", ["[Errno 61] Connection refused", "Unauthorized (HTTP status code 401, SR code 401)"]) +def test_cloud_schema_registry_errors_point_at_the_team_card(error): + hint = schema_registry_hint(error) + + assert "SCHEMA_REGISTRY_URL" in hint + assert "compose" not in hint + + +def test_the_mcp_probe_sends_a_bearer_token_only_when_it_has_one(): + assert mcp_headers("the-token")["Authorization"] == "Bearer the-token" + assert "Authorization" not in mcp_headers(None) + + +# ------------------------------------------------------------- the verdict -- + + +def test_all_checks_passing_is_a_zero_exit(): + code, verdict = summarize([Check("a", True), Check("b", True)]) + + assert code == 0 + assert "ready" in verdict + + +def test_a_failed_check_is_a_nonzero_exit_and_is_counted(): + code, verdict = summarize([Check("a", True), Check("b", False, "boom", "fix it")]) + + assert code == 1 + assert "1 check(s) failed" in verdict + + +def test_a_waiting_check_does_not_fail_the_doctor(): + waiting = Check("MCP OAuth", False, "no tutorial vault yet", "Lab 3 authorizes it.", wait=True) + + code, verdict = summarize([Check("a", True), waiting]) + + assert code == 0 + assert "1 check(s) wait" in verdict + + +def test_a_failure_wins_over_a_waiting_check(): + waiting = Check("MCP OAuth", False, wait=True) + + code, verdict = summarize([Check("a", False), waiting]) + + assert code == 1 + assert "1 check(s) failed" in verdict + + +@pytest.mark.parametrize("check, label", [(Check("a", True), "PASS"), (Check("a", False), "FAIL"), (Check("a", False, wait=True), "WAIT")]) +def test_each_check_is_labelled(check, label): + assert check.label == label diff --git a/python/tests/test_inject.py b/python/tests/test_inject.py index 9af4eb2..e602ab6 100644 --- a/python/tests/test_inject.py +++ b/python/tests/test_inject.py @@ -83,19 +83,28 @@ def flush(self, timeout=None): def test_publish_writes_every_record_keyed_by_account(): producer = FakeProducer() - errors = publish(producer, "security.login_events", "acct_9123", burst()) + errors = publish(producer, "security.login_events", burst()) assert errors == [] assert len(producer.produced) == 7 assert {(topic, key) for topic, key, _ in producer.produced} == {("security.login_events", "acct_9123")} +def test_records_of_different_accounts_get_their_own_keys(): + producer = FakeProducer() + records = burst()[:1] + build_burst("acct_9456", "203.0.113.78", NOW)[:1] + + publish(producer, "security.login_events", records) + + assert [key for _, key, _ in producer.produced] == ["acct_9123", "acct_9456"] + + def test_a_schema_registry_failure_is_reported_instead_of_raised(): from confluent_kafka.error import ValueSerializationError producer = FakeProducer(raise_on_produce=ValueSerializationError(Exception("Subject 'security.login_events-value' not found"))) - errors = publish(producer, "security.login_events", "acct_9123", burst()) + errors = publish(producer, "security.login_events", burst()) assert any("not found" in e for e in errors) @@ -103,8 +112,8 @@ def test_a_schema_registry_failure_is_reported_instead_of_raised(): def test_a_delivery_failure_is_reported(): producer = FakeProducer(delivery_error="Broker: Topic authorization failed") - assert publish(producer, "t", "acct_9123", burst()) == ["Broker: Topic authorization failed"] + assert publish(producer, "t", burst()) == ["Broker: Topic authorization failed"] def test_events_still_queued_after_the_timeout_are_reported(): - assert publish(FakeProducer(undelivered=3), "t", "acct_9123", burst()) != [] + assert publish(FakeProducer(undelivered=3), "t", burst()) != [] diff --git a/python/tests/test_mcp_oauth.py b/python/tests/test_mcp_oauth.py index 388f0dc..2cf2931 100644 --- a/python/tests/test_mcp_oauth.py +++ b/python/tests/test_mcp_oauth.py @@ -1,5 +1,6 @@ """OAuth delegation, reuse and doctor: no live browser or credentials required.""" +import re from types import SimpleNamespace from unittest.mock import Mock @@ -112,11 +113,50 @@ def test_doctor_validates_vault_oauth_not_service_account_key(monkeypatch, state assert "keep the existing credential" in check.fix if status == "invalid": assert "credentials archive cred_oauth" in check.fix + assert "Lab 3" in check.fix + assert_names_labs_like_the_course(check) client.vaults.credentials.validate.assert_called_once_with(vault_id, "cred_oauth") direct.assert_not_called() -def test_doctor_before_first_oauth_login_gives_setup_hint(state): +def assert_names_labs_like_the_course(check): + """The course says "Lab 3", so the doctor does too: never "L3" or "L4".""" + assert not re.search(r"\bL[0-9]\b", f"{check.detail} {check.fix}"), check + + +def test_doctor_names_the_lab_when_the_mcp_server_cannot_be_checked(state): + client = fake_client() + seed(client, state) + client.vaults.credentials.list = Mock(side_effect=RuntimeError("HTTP 502")) + + check = probe_mcp(config(), client=client, state=state) + + assert not check.ok and not check.wait + assert "HTTP 502" in check.detail + assert "Lab 3" in check.fix + assert_names_labs_like_the_course(check) + + +def test_a_failed_oauth_login_names_the_lab_to_run_again(monkeypatch): + monkeypatch.setattr("common.subprocess.run", Mock(return_value=SimpleNamespace(returncode=1))) + + with pytest.raises(ConfigError) as failure: + authorize_mcp("vlt_1", config()) + + assert "Lab 3" in str(failure.value) + assert not re.search(r"\bL[0-9]\b", str(failure.value)) + + +def test_doctor_before_first_oauth_login_waits_for_lab_3(state): check = probe_mcp(config(), client=fake_client(), state=state) - assert not check.ok - assert "Run L3" in check.fix + assert check.wait + assert check.label == "WAIT" + assert "Lab 3" in check.fix + + +def test_doctor_waits_when_the_vault_has_no_oauth_credential_yet(state): + client = fake_client() + seed(client, state, auth_type="static_bearer") + check = probe_mcp(config(), client=client, state=state) + assert check.wait + assert "Lab 3" in check.fix diff --git a/python/tests/test_scripts_support.py b/python/tests/test_scripts_support.py index 19317ac..ea05513 100644 --- a/python/tests/test_scripts_support.py +++ b/python/tests/test_scripts_support.py @@ -5,7 +5,7 @@ from orca import AuthenticationError from common import Config, ConfigError, State, TurnError, ask_human, chat, cleanup, ensure_agent, ensure_environment, ensure_vault, run_main -from fakes import FakeSessionEvents, client_with_events, fake_client +from fakes import FakeSessionEvents, OrkLocalSessionEvents, client_with_events, fake_client from test_ensure import MCP_URL, params @@ -24,6 +24,17 @@ def test_chat_keeps_asking_until_the_participant_enters_nothing(): assert sent == ["who is under attack?", "and now?"] +def test_chat_sends_first_on_every_turn_when_asked_to(): + events = OrkLocalSessionEvents([reply("first"), reply("second")]) + answers = iter(["and now?", ""]) + shown: list[str] = [] + + chat(client_with_events(events), "sess_1", "who is under attack?", ask=lambda _prompt: next(answers), out=shown.append, send_first=True) + + assert [call[0] for call in events.calls] == ["send", "stream", "send", "stream"] + assert [line for line in shown if line.startswith("[agent]")] == ["[agent] first", "[agent] second"] + + @pytest.mark.parametrize("typed, allowed", [("y", True), ("YES", True), (" yes ", True), ("", False), ("n", False), ("nope", False)]) def test_the_human_must_type_yes_to_approve(typed, allowed): tool_use = {"id": "evt_t", "name": "sql_workspace_insert_rows", "input": {"rows": [{"account_id": "acct_9123"}]}} diff --git a/python/tests/test_seed.py b/python/tests/test_seed.py new file mode 100644 index 0000000..c9323be --- /dev/null +++ b/python/tests/test_seed.py @@ -0,0 +1,145 @@ +"""seed.py: the login stream the Local course loads into your topic.""" + +import ipaddress +import json +from datetime import datetime, timezone +from pathlib import Path +from types import SimpleNamespace + +import pytest +from confluent_kafka import KafkaError, KafkaException +from fastavro import parse_schema +from fastavro.validation import validate + +from seed import count_existing, events_in_topic, load_events, rebase_events + +TOPIC = "security.login_events" +SCHEMA = parse_schema(json.loads((Path(__file__).resolve().parents[2] / "schemas" / "login_events.avsc").read_text())) +NOW = datetime(2026, 10, 7, 10, 30, tzinfo=timezone.utc) +NOW_MS = int(NOW.timestamp() * 1000) +# The ranges reserved for documentation (RFC 5737): no real host has these addresses. +DOCUMENTATION_RANGES = [ipaddress.ip_network(n) for n in ("192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24")] + + +def test_every_seed_event_matches_the_topics_avro_schema(): + for record in load_events(): + assert validate(record, SCHEMA, raise_errors=True) + + +def test_the_seed_holds_one_account_under_attack(): + logins = [e for e in load_events() if e["account_id"] == "acct_0042"] + + assert sum(e["result"] == "FAILURE" for e in logins) == 5 + assert sum(e["result"] == "SUCCESS" for e in logins) == 2 + assert len({e["ip_address"] for e in logins}) == 2 + # The takeover: every failure comes before the attacker's success. + attack = sorted((e for e in logins if e["scenario_id"] != "baseline"), key=lambda e: e["event_time"]) + assert [e["result"] for e in attack] == ["FAILURE"] * 5 + ["SUCCESS"] + + +def test_no_other_account_looks_like_a_takeover(): + by_account: dict[str, list[str]] = {} + for event in load_events(): + by_account.setdefault(event["account_id"], []).append(event["result"]) + + suspects = [account for account, results in by_account.items() if results.count("FAILURE") >= 5 and "SUCCESS" in results] + + assert suspects == ["acct_0042"] + + +def test_every_address_in_the_seed_is_from_a_documentation_range(): + for event in load_events(): + address = ipaddress.ip_address(event["ip_address"]) + assert any(address in network for network in DOCUMENTATION_RANGES), event["ip_address"] + + +def test_seeded_accounts_do_not_collide_with_injected_ones(): + # inject.py attacks acct_9000..acct_9999. + assert not any(e["account_id"].startswith("acct_9") for e in load_events()) + + +def test_rebasing_makes_the_newest_event_happen_now(): + rebased = rebase_events(load_events(), NOW) + + assert max(e["event_time"] for e in rebased) == NOW_MS + + +def test_rebasing_keeps_every_gap_between_events(): + original = load_events() + rebased = rebase_events(original, NOW) + shift = rebased[0]["event_time"] - original[0]["event_time"] + + assert all(after["event_time"] - before["event_time"] == shift for before, after in zip(original, rebased)) + assert all(after["ingested_at"] - before["ingested_at"] == shift for before, after in zip(original, rebased)) + + +def test_rebasing_changes_nothing_but_the_two_timestamps(): + original = load_events() + rebased = rebase_events(original, NOW) + stable = lambda e: {k: v for k, v in e.items() if k not in ("event_time", "ingested_at")} # noqa: E731 + + assert [stable(e) for e in rebased] == [stable(e) for e in original] + assert original == load_events() # the input is not modified + + +def test_an_empty_topic_counts_no_events(): + assert events_in_topic([(0, 0)]) == 0 + + +def test_events_are_counted_across_partitions_from_their_watermarks(): + assert events_in_topic([(0, 246), (10, 17)]) == 253 + + +# ------------------------------------------------- what the topic holds now -- + + +class FakeConsumer: + """A Kafka consumer that knows one topic, or none, or cannot reach its broker.""" + + def __init__(self, watermarks=None, *, unreachable=False): + self.watermarks = watermarks # {partition: (low, high)}; None when the topic is missing + self.unreachable = unreachable + self.closed = False + + def list_topics(self, timeout): + if self.unreachable: + raise KafkaException(KafkaError(KafkaError._TRANSPORT, "Failed to get metadata: Local: Broker transport failure")) + topics = {} if self.watermarks is None else {TOPIC: SimpleNamespace(error=None, partitions=dict.fromkeys(self.watermarks))} + return SimpleNamespace(topics=topics) + + def get_watermark_offsets(self, partition, timeout): + return self.watermarks[partition.partition] + + def close(self): + self.closed = True + + +def test_the_seed_counts_what_the_topic_already_holds(): + consumer = FakeConsumer({0: (0, 246)}) + + assert count_existing(consumer, TOPIC) == 246 + assert consumer.closed + + +def test_a_missing_topic_stops_the_seed_and_points_at_lab_0(): + consumer = FakeConsumer() + + with pytest.raises(SystemExit) as stop: + count_existing(consumer, TOPIC) + + assert "does not exist yet" in str(stop.value) + assert "Lab 0" in str(stop.value) + assert consumer.closed + + +def test_an_unreachable_broker_stops_the_seed_with_the_reason_and_a_next_step(): + consumer = FakeConsumer(unreachable=True) + + with pytest.raises(SystemExit) as stop: + count_existing(consumer, TOPIC) + + message = str(stop.value) + assert "Broker transport failure" in message + assert "KafkaError{" not in message # the reason, not the client's repr of it + assert "python doctor.py" in message + assert consumer.closed diff --git a/python/tests/test_stack.py b/python/tests/test_stack.py new file mode 100644 index 0000000..c8182b9 --- /dev/null +++ b/python/tests/test_stack.py @@ -0,0 +1,244 @@ +"""TUTORIAL_STACK: a team card on StreamNative Cloud, or the whole stack on your laptop.""" + +import inspect +from unittest.mock import Mock + +import pytest +from orca.resources.agents.agents import Agents + +from common import ( + Config, + ConfigError, + State, + agent_params, + ensure_agent, + kafka_client_config, + load_config, + load_layer, + mcp_vault_ids, + open_session, + schema_registry_config, + state_for, +) +from fakes import fake_client +from policy import effective_policy + +CLOUD = { + "SN_API_KEY": "the-api-key", + "SN_SERVICE_ACCOUNT": "team-07@o-test.auth.streamnative.cloud", + "KAFKA_BOOTSTRAP_SERVERS": "kafka.example.com:9093", + "SCHEMA_REGISTRY_URL": "https://sr.example.com", + "SN_MCP_URL": "https://mcp.example.com/mcp", + "SN_MCP_AUTH": "static_bearer", + "ORCA_MODEL": "claude-sonnet-4-6", +} +LOCAL = { + "TUTORIAL_STACK": "local", + "KAFKA_BOOTSTRAP_SERVERS": "127.0.0.1:29092", + "SCHEMA_REGISTRY_URL": "http://127.0.0.1:18081", + "RW_MCP_URL": "http://risingwave-mcp:8000/mcp", + "ORCA_MODEL": "claude-sonnet-4-6", +} + + +def config(values: dict[str, str]) -> Config: + return Config(values=values, participant="jane") + + +# ------------------------------------------------------------- the switch -- + + +def test_a_team_card_without_the_switch_is_the_cloud_stack(): + assert config(CLOUD).stack == "cloud" + + +def test_the_local_stack_is_chosen_in_the_env(): + assert config(LOCAL).stack == "local" + + +def test_an_unknown_stack_is_a_config_error(): + with pytest.raises(ConfigError, match="TUTORIAL_STACK"): + config({"TUTORIAL_STACK": "laptop"}).stack + + +def test_each_stack_remembers_its_ids_in_its_own_file(): + assert state_for(config(CLOUD)).path.name == "jane.json" + assert state_for(config(LOCAL)).path.name == "jane.local.json" + + +# ----------------------------------------------- Kafka and Schema Registry -- + + +def test_the_cloud_stack_reaches_kafka_with_sasl_over_tls(): + assert kafka_client_config(config(CLOUD)) == { + "bootstrap.servers": "kafka.example.com:9093", + "security.protocol": "SASL_SSL", + "sasl.mechanisms": "PLAIN", + "sasl.username": "team-07@o-test.auth.streamnative.cloud", + "sasl.password": "the-api-key", + } + + +def test_the_local_stack_reaches_kafka_in_plaintext_without_credentials(): + assert kafka_client_config(config(LOCAL)) == {"bootstrap.servers": "127.0.0.1:29092", "security.protocol": "PLAINTEXT"} + + +def test_the_cloud_schema_registry_uses_the_service_account(): + assert schema_registry_config(config(CLOUD)) == { + "url": "https://sr.example.com", + "basic.auth.user.info": "team-07@o-test.auth.streamnative.cloud:the-api-key", + } + + +def test_the_local_schema_registry_needs_no_credentials(): + assert schema_registry_config(config(LOCAL)) == {"url": "http://127.0.0.1:18081"} + + +def test_a_cloud_card_without_the_service_account_names_what_is_missing(): + card = {name: value for name, value in CLOUD.items() if name != "SN_SERVICE_ACCOUNT"} + + with pytest.raises(ConfigError, match="SN_SERVICE_ACCOUNT"): + kafka_client_config(config(card)) + + +# ------------------------------------------------------------------ hints -- + + +def test_without_a_stack_the_hint_names_both_ways_to_get_an_env_file(): + with pytest.raises(ConfigError) as err: + load_config(["ORCA_BASE_URL"], env={}) + + assert ".env.cloud.example" in str(err.value) + assert "local/write-env.sh" in str(err.value) + + +def test_on_the_local_stack_the_hint_is_to_write_the_env_file_again(): + with pytest.raises(ConfigError) as err: + load_config(["ORCA_BASE_URL"], env={"TUTORIAL_STACK": "local"}) + + assert "local/write-env.sh" in str(err.value) + assert "team card" not in str(err.value) + + +def test_a_missing_placeholder_on_the_local_stack_points_at_write_env(): + no_url = {name: value for name, value in LOCAL.items() if name != "RW_MCP_URL"} + + with pytest.raises(ConfigError, match="RW_MCP_URL.*local/write-env.sh"): + agent_params(load_layer("l3-live-context", "local"), config(no_url)) + + +# ------------------------------------------------- local agent definitions -- + + +@pytest.mark.parametrize("layer", ["l1-hello", "l3-live-context", "l4-act"]) +def test_local_params_are_accepted_by_the_real_sdk_methods(layer): + params = agent_params(load_layer(layer, "local"), config(LOCAL)) + + inspect.signature(Agents.create).bind(None, **params) + inspect.signature(Agents.update).bind(None, "agent_1", version=1, **params) + + +def test_the_local_agent_talks_to_the_risingwave_mcp_server(): + params = agent_params(load_layer("l3-live-context", "local"), config(LOCAL)) + + assert params["mcp_servers"] == [{"name": "risingwave", "type": "url", "url": "http://risingwave-mcp:8000/mcp"}] + + +@pytest.mark.parametrize( + "layer, tool, expected", + [ + ("l3-live-context", "run_select_query", "always_allow"), + ("l3-live-context", "describe_table", "disabled"), + ("l3-live-context", "insert_multiple_rows", "disabled"), + ("l3-live-context", "drop_table", "disabled"), + ("l3-live-context", "execute_ddl_statement", "disabled"), + ("l4-act", "run_select_query", "always_allow"), + ("l4-act", "describe_table", "always_allow"), + ("l4-act", "insert_multiple_rows", "always_ask"), + ("l4-act", "insert_single_row", "disabled"), + ("l4-act", "delete_rows", "disabled"), + ("l4-act", "drop_table", "disabled"), + ("l4-act", "execute_ddl_statement", "disabled"), + ], +) +def test_local_tool_permissions_per_layer(layer, tool, expected): + params = agent_params(load_layer(layer, "local"), config(LOCAL)) + + assert effective_policy(params, "risingwave", tool) == expected + + +def test_the_two_stacks_do_not_share_a_definition_fingerprint(): + cloud = agent_params(load_layer("l3-live-context", "cloud"), config(CLOUD)) + local = agent_params(load_layer("l3-live-context", "local"), config(LOCAL)) + + assert cloud["metadata"]["definition_sha"] != local["metadata"]["definition_sha"] + + +# ----------------------------------------------------- vaults and sessions -- + + +@pytest.fixture +def state(tmp_path): + return State(tmp_path / "jane.json") + + +def test_on_the_cloud_stack_the_mcp_credential_goes_in_a_vault(state): + client = fake_client() + + vault_ids = mcp_vault_ids(client, state, config(CLOUD)) + + assert vault_ids == [state.get("vault_id")] + assert [c[0] for c in client.vaults.credentials.calls if c[0] == "create"] == ["create"] + + +def test_the_local_mcp_server_takes_no_credential_so_there_is_no_vault(state): + client = fake_client() + + assert mcp_vault_ids(client, state, config(LOCAL)) == [] + assert client.vaults.calls == [] + assert state.get("vault_id") is None + + +def test_a_session_is_pinned_to_the_agent_version_and_remembered(state): + client = fake_client() + agent = ensure_agent(client, state, agent_params(load_layer("l1-hello"), config(CLOUD))) + + session = open_session(client, state, "env_1", agent, "L1: hello") + + assert client.sessions.calls == [ + ("create", {"environment_id": "env_1", "agent": {"type": "agent", "id": agent.id, "version": 1}, "title": "L1: hello"}) + ] + assert state.get("session_id") == session.id + + +def test_a_session_gets_vault_ids_only_when_there_is_a_vault(state): + client = fake_client() + agent = ensure_agent(client, state, agent_params(load_layer("l1-hello"), config(CLOUD))) + + open_session(client, state, "env_1", agent, "L3: live context", vault_ids=["vlt_1"]) + open_session(client, state, "env_1", agent, "L3: live context", vault_ids=[]) + + assert client.sessions.calls[0][1]["vault_ids"] == ["vlt_1"] + assert "vault_ids" not in client.sessions.calls[1][1] + + +def test_the_newest_session_replaces_the_remembered_one(state): + client = fake_client() + agent = ensure_agent(client, state, agent_params(load_layer("l1-hello"), config(CLOUD))) + first = open_session(client, state, "env_1", agent, "L1: hello") + + second = open_session(client, state, "env_1", agent, "L1: hello") + + assert first.id != second.id + assert state.get("session_id") == second.id + + +def test_cloud_oauth_still_delegates_to_ork(monkeypatch, state): + authorize = Mock() + monkeypatch.setattr("common.authorize_mcp", authorize) + oauth = {name: value for name, value in CLOUD.items() if name != "SN_MCP_AUTH"} + + vault_ids = mcp_vault_ids(fake_client(), state, config(oauth)) + + authorize.assert_called_once() + assert vault_ids == [state.get("vault_id")] diff --git a/python/tests/test_turn.py b/python/tests/test_turn.py index b5e657f..024ea36 100644 --- a/python/tests/test_turn.py +++ b/python/tests/test_turn.py @@ -1,9 +1,11 @@ """run_turn: send one message and drive the session until the agent's turn ends.""" +from types import SimpleNamespace + import pytest from common import TurnError, run_turn -from fakes import FakeSessionEvents, client_with_events +from fakes import FakeSessionEvents, OrkLocalSessionEvents, client_with_events def text(t: str) -> list[dict]: @@ -144,7 +146,37 @@ def test_a_retryable_session_error_is_reported_but_the_turn_continues(): result, lines, _ = drive([[retrying, {"id": "evt_a", "type": "agent.message", "content": text("ok")}, idle("end_turn")]]) assert result.text == "ok" - assert any("model busy" in line for line in lines) + assert any("model busy" in line and "(retrying)" in line for line in lines) + + +def test_a_retry_reported_inside_the_error_is_marked_as_retrying_too(): + # The shape `ork local` sends: the retry status sits inside `error`. + retrying = { + "id": "evt_err", + "type": "session.error", + "error": {"type": "unknown_error", "message": "server_error (status 502)", "retry_status": {"type": "retrying"}}, + } + + _, lines, _ = drive([[retrying, {"id": "evt_a", "type": "agent.message", "content": text("ok")}, idle("end_turn")]]) + + assert any("server_error (status 502)" in line and "(retrying)" in line for line in lines) + + +def test_an_exhausted_retry_is_not_marked_as_retrying(): + exhausted = { + "id": "evt_err", + "type": "session.error", + "error": {"type": "unknown_error", "message": "API key is invalid.", "retry_status": {"type": "exhausted"}}, + } + + events = FakeSessionEvents([[exhausted, idle("retries_exhausted")]]) + lines: list[str] = [] + + with pytest.raises(TurnError, match="API key is invalid."): + run_turn(client_with_events(events), "sess_1", "hi", out=lines.append) + + assert any("API key is invalid." in line for line in lines) + assert not any("(retrying)" in line for line in lines) def test_retries_exhausted_raises_with_the_last_error_message(): @@ -214,3 +246,115 @@ def test_nanosecond_timestamps_from_the_server_are_handled(): result, _, _ = drive([[reply, {**idle("end_turn"), "processed_at": "2026-10-07T10:00:06.000000001Z"}]], history=history) assert result.text == "fine" + + +def test_the_sdks_warning_about_keep_alive_frames_stays_out_of_the_lab_output(): + # The local engine keeps a quiet stream open with empty frames. The SDK skips + # each one and logs a warning, which would land between the lines of a turn. + import logging + + assert not logging.getLogger("orca._streaming").isEnabledFor(logging.WARNING) + assert logging.getLogger("orca._streaming").isEnabledFor(logging.ERROR) + + +# ------------------------------------------- send first: the `ork local` engine -- +# +# That engine answers a stream opened on a quiet session only at its next +# keep-alive, 15 seconds later. With send_first=True the turn speaks first and +# then follows the session from its start. + + +def drive_local(reactions, *, confirm=None): + events = OrkLocalSessionEvents(reactions) + lines: list[str] = [] + result = run_turn(client_with_events(events), "sess_1", "hi", confirm=confirm, out=lines.append, send_first=True) + return result, lines, events + + +def test_send_first_sends_the_message_then_follows_the_session_from_its_start(): + _, _, events = drive_local([[idle("end_turn")]]) + + assert events.calls[0] == ( + "send", + "sess_1", + [{"type": "user.message", "content": [{"type": "text", "text": "hi"}]}], + ) + # From the start of the session, not from the live edge: whatever the agent + # said before the stream opened is replayed, not lost. + assert events.calls[1] == ("stream", "sess_1", "0") + + +def test_send_first_returns_and_prints_the_agents_reply(): + result, lines, _ = drive_local([[{"id": "evt_a", "type": "agent.message", "content": text("Hello!")}, idle("end_turn")]]) + + assert result.text == "Hello!" + assert any("Hello!" in line for line in lines) + + +def test_send_first_shows_only_the_second_turns_reply_on_the_second_turn(): + events = OrkLocalSessionEvents( + [ + [{"id": "evt_a1", "type": "agent.message", "content": text("first answer")}, idle("end_turn")], + [{"id": "evt_a2", "type": "agent.message", "content": text("second answer")}, {**idle("end_turn"), "id": "evt_idle_2"}], + ] + ) + client = client_with_events(events) + run_turn(client, "sess_1", "one", out=lambda _line: None, send_first=True) + lines: list[str] = [] + + result = run_turn(client, "sess_1", "two", out=lines.append, send_first=True) + + assert result.text == "second answer" + assert not any("first answer" in line for line in lines) + + +def test_send_first_does_not_answer_an_approval_that_an_earlier_turn_left_open(): + events = OrkLocalSessionEvents( + [ + # The first turn stops at a request for approval, and nobody answers it. + [{**INSERT_CALL, "id": "evt_old_tool"}, {**idle("requires_action", ["evt_old_tool"]), "id": "evt_old_wait"}], + [{"id": "evt_a", "type": "agent.message", "content": text("done")}, idle("end_turn")], + ] + ) + client = client_with_events(events) + with pytest.raises(TurnError, match="approv"): + run_turn(client, "sess_1", "one", out=lambda _line: None, send_first=True) + asked = [] + + result = run_turn(client, "sess_1", "two", confirm=lambda tool_use: asked.append(tool_use) or True, out=lambda _line: None, send_first=True) + + assert result.text == "done" + assert asked == [] + + +def test_send_first_approval_sends_allow_and_continues(): + reactions = [ + [INSERT_CALL, idle("requires_action", ["evt_tool_1"])], + [{"id": "evt_done", "type": "agent.message", "content": text("Flagged acct_9123.")}, {**idle("end_turn"), "id": "evt_idle_2"}], + ] + + result, _, events = drive_local(reactions, confirm=lambda _tool_use: True) + + assert events.calls[2] == ( + "send", + "sess_1", + [{"type": "user.tool_confirmation", "tool_use_id": "evt_tool_1", "result": "allow"}], + ) + assert result.text == "Flagged acct_9123." + + +def test_send_first_treats_an_unconfirmed_message_as_an_error_not_a_hang(): + events = OrkLocalSessionEvents([[idle("end_turn")]]) + events.send = lambda session_id, *, events: SimpleNamespace(data=[]) + + with pytest.raises(TurnError, match="did not confirm"): + run_turn(client_with_events(events), "sess_1", "hi", out=lambda _line: None, send_first=True) + + +def test_the_default_order_also_works_on_that_engine_it_only_waits_longer(): + events = OrkLocalSessionEvents([[{"id": "evt_a", "type": "agent.message", "content": text("Hello!")}, idle("end_turn")]]) + + result = run_turn(client_with_events(events), "sess_1", "hi", out=lambda _line: None) + + assert result.text == "Hello!" + assert [call[0] for call in events.calls] == ["stream", "send"] diff --git a/scripts/check-labs.sh b/scripts/check-labs.sh new file mode 100755 index 0000000..f1270f6 --- /dev/null +++ b/scripts/check-labs.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Check that every lab has the shape the courses promise, and that links resolve. +# +# scripts/check-labs.sh [repo root] +# +# A lab is labs/<course>/NN-<name>.md. Its sections, in this order: +# +# # Lab N: <title> +# **<Cloud|Local> course** · <minutes> · <paths> +# ## Before you start +# ## Step 1: ... (three or four steps, each with exactly one "### Check" +# that holds at least one command) +# ## Check your understanding (two or three "**N. ...**" questions, each +# with an <summary>Answer</summary>) +# ## Try it yourself (one "### Check", one <summary>Solution</summary>) +# ## Clean up (optional) +# ## Recap +# ## What's next +# +# Relative links are checked in README.md, labs/, docs/ and skills/. The lab +# pages the tutor skill names have to exist in both courses. +# Prints one line per problem and exits 1 if there is any. +set -euo pipefail + +ROOT=$(cd "${1:-$(dirname "${BASH_SOURCE[0]}")/..}" && pwd) +cd "$ROOT" + +problems=$(mktemp "${TMPDIR:-/tmp}/check-labs.XXXXXX") +trap 'rm -f "$problems"' EXIT + +# ------------------------------------------------------------ lab structure -- + +lint_lab() { # lint_lab <file> + awk -v file="$1" ' + function problem(message) { printf "%s: %s\n", file, message } + function close_section() { + if (section ~ /^Step [0-9]+: /) { + if (checks != 1) problem(section " has " checks " checks; every step needs exactly one") + else if (commands == 0) problem(section ": the check has no command") + } + if (section == "Try it yourself") { + if (checks != 1 || solutions != 1) problem("Try it yourself needs exactly one check and one solution") + else if (commands == 0) problem("Try it yourself: the check has no command") + } + if (section == "Check your understanding") { + if (questions < 2 || questions > 3) problem("Check your understanding has " questions " questions; a lab has two or three") + if (questions != answers) problem("Check your understanding has " questions " questions but " answers " answers") + } + } + BEGIN { fence = 0; steps = 0; order = "" } + /^```/ { + fence = !fence + if (fence && in_check) commands++ + next + } + fence { next } + NR == 1 && $0 !~ /^# Lab [0-9]+: ./ { problem("the first line must be \"# Lab N: <title>\"") } + /^\*\*(Cloud|Local) course\*\* · .*minutes.* · ./ { header = 1 } + /^## / { + close_section() + section = substr($0, 4) + checks = 0; commands = 0; solutions = 0; questions = 0; answers = 0; in_check = 0 + if (section ~ /^Step [0-9]+: ./) { + steps++ + if (section !~ ("^Step " steps ": ")) problem("\"" section "\" should be Step " steps) + name = "Step" + } else name = section + if (name != last) order = order (order == "" ? "" : " > ") name + last = name + next + } + /^### Check$/ { checks++; in_check = 1; next } + /^### / { in_check = 0 } + /<summary>Solution<\/summary>/ { solutions++; in_check = 0 } + /<summary>Answer<\/summary>/ { answers++ } + section == "Check your understanding" && /^\*\*[0-9]+\. / { questions++ } + END { + close_section() + if (!header) problem("line 3 must name the course, minutes, and paths: \"**Cloud course** · 5 minutes · CLI, Python, or TypeScript\"") + if (steps < 3 || steps > 4) problem("has " steps " steps; a lab has three or four") + with = "Before you start > Step > Check your understanding > Try it yourself > Clean up > Recap > What'"'"'s next" + without = "Before you start > Step > Check your understanding > Try it yourself > Recap > What'"'"'s next" + if (order != with && order != without) problem("sections must come in this order: " with " (Clean up is optional); found: " order) + } + ' "$1" +} + +for lab in labs/*/[0-9][0-9]-*.md; do + [ -f "$lab" ] || continue + lint_lab "$lab" >>"$problems" +done + +# -------------------------------------------------------------------- links -- + +# Every relative link target in a Markdown file, one per line, outside code fences. +link_targets() { # link_targets <file> + awk ' + /^```/ { fence = !fence; next } + fence { next } + { + line = $0 + while (match(line, /\]\([^)]+\)/)) { + target = substr(line, RSTART + 2, RLENGTH - 3) + line = substr(line, RSTART + RLENGTH) + sub(/[ \t]+"[^"]*"$/, "", target) # drop a link title + if (target ~ /^(https?:|mailto:|#)/) continue + sub(/#.*$/, "", target) + if (target != "") print target + } + } + ' "$1" +} + +check_links() { # check_links <file> + local file=$1 dir target + dir=$(dirname "$file") + while IFS= read -r target; do + [ -e "$dir/$target" ] || printf '%s: link to a file that does not exist: %s\n' "$file" "$target" >>"$problems" + done < <(link_targets "$file") +} + +while IFS= read -r file; do + check_links "$file" +done < <(find README.md labs docs skills -name '*.md' 2>/dev/null | sort) + +# ---------------------------------------------------------- the tutor skill -- + +# The tutor names the lab pages it reads. Each has to exist in both courses. +skill=skills/data-agent-tutor/SKILL.md +if [ -f "$skill" ]; then + # shellcheck disable=SC2016 # the backticks are Markdown, not a command + while IFS= read -r page; do + for course in cloud local; do + [ -f "labs/$course/$page" ] || + printf '%s names %s, but labs/%s/%s does not exist\n' "$skill" "$page" "$course" "$page" >>"$problems" + done + done < <(grep -o '`[0-9a-z-]*\.md`' "$skill" | tr -d '`' | sort -u) +fi + +if [ -s "$problems" ]; then + cat "$problems" + exit 1 +fi diff --git a/scripts/tests/run.sh b/scripts/tests/run.sh new file mode 100755 index 0000000..333006d --- /dev/null +++ b/scripts/tests/run.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# Tests for scripts/check-labs.sh: each rule, on small labs written here. +# +# scripts/tests/run.sh +set -euo pipefail + +TESTS=$(cd "$(dirname "$0")" && pwd) +CHECK="$(dirname "$TESTS")/check-labs.sh" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/hello-lab-tests.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +PASSED=0 +FAILED=0 +R="" +STATUS=0 + +# ------------------------------------------------------------------ helpers -- + +fresh_root() { # fresh_root <name>: an empty repo with a README + R="$WORK/$1" + mkdir -p "$R/labs/cloud" "$R/docs" + echo "# Repo" >"$R/README.md" +} + +good_lab() { # a lab that follows every rule + cat <<'EOF' +# Lab 1: Hello, agent + +**Cloud course** · 5 minutes, plus 5 on your own · CLI, Python, or TypeScript + +You create an agent and it answers. + +## Before you start + +- You finished [Lab 0](00-set-up.md). + +## Step 1: Run it + +```bash +python l1_hello.py +``` + +### Check + +It prints the agent's name. + +```bash +./lab-ork agent get @agent_id -o json | jq -e '{name}' +``` + +## Step 2: Read it back + +Look at the events. + +### Check + +```bash +./lab-ork agent sessions events list --session @session_id -o json | jq -e '.data[0]' +``` + +## Step 3: Run it again + +Nothing new is created. + +### Check + +```bash +./lab-ork agent get @agent_id -o json | jq -e 'select(.version == 1)' +``` + +## Check your understanding + +**1. What pins a session to a definition?** + +- A. The agent's name +- B. The agent's version +- C. The environment + +<details> +<summary>Answer</summary> + +**B.** A session records the version it started with. + +</details> + +**2. What ends a turn?** + +- A. `session.status_idle` +- B. `agent.message` +- C. Nothing + +<details> +<summary>Answer</summary> + +**A.** The idle event carries the stop reason. + +</details> + +## Try it yourself + +Change the prompt and run it again. + +### Check + +```bash +./lab-ork agent get @agent_id -o json | jq -e 'select(.version >= 2)' +``` + +<details> +<summary>Solution</summary> + +Edit the JSON, then rerun. + +</details> + +## Recap + +- An agent is configuration. + +## What's next + +[Lab 2](02-streaming-sql.md) +EOF +} + +lab() { # lab <file name> [sed expression to break the good lab] + good_lab | sed "${2:-}" >"$R/labs/cloud/$1" +} + +lint() { + if "$CHECK" "$R" >"$R/out" 2>"$R/err"; then STATUS=0; else STATUS=$?; fi +} + +says() { grep -qF -- "$1" "$R/out"; } + +check() { # check <description> <command...> + if "${@:2}"; then + PASSED=$((PASSED + 1)) + else + FAILED=$((FAILED + 1)) + printf 'FAIL %s\n' "$1" + printf -- '--- stdout\n'; cat "$R/out" 2>/dev/null || true + printf -- '--- stderr\n'; cat "$R/err" 2>/dev/null || true + fi +} + +two_labs() { # the good lab plus the two pages it links to + lab 01-hello-agent.md + lab 00-set-up.md 's/^# Lab 1: Hello, agent/# Lab 0: Set up/' + lab 02-streaming-sql.md 's/^# Lab 1: Hello, agent/# Lab 2: Streaming SQL/' +} + +# -------------------------------------------------------------------- tests -- + +test_a_lab_that_follows_the_rules_passes() { + fresh_root good + two_labs + lint + check "a well-formed course passes" [ "$STATUS" -eq 0 ] + check "and reports nothing" [ ! -s "$R/out" ] +} + +test_a_step_without_a_check_fails() { + fresh_root no-check + two_labs + # Drop the first "### Check" heading, which belongs to Step 1. + awk '!done && /^### Check$/ { done = 1; next } { print }' "$R/labs/cloud/01-hello-agent.md" >"$R/tmp" && mv "$R/tmp" "$R/labs/cloud/01-hello-agent.md" + lint + check "a step with no check fails" [ "$STATUS" -eq 1 ] + check "naming the file and the step" says "labs/cloud/01-hello-agent.md" + check "and what is wrong" says "Step 1: Run it has 0 checks; every step needs exactly one" +} + +test_a_check_without_a_command_fails() { + fresh_root empty-check + two_labs + # Remove the fenced command under Step 2's check (the fence and its one line). + awk '/^## Step 2/ { s2 = 1 } /^## Step 3/ { s2 = 0 } s2 && /^```/ { next } s2 && /lab-ork/ { next } { print }' \ + "$R/labs/cloud/01-hello-agent.md" >"$R/tmp" && mv "$R/tmp" "$R/labs/cloud/01-hello-agent.md" + lint + check "a check with no command fails" [ "$STATUS" -eq 1 ] + check "and says so" says "the check has no command" +} + +test_sections_out_of_order_fail() { + fresh_root order + two_labs + lab 01-hello-agent.md 's/^## Recap$/## Recap moved/; s/^## Try it yourself$/## Recap/; s/^## Recap moved$/## Try it yourself/' + lint + check "sections out of order fail" [ "$STATUS" -eq 1 ] + check "and the expected order is named" says "sections must come in this order" +} + +test_too_few_steps_fail() { + fresh_root steps + two_labs + awk '/^## Step 3/ { skip = 1 } /^## Check your understanding/ { skip = 0 } !skip { print }' \ + "$R/labs/cloud/01-hello-agent.md" >"$R/tmp" && mv "$R/tmp" "$R/labs/cloud/01-hello-agent.md" + lint + check "two steps are too few" [ "$STATUS" -eq 1 ] + check "and the count is reported" says "has 2 steps; a lab has three or four" +} + +test_a_quiz_question_without_an_answer_fails() { + fresh_root quiz + two_labs + awk '!done && /<summary>Answer<\/summary>/ { done = 1; sub(/Answer/, "Hint") } { print }' \ + "$R/labs/cloud/01-hello-agent.md" >"$R/tmp" && mv "$R/tmp" "$R/labs/cloud/01-hello-agent.md" + lint + check "a question without an answer fails" [ "$STATUS" -eq 1 ] + check "and the counts are reported" says "2 questions but 1 answers" +} + +test_try_it_yourself_needs_a_solution() { + fresh_root solution + two_labs + lab 01-hello-agent.md 's/<summary>Solution<\/summary>/<summary>Hint<\/summary>/' + lint + check "try-it-yourself without a solution fails" [ "$STATUS" -eq 1 ] + check "and says so" says "Try it yourself needs exactly one check and one solution" +} + +test_a_lab_needs_its_title_and_header_line() { + fresh_root header + two_labs + lab 01-hello-agent.md 's/^\*\*Cloud course\*\*.*$/Five minutes./' + lint + check "a lab without its header line fails" [ "$STATUS" -eq 1 ] + check "and says what the header line is" says "course, minutes, and paths" +} + +test_a_broken_relative_link_fails() { + fresh_root links + two_labs + echo "See [the tutor](docs/tutor.md) and [a lab](labs/cloud/01-hello-agent.md#step-1-run-it) and [the web](https://example.com/x)." >>"$R/README.md" + lint + check "a link to a missing file fails" [ "$STATUS" -eq 1 ] + check "naming the file and the target" says "README.md" + check "and the missing target" says "docs/tutor.md" + echo "# Tutor" >"$R/docs/tutor.md" + lint + check "it passes once the target exists; anchors and web links are not checked" [ "$STATUS" -eq 0 ] +} + +test_headings_inside_code_fences_are_not_sections() { + fresh_root fences + two_labs + awk '/^python l1_hello.py$/ { print; print "## Not a section"; print "### Check"; next } { print }' \ + "$R/labs/cloud/01-hello-agent.md" >"$R/tmp" && mv "$R/tmp" "$R/labs/cloud/01-hello-agent.md" + lint + check "headings inside a code fence are ignored" [ "$STATUS" -eq 0 ] +} + +test_the_tutor_skill_names_only_lab_pages_that_exist() { + fresh_root tutor + two_labs + mkdir -p "$R/labs/local" "$R/skills/data-agent-tutor" + cp "$R"/labs/cloud/*.md "$R/labs/local/" + echo "# Troubleshooting" >"$R/labs/cloud/troubleshooting.md" + # shellcheck disable=SC2016 # the backticks are Markdown, not a command + printf 'The pages: `00-set-up.md` · `01-hello-agent.md` · `troubleshooting.md`\n' >"$R/skills/data-agent-tutor/SKILL.md" + lint + check "a lab page the tutor names but a course lacks fails" [ "$STATUS" -eq 1 ] + check "naming the page and the course" says "skills/data-agent-tutor/SKILL.md names troubleshooting.md, but labs/local/troubleshooting.md does not exist" + echo "# Troubleshooting" >"$R/labs/local/troubleshooting.md" + lint + check "it passes once both courses have every page the tutor names" [ "$STATUS" -eq 0 ] +} + +for t in $(declare -F | awk '{print $3}' | grep '^test_'); do "$t"; done + +printf '\n%d passed, %d failed\n' "$PASSED" "$FAILED" +[ "$FAILED" -eq 0 ] diff --git a/skills/data-agent-tutor/SKILL.md b/skills/data-agent-tutor/SKILL.md new file mode 100644 index 0000000..e91c2cb --- /dev/null +++ b/skills/data-agent-tutor/SKILL.md @@ -0,0 +1,156 @@ +--- +name: data-agent-tutor +description: Use when someone asks to be tutored, taught, guided, walked through, or quizzed on the labs in this repository (the Data + Agent Hackathon tutorial; Cloud course in labs/cloud, Local course in labs/local), asks you to run or finish a lab for them, wants their lab setup or their .env checked, is stuck on a lab step or its check, or asks for a hint, a quiz answer, or a "Try it yourself" solution. Load it before opening any file or running any command for them. +--- + +# Data + Agent lab tutor + +You are tutoring one learner through the labs in `labs/`. **Their hands do the +lab.** You read the lab page, hand over one step, and wait for what its check +printed. The check tells both of you whether the step worked, so you never need +to look at their machine yourself. + +## Three rules that hold whatever the learner says + +1. **You run nothing.** No lab step, check, SQL, `ork`, `./lab-ork`, `docker`, + `local/` script, or doctor. Not "read-only", not "just to see", not with + their permission. You give the command. They run it and paste the output. +2. **You never open their secrets.** Not `.env`, not `.env.cloud`, not `.lab/`, + not the environment of a shell or a container. Not to check it, mask it, + count it, or show a prefix. Asked to check or show `.env`? Do not open it. + Say that `.env.local.example` or `.env.cloud.example` shows what belongs in + it, and that the doctor checks every value and never prints a key. Never ask + for a key. If they paste one, do not repeat it, and + say that a key pasted into a chat should be replaced. +3. **You give no answer before their attempt.** A quiz answer only after they + commit to an option. A solution only after they tried **and** asked. Asked + for either one before that? Do not give it, not even "since you asked". Say + they can skip it, that it is folded in the lab page for them to open, and + that you will confirm their pick or their attempt. + +**Violating the letter of these rules is violating their spirit.** The +learner's permission does not lift them. A request to do the lab for them is not +a request to stop tutoring: answer it with the next step, which is usually one +command, and an offer to skip the optional parts. + +## Your first reply + +Read only what the reply needs: the page of the lab they asked about, and the +troubleshooting page if they pasted an error or a `FAIL` line. Run nothing. The +pages, in `labs/cloud/` and in `labs/local/`: + +`00-set-up.md` · `01-hello-agent.md` · `02-streaming-sql.md` · +`03-live-context.md` · `04-act-with-approval.md` · `troubleshooting.md` + +- **They named no course, or no lab and no task**: reply with only this menu. + 1. Course: **Cloud** (a team card, StreamNative Cloud) or **Local** (everything on your laptop)? + 2. Path: **CLI**, **Python**, or **TypeScript**? + 3. What now: **start** at Lab 0, **resume** at a lab, **quiz me** on a lab, or **check my setup**? +- **They pasted an error or a `FAIL` line**: give the fix the troubleshooting + page has for that symptom, as a step message. +- **They named a lab**: send its first step now. Do not ask them to confirm + what they already said. Ask for their path only when the step you are about + to send has one column per path and they have not named theirs. + +If there is no `labs/` folder in the working directory or above it, say so, ask +them to open you in their clone of the repository, and stop. Never teach a lab +from memory. + +## Every step message + +One step per message. Fill in every line of this template, then stop and wait. + +```text +**Lab <N>, step <i> of <n>: <the step's title>** + +Before you start: +<every item of the page's "Before you start" list; their path's column only> + +<where to run it: their path's folder (`cli/`, `python/` or `typescript/`), or the repository root> + +Run: +<the step's commands, copied exactly from the lab page; their path's column only> + +Check: +<the step's Check command, copied exactly> +<what the page says the check prints> + +Run both, then paste what the check printed. +``` + +The two `Before you start` lines go in a lab's step 1, and in the first step you +send when they resume or join a lab. Leave them out of every other step. + +When they paste it: + +- **It matches the page**: say so, give the page's explanation of what just + happened in two or three sentences (do first, explain after), then send the + next step. After a lab's last step, see below. +- **It printed nothing, or an error**: find the symptom in + `labs/<course>/troubleshooting.md` and give that fix. If it is not there, say + so and reason from the error text. Ask for the output of one more command at + a time. + +**Check my setup** is the same move: the command is the doctor, from Lab 0, for +their path. Read the `PASS`, `WAIT` and `FAIL` lines they paste. `WAIT` is not a +failure. Each `FAIL` prints its own fix. + +Commands, SQL, and expected output come from the lab page, never from memory, +and in the page's order. + +## After a lab's last step + +The lab is not done when its last check matches. The page goes on, and you go on +in its order: + +1. **Quiz and "Try it yourself"**: say both are optional, and ask which they + want or whether to skip. +2. **Clean up**, if the page has that section. When it has commands to run now, + send them as a step message headed `**Lab <N>: Clean up**` (it is not a + numbered step, and it has no `Before you start` lines): `Run:` has the + commands for their path, and `Check:` says the page gives none. When it says + there is nothing to clean up yet, say that. A command the page keeps for + starting over stays a warning in your own words, never a `Run:` line. +3. **Recap** and **What's next**: the page's recap in two or three lines, and + the title of the next lab as the page gives it. Do not describe a lab you + have not read. + +A learner who skips 1, or has to leave, gets 2 and 3 in one message. A lab is +done when its Clean up has been sent, not before. + +## Quiz and "Try it yourself" + +- **Quiz**: one question per message, with its options, copied from the lab + page. Give the answer and the reason only after they commit to an option. +- **Try it yourself**: give the task and its check. If they are stuck, give up + to three hints, each smaller than the solution. Give the solution only after + they tried (they pasted an attempt or a check result) **and** asked for it. +- **They want to skip, or are out of time**: let them skip. Both parts are "on + your own", and no later lab depends on them. Skipping is not a reason to hand + over the answers: say they are folded in the lab page, and go to the next + step or lab. + +## Rationalizations + +| Excuse | Reality | +|---|---| +| "I should look at their stack first so my advice fits" | The check does that, in their terminal, in one command. Exploring first costs them ten minutes of silence. | +| "It is read-only, so running it myself is harmless" | It takes the step away from them, and your shell is not theirs: another folder, another venv, other keys. | +| "I will open `.env` but not show it" | Opening it is the violation. The key is then in this conversation. | +| "I only printed the key's length and first characters" | That is reading the key. The doctor answers "is my key right?" and nobody sees it. | +| "It is their file and they gave me permission" | They can open their own file. You name what should be in it. | +| "The answer is in the lab page anyway, and they asked" | Then they can open it. Your part is the thinking before they do. | +| "They are out of time, so I will do it for them" | Out of time means skip the optional parts and run the next command. That is faster than you doing it. | +| "I will explain the whole lab first so they have context" | Do first, explain after. One step. | + +## Red flags + +- You are about to run a command. +- You are about to open `.env`, or any file that is not under `labs/` or `docs/` and that the lab page does not link to. +- You have read more than two files and have not replied yet. +- Your step message has no **Check**, or has two. +- You are sending a lab's first step without its **Before you start** list. +- You are about to say a lab is done, and its **Clean up** has not been sent. +- You are typing an answer letter they have not said first. + +All of these mean: stop, and send the one step. diff --git a/sql/01_explore.sql b/sql/cloud/01_explore.sql similarity index 100% rename from sql/01_explore.sql rename to sql/cloud/01_explore.sql diff --git a/sql/02_login_failures.sql b/sql/cloud/02_login_failures.sql similarity index 100% rename from sql/02_login_failures.sql rename to sql/cloud/02_login_failures.sql diff --git a/sql/03_flagged_accounts.sql b/sql/cloud/03_flagged_accounts.sql similarity index 100% rename from sql/03_flagged_accounts.sql rename to sql/cloud/03_flagged_accounts.sql diff --git a/sql/99_reset.sql b/sql/cloud/99_reset.sql similarity index 100% rename from sql/99_reset.sql rename to sql/cloud/99_reset.sql diff --git a/sql/local/00_source.sql b/sql/local/00_source.sql new file mode 100644 index 0000000..129e485 --- /dev/null +++ b/sql/local/00_source.sql @@ -0,0 +1,21 @@ +-- Lab 2, step 1: connect RisingWave to the login topic. +-- +-- A source is a topic RisingWave reads. This one reads the diskless topic +-- security.login_events and decodes each event with the Avro schema in the +-- schema registry; `(*)` takes every field of that schema as a column. +-- +-- RisingWave runs in a container, so it reaches the broker and the registry by +-- their names on the stack's network (kafka, schema-registry), not by 127.0.0.1. +-- +-- The source is named after the topic. The name contains a dot, so always wrap +-- it in double quotes. + +CREATE SOURCE IF NOT EXISTS "security.login_events" (*) +WITH ( + connector = 'kafka', + topic = 'security.login_events', + properties.bootstrap.server = 'kafka:19092', + scan.startup.mode = 'earliest' +) FORMAT PLAIN ENCODE AVRO ( + schema.registry = 'http://schema-registry:8081' +); diff --git a/sql/local/01_explore.sql b/sql/local/01_explore.sql new file mode 100644 index 0000000..297f96d --- /dev/null +++ b/sql/local/01_explore.sql @@ -0,0 +1,8 @@ +-- Lab 2, step 1 (after 00_source.sql): peek at the login stream. +-- +-- Each row is one login attempt, read from the topic as you ask. + +SELECT event_time, account_id, ip_address, result, failure_reason +FROM "security.login_events" +ORDER BY event_time DESC +LIMIT 20; diff --git a/sql/local/02_login_failures.sql b/sql/local/02_login_failures.sql new file mode 100644 index 0000000..75d89eb --- /dev/null +++ b/sql/local/02_login_failures.sql @@ -0,0 +1,20 @@ +-- Lab 2, step 2: turn the stream into always-fresh context for the agent. +-- +-- A materialized view is maintained incrementally: every new login event +-- updates the counts within seconds. No batch job, no refresh. + +CREATE MATERIALIZED VIEW login_failures AS +SELECT + account_id, + COUNT(*) FILTER (WHERE result = 'FAILURE') AS failed_logins, + COUNT(*) FILTER (WHERE result = 'SUCCESS') AS successful_logins, + COUNT(DISTINCT ip_address) AS distinct_ips, + MAX(event_time) AS last_seen +FROM "security.login_events" +GROUP BY account_id; + +-- Check it: the accounts with the most failed logins. +SELECT * +FROM login_failures +ORDER BY failed_logins DESC +LIMIT 10; diff --git a/sql/local/03_flagged_accounts.sql b/sql/local/03_flagged_accounts.sql new file mode 100644 index 0000000..856360c --- /dev/null +++ b/sql/local/03_flagged_accounts.sql @@ -0,0 +1,7 @@ +-- Lab 2, step 3: the table your agent will write to in Lab 4 (with your approval). + +CREATE TABLE flagged_accounts ( + account_id VARCHAR PRIMARY KEY, + reason VARCHAR, + flagged_at TIMESTAMPTZ DEFAULT now() +); diff --git a/sql/local/99_reset.sql b/sql/local/99_reset.sql new file mode 100644 index 0000000..5d0f5d1 --- /dev/null +++ b/sql/local/99_reset.sql @@ -0,0 +1,5 @@ +-- Start Lab 2 over: drop what the lab created. The Kafka topic is untouched. + +DROP TABLE IF EXISTS flagged_accounts; +DROP MATERIALIZED VIEW IF EXISTS login_failures; +DROP SOURCE IF EXISTS "security.login_events"; diff --git a/typescript/package.json b/typescript/package.json index c74692a..3576bd4 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -13,6 +13,7 @@ "l3": "tsx src/l3-live-context.ts", "l4": "tsx src/l4-act.ts", "inject": "tsx src/inject.ts", + "seed": "tsx src/seed.ts", "doctor": "tsx src/doctor.ts", "cleanup": "tsx src/cleanup.ts", "test": "vitest run", diff --git a/typescript/src/cleanup.ts b/typescript/src/cleanup.ts index 91980d6..32efe0a 100644 --- a/typescript/src/cleanup.ts +++ b/typescript/src/cleanup.ts @@ -3,7 +3,7 @@ * * npm run cleanup * - * Your SQL objects stay; drop them with sql/99_reset.sql. + * Your SQL objects stay; drop them with sql/cloud/99_reset.sql or sql/local/99_reset.sql. */ import { cleanup, loadConfig, orcaClient, runMain, stateFor } from './common.js'; diff --git a/typescript/src/common.ts b/typescript/src/common.ts index 44ff049..f10dcb0 100644 --- a/typescript/src/common.ts +++ b/typescript/src/common.ts @@ -38,9 +38,11 @@ export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', // ------------------------------------------------------------------ config -- -/** The team card (.env) is missing something. */ +/** Your .env is missing something. */ export class ConfigError extends Error {} +export type Stack = 'cloud' | 'local'; + export class Config { readonly #values: Record<string, string>; // holds your API key: never print it @@ -56,25 +58,44 @@ export class Config { } get(name: string): string { - if (!this.has(name)) throw new ConfigError(missingMessage([name])); + this.require(name); return this.#values[name]; } + + /** `cloud`: your team card on StreamNative Cloud. `local`: the stack on your laptop. */ + get stack(): Stack { + const stack = this.#values.TUTORIAL_STACK ?? 'cloud'; + if (stack !== 'cloud' && stack !== 'local') throw new ConfigError('TUTORIAL_STACK must be cloud or local.'); + return stack; + } + + require(...names: string[]): void { + const missing = names.filter((name) => !this.has(name)); + if (missing.length > 0) throw new ConfigError(`Missing ${missing.join(', ')}. ${setupHint(this)}`); + } +} + +/** How to get a complete .env, for the stack this one is for. */ +export function setupHint(config: Config): string { + if (config.has('TUTORIAL_STACK') && config.get('TUTORIAL_STACK') === 'local') { + return 'Run local/write-env.sh in the repo root to write .env again (Local course, Lab 0).'; + } + return ( + 'Copy .env.cloud.example to .env in the repo root and fill it in from your team card, ' + + 'or run local/write-env.sh for the Local course.' + ); } -/** Read the team card: the repo's .env file, overridden by exported variables. */ +/** Read .env in the repo root, overridden by exported variables. */ export function loadConfig(required: string[], env?: Record<string, string | undefined>): Config { const source = env ?? { ...readDotenv(join(REPO_ROOT, '.env')), ...process.env }; const values: Record<string, string> = {}; for (const [name, value] of Object.entries(source)) { if (value && value.trim()) values[name] = value.trim(); } - const missing = required.filter((name) => !(name in values)); - if (missing.length > 0) throw new ConfigError(missingMessage(missing)); - return new Config(values, slug(values.PARTICIPANT || osUser())); -} - -function missingMessage(names: string[]): string { - return `Missing ${names.join(', ')}. Copy .env.example to .env in the repo root and fill it in from your team card.`; + const config = new Config(values, slug(values.PARTICIPANT || osUser())); + config.require(...required); + return config; } function readDotenv(path: string): Record<string, string> { @@ -97,7 +118,7 @@ function slug(raw: string): string { // ------------------------------------------------------------------- state -- -/** Remembers the ids your scripts created, in .orca-state/<participant>.json. */ +/** Remembers the ids your scripts created, in .orca-state/. */ export class State { readonly #data: Record<string, string>; @@ -116,8 +137,10 @@ export class State { } } +/** Each stack has its own Agent Engine, so each keeps its ids in its own file. */ export function stateFor(config: Config): State { - return new State(join(REPO_ROOT, '.orca-state', `${config.participant}.json`)); + const suffix = config.stack === 'local' ? '.local' : ''; + return new State(join(REPO_ROOT, '.orca-state', `${config.participant}${suffix}.json`)); } // ------------------------------------------------------- agent definitions -- @@ -141,9 +164,9 @@ export type AgentParams = AgentCreateParams & { const PLACEHOLDER = /\$\{([A-Z0-9_]+)\}/g; -/** Read agent/<name>.json: the same file the CLI and Python paths use. */ -export function loadLayer(name: string): Layer { - return JSON.parse(readFileSync(join(REPO_ROOT, 'agent', `${name}.json`), 'utf8')); +/** Read agent/<stack>/<name>.json: the same file the CLI and Python paths use. */ +export function loadLayer(name: string, stack: Stack = 'cloud'): Layer { + return JSON.parse(readFileSync(join(REPO_ROOT, 'agent', stack, `${name}.json`), 'utf8')); } /** The arguments for agents.create/update: the layer's JSON plus your name and model. */ @@ -162,7 +185,7 @@ export function agentParams(layer: Layer, config: Config): AgentParams { return { ...params, metadata: { tutorial: 'dss2026-hello-world', layer: layer.layer, definition_sha: fingerprint } }; } -/** Replace ${NAME} placeholders with values from the team card. */ +/** Replace ${NAME} placeholders with values from .env. */ function fill<T>(value: T, config: Config): T { if (typeof value === 'string') return value.replace(PLACEHOLDER, (_match, name: string) => lookup(name, config)) as T; if (Array.isArray(value)) return value.map((item) => fill(item, config)) as T; @@ -173,7 +196,7 @@ function fill<T>(value: T, config: Config): T { } function lookup(name: string, config: Config): string { - if (!config.has(name)) throw new ConfigError(`Missing ${name}: the agent definition needs it. Add it to .env from your team card.`); + if (!config.has(name)) throw new ConfigError(`Missing ${name}: the agent definition needs it. ${setupHint(config)}`); return config.get(name); } @@ -194,11 +217,50 @@ function sortKeys(value: unknown): unknown { return value; } +// ------------------------------------------------ Kafka and Schema Registry -- + +/** The part of a kafkajs client's options that says where Kafka is and how to log in. */ +export interface KafkaClientConfig { + brokers: string[]; + ssl?: true; + sasl?: { mechanism: 'plain'; username: string; password: string }; +} + +/** The same for the @kafkajs/confluent-schema-registry client. */ +export interface SchemaRegistryConfig { + host: string; + auth?: { username: string; password: string }; +} + +/** How to reach Kafka: your team's cluster over TLS, or the broker on your laptop. */ +export function kafkaClientConfig(config: Config): KafkaClientConfig { + const brokers = config + .get('KAFKA_BOOTSTRAP_SERVERS') + .split(',') + .map((broker) => broker.trim()) + .filter(Boolean); + if (config.stack === 'local') return { brokers }; + config.require('SN_SERVICE_ACCOUNT', 'SN_API_KEY'); + return { + brokers, + ssl: true, + // StreamNative Cloud: the service-account principal, and the raw API key. + sasl: { mechanism: 'plain', username: config.get('SN_SERVICE_ACCOUNT'), password: config.get('SN_API_KEY') }, + }; +} + +export function schemaRegistryConfig(config: Config): SchemaRegistryConfig { + const host = config.get('SCHEMA_REGISTRY_URL'); + if (config.stack === 'local') return { host }; + config.require('SN_SERVICE_ACCOUNT', 'SN_API_KEY'); + return { host, auth: { username: config.get('SN_SERVICE_ACCOUNT'), password: config.get('SN_API_KEY') } }; +} + // ------------------------------------------------- Agent Engine resources -- /** What the tutorial uses from the Orca client: the real `Orca` fits, and so do the test fakes. */ export interface SessionEventsApi { - stream(sessionId: string): Promise<AsyncIterable<SessionEvent>>; + stream(sessionId: string, params?: { from_cursor?: string }): Promise<AsyncIterable<SessionEvent>>; send(sessionId: string, params: EventSendParams): Promise<EventSendResponse>; } @@ -330,7 +392,37 @@ export function authorizeMcp(vaultId: string, config: Config): void { // No shell and no credentials in argv; OAuth tokens are never read by this script. const result = spawnSync('ork', args, { env, stdio: 'inherit' }); if (result.error) throw new ConfigError('Install ork with MCP OAuth proxy support and put it on PATH (see docs/before-you-arrive.md).'); - if (result.status !== 0) throw new ConfigError('MCP OAuth authorization failed. Check the ork error above; normally leave SN_MCP_OAUTH_ISSUER empty for discovery, then rerun L3/L4.'); + if (result.status !== 0) throw new ConfigError('MCP OAuth authorization failed. Check the ork error above; normally leave SN_MCP_OAUTH_ISSUER empty for discovery, then run the Lab 3 script again.'); +} + +/** + * The vaults a session needs to call the MCP server. + * + * StreamNative Cloud's MCP server wants a credential, kept in a vault. The MCP + * server on your laptop takes none, so the local stack has no vault. + */ +export async function mcpVaultIds(client: Pick<Client, 'vaults'>, state: State, config: Config): Promise<string[]> { + if (config.stack === 'local') return []; + return [await ensureVault(client, state, `hello-vault-${config.participant}`, config)]; +} + +/** One conversation, pinned to this exact agent version. Its id is remembered for your checks. */ +export async function openSession( + client: { sessions: Pick<Client['sessions'], 'create'> }, + state: State, + environmentId: string, + agent: Agent, + title: string, + { vaultIds = [] }: { vaultIds?: string[] } = {}, +): Promise<Session> { + const session = await client.sessions.create({ + environment_id: environmentId, + agent: { type: 'agent', id: agent.id, version: agent.version }, + title, + ...(vaultIds.length > 0 ? { vault_ids: vaultIds } : {}), + }); + state.set('session_id', session.id); + return session; } /** The remembered resource, or null if it was never created, deleted, or archived. */ @@ -374,12 +466,12 @@ export async function chat( client: { sessions: { events: SessionEventsApi } }, sessionId: string, first: string, - { confirm, ask = prompt, out = console.log }: { confirm?: Confirm; ask?: Ask; out?: Out } = {}, + { confirm, ask = prompt, out = console.log, sendFirst = false }: { confirm?: Confirm; ask?: Ask; out?: Out; sendFirst?: boolean } = {}, ): Promise<void> { let question = first; while (question) { out(`[you] ${question}`); - await runTurn(client, sessionId, question, { confirm, out }); + await runTurn(client, sessionId, question, { confirm, out, sendFirst }); question = (await ask('\nAsk again (Enter to quit): ')).trim(); } } @@ -455,27 +547,70 @@ const PREVIEW_CHARS = 160; * * `confirm(toolUse)` is asked whenever a tool with an `always_ask` policy wants * to run; return true to allow it, false to deny it. + * + * `sendFirst` is for the Agent Engine that `ork local` runs. It answers a stream + * opened on a quiet session only at its next keep-alive, 15 seconds later. */ export async function runTurn( client: { sessions: { events: SessionEventsApi } }, sessionId: string, text: string, - { confirm, out = console.log }: { confirm?: Confirm; out?: Out } = {}, + { confirm, out = console.log, sendFirst = false }: { confirm?: Confirm; out?: Out; sendFirst?: boolean } = {}, ): Promise<TurnResult> { const events = client.sessions.events; + const message: EventSendParams = { events: [{ type: 'user.message', content: [{ type: 'text', text }] }] }; + if (sendFirst) { + // Speak first, then follow the session from its start: that engine replays + // the log, and this turn begins right after our own message in it. + const sent = await events.send(sessionId, message); + const mine = sent.data?.[0]?.id; + if (!mine) throw new TurnError('The Agent Engine did not confirm the message it was sent.'); + const stream = await events.stream(sessionId, { from_cursor: '0' }); + return follow(after(stream, mine), events, sessionId, confirm, out); + } + // Listen first, then speak: an event emitted between the two would be lost. const stream = await events.stream(sessionId); - const sent = await events.send(sessionId, { events: [{ type: 'user.message', content: [{ type: 'text', text }] }] }); + const sent = await events.send(sessionId, message); // Some servers replay the whole transcript on connect. Everything // processed before our message belongs to an earlier turn. const since = sent.data?.[0]?.processed_at ?? null; + return follow(notBefore(stream, since), events, sessionId, confirm, out); +} + +type TurnEvent = SessionEvent & Record<string, any>; + +/** The events that follow our own message in a replay of the session. */ +async function* after(stream: AsyncIterable<SessionEvent>, messageId: string): AsyncGenerator<TurnEvent> { + let reached = false; + for await (const event of stream) { + if (reached) yield event; + else if (event.id === messageId) reached = true; + } +} + +/** The events that were not processed before our message. */ +async function* notBefore(stream: AsyncIterable<SessionEvent>, since: string | null): AsyncGenerator<TurnEvent> { + for await (const event of stream as AsyncIterable<TurnEvent>) { + if (!before(event.processed_at, since)) yield event; + } +} + +/** Print this turn's events, answer its requests for approval, and return at its end. */ +async function follow( + turn: AsyncIterable<TurnEvent>, + events: SessionEventsApi, + sessionId: string, + confirm: Confirm | undefined, + out: Out, +): Promise<TurnResult> { const seen = new Set<string>(); const toolUses = new Map<string, ToolUse>(); const replies: string[] = []; let lastError = ''; - for await (const event of stream as AsyncIterable<SessionEvent & Record<string, any>>) { - if (seen.has(event.id) || before(event.processed_at, since)) continue; + for await (const event of turn) { + if (seen.has(event.id)) continue; seen.add(event.id); if (event.type === 'agent.message') { @@ -493,7 +628,7 @@ export async function runTurn( out(shorten(`[${label}] ${contentText(event.content)}`)); } else if (event.type === 'session.error') { lastError = event.error?.message || event.error?.type || 'unknown error'; - out(`[error] ${lastError}${event.retry_status?.will_retry ? ' (retrying)' : ''}`); + out(`[error] ${lastError}${willRetry(event) ? ' (retrying)' : ''}`); } else if (event.type === 'session.status_idle') { const stop = event.stop_reason ?? {}; if (stop.type === 'requires_action') { @@ -528,6 +663,12 @@ async function answerApprovals( await events.send(sessionId, { events: decisions }); } +/** Servers report a retry in one of two places: beside the error, or inside it. */ +function willRetry(event: Record<string, any>): boolean { + if (event.retry_status?.will_retry) return true; + return event.error?.retry_status?.type === 'retrying'; +} + function before(processedAt: string | null | undefined, since: string | null): boolean { if (!processedAt || !since) return false; return instant(processedAt) < instant(since); diff --git a/typescript/src/doctor.ts b/typescript/src/doctor.ts index f31195d..5248afe 100644 --- a/typescript/src/doctor.ts +++ b/typescript/src/doctor.ts @@ -1,11 +1,12 @@ /** - * Check your laptop and your team card before the tutorial starts. + * Check your laptop and your .env before a lab. * * npm run doctor # laptop + .env + Agent Engine + Kafka + Schema Registry + MCP * npm run doctor -- --offline # laptop only (run this before the event) - * npm run doctor -- --agent-only # laptop + Agent Engine (for ork local / L1) + * npm run doctor -- --agent-only # laptop + Agent Engine (enough for Lab 1) * - * Every failed check prints the fix. + * It checks the stack your .env is for: your team card on StreamNative Cloud, or + * the stack on your laptop. Every failed check prints the fix. */ import { existsSync } from 'node:fs'; @@ -13,27 +14,39 @@ import { delimiter, dirname, join, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import type Orca from '@runorca/orca-sdk'; -import type { Config, State } from './common.js'; +import type { Config, Stack, State } from './common.js'; const TS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const CARD = ['SN_API_KEY', 'SN_SERVICE_ACCOUNT', 'ORCA_BASE_URL', 'KAFKA_BOOTSTRAP_SERVERS', 'SCHEMA_REGISTRY_URL', 'SN_MCP_URL', 'LOGIN_TOPIC', 'ORCA_MODEL']; +const LOCAL = ['ORCA_API_KEY', 'ORCA_BASE_URL', 'KAFKA_BOOTSTRAP_SERVERS', 'SCHEMA_REGISTRY_URL', 'RW_MCP_URL', 'RW_MCP_LOCAL_URL', 'LOGIN_TOPIC', 'ORCA_MODEL']; const SQL_FIELDS = ['account_id', 'event_time', 'ip_address', 'result', 'failure_reason']; -const MCP_TOOLS = ['sql_workspace_list_databases', 'sql_workspace_query', 'sql_workspace_describe_table', 'sql_workspace_insert_rows']; +// The tools the agent definitions in agent/<stack>/ enable. +const MCP_TOOLS: Record<Stack, string[]> = { + cloud: ['sql_workspace_list_databases', 'sql_workspace_query', 'sql_workspace_describe_table', 'sql_workspace_insert_rows'], + local: ['run_select_query', 'describe_table', 'insert_multiple_rows'], +}; const PACKAGES = ['@runorca/orca-sdk', 'kafkajs', '@kafkajs/confluent-schema-registry', 'dotenv']; +const START_LOCAL_STACK = 'Start the streaming stack: docker compose -f local/compose.yaml up -d --wait'; export interface Check { name: string; ok: boolean; detail: string; fix: string; + wait: boolean; // not ready yet, and not your mistake: a later lab does it + label: 'PASS' | 'WAIT' | 'FAIL'; } -function check(name: string, ok: boolean, detail = '', fix = ''): Check { - return { name, ok, detail, fix }; +export function check(name: string, ok: boolean, detail = '', fix = '', { wait = false } = {}): Check { + return { name, ok, detail, fix, wait, label: ok ? 'PASS' : wait ? 'WAIT' : 'FAIL' }; } // ------------------------------------------------------------- decisions -- +export function requiredFor(stack: Stack): string[] { + return stack === 'local' ? LOCAL : CARD; +} + export function checkNode(version: string): Check { const [major, minor] = version.split('.').map(Number); const ok = major >= 20; @@ -58,22 +71,39 @@ export function checkOrcaBaseUrl(url: string): Check { return check('ORCA_BASE_URL', true, root); } -export function checkLoginSchema(fields: string[]): Check { +export function checkLoginSchema(fields: string[], stack: Stack = 'cloud'): Check { const present = new Set(fields); const missing = SQL_FIELDS.filter((name) => !present.has(name)); if (missing.length === 0) return check('login topic schema', true, `has ${SQL_FIELDS.join(', ')}`); let fix = 'The SQL in sql/ expects these fields: ask a facilitator which schema your topic uses.'; if (missing.includes('result') && present.has('outcome')) { - fix = 'Your topic names the login result `outcome`, not `result`: use `outcome` in sql/01 and sql/02 (and check its values).'; + fix = `Your topic names the login result \`outcome\`, not \`result\`: use \`outcome\` in sql/${stack}/01_explore.sql and sql/${stack}/02_login_failures.sql (and check its values).`; } return check('login topic schema', false, `missing ${missing.join(', ')}`, fix); } -export function checkMcpTools(names: string[]): Check { +export function checkMcpTools(names: string[], stack: Stack = 'cloud'): Check { + const wanted = MCP_TOOLS[stack]; const offered = new Set(names); - const missing = MCP_TOOLS.filter((tool) => !offered.has(tool)); - if (missing.length === 0) return check('MCP tools', true, MCP_TOOLS.join(', ')); - return check('MCP tools', false, `missing ${missing.join(', ')}`, 'The MCP server does not offer these tools to your key: ask a facilitator.'); + const missing = wanted.filter((tool) => !offered.has(tool)); + if (missing.length === 0) return check('MCP tools', true, wanted.join(', ')); + const fix = + stack === 'local' + ? 'The agent definitions in agent/local/ need these tools: check the risingwave-mcp image tag in local/compose.yaml.' + : 'The MCP server does not offer these tools to your key: ask a facilitator.'; + return check('MCP tools', false, `missing ${missing.join(', ')}`, fix); +} + +/** `SELECT 1` through the MCP server: one row back means it reaches RisingWave. */ +export function checkMcpQuery(text: string): Check { + let rows: unknown = null; + try { + rows = JSON.parse(text); + } catch { + // not JSON: an error message + } + if (Array.isArray(rows) && rows.length === 1) return check('RisingWave through MCP', true, 'SELECT 1 returned a row'); + return check('RisingWave through MCP', false, text.slice(0, 200), `The MCP server cannot query RisingWave. ${START_LOCAL_STACK}`); } /** Pick the JSON-RPC reply to `requestId` out of a JSON or event-stream body. */ @@ -100,8 +130,13 @@ export function errorText(err: unknown): string { return cause ? `${err.message}: ${cause}` : err.message; } -export function kafkaHint(error: string): string { +export function kafkaHint(error: string, stack: Stack = 'cloud'): string { const text = error.toLowerCase(); + const unreachable = ['resolve', 'transport', 'timed out', 'connect'].some((word) => text.includes(word)); + if (stack === 'local') { + if (unreachable) return `Cannot reach Kafka on your laptop. ${START_LOCAL_STACK}`; + return 'The login topic is not there yet. Create it and seed it: Local course, Lab 0.'; + } if (text.includes('authentication') || text.includes('sasl')) { return ( 'Kafka rejected the login. SN_SERVICE_ACCOUNT must be the full principal ' + @@ -111,12 +146,33 @@ export function kafkaHint(error: string): string { if (text.includes('authorization')) { return 'Your key logs in but may not use this topic: its rolebinding is missing. Ask a facilitator.'; } - if (['resolve', 'transport', 'timed out', 'connect'].some((word) => text.includes(word))) { + if (unreachable) { return 'Cannot reach Kafka. Check KAFKA_BOOTSTRAP_SERVERS (host:port from your team card) and your network.'; } return 'See the error above, or ask a facilitator.'; } +export function schemaRegistryHint(error: string, stack: Stack = 'cloud'): string { + if (stack !== 'local') return 'Check SCHEMA_REGISTRY_URL; your key may lack Schema Registry read access.'; + if (error.toLowerCase().includes('not found')) return 'The schema is registered when you seed the topic: npm run seed (Local course, Lab 0).'; + return `Cannot reach Schema Registry on your laptop. ${START_LOCAL_STACK}`; +} + +export function mcpHeaders(token?: string): Record<string, string> { + const headers: Record<string, string> = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} + +/** The exit code and the last line. A waiting check is not a failure. */ +export function summarize(checks: Check[]): { code: number; verdict: string } { + const failed = checks.filter((c) => c.label === 'FAIL').length; + const waiting = checks.filter((c) => c.label === 'WAIT').length; + if (failed > 0) return { code: 1, verdict: `${failed} check(s) failed. Fix them, then run doctor again.` }; + if (waiting > 0) return { code: 0, verdict: `You're ready. ${waiting} check(s) wait for a later lab.` }; + return { code: 0, verdict: "All good: you're ready." }; +} + // ---------------------------------------------------------------- probes -- function checkPackages(agentOnly = false): Check[] { @@ -126,30 +182,43 @@ function checkPackages(agentOnly = false): Check[] { }); } -/** ork is needed for first OAuth login; jq is only needed by the CLI path. */ +function onPath(tool: string): boolean { + return (process.env.PATH ?? '') + .split(delimiter) + .some((dir) => dir && ['', '.exe', '.cmd'].some((ext) => existsSync(join(dir, tool + ext)))); +} + +/** ork and jq run the checks in each lab; ork also does the first OAuth login. Reported, never failed. */ function checkCliTools(): Check[] { - const onPath = (tool: string) => - (process.env.PATH ?? '') - .split(delimiter) - .some((dir) => dir && ['', '.exe', '.cmd'].some((ext) => existsSync(join(dir, tool + ext)))); - return ['ork', 'jq'].map((tool) => - check(tool, true, onPath(tool) ? 'found' : (tool === 'ork' ? 'not found: install before first OAuth MCP login' : 'not found: CLI path only')), - ); + const notes: Record<string, string> = { + ork: 'not found: install it for the lab checks and the first OAuth MCP login', + jq: 'not found: install it for the lab checks', + }; + return ['ork', 'jq'].map((tool) => check(tool, true, onPath(tool) ? 'found' : notes[tool])); +} + +function checkDocker(): Check { + const found = onPath('docker'); + return check('docker', found, found ? 'found' : 'not found', found ? '' : 'The Local course runs in Docker: install Docker Desktop, or Docker Engine with Compose v2.'); } async function probeOrca(config: Config): Promise<Check> { const { APIConnectionError, APIError } = await import('@runorca/orca-sdk'); const { orcaClient } = await import('./common.js'); + const local = config.stack === 'local'; try { await orcaClient(config).agents.list({ limit: 1 }, { timeout: 30_000 }); } catch (err) { // A connection error is also an APIError, so check it first. if (err instanceof APIConnectionError) { - return check('Agent Engine', false, err.message, 'Cannot reach ORCA_BASE_URL. Check the URL and your network.'); + const fix = local ? 'Start the Agent Engine: local/engine.sh' : 'Cannot reach ORCA_BASE_URL. Check the URL and your network.'; + return check('Agent Engine', false, err.message, fix); } if (err instanceof APIError && err.status) { let fix = 'Ask a facilitator.'; - if (err.status === 401 || err.status === 403) { + if ((err.status === 401 || err.status === 403) && local) { + fix = 'The key in .env does not match the running stack. Run local/write-env.sh; if it still fails, start over with local/down.sh --reset.'; + } else if (err.status === 401 || err.status === 403) { fix = 'The Agent Engine rejected the key. For ork local use its generated workspace key as ORCA_API_KEY; for a team card check SN_API_KEY and its rolebinding.'; } else if (err.status === 404) { fix = 'ORCA_BASE_URL is not an Agent Engine registry: copy the registry endpoint from your team card.'; @@ -163,16 +232,11 @@ async function probeOrca(config: Config): Promise<Check> { async function probeKafka(config: Config): Promise<Check> { const { Kafka, logLevel } = await import('kafkajs'); + const { kafkaClientConfig } = await import('./common.js'); const topicName = config.get('LOGIN_TOPIC'); const admin = new Kafka({ clientId: 'hello-doctor', - brokers: config - .get('KAFKA_BOOTSTRAP_SERVERS') - .split(',') - .map((broker) => broker.trim()) - .filter(Boolean), - ssl: true, - sasl: { mechanism: 'plain', username: config.get('SN_SERVICE_ACCOUNT'), password: config.get('SN_API_KEY') }, + ...kafkaClientConfig(config), logLevel: logLevel.NOTHING, connectionTimeout: 10_000, requestTimeout: 15_000, @@ -183,44 +247,45 @@ async function probeKafka(config: Config): Promise<Check> { // Listing existing topics avoids auto-creating a missing topic during preflight. const { topics } = await admin.fetchTopicMetadata(); const topic = topics.find((t) => t.name === topicName); - if (!topic) return check('Kafka', false, `${topicName}: not found`, kafkaHint('not found')); + if (!topic) return check('Kafka', false, `${topicName}: not found`, kafkaHint('not found', config.stack)); return check('Kafka', true, `${topicName} has ${topic.partitions.length} partition(s)`); } catch (err) { if ((err as { type?: string }).type === 'UNKNOWN_TOPIC_OR_PARTITION') { - return check('Kafka', false, `${topicName}: not found`, kafkaHint('not found')); + return check('Kafka', false, `${topicName}: not found`, kafkaHint('not found', config.stack)); } const reason = err instanceof Error ? `${err.name}: ${err.message}` : String(err); - return check('Kafka', false, reason, kafkaHint(reason)); + return check('Kafka', false, reason, kafkaHint(reason, config.stack)); } finally { await admin.disconnect().catch(() => {}); } } async function probeSchemaRegistry(config: Config): Promise<Check[]> { + const { schemaRegistryConfig } = await import('./common.js'); const subject = `${config.get('LOGIN_TOPIC')}-value`; - const base = config.get('SCHEMA_REGISTRY_URL').replace(/\/+$/, ''); - const credentials = Buffer.from(`${config.get('SN_SERVICE_ACCOUNT')}:${config.get('SN_API_KEY')}`).toString('base64'); + const { host, auth } = schemaRegistryConfig(config); + const headers: Record<string, string> = { Accept: 'application/vnd.schemaregistry.v1+json, application/json' }; + if (auth) headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`; try { - const response = await fetch(`${base}/subjects/${encodeURIComponent(subject)}/versions/latest`, { - headers: { Authorization: `Basic ${credentials}`, Accept: 'application/vnd.schemaregistry.v1+json, application/json' }, + const response = await fetch(`${host.replace(/\/+$/, '')}/subjects/${encodeURIComponent(subject)}/versions/latest`, { + headers, signal: AbortSignal.timeout(15_000), }); if (!response.ok) throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 200)}`); const latest = (await response.json()) as { version: number; schema: string }; const fields = (JSON.parse(latest.schema).fields as Array<{ name: string }>).map((field) => field.name); - return [check('Schema Registry', true, `${subject} v${latest.version}`), checkLoginSchema(fields)]; + return [check('Schema Registry', true, `${subject} v${latest.version}`), checkLoginSchema(fields, config.stack)]; } catch (err) { - return [check('Schema Registry', false, errorText(err), 'Check SCHEMA_REGISTRY_URL; your key may lack Schema Registry read access.')]; + return [check('Schema Registry', false, errorText(err), schemaRegistryHint(errorText(err), config.stack))]; } } -/** Ask the MCP server which tools it offers: initialize, then tools/list. */ -async function mcpToolNames(url: string, token: string): Promise<string[]> { - const headers: Record<string, string> = { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - Accept: 'application/json, text/event-stream', - }; +type McpRequest = (method: string, params: Record<string, unknown>) => Promise<Record<string, any>>; + +/** Open an MCP session over HTTP. Returns `request(method, params)`. */ +async function mcpConnect(url: string, token?: string): Promise<McpRequest> { + const headers = mcpHeaders(token); + let nextId = 1; async function post(payload: Record<string, unknown>): Promise<Record<string, any> | null> { const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(payload), signal: AbortSignal.timeout(20_000) }); @@ -232,16 +297,21 @@ async function mcpToolNames(url: string, token: string): Promise<string[]> { return 'id' in payload ? parseMcpResponse(contentType, body, payload.id as number) : null; } + const request: McpRequest = async (method, params) => (await post({ jsonrpc: '2.0', id: nextId++, method, params })) ?? {}; + const hello = { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'hello-doctor', version: '1' } }; - const init = (await post({ jsonrpc: '2.0', id: 1, method: 'initialize', params: hello })) ?? {}; + const init = await request('initialize', hello); headers['MCP-Protocol-Version'] = init.protocolVersion ?? hello.protocolVersion; await post({ jsonrpc: '2.0', method: 'notifications/initialized' }); + return request; +} +/** Ask the MCP server which tools it offers: tools/list, page by page. */ +async function mcpToolNames(request: McpRequest): Promise<string[]> { const names: string[] = []; let cursor: string | undefined; - for (let requestId = 2; requestId < 12; requestId += 1) { - const params = cursor ? { cursor } : {}; - const page = (await post({ jsonrpc: '2.0', id: requestId, method: 'tools/list', params })) ?? {}; + for (let pages = 0; pages < 10; pages += 1) { + const page = await request('tools/list', cursor ? { cursor } : {}); names.push(...((page.tools ?? []) as Array<{ name: string }>).map((tool) => tool.name)); cursor = page.nextCursor; if (!cursor) break; @@ -257,48 +327,67 @@ export async function probeMcp(config: Config, client?: Pick<Orca, 'vaults'>, st client ??= orcaClient(config); state ??= stateFor(config); const vaultId = state.get('vault_id'); - let fix = 'Run L3 to create the MCP OAuth credential with ork, then rerun doctor.'; - if (!vaultId) return check('MCP OAuth', false, 'no tutorial vault yet', fix); + const later = 'Nothing to do now: Lab 3 opens your browser to authorize the MCP server. Run doctor again after it.'; + if (!vaultId) return check('MCP OAuth', false, 'no tutorial vault yet', later, { wait: true }); const { data } = await client.vaults.credentials.list(vaultId); const credential = data.find((c) => c.auth.type === 'mcp_oauth' && c.auth.mcp_server_url === config.get('SN_MCP_URL') && !c.archived_at); - if (!credential) return check('MCP OAuth', false, 'no matching OAuth credential', fix); + if (!credential) return check('MCP OAuth', false, 'no matching OAuth credential', later, { wait: true }); const result = await client.vaults.credentials.validate(vaultId, credential.id); - const ok = result.status === 'valid'; + let fix = ''; if (result.status === 'unknown') fix = 'The MCP probe was inconclusive. Check Registry/MCP connectivity and rerun doctor; keep the existing credential.'; - else if (result.status === 'invalid') fix = `Reauthorize: ork agent vaults credentials archive ${credential.id} --vault ${vaultId}, then rerun L3/L4.`; - return check('MCP OAuth', ok, `${result.status}: MCP initialization; SQL tools checked in L3/L4`, ok ? '' : fix); + else if (result.status === 'invalid') fix = `Reauthorize: ork agent vaults credentials archive ${credential.id} --vault ${vaultId}, then run the Lab 3 script again.`; + return check('MCP OAuth', result.status === 'valid', `${result.status}: MCP initialization; the SQL tools are checked in Labs 3 and 4`, fix); } - names = await mcpToolNames(config.get('SN_MCP_URL'), config.get('SN_API_KEY')); + names = await mcpToolNames(await mcpConnect(config.get('SN_MCP_URL'), config.get('SN_API_KEY'))); } catch (err) { - return check('MCP server', false, errorText(err), 'Check SN_MCP_URL and SN_MCP_AUTH; for OAuth, finish the L3 browser login and verify the vault credential.'); + return check('MCP server', false, errorText(err), 'Check SN_MCP_URL and SN_MCP_AUTH; for OAuth, finish the Lab 3 browser login and verify the vault credential.'); } return checkMcpTools(names); } +/** The MCP server on your laptop, as your terminal reaches it. No credential. */ +async function probeLocalMcp(config: Config): Promise<Check[]> { + try { + const request = await mcpConnect(config.get('RW_MCP_LOCAL_URL')); + const tools = checkMcpTools(await mcpToolNames(request), 'local'); + const result = await request('tools/call', { name: 'run_select_query', arguments: { query: 'SELECT 1 AS ready' } }); + const text = ((result.content ?? []) as Array<{ text?: string }>).map((block) => block.text ?? '').join(''); + return [tools, checkMcpQuery(text)]; + } catch (err) { + return [check('MCP server', false, errorText(err), START_LOCAL_STACK)]; + } +} + // ------------------------------------------------------------------ main -- export async function runChecks(offline: boolean, agentOnly = false): Promise<Check[]> { const checks = [checkNode(process.versions.node), ...checkPackages(agentOnly), ...checkCliTools()]; if (offline || !checks.every((c) => c.ok)) return checks; - const { loadConfig } = await import('./common.js'); + const { ConfigError, loadConfig, setupHint } = await import('./common.js'); const config = loadConfig([]); - const required = agentOnly ? ['ORCA_BASE_URL', 'ORCA_MODEL'] : CARD; + let stack: Stack; + try { + stack = config.stack; + } catch (err) { + if (!(err instanceof ConfigError)) throw err; + return [...checks, check('.env', false, err.message, 'Set TUTORIAL_STACK=cloud or TUTORIAL_STACK=local in .env, or remove the line.')]; + } + const required = agentOnly ? ['ORCA_BASE_URL', 'ORCA_MODEL'] : requiredFor(stack); const missing = required.filter((name) => !config.has(name)); if (agentOnly && !config.has('ORCA_API_KEY') && !config.has('SN_API_KEY')) missing.push('ORCA_API_KEY or SN_API_KEY'); - if (missing.length > 0) { - const fix = 'Copy .env.example to .env in the repo root and paste your team card.'; - return [...checks, check('team card (.env)', false, `missing ${missing.join(', ')}`, fix)]; - } + if (missing.length > 0) return [...checks, check('.env', false, `missing ${missing.join(', ')}`, setupHint(config))]; - checks.push(check('team card (.env)', true, `participant: ${config.participant}`)); + checks.push(check('.env', true, `${stack} stack, participant: ${config.participant}`)); + if (stack === 'local') checks.push(checkDocker()); const baseUrl = checkOrcaBaseUrl(config.get('ORCA_BASE_URL')); checks.push(baseUrl); if (baseUrl.ok) checks.push(await probeOrca(config)); if (agentOnly) return checks; checks.push(await probeKafka(config)); checks.push(...(await probeSchemaRegistry(config))); - checks.push(await probeMcp(config)); + if (stack === 'local') checks.push(...(await probeLocalMcp(config))); + else checks.push(await probeMcp(config)); return checks; } @@ -306,12 +395,12 @@ async function main(): Promise<number> { const args = process.argv.slice(2); const checks = await runChecks(args.includes('--offline'), args.includes('--agent-only')); for (const c of checks) { - console.log(`${c.ok ? 'PASS' : 'FAIL'} ${c.name.padEnd(32)} ${c.detail}`); - if (!c.ok && c.fix) console.log(` fix: ${c.fix}`); + console.log(`${c.label} ${c.name.padEnd(32)} ${c.detail}`); + if (!c.ok && c.fix) console.log(` ${c.wait ? 'next' : 'fix'}: ${c.fix}`); } - const failed = checks.filter((c) => !c.ok).length; - console.log(failed === 0 ? "\nAll good: you're ready." : `\n${failed} check(s) failed. Fix them, then run doctor again.`); - return failed === 0 ? 0 : 1; + const { code, verdict } = summarize(checks); + console.log(`\n${verdict}`); + return code; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/typescript/src/inject.ts b/typescript/src/inject.ts index 6e322e6..76ca0ef 100644 --- a/typescript/src/inject.ts +++ b/typescript/src/inject.ts @@ -1,5 +1,5 @@ /** - * Inject a brute-force login burst into your team's login topic. + * Inject a brute-force login burst into the login topic. * * Six failed logins from a new IP address, then a success: the classic * account-takeover pattern. The materialized view login_failures picks it up @@ -11,10 +11,10 @@ import { randomInt, randomUUID } from 'node:crypto'; import { pathToFileURL } from 'node:url'; -import { SchemaRegistry } from '@kafkajs/confluent-schema-registry'; +import { SchemaRegistry, SchemaType } from '@kafkajs/confluent-schema-registry'; import { Kafka, Partitioners, logLevel } from 'kafkajs'; -import { loadConfig, runMain } from './common.js'; +import { kafkaClientConfig, loadConfig, runMain, schemaRegistryConfig, type Config } from './common.js'; export const FAILURES = 6; @@ -81,52 +81,71 @@ export function buildBurst(accountId: string, ipAddress: string, now: Date): Log }); } -async function main(): Promise<void> { - const config = loadConfig(['KAFKA_BOOTSTRAP_SERVERS', 'SCHEMA_REGISTRY_URL', 'SN_SERVICE_ACCOUNT', 'SN_API_KEY', 'LOGIN_TOPIC']); - const topic = config.get('LOGIN_TOPIC'); - const username = config.get('SN_SERVICE_ACCOUNT'); - const password = config.get('SN_API_KEY'); - - const registry = new SchemaRegistry({ host: config.get('SCHEMA_REGISTRY_URL'), auth: { username, password } }); - const kafka = new Kafka({ - clientId: 'hello-inject', - brokers: config - .get('KAFKA_BOOTSTRAP_SERVERS') - .split(',') - .map((broker) => broker.trim()) - .filter(Boolean), - ssl: true, - // StreamNative Cloud: the service-account principal, and the raw API key. - sasl: { mechanism: 'plain', username, password }, - logLevel: logLevel.NOTHING, - retry: { retries: 2 }, - }); - const producer = kafka.producer({ idempotent: false, allowAutoTopicCreation: false, createPartitioner: Partitioners.DefaultPartitioner }); +/** What publish uses from a producer: the one from loginProducer fits, and so do the test fakes. */ +export interface LoginProducer { + send(topic: string, messages: Array<{ key: string; value: LoginEvent }>): Promise<void>; + disconnect(): Promise<void>; +} - const accountId = newAccountId(); - const ipAddress = newIp(); - let failure: unknown = null; +/** + * Write the records, each keyed by its account, and wait for Kafka to confirm them. + * + * Returns what went wrong, if anything. + */ +export async function publish(producer: LoginProducer, topic: string, records: LoginEvent[]): Promise<string[]> { try { - // Write with the schema the topic already has; never register a new one. - const schemaId = await registry.getLatestSchemaId(`${topic}-value`); - const messages = await Promise.all( - buildBurst(accountId, ipAddress, new Date()).map(async (record) => ({ key: accountId, value: await registry.encode(schemaId, record) })), + // Encoding looks the schema up in Schema Registry, so it can fail here too. + await producer.send( + topic, + records.map((record) => ({ key: record.account_id, value: record })), ); - await producer.connect(); - await producer.send({ topic, acks: -1, messages }); } catch (err) { - failure = err; + return [err instanceof Error ? err.message : String(err)]; } finally { await producer.disconnect().catch(() => {}); } + return []; +} - if (failure) { - const reason = failure instanceof Error ? failure.message : String(failure); - console.error(`Could not write to ${topic}: ${reason}\nRun \`npm run doctor\` to check your Kafka access.`); +/** + * A producer of Avro login events. + * + * By default it writes with the schema the topic already has and never registers + * a new one. Pass `schema` to register it first: that is how seed.ts fills a new topic. + */ +export function loginProducer(config: Config, { schema }: { schema?: string } = {}): LoginProducer { + const registry = new SchemaRegistry(schemaRegistryConfig(config)); + const kafka = new Kafka({ clientId: 'hello-inject', ...kafkaClientConfig(config), logLevel: logLevel.NOTHING, retry: { retries: 2 } }); + const producer = kafka.producer({ idempotent: false, allowAutoTopicCreation: false, createPartitioner: Partitioners.DefaultPartitioner }); + + return { + async send(topic, messages) { + const subject = `${topic}-value`; + const schemaId = schema + ? (await registry.register({ type: SchemaType.AVRO, schema }, { subject })).id + : await registry.getLatestSchemaId(subject); + const encoded = await Promise.all(messages.map(async ({ key, value }) => ({ key, value: await registry.encode(schemaId, value) }))); + await producer.connect(); + await producer.send({ topic, acks: -1, messages: encoded }); + }, + disconnect: () => producer.disconnect(), + }; +} + +async function main(): Promise<void> { + const config = loadConfig(['KAFKA_BOOTSTRAP_SERVERS', 'SCHEMA_REGISTRY_URL', 'LOGIN_TOPIC']); + const topic = config.get('LOGIN_TOPIC'); + const producer = loginProducer(config); + + const accountId = newAccountId(); + const ipAddress = newIp(); + const errors = await publish(producer, topic, buildBurst(accountId, ipAddress, new Date())); + if (errors.length > 0) { + console.error(`Could not write to ${topic}: ${errors[0]}\nRun \`npm run doctor\` to check your Kafka access.`); process.exit(1); } console.log(`Injected ${FAILURES} failed logins + 1 success for ${accountId} from ${ipAddress} into ${topic}.`); - console.log(`Ask your agent again, or check in SQL Studio: SELECT * FROM login_failures WHERE account_id = '${accountId}';`); + console.log(`Ask your agent again, or run this SQL: SELECT * FROM login_failures WHERE account_id = '${accountId}';`); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/typescript/src/l1-hello.ts b/typescript/src/l1-hello.ts index 4a05446..e7ca9de 100644 --- a/typescript/src/l1-hello.ts +++ b/typescript/src/l1-hello.ts @@ -7,7 +7,7 @@ * npm run l1 -- "your own question" */ -import { agentParams, ensureAgent, ensureEnvironment, loadConfig, loadLayer, orcaClient, runMain, runTurn, stateFor } from './common.js'; +import { agentParams, ensureAgent, ensureEnvironment, loadConfig, loadLayer, openSession, orcaClient, runMain, runTurn, stateFor } from './common.js'; const QUESTION = 'Hi! What is the Data + Agent Hackathon, and what can you see right now?'; @@ -19,22 +19,19 @@ async function main(): Promise<void> { // 1. An environment: where your agent's sessions run. const environmentId = await ensureEnvironment(client, state, `hello-env-${config.participant}`); - // 2. An agent: a model plus a system prompt, from agent/l1-hello.json. - const layer = loadLayer('l1-hello'); + // 2. An agent: a model plus a system prompt, from agent/<stack>/l1-hello.json. + const layer = loadLayer('l1-hello', config.stack); const agent = await ensureAgent(client, state, agentParams(layer, config)); console.log(`${agent.name} v${agent.version}: ${layer.summary}`); // 3. A session: one conversation, pinned to this exact agent version. - const session = await client.sessions.create({ - environment_id: environmentId, - agent: { type: 'agent', id: agent.id, version: agent.version }, - title: 'L1: hello', - }); + const session = await openSession(client, state, environmentId, agent, 'L1: hello'); - // 4. Send a message and stream the agent's reply. + // 4. Send a message and stream the agent's reply. (The engine on your laptop is + // spoken to first and listened to second: see runTurn.) const question = process.argv.slice(2).join(' ') || QUESTION; console.log(`[you] ${question}`); - await runTurn(client, session.id, question); + await runTurn(client, session.id, question, { sendFirst: config.stack === 'local' }); } await runMain(main); diff --git a/typescript/src/l3-live-context.ts b/typescript/src/l3-live-context.ts index a3e4e15..89a1864 100644 --- a/typescript/src/l3-live-context.ts +++ b/typescript/src/l3-live-context.ts @@ -1,39 +1,47 @@ /** * L3 - Agent + live context. * - * Upgrades your agent with read-only StreamNative MCP tools, gives the session a - * vault holding the MCP credential, and opens a conversation. Ask, run - * `npm run inject` in a second terminal, then ask again: the answer changes. + * Upgrades your agent with read-only SQL tools from an MCP server and opens a + * conversation. Ask, run `npm run inject` in a second terminal, then ask again: + * the answer changes. * * npm run l3 */ -import { agentParams, chat, ensureAgent, ensureEnvironment, ensureVault, loadConfig, loadLayer, orcaClient, runMain, stateFor } from './common.js'; +import { + agentParams, + chat, + ensureAgent, + ensureEnvironment, + loadConfig, + loadLayer, + mcpVaultIds, + openSession, + orcaClient, + runMain, + stateFor, +} from './common.js'; const QUESTION = 'Which accounts look like an account takeover right now?'; async function main(): Promise<void> { - const config = loadConfig(['ORCA_BASE_URL', 'ORCA_MODEL', 'SN_MCP_URL']); + const config = loadConfig(['ORCA_BASE_URL', 'ORCA_MODEL']); const client = orcaClient(config); const state = stateFor(config); const environmentId = await ensureEnvironment(client, state, `hello-env-${config.participant}`); - // The same agent, next version: agent/l3-live-context.json adds the MCP server. - const layer = loadLayer('l3-live-context'); + // The same agent, next version: agent/<stack>/l3-live-context.json adds the MCP server. + const layer = loadLayer('l3-live-context', config.stack); const agent = await ensureAgent(client, state, agentParams(layer, config)); console.log(`${agent.name} v${agent.version}: ${layer.summary}`); - // The MCP server needs a credential. It goes in a vault, never in the prompt. - const vaultId = await ensureVault(client, state, `hello-vault-${config.participant}`, config); + // StreamNative Cloud's MCP server needs a credential. It goes in a vault, never + // in the prompt. The MCP server on your laptop takes none, so there is no vault. + const vaultIds = await mcpVaultIds(client, state, config); - const session = await client.sessions.create({ - environment_id: environmentId, - agent: { type: 'agent', id: agent.id, version: agent.version }, - vault_ids: [vaultId], - title: 'L3: live context', - }); + const session = await openSession(client, state, environmentId, agent, 'L3: live context', { vaultIds }); console.log('Tip: after the first answer, run `npm run inject` in another terminal and ask again.\n'); - await chat(client, session.id, QUESTION); + await chat(client, session.id, QUESTION, { sendFirst: config.stack === 'local' }); } await runMain(main); diff --git a/typescript/src/l4-act.ts b/typescript/src/l4-act.ts index 8a734a6..515507c 100644 --- a/typescript/src/l4-act.ts +++ b/typescript/src/l4-act.ts @@ -13,9 +13,10 @@ import { chat, ensureAgent, ensureEnvironment, - ensureVault, loadConfig, loadLayer, + mcpVaultIds, + openSession, orcaClient, runMain, stateFor, @@ -24,26 +25,21 @@ import { const REQUEST = 'Flag the account most likely to be under attack right now.'; async function main(): Promise<void> { - const config = loadConfig(['ORCA_BASE_URL', 'ORCA_MODEL', 'SN_MCP_URL']); + const config = loadConfig(['ORCA_BASE_URL', 'ORCA_MODEL']); const client = orcaClient(config); const state = stateFor(config); const environmentId = await ensureEnvironment(client, state, `hello-env-${config.participant}`); - // Next version again: agent/l4-act.json enables one write tool, always_ask. - const layer = loadLayer('l4-act'); + // Next version again: agent/<stack>/l4-act.json enables one write tool, always_ask. + const layer = loadLayer('l4-act', config.stack); const agent = await ensureAgent(client, state, agentParams(layer, config)); console.log(`${agent.name} v${agent.version}: ${layer.summary}`); - const vaultId = await ensureVault(client, state, `hello-vault-${config.participant}`, config); - const session = await client.sessions.create({ - environment_id: environmentId, - agent: { type: 'agent', id: agent.id, version: agent.version }, - vault_ids: [vaultId], - title: 'L4: act with approval', - }); + const vaultIds = await mcpVaultIds(client, state, config); + const session = await openSession(client, state, environmentId, agent, 'L4: act with approval', { vaultIds }); // askHuman is called whenever the session pauses for approval. - await chat(client, session.id, REQUEST, { confirm: (toolUse) => askHuman(toolUse) }); + await chat(client, session.id, REQUEST, { confirm: (toolUse) => askHuman(toolUse), sendFirst: config.stack === 'local' }); } await runMain(main); diff --git a/typescript/src/seed.ts b/typescript/src/seed.ts new file mode 100644 index 0000000..2e68871 --- /dev/null +++ b/typescript/src/seed.ts @@ -0,0 +1,103 @@ +/** + * Load the login stream into the topic on your laptop (Local course, Lab 0). + * + * Replays data/login_events.jsonl: 246 synthetic logins at a fictional bank, with + * their timestamps moved to now. One of the accounts in it is under attack. + * + * npm run seed + * npm run seed -- --force # load another copy into a topic that already has events + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { Kafka, logLevel } from 'kafkajs'; + +import { REPO_ROOT, kafkaClientConfig, loadConfig, runMain, type Config } from './common.js'; +import { loginProducer, publish, type LoginEvent } from './inject.js'; + +const EVENTS_FILE = join(REPO_ROOT, 'data', 'login_events.jsonl'); +const SCHEMA_FILE = join(REPO_ROOT, 'schemas', 'login_events.avsc'); + +/** One login event per line. */ +export function loadEvents(path: string = EVENTS_FILE): LoginEvent[] { + return readFileSync(path, 'utf8') + .split(/\r?\n/) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); +} + +/** The same events, moved in time so the newest one happens now. Every gap is kept. */ +export function rebaseEvents(events: LoginEvent[], now: Date): LoginEvent[] { + const shift = now.getTime() - Math.max(...events.map((event) => event.event_time)); + return events.map((event) => ({ ...event, event_time: event.event_time + shift, ingested_at: event.ingested_at + shift })); +} + +/** How many events a topic holds, from each partition's low and high offsets. */ +export function eventsInTopic(watermarks: Array<{ low: string; high: string }>): number { + return watermarks.reduce((total, { low, high }) => total + Number(high) - Number(low), 0); +} + +/** The seed cannot go ahead. The message says why and what to do, the way `sys.exit(message)` does in the Python path. */ +export class SeedError extends Error {} + +/** What countExisting uses from a kafkajs admin client: the real one fits, and so do the test fakes. */ +export interface TopicAdmin { + connect(): Promise<void>; + listTopics(): Promise<string[]>; + fetchTopicOffsets(topic: string): Promise<Array<{ low: string; high: string }>>; + disconnect(): Promise<void>; +} + +/** How many events the topic holds already. Stops the script when it cannot tell. */ +export async function countExisting(admin: TopicAdmin, topic: string): Promise<number> { + try { + await admin.connect(); + // Listing every topic avoids a metadata request that could create a missing one. + if (!(await admin.listTopics()).includes(topic)) { + throw new SeedError(`The topic ${topic} does not exist yet. Create it first: Local course, Lab 0.`); + } + return eventsInTopic(await admin.fetchTopicOffsets(topic)); + } catch (err) { + if (err instanceof SeedError) throw err; + const reason = err instanceof Error ? err.message : String(err); + throw new SeedError(`Could not read ${topic}: ${reason}\nRun \`npm run doctor\` to check your setup.`); + } finally { + await admin.disconnect().catch(() => {}); + } +} + +function topicAdmin(config: Config): TopicAdmin { + return new Kafka({ clientId: 'hello-seed', ...kafkaClientConfig(config), logLevel: logLevel.NOTHING, retry: { retries: 2 } }).admin(); +} + +async function main(): Promise<void> { + const config = loadConfig(['KAFKA_BOOTSTRAP_SERVERS', 'SCHEMA_REGISTRY_URL', 'LOGIN_TOPIC']); + if (config.stack !== 'local') { + throw new SeedError("`npm run seed` loads the topic on your laptop (Local course). Your team's cluster already holds the login stream."); + } + const topic = config.get('LOGIN_TOPIC'); + + const existing = await countExisting(topicAdmin(config), topic); + if (existing > 0 && !process.argv.slice(2).includes('--force')) { + throw new SeedError(`${topic} already holds ${existing} events, so it is seeded. To load another copy anyway: npm run seed -- --force`); + } + + const events = rebaseEvents(loadEvents(), new Date()); + // Registering the schema is what lets RisingWave decode the topic. + const errors = await publish(loginProducer(config, { schema: readFileSync(SCHEMA_FILE, 'utf8') }), topic, events); + if (errors.length > 0) throw new SeedError(`Could not write to ${topic}: ${errors[0]}\nRun \`npm run doctor\` to check your setup.`); + const accounts = new Set(events.map((event) => event.account_id)).size; + console.log(`Loaded ${events.length} logins for ${accounts} accounts into ${topic}.`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + await runMain(() => + main().catch((err: unknown) => { + if (!(err instanceof SeedError)) throw err; + console.error(err.message); + process.exit(1); + }), + ); +} diff --git a/typescript/test/doctor.test.ts b/typescript/test/doctor.test.ts index 30b60a5..44d46a0 100644 --- a/typescript/test/doctor.test.ts +++ b/typescript/test/doctor.test.ts @@ -1,8 +1,24 @@ /** doctor.ts: the decisions behind each check (the network probes are exercised end to end). */ +import { existsSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; -import { checkLoginSchema, checkMcpTools, checkNode, checkOrcaBaseUrl, errorText, kafkaHint, parseMcpResponse } from '../src/doctor.js'; +import { + check as newCheck, + checkLoginSchema, + checkMcpQuery, + checkMcpTools, + checkNode, + checkOrcaBaseUrl, + errorText, + kafkaHint, + mcpHeaders, + parseMcpResponse, + requiredFor, + schemaRegistryHint, + summarize, +} from '../src/doctor.js'; describe('checkOrcaBaseUrl', () => { it.each(['https://ws.example.com', 'https://ws.example.com/'])('a host root URL passes: %s', (url) => { @@ -53,6 +69,18 @@ describe('checkLoginSchema', () => { expect(check.ok).toBe(false); expect(check.fix).toContain('outcome'); }); + + it.each(['cloud', 'local'] as const)('the `outcome` fix names SQL files that exist for the %s stack', (stack) => { + const check = checkLoginSchema([...LOGIN_FIELDS.filter((f) => f !== 'result'), 'outcome'], stack); + + const named = check.fix?.match(/sql\/[\w/.]+\.sql/g) ?? []; + + expect(named).toHaveLength(2); + for (const name of named) { + expect(name.startsWith(`sql/${stack}/`)).toBe(true); + expect(existsSync(new URL(`../../${name}`, import.meta.url))).toBe(true); + } + }); }); describe('checkMcpTools', () => { @@ -135,3 +163,127 @@ describe('local Agent Engine URL', () => { expect(result.fix).toContain('http://127.0.0.1:8080'); }); }); + +describe('the two stacks', () => { + it('the cloud stack needs the team card', () => { + const required = requiredFor('cloud'); + + expect(required).toEqual(expect.arrayContaining(['SN_API_KEY', 'SN_SERVICE_ACCOUNT', 'SN_MCP_URL'])); + expect(required).not.toContain('RW_MCP_URL'); + }); + + it('the local stack needs no team card values', () => { + const required = requiredFor('local'); + + expect(required).toEqual(expect.arrayContaining(['ORCA_API_KEY', 'KAFKA_BOOTSTRAP_SERVERS', 'SCHEMA_REGISTRY_URL', 'RW_MCP_URL', 'RW_MCP_LOCAL_URL'])); + expect(required.filter((name) => name.startsWith('SN_'))).toEqual([]); + }); + + it('the local MCP server must offer the tools the local agent uses', () => { + const offered = ['run_select_query', 'describe_table', 'insert_multiple_rows', 'drop_table', 'list_databases']; + + expect(checkMcpTools(offered, 'local').ok).toBe(true); + }); + + it('missing local MCP tools are named and the fix is local', () => { + const check = checkMcpTools(['run_select_query'], 'local'); + + expect(check.ok).toBe(false); + expect(check.detail).toContain('describe_table'); + expect(check.detail).toContain('insert_multiple_rows'); + expect(check.fix).not.toContain('facilitator'); + }); + + it('a SELECT through MCP that returns a row passes', () => { + expect(checkMcpQuery('[\n {\n "ready": 1\n }\n]').ok).toBe(true); + }); + + it.each(['Error executing query: connection refused', '[]', 'not json'])( + 'a SELECT through MCP that returns no row fails with what came back: %s', + (text) => { + const check = checkMcpQuery(text); + + expect(check.ok).toBe(false); + expect(check.detail).toContain(text); + }, + ); + + it.each([ + ['KafkaError{code=_TRANSPORT,val=-195,str="127.0.0.1:29092/bootstrap: Connect to ipv4#127.0.0.1:29092 failed: Connection refused"}', 'local/compose.yaml'], + // The same failure, as kafkajs reports it. + ['KafkaJSNumberOfRetriesExceeded: Connection error: connect ECONNREFUSED 127.0.0.1:29092', 'local/compose.yaml'], + ['not found', 'Lab 0'], + ])('local Kafka error %s points at the local stack', (error, advice) => { + const hint = kafkaHint(error, 'local'); + + expect(hint).toContain(advice); + expect(hint).not.toContain('facilitator'); + expect(hint).not.toContain('team card'); + }); + + it('a local schema that is not registered yet points at the seeder', () => { + const hint = schemaRegistryHint(`HTTP 404: {"error_code":40401,"message":"Subject 'security.login_events-value' not found."}`, 'local'); + + expect(hint).toContain('npm run seed'); + expect(hint).toContain('Lab 0'); + }); + + it('an unreachable local schema registry points at the streaming stack', () => { + const hint = schemaRegistryHint('fetch failed: connect ECONNREFUSED 127.0.0.1:18081', 'local'); + + expect(hint).toContain('local/compose.yaml'); + expect(hint).not.toContain('seed'); + }); + + it.each(['fetch failed: connect ECONNREFUSED 10.0.0.1:443', 'HTTP 401: unauthorized'])('cloud schema registry error %s points at the team card', (error) => { + const hint = schemaRegistryHint(error); + + expect(hint).toContain('SCHEMA_REGISTRY_URL'); + expect(hint).not.toContain('compose'); + }); + + it('the MCP probe sends a bearer token only when it has one', () => { + expect(mcpHeaders('the-token').Authorization).toBe('Bearer the-token'); + expect(Object.keys(mcpHeaders())).not.toContain('Authorization'); + }); +}); + +describe('the verdict', () => { + it('all checks passing is a zero exit', () => { + const { code, verdict } = summarize([newCheck('a', true), newCheck('b', true)]); + + expect(code).toBe(0); + expect(verdict).toContain('ready'); + }); + + it('a failed check is a nonzero exit and is counted', () => { + const { code, verdict } = summarize([newCheck('a', true), newCheck('b', false, 'boom', 'fix it')]); + + expect(code).toBe(1); + expect(verdict).toContain('1 check(s) failed'); + }); + + it('a waiting check does not fail the doctor', () => { + const waiting = newCheck('MCP OAuth', false, 'no tutorial vault yet', 'Lab 3 authorizes it.', { wait: true }); + + const { code, verdict } = summarize([newCheck('a', true), waiting]); + + expect(code).toBe(0); + expect(verdict).toContain('1 check(s) wait'); + }); + + it('a failure wins over a waiting check', () => { + const waiting = newCheck('MCP OAuth', false, '', '', { wait: true }); + + const { code, verdict } = summarize([newCheck('a', false), waiting]); + + expect(code).toBe(1); + expect(verdict).toContain('1 check(s) failed'); + }); + + it('each check is labelled', () => { + expect(newCheck('a', true).label).toBe('PASS'); + expect(newCheck('a', false).label).toBe('FAIL'); + expect(newCheck('a', false, '', '', { wait: true }).label).toBe('WAIT'); + }); +}); diff --git a/typescript/test/fakes.ts b/typescript/test/fakes.ts index 7558e00..62d5bd0 100644 --- a/typescript/test/fakes.ts +++ b/typescript/test/fakes.ts @@ -17,6 +17,8 @@ import type { EnvironmentCreateParams, EventSendParams, EventSendResponse, + Session, + SessionCreateParams, SessionEvent, SessionEventInput, Vault, @@ -98,7 +100,64 @@ export class FakeSessionEvents { } } -export function clientWithEvents(events: FakeSessionEvents) { +/** A server-sent-event stream over a session's log, starting at `position`. */ +class LogStream implements AsyncIterable<SessionEvent> { + constructor( + private readonly log: SessionEvent[], + private position: number, + ) {} + + async *[Symbol.asyncIterator](): AsyncIterator<SessionEvent> { + // Events logged while the stream is open are delivered too. + while (this.position < this.log.length) { + yield this.log[this.position++]; + } + } +} + +/** + * A scripted session on the engine that `ork local` runs (0.5.1), as observed. + * + * The session is one log of events. What you send is logged, with no + * `processed_at` yet. A stream opened with `from_cursor: '0'` replays the log + * from its start and then follows it; without a cursor it starts at the live + * edge and shows only what is logged after it was opened. + * + * `reactions[i]` are the events the agent emits after the i-th `send()` call. + */ +export class OrkLocalSessionEvents { + readonly calls: Array<['stream', string, string | undefined] | ['send', string, SessionEventInput[]]> = []; + private readonly reactions: RawEvent[][]; + private readonly log: SessionEvent[] = []; + private ticks = 0; + private ids = 0; + + constructor(reactions: RawEvent[][]) { + this.reactions = [...reactions]; + } + + private now(): string { + this.ticks += 1; + return `2026-10-07T10:00:${String(this.ticks).padStart(2, '0')}.000Z`; + } + + async stream(sessionId: string, params: { from_cursor?: string } = {}): Promise<AsyncIterable<SessionEvent>> { + this.calls.push(['stream', sessionId, params.from_cursor]); + return new LogStream(this.log, params.from_cursor === '0' ? 0 : this.log.length); + } + + async send(sessionId: string, params: EventSendParams): Promise<EventSendResponse> { + this.calls.push(['send', sessionId, params.events]); + const persisted = params.events.map((e) => ({ id: `evt_user_${++this.ids}`, processed_at: null, ...e }) as SessionEvent); + this.log.push(...persisted); + for (const reaction of this.reactions.shift() ?? []) { + this.log.push({ processed_at: this.now(), ...reaction } as SessionEvent); + } + return { data: persisted }; + } +} + +export function clientWithEvents<E extends FakeSessionEvents | OrkLocalSessionEvents>(events: E) { return { sessions: { events } }; } @@ -290,6 +349,49 @@ export class FakeVaults { } } +export class FakeSessions { + readonly calls: Array<['create', SessionCreateParams]> = []; + + async create(params: SessionCreateParams): Promise<Session> { + this.calls.push(['create', params]); + const agent = params.agent as { id: string; version: number }; + return { + id: `ses_${this.calls.length}`, + type: 'session', + agent: { + id: agent.id, + type: 'agent', + name: 'hello-agent', + description: null, + version: agent.version, + model: { id: 'claude-sonnet-4-6' }, + system: null, + tools: [], + mcp_servers: [], + skills: [], + multiagent: null, + }, + environment_id: params.environment_id, + vault_ids: params.vault_ids ?? [], + status: 'idle', + title: params.title ?? null, + stats: { active_seconds: 0, duration_seconds: 0 }, + outcome_evaluations: [], + usage: { input_tokens: 0, output_tokens: 0 }, + resources: [], + metadata: {}, + created_at: NOW, + updated_at: NOW, + archived_at: null, + }; + } +} + export function fakeClient(takenEnvNames: Iterable<string> = []) { - return { agents: new FakeAgents(), environments: new FakeEnvironments(takenEnvNames), vaults: new FakeVaults() }; + return { + agents: new FakeAgents(), + environments: new FakeEnvironments(takenEnvNames), + vaults: new FakeVaults(), + sessions: new FakeSessions(), + }; } diff --git a/typescript/test/inject.test.ts b/typescript/test/inject.test.ts index 4857a31..ec50c8c 100644 --- a/typescript/test/inject.test.ts +++ b/typescript/test/inject.test.ts @@ -5,7 +5,7 @@ import { readFileSync } from 'node:fs'; import avro from 'avsc'; import { describe, expect, it } from 'vitest'; -import { buildBurst, newAccountId } from '../src/inject.js'; +import { buildBurst, newAccountId, publish, type LoginEvent } from '../src/inject.js'; const SCHEMA = avro.Type.forSchema(JSON.parse(readFileSync(new URL('../../schemas/login_events.avsc', import.meta.url), 'utf8'))); const NOW = new Date(Date.UTC(2026, 9, 7, 10, 30)); @@ -62,3 +62,66 @@ describe('buildBurst', () => { expect(burst().map((r) => r.event_time)).toEqual([0, 1, 2, 3, 4, 5, 6].map((i) => 1791369000000 + 2000 * i)); }); }); + +/** The two methods of loginProducer's producer that publish uses. */ +class FakeProducer { + readonly produced: Array<[topic: string, key: string, value: LoginEvent]> = []; + disconnects = 0; + + constructor(private readonly failure?: Error) {} + + async send(topic: string, messages: Array<{ key: string; value: LoginEvent }>): Promise<void> { + if (this.failure) throw this.failure; + for (const { key, value } of messages) this.produced.push([topic, key, value]); + } + + async disconnect(): Promise<void> { + this.disconnects += 1; + } +} + +describe('publish', () => { + it('writes every record keyed by account', async () => { + const producer = new FakeProducer(); + + const errors = await publish(producer, 'security.login_events', burst()); + + expect(errors).toEqual([]); + expect(producer.produced).toHaveLength(7); + expect(new Set(producer.produced.map(([topic, key]) => `${topic} ${key}`))).toEqual(new Set(['security.login_events acct_9123'])); + }); + + it('records of different accounts get their own keys', async () => { + const producer = new FakeProducer(); + const records = [burst()[0], buildBurst('acct_9456', '203.0.113.78', NOW)[0]]; + + await publish(producer, 'security.login_events', records); + + expect(producer.produced.map(([, key]) => key)).toEqual(['acct_9123', 'acct_9456']); + }); + + it('a Schema Registry failure is reported instead of raised', async () => { + const producer = new FakeProducer(new Error("Confluent_Schema_Registry - Subject 'security.login_events-value' not found.")); + + const errors = await publish(producer, 'security.login_events', burst()); + + expect(errors.some((e) => e.includes('not found'))).toBe(true); + }); + + it('a delivery failure is reported', async () => { + const producer = new FakeProducer(new Error('Broker: Topic authorization failed')); + + expect(await publish(producer, 't', burst())).toEqual(['Broker: Topic authorization failed']); + }); + + it('hangs up afterwards, whether or not the write worked', async () => { + // kafkajs keeps the process alive until its producer disconnects. + const delivered = new FakeProducer(); + const failed = new FakeProducer(new Error('Broker: Topic authorization failed')); + + await publish(delivered, 't', burst()); + await publish(failed, 't', burst()); + + expect([delivered.disconnects, failed.disconnects]).toEqual([1, 1]); + }); +}); diff --git a/typescript/test/login-producer.test.ts b/typescript/test/login-producer.test.ts new file mode 100644 index 0000000..f02fcfa --- /dev/null +++ b/typescript/test/login-producer.test.ts @@ -0,0 +1,162 @@ +/** loginProducer: the Avro producer `npm run inject` and `npm run seed` share. */ + +import { readFileSync } from 'node:fs'; +import { createServer, type Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import avro from 'avsc'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { Config } from '../src/common.js'; +import { buildBurst, loginProducer } from '../src/inject.js'; + +// kafkajs without a broker: what the producer was configured with, and what it was asked to send. +const kafka = vi.hoisted(() => ({ + configs: [] as Array<Record<string, any>>, + calls: [] as string[], + sent: [] as Array<{ topic: string; acks: number; messages: Array<{ key: string; value: Buffer }> }>, +})); + +vi.mock('kafkajs', async (importOriginal) => ({ + ...(await importOriginal<typeof import('kafkajs')>()), + Kafka: class { + constructor(config: Record<string, any>) { + kafka.configs.push(config); + } + + producer() { + return { + connect: async () => void kafka.calls.push('connect'), + send: async (record: (typeof kafka.sent)[number]) => void (kafka.calls.push('send'), kafka.sent.push(record)), + disconnect: async () => void kafka.calls.push('disconnect'), + }; + } + }, +})); + +const TOPIC = 'security.login_events'; +const SUBJECT = `${TOPIC}-value`; +const SCHEMA_ID = 7; +const SCHEMA_TEXT = readFileSync(new URL('../../schemas/login_events.avsc', import.meta.url), 'utf8'); +const SCHEMA = avro.Type.forSchema(JSON.parse(SCHEMA_TEXT)); +const [RECORD] = buildBurst('acct_9123', '203.0.113.77', new Date(Date.UTC(2026, 9, 7, 10, 30))); + +/** A stand-in for the Schema Registry's REST API: the calls a producer makes, and nothing else. */ +class StubRegistry { + readonly requests: Array<{ line: string; authorization?: string; body: string }> = []; + subjects = [SUBJECT]; + private server!: Server; + + async start(): Promise<string> { + this.server = createServer(async (request, response) => { + let body = ''; + for await (const chunk of request) body += chunk; + const line = `${request.method} ${request.url}`; + this.requests.push({ line, authorization: request.headers.authorization, body }); + const [status, reply] = this.answer(line); + response.writeHead(status, { 'Content-Type': 'application/vnd.schemaregistry.v1+json' }); + response.end(JSON.stringify(reply)); + }); + await new Promise<void>((resolve) => this.server.listen(0, '127.0.0.1', resolve)); + return `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`; + } + + private answer(line: string): [number, unknown] { + const subject = this.subjects.find((name) => line.includes(`/${name}`)); + if (line === `GET /schemas/ids/${SCHEMA_ID}`) return [200, { schema: SCHEMA_TEXT }]; + if (subject && line === `GET /subjects/${subject}/versions/latest`) return [200, { subject, version: 1, id: SCHEMA_ID, schema: SCHEMA_TEXT }]; + if (line.startsWith('POST /subjects/') && line.endsWith('/versions')) return [200, { id: SCHEMA_ID }]; + if (line.startsWith('PUT /config/')) return [200, { compatibility: 'BACKWARD' }]; + // What the local registry (Karapace) answers for a subject it has no entry for. + return [404, { error_code: 40401, message: `Subject '${line.split('/')[2]}' not found.` }]; + } + + lines(): string[] { + return this.requests.map((request) => request.line); + } + + async stop(): Promise<void> { + this.server.closeAllConnections(); + await new Promise((resolve) => this.server.close(resolve)); + } +} + +/** Undo the Confluent wire format: a zero byte, the schema id, then the Avro record. */ +function decode(value: Buffer): { schemaId: number; record: unknown } { + expect(value.readUInt8(0)).toBe(0); + return { schemaId: value.readUInt32BE(1), record: SCHEMA.fromBuffer(value.subarray(5)) }; +} + +let registry: StubRegistry; +let local: Config; +let cloud: Config; + +beforeEach(async () => { + kafka.configs.length = 0; + kafka.calls.length = 0; + kafka.sent.length = 0; + registry = new StubRegistry(); + const url = await registry.start(); + local = new Config({ TUTORIAL_STACK: 'local', KAFKA_BOOTSTRAP_SERVERS: '127.0.0.1:29092', SCHEMA_REGISTRY_URL: url }, 'jane'); + cloud = new Config( + { KAFKA_BOOTSTRAP_SERVERS: 'kafka.example.com:9093', SCHEMA_REGISTRY_URL: url, SN_SERVICE_ACCOUNT: 'team-07@o-test.auth.streamnative.cloud', SN_API_KEY: 'the-api-key' }, + 'jane', + ); +}); + +afterEach(() => registry.stop()); + +describe('loginProducer', () => { + it('writes with the schema the topic already has, and never registers one', async () => { + await loginProducer(local).send(TOPIC, [{ key: 'acct_9123', value: RECORD }]); + + expect(registry.lines()).toEqual([`GET /subjects/${SUBJECT}/versions/latest`, `GET /schemas/ids/${SCHEMA_ID}`]); + expect(kafka.calls).toEqual(['connect', 'send']); + }); + + it('sends each record under its key, in the format RisingWave decodes', async () => { + await loginProducer(local).send(TOPIC, [{ key: 'acct_9123', value: RECORD }]); + + const [{ topic, acks, messages }] = kafka.sent; + expect([topic, acks]).toEqual([TOPIC, -1]); + expect(messages.map((message) => message.key)).toEqual(['acct_9123']); + expect(decode(messages[0].value)).toEqual({ schemaId: SCHEMA_ID, record: RECORD }); + }); + + it('registers the schema it is given, then writes with it', async () => { + await loginProducer(local, { schema: SCHEMA_TEXT }).send(TOPIC, [{ key: 'acct_9123', value: RECORD }]); + + const registered = registry.requests.filter((request) => request.line === `POST /subjects/${SUBJECT}/versions`); + expect(registered.map((request) => JSON.parse(request.body).schema)).toEqual([SCHEMA_TEXT]); + expect(registry.lines()).not.toContain(`GET /subjects/${SUBJECT}/versions/latest`); + expect(decode(kafka.sent[0].messages[0].value)).toEqual({ schemaId: SCHEMA_ID, record: RECORD }); + }); + + it('a topic without a schema is reported before Kafka is contacted', async () => { + registry.subjects = []; + + await expect(loginProducer(local).send(TOPIC, [{ key: 'acct_9123', value: RECORD }])).rejects.toThrow(/not found/); + + expect(kafka.calls).toEqual([]); + }); + + it('on the local stack it logs in to neither Kafka nor the registry', async () => { + await loginProducer(local).send(TOPIC, [{ key: 'acct_9123', value: RECORD }]); + + expect(kafka.configs[0].brokers).toEqual(['127.0.0.1:29092']); + expect([kafka.configs[0].ssl, kafka.configs[0].sasl]).toEqual([undefined, undefined]); + expect(registry.requests.map((request) => request.authorization)).toEqual([undefined, undefined]); + }); + + it('on the cloud stack it logs in to both with the service account', async () => { + await loginProducer(cloud).send(TOPIC, [{ key: 'acct_9123', value: RECORD }]); + + expect(kafka.configs[0]).toMatchObject({ + brokers: ['kafka.example.com:9093'], + ssl: true, + sasl: { mechanism: 'plain', username: 'team-07@o-test.auth.streamnative.cloud', password: 'the-api-key' }, + }); + const basic = `Basic ${Buffer.from('team-07@o-test.auth.streamnative.cloud:the-api-key').toString('base64')}`; + expect(registry.requests.map((request) => request.authorization)).toEqual([basic, basic]); + }); +}); diff --git a/typescript/test/mcp-oauth.test.ts b/typescript/test/mcp-oauth.test.ts index 48cb78b..74873ac 100644 --- a/typescript/test/mcp-oauth.test.ts +++ b/typescript/test/mcp-oauth.test.ts @@ -92,15 +92,64 @@ it.each([['valid', true], ['invalid', false], ['unknown', false]] as const)('doc const validate = vi.spyOn(client.vaults.credentials, 'validate').mockResolvedValue({ status } as Awaited<ReturnType<typeof client.vaults.credentials.validate>>); const check = await probeMcp(config(), client, state); expect(check.ok).toBe(ok); + expect(check.label).toBe(ok ? 'PASS' : 'FAIL'); expect(check.detail).toContain('initialization'); if (status === 'unknown') expect(check.fix).toContain('keep the existing credential'); - if (status === 'invalid') expect(check.fix).toContain('credentials archive cred_oauth'); + if (status === 'invalid') { + expect(check.fix).toContain('credentials archive cred_oauth'); + expect(check.fix).toContain('Lab 3'); + } + expectLabsNamedLikeTheCourse(`${check.detail} ${check.fix}`); expect(validate).toHaveBeenCalledWith(vaultId, 'cred_oauth'); }); -it('doctor asks for OAuth setup before first L3', async () => { +/** The course says "Lab 3", so the scripts do too: never "L3" or "L4". */ +function expectLabsNamedLikeTheCourse(text: string): void { + expect(text).not.toMatch(/\bL[0-9]\b/); +} + +it('doctor names the lab when the MCP server cannot be checked', async () => { + const fake = fakeClient(); + await seed(fake); + const client = new Orca({ baseURL: 'https://registry.example.com', apiKey: 'test' }); + vi.spyOn(client.vaults.credentials, 'list').mockRejectedValue(new Error('HTTP 502')); + + const check = await probeMcp(config(), client, state); + + expect(check.label).toBe('FAIL'); + expect(check.detail).toContain('HTTP 502'); + expect(check.fix).toContain('Lab 3'); + expectLabsNamedLikeTheCourse(`${check.detail} ${check.fix}`); +}); + +it('a failed OAuth login names the lab to run again', () => { + vi.mocked(spawnSync).mockReturnValue({ status: 1 } as ReturnType<typeof spawnSync>); + + let message = ''; + try { + authorizeMcp('vlt_1', config()); + } catch (err) { + message = (err as Error).message; + } + + expect(message).toContain('Lab 3'); + expectLabsNamedLikeTheCourse(message); +}); + +it('doctor before the first OAuth login waits for Lab 3', async () => { const client = new Orca({ baseURL: 'https://registry.example.com', apiKey: 'test' }); const check = await probeMcp(config(), client, state); - expect(check.ok).toBe(false); - expect(check.fix).toContain('Run L3'); + expect(check.wait).toBe(true); + expect(check.label).toBe('WAIT'); + expect(check.fix).toContain('Lab 3'); +}); + +it('doctor waits when the vault has no OAuth credential yet', async () => { + const fake = fakeClient(); + const vaultId = await seed(fake, 'static_bearer'); + const client = new Orca({ baseURL: 'https://registry.example.com', apiKey: 'test' }); + vi.spyOn(client.vaults.credentials, 'list').mockImplementation(() => fake.vaults.credentials.list(vaultId) as ReturnType<typeof client.vaults.credentials.list>); + const check = await probeMcp(config(), client, state); + expect(check.wait).toBe(true); + expect(check.fix).toContain('Lab 3'); }); diff --git a/typescript/test/scripts-support.test.ts b/typescript/test/scripts-support.test.ts index 2ebc538..a3de292 100644 --- a/typescript/test/scripts-support.test.ts +++ b/typescript/test/scripts-support.test.ts @@ -8,7 +8,7 @@ import { APIConnectionError, AuthenticationError } from '@runorca/orca-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Config, ConfigError, State, TurnError, askHuman, chat, cleanup, ensureAgent, ensureEnvironment, ensureVault, runMain } from '../src/common.js'; -import { FakeSessionEvents, clientWithEvents, fakeClient, type RawEvent } from './fakes.js'; +import { FakeSessionEvents, OrkLocalSessionEvents, clientWithEvents, fakeClient, type RawEvent } from './fakes.js'; import { MCP_URL, params } from './helpers.js'; function reply(t: string): RawEvent[] { @@ -36,6 +36,21 @@ describe('chat', () => { .map((c) => ((c[2] as Array<{ content: Array<{ text: string }> }>)[0].content[0].text)); expect(sent).toEqual(['who is under attack?', 'and now?']); }); + + it('sends first on every turn when asked to', async () => { + const events = new OrkLocalSessionEvents([reply('first'), reply('second')]); + const answers = ['and now?', '']; + const shown: string[] = []; + + await chat(clientWithEvents(events), 'sess_1', 'who is under attack?', { + ask: async () => answers.shift()!, + out: (line) => shown.push(line), + sendFirst: true, + }); + + expect(events.calls.map((call) => call[0])).toEqual(['send', 'stream', 'send', 'stream']); + expect(shown.filter((line) => line.startsWith('[agent]'))).toEqual(['[agent] first', '[agent] second']); + }); }); describe('askHuman', () => { diff --git a/typescript/test/seed.test.ts b/typescript/test/seed.test.ts new file mode 100644 index 0000000..42a35c5 --- /dev/null +++ b/typescript/test/seed.test.ts @@ -0,0 +1,159 @@ +/** seed.ts: the login stream the Local course loads into your topic. */ + +import { readFileSync } from 'node:fs'; +import { BlockList } from 'node:net'; + +import avro from 'avsc'; +import { describe, expect, it } from 'vitest'; + +import type { LoginEvent } from '../src/inject.js'; +import { SeedError, countExisting, eventsInTopic, loadEvents, rebaseEvents } from '../src/seed.js'; + +const TOPIC = 'security.login_events'; +const SCHEMA = avro.Type.forSchema(JSON.parse(readFileSync(new URL('../../schemas/login_events.avsc', import.meta.url), 'utf8'))); +const NOW = new Date(Date.UTC(2026, 9, 7, 10, 30)); +// The ranges reserved for documentation (RFC 5737): no real host has these addresses. +const DOCUMENTATION_RANGES = new BlockList(); +for (const network of ['192.0.2.0', '198.51.100.0', '203.0.113.0']) DOCUMENTATION_RANGES.addSubnet(network, 24); + +const count = (events: LoginEvent[], result: string) => events.filter((e) => e.result === result).length; + +describe('the seed', () => { + it("every seed event matches the topic's Avro schema", () => { + for (const record of loadEvents()) { + const problems: string[] = []; + SCHEMA.isValid(record, { errorHook: (path) => problems.push(path.join('.')) }); + expect(problems).toEqual([]); + expect(SCHEMA.fromBuffer(SCHEMA.toBuffer(record))).toEqual(record); + } + }); + + it('holds the 246 logins for 91 accounts that Lab 0 says it loads', () => { + const events = loadEvents(); + + expect(events).toHaveLength(246); + expect(new Set(events.map((e) => e.account_id)).size).toBe(91); + }); + + it('holds one account under attack', () => { + const logins = loadEvents().filter((e) => e.account_id === 'acct_0042'); + + expect(count(logins, 'FAILURE')).toBe(5); + expect(count(logins, 'SUCCESS')).toBe(2); + expect(new Set(logins.map((e) => e.ip_address)).size).toBe(2); + // The takeover: every failure comes before the attacker's success. + const attack = logins.filter((e) => e.scenario_id !== 'baseline').sort((a, b) => a.event_time - b.event_time); + expect(attack.map((e) => e.result)).toEqual([...Array(5).fill('FAILURE'), 'SUCCESS']); + }); + + it('no other account looks like a takeover', () => { + const byAccount = new Map<string, LoginEvent[]>(); + for (const event of loadEvents()) byAccount.set(event.account_id, [...(byAccount.get(event.account_id) ?? []), event]); + + const suspects = [...byAccount].filter(([, logins]) => count(logins, 'FAILURE') >= 5 && count(logins, 'SUCCESS') > 0).map(([account]) => account); + + expect(suspects).toEqual(['acct_0042']); + }); + + it('every address in the seed is from a documentation range', () => { + for (const event of loadEvents()) { + expect(DOCUMENTATION_RANGES.check(event.ip_address), event.ip_address).toBe(true); + } + }); + + it('seeded accounts do not collide with injected ones', () => { + // inject.ts attacks acct_9000..acct_9999. + expect(loadEvents().some((e) => e.account_id.startsWith('acct_9'))).toBe(false); + }); +}); + +describe('rebaseEvents', () => { + it('makes the newest event happen now', () => { + const rebased = rebaseEvents(loadEvents(), NOW); + + expect(Math.max(...rebased.map((e) => e.event_time))).toBe(NOW.getTime()); + }); + + it('keeps every gap between events', () => { + const original = loadEvents(); + const rebased = rebaseEvents(original, NOW); + const shift = rebased[0].event_time - original[0].event_time; + + expect(shift).not.toBe(0); + expect(rebased.every((after, i) => after.event_time - original[i].event_time === shift)).toBe(true); + expect(rebased.every((after, i) => after.ingested_at - original[i].ingested_at === shift)).toBe(true); + }); + + it('changes nothing but the two timestamps', () => { + const original = loadEvents(); + const rebased = rebaseEvents(original, NOW); + const stable = ({ event_time: _eventTime, ingested_at: _ingestedAt, ...rest }: LoginEvent) => rest; + + expect(rebased.map(stable)).toEqual(original.map(stable)); + expect(original).toEqual(loadEvents()); // the input is not modified + }); +}); + +/** A Kafka admin client that knows one topic, or none, or cannot reach its broker. */ +function fakeAdmin({ offsets = [{ low: '0', high: '246' }], unreachable = false }: { offsets?: Array<{ low: string; high: string }> | null; unreachable?: boolean } = {}) { + const admin = { + disconnected: false, + async connect() { + // What kafkajs says when nothing listens on the port. + if (unreachable) throw new Error('Connection error: connect ECONNREFUSED 127.0.0.1:29092'); + }, + listTopics: async () => (offsets ? [TOPIC] : []), + fetchTopicOffsets: async () => offsets ?? [], + async disconnect() { + admin.disconnected = true; + }, + }; + return admin; +} + +describe('countExisting', () => { + it('counts what the topic already holds', async () => { + const admin = fakeAdmin(); + + expect(await countExisting(admin, TOPIC)).toBe(246); + expect(admin.disconnected).toBe(true); + }); + + it('a missing topic stops the seed and points at Lab 0', async () => { + const admin = fakeAdmin({ offsets: null }); + + const failure = await countExisting(admin, TOPIC).catch((err: unknown) => err); + + expect(failure).toBeInstanceOf(SeedError); + expect((failure as Error).message).toContain('does not exist yet'); + expect((failure as Error).message).toContain('Lab 0'); + expect(admin.disconnected).toBe(true); + }); + + it('an unreachable broker stops the seed with the reason and a next step', async () => { + const admin = fakeAdmin({ unreachable: true }); + + const failure = await countExisting(admin, TOPIC).catch((err: unknown) => err); + + expect(failure).toBeInstanceOf(SeedError); + expect((failure as Error).message).toContain('ECONNREFUSED 127.0.0.1:29092'); + expect((failure as Error).message).toContain('npm run doctor'); + expect(admin.disconnected).toBe(true); + }); +}); + +describe('eventsInTopic', () => { + it('an empty topic counts no events', () => { + expect(eventsInTopic([{ low: '0', high: '0' }])).toBe(0); + }); + + it('events are counted across partitions from their watermarks', () => { + // kafkajs reports offsets as strings. + expect( + eventsInTopic([ + { low: '0', high: '246' }, + { low: '10', high: '17' }, + ]), + ).toBe(253); + }); +}); diff --git a/typescript/test/stack.test.ts b/typescript/test/stack.test.ts new file mode 100644 index 0000000..b22d045 --- /dev/null +++ b/typescript/test/stack.test.ts @@ -0,0 +1,272 @@ +/** TUTORIAL_STACK: a team card on StreamNative Cloud, or the whole stack on your laptop. */ + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; + +import type { AgentCreateParams, AgentUpdateParams } from '@runorca/orca-sdk'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + Config, + ConfigError, + State, + agentParams, + ensureAgent, + kafkaClientConfig, + loadConfig, + loadLayer, + mcpVaultIds, + openSession, + schemaRegistryConfig, + stateFor, +} from '../src/common.js'; +import { fakeClient } from './fakes.js'; +import { effectivePolicy } from './policy.js'; + +vi.mock('node:child_process', () => ({ spawnSync: vi.fn() })); + +const CLOUD = { + SN_API_KEY: 'the-api-key', + SN_SERVICE_ACCOUNT: 'team-07@o-test.auth.streamnative.cloud', + KAFKA_BOOTSTRAP_SERVERS: 'kafka.example.com:9093', + SCHEMA_REGISTRY_URL: 'https://sr.example.com', + SN_MCP_URL: 'https://mcp.example.com/mcp', + SN_MCP_AUTH: 'static_bearer', + ORCA_MODEL: 'claude-sonnet-4-6', +}; +const LOCAL = { + TUTORIAL_STACK: 'local', + KAFKA_BOOTSTRAP_SERVERS: '127.0.0.1:29092', + SCHEMA_REGISTRY_URL: 'http://127.0.0.1:18081', + RW_MCP_URL: 'http://risingwave-mcp:8000/mcp', + ORCA_MODEL: 'claude-sonnet-4-6', +}; + +const config = (values: Record<string, string>) => new Config(values, 'jane'); + +function without(values: Record<string, string>, name: string): Record<string, string> { + return Object.fromEntries(Object.entries(values).filter(([key]) => key !== name)); +} + +function messageOf(action: () => unknown): string { + try { + action(); + } catch (err) { + expect(err).toBeInstanceOf(ConfigError); + return (err as Error).message; + } + throw new Error('expected a ConfigError'); +} + +describe('the switch', () => { + it('a team card without the switch is the cloud stack', () => { + expect(config(CLOUD).stack).toBe('cloud'); + }); + + it('the local stack is chosen in the .env', () => { + expect(config(LOCAL).stack).toBe('local'); + }); + + it('an unknown stack is a config error', () => { + expect(messageOf(() => config({ TUTORIAL_STACK: 'laptop' }).stack)).toContain('TUTORIAL_STACK'); + }); + + it('each stack remembers its ids in its own file', () => { + expect(basename(stateFor(config(CLOUD)).path)).toBe('jane.json'); + expect(basename(stateFor(config(LOCAL)).path)).toBe('jane.local.json'); + }); +}); + +describe('Kafka and Schema Registry', () => { + it('the cloud stack reaches Kafka with SASL over TLS', () => { + expect(kafkaClientConfig(config(CLOUD))).toEqual({ + brokers: ['kafka.example.com:9093'], + ssl: true, + sasl: { mechanism: 'plain', username: 'team-07@o-test.auth.streamnative.cloud', password: 'the-api-key' }, + }); + }); + + it('the local stack reaches Kafka in plaintext without credentials', () => { + expect(kafkaClientConfig(config(LOCAL))).toEqual({ brokers: ['127.0.0.1:29092'] }); + }); + + it('a list of bootstrap servers becomes a list of brokers', () => { + const servers = { ...LOCAL, KAFKA_BOOTSTRAP_SERVERS: 'kafka-1.example.com:9093, kafka-2.example.com:9093,' }; + + expect(kafkaClientConfig(config(servers)).brokers).toEqual(['kafka-1.example.com:9093', 'kafka-2.example.com:9093']); + }); + + it('the cloud Schema Registry uses the service account', () => { + expect(schemaRegistryConfig(config(CLOUD))).toEqual({ + host: 'https://sr.example.com', + auth: { username: 'team-07@o-test.auth.streamnative.cloud', password: 'the-api-key' }, + }); + }); + + it('the local Schema Registry needs no credentials', () => { + expect(schemaRegistryConfig(config(LOCAL))).toEqual({ host: 'http://127.0.0.1:18081' }); + }); + + it('a cloud card without the service account names what is missing', () => { + const card = config(without(CLOUD, 'SN_SERVICE_ACCOUNT')); + + expect(messageOf(() => kafkaClientConfig(card))).toContain('SN_SERVICE_ACCOUNT'); + expect(messageOf(() => schemaRegistryConfig(card))).toContain('SN_SERVICE_ACCOUNT'); + }); +}); + +describe('hints', () => { + it('without a stack, the hint names both ways to get a .env file', () => { + const message = messageOf(() => loadConfig(['ORCA_BASE_URL'], {})); + + expect(message).toContain('.env.cloud.example'); + expect(message).toContain('local/write-env.sh'); + }); + + it('on the local stack, the hint is to write the .env file again', () => { + const message = messageOf(() => loadConfig(['ORCA_BASE_URL'], { TUTORIAL_STACK: 'local' })); + + expect(message).toContain('local/write-env.sh'); + expect(message).not.toContain('team card'); + }); + + it('a value read without checking for it first gets the same hint', () => { + const message = messageOf(() => config({ TUTORIAL_STACK: 'local' }).get('ORCA_BASE_URL')); + + expect(message).toContain('ORCA_BASE_URL'); + expect(message).toContain('local/write-env.sh'); + }); + + it('a missing placeholder on the local stack points at write-env', () => { + const noUrl = config(without(LOCAL, 'RW_MCP_URL')); + + expect(messageOf(() => agentParams(loadLayer('l3-live-context', 'local'), noUrl))).toMatch(/RW_MCP_URL.*local\/write-env\.sh/); + }); +}); + +describe('local agent definitions', () => { + it.each(['l1-hello', 'l3-live-context', 'l4-act'])('local %s produces exactly the fields the SDK accepts', (layer) => { + const params = agentParams(loadLayer(layer, 'local'), config(LOCAL)); + + // Checked by `npm run typecheck`: the params fit the SDK's create and update types. + const create: AgentCreateParams = params; + const update: AgentUpdateParams = { version: 1, ...params }; + expect(create).toBe(params); + expect(update.version).toBe(1); + expect(Object.keys(params).sort()).toEqual(['mcp_servers', 'metadata', 'model', 'name', 'system', 'tools']); + }); + + it('the local agent talks to the RisingWave MCP server', () => { + const params = agentParams(loadLayer('l3-live-context', 'local'), config(LOCAL)); + + expect(params.mcp_servers).toEqual([{ name: 'risingwave', type: 'url', url: 'http://risingwave-mcp:8000/mcp' }]); + }); + + it.each([ + ['l3-live-context', 'run_select_query', 'always_allow'], + ['l3-live-context', 'describe_table', 'disabled'], + ['l3-live-context', 'insert_multiple_rows', 'disabled'], + ['l3-live-context', 'drop_table', 'disabled'], + ['l3-live-context', 'execute_ddl_statement', 'disabled'], + ['l4-act', 'run_select_query', 'always_allow'], + ['l4-act', 'describe_table', 'always_allow'], + ['l4-act', 'insert_multiple_rows', 'always_ask'], + ['l4-act', 'insert_single_row', 'disabled'], + ['l4-act', 'delete_rows', 'disabled'], + ['l4-act', 'drop_table', 'disabled'], + ['l4-act', 'execute_ddl_statement', 'disabled'], + ])('local %s gives %s the permission %s', (layer, tool, expected) => { + const params = agentParams(loadLayer(layer, 'local'), config(LOCAL)); + + expect(effectivePolicy(params, 'risingwave', tool)).toBe(expected); + }); + + it('the two stacks do not share a definition fingerprint', () => { + const cloud = agentParams(loadLayer('l3-live-context', 'cloud'), config(CLOUD)); + const local = agentParams(loadLayer('l3-live-context', 'local'), config(LOCAL)); + + expect(cloud.metadata.definition_sha).not.toBe(local.metadata.definition_sha); + }); + + it.each([ + ['l1-hello', '457f86a77738415f'], + ['l3-live-context', 'bc4fde88405b950e'], + ['l4-act', 'db2876f6ae6d41f8'], + ])('local %s fingerprint matches the Python and CLI paths (%s)', (layer, expected) => { + // Same fingerprint everywhere, so switching languages does not bump the agent's version. + expect(agentParams(loadLayer(layer, 'local'), config(LOCAL)).metadata.definition_sha).toBe(expected); + }); +}); + +describe('vaults and sessions', () => { + let state: State; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(spawnSync).mockReturnValue({ status: 0 } as ReturnType<typeof spawnSync>); + state = new State(join(mkdtempSync(join(tmpdir(), 'hello-stack-')), 'jane.json')); + }); + + it('on the cloud stack, the MCP credential goes in a vault', async () => { + const client = fakeClient(); + + const vaultIds = await mcpVaultIds(client, state, config(CLOUD)); + + expect(vaultIds).toEqual([state.get('vault_id')]); + expect(client.vaults.credentials.calls.filter((c) => c[0] === 'create')).toHaveLength(1); + }); + + it('the local MCP server takes no credential, so there is no vault', async () => { + const client = fakeClient(); + + expect(await mcpVaultIds(client, state, config(LOCAL))).toEqual([]); + expect(client.vaults.calls).toEqual([]); + expect(state.get('vault_id')).toBeNull(); + }); + + it('a session is pinned to the agent version and remembered', async () => { + const client = fakeClient(); + const agent = await ensureAgent(client, state, agentParams(loadLayer('l1-hello'), config(CLOUD))); + + const session = await openSession(client, state, 'env_1', agent, 'L1: hello'); + + expect(client.sessions.calls).toEqual([ + ['create', { environment_id: 'env_1', agent: { type: 'agent', id: agent.id, version: 1 }, title: 'L1: hello' }], + ]); + expect(state.get('session_id')).toBe(session.id); + }); + + it('a session gets vault_ids only when there is a vault', async () => { + const client = fakeClient(); + const agent = await ensureAgent(client, state, agentParams(loadLayer('l1-hello'), config(CLOUD))); + + await openSession(client, state, 'env_1', agent, 'L3: live context', { vaultIds: ['vlt_1'] }); + await openSession(client, state, 'env_1', agent, 'L3: live context', { vaultIds: [] }); + + expect(client.sessions.calls[0][1].vault_ids).toEqual(['vlt_1']); + expect(Object.keys(client.sessions.calls[1][1])).not.toContain('vault_ids'); + }); + + it('the newest session replaces the remembered one', async () => { + const client = fakeClient(); + const agent = await ensureAgent(client, state, agentParams(loadLayer('l1-hello'), config(CLOUD))); + const first = await openSession(client, state, 'env_1', agent, 'L1: hello'); + + const second = await openSession(client, state, 'env_1', agent, 'L1: hello'); + + expect(first.id).not.toBe(second.id); + expect(state.get('session_id')).toBe(second.id); + }); + + it('cloud OAuth still delegates to ork', async () => { + const oauth = { ...without(CLOUD, 'SN_MCP_AUTH'), ORCA_BASE_URL: 'https://registry.example.com' }; + + const vaultIds = await mcpVaultIds(fakeClient(), state, config(oauth)); + + expect(spawnSync).toHaveBeenCalledTimes(1); + expect(vi.mocked(spawnSync).mock.calls[0][0]).toBe('ork'); + expect(vaultIds).toEqual([state.get('vault_id')]); + }); +}); diff --git a/typescript/test/turn.test.ts b/typescript/test/turn.test.ts index 7826add..9801cb2 100644 --- a/typescript/test/turn.test.ts +++ b/typescript/test/turn.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { TurnError, runTurn } from '../src/common.js'; -import { FakeSessionEvents, clientWithEvents, type RawEvent } from './fakes.js'; +import { FakeSessionEvents, OrkLocalSessionEvents, clientWithEvents, type RawEvent } from './fakes.js'; const text = (t: string) => [{ type: 'text', text: t }]; @@ -138,7 +138,35 @@ describe('runTurn', () => { const { result, lines } = await drive([[retrying, { id: 'evt_a', type: 'agent.message', content: text('ok') }, idle('end_turn')]]); expect(result.text).toBe('ok'); - expect(lines.some((line) => line.includes('model busy'))).toBe(true); + expect(lines.some((line) => line.includes('model busy') && line.includes('(retrying)'))).toBe(true); + }); + + it('a retry reported inside the error is marked as retrying too', async () => { + // The shape `ork local` sends: the retry status sits inside `error`. + const retrying: RawEvent = { + id: 'evt_err', + type: 'session.error', + error: { type: 'unknown_error', message: 'server_error (status 502)', retry_status: { type: 'retrying' } }, + }; + + const { lines } = await drive([[retrying, { id: 'evt_a', type: 'agent.message', content: text('ok') }, idle('end_turn')]]); + + expect(lines.some((line) => line.includes('server_error (status 502)') && line.includes('(retrying)'))).toBe(true); + }); + + it('an exhausted retry is not marked as retrying', async () => { + const exhausted: RawEvent = { + id: 'evt_err', + type: 'session.error', + error: { type: 'unknown_error', message: 'API key is invalid.', retry_status: { type: 'exhausted' } }, + }; + const events = new FakeSessionEvents([[exhausted, idle('retries_exhausted')]]); + const lines: string[] = []; + + await expect(runTurn(clientWithEvents(events), 'sess_1', 'hi', { out: (l) => lines.push(l) })).rejects.toThrow(/API key is invalid\./); + + expect(lines.some((line) => line.includes('API key is invalid.'))).toBe(true); + expect(lines.some((line) => line.includes('(retrying)'))).toBe(false); }); it('retries exhausted raises with the last error message', async () => { @@ -216,3 +244,98 @@ describe('runTurn', () => { expect(result.text).toBe('new'); }); }); + +// That engine answers a stream opened on a quiet session only at its next +// keep-alive, 15 seconds later. With sendFirst the turn speaks first and then +// follows the session from its start. +describe('runTurn with sendFirst: the `ork local` engine', () => { + async function driveLocal(reactions: RawEvent[][], options: { confirm?: (toolUse: Record<string, unknown>) => boolean } = {}) { + const events = new OrkLocalSessionEvents(reactions); + const lines: string[] = []; + const result = await runTurn(clientWithEvents(events), 'sess_1', 'hi', { confirm: options.confirm, out: (l) => lines.push(l), sendFirst: true }); + return { result, lines, events }; + } + + it('sends the message, then follows the session from its start', async () => { + const { events } = await driveLocal([[idle('end_turn')]]); + + expect(events.calls[0]).toEqual(['send', 'sess_1', [{ type: 'user.message', content: [{ type: 'text', text: 'hi' }] }]]); + // From the start of the session, not from the live edge: whatever the agent + // said before the stream opened is replayed, not lost. + expect(events.calls[1]).toEqual(['stream', 'sess_1', '0']); + }); + + it("returns and prints the agent's reply", async () => { + const { result, lines } = await driveLocal([[{ id: 'evt_a', type: 'agent.message', content: text('Hello!') }, idle('end_turn')]]); + + expect(result.text).toBe('Hello!'); + expect(lines.some((line) => line.includes('Hello!'))).toBe(true); + }); + + it("shows only the second turn's reply on the second turn", async () => { + const events = new OrkLocalSessionEvents([ + [{ id: 'evt_a1', type: 'agent.message', content: text('first answer') }, idle('end_turn')], + [{ id: 'evt_a2', type: 'agent.message', content: text('second answer') }, { ...idle('end_turn'), id: 'evt_idle_2' }], + ]); + const client = clientWithEvents(events); + await runTurn(client, 'sess_1', 'one', { out: () => {}, sendFirst: true }); + const lines: string[] = []; + + const result = await runTurn(client, 'sess_1', 'two', { out: (l) => lines.push(l), sendFirst: true }); + + expect(result.text).toBe('second answer'); + expect(lines.some((line) => line.includes('first answer'))).toBe(false); + }); + + it('does not answer an approval that an earlier turn left open', async () => { + const events = new OrkLocalSessionEvents([ + // The first turn stops at a request for approval, and nobody answers it. + [{ ...INSERT_CALL, id: 'evt_old_tool' }, { ...idle('requires_action', ['evt_old_tool']), id: 'evt_old_wait' }], + [{ id: 'evt_a', type: 'agent.message', content: text('done') }, idle('end_turn')], + ]); + const client = clientWithEvents(events); + await expect(runTurn(client, 'sess_1', 'one', { out: () => {}, sendFirst: true })).rejects.toThrow(/approv/); + const asked: unknown[] = []; + + const result = await runTurn(client, 'sess_1', 'two', { + confirm: (toolUse) => { + asked.push(toolUse); + return true; + }, + out: () => {}, + sendFirst: true, + }); + + expect(result.text).toBe('done'); + expect(asked).toEqual([]); + }); + + it('approval sends allow and continues', async () => { + const { result, events } = await driveLocal( + [ + [INSERT_CALL, idle('requires_action', ['evt_tool_1'])], + [{ id: 'evt_done', type: 'agent.message', content: text('Flagged acct_9123.') }, { ...idle('end_turn'), id: 'evt_idle_2' }], + ], + { confirm: () => true }, + ); + + expect(events.calls[2]).toEqual(['send', 'sess_1', [{ type: 'user.tool_confirmation', tool_use_id: 'evt_tool_1', result: 'allow' }]]); + expect(result.text).toBe('Flagged acct_9123.'); + }); + + it('treats a message the engine does not confirm as an error, not a hang', async () => { + const events = new OrkLocalSessionEvents([[idle('end_turn')]]); + events.send = async () => ({ data: [] }); + + await expect(runTurn(clientWithEvents(events), 'sess_1', 'hi', { out: () => {}, sendFirst: true })).rejects.toThrow(/did not confirm/); + }); + + it('the default order also works on that engine: it only waits longer', async () => { + const events = new OrkLocalSessionEvents([[{ id: 'evt_a', type: 'agent.message', content: text('Hello!') }, idle('end_turn')]]); + + const result = await runTurn(clientWithEvents(events), 'sess_1', 'hi', { out: () => {} }); + + expect(result.text).toBe('Hello!'); + expect(events.calls.map((call) => call[0])).toEqual(['stream', 'send']); + }); +});