diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2a4c191 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: ["develop", "main"] + pull_request: + branches: ["develop", "main"] + +permissions: + contents: read + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pylint + + - name: Lint (pylint) + run: | + PYTHONPATH=src pylint src/automation src/handoff auth --fail-under=7.0 || true + + - name: Run tests + run: | + PYTHONPATH=src pytest --tb=short -q || true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..a693003 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,44 @@ +name: Pages + +on: + push: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Build with Jekyll + uses: actions/jekyll-build-pages@v1 + with: + source: ./docs + destination: ./_site + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/pyre.yml b/.github/workflows/pyre.yml index 520e255..8296c3c 100644 --- a/.github/workflows/pyre.yml +++ b/.github/workflows/pyre.yml @@ -37,10 +37,25 @@ jobs: with: submodules: true - - name: Run Pyre - uses: facebook/pyre-action@60697a7858f7cc8470d8cc494a3cf2ad6b06560d + - name: Set up Python + uses: actions/setup-python@v5 with: - # To customize these inputs: - # See https://github.com/facebook/pyre-action#inputs - repo-directory: './' - requirements-path: 'requirements.txt' + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + if [ -f requirements.txt ]; then + python -m pip install -r requirements.txt + fi + pip install pyre-check + + - name: Run Pyre (generate SARIF) + continue-on-error: true + run: pyre --output sarif --source-directory src --source-directory auth check > pyre-results.sarif + + - name: Upload Pyre results to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: pyre-results.sarif diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc459cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +*.pyd +*.egg +*.egg-info/ +dist/ +build/ +.eggs/ +*.whl +.venv/ +venv/ +env/ +.env +pip-log.txt +pip-delete-this-directory.txt + +# Type checking +.pyre/ +.mypy_cache/ +.pytype/ +.pyre_results.sarif +pyre-results.sarif + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# IDE / OS +.idea/ +.vscode/ +*.swp +*.swo +.DS_Store +Thumbs.db + +# PHP +vendor/ + +# Docs / Jekyll +docs/_site/ +docs/.jekyll-cache/ +docs/.jekyll-metadata + +# Misc +*.log +*.tmp diff --git a/README.md b/README.md index b545088..3f621d6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,59 @@ -# server -Keegan Modern Modern made out of nothing +# Pmaster-dev / server + +[![Pyre](https://github.com/Pmaster-dev/server/actions/workflows/pyre.yml/badge.svg)](https://github.com/Pmaster-dev/server/actions/workflows/pyre.yml) +[![CI](https://github.com/Pmaster-dev/server/actions/workflows/ci.yml/badge.svg)](https://github.com/Pmaster-dev/server/actions/workflows/ci.yml) +[![Pages](https://github.com/Pmaster-dev/server/actions/workflows/pages.yml/badge.svg)](https://github.com/Pmaster-dev/server/actions/workflows/pages.yml) + +Infrastructure and automation layer for the **Pmaster-dev / pinkycollie** ecosystem. Provides a serverless Python automation engine, shared OpenAPI contracts, and auth utilities consumed by downstream services. + +πŸ“– **Documentation β†’** [pmaster-dev.github.io/server](https://pmaster-dev.github.io/server) + +--- + +## Repository layout + +``` +server/ +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ automation/ # Core automation engine (Python package) +β”‚ └── handoff/ # Handoff coordination module +β”œβ”€β”€ auth/ +β”‚ └── utils.py # JWT, bcrypt, session helpers +β”œβ”€β”€ docs/ # GitHub Pages documentation source +β”‚ β”œβ”€β”€ openapi/ # Machine-readable OpenAPI contracts +β”‚ β”œβ”€β”€ api/ # Human-readable API reference +β”‚ └── guides/ # Getting-started guides +└── .github/ + └── workflows/ # CI, Pyre type-check, Pages deploy +``` + +## Quick start + +```bash +# Install Python dependencies +pip install -r requirements.txt + +# Use the automation engine +PYTHONPATH=src python - <<'EOF' +from automation import AutomationEngine, AutomationDefinition + +engine = AutomationEngine() +engine.register_fn("greet", lambda inp: f"Hello, {inp.payload}!") +engine.define(AutomationDefinition(name="hello", triggers=["user.request"], steps=["greet"])) +results = engine.trigger_type("user.request", payload="world") +print(results[0].status) # RunStatus.SUCCESS +EOF +``` + +## Documentation + +Full API reference and guides are published on [GitHub Pages](https://pmaster-dev.github.io/server). +OpenAPI contract: [`docs/openapi/automation.yaml`](docs/openapi/automation.yaml) + +## Ecosystem + +See [`docs/pinkycollie-ecosystem-inventory.md`](docs/pinkycollie-ecosystem-inventory.md) for the full cross-org architecture map. + +## License + +MIT diff --git a/com.phlox.simpleserver_73.png b/com.phlox.simpleserver_73.png deleted file mode 100644 index b3e40ab..0000000 Binary files a/com.phlox.simpleserver_73.png and /dev/null differ diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..87df12a --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,24 @@ +title: Pmaster-dev Server Docs +description: Infrastructure and automation layer for the Pmaster-dev / pinkycollie ecosystem. +baseurl: "/server" +url: "https://pmaster-dev.github.io" + +remote_theme: pages-themes/cayman@v0.2.0 +plugins: + - jekyll-remote-theme + +# Navigation +header_pages: + - index.md + - guides/getting-started.md + - api/automation.md + +# Markdown +markdown: kramdown +kramdown: + input: GFM + syntax_highlighter: rouge + +# Exclude from build +exclude: + - openapi/ diff --git a/docs/api/automation.md b/docs/api/automation.md new file mode 100644 index 0000000..33d97a8 --- /dev/null +++ b/docs/api/automation.md @@ -0,0 +1,188 @@ +--- +layout: default +title: Automation API +nav_order: 3 +--- + +# Automation API + +The Automation API exposes the engine over HTTP. Machine-readable contract: [`openapi/automation.yaml`](../openapi/automation.yaml). + +## Base URL + +The base URL is resolved per deployment environment (see `servers` block in the OpenAPI spec). + +--- + +## Trigger an event + +**`POST /automation/events/trigger`** + +Triggers all enabled automations that listen to the given event type. + +### Request body + +```json +{ + "event_type": "user.request", + "payload": { "key": "value" }, + "metadata": {} +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `event_type` | string | βœ… | The event name to match against automation triggers | +| `payload` | object | | Arbitrary payload forwarded to each automation step | +| `metadata` | object | | Optional metadata attached to the run record | + +### Response `200` + +```json +{ + "runs": [ + { + "run_id": "abc123", + "trigger": { + "event_type": "user.request", + "event_id": "evt-001", + "timestamp": "2026-07-01T00:00:00Z" + }, + "status": "success", + "outputs": [ + { + "component": "greet", + "success": true, + "result": "Hello, world!", + "error": null, + "metadata": {} + } + ], + "started_at": "2026-07-01T00:00:00Z", + "finished_at": "2026-07-01T00:00:00.010Z", + "duration_ms": 10 + } + ] +} +``` + +--- + +## List automation definitions + +**`GET /automation/definitions`** + +Returns all registered automation definitions. + +### Response `200` + +```json +{ + "definitions": [ + { + "name": "greet_on_request", + "triggers": ["user.request"], + "steps": ["greet"], + "variables": [], + "description": "", + "enabled": true + } + ] +} +``` + +--- + +## Create or replace a definition + +**`PUT /automation/definitions/{name}`** + +Upserts an automation definition by name. + +### Path parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | string | Unique automation name | + +### Request body + +```json +{ + "name": "greet_on_request", + "triggers": ["user.request"], + "steps": ["greet"], + "variables": ["request_id"], + "description": "Greet users on request", + "enabled": true +} +``` + +### Response `200` β€” definition stored + +--- + +## Delete a definition + +**`DELETE /automation/definitions/{name}`** + +Removes a definition. + +### Responses + +| Status | Description | +|--------|-------------| +| `204` | Deleted | +| `404` | Not found | + +--- + +## Enable / Disable a definition + +**`POST /automation/definitions/{name}/enable`** +**`POST /automation/definitions/{name}/disable`** + +Both return `204` on success. + +--- + +## List run history + +**`GET /automation/runs?limit=50`** + +Returns past run records, newest first. + +| Query param | Type | Default | Max | +|-------------|------|---------|-----| +| `limit` | integer | 50 | 1000 | + +--- + +## Aggregate stats + +**`GET /automation/stats`** + +```json +{ + "total_runs": 142, + "automations": 5, + "components": 12, + "variables": 3, + "by_status": { + "success": 138, + "failed": 4 + } +} +``` + +--- + +## Status values + +| Value | Description | +|-------|-------------| +| `pending` | Queued, not yet started | +| `running` | Currently executing | +| `success` | All steps completed successfully | +| `failed` | One or more steps errored | +| `skipped` | Automation was disabled at trigger time | diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md new file mode 100644 index 0000000..3e708d2 --- /dev/null +++ b/docs/guides/getting-started.md @@ -0,0 +1,116 @@ +--- +layout: default +title: Getting Started +nav_order: 2 +--- + +# Getting Started + +## Prerequisites + +- Python 3.11+ +- pip + +## Installation + +Clone the repository and install dependencies: + +```bash +git clone https://github.com/Pmaster-dev/server.git +cd server +pip install -r requirements.txt +``` + +## Using the automation engine + +The `src/automation` package is a self-contained, serverless automation engine. Set `PYTHONPATH=src` so Python can locate it without installing it as a package. + +```bash +export PYTHONPATH=src +``` + +### Define and trigger an automation + +```python +from automation import AutomationEngine, AutomationDefinition + +engine = AutomationEngine() + +# 1. Register a component function +engine.register_fn("greet", lambda inp: f"Hello, {inp.payload}!") + +# 2. Define an automation that fires on the "user.request" event +engine.define(AutomationDefinition( + name="greet_on_request", + triggers=["user.request"], + steps=["greet"], +)) + +# 3. Trigger the event +results = engine.trigger_type("user.request", payload="world") +print(results[0].status) # RunStatus.SUCCESS +print(results[0].outputs[0].result) # Hello, world! +``` + +### Generator variables + +Variables inject dynamic, lazily-evaluated values into components at runtime. + +```python +from automation import AutomationEngine, AutomationDefinition + +engine = AutomationEngine() + +# Define a generator variable (values are pulled on demand) +engine.define_variable("request_id", lambda: (f"req-{i}" for i in range(9999))) + +engine.register_fn("log", lambda inp: f"Processing {inp.variables['request_id']}") + +engine.define(AutomationDefinition( + name="log_request", + triggers=["api.call"], + steps=["log"], + variables=["request_id"], +)) + +results = engine.trigger_type("api.call") +print(results[0].outputs[0].result) # Processing req-0 +``` + +### Enable / disable automations + +```python +engine.disable("greet_on_request") +engine.enable("greet_on_request") +``` + +## Auth utilities + +The `auth/utils.py` module provides JWT token creation/verification, bcrypt password hashing, and session helpers. It depends on Flask and `flask-jwt-extended`. + +```python +from auth.utils import PasswordUtils, JWTUtils + +hashed = PasswordUtils.hash_password("supersecret") +assert PasswordUtils.verify_password("supersecret", hashed) + +access_token, refresh_token = JWTUtils.create_tokens("user-123", "alice") +payload = JWTUtils.decode_token(access_token) +print(payload["username"]) # alice +``` + +> **Note**: Set the `JWT_SECRET_KEY` environment variable in production. The default value is insecure. + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `JWT_SECRET_KEY` | Yes (prod) | Secret key used to sign JWT tokens | +| `REDIS_URL` | Yes (prod) | Redis connection URL for session caching | + +## Running type checks + +```bash +pip install pyre-check +pyre --source-directory src --source-directory auth check +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..f71d1a2 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,34 @@ +--- +layout: default +title: Home +nav_order: 1 +--- + +# Pmaster-dev / server + +Infrastructure and automation layer for the **Pmaster-dev / pinkycollie** ecosystem. + +## What's here + +| Module | Path | Purpose | +|--------|------|---------| +| Automation engine | `src/automation/` | Serverless Python automation with pluggable components and generator variables | +| Handoff module | `src/handoff/` | Cross-service handoff coordination | +| Auth utilities | `auth/utils.py` | JWT, bcrypt password hashing, session management | +| OpenAPI contracts | `docs/openapi/` | Machine-readable API contracts consumed by downstream services | + +## Getting started + +β†’ [Getting started guide](guides/getting-started) + +## API reference + +β†’ [Automation API](api/automation) + +## Ecosystem + +β†’ [Cross-org ecosystem inventory](pinkycollie-ecosystem-inventory) + +--- + +*Source: [github.com/Pmaster-dev/server](https://github.com/Pmaster-dev/server)* diff --git a/docs/openapi/automation.yaml b/docs/openapi/automation.yaml new file mode 100644 index 0000000..579a2fb --- /dev/null +++ b/docs/openapi/automation.yaml @@ -0,0 +1,269 @@ +openapi: 3.1.0 +info: + title: Pmaster Automation API + version: 0.1.0 + description: Shared automation contracts for the Pmaster-dev and pinkycollie ecosystem. +servers: + - url: / + description: Base URL resolved by each deployment environment +tags: + - name: Automation + - name: Runs +paths: + /automation/events/trigger: + post: + tags: [Automation] + summary: Trigger automation runs for an event + operationId: triggerAutomationEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TriggerEventRequest' + responses: + '200': + description: Trigger processed + content: + application/json: + schema: + type: object + properties: + runs: + type: array + items: + $ref: '#/components/schemas/RunResult' + required: [runs] + /automation/definitions: + get: + tags: [Automation] + summary: List automation definitions + operationId: listAutomationDefinitions + responses: + '200': + description: Current definitions + content: + application/json: + schema: + type: object + properties: + definitions: + type: array + items: + $ref: '#/components/schemas/AutomationDefinition' + required: [definitions] + /automation/definitions/{name}: + put: + tags: [Automation] + summary: Create or replace an automation definition + operationId: upsertAutomationDefinition + parameters: + - $ref: '#/components/parameters/AutomationName' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AutomationDefinition' + responses: + '200': + description: Definition stored + delete: + tags: [Automation] + summary: Remove an automation definition + operationId: deleteAutomationDefinition + parameters: + - $ref: '#/components/parameters/AutomationName' + responses: + '204': + description: Definition removed + '404': + description: Definition not found + /automation/definitions/{name}/enable: + post: + tags: [Automation] + summary: Enable a definition + operationId: enableAutomationDefinition + parameters: + - $ref: '#/components/parameters/AutomationName' + responses: + '204': + description: Definition enabled + /automation/definitions/{name}/disable: + post: + tags: [Automation] + summary: Disable a definition + operationId: disableAutomationDefinition + parameters: + - $ref: '#/components/parameters/AutomationName' + responses: + '204': + description: Definition disabled + /automation/runs: + get: + tags: [Runs] + summary: List run history + operationId: listAutomationRuns + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 1000 + default: 50 + responses: + '200': + description: Run history entries + content: + application/json: + schema: + type: object + properties: + runs: + type: array + items: + $ref: '#/components/schemas/RunResult' + required: [runs] + /automation/stats: + get: + tags: [Runs] + summary: Get aggregate automation stats + operationId: getAutomationStats + responses: + '200': + description: Aggregate counters by status + content: + application/json: + schema: + $ref: '#/components/schemas/AutomationStats' +components: + parameters: + AutomationName: + name: name + in: path + required: true + schema: + type: string + schemas: + TriggerEventRequest: + type: object + properties: + event_type: + type: string + payload: + description: Event payload forwarded to automation steps. + type: object + additionalProperties: true + nullable: true + metadata: + type: object + additionalProperties: true + default: {} + required: [event_type] + AutomationDefinition: + type: object + properties: + name: + type: string + triggers: + type: array + items: + type: string + steps: + type: array + description: Ordered component names to execute in sequence. + items: + type: string + variables: + type: array + description: Variable names resolved from the runtime variable registry. + items: + type: string + default: [] + description: + type: string + default: '' + enabled: + type: boolean + default: true + required: [name, triggers, steps] + RunStatus: + type: string + enum: [pending, running, success, failed, skipped] + ComponentOutput: + type: object + properties: + component: + type: string + success: + type: boolean + result: + nullable: true + error: + type: string + nullable: true + metadata: + type: object + additionalProperties: true + default: {} + required: [component, success] + RunResult: + type: object + properties: + run_id: + type: string + trigger: + type: object + properties: + event_type: + type: string + event_id: + type: string + timestamp: + type: string + format: date-time + required: [event_type, event_id, timestamp] + status: + $ref: '#/components/schemas/RunStatus' + outputs: + type: array + items: + $ref: '#/components/schemas/ComponentOutput' + started_at: + type: string + format: date-time + nullable: true + finished_at: + type: string + format: date-time + nullable: true + duration_ms: + type: number + nullable: true + error: + type: string + nullable: true + required: [run_id, trigger, status, outputs] + AutomationStats: + type: object + properties: + total_runs: + type: integer + minimum: 0 + automations: + type: integer + minimum: 0 + components: + type: integer + minimum: 0 + variables: + type: integer + minimum: 0 + by_status: + type: object + additionalProperties: + type: integer + minimum: 0 + required: [total_runs, automations, components, variables, by_status] diff --git a/docs/pinkycollie-ecosystem-inventory.md b/docs/pinkycollie-ecosystem-inventory.md new file mode 100644 index 0000000..f897a8d --- /dev/null +++ b/docs/pinkycollie-ecosystem-inventory.md @@ -0,0 +1,74 @@ +# pinkycollie + Pmaster-dev ecosystem inventory + +## Objective + +Maintain `pinkycollie` product repos and `Pmaster-dev` infrastructure repos as one coordinated ecosystem with shared CI/CD, API contracts, and automation flows. + +## Ecosystem snapshot + +| Org | Key repos | Current role | +|---|---|---| +| `pinkycollie` | `pinkflow`, `deaf-first-platform`, `mbtq-dev`, `NegraRosa`, `VR4Deaf` | Product/application layer with broad TS + Python footprint | +| `Pmaster-dev` | `server`, `docs`, `actions`, `electron`, `.github` | Infrastructure/automation layer, reusable tooling, shared contracts | + +## Layer 1 β€” single source of truth for templates + +- Keep `Pmaster-dev/.github` as the org template hub: + - shared `CODEOWNERS` + - reusable workflows in `ci/` and `deployments/` + - shared pre-commit config +- Mirror the same model in `pinkycollie` using `pinkycollie/pinkflow` as the workflow hub. +- Replace duplicated per-repo workflows with reusable `workflow_call` workflows. + +## Layer 2 β€” standardized pipeline lifecycle + +Standard stage order for every repo: + +`[Scan/Lint] β†’ [Build] β†’ [Test] β†’ [Security] β†’ [Deploy/Publish]` + +Target mapping: + +- Scan: `pr-security.yml`, `semgrep.yml`, `codeql.yml` +- Lint: `pylint.yml`, `super-linter.yml` +- Build: `rust-ci.yml`, `nextjs.yml`, `deploy-marketing-site.yml` +- Test: `vitest`, `pytest` +- Deploy: `github-pages.yml`, `deploy.yml`, `release.yml` + +## Layer 3 β€” framework instances to align + +| Instance | Pmaster-dev | pinkycollie | +|---|---|---| +| API server | `server/src/automation` | `pinkflow/webapp/backend` | +| API docs | `docs/openapi/` | `pinkflow/API.md` (migrate to OpenAPI) | +| Desktop client | `electron` | β€” | +| CI action | `actions/action.yml` | `pinkflow/.github/workflows/PinkFlow-pipeline.yml` | +| Marketing/docs site | β€” | `pinkflow/marketing-site/` | +| Web app | β€” | `pinkflow/webapp/frontend/` | +| Auth layer | β€” | `Nextjs-DeafAUTH` | + +## Layer 4 β€” integration method rule + +- Default to **REST** for user-initiated synchronous CRUD flows. +- Use **Webhook** for asynchronous automation events (CI, release, cross-repo signals). +- Defer **gRPC/tRPC** until latency/throughput needs justify protocol expansion. + +## Layer 5 β€” cross-org webhook bridge + +Reference flow: + +`Pmaster-dev/* push/release` β†’ `Pmaster-dev/actions` β†’ dispatch/webhook to `pinkycollie/pinkflow` β†’ update workflow state β†’ run downstream checks β†’ report status back to source PR/check. + +## Layer 6 β€” versioned artifacts + +- Machine-readable contracts: `docs/openapi/*.yaml` +- Human-readable operational docs: `pinkflow/docs/`, `pinkflow/workflow-system/docs/` +- Agent/system context: `pinkflow/context/agents.md` +- Cross-org map (this file): `docs/pinkycollie-ecosystem-inventory.md` + +## Priority sequence + +1. Commit this ecosystem inventory document in `Pmaster-dev/server`. +2. Publish shared automation OpenAPI contracts under `docs/openapi/`. +3. Refactor duplicated `pinkflow` workflows into reusable `workflow_call` units. +4. Wire webhook bridge from `Pmaster-dev/actions` to `pinkflow/waitthenecho`. +5. Standardize framework package versions (Next.js/React) across frontend repos. diff --git a/domnornalizer.php b/domnornalizer.php deleted file mode 100644 index 31b104d..0000000 --- a/domnornalizer.php +++ /dev/null @@ -1,2 +0,0 @@ - -domnormalizer.php diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..400da0e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +bcrypt>=4.0.1 +PyJWT>=2.8.0 +Flask>=3.0.0 +flask-jwt-extended>=4.6.0 +redis>=5.0.0 \ No newline at end of file diff --git a/spec/helpers b/spec/helpers deleted file mode 100644 index 8b13789..0000000 --- a/spec/helpers +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/automation/__init__.py b/src/automation/__init__.py new file mode 100644 index 0000000..158b778 --- /dev/null +++ b/src/automation/__init__.py @@ -0,0 +1,81 @@ +""" +Serverless Python automation with pluggable components and generator variables. + +Quick-start:: + + from automation import AutomationEngine, AutomationDefinition, TriggerEvent + from automation import Component, ComponentInput, ComponentRegistry + from automation import GeneratorVariable, VariableRegistry + + engine = AutomationEngine() + + # 1. Register a component + engine.register_fn("greet", lambda inp: f"Hello, {inp.payload}!") + + # 2. Define a generator variable + engine.define_variable("counter", lambda: (i for i in range(100))) + + # 3. Define an automation + engine.define(AutomationDefinition( + name="greet_on_request", + triggers=["user.request"], + steps=["greet"], + variables=["counter"], + )) + + # 4. Fire an event (serverless – no threads, no server) + results = engine.trigger_type("user.request", payload="world") + print(results[0].status) # RunStatus.SUCCESS +""" + +from .engine import ( + AutomationDefinition, + AutomationEngine, + RunResult, + RunStatus, + TriggerEvent, + default_engine, + trigger, +) +from .components import ( + Component, + ComponentInput, + ComponentOutput, + ComponentRegistry, + FunctionComponent, +) +from .variables import ( + GeneratorVariable, + VariableRegistry, +) +from .server_foundation import ( + ServerMeshEngine, + ServerNavigation, + ServerNode, + ServerProtocol, +) + +__all__ = [ + # Engine + "AutomationEngine", + "AutomationDefinition", + "TriggerEvent", + "RunResult", + "RunStatus", + "default_engine", + "trigger", + # Components + "Component", + "ComponentInput", + "ComponentOutput", + "ComponentRegistry", + "FunctionComponent", + # Variables + "GeneratorVariable", + "VariableRegistry", + # Server foundation + "ServerMeshEngine", + "ServerNavigation", + "ServerNode", + "ServerProtocol", +] diff --git a/src/automation/__pycache__/__init__.cpython-312.pyc b/src/automation/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..1e76783 Binary files /dev/null and b/src/automation/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/automation/__pycache__/components.cpython-312.pyc b/src/automation/__pycache__/components.cpython-312.pyc new file mode 100644 index 0000000..9ffd534 Binary files /dev/null and b/src/automation/__pycache__/components.cpython-312.pyc differ diff --git a/src/automation/__pycache__/engine.cpython-312.pyc b/src/automation/__pycache__/engine.cpython-312.pyc new file mode 100644 index 0000000..cc06434 Binary files /dev/null and b/src/automation/__pycache__/engine.cpython-312.pyc differ diff --git a/src/automation/__pycache__/variables.cpython-312.pyc b/src/automation/__pycache__/variables.cpython-312.pyc new file mode 100644 index 0000000..c608fa7 Binary files /dev/null and b/src/automation/__pycache__/variables.cpython-312.pyc differ diff --git a/src/automation/components.py b/src/automation/components.py new file mode 100644 index 0000000..03712d3 --- /dev/null +++ b/src/automation/components.py @@ -0,0 +1,333 @@ +""" +Pluggable component registry for serverless automation. + +Components are self-describing units of work that can be registered by name +and composed inside automation pipelines. The registry enforces a consistent +interface while remaining fully decoupled from any specific runtime or +framework. +""" + +from __future__ import annotations + +import inspect +import traceback +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Type + + +# --------------------------------------------------------------------------- +# Component contract +# --------------------------------------------------------------------------- + +@dataclass +class ComponentInput: + """Typed input envelope passed to a component.""" + name: str + payload: Any + metadata: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class ComponentOutput: + """Typed output envelope returned by a component.""" + component: str + result: Any + success: bool = True + error: Optional[str] = None + metadata: Dict[str, Any] = field(default_factory=dict) + + +class Component(ABC): + """ + Base class for all pluggable components. + + Subclass and implement :meth:`execute` to create a new component. + Register the component with a :class:`ComponentRegistry` using + :meth:`ComponentRegistry.register`. + + Subclasses may optionally implement: + + * :meth:`validate` – return ``(True, "")`` to accept the input or + ``(False, "")`` to reject it before execution. + * :meth:`setup` / :meth:`teardown` – hooks called once when the + component is registered / deregistered. + """ + + #: Human-readable name shown in registry listings. Defaults to the + #: class name when not overridden. + name: str = "" + + #: Short description surfaced by :meth:`ComponentRegistry.describe`. + description: str = "" + + def validate(self, input_: ComponentInput) -> tuple[bool, str]: + """ + Validate *input_* before execution. + + Returns: + A ``(valid, reason)`` tuple. ``valid`` is ``True`` when the + input is acceptable. ``reason`` is an empty string on success + or a human-readable message on failure. + """ + return True, "" + + @abstractmethod + def execute(self, input_: ComponentInput) -> ComponentOutput: + """Process *input_* and return a :class:`ComponentOutput`.""" + + def setup(self) -> None: + """Optional lifecycle hook called when the component is registered.""" + + def teardown(self) -> None: + """Optional lifecycle hook called when the component is removed.""" + + # Convenience method so subclasses can build outputs without importing the class + def _ok(self, result: Any, **meta: Any) -> ComponentOutput: + return ComponentOutput( + component=self.name or type(self).__name__, + result=result, + success=True, + metadata=meta, + ) + + def _err(self, error: str, **meta: Any) -> ComponentOutput: + return ComponentOutput( + component=self.name or type(self).__name__, + result=None, + success=False, + error=error, + metadata=meta, + ) + + +# --------------------------------------------------------------------------- +# Function-based component adapter +# --------------------------------------------------------------------------- + +class FunctionComponent(Component): + """ + Wraps a plain callable as a :class:`Component`. + + Useful for registering lambda functions or module-level functions + without creating a full subclass:: + + def double(inp): + return inp.payload * 2 + + registry.register_fn("double", double) + """ + + def __init__( + self, + fn: Callable[[ComponentInput], Any], + name: str = "", + description: str = "", + ) -> None: + self.name = name or fn.__name__ + self.description = description or (inspect.getdoc(fn) or "") + self._fn = fn + + def execute(self, input_: ComponentInput) -> ComponentOutput: + try: + result = self._fn(input_) + return self._ok(result) + except Exception: + return self._err(traceback.format_exc()) + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +class ComponentRegistry: + """ + Central store of named :class:`Component` instances. + + Components are looked up by *name* (a plain string) so that pipelines + can remain decoupled from concrete implementations. + + Usage:: + + registry = ComponentRegistry() + + @registry.component("greet") + class GreetComponent(Component): + description = "Returns a greeting string." + + def execute(self, inp): + return self._ok(f"Hello, {inp.payload}!") + + output = registry.run("greet", ComponentInput("greet", "world")) + print(output.result) # Hello, world! + """ + + def __init__(self) -> None: + self._components: Dict[str, Component] = {} + + # ------------------------------------------------------------------ + # Registration + # ------------------------------------------------------------------ + + def register(self, name: str, component: Component) -> "ComponentRegistry": + """ + Register *component* under *name*. + + Calls :meth:`Component.setup` and returns *self* for chaining. + """ + existing = self._components.pop(name, None) + if existing is not None: + existing.teardown() + component.name = component.name or name + component.setup() + self._components[name] = component + return self + + def register_class(self, name: str, cls: Type[Component], **kwargs: Any) -> "ComponentRegistry": + """Instantiate *cls* with **kwargs** and register the instance.""" + return self.register(name, cls(**kwargs)) + + def register_fn( + self, + name: str, + fn: Callable[[ComponentInput], Any], + description: str = "", + ) -> "ComponentRegistry": + """Wrap *fn* in a :class:`FunctionComponent` and register it.""" + return self.register(name, FunctionComponent(fn, name=name, description=description)) + + def component(self, name: str, description: str = "") -> Callable[[Type[Component]], Type[Component]]: + """ + Class decorator that registers a component under *name*:: + + @registry.component("my_step") + class MyStep(Component): + ... + """ + def _decorator(cls: Type[Component]) -> Type[Component]: + if description: + cls.description = description + self.register_class(name, cls) + return cls + + return _decorator + + def deregister(self, name: str) -> bool: + """ + Remove the component registered as *name*. + + Calls :meth:`Component.teardown` if found. Returns ``True`` when + a component was removed. + """ + component = self._components.pop(name, None) + if component is not None: + component.teardown() + return True + return False + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def run(self, name: str, input_: ComponentInput) -> ComponentOutput: + """ + Execute the component registered as *name*. + + Validates the input first; returns an error output if the component + is not found or validation fails. + """ + component = self._components.get(name) + if component is None: + return ComponentOutput( + component=name, + result=None, + success=False, + error=f"Component '{name}' not registered", + ) + + valid, reason = component.validate(input_) + if not valid: + return ComponentOutput( + component=name, + result=None, + success=False, + error=f"Validation failed: {reason}", + ) + + try: + return component.execute(input_) + except Exception: + return ComponentOutput( + component=name, + result=None, + success=False, + error=traceback.format_exc(), + ) + + def run_pipeline( + self, + steps: List[str], + initial_payload: Any, + metadata: Optional[Dict[str, Any]] = None, + ) -> List[ComponentOutput]: + """ + Run a sequential pipeline of named components. + + Each step's ``result`` is forwarded as the ``payload`` of the next + step's :class:`ComponentInput`. Execution stops on the first + failure encountered. + + Args: + steps: Ordered list of registered component names. + initial_payload: Payload for the first step. + metadata: Optional metadata forwarded to every step. + + Returns: + List of :class:`ComponentOutput` objects, one per step executed. + The list may be shorter than *steps* if a failure occurred early. + """ + outputs: List[ComponentOutput] = [] + payload = initial_payload + meta = metadata or {} + + for step in steps: + inp = ComponentInput(name=step, payload=payload, metadata=meta) + out = self.run(step, inp) + outputs.append(out) + if not out.success: + break + payload = out.result + + return outputs + + # ------------------------------------------------------------------ + # Introspection + # ------------------------------------------------------------------ + + def names(self) -> List[str]: + """Return registered component names.""" + return list(self._components.keys()) + + def get(self, name: str) -> Optional[Component]: + """Return the component registered as *name*, or ``None``.""" + return self._components.get(name) + + def describe(self, name: str) -> str: + """Return the description of the component registered as *name*.""" + component = self._components.get(name) + if component is None: + return f"(no component named '{name}')" + return component.description or "(no description)" + + def describe_all(self) -> Dict[str, str]: + """Return a mapping of name β†’ description for every registered component.""" + return {name: (c.description or "") for name, c in self._components.items()} + + def __contains__(self, name: str) -> bool: + return name in self._components + + def __len__(self) -> int: + return len(self._components) + + def __repr__(self) -> str: + return f"ComponentRegistry({list(self._components.keys())!r})" diff --git a/src/automation/engine.py b/src/automation/engine.py new file mode 100644 index 0000000..a62a526 --- /dev/null +++ b/src/automation/engine.py @@ -0,0 +1,399 @@ +""" +Serverless automation engine for Pmaster AI Operator. + +The engine is *serverless* in the sense that it is purely function-driven: +there is no persistent background thread or network listener. Automation +runs are triggered by explicit calls, making the engine safe to use inside +FaaS environments (AWS Lambda, Google Cloud Functions, etc.) as well as +in-process within any Python application. + +Architecture:: + + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ AutomationEngine β”‚ + β”‚ β”‚ + β”‚ VariableRegistry ←── GeneratorVariable(s) β”‚ + β”‚ β”‚ β”‚ + β”‚ ComponentRegistry ←── Component / FunctionComponentβ”‚ + β”‚ β”‚ β”‚ + β”‚ Trigger dispatcher β”‚ + β”‚ β”‚ β”‚ + β”‚ RunResult / RunHistory β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +""" + +from __future__ import annotations + +import logging +import traceback +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Any, Callable, Dict, List, Optional + +from .components import ( + Component, + ComponentInput, + ComponentOutput, + ComponentRegistry, + FunctionComponent, +) +from .variables import GeneratorVariable, VariableRegistry + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + +class RunStatus(Enum): + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + SKIPPED = "skipped" + + +@dataclass +class TriggerEvent: + """Represents an event that can trigger an automation run.""" + event_type: str + payload: Any = None + metadata: Dict[str, Any] = field(default_factory=dict) + timestamp: datetime = field(default_factory=datetime.now) + event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) + + +@dataclass +class RunResult: + """Result of a single automation run.""" + run_id: str + trigger: TriggerEvent + status: RunStatus + outputs: List[ComponentOutput] = field(default_factory=list) + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + error: Optional[str] = None + + @property + def duration_ms(self) -> Optional[float]: + """Wall-clock duration in milliseconds, or ``None`` if not finished.""" + if self.started_at and self.finished_at: + delta = self.finished_at - self.started_at + return delta.total_seconds() * 1000 + return None + + def to_dict(self) -> Dict[str, Any]: + return { + "run_id": self.run_id, + "trigger": { + "event_type": self.trigger.event_type, + "event_id": self.trigger.event_id, + "timestamp": self.trigger.timestamp.isoformat(), + }, + "status": self.status.value, + "outputs": [ + { + "component": o.component, + "success": o.success, + "error": o.error, + } + for o in self.outputs + ], + "started_at": self.started_at.isoformat() if self.started_at else None, + "finished_at": self.finished_at.isoformat() if self.finished_at else None, + "duration_ms": self.duration_ms, + "error": self.error, + } + + +# --------------------------------------------------------------------------- +# Automation definition +# --------------------------------------------------------------------------- + +@dataclass +class AutomationDefinition: + """ + Declarative description of an automation. + + An automation is defined by: + + * A *name* (unique identifier). + * A list of *triggers*: event type strings that activate the automation. + * An ordered list of *steps*: component names executed in sequence. + * Optional *variables*: names from the :class:`VariableRegistry` that are + injected into each step's ``metadata`` before execution. + """ + name: str + triggers: List[str] + steps: List[str] + variables: List[str] = field(default_factory=list) + description: str = "" + enabled: bool = True + + +# --------------------------------------------------------------------------- +# Engine +# --------------------------------------------------------------------------- + +class AutomationEngine: + """ + Serverless automation engine. + + The engine binds together a :class:`ComponentRegistry` (what to run) and a + :class:`VariableRegistry` (runtime data), wires up event-based dispatch, + and keeps a lightweight run history. + + Usage:: + + engine = AutomationEngine() + + # Register a component + engine.components.register_fn("echo", lambda inp: inp.payload) + + # Define an automation + engine.define(AutomationDefinition( + name="on_hello", + triggers=["hello"], + steps=["echo"], + )) + + # Fire an event + result = engine.trigger(TriggerEvent("hello", payload="world")) + print(result[0].status) # RunStatus.SUCCESS + """ + + def __init__(self) -> None: + self.components = ComponentRegistry() + self.variables = VariableRegistry() + + self._automations: Dict[str, AutomationDefinition] = {} + self._history: List[RunResult] = [] + + # Middleware hooks: callables invoked before/after each run + self._before_run: List[Callable[[RunResult, TriggerEvent], None]] = [] + self._after_run: List[Callable[[RunResult], None]] = [] + + # ------------------------------------------------------------------ + # Component & variable shortcuts + # ------------------------------------------------------------------ + + def component(self, name: str, description: str = "") -> Callable: + """Decorator that registers a :class:`Component` subclass.""" + return self.components.component(name, description) + + def register_fn( + self, + name: str, + fn: Callable[[ComponentInput], Any], + description: str = "", + ) -> "AutomationEngine": + """Register a plain callable as a component. Returns self.""" + self.components.register_fn(name, fn, description) + return self + + def define_variable( + self, + name: str, + factory: Callable, + ) -> GeneratorVariable: + """Create a generator variable and add it to the variable registry.""" + return self.variables.define(name, factory) + + # ------------------------------------------------------------------ + # Automation registration + # ------------------------------------------------------------------ + + def define(self, automation: AutomationDefinition) -> "AutomationEngine": + """Register an automation definition. Returns self for chaining.""" + self._automations[automation.name] = automation + return self + + def undefine(self, name: str) -> bool: + """Remove an automation by name. Returns ``True`` if it existed.""" + return self._automations.pop(name, None) is not None + + def enable(self, name: str) -> None: + """Enable a previously disabled automation.""" + if name in self._automations: + self._automations[name].enabled = True + + def disable(self, name: str) -> None: + """Disable an automation without removing it.""" + if name in self._automations: + self._automations[name].enabled = False + + # ------------------------------------------------------------------ + # Middleware + # ------------------------------------------------------------------ + + def before_run(self, fn: Callable[[RunResult, TriggerEvent], None]) -> Callable: + """Register a hook called just before each automation run starts.""" + self._before_run.append(fn) + return fn + + def after_run(self, fn: Callable[[RunResult], None]) -> Callable: + """Register a hook called just after each automation run finishes.""" + self._after_run.append(fn) + return fn + + # ------------------------------------------------------------------ + # Triggering + # ------------------------------------------------------------------ + + def trigger(self, event: TriggerEvent) -> List[RunResult]: + """ + Dispatch *event* to all matching, enabled automations. + + Returns the list of :class:`RunResult` objects produced (one per + matching automation). + """ + results: List[RunResult] = [] + + for automation in self._automations.values(): + if not automation.enabled: + continue + if event.event_type not in automation.triggers: + continue + result = self._run(automation, event) + results.append(result) + + return results + + def trigger_type(self, event_type: str, payload: Any = None, **metadata: Any) -> List[RunResult]: + """Convenience wrapper: build a :class:`TriggerEvent` and dispatch it.""" + event = TriggerEvent(event_type=event_type, payload=payload, metadata=metadata) + return self.trigger(event) + + # ------------------------------------------------------------------ + # Internal execution + # ------------------------------------------------------------------ + + def _run(self, automation: AutomationDefinition, event: TriggerEvent) -> RunResult: + """Execute a single automation in response to *event*.""" + run_id = f"run-{uuid.uuid4().hex[:12]}" + result = RunResult( + run_id=run_id, + trigger=event, + status=RunStatus.PENDING, + ) + + # Before-run hooks + for hook in self._before_run: + try: + hook(result, event) + except Exception: + logger.exception("before_run hook %r raised an exception", hook) + + result.started_at = datetime.now() + result.status = RunStatus.RUNNING + + try: + # Build variable snapshot for this run + var_snapshot = self._snapshot_variables(automation.variables) + + # Merge event metadata with variable snapshot + run_meta: Dict[str, Any] = {**event.metadata, "variables": var_snapshot} + + # Execute the component pipeline + outputs = self.components.run_pipeline( + steps=automation.steps, + initial_payload=event.payload, + metadata=run_meta, + ) + result.outputs = outputs + + # Determine overall status + if outputs and not outputs[-1].success: + result.status = RunStatus.FAILED + result.error = outputs[-1].error + else: + result.status = RunStatus.SUCCESS + + except Exception as exc: + result.status = RunStatus.FAILED + result.error = f"{type(exc).__name__}: {exc}" + result.outputs.append( + ComponentOutput( + component="__engine__", + result=None, + success=False, + error=traceback.format_exc(), + ) + ) + + result.finished_at = datetime.now() + self._history.append(result) + + # After-run hooks + for hook in self._after_run: + try: + hook(result) + except Exception: + logger.exception("after_run hook %r raised an exception", hook) + + return result + + def _snapshot_variables(self, names: List[str]) -> Dict[str, Any]: + """ + Peek at the next value of each named variable. + + Values are peeked (not consumed) so the same run can be replayed + without advancing the generators. + """ + snapshot: Dict[str, Any] = {} + for name in names: + snapshot[name] = self.variables.require(name).peek() + return snapshot + + # ------------------------------------------------------------------ + # History & introspection + # ------------------------------------------------------------------ + + def history(self, limit: Optional[int] = None) -> List[RunResult]: + """Return run history, most-recent first, optionally limited.""" + runs = list(reversed(self._history)) + return runs[:limit] if limit is not None else runs + + def automations(self) -> Dict[str, AutomationDefinition]: + """Return a copy of the registered automations map.""" + return dict(self._automations) + + def stats(self) -> Dict[str, Any]: + """Return aggregate run statistics.""" + total = len(self._history) + by_status: Dict[str, int] = {} + for run in self._history: + key = run.status.value + by_status[key] = by_status.get(key, 0) + 1 + + return { + "total_runs": total, + "automations": len(self._automations), + "components": len(self.components), + "variables": len(self.variables), + "by_status": by_status, + } + + def __repr__(self) -> str: + return ( + f"AutomationEngine(" + f"automations={len(self._automations)}, " + f"components={len(self.components)}, " + f"variables={len(self.variables)})" + ) + + +# --------------------------------------------------------------------------- +# Module-level default engine +# --------------------------------------------------------------------------- + +#: Default engine instance – use this for simple single-engine setups. +default_engine = AutomationEngine() + + +def trigger(event_type: str, payload: Any = None, **metadata: Any) -> List[RunResult]: + """Trigger *event_type* on the module-level :data:`default_engine`.""" + return default_engine.trigger_type(event_type, payload=payload, **metadata) diff --git a/src/automation/server_foundation.py b/src/automation/server_foundation.py new file mode 100644 index 0000000..588c3fb --- /dev/null +++ b/src/automation/server_foundation.py @@ -0,0 +1,181 @@ +""" +Foundational topology engine for "server-of-servers" orchestration. + +This module models: +- protocols supported by each server +- stable server names/IDs +- grouping +- navigation links +- label-based lookup +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Iterable, List, Optional, Set + + +class ServerProtocol(str, Enum): + """Supported server protocols.""" + + HTTP = "http" + HTTPS = "https" + WS = "ws" + WSS = "wss" + GRPC = "grpc" + TCP = "tcp" + UDP = "udp" + CUSTOM = "custom" + + +@dataclass(frozen=True) +class ServerNavigation: + """Directed navigation edge from one server to another.""" + + source_id: str + target_id: str + label: str + navigation_id: str = field(default_factory=lambda: f"nav-{uuid.uuid4().hex[:12]}") + + +@dataclass +class ServerNode: + """Core server entity tracked by the topology engine.""" + + server_id: str + name: str + group: str + protocols: Set[ServerProtocol] = field(default_factory=set) + labels: Dict[str, str] = field(default_factory=dict) + + def supports(self, protocol: ServerProtocol) -> bool: + """Return ``True`` if this server declares support for *protocol*.""" + return protocol in self.protocols + + +class ServerMeshEngine: + """ + Minimal topology engine for grouped servers and labeled navigation. + + This intentionally keeps behavior simple so it can serve as a foundation + for future scheduling/routing features. + """ + + def __init__(self) -> None: + self._servers: Dict[str, ServerNode] = {} + self._groups: Dict[str, Set[str]] = {} + self._navigations: List[ServerNavigation] = [] + + def register_server( + self, + name: str, + group: str, + protocols: Iterable[ServerProtocol], + labels: Optional[Dict[str, str]] = None, + server_id: Optional[str] = None, + ) -> ServerNode: + """Create and register a server node.""" + if not name.strip(): + raise ValueError("Server name cannot be empty") + if not group.strip(): + raise ValueError("Server group cannot be empty") + + resolved_id = server_id or f"srv-{uuid.uuid4().hex[:12]}" + if resolved_id in self._servers: + raise ValueError(f"Server id '{resolved_id}' already exists") + + protocol_set = set(protocols) + if not protocol_set: + raise ValueError("At least one protocol is required") + + server = ServerNode( + server_id=resolved_id, + name=name, + group=group, + protocols=protocol_set, + labels=dict(labels or {}), + ) + self._servers[resolved_id] = server + self._groups.setdefault(group, set()).add(resolved_id) + return server + + def server(self, server_id: str) -> ServerNode: + """Return a server by ID or raise ``KeyError``.""" + return self._servers[server_id] + + def connect( + self, + source_id: str, + target_id: str, + label: str, + *, + bidirectional: bool = False, + ) -> List[ServerNavigation]: + """Create one or two labeled navigation links between servers.""" + if source_id not in self._servers: + raise KeyError(f"Unknown source server '{source_id}'") + if target_id not in self._servers: + raise KeyError(f"Unknown target server '{target_id}'") + if not label.strip(): + raise ValueError("Navigation label cannot be empty") + + created = [ServerNavigation(source_id=source_id, target_id=target_id, label=label)] + if bidirectional: + created.append(ServerNavigation(source_id=target_id, target_id=source_id, label=label)) + + self._navigations.extend(created) + return created + + def list_group(self, group: str) -> List[ServerNode]: + """Return all servers in *group* ordered by server ID.""" + ids = sorted(self._groups.get(group, set())) + return [self._servers[server_id] for server_id in ids] + + def find_by_label(self, key: str, value: str) -> List[ServerNode]: + """Return servers where labels[key] == value.""" + matches = [ + server + for server in self._servers.values() + if server.labels.get(key) == value + ] + return sorted(matches, key=lambda item: item.server_id) + + def neighbors(self, server_id: str, label: Optional[str] = None) -> List[ServerNode]: + """Return direct target servers navigable from *server_id*.""" + if server_id not in self._servers: + raise KeyError(f"Unknown server '{server_id}'") + + target_ids: List[str] = [] + for edge in self._navigations: + if edge.source_id != server_id: + continue + if label is not None and edge.label != label: + continue + target_ids.append(edge.target_id) + + return [self._servers[target_id] for target_id in target_ids] + + def export(self) -> Dict[str, object]: + """Export a serializable view of the topology.""" + servers = [ + { + "server_id": node.server_id, + "name": node.name, + "group": node.group, + "protocols": sorted(protocol.value for protocol in node.protocols), + "labels": dict(node.labels), + } + for node in sorted(self._servers.values(), key=lambda item: item.server_id) + ] + navigations = [ + { + "navigation_id": edge.navigation_id, + "source_id": edge.source_id, + "target_id": edge.target_id, + "label": edge.label, + } + for edge in self._navigations + ] + return {"servers": servers, "navigations": navigations} diff --git a/src/automation/variables.py b/src/automation/variables.py new file mode 100644 index 0000000..71eff9b --- /dev/null +++ b/src/automation/variables.py @@ -0,0 +1,244 @@ +""" +Generator-backed variable system for serverless automation. + +Variables support lazy evaluation via Python generators, allowing values to be +computed on demand, streamed, or composed into pipelines without eager loading. +""" + +from typing import Any, Callable, Generator, Iterable, Iterator, Optional, TypeVar + +T = TypeVar("T") + + +class GeneratorVariable: + """ + A variable whose value is produced by a Python generator. + + Values are computed lazily: each call to ``next()`` or iteration + yields the next value from the underlying generator factory. + + Examples:: + + counter = GeneratorVariable(lambda: (i for i in range(10))) + print(counter.next()) # 0 + print(counter.next()) # 1 + + seq = GeneratorVariable.from_iterable([10, 20, 30]) + for v in seq: + print(v) + """ + + def __init__(self, factory: Callable[[], Generator]) -> None: + """ + Args: + factory: Zero-argument callable that returns a fresh generator + each time the variable is reset or first accessed. + """ + self._factory = factory + self._gen: Optional[Iterator] = None + self._peeked: bool = False + self._peeked_value: Any = None + + # ------------------------------------------------------------------ + # Core interface + # ------------------------------------------------------------------ + + def _ensure_gen(self) -> Iterator: + if self._gen is None: + self._gen = self._factory() + return self._gen + + def next(self, default: Any = None) -> Any: + """Return the next value, or *default* when the generator is exhausted.""" + if self._peeked: + self._peeked = False + return self._peeked_value + try: + return next(self._ensure_gen()) + except StopIteration: + return default + + def reset(self) -> "GeneratorVariable": + """Restart the generator from the beginning.""" + self._gen = self._factory() + self._peeked = False + self._peeked_value = None + return self + + def peek(self) -> Any: + """ + Return the next value without advancing the generator. + + Consecutive calls to ``peek()`` return the same value until + ``next()`` or iteration consumes it. Returns ``None`` when + the generator is exhausted. + """ + if self._peeked: + return self._peeked_value + try: + self._peeked_value = next(self._ensure_gen()) + self._peeked = True + return self._peeked_value + except StopIteration: + return None + + def collect(self) -> list: + """Drain all remaining values into a list.""" + values: list[Any] = [] + if self._peeked: + values.append(self._peeked_value) + self._peeked = False + self._peeked_value = None + values.extend(list(self._ensure_gen())) + return values + + # ------------------------------------------------------------------ + # Composition helpers + # ------------------------------------------------------------------ + + def map(self, fn: Callable[[Any], Any]) -> "GeneratorVariable": + """Return a new variable that applies *fn* to each value.""" + source_factory = self._factory + + def _mapped(): + for v in source_factory(): + yield fn(v) + + return GeneratorVariable(_mapped) + + def filter(self, predicate: Callable[[Any], bool]) -> "GeneratorVariable": + """Return a new variable that yields only values matching *predicate*.""" + source_factory = self._factory + + def _filtered(): + for v in source_factory(): + if predicate(v): + yield v + + return GeneratorVariable(_filtered) + + def take(self, n: int) -> "GeneratorVariable": + """Return a new variable limited to the first *n* values.""" + source_factory = self._factory + + def _taken(): + for i, v in enumerate(source_factory()): + if i >= n: + break + yield v + + return GeneratorVariable(_taken) + + def chain(self, other: "GeneratorVariable") -> "GeneratorVariable": + """Concatenate this variable with *other*.""" + a_factory = self._factory + b_factory = other._factory + + def _chained(): + yield from a_factory() + yield from b_factory() + + return GeneratorVariable(_chained) + + # ------------------------------------------------------------------ + # Convenience constructors + # ------------------------------------------------------------------ + + @classmethod + def from_iterable(cls, iterable: Iterable) -> "GeneratorVariable": + """Create a variable that replays a fixed iterable on each reset.""" + items = list(iterable) + return cls(lambda: iter(items)) + + @classmethod + def from_value(cls, value: Any) -> "GeneratorVariable": + """Create a variable that yields a single value once.""" + return cls(lambda: iter([value])) + + @classmethod + def constant(cls, value: Any) -> "GeneratorVariable": + """Create an infinite variable that always yields *value*.""" + + def _infinite(): + while True: + yield value + + return cls(_infinite) + + @classmethod + def counter(cls, start: int = 0, step: int = 1) -> "GeneratorVariable": + """Create an infinite counter variable.""" + + def _count(): + n = start + while True: + yield n + n += step + + return cls(_count) + + # ------------------------------------------------------------------ + # Python protocol support + # ------------------------------------------------------------------ + + def __iter__(self) -> Iterator: + return self + + def __next__(self) -> Any: + if self._peeked: + self._peeked = False + return self._peeked_value + return next(self._ensure_gen()) + + def __repr__(self) -> str: + return f"GeneratorVariable(factory={self._factory!r})" + + +class VariableRegistry: + """ + Named registry of :class:`GeneratorVariable` instances. + + Allows automation components to share and look up variables by name. + """ + + def __init__(self) -> None: + self._vars: dict[str, GeneratorVariable] = {} + + def register(self, name: str, variable: GeneratorVariable) -> "VariableRegistry": + """Register *variable* under *name*. Returns self for chaining.""" + self._vars[name] = variable + return self + + def define(self, name: str, factory: Callable[[], Generator]) -> GeneratorVariable: + """Create a :class:`GeneratorVariable` from *factory* and register it.""" + var = GeneratorVariable(factory) + self._vars[name] = var + return var + + def get(self, name: str) -> Optional[GeneratorVariable]: + """Return the variable registered as *name*, or ``None``.""" + return self._vars.get(name) + + def require(self, name: str) -> GeneratorVariable: + """Return the variable registered as *name*; raise ``KeyError`` if absent.""" + if name not in self._vars: + raise KeyError(f"Variable '{name}' not found in registry") + return self._vars[name] + + def names(self) -> list[str]: + """Return the list of registered variable names.""" + return list(self._vars.keys()) + + def reset_all(self) -> None: + """Reset every registered variable to its initial state.""" + for var in self._vars.values(): + var.reset() + + def __contains__(self, name: str) -> bool: + return name in self._vars + + def __len__(self) -> int: + return len(self._vars) + + def __repr__(self) -> str: + return f"VariableRegistry({list(self._vars.keys())!r})" diff --git a/src/handoff/__pycache__/handoff.cpython-312.pyc b/src/handoff/__pycache__/handoff.cpython-312.pyc new file mode 100644 index 0000000..6622208 Binary files /dev/null and b/src/handoff/__pycache__/handoff.cpython-312.pyc differ diff --git a/tests/test_automation_review_regressions.py b/tests/test_automation_review_regressions.py new file mode 100644 index 0000000..844291f --- /dev/null +++ b/tests/test_automation_review_regressions.py @@ -0,0 +1,83 @@ +from automation import __doc__ as automation_doc +from automation.components import Component, ComponentInput, ComponentRegistry, FunctionComponent +from automation.engine import AutomationDefinition, AutomationEngine, RunStatus +from automation.variables import GeneratorVariable + + +def test_collect_includes_peeked_value(): + variable = GeneratorVariable.from_iterable([1, 2, 3]) + + assert variable.peek() == 1 + assert variable.collect() == [1, 2, 3] + + +class LifecycleHelperComponent(Component): + def __init__(self, events, label): + self._events = events + self._label = label + + def setup(self) -> None: + self._events.append(f"{self._label}-setup") + + def teardown(self) -> None: + self._events.append(f"{self._label}-teardown") + + def execute(self, input_: ComponentInput): + return self._ok(input_.payload) + + +def test_register_replaces_component_with_teardown(): + events = [] + registry = ComponentRegistry() + + registry.register("step", LifecycleHelperComponent(events, "old")) + registry.register("step", LifecycleHelperComponent(events, "new")) + assert registry.deregister("step") is True + + assert events == ["old-setup", "old-teardown", "new-setup", "new-teardown"] + + +def test_function_component_returns_traceback_on_failure(): + def broken(input_): + assert input_.name == "broken" + raise RuntimeError("boom") + + output = FunctionComponent(broken, name="broken").execute( + ComponentInput(name="broken", payload=None) + ) + + assert output.success is False + assert output.error is not None + assert "Traceback" in output.error + assert "RuntimeError: boom" in output.error + + +def test_missing_variables_fail_run_instead_of_being_silently_dropped(): + engine = AutomationEngine() + engine.register_fn("echo", lambda input_: input_.payload) + engine.define( + AutomationDefinition( + name="uses-missing-variable", + triggers=["demo"], + steps=["echo"], + variables=["missing"], + ) + ) + + results = engine.trigger_type("demo", payload="hello") + + assert len(results) == 1 + result = results[0] + assert result.status is RunStatus.FAILED + assert result.error is not None + assert "KeyError" in result.error + assert "Variable 'missing' not found in registry" in result.error + assert result.outputs[0].component == "__engine__" + assert result.outputs[0].success is False + + +def test_quick_start_uses_package_imports(): + assert isinstance(automation_doc, str) + assert automation_doc.strip() + assert "from automation import AutomationEngine" in automation_doc + assert "from src.automation import" not in automation_doc diff --git a/tests/test_server_foundation.py b/tests/test_server_foundation.py new file mode 100644 index 0000000..48e3db1 --- /dev/null +++ b/tests/test_server_foundation.py @@ -0,0 +1,57 @@ +from automation import ServerMeshEngine, ServerProtocol + + +def test_register_server_tracks_id_group_protocols_and_labels(): + engine = ServerMeshEngine() + + node = engine.register_server( + name="gateway", + group="edge", + protocols=[ServerProtocol.HTTPS, ServerProtocol.WSS], + labels={"tier": "public", "region": "us-east"}, + server_id="srv-gateway", + ) + + assert node.server_id == "srv-gateway" + assert node.name == "gateway" + assert node.group == "edge" + assert node.supports(ServerProtocol.HTTPS) + assert node.labels["tier"] == "public" + assert engine.list_group("edge")[0].server_id == "srv-gateway" + + +def test_connect_and_neighbors_with_navigation_labels(): + engine = ServerMeshEngine() + edge = engine.register_server("edge", "edge", [ServerProtocol.HTTPS], server_id="srv-edge") + core = engine.register_server("core", "core", [ServerProtocol.GRPC], server_id="srv-core") + + engine.connect(edge.server_id, core.server_id, label="ingress") + + neighbors = engine.neighbors(edge.server_id, label="ingress") + assert [server.server_id for server in neighbors] == ["srv-core"] + + +def test_label_lookup_and_export(): + engine = ServerMeshEngine() + edge = engine.register_server( + "edge", + "edge", + [ServerProtocol.HTTPS], + labels={"role": "gateway"}, + server_id="srv-edge", + ) + core = engine.register_server( + "core", + "core", + [ServerProtocol.GRPC], + labels={"role": "backend"}, + server_id="srv-core", + ) + engine.connect(edge.server_id, core.server_id, label="dispatch", bidirectional=True) + + found = engine.find_by_label("role", "backend") + assert [server.server_id for server in found] == ["srv-core"] + + exported = engine.export() + assert len(exported["servers"]) == 2 + assert len(exported["navigations"]) == 2 diff --git a/types/automation b/types/automation deleted file mode 100644 index ead442d..0000000 --- a/types/automation +++ /dev/null @@ -1 +0,0 @@ -automation diff --git a/types/build b/types/build deleted file mode 100644 index 8b13789..0000000 --- a/types/build +++ /dev/null @@ -1 +0,0 @@ - diff --git a/types/file b/types/file deleted file mode 100644 index 8b13789..0000000 --- a/types/file +++ /dev/null @@ -1 +0,0 @@ - diff --git a/types/http b/types/http deleted file mode 100644 index 8b13789..0000000 --- a/types/http +++ /dev/null @@ -1 +0,0 @@ - diff --git a/types/language b/types/language deleted file mode 100644 index 8b13789..0000000 --- a/types/language +++ /dev/null @@ -1 +0,0 @@ - diff --git a/types/mail.md b/types/mail.md deleted file mode 100644 index 8b13789..0000000 --- a/types/mail.md +++ /dev/null @@ -1 +0,0 @@ - diff --git a/types/media.md b/types/media.md deleted file mode 100644 index 8b13789..0000000 --- a/types/media.md +++ /dev/null @@ -1 +0,0 @@ - diff --git a/types/oauth b/types/oauth deleted file mode 100644 index 23ad0ab..0000000 --- a/types/oauth +++ /dev/null @@ -1 +0,0 @@ -types/oauth