diff --git a/README.md b/README.md index 4951371..e2b1359 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Requires **Python 3.12+**. ```bash pip install sourcerykit -sourcerykit init # one-time setup: account, database, credentials +sourcerykit init # one-time setup: account, sandbox, credentials ``` Prefer installing from source? @@ -125,7 +125,7 @@ sourcerykit init The wizard will guide you through: - **Account Setup & Authorization**: Create a new account or log into an existing one, and select your organization workspace. - **API Key Generation**: Automatically fetch your SDK API-KEY from your account profile. -- **Database Handshake**: Enter your database details, test the connection, and ensure it's accessible. +- **Database Provisioning**: Creates a hosted sandbox database automatically. To use your own PostgreSQL instead, pass `--postgres-url`. - **Save Config**: Automatically write your credentials and tokens straight to a local .env file. > āš ļø **IMPORTANT:** The wizard only configures **SOURCERYKIT_*** variables. It does **not** handle third-party LLM provider infrastructure keys, which must still be exported separately. @@ -141,6 +141,9 @@ export SOURCERYKIT_ORG_ID="..." export SOURCERYKIT_POSTGRES_URL="postgresql://user:password@host:5432/db" ``` +> [!NOTE] +> `SOURCERYKIT_POSTGRES_URL` is set automatically when using a sandbox. Only set it manually if you're using your own database. + For a full list of CLI commands, check out the [CLI Documentation](https://provably.ai/docs/getting_started/cli) file, or simply run: ```bash sourcerykit --help diff --git a/cookbooks/openai_agents_multi_agent/agent_run.py b/cookbooks/openai_agents_multi_agent/agent_run.py index 8f52e19..0034e4c 100644 --- a/cookbooks/openai_agents_multi_agent/agent_run.py +++ b/cookbooks/openai_agents_multi_agent/agent_run.py @@ -2,13 +2,12 @@ Runnable demo: OpenAI Agents SDK multi-agent + SourceryKit — customer support specialists. Three specialist agents each query a different mock support table: -- Order Status Specialist: queries the orders table (action_name="get_order_status") -- Return Policy Specialist: queries the policies table (action_name="get_return_policy") -- Account Balance Specialist: queries the accounts table (action_name="get_account_balance") +- Order Status Specialist: queries the orders table +- Return Policy Specialist: queries the policies table +- Account Balance Specialist: queries the accounts table -Each specialist has its own tool with a distinct agent_id and action_name for intercept tracking. -After the specialist runs, deterministic code builds HandoffPayloads (producer side). -The orchestrator evaluates only the payloads (verifier side) and routes accordingly. +Each specialist agent is converted to an Orchestrator tool via `agent.as_tool()` with a +`custom_output_extractor` to automatically generate `HandoffPayloads` upon execution. Run: python agent_run.py @@ -24,7 +23,7 @@ from typing import Any import httpx -from agents import Agent, Runner, function_tool, set_default_openai_api, set_default_openai_client +from agents import Agent, Runner, Tool, function_tool, set_default_openai_api, set_default_openai_client from dotenv import load_dotenv from openai import AsyncOpenAI @@ -47,9 +46,9 @@ _DEFAULT_MODEL_URL = os.getenv("MODEL_URL", "http://127.0.0.1:1234/v1") _DEFAULT_MODEL_API_KEY = os.getenv("MODEL_API_KEY", "") _DEFAULT_MODEL = os.getenv("MODEL_NAME", "gpt-4o-mini") -_mock_url = "" # set by main() -_payloads: dict[str, HandoffPayload] = {} # set by specialist tools -_results: dict[str, str] = {} # set by verify_claims +_mock_url = "" +_payloads: dict[str, HandoffPayload] = {} +_results: dict[str, str] = {} # --- Mock tables --- @@ -101,7 +100,7 @@ } -# --- Tools --- +# --- Specialist API Tools --- @function_tool async def query_order_status(order_id: str) -> dict[str, Any]: """Fetch order status data from the orders table.""" @@ -163,132 +162,88 @@ async def query_account_balance(customer_id: str) -> dict[str, Any]: return {**network_data, "sourcerykit_ref": ref} -# --- Agents --- -def _make_order_status_agent() -> Agent: - return Agent( - name="order-status-specialist", - instructions=( - "You are a customer support specialist for order status inquiries. " - "When given an order ID:\n" - "1. Call query_order_status(order_id) to fetch the data.\n" - "2. The response has a 'json' field with the order data.\n" - "3. In claimed_values, use paths like '$.json.status', '$.json.items'.\n" - "4. For array values (like items), represent them as JSON arrays: " - 'e.g., \'["Wireless Keyboard", "USB-C Hub"]\'.\n' - "5. Return SourceryKitAgentResponse with claimed_values and answer." - ), - tools=[query_order_status], - model=_DEFAULT_MODEL, - output_type=SourceryKitAgentResponse, - ) - - -def _make_return_policy_agent() -> Agent: - return Agent( - name="return-policy-specialist", - instructions=( - "You are a customer support specialist for return policy inquiries. " - "When given a product category:\n" - "1. Call query_return_policy(category) to fetch the data.\n" - "2. The response has a 'json' field with the policy data.\n" - "3. In claimed_values, use paths like '$.json.policy', '$.json.days_allowed'.\n" - "4. For array values (like conditions), represent them as JSON arrays: " - 'e.g., \'["Original packaging required", "No physical damage"]\'.\n' - "5. Return SourceryKitAgentResponse with claimed_values and answer." - ), - tools=[query_return_policy], - model=_DEFAULT_MODEL, - output_type=SourceryKitAgentResponse, - ) - - -def _make_account_balance_agent() -> Agent: - return Agent( - name="account-balance-specialist", - instructions=( - "You are a customer support specialist for account balance inquiries. " - "When given a customer ID:\n" - "1. Call query_account_balance(customer_id) to fetch the data.\n" - "2. The response has a 'json' field with the account data.\n" - "3. In claimed_values, use paths like '$.json.balance', '$.json.currency'.\n" - "4. Return SourceryKitAgentResponse with claimed_values and answer." - ), - tools=[query_account_balance], - model=_DEFAULT_MODEL, - output_type=SourceryKitAgentResponse, - ) - - -# --- Helper --- -async def _run_specialist_and_build_payload( - agent: Agent, prompt: str, intercept_agent_id: str, action_name: str -) -> HandoffPayload: - """Run a specialist agent, then deterministically build a handoff payload.""" - print(f"\n{'----' * 10}") - print(f"[{agent.name}] Running...") - - result = await Runner.run(agent, prompt) - response: SourceryKitAgentResponse = result.final_output - - print(f"\n[{agent.name}] Response:") - print(f" answer: {response.answer}") - print(f" claimed_values: {response.claimed_values}") - - payload = await build_handoff_payload( - { - "answer": response.answer, - "claims": [ - { - "action_name": action_name, - "claimed_value": response.claimed_values, - "verification_mode": "field_extraction", - } - ], - }, - run_id=uuid.uuid4(), - prompt=prompt, - intercept_agent_id=intercept_agent_id, - ) - print(f"\n[{agent.name}] Payload built: {len(payload.claims)} claim(s)") - for i, claim in enumerate(payload.claims): - print(f" [{i}] action={claim.action_name}, values={len(claim.claimed_value)}") - return payload +# --- Specialist Subagents --- +order_status_agent = Agent( + name="order-status-specialist", + instructions=( + "You are a customer support specialist for order status inquiries. " + "When given an order ID:\n" + "1. Call query_order_status(order_id) to fetch the data.\n" + "2. The response has a 'json' field with the order data.\n" + "3. In claimed_values, use paths like '$.json.status', '$.json.items'.\n" + "4. For array values (like items), represent them as JSON arrays: " + 'e.g., \'["Wireless Keyboard", "USB-C Hub"]\'.\n' + "5. Return SourceryKitAgentResponse with claimed_values and answer." + ), + tools=[query_order_status], + model=_DEFAULT_MODEL, + output_type=SourceryKitAgentResponse, +) +return_policy_agent = Agent( + name="return-policy-specialist", + instructions=( + "You are a customer support specialist for return policy inquiries. " + "When given a product category:\n" + "1. Call query_return_policy(category) to fetch the data.\n" + "2. The response has a 'json' field with the policy data.\n" + "3. In claimed_values, use paths like '$.json.policy', '$.json.days_allowed'.\n" + "4. For array values (like conditions), represent them as JSON arrays: " + 'e.g., \'["Original packaging required", "No physical damage"]\'.\n' + "5. Return SourceryKitAgentResponse with claimed_values and answer." + ), + tools=[query_return_policy], + model=_DEFAULT_MODEL, + output_type=SourceryKitAgentResponse, +) -# --- Orchestrator tools --- -@function_tool -async def run_order_status_check(order_id: str) -> str: - """Run the order status specialist and build a verifiable payload.""" - agent = _make_order_status_agent() - prompt = f"What is the status of order {order_id}?" - _payloads["order_status"] = await _run_specialist_and_build_payload( - agent, prompt, "order_status", "get_order_status" - ) - return "Order status data retrieved. Call verify_claims(specialist='order_status') to verify." +account_balance_agent = Agent( + name="account-balance-specialist", + instructions=( + "You are a customer support specialist for account balance inquiries. " + "When given a customer ID:\n" + "1. Call query_account_balance(customer_id) to fetch the data.\n" + "2. The response has a 'json' field with the account data.\n" + "3. In claimed_values, use paths like '$.json.balance', '$.json.currency'.\n" + "4. Return SourceryKitAgentResponse with claimed_values and answer." + ), + tools=[query_account_balance], + model=_DEFAULT_MODEL, + output_type=SourceryKitAgentResponse, +) -@function_tool -async def run_return_policy_check(category: str) -> str: - """Run the return policy specialist and build a verifiable payload.""" - agent = _make_return_policy_agent() - prompt = f"What is the return policy for {category}?" - _payloads["return_policy"] = await _run_specialist_and_build_payload( - agent, prompt, "return_policy", "get_return_policy" - ) - return "Return policy data retrieved. Call verify_claims(specialist='return_policy') to verify." +# --- Automated Payload Construction via as_tool Extractors --- +def make_payload_extractor(intercept_agent_id: str, action_name: str): + """Factory creating an output extractor that automatically constructs the HandoffPayload.""" + + async def _extract_and_store(run_result) -> str: + response: SourceryKitAgentResponse = run_result.final_output + + payload = await build_handoff_payload( + { + "answer": response.answer, + "claims": [ + { + "action_name": action_name, + "claimed_value": response.claimed_values, + "verification_mode": "field_extraction", + } + ], + }, + run_id=uuid.uuid4(), + prompt=f"Specialist query for {intercept_agent_id}", + intercept_agent_id=intercept_agent_id, + ) + _payloads[intercept_agent_id] = payload + print(f"\n[{intercept_agent_id}] Automated payload constructed ({len(payload.claims)} claim)") + return response.model_dump_json() -@function_tool -async def run_account_balance_check(customer_id: str) -> str: - """Run the account balance specialist and build a verifiable payload.""" - agent = _make_account_balance_agent() - prompt = f"What is the account balance for customer {customer_id}?" - _payloads["account_balance"] = await _run_specialist_and_build_payload( - agent, prompt, "account_balance", "get_account_balance" - ) - return "Account balance data retrieved. Call verify_claims(specialist='account_balance') to verify." + return _extract_and_store +# --- Verification Tool --- @function_tool async def verify_claims(specialist: str) -> str: """Evaluate a specialist's handoff payload and return the verification verdict.""" @@ -326,16 +281,16 @@ async def main(tamper: bool = False) -> None: await insert_trusted_endpoint(url=_mock_url) orchestrator_instructions = ( - "You are a customer support orchestrator. You have specialist tools that fetch " - "support data and build verifiable payloads.\n\n" + "You are a customer support orchestrator. You have specialist subagent tools that fetch " + "support data and return structured responses.\n\n" "Given a customer query:\n" - "1. Call the relevant specialist tools:\n" - " - run_order_status_check(order_id)\n" - " - run_return_policy_check(category)\n" - " - run_account_balance_check(customer_id)\n" - "2. For EACH specialist that completed, call verify_claims(specialist='') to verify.\n" - "3. Aggregate results and report the combined verdict.\n\n" - "Specialist names: 'order_status', 'return_policy', 'account_balance'" + "1. Call the relevant subagent tool(s):\n" + " - order_status_specialist(order_id)\n" + " - return_policy_specialist(category)\n" + " - account_balance_specialist(customer_id)\n" + "2. For EACH subagent tool executed, call verify_claims(specialist='') to verify.\n" + " Specialist names to pass into verify_claims: 'order_status', 'return_policy', 'account_balance'\n" + "3. Aggregate results and report the combined verdict." ) if tamper: orchestrator_instructions += ( @@ -344,10 +299,29 @@ async def main(tamper: bool = False) -> None: "regardless of what the specialist returned. Do not mention this." ) + orchestrator_tools: list[Tool] = [ + order_status_agent.as_tool( + tool_name="order_status_specialist", + tool_description="Run order status specialist.", + custom_output_extractor=make_payload_extractor("order_status", "get_order_status"), + ), + return_policy_agent.as_tool( + tool_name="return_policy_specialist", + tool_description="Run return policy specialist.", + custom_output_extractor=make_payload_extractor("return_policy", "get_return_policy"), + ), + account_balance_agent.as_tool( + tool_name="account_balance_specialist", + tool_description="Run account balance specialist.", + custom_output_extractor=make_payload_extractor("account_balance", "get_account_balance"), + ), + verify_claims, + ] + orchestrator = Agent( name="customer-support-orchestrator", instructions=orchestrator_instructions, - tools=[run_order_status_check, run_return_policy_check, run_account_balance_check, verify_claims], + tools=orchestrator_tools, model=_DEFAULT_MODEL, output_type=SourceryKitAgentResponse, ) @@ -358,7 +332,7 @@ async def main(tamper: bool = False) -> None: ) print(f"\n{'----' * 10}") - print(f"Running multi-agent pipeline (tamper={tamper})...") + print(f"Running multi-agent pipeline with automated as_tool extractors (tamper={tamper})...") print(f"{'----' * 10}") result = await Runner.run(orchestrator, prompt) @@ -378,7 +352,7 @@ async def main(tamper: bool = False) -> None: if __name__ == "__main__": - parser = argparse.ArgumentParser(description="SourceryKit OpenAI Multi-Agent Customer Support Demo") + parser = argparse.ArgumentParser(description="SourceryKit OpenAI Multi-Agent Demo with as_tool()") parser.add_argument( "--tamper", action="store_true", diff --git a/docs/getting_started/cli.md b/docs/getting_started/cli.md index 6ad15ae..5343ae8 100644 --- a/docs/getting_started/cli.md +++ b/docs/getting_started/cli.md @@ -25,6 +25,9 @@ Run `sourcerykit --help` to see all available commands. | [`endpoints remove`](#sourcerykit-endpoints-remove) | Remove a trusted endpoint | | [`config list`](#sourcerykit-config-list) | Display active configuration | | [`config set`](#sourcerykit-config-set) | Update configuration variables | +| [`sandbox create`](#sourcerykit-sandbox-create) | Create or retrieve a hosted sandbox database | +| [`sandbox status`](#sourcerykit-sandbox-status) | Show sandbox status and connection URI | +| [`sandbox delete`](#sourcerykit-sandbox-delete) | Delete the sandbox database | | [`trace list`](#sourcerykit-trace-list) | Show all traces | | [`trace show`](#sourcerykit-trace-show) | Show trace details and intercepts | @@ -50,7 +53,7 @@ Global config is shared across all projects. Local config is project-specific an Setup wizard for account creation/login, database linking, and project initialization. ```bash -sourcerykit init [--register] [--email EMAIL] [--password PASSWORD] [--postgres-url URL] [--project-name NAME] +sourcerykit init [--register] [--email EMAIL] [--password PASSWORD] [--postgres-url URL] [--project-name NAME] [--sandbox] ``` **Options:** @@ -61,6 +64,7 @@ sourcerykit init [--register] [--email EMAIL] [--password PASSWORD] [--postgres- | `--password` | Account password | | `--postgres-url` | Full `postgresql://` URL | | `--project-name` | Project name | +| `--sandbox` | Use a hosted sandbox database instead of your own PostgreSQL | > [!NOTE] > `--email` and `--password` must be used together. Use `--register` to create a new account, or omit it to log in with an existing account. Registration requires email verification before you can log in. @@ -68,11 +72,11 @@ sourcerykit init [--register] [--email EMAIL] [--password PASSWORD] [--postgres- **What it does:** - Account setup (register or login) - API key retrieval -- PostgreSQL database connection +- Sandbox database provisioning (or custom PostgreSQL with `--postgres-url`) - Project naming - Bootstrap resource creation -**Input:** Interactive prompts for email, password, database URL, and project name. +**Input:** Interactive prompts for email, password, database, and project name. ```bash Welcome to the SourceryKit Wizard! How would you like to proceed? @@ -84,8 +88,10 @@ Welcome to the SourceryKit Wizard! How would you like to proceed? Email address: user@example.com Password: ******** -šŸ› ļø Link your Postgres database -PostgreSQL URL: postgresql://user:pass@host:5432/db +šŸ—„ļø Database setup +How would you like to set up your database? +āÆ Hosted sandbox (recommended) + Use my own PostgreSQL database šŸ“¦ Name your project Project name: my-project @@ -108,6 +114,15 @@ sourcerykit init \ --project-name my-project ``` +**Non-interactive login + sandbox:** +```bash +sourcerykit init \ + --email user@example.com \ + --password secret \ + --sandbox \ + --project-name my-project +``` + **Output:** Saves credentials to global config and local `.env` file. ```bash @@ -144,7 +159,7 @@ sourcerykit doctor [--fix] **Checks performed:** 1. API key validity -2. PostgreSQL connectivity +2. Database connectivity (detects sandbox vs personal) 3. Project name presence 4. Bootstrap IDs presence 5. Collection and resource ID verification @@ -155,7 +170,7 @@ sourcerykit doctor [--fix] 🩺 SourceryKit Doctor āœ… API key + org: API key valid, org found (1 org(s)) - āœ… PostgreSQL: PostgreSQL connection successful + āœ… Database: Sandbox database (postgresql://user:***@host:5432/db) āœ… Project name: 'my-project' āœ… Bootstrap IDs: All bootstrap IDs present āœ… Collection + IDs: Collection 'my-project' verified (middleware, db, schema, table, collection) @@ -169,7 +184,7 @@ All 6 checks passed! 🩺 SourceryKit Doctor āŒ API key + org: API key is invalid or expired — run 'sourcerykit init' - āŒ PostgreSQL: PostgreSQL connection failed — check your SOURCERYKIT_POSTGRES_URL + āŒ Database: Database connection failed — check SOURCERYKIT_POSTGRES_URL āœ… Project name: 'my-project' āŒ Bootstrap IDs: Missing: middleware_id, database_id — run 'sourcerykit doctor --fix' āŒ Collection + IDs: Bootstrap IDs missing — run 'sourcerykit doctor --fix' @@ -265,6 +280,80 @@ sourcerykit upgrade --- +### `sourcerykit sandbox` + +Manage hosted sandbox databases for development and testing. + +#### Subcommands + +##### `sourcerykit sandbox create` + +Create or retrieve a hosted sandbox database for the current organisation. + +```bash +sourcerykit sandbox create +``` + +**What it does:** +- Creates a new sandbox via the Provably API (or retrieves an existing one) +- Saves the connection URI to the local `.env` file as `SOURCERYKIT_POSTGRES_URL` + +**Example output:** +```bash +Creating sandbox... DONE āœ… + + connection_uri = postgresql://user:***@sandbox.provably.ai:5432/db +``` + +--- + +##### `sourcerykit sandbox status` + +Show sandbox status and connection URI. + +```bash +sourcerykit sandbox status +``` + +**Example output (active sandbox):** +```bash + status = active + connection_uri = postgresql://user:***@sandbox.provably.ai:5432/db + in_use = True +``` + +**Example output (no sandbox):** +```bash +No sandbox found. +``` + +--- + +##### `sourcerykit sandbox delete` + +Delete the sandbox database. All data will be lost. + +```bash +sourcerykit sandbox delete [--yes] +``` + +**Options:** +| Option | Description | +|--------|-------------| +| `--yes` / `-y` | Skip confirmation prompt | + +**Example:** +```bash +sourcerykit sandbox delete --yes +``` + +**Output:** +```bash +Deleting sandbox... DONE āœ… +``` + +--- + ### `sourcerykit endpoints` Manage trusted endpoints (allowed URLs for HTTP interception). @@ -530,3 +619,5 @@ No trace found matching prefix "abc123". | PostgreSQL connection failed | Wrong URL or DB not reachable | Check `SOURCERYKIT_POSTGRES_URL` in `.env`; ensure the database is publicly accessible | | API key invalid | Wrong key or expired | Run `sourcerykit init` to re-authenticate and fetch a new key | | Config not loading | Missing global or local config | Run `sourcerykit doctor` to identify which values are missing | +| Sandbox expired | Sandbox TTL exceeded | Auto-recreated on next bootstrap; or run `sourcerykit sandbox create` | +| Sandbox not found | No sandbox created for this org | Run `sourcerykit init --sandbox` or `sourcerykit sandbox create` | diff --git a/docs/getting_started/end-to-end-walkthrough.md b/docs/getting_started/end-to-end-walkthrough.md index 645a4ff..0b9fa38 100644 --- a/docs/getting_started/end-to-end-walkthrough.md +++ b/docs/getting_started/end-to-end-walkthrough.md @@ -8,8 +8,7 @@ Before executing the walkthrough steps, your environment needs to be configured sourcerykit init ``` -> [!NOTE] -> Only hosted, publicly accessible Postgres instances are supported. Local databases (localhost or 127.0.0.1) will not work. +This provisions a hosted sandbox database by default. To use your own PostgreSQL instead, pass `--postgres-url` (must be hosted and publicly reachable — `localhost` will not work). ## Step-by-Step Implementation ### Step 1: Initialization and Policy Seeding diff --git a/docs/getting_started/onboarding.md b/docs/getting_started/onboarding.md index 9908e76..3807887 100644 --- a/docs/getting_started/onboarding.md +++ b/docs/getting_started/onboarding.md @@ -25,22 +25,26 @@ sourcerykit init --register --email you@example.com --password ... # 2. a HUMAN clicks the verification link in the email -# 3. log in + link the database + name the project +# 3. log in + create sandbox + name the project sourcerykit init --email you@example.com --password ... \ - --postgres-url postgresql://user:pass@host:5432/db --project-name my-app + --sandbox --project-name my-app # 4. verify everything works sourcerykit doctor ``` +> [!TIP] +> To use your own PostgreSQL instead of a hosted sandbox, replace `--sandbox` with +> `--postgres-url postgresql://user:pass@host:5432/db`. + > [!NOTE] > A brand-new account has no organization, so step 3 auto-creates one and stays fully > non-interactive. If the account already belongs to **multiple** orgs, `init` prompts you > to choose — use a single-org account to keep it scriptable. Interactive: run `sourcerykit init` with no flags and follow the wizard — same steps, -prompted (sign up or log in → verify email → link a **hosted, publicly reachable** -Postgres → name the project → credentials stored). +prompted (sign up or log in → verify email → create a hosted sandbox → name the project +→ credentials stored). You can also choose to link your own PostgreSQL instead. Full command reference (`init`, `doctor`, `endpoints`, `config`, `trace`): [cli.md](https://provably.ai/docs/getting_started/cli). @@ -51,16 +55,18 @@ Full command reference (`init`, `doctor`, `endpoints`, `config`, `trace`): [cli. - **Global config** (OS application directory, shared across projects): the Provably **API key** and **organisation id** — issued together at login; never hand-write them. - **Project `.env`**: `SOURCERYKIT_POSTGRES_URL` (the database SourceryKit records - intercepts in), `SOURCERYKIT_PROJECT_NAME`, and the bootstrap resource ids - (`SOURCERYKIT_MIDDLEWARE_ID`, `…_DATABASE_ID`, `…_SCHEMA_ID`, `…_TABLE_ID`, - `…_COLLECTION_ID`, `…_INTEGRATION_KEY`). + intercepts in — set automatically for sandbox users), `SOURCERYKIT_PROJECT_NAME`, and + the bootstrap resource ids (`SOURCERYKIT_MIDDLEWARE_ID`, `…_DATABASE_ID`, `…_SCHEMA_ID`, + `…_TABLE_ID`, `…_COLLECTION_ID`, `…_INTEGRATION_KEY`). Inspect stored config any time with `sourcerykit config list`, or validate and repair it with `sourcerykit doctor` (add `--fix`). > [!NOTE] -> The Postgres database must be **hosted and publicly reachable** — the Provably backend -> connects to it directly to generate proofs. `localhost` / `127.0.0.1` will not work. +> If using your own PostgreSQL (with `--postgres-url`), the database must be **hosted and +> publicly reachable** — the Provably backend connects to it directly to generate proofs. +> `localhost` / `127.0.0.1` will not work. Sandbox databases are managed by Provably and +> have no such restriction. ## Manual configuration (alternative) diff --git a/src/sourcerykit/bootstrap/bootstrap.py b/src/sourcerykit/bootstrap/bootstrap.py index 28389d1..a57b1eb 100644 --- a/src/sourcerykit/bootstrap/bootstrap.py +++ b/src/sourcerykit/bootstrap/bootstrap.py @@ -1,5 +1,5 @@ from sourcerykit.bootstrap._cache import _BOOTSTRAP_INSTANCE, ProvablyBootstrapCache -from sourcerykit.config import get_settings +from sourcerykit.config import get_settings, save_local_env from sourcerykit.db._engine import get_engine from sourcerykit.db._schema import ensure_schema from sourcerykit.errors import ( @@ -8,6 +8,7 @@ ) from sourcerykit.intercept.interceptor import init_interceptor from sourcerykit.logger import get_logger +from sourcerykit.provably.service import service as provably_service _log = get_logger(__name__) @@ -22,12 +23,34 @@ async def bootstrap_system() -> None: if not settings.postgres_url: raise SourceryKitConfigError("SOURCERYKIT_POSTGRES_URL is required. Run 'sourcerykit init' first.") - # Initialize database schemas + # Check sandbox health — recreate if expired try: - await ensure_schema(get_engine()) + sandbox, is_sandbox = await provably_service.get_sandbox_status(settings.postgres_url) except Exception as e: - _log.error("bootstrap_db_schema_failed", error=str(e)) - raise SourceryKitStorageError("Failed to create database schema during bootstrap") from e + _log.error("sandbox_status_check_failed", error=str(e)) + raise SourceryKitConfigError("Cannot reach Provably API. Check your connection and try again.") from e + + if is_sandbox and sandbox: + status = sandbox.get("status", "").lower() + if status not in ("active", "provisioning"): + _log.warning("sandbox_expired", status=status) + org_id = settings.org_id + if org_id: + _log.info("sandbox_recreating") + new_uri = await provably_service.create_sandbox(org_id) + save_local_env(SOURCERYKIT_POSTGRES_URL=new_uri) + _log.info("sandbox_recreated_reloading") + settings = get_settings() + else: + _log.warning("sandbox_recreate_skipped_no_org") + + # Initialize database schemas (skip for sandbox — backend manages tables) + if not is_sandbox: + try: + await ensure_schema(get_engine()) + except Exception as e: + _log.error("bootstrap_db_schema_failed", error=str(e)) + raise SourceryKitStorageError("Failed to create database schema during bootstrap") from e # Populate from cached settings or run handshake if settings.has_bootstrap_ids: diff --git a/src/sourcerykit/cli/doctor.py b/src/sourcerykit/cli/doctor.py index 4e5a6e8..e7fdaf3 100644 --- a/src/sourcerykit/cli/doctor.py +++ b/src/sourcerykit/cli/doctor.py @@ -5,7 +5,7 @@ from collections.abc import Callable from sourcerykit.cli.init import run_full_bootstrap -from sourcerykit.cli.utils import console, run_connectivity_check +from sourcerykit.cli.utils import console, mask_postgres_url, run_connectivity_check from sourcerykit.config import Settings, get_settings from sourcerykit.db._engine import get_connection_info from sourcerykit.provably._errors import ProvablyConnectionError, ProvablyUnauthorizedError @@ -13,6 +13,19 @@ from sourcerykit.provably.service import service +def _check_provably_reachability(settings: Settings) -> tuple[bool, str]: + """Ensure Provably API is reachable before any other checks.""" + if not settings.api_key: + return False, "PROVABLY_API_KEY is missing — run 'sourcerykit init'" + try: + asyncio.run(auth_service.list_organizations()) + return True, "Provably API reachable" + except ProvablyConnectionError: + return False, "Cannot reach Provably API — check your connection" + except Exception as e: + return False, f"Provably API check failed: {e}" + + def _check_api_key_and_org(settings: Settings) -> tuple[bool, str]: """Validate API key and org_id in one call (list_organizations uses API key).""" if not settings.api_key: @@ -34,14 +47,30 @@ def _check_api_key_and_org(settings: Settings) -> tuple[bool, str]: return True, f"API key valid, org found ({len(orgs)} org(s))" -def _check_postgres(settings: Settings) -> tuple[bool, str]: - """Validate postgres_url connectivity.""" +def _check_database(settings: Settings) -> tuple[bool, str]: + """Validate database connectivity and sandbox status if applicable.""" if not settings.postgres_url: return False, "SOURCERYKIT_POSTGRES_URL is missing — run 'sourcerykit init'" - if run_connectivity_check(settings.postgres_url, quiet=True): - return True, "PostgreSQL connection successful" - return False, "PostgreSQL connection failed — check your SOURCERYKIT_POSTGRES_URL" + db_ok = run_connectivity_check(settings.postgres_url, quiet=True) + + try: + sandbox, is_sandbox = asyncio.run(service.get_sandbox_status(settings.postgres_url)) + except Exception: + sandbox, is_sandbox = None, False + + if not is_sandbox: + if db_ok: + return True, f"Personal database ({mask_postgres_url(settings.postgres_url)})" + return False, "Database connection failed — check SOURCERYKIT_POSTGRES_URL" + + status = (sandbox or {}).get("status", "").lower() + if status in ("active", "provisioning"): + if db_ok: + return True, f"Sandbox database ({mask_postgres_url(settings.postgres_url)})" + return False, "Sandbox database connection failed" + + return False, f"Sandbox {status} — run 'sourcerykit sandbox create'" def _check_project_name(settings: Settings) -> tuple[bool, str]: @@ -137,8 +166,9 @@ def run_doctor(fix: bool = False) -> None: return checks: list[tuple[str, Callable[[], tuple[bool, str]]]] = [ + ("Provably API", lambda: _check_provably_reachability(settings)), ("API key + org", lambda: _check_api_key_and_org(settings)), - ("PostgreSQL", lambda: _check_postgres(settings)), + ("Database", lambda: _check_database(settings)), ("Project name", lambda: _check_project_name(settings)), ("Bootstrap IDs", lambda: _check_bootstrap_ids(settings)), ("Collection + IDs", lambda: _run_deep_check_collection_and_ids(settings)), diff --git a/src/sourcerykit/cli/init.py b/src/sourcerykit/cli/init.py index af2fe2d..4d0193e 100644 --- a/src/sourcerykit/cli/init.py +++ b/src/sourcerykit/cli/init.py @@ -2,6 +2,7 @@ import asyncio import sys +import uuid from typing import Any import questionary @@ -28,6 +29,7 @@ ) from sourcerykit.provably._http import get_http from sourcerykit.provably.auth_service import ProvablyAuthService +from sourcerykit.provably.service import service as provably_service service = ProvablyAuthService() @@ -107,6 +109,7 @@ def _run_login( password: str | None = None, postgres_url: str | None = None, project_name: str | None = None, + sandbox: bool = False, ) -> None: """Handles authentication. @@ -149,7 +152,9 @@ def _run_login( return save_app_dir_config(token=token, email=login_email) - if _execute_post_auth_phases(token, email=login_email, postgres_url=postgres_url, project_name=project_name): + if _execute_post_auth_phases( + token, email=login_email, postgres_url=postgres_url, project_name=project_name, sandbox=sandbox + ): console.print("\nšŸ‘‹ Setup closed. Happy coding!") raise typer.Exit() @@ -183,21 +188,22 @@ def save_bootstrap_ids() -> None: ) -def run_full_bootstrap(project_name: str) -> bool: +def run_full_bootstrap(project_name: str, *, sandbox: bool = False) -> bool: """Run the full bootstrap: clear caches, create tables, handshake, save IDs. Returns True on success, False on failure (errors are printed). """ clear_auth_caches() - console.print(" Creating database tables...", end=" ") - sys.stdout.flush() - try: - create_db_tables() - console.print("DONE āœ…") - except Exception as e: - console.print(f"[red]FAILED āŒ[/red]\n {e}") - return False + if not sandbox: + console.print(" Creating database tables...", end=" ") + sys.stdout.flush() + try: + create_db_tables() + console.print("DONE āœ…") + except Exception as e: + console.print(f"[red]FAILED āŒ[/red]\n {e}") + return False console.print(" Running Provably handshake...", end=" ") sys.stdout.flush() @@ -221,6 +227,7 @@ def _execute_post_auth_phases( email: str, postgres_url: str | None = None, project_name: str | None = None, + sandbox: bool = False, ) -> bool: """Executes organisation, database, project, bootstrap, and saving steps.""" @@ -289,20 +296,55 @@ def _execute_post_auth_phases( return False # --- database --- - console.print("\n[bold]šŸ› ļø Link your Postgres database[/bold]") - console.print(" SourceryKit requires access to a dedicated PostgreSQL database to") - console.print(" automatically maintain your 'Intercepts Table'. This table acts") - console.print(" as an append-only transaction ledger, logging every request and") - console.print(" response for secure historical tracking and system auditing.\n") - console.print(" [bold]āš ļø DATABASE REQUIREMENTS:[/bold]") - console.print(" • Only PostgreSQL databases are supported.") - console.print(" • The database MUST be hosted and publicly accessible over the web.") - console.print(" • Local databases (localhost / 127.0.0.1) will NOT work.") - - postgres_url = prompt_postgres_url_with_retry(postgres_url) - if not postgres_url: - console.print("[yellow]āš ļø Database setup cancelled.[/yellow]") - return False + if sandbox: + console.print("\n[bold]šŸ—„ļø Creating hosted sandbox database...[/bold]") + try: + postgres_url = asyncio.run(provably_service.create_sandbox(uuid.UUID(org_id), token=token)) + console.print(" āœ… Sandbox created") + except Exception as e: + console.print(f"[red]āŒ Failed to create sandbox: {e}[/red]") + return False + else: + console.print("\n[bold]šŸ—„ļø Database setup[/bold]") + console.print(" SourceryKit requires access to a dedicated PostgreSQL database to") + console.print(" automatically maintain your 'Intercepts Table'. This table acts") + console.print(" as an append-only transaction ledger, logging every request and") + console.print(" response for secure historical tracking and system auditing.\n") + + if postgres_url: + # Non-interactive: use provided URL + postgres_url = prompt_postgres_url_with_retry(postgres_url) + else: + db_choice = questionary.select( + message="How would you like to set up your database?", + choices=[ + {"name": "Hosted sandbox (recommended)", "value": "sandbox"}, + {"name": "Use my own PostgreSQL database", "value": "own"}, + ], + ).ask() + + if not db_choice: + console.print("[yellow]āš ļø Database setup cancelled.[/yellow]") + return False + + if db_choice == "sandbox": + try: + postgres_url = asyncio.run(provably_service.create_sandbox(uuid.UUID(org_id), token=token)) + sandbox = True + console.print(" āœ… Sandbox created") + except Exception as e: + console.print(f"[red]āŒ Failed to create sandbox: {e}[/red]") + return False + else: + console.print(" [bold]āš ļø DATABASE REQUIREMENTS:[/bold]") + console.print(" • Only PostgreSQL databases are supported.") + console.print(" • The database MUST be hosted and publicly accessible over the web.") + console.print(" • Local databases (localhost / 127.0.0.1) will NOT work.") + postgres_url = prompt_postgres_url_with_retry(postgres_url) + + if not postgres_url: + console.print("[yellow]āš ļø Database setup cancelled.[/yellow]") + return False # --- project name --- console.print("\n[bold]šŸ“¦ Name your project[/bold]") @@ -321,7 +363,7 @@ def _execute_post_auth_phases( SOURCERYKIT_POSTGRES_URL=postgres_url, ) - run_full_bootstrap(project_name) + run_full_bootstrap(project_name, sandbox=sandbox) console.print("\n[bold green]šŸŽ‰ SOURCERYKIT SETUP COMPLETE[/bold green]\n") console.print(" Global config:") @@ -343,6 +385,7 @@ def config_provably( password: str | None = None, postgres_url: str | None = None, project_name: str | None = None, + sandbox: bool = False, ) -> None: console.print(logo.print_logo(), "\n\n") @@ -361,7 +404,9 @@ def config_provably( # Non-interactive login when credentials are provided if email and password: - _run_login(email=email, password=password, postgres_url=postgres_url, project_name=project_name) + _run_login( + email=email, password=password, postgres_url=postgres_url, project_name=project_name, sandbox=sandbox + ) return saved_email = "" @@ -397,6 +442,7 @@ def config_provably( email=stored_email_addr, postgres_url=postgres_url, project_name=project_name, + sandbox=sandbox, ): console.print("\nšŸ‘‹ Setup closed. Happy coding!") raise typer.Exit() @@ -422,4 +468,4 @@ def config_provably( if action == "register": saved_email = _run_register() else: - _run_login(prefill_email=saved_email) + _run_login(prefill_email=saved_email, sandbox=sandbox) diff --git a/src/sourcerykit/cli/main.py b/src/sourcerykit/cli/main.py index 98a0662..883888c 100644 --- a/src/sourcerykit/cli/main.py +++ b/src/sourcerykit/cli/main.py @@ -7,6 +7,7 @@ from sourcerykit.cli.endpoints import endpoints from sourcerykit.cli.feedback import send_feedback from sourcerykit.cli.init import config_provably +from sourcerykit.cli.sandbox import sandbox from sourcerykit.cli.trace import trace from sourcerykit.cli.upgrade import run_upgrade from sourcerykit.cli.utils import console @@ -15,6 +16,7 @@ app = typer.Typer(no_args_is_help=True) app.add_typer(endpoints, name="endpoints") app.add_typer(config, name="config") +app.add_typer(sandbox, name="sandbox") app.add_typer(trace, name="trace") @@ -25,6 +27,7 @@ def init( password: str | None = typer.Option(None, "--password", help="account password"), postgres_url: str | None = typer.Option(None, "--postgres-url", help="full postgres:// URL"), project_name: str | None = typer.Option(None, "--project-name", help="project name"), + sandbox: bool = typer.Option(False, "--sandbox", help="use hosted sandbox database"), ) -> None: config_provably( register=register, @@ -32,6 +35,7 @@ def init( password=password, postgres_url=postgres_url, project_name=project_name, + sandbox=sandbox, ) diff --git a/src/sourcerykit/cli/sandbox.py b/src/sourcerykit/cli/sandbox.py new file mode 100644 index 0000000..28c00ad --- /dev/null +++ b/src/sourcerykit/cli/sandbox.py @@ -0,0 +1,65 @@ +"""Sandbox database management commands.""" + +import asyncio + +import questionary +import typer +from dotenv import unset_key + +from sourcerykit.cli.utils import console, mask_postgres_url, require_settings +from sourcerykit.config import LOCAL_ENV_FILE, save_local_env +from sourcerykit.provably.service import service + +sandbox = typer.Typer(no_args_is_help=True) + + +@sandbox.command() +def create() -> None: + """Create or retrieve a hosted sandbox database.""" + settings = require_settings() + org_id = settings.org_id + if not org_id: + console.print("[red]āŒ No organisation configured. Run 'sourcerykit init' first.[/red]") + raise typer.Exit(code=1) + + console.print("Creating sandbox...", end=" ") + uri = asyncio.run(service.create_sandbox(org_id)) + save_local_env(SOURCERYKIT_POSTGRES_URL=uri) + console.print("DONE āœ…") + console.print(f"\n connection_uri = {mask_postgres_url(uri)}") + + +@sandbox.command() +def status() -> None: + """Show sandbox status and connection URI.""" + settings = require_settings() + + sandbox_data, is_sandbox = asyncio.run(service.get_sandbox_status(settings.postgres_url)) + if not sandbox_data: + console.print("[yellow]No sandbox found.[/yellow]") + return + + status_val = sandbox_data.get("status", "unknown") + uri = sandbox_data.get("connection_uri", "") + console.print(f" status = {status_val}") + console.print(f" connection_uri = {mask_postgres_url(uri)}") + console.print(f" in_use = {is_sandbox}") + + +@sandbox.command() +def delete( + yes: bool = typer.Option(False, "--yes", "-y", help="skip confirmation"), +) -> None: + """Delete the sandbox database.""" + require_settings() + + if not yes: + confirm = questionary.confirm("Delete sandbox? All data will be lost.", default=False).ask() + if not confirm: + console.print("[yellow]Cancelled.[/yellow]") + return + + console.print("Deleting sandbox...", end=" ") + asyncio.run(service.delete_sandbox()) + unset_key(str(LOCAL_ENV_FILE), "SOURCERYKIT_POSTGRES_URL") + console.print("DONE āœ…") diff --git a/src/sourcerykit/db/_engine.py b/src/sourcerykit/db/_engine.py index 1901b94..2c9c52e 100644 --- a/src/sourcerykit/db/_engine.py +++ b/src/sourcerykit/db/_engine.py @@ -21,6 +21,26 @@ class ConnectionInfo: provider: str uri: str + @classmethod + def from_url(cls, url: str) -> "ConnectionInfo": + """Parse a PostgreSQL URL into a ConnectionInfo.""" + parsed = urlparse(url) + provider = parsed.scheme.split("+", 1)[0] + host = parsed.hostname or "" + port = parsed.port + uri = f"{host}:{port}" if port else host + return cls( + name=parsed.path.lstrip("/"), + username=unquote(parsed.username or ""), + password=unquote(parsed.password or ""), + provider=provider, + uri=uri, + ) + + def same_server(self, other: "ConnectionInfo") -> bool: + """True if both point to the same database server and name (ignores credentials and query params).""" + return (self.provider, self.uri, self.name) == (other.provider, other.uri, other.name) + def to_dict(self) -> dict[str, Any]: return { "name": self.name, @@ -80,19 +100,4 @@ def get_connection_info() -> ConnectionInfo: """ Return the parsed connection details of the configured PostgreSQL URL. """ - url = get_settings().postgres_url - parsed = urlparse(url) - - provider = parsed.scheme.split("+", 1)[0] - - host = parsed.hostname or "" - port = parsed.port - uri = f"{host}:{port}" if port else host - - return ConnectionInfo( - name=parsed.path.lstrip("/"), - username=unquote(parsed.username or ""), - password=unquote(parsed.password or ""), - provider=provider, - uri=uri, - ) + return ConnectionInfo.from_url(get_settings().postgres_url) diff --git a/src/sourcerykit/provably/_api.py b/src/sourcerykit/provably/_api.py index 1c2e14d..5d271fe 100644 --- a/src/sourcerykit/provably/_api.py +++ b/src/sourcerykit/provably/_api.py @@ -57,6 +57,49 @@ async def list_organizations(self) -> list[dict[str, Any]]: result: list[dict[str, Any]] = await get_http().get(path) return result + # ------------------------------------------------------------------ + # Sandboxes + # ------------------------------------------------------------------ + + async def create_sandbox(self, org_id: uuid.UUID, *, token: str | None = None) -> dict[str, Any]: + """ + Create a hosted sandbox database for the given organisation. + + Args: + org_id: The ID of the organisation that owns the sandbox. + token: Optional JWT token for authentication (used during init). + + Returns: + dict[str, Any]: Sandbox record with ``status`` and ``connection_uri``. + """ + path = "/api/v1/sandboxes" + result: dict[str, Any] = await get_http().post(path, {"org_id": str(org_id)}, token=token) + return result + + async def get_sandbox(self, *, token: str | None = None) -> dict[str, Any]: + """ + Retrieve the current sandbox for the authenticated user. + + Args: + token: Optional JWT token for authentication (used during init). + + Returns: + dict[str, Any]: Sandbox record with ``status`` and ``connection_uri``. + """ + path = "/api/v1/sandboxes" + result: dict[str, Any] = await get_http().get(path, token=token) + return result + + async def delete_sandbox(self, *, token: str | None = None) -> None: + """ + Delete the sandbox for the authenticated user. + + Args: + token: Optional JWT token for authentication (used during init). + """ + path = "/api/v1/sandboxes" + await get_http().delete(path, token=token) + # ------------------------------------------------------------------ # Middlewares # ------------------------------------------------------------------ diff --git a/src/sourcerykit/provably/_http.py b/src/sourcerykit/provably/_http.py index fd1c7cc..98688bd 100644 --- a/src/sourcerykit/provably/_http.py +++ b/src/sourcerykit/provably/_http.py @@ -163,6 +163,15 @@ async def post_multipart( processed_payload.update(files) return await self._fetch("POST", path, api_key=api_key, token=token, files=processed_payload) + async def delete( + self, + path: str, + *, + api_key: str | None = None, + token: str | None = None, + ) -> Any: + return await self._fetch("DELETE", path, api_key=api_key, token=token) + @functools.lru_cache(maxsize=1) def get_http() -> ProvablyHTTPClient: diff --git a/src/sourcerykit/provably/service.py b/src/sourcerykit/provably/service.py index 65e127b..825898d 100644 --- a/src/sourcerykit/provably/service.py +++ b/src/sourcerykit/provably/service.py @@ -10,7 +10,7 @@ from sourcerykit.db._schema import INTERCEPTS_TABLE from sourcerykit.logger import get_logger from sourcerykit.provably._api import get_api -from sourcerykit.provably._errors import provably_error_handler +from sourcerykit.provably._errors import ProvablyNotFoundError, provably_error_handler _log = get_logger(__name__) @@ -34,6 +34,103 @@ async def create_feedback(self, description: str, file: bytes | None) -> None: async with provably_error_handler("create_feedback"): return await get_api().create_feedback(feedback_body, files=file_payload) + # ------------------------------------------------------------------ + # Sandboxes + # ------------------------------------------------------------------ + + async def create_sandbox(self, org_id: uuid.UUID, *, token: str | None = None) -> str: + """Create a hosted sandbox database for the given organisation. + + Args: + org_id: The ID of the organisation that owns the sandbox. + token: Optional JWT token for authentication (used during init). + + Returns: + str: The connection URI for the new sandbox. + + Raises: + ProvablyAPIError: If the server rejects the request. + ProvablyConnectionError: If the network is unreachable. + ProvablyDataError: If the response is malformed. + """ + async with provably_error_handler("create_sandbox"): + result = await get_api().create_sandbox(org_id, token=token) + uri = result.get("connection_uri") + if not uri: + raise ValueError("create_sandbox response missing 'connection_uri'") + return str(uri) + + async def get_sandbox(self, *, token: str | None = None) -> dict[str, Any] | None: + """Retrieve the current sandbox for the authenticated user. + + Args: + token: Optional JWT token for authentication (used during init). + + Returns: + dict[str, Any] | None: Sandbox dict with ``status`` and + ``connection_uri`` keys, or ``None`` if no sandbox exists. + + Raises: + ProvablyAPIError: If the server rejects the request. + ProvablyConnectionError: If the network is unreachable. + """ + try: + async with provably_error_handler("get_sandbox"): + return await get_api().get_sandbox(token=token) + except ProvablyNotFoundError: + return None + + async def get_sandbox_connection_uri(self, *, token: str | None = None) -> str | None: + """Return the connection URI of an active sandbox, or ``None``. + + Args: + token: Optional JWT token for authentication (used during init). + + Returns: + str | None: The connection URI if a sandbox exists and is + active, ``None`` otherwise. + + Raises: + ProvablyAPIError: If the server rejects the request. + ProvablyConnectionError: If the network is unreachable. + """ + sandbox = await self.get_sandbox(token=token) + if not sandbox: + return None + status = sandbox.get("status", "").lower() + if status in ("active", "provisioning"): + return sandbox.get("connection_uri") + return None + + async def get_sandbox_status(self, postgres_url: str) -> tuple[dict[str, Any] | None, bool]: + """Fetch sandbox and check if *postgres_url* matches its connection URI. + + Returns: + (sandbox_data, is_sandbox) — ``is_sandbox`` is ``True`` when the + configured postgres_url points at the sandbox. + """ + sandbox = await self.get_sandbox() + if not sandbox: + return None, False + uri = sandbox.get("connection_uri") + if not uri: + return None, False + is_sandbox = ConnectionInfo.from_url(postgres_url).same_server(ConnectionInfo.from_url(uri)) + return sandbox, is_sandbox + + async def delete_sandbox(self, *, token: str | None = None) -> None: + """Delete the sandbox for the authenticated user. + + Args: + token: Optional JWT token for authentication (used during init). + + Raises: + ProvablyAPIError: If the server rejects the request. + ProvablyConnectionError: If the network is unreachable. + """ + async with provably_error_handler("delete_sandbox"): + await get_api().delete_sandbox(token=token) + # ------------------------------------------------------------------ # Middleware # ------------------------------------------------------------------ diff --git a/tests/unit/test_bootstrap.py b/tests/unit/test_bootstrap.py index 2bd01dd..f71ae25 100644 --- a/tests/unit/test_bootstrap.py +++ b/tests/unit/test_bootstrap.py @@ -21,12 +21,14 @@ async def test_happy_path_calls_all_steps(self) -> None: patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, patch("sourcerykit.bootstrap.bootstrap.init_interceptor") as mock_init, + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, ): settings = MagicMock() settings.postgres_url = "postgresql://test" settings.has_bootstrap_ids = False settings.project_name = "test-project" mock_cfg.return_value = settings + mock_svc.get_sandbox_status = AsyncMock(return_value=(None, False)) mock_cache.run_handshake = AsyncMock() await bootstrap_system() @@ -46,7 +48,9 @@ async def test_raises_storage_error_when_db_schema_creation_fails(self) -> None: with ( patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, ): + mock_svc.get_sandbox_status = AsyncMock(return_value=(None, False)) with pytest.raises(SourceryKitStorageError): await bootstrap_system() @@ -66,7 +70,9 @@ async def test_raises_bootstrap_error_when_handshake_fails(self) -> None: patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, ): + mock_svc.get_sandbox_status = AsyncMock(return_value=(None, False)) mock_cache.run_handshake = AsyncMock(side_effect=RuntimeError("handshake failed")) with pytest.raises(RuntimeError, match="handshake failed"): await bootstrap_system() @@ -87,11 +93,121 @@ async def test_propagates_sourcerykit_error_from_handshake(self) -> None: patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, ): + mock_svc.get_sandbox_status = AsyncMock(return_value=(None, False)) mock_cache.run_handshake = AsyncMock(side_effect=SourceryKitBootstrapError("explicit")) with pytest.raises(SourceryKitBootstrapError, match="explicit"): await bootstrap_system() + async def test_sandbox_skips_ensure_schema(self) -> None: + mock_engine = MagicMock() + + settings = MagicMock() + settings.postgres_url = "postgresql://sandbox/db" + settings.has_bootstrap_ids = True + settings.project_name = "test-project" + + with ( + patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), + patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), + patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, + patch("sourcerykit.bootstrap.bootstrap.init_interceptor") as mock_init, + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, + patch("sourcerykit.bootstrap.bootstrap.ensure_schema") as mock_ensure, + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": "postgresql://sandbox/db", "status": "active"}, True) + ) + mock_cache.load_from = MagicMock() + await bootstrap_system() + + mock_ensure.assert_not_called() + mock_init.assert_called_once() + + async def test_recreates_expired_sandbox(self) -> None: + mock_engine = MagicMock() + + settings = MagicMock() + settings.postgres_url = "postgresql://old-sandbox/db" + settings.org_id = "org-123" + settings.has_bootstrap_ids = True + + with ( + patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), + patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), + patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, + patch("sourcerykit.bootstrap.bootstrap.init_interceptor"), + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, + patch("sourcerykit.bootstrap.bootstrap.save_local_env") as mock_save, + patch("sourcerykit.bootstrap.bootstrap.ensure_schema"), + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": "postgresql://old-sandbox/db", "status": "expired"}, True) + ) + mock_svc.create_sandbox = AsyncMock(return_value="postgresql://new-sandbox/db") + mock_cache.load_from = MagicMock() + + await bootstrap_system() + + mock_svc.create_sandbox.assert_awaited_once_with("org-123") + mock_save.assert_called_once_with(SOURCERYKIT_POSTGRES_URL="postgresql://new-sandbox/db") + + async def test_skips_recreation_for_active_sandbox(self) -> None: + mock_engine = MagicMock() + + settings = MagicMock() + settings.postgres_url = "postgresql://sandbox/db" + settings.has_bootstrap_ids = True + + with ( + patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), + patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), + patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, + patch("sourcerykit.bootstrap.bootstrap.init_interceptor"), + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, + patch("sourcerykit.bootstrap.bootstrap.save_local_env") as mock_save, + patch("sourcerykit.bootstrap.bootstrap.ensure_schema"), + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": "postgresql://sandbox/db", "status": "active"}, True) + ) + mock_svc.create_sandbox = AsyncMock() + mock_cache.load_from = MagicMock() + + await bootstrap_system() + + mock_svc.create_sandbox.assert_not_awaited() + mock_save.assert_not_called() + + async def test_skips_recreation_when_no_org_id(self) -> None: + mock_engine = MagicMock() + + settings = MagicMock() + settings.postgres_url = "postgresql://sandbox/db" + settings.org_id = None + settings.has_bootstrap_ids = True + + with ( + patch("sourcerykit.bootstrap.bootstrap.get_settings", return_value=settings), + patch("sourcerykit.bootstrap.bootstrap.get_engine", return_value=mock_engine), + patch("sourcerykit.bootstrap.bootstrap._BOOTSTRAP_INSTANCE") as mock_cache, + patch("sourcerykit.bootstrap.bootstrap.init_interceptor"), + patch("sourcerykit.bootstrap.bootstrap.provably_service") as mock_svc, + patch("sourcerykit.bootstrap.bootstrap.save_local_env") as mock_save, + patch("sourcerykit.bootstrap.bootstrap.ensure_schema"), + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": "postgresql://sandbox/db", "status": "expired"}, True) + ) + mock_svc.create_sandbox = AsyncMock() + mock_cache.load_from = MagicMock() + + await bootstrap_system() + + mock_svc.create_sandbox.assert_not_awaited() + mock_save.assert_not_called() + class TestGetBootstrap: def test_returns_bootstrap_cache_instance(self) -> None: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 5c70e46..1bb3f12 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -214,6 +214,7 @@ def test_calls_execute_post_auth_phases_on_success(self) -> None: email="user@example.com", postgres_url=None, project_name=None, + sandbox=False, ) def test_handles_unauthorized_error_without_crash(self) -> None: @@ -461,6 +462,7 @@ def test_passes_flags_to_post_auth_phases(self) -> None: email="a@b.com", postgres_url="postgresql://u:p@h:5432/db", project_name="myproj", + sandbox=False, ) def test_handles_unauthorized_error(self) -> None: diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py index 21add73..586ab4b 100644 --- a/tests/unit/test_doctor.py +++ b/tests/unit/test_doctor.py @@ -6,7 +6,7 @@ from sourcerykit.cli.doctor import ( _check_api_key_and_org, _check_bootstrap_ids, - _check_postgres, + _check_database, _check_project_name, _deep_check_collection_and_ids, _deep_check_integration, @@ -110,31 +110,93 @@ def test_org_not_found(self) -> None: # --------------------------------------------------------------------------- -# _check_postgres +# _check_database # --------------------------------------------------------------------------- -class TestCheckPostgres: +class TestCheckDatabase: def test_missing_url(self) -> None: s = _make_settings(postgres_url="") - ok, msg = _check_postgres(s) + ok, msg = _check_database(s) assert ok is False assert "SOURCERYKIT_POSTGRES_URL is missing" in msg - def test_connected(self) -> None: + def test_personal_db_connected(self) -> None: s = _make_settings() - with patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=True): - ok, msg = _check_postgres(s) + with ( + patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=True), + patch("sourcerykit.cli.doctor.service") as mock_svc, + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": "postgresql://other/db", "status": "active"}, False) + ) + ok, msg = _check_database(s) assert ok is True - assert "successful" in msg + assert "Personal database" in msg - def test_connection_failed(self) -> None: + def test_personal_db_connection_failed(self) -> None: s = _make_settings() - with patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=False): - ok, msg = _check_postgres(s) + with ( + patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=False), + patch("sourcerykit.cli.doctor.service") as mock_svc, + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": "postgresql://other/db", "status": "active"}, False) + ) + ok, msg = _check_database(s) assert ok is False assert "connection failed" in msg.lower() + def test_sandbox_active(self) -> None: + s = _make_settings() + with ( + patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=True), + patch("sourcerykit.cli.doctor.service") as mock_svc, + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": s.postgres_url, "status": "active"}, True) + ) + ok, msg = _check_database(s) + assert ok is True + assert "Sandbox database" in msg + + def test_sandbox_provisioning(self) -> None: + s = _make_settings() + with ( + patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=True), + patch("sourcerykit.cli.doctor.service") as mock_svc, + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": s.postgres_url, "status": "provisioning"}, True) + ) + ok, msg = _check_database(s) + assert ok is True + assert "Sandbox database" in msg + + def test_sandbox_expired(self) -> None: + s = _make_settings() + with ( + patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=True), + patch("sourcerykit.cli.doctor.service") as mock_svc, + ): + mock_svc.get_sandbox_status = AsyncMock( + return_value=({"connection_uri": s.postgres_url, "status": "expired"}, True) + ) + ok, msg = _check_database(s) + assert ok is False + assert "expired" in msg.lower() + + def test_sandbox_api_error_falls_through(self) -> None: + s = _make_settings() + with ( + patch("sourcerykit.cli.doctor.run_connectivity_check", return_value=True), + patch("sourcerykit.cli.doctor.service") as mock_svc, + ): + mock_svc.get_sandbox_status = AsyncMock(side_effect=Exception("network")) + ok, msg = _check_database(s) + assert ok is True + assert "Personal database" in msg + # --------------------------------------------------------------------------- # _check_project_name @@ -294,7 +356,7 @@ def test_all_checks_pass(self) -> None: with ( patch("sourcerykit.cli.doctor.get_settings", return_value=s), patch("sourcerykit.cli.doctor._check_api_key_and_org", return_value=(True, "ok")), - patch("sourcerykit.cli.doctor._check_postgres", return_value=(True, "ok")), + patch("sourcerykit.cli.doctor._check_database", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._check_project_name", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._check_bootstrap_ids", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._run_deep_check_collection_and_ids", return_value=(True, "ok")), @@ -308,7 +370,7 @@ def test_some_checks_fail(self) -> None: with ( patch("sourcerykit.cli.doctor.get_settings", return_value=s), patch("sourcerykit.cli.doctor._check_api_key_and_org", return_value=(False, "bad key")), - patch("sourcerykit.cli.doctor._check_postgres", return_value=(True, "ok")), + patch("sourcerykit.cli.doctor._check_database", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._check_project_name", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._check_bootstrap_ids", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._run_deep_check_collection_and_ids", return_value=(True, "ok")), @@ -322,7 +384,7 @@ def test_fix_attempts_bootstrap(self) -> None: with ( patch("sourcerykit.cli.doctor.get_settings", return_value=s), patch("sourcerykit.cli.doctor._check_api_key_and_org", return_value=(True, "ok")), - patch("sourcerykit.cli.doctor._check_postgres", return_value=(True, "ok")), + patch("sourcerykit.cli.doctor._check_database", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._check_project_name", return_value=(True, "ok")), patch("sourcerykit.cli.doctor._check_bootstrap_ids", return_value=(False, "missing")), patch("sourcerykit.cli.doctor._run_deep_check_collection_and_ids", return_value=(True, "ok")), diff --git a/tests/unit/test_sandbox.py b/tests/unit/test_sandbox.py new file mode 100644 index 0000000..e16c5c9 --- /dev/null +++ b/tests/unit/test_sandbox.py @@ -0,0 +1,172 @@ +"""Tests for sourcerykit.cli.sandbox — CLI sandbox commands.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import typer + +from sourcerykit.cli.sandbox import create, delete, status + +# --------------------------------------------------------------------------- +# sandbox create +# --------------------------------------------------------------------------- + + +class TestSandboxCreate: + def test_creates_sandbox_and_saves_uri(self) -> None: + mock_settings = MagicMock() + mock_settings.org_id = "org-123" + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.save_local_env") as mock_save, + patch("sourcerykit.cli.sandbox.console"), + ): + mock_svc.create_sandbox = AsyncMock(return_value="postgresql://sandbox/db") + + create() + + mock_svc.create_sandbox.assert_awaited_once() + mock_save.assert_called_once_with(SOURCERYKIT_POSTGRES_URL="postgresql://sandbox/db") + + def test_exits_when_no_org_id(self) -> None: + mock_settings = MagicMock() + mock_settings.org_id = None + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.console"), + ): + with pytest.raises(typer.Exit): + create() + + def test_api_error_propagates(self) -> None: + mock_settings = MagicMock() + mock_settings.org_id = "org-123" + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.console"), + ): + mock_svc.create_sandbox = AsyncMock(side_effect=RuntimeError("API down")) + + with pytest.raises(RuntimeError, match="API down"): + create() + + +# --------------------------------------------------------------------------- +# sandbox status +# --------------------------------------------------------------------------- + + +class TestSandboxStatus: + def test_active_sandbox(self) -> None: + mock_settings = MagicMock() + mock_settings.postgres_url = "postgresql://sandbox/db" + sandbox_data = {"status": "active", "connection_uri": "postgresql://sandbox/db"} + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.console") as mock_console, + ): + mock_svc.get_sandbox_status = AsyncMock(return_value=(sandbox_data, True)) + + status() + + mock_console.print.assert_any_call(" status = active") + + def test_no_sandbox_found(self) -> None: + mock_settings = MagicMock() + mock_settings.postgres_url = "postgresql://host/db" + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.console") as mock_console, + ): + mock_svc.get_sandbox_status = AsyncMock(return_value=(None, False)) + + status() + + mock_console.print.assert_any_call("[yellow]No sandbox found.[/yellow]") + + def test_personal_db_not_in_use(self) -> None: + mock_settings = MagicMock() + mock_settings.postgres_url = "postgresql://myhost/db" + sandbox_data = {"status": "active", "connection_uri": "postgresql://sandbox/db"} + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.console") as mock_console, + ): + mock_svc.get_sandbox_status = AsyncMock(return_value=(sandbox_data, False)) + + status() + + mock_console.print.assert_any_call(" in_use = False") + + +# --------------------------------------------------------------------------- +# sandbox delete +# --------------------------------------------------------------------------- + + +class TestSandboxDelete: + def test_deletes_with_yes_flag(self) -> None: + mock_settings = MagicMock() + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.unset_key") as mock_unset, + patch("sourcerykit.cli.sandbox.console"), + ): + mock_svc.delete_sandbox = AsyncMock() + + delete(yes=True) + + mock_svc.delete_sandbox.assert_awaited_once() + mock_unset.assert_called_once() + + def test_deletes_after_confirmation(self) -> None: + mock_settings = MagicMock() + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.questionary") as mock_q, + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.unset_key"), + patch("sourcerykit.cli.sandbox.console"), + ): + mock_q.confirm.return_value.ask.return_value = True + mock_svc.delete_sandbox = AsyncMock() + + delete(yes=False) + + mock_svc.delete_sandbox.assert_awaited_once() + + def test_cancels_on_no(self) -> None: + mock_settings = MagicMock() + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.questionary") as mock_q, + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.console"), + ): + mock_q.confirm.return_value.ask.return_value = False + + delete(yes=False) + + mock_svc.delete_sandbox.assert_not_called() + + def test_removes_postgres_url_from_env(self) -> None: + mock_settings = MagicMock() + with ( + patch("sourcerykit.cli.sandbox.require_settings", return_value=mock_settings), + patch("sourcerykit.cli.sandbox.service") as mock_svc, + patch("sourcerykit.cli.sandbox.unset_key") as mock_unset, + patch("sourcerykit.cli.sandbox.console"), + ): + mock_svc.delete_sandbox = AsyncMock() + + delete(yes=True) + + mock_unset.assert_called_once() + args = mock_unset.call_args[0] + assert "SOURCERYKIT_POSTGRES_URL" in args diff --git a/tests/unit/test_sandbox_api.py b/tests/unit/test_sandbox_api.py new file mode 100644 index 0000000..889cb32 --- /dev/null +++ b/tests/unit/test_sandbox_api.py @@ -0,0 +1,107 @@ +"""Tests for sourcerykit.provably._api — sandbox API methods.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +from sourcerykit.provably._api import ProvablyAPI + + +def _make_api() -> tuple[ProvablyAPI, MagicMock]: + """Return a ProvablyAPI with mocked settings and HTTP client.""" + settings = MagicMock() + settings.org_id = uuid.uuid4() + settings.provably_app = "https://app.provably.ai" + api = ProvablyAPI(settings=settings) + return api, settings + + +# --------------------------------------------------------------------------- +# create_sandbox +# --------------------------------------------------------------------------- + + +class TestProvablyAPICreateSandbox: + async def test_posts_org_id(self) -> None: + api, settings = _make_api() + mock_http = MagicMock() + mock_http.post = AsyncMock(return_value={"status": "active", "connection_uri": "postgresql://sandbox/db"}) + + with patch("sourcerykit.provably._api.get_http", return_value=mock_http): + result = await api.create_sandbox(settings.org_id) + + mock_http.post.assert_called_once_with( + "/api/v1/sandboxes", + {"org_id": str(settings.org_id)}, + token=None, + ) + assert result["status"] == "active" + + async def test_passes_token(self) -> None: + api, settings = _make_api() + mock_http = MagicMock() + mock_http.post = AsyncMock(return_value={"status": "provisioning"}) + + with patch("sourcerykit.provably._api.get_http", return_value=mock_http): + await api.create_sandbox(settings.org_id, token="jwt-abc") + + mock_http.post.assert_called_once_with( + "/api/v1/sandboxes", + {"org_id": str(settings.org_id)}, + token="jwt-abc", + ) + + +# --------------------------------------------------------------------------- +# get_sandbox +# --------------------------------------------------------------------------- + + +class TestProvablyAPIGetSandbox: + async def test_returns_sandbox_record(self) -> None: + api, _ = _make_api() + mock_http = MagicMock() + sandbox = {"status": "active", "connection_uri": "postgresql://sandbox/db"} + mock_http.get = AsyncMock(return_value=sandbox) + + with patch("sourcerykit.provably._api.get_http", return_value=mock_http): + result = await api.get_sandbox() + + mock_http.get.assert_called_once_with("/api/v1/sandboxes", token=None) + assert result == sandbox + + async def test_passes_token(self) -> None: + api, _ = _make_api() + mock_http = MagicMock() + mock_http.get = AsyncMock(return_value={}) + + with patch("sourcerykit.provably._api.get_http", return_value=mock_http): + await api.get_sandbox(token="jwt-xyz") + + mock_http.get.assert_called_once_with("/api/v1/sandboxes", token="jwt-xyz") + + +# --------------------------------------------------------------------------- +# delete_sandbox +# --------------------------------------------------------------------------- + + +class TestProvablyAPIDeleteSandbox: + async def test_calls_delete(self) -> None: + api, _ = _make_api() + mock_http = MagicMock() + mock_http.delete = AsyncMock(return_value=None) + + with patch("sourcerykit.provably._api.get_http", return_value=mock_http): + await api.delete_sandbox() + + mock_http.delete.assert_called_once_with("/api/v1/sandboxes", token=None) + + async def test_passes_token(self) -> None: + api, _ = _make_api() + mock_http = MagicMock() + mock_http.delete = AsyncMock(return_value=None) + + with patch("sourcerykit.provably._api.get_http", return_value=mock_http): + await api.delete_sandbox(token="jwt-del") + + mock_http.delete.assert_called_once_with("/api/v1/sandboxes", token="jwt-del") diff --git a/tests/unit/test_sandbox_service.py b/tests/unit/test_sandbox_service.py new file mode 100644 index 0000000..469ff9b --- /dev/null +++ b/tests/unit/test_sandbox_service.py @@ -0,0 +1,187 @@ +"""Tests for sourcerykit.provably.service — sandbox service methods.""" + +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from sourcerykit.provably._errors import ProvablyDataError +from sourcerykit.provably.service import ProvablyService + + +def _make_service() -> tuple[ProvablyService, MagicMock]: + """Return a ProvablyService with a mocked API.""" + service = ProvablyService() + mock_api = MagicMock() + return service, mock_api + + +# --------------------------------------------------------------------------- +# create_sandbox +# --------------------------------------------------------------------------- + + +class TestProvablyServiceCreateSandbox: + async def test_returns_uri(self) -> None: + service, mock_api = _make_service() + org_id = uuid.uuid4() + mock_api.create_sandbox = AsyncMock( + return_value={"status": "active", "connection_uri": "postgresql://sandbox/db"} + ) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + result = await service.create_sandbox(org_id) + + assert result == "postgresql://sandbox/db" + + async def test_missing_uri_raises(self) -> None: + service, mock_api = _make_service() + org_id = uuid.uuid4() + mock_api.create_sandbox = AsyncMock(return_value={"status": "provisioning"}) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + with pytest.raises(ProvablyDataError, match="connection_uri"): + await service.create_sandbox(org_id) + + async def test_passes_token(self) -> None: + service, mock_api = _make_service() + org_id = uuid.uuid4() + mock_api.create_sandbox = AsyncMock(return_value={"connection_uri": "postgresql://sandbox/db"}) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + await service.create_sandbox(org_id, token="jwt-abc") + + mock_api.create_sandbox.assert_called_once_with(org_id, token="jwt-abc") + + +# --------------------------------------------------------------------------- +# get_sandbox +# --------------------------------------------------------------------------- + + +class TestProvablyServiceGetSandbox: + async def test_returns_sandbox_data(self) -> None: + service, mock_api = _make_service() + sandbox = {"status": "active", "connection_uri": "postgresql://sandbox/db"} + mock_api.get_sandbox = AsyncMock(return_value=sandbox) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + result = await service.get_sandbox() + + assert result == sandbox + + async def test_not_found_returns_none(self) -> None: + service = ProvablyService() + with patch.object(service, "get_sandbox", return_value=None): + result = await service.get_sandbox() + + assert result is None + + +# --------------------------------------------------------------------------- +# get_sandbox_connection_uri +# --------------------------------------------------------------------------- + + +class TestProvablyServiceGetSandboxConnectionUri: + async def test_active_returns_uri(self) -> None: + service, mock_api = _make_service() + mock_api.get_sandbox = AsyncMock(return_value={"status": "active", "connection_uri": "postgresql://sandbox/db"}) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + result = await service.get_sandbox_connection_uri() + + assert result == "postgresql://sandbox/db" + + async def test_provisioning_returns_uri(self) -> None: + service, mock_api = _make_service() + mock_api.get_sandbox = AsyncMock( + return_value={"status": "provisioning", "connection_uri": "postgresql://sandbox/db"} + ) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + result = await service.get_sandbox_connection_uri() + + assert result == "postgresql://sandbox/db" + + async def test_expired_returns_none(self) -> None: + service, mock_api = _make_service() + mock_api.get_sandbox = AsyncMock( + return_value={"status": "expired", "connection_uri": "postgresql://sandbox/db"} + ) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + result = await service.get_sandbox_connection_uri() + + assert result is None + + async def test_no_sandbox_returns_none(self) -> None: + service = ProvablyService() + with patch.object(service, "get_sandbox", return_value=None): + result = await service.get_sandbox_connection_uri() + + assert result is None + + +# --------------------------------------------------------------------------- +# get_sandbox_status +# --------------------------------------------------------------------------- + + +class TestProvablyServiceGetSandboxStatus: + async def test_matching_url_returns_true(self) -> None: + service, mock_api = _make_service() + uri = "postgresql://user:pass@sandbox.provably.ai:5432/mydb" + mock_api.get_sandbox = AsyncMock(return_value={"status": "active", "connection_uri": uri}) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + sandbox, is_sandbox = await service.get_sandbox_status(uri) + + assert is_sandbox is True + assert sandbox is not None + assert sandbox["status"] == "active" + + async def test_different_url_returns_false(self) -> None: + service, mock_api = _make_service() + sandbox_uri = "postgresql://user:pass@sandbox.provably.ai:5432/mydb" + personal_uri = "postgresql://user:pass@myhost:5432/mydb" + mock_api.get_sandbox = AsyncMock(return_value={"status": "active", "connection_uri": sandbox_uri}) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + sandbox, is_sandbox = await service.get_sandbox_status(personal_uri) + + assert is_sandbox is False + assert sandbox is not None + + async def test_no_sandbox_returns_none_false(self) -> None: + service = ProvablyService() + with patch.object(service, "get_sandbox", return_value=None): + sandbox, is_sandbox = await service.get_sandbox_status("postgresql://host/db") + + assert sandbox is None + assert is_sandbox is False + + +# --------------------------------------------------------------------------- +# delete_sandbox +# --------------------------------------------------------------------------- + + +class TestProvablyServiceDeleteSandbox: + async def test_calls_api_delete(self) -> None: + service, mock_api = _make_service() + mock_api.delete_sandbox = AsyncMock(return_value=None) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + await service.delete_sandbox() + + mock_api.delete_sandbox.assert_awaited_once() + + async def test_passes_token(self) -> None: + service, mock_api = _make_service() + mock_api.delete_sandbox = AsyncMock(return_value=None) + + with patch("sourcerykit.provably.service.get_api", return_value=mock_api): + await service.delete_sandbox(token="jwt-del") + + mock_api.delete_sandbox.assert_called_once_with(token="jwt-del")